Compare commits

..

52 Commits

Author SHA1 Message Date
Ben Stull 1d716d0cb8 v0.31.2: landing welcome panel spacing
Visual-only patch. The "/" welcome read-view was jammed against the
catalog divider with no top offset and loose paragraph rhythm: the
.main-pane §8 override (padding:0; display:flex) shadows the padded
read-view rule, so the pane gives no padding, and .welcome (max-width
only) never compensated. The welcome surface now owns its breathing
room — 56px top / 48px side gutters, a 680px measure, a text-3xl hero,
and even --space-8 paragraph spacing at --leading-relaxed. Covers both
the signed-out and signed-in welcome.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 22:54:29 -07:00
Ben Stull f96883506e v0.31.1: admin Users tab + header UX polish
Visual-only patch atop v0.31.0. CSS plus markup/structure in Admin.jsx;
no API, schema, config, overlay, or secret change — a plain frontend
rebuild applies it.

Two latent CSS defects fixed:
- .invite-badge had no rule, so "(pending invite)" rendered as bare
  parenthetical text; it's now a quiet amber pill.
- .btn-link-quiet never reset native <button> chrome, so the admin
  Revoke/Grant/Remove buttons, the modal close ×, and Login/BetaPending
  link-buttons kept the browser's grey button box. The reset the
  .otc-login scope already carried is folded into the base rule.

Users tab: table headers no longer wrap (WRITE-MUTED), timestamps
render as a date-over-time stack, the duplicated subline email is
de-duped, the Create-user-+-invite action moves flush-right beside the
title, and intro DB-column refs read as quiet chips.

Header: the Inbox (§15.2) trigger was styled for a light surface
(gray-200 border, gray-50 hover) and rendered as a pale box that went
white-on-white on hover; restyled to the nav-link vocabulary
(borderless, gray-300 icon → white on a faint translucent hover).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 22:32:47 -07:00
Ben Stull 0c972c8af5 v0.31.0: meta-only repository topology (ROADMAP #36)
Retire the per-RFC-repo model. RFCs now live in their meta-repo entry
(rfcs/<slug>.md) for their whole life; graduation is an in-place
super-draft → active state flip that keeps the body in the entry — no
repo creation, no body-strip, no five-step transaction, no rollback.

SPEC: §1 topology rewritten (one meta/content repository, no per-RFC
repos) with a deployer-facing "single content repository" framing; §2
(repo: always-null), §3 (active is in-place), §4, §9.8 (handoff
frictions dissolve), and §13 fully rewritten (two-field dialog, "The
flip", §13.6 RFC-0001 fold-back record).

Code: graduation collapses to open+merge one frontmatter PR; branch/PR/
chat dispatch re-keyed on meta-residency (repo IS NULL) so active RFCs
edit on the meta repo exactly as super-drafts do; the two "RFC has no
repo" 409 guards removed; promote-to-branch slug-embeds an active RFC's
auto-branch (edit-<slug>-<hex>) for shared-repo cache attribution;
refresh_meta_branches + hygiene branch-resolution include meta-resident
actives; the §9.8 read-only guard + pre_graduation_history scoped to
legacy per-repo only; dead bot primitives + GraduateDialog repo field +
blocking-PR popover removed. The repo: frontmatter field and the
/blocking-prs endpoint are retained (schema stability / informational).

Tests: graduation suite rewritten to the flip model; e2e + hygiene
updated. Full backend suite 375 passed; frontend builds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 07:12:06 -07:00
Ben Stull d581010063 v0.30.2: header nav label "Philosophy" -> "About"
Revert the §14.3 persistent chrome link's display text from "Philosophy"
back to "About" (relabeled in v0.21.0). Display text only — route
/philosophy, the header-about class, and the page are unchanged. Patch
per SPEC §20.2: cosmetic, no deployment action beyond a frontend rebuild.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 06:56:14 -07:00
Ben Stull 732b23b156 v0.30.1: fix phantom pending-idea after merge-with-branch-delete
refresh_meta_pulls / refresh_rfc_repo recover a PR's slug from its
Gitea head.ref, which collapses to the refs/pull/<N>/head sentinel
once a merged PR's branch is deleted. The slug then parsed to None,
the row was skipped, and cached_prs.state froze at 'open' — so the
entry showed as both a super-draft and a pending idea. Recover the
real branch name from the stored cached_prs row when Gitea reports an
empty or sentinel ref.

Surfaced via the ROADMAP #35 operator authoring lane (CLI merge with
--delete-branch); the web UX leaves branches in place so it never hit
this. Regression test added; full suite 375 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 05:26:00 -07:00
Ben Stull 1558cc3a8b Release v0.30.0: sync user guide (DOCS.md) with the shipped app
Documentation-only minor. The guide had drifted since it was first
written; brought back in sync with v0.7.0–v0.29.0:

- "Signing in" rewritten: email + one-time-code, optional passcode,
  trust-device 30d, optional Turnstile, and the beta-request → pending
  → admin-grant gate, plus admin-create + invite-claim. The vestigial
  email allowlist is no longer described as the gate.
- "Proposing a new RFC": four → five fields (optional use-case #26) +
  AI tag-suggestion disclosure (#27).
- "Roles & permissions": documents the pending state.
- New "Invitations, cross-references, and contribution requests"
  section (#12 owner invites; #28 auto-link / create-RFC / ask-to-
  contribute).
- New "Privacy and cookies" section (#11/#13).

No code/schema/API/config/overlay/secret change — DOCS.md is served
verbatim by /api/docs. VERSION + frontend/package.json bumped to 0.30.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 02:12:18 -07:00
Ben Stull 8a94e26f75 Merge pull request 'Release v0.29.0: #28 Parts 2+3 — create-RFC offers + contribute-to-pending requests' (#3) from feature/v0.29.0-pr-rfc-create-contribute into main 2026-05-29 03:11:02 +00:00
Ben Stull 3c9109c392 v0.29.0: #28 Parts 2+3 — create-RFC offers + contribute-to-pending requests
Extends the v0.26.0 (#28 Part 1) read-time scanner into three buckets in
one pass — active link (Part 1), pending-RFC contribute offer (Part 3),
create-RFC offer (Part 2) — precedence active > pending > candidate. The
backend still emits only structured segments (never HTML), so the surface
stays XSS-safe by construction.

Part 2 — create-RFC offers: a multi-word tag from the #27 taxonomy with no
defining RFC renders, for a create-rights viewer, as an inline "+ create
RFC" affordance that opens the propose modal pre-filled (?propose=<term>;
ProposeModal gained initialTitle). Conservative multi-word gate; broader
heuristics + the Haiku path are deferred.

Part 3 — contribute-to-pending offers: a term matching a super-draft
renders, for a signed-in non-owner, an "ask to contribute" affordance with
the owner's display name. It opens a 3-field request form (who/why/optional
use-case); submitting lands a contribution_requests row (migration 024) and
one actionable §15 notification per owner (new kind
contribution_request_on_pending_rfc, personal-direct). The owner's inbox
shows who/why/use-case inline with Accept/Decline. Accept fires #12's
owner-invite flow with the requester as invitee and echoes a notification
back; decline notifies the requester. Pre-merge idea PRs are out of scope.

New endpoints: GET /api/rfcs/{slug}/contribution-target,
POST /api/rfcs/{slug}/contribution-requests,
.../{id}/accept, .../{id}/decline. The invite issue path was refactored
into one reusable api_invitations.issue_invitation(...) chokepoint shared
by the manual invite endpoint and Part 3's accept.

Tests: 9 new (3 scanner-bucket unit + 6 e2e). Full suite 374 passing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 20:10:13 -07:00
Ben Stull 019c8a9185 Merge pull request 'Release v0.28.0: security-audit-0026 I3 + I4 (HTML-email guard + async Turnstile siteverify)' (#2) from feature/v0.28.0-email-turnstile-async into main 2026-05-29 02:42:05 +00:00
Ben Stull 79a447c77b Release v0.28.0: security-audit-0026 I3 + I4 (HTML-email guard + async Turnstile)
Two informational findings from the Session-0026 audit, both
framework-internal defense-in-depth. No operator action: no migration,
no schema/config/overlay change, no deployment-facing surface.

- I3: guard the dead text/html branch in email_envelope.build_envelope.
  No send path passes body_html; the unused branch would emit HTML built
  from possibly-unescaped user content (C1 stored-XSS class in the mail
  channel). Passing body_html now raises NotImplementedError; the arg is
  kept for documented future symmetry, enabling HTML mail becomes a
  deliberate escape-then-unguard change.

- I4: make turnstile.verify_token async. The sync httpx.post ran inside
  the async /auth/otc/request handler, blocking the event loop up to the
  10s timeout on a slow CloudFlare call. It now awaits httpx.AsyncClient
  via a narrow _siteverify_post seam (tests patch the seam, not the
  shared AsyncClient). The sole caller (main.py) now awaits it.

Tests: full backend suite 365 passed. Added a coroutine-contract unit
test for verify_token and flipped the email_envelope HTML test to assert
the guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 19:12:16 -07:00
Ben Stull fe044ed3db Merge pull request 'Release v0.27.0: security hardening (audit 0026)' (#1) from feature/v0.27.0-security-hardening into main 2026-05-29 01:28:42 +00:00
Ben Stull bd3ef269d4 Release v0.27.0: security hardening (audit 0026)
Remediates the rfc-app application + deploy-config findings from the
Session 0026 security audit. Cut as the "v0.25.0-security-hardening"
branch (from v0.24.0); reversioned to 0.27.0 on rebase onto main since
v0.26.0 (#28) shipped while this was in flight.

- C1 (Critical): single sanitizeHtml.js chokepoint (DOMPurify) for every
  marked→innerHTML / dangerouslySetInnerHTML sink (MarkdownPreview,
  ProposalView x2, Editor); rel=noopener hook on target=_blank links.
- H1: per-account OTC-verify lockout (migration 023, auto-applied) +
  per-IP throttle via new ratelimit.py; wired on otc verify/request +
  passcode check/verify.
- M1: device_trust.lookup() single indexed-row read — cookie value is now
  "<row_id>.<raw_token>"; bcrypt-checks one row, not a global table scan.
  (Behavior change: existing device-trust cookies re-prompt once.)
- M2: HTTP security headers (CSP/HSTS/XFO/XCTO/Referrer-Policy) at nginx.
- M4: session cookie Secure-by-default (SESSION_COOKIE_SECURE opt-out).
- M5: bounce webhook fails CLOSED (503) when secret unset, instead of open;
  RFC_APP_INSECURE_BOUNCE_WEBHOOK=1 dev opt-in.
- L2/L3: per-IP cooldown + check-endpoint throttle.
- L4: systemd sandbox knobs. L8/I1: nginx server_tokens off + TLS1.0/1.1 out.

VERSION + frontend/package.json → 0.27.0; CHANGELOG documents the upgrade
steps (incl. the out-of-band nginx + systemd apply, which the flotilla
deploy gesture does not perform).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 18:28:10 -07:00
Ben Stull 698821f065 Merge feature/v0.26.0-pr-rfc-links (#28 Part 1: auto-link accepted RFCs in PR text + comments) 2026-05-28 16:21:26 -07:00
Ben Stull e794523079 v0.26.0: auto-link referenced RFCs in PR text + comments (roadmap #28 Part 1)
Part 1 of item #28: references to existing accepted RFCs inside PR
descriptions and comment text now render as inline links to the
referenced RFC. Parts 2 (offer-to-create) and 3 (offer-to-contribute-
to-pending) are deliberately deferred — Part 1 ships first as the easy
win, per the roadmap row.

Shipped in parallel with the v0.25.0 security-hardening session; this
took the next free version slot (0.26.0) per the roadmap's
"claims the next available version number" rule. Expect a top-of-file
CHANGELOG/VERSION merge with 0.25.0 — distinct concerns, trivial to
resolve.

Backend:
- rfc_links.py (new): builds a term index from the live accepted
  (state='active') RFC corpus and segments plain text into text /
  rfc-link segments. Conservative matching — links only rfc_id tokens
  (RFC-0001), multi-word titles (Open Human Model), and hyphenated
  slugs (open-human-model); a single common-word title/slug is NOT
  linked (would turn every prose "human" into a link). Case-insensitive,
  word-boundary-anchored, longest-match-wins, self-reference suppressed.
- api_prs.py get_pr(): enriches the PR description (description_segments)
  and every PR comment (text_segments).
- api_discussion.py: enriches PR-less discussion comments (text_segments).

Read-time, not submit-time: the roadmap says "at submit time" but the
intent it names is "not as live compose preview", which read-time
honors. Chosen for correctness (links track the live active set —
newly-accepted RFCs start linking, withdrawn ones stop), zero migration,
and cheapness (small cache-resident corpus). Recorded as a §19.3-rule-2
note in the session transcript.

Frontend:
- LinkedText.jsx (new): maps backend segments onto React text nodes +
  anchors. No dangerouslySetInnerHTML — XSS-safe by construction,
  independent of any HTML-sanitization layer. Falls back to raw text
  when segments are absent.
- PRView.jsx: description + PR conversation comment bodies render via
  LinkedText.
- RFCDiscussionPanel.jsx: discussion comment bodies render via LinkedText.
- App.css: .rfc-autolink (subtle accent + dotted underline, tokenized).

Tests: 12 new (test_rfc_links_vertical.py) — 9 scanner units + 3
end-to-end (PR description / review comment / discussion comment all
surface *_segments; self-reference suppression). Full suite 363 green;
frontend builds clean.

No upgrade steps: additive, no migration, no secret, no config.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 16:17:01 -07:00
Ben Stull 28015ed1a2 Release v0.24.0: Claude Haiku tag suggestions on propose-RFC (#27)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 13:28:12 -07:00
Ben Stull 376a6daddc Merge feature/v0.24.0-haiku-tags (#27: Claude Haiku tag suggestions on propose-RFC) 2026-05-28 13:25:50 -07:00
Ben Stull fb9b4fa422 v0.24.0: Claude Haiku tag suggestions on propose-RFC (roadmap #27)
The §9.1 Slice-2 AI-suggested tag chips, deferred since the propose
modal landed, now wired up. As the propose-RFC draft fills in, the
backend asks Claude Haiku for tags drawn ONLY from the corpus's
existing tag set (the de-facto taxonomy — v1 tags are free-form chip
input, there is no curated list), surfaced as clickable suggestion
chips. Nothing auto-applies; the user clicks to add.

Backend:
- tag_suggest.py: universe gather (distinct corpus tags, most-common
  first), Haiku prompt + tolerant reply parser (drops invented tags,
  dedupes, clamps confidence), in-process per-user rate limit.
- providers.construct_haiku(): dedicated Haiku provider from the
  operator key, independent of ENABLED_MODELS — tag suggestion always
  uses the cheap+fast model. No RFC slug at propose time, so the §6.7
  funder path does not apply.
- POST /api/rfcs/suggest-tags: contributor-gated, rate-limited.
  Degrades to an empty list (never an error) when no Anthropic key is
  bound, the corpus has no tags, or the draft is empty.

Frontend:
- ProposeModal: debounced suggestion fetch (700ms, stale-response
  guarded), clickable suggestion chips, and the required inline
  disclosure that the draft text is sent to Anthropic.
- api.suggestTags(): forgiving — any non-OK resolves to [].

Tests: 11 new (vertical contributor-gating / filtering / no-key /
empty-corpus / 429, plus units for gather, parser tolerance, max,
short-circuit, provider-failure). Full suite 351 green.

New secret on deploy: ANTHROPIC_API_KEY (see CHANGELOG Upgrade steps).
Disclosure copy wants a counsel pass before deploy, per #22 discipline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 13:25:39 -07:00
Ben Stull daebb54f47 Release v0.23.0: server-side sign-in state resume (#29)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 12:37:21 -07:00
Ben Stull a598221812 Merge feature/v0.23.0-signin-resume (#29: server-side sign-in state resume) 2026-05-28 12:35:46 -07:00
Ben Stull bada72f87e v0.23.0: server-side sign-in state resume (roadmap #29)
Track each authenticated user's last-viewed route + light view state
server-side, and on next sign-in redirect them to that state, falling
back to the empty-state home only when there's no recorded state.

Backend:
- migration 022_user_session_state.sql: one row per user
  (user_id PK/FK, last_route, last_route_state JSON-as-TEXT,
  resume_enabled default 1, last_updated_at).
- PUT /api/me/last-state (require_user): upserts route + light state;
  no-ops when resume_enabled=0. Localized in the /me region.
- /api/auth/me payload now carries resume_enabled + last_route +
  decoded last_route_state (no extra round-trip).
- test_session_resume_vertical.py: auth-required, upsert/read-back,
  per-user isolation, resume_enabled=0 disable.

Frontend:
- lib/useLastState.js: debounced (~1s) route-change PUT for
  authenticated users; one-time resume redirect on sign-in, gated on
  identify having fired (preserves #21 Part C identify-then-track).
- api.js: putLastState() client call.
- App.jsx: import + call the hook; set identifyReady after identify.
  Header region untouched.

Per-user (not per-device); profile-settings opt-out toggle UI
deferred (column + default-on behavior ship now). Stored state is
route + light view state ONLY, never draft buffers — documented in
SPEC §6.8.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 12:34:19 -07:00
Ben Stull 7d8371dea1 Release v0.22.0: optional propose use-case field (#26)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 12:32:34 -07:00
Ben Stull 3a51425ec7 Merge feature/v0.22.0-usecase-field (#26: optional propose use-case field) 2026-05-28 12:30:36 -07:00
Ben Stull 7c6c906db2 #26: optional proposed-use-case field on propose-RFC + propose-PR
Adds an optional "What will you be using this for?" capture as a sibling
to the required justification on both propose surfaces, per roadmap #26.

- propose-RFC modal (ProposeModal): optional textarea below the required
  "Why is this RFC needed?" pitch, labeled "What will you be using this
  RFC for? (optional)".
- propose-PR modal (PRModal): optional textarea below the required
  description, labeled "What will you be using this change for?".
- Backend: ProposeBody / OpenPRBody gain an optional `proposed_use_case`
  (NULL/omitted accepted, no min, 8000-char cap matching the existing
  free-text bound). Persisted to a new canonical side table
  `proposed_use_cases` keyed by PR number, mirrored onto the cache
  columns added by migration 021. Returned on the proposal list/detail,
  RFC detail (by slug), and PR detail endpoints.
- Display: ProposalView, RFCView (main only), and PRView render the
  captured use case with a muted "left blank" treatment when NULL.
- migration 021: nullable `proposed_use_case` on cached_rfcs/cached_prs
  plus the reconcile-proof `proposed_use_cases` truth table.
- New vertical test_proposed_use_case_vertical: persists+returns when
  supplied, accepted as NULL/omitted, for both surfaces.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 12:28:52 -07:00
Ben Stull 2ac20b1621 Merge feature/v0.21.0-ux-polish (UX-polish wave: #31 + #24 + #25 + #32) 2026-05-28 11:52:05 -07:00
Ben Stull 493d6b6eee Release v0.21.0: UX-polish wave (#31 foundation + #24 + #25 + #32)
Token foundation + App.css sweep, header Philosophy rename, inbox
inline-SVG icon + light UX pass, session/transcript page collapse +
metadata header. Pure frontend; 332 backend tests green; build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 11:36:55 -07:00
Ben Stull 959fc906de Merge feature/v0.21.0-docs-32 into v0.21.0 polish wave 2026-05-28 11:34:28 -07:00
Ben Stull b648b3ed45 Merge feature/v0.21.0-header-inbox into v0.21.0 polish wave 2026-05-28 11:34:28 -07:00
Ben Stull 54736de91c Merge feature/v0.21.0-appcss-sweep into v0.21.0 polish wave 2026-05-28 11:34:28 -07:00
Ben Stull adb5d25715 docs(#32): inline-collapse session roots + transcript metadata header
Roadmap item #32 (session/transcript page polish) plus the /docs
surfaces' share of #31, for rfc-app v0.21.0.

- DocsSessionIndex: the session root (/docs/sessions/:nnnn) no longer
  renders a dead-end "N transcript(s) — select from the nav"
  placeholder. It now renders a transcript INLINE: the lone transcript
  for single-transcript sessions, or the `.0` driver transcript (falling
  back to first-by-sort) for multi-transcript sessions, with the
  remaining siblings listed/linked above the body. URL stays stable to
  the session number — inline render, no 301.

- DocsSessionTranscript: adds a compact metadata header above the body
  (title; started/ended parsed from the filename's ISO segments,
  human-readable; derived duration; optional TL;DR from the manifest's
  `tldr` string field, graceful-degrade when absent; external
  "View source on git.wiggleverse.org" link). The parse/header helpers
  are exported so the inline-collapse view reuses identical rendering.

- Docs.css (new): token-based styling for the new metadata-header +
  sibling-list elements only; existing docs classes stay owned by
  App.css to avoid racing the #31 sweep.

- DocsUserGuide: loading/error states brought onto the shared
  .docs-empty/.docs-error convention with a retry button.

No backend change: /api/docs/sessions/manifest already passes the full
sessions.json entry through, so a `tldr` field on an entry reaches the
frontend with no docs_sessions.py change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 11:33:03 -07:00
Ben Stull cbf02d5507 v0.21.0: sweep App.css + index.css to design tokens (#31)
Replace hardcoded colors, font-sizes, and radii in App.css/index.css
with the tokens.css design-token system. Consolidate ~80 distinct
hex values onto the neutral ramp + semantic/status families, map
font-size literals to the --text-* scale and border-radius literals
to the --radius-* scale, route the on-dark translucent-white pattern
and header band through their semantic tokens, and point the base
rules at --color-bg/--color-text/--font-sans.

Add an appended interaction-polish layer: a coherent transition
vocabulary (var(--motion-base) var(--ease-out)) on surfaces that
already react to hover, plus one consistent :focus-visible ring using
var(--color-focus-ring). No existing selector renamed or removed;
only property values changed and additive rules appended.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 11:32:17 -07:00
Ben Stull 317738ed79 v0.21.0 header+inbox UX (#24,#25,#31): rename About→Philosophy, inline-SVG inbox icon, light inbox pass
- #24: header link "About" → "Philosophy" (route/title unchanged).
- #25 icon: replace 📮 emoji with a dependency-free inline-SVG envelope
  in .inbox-trigger; aria-label/title reframed to "Inbox". Badge intact.
- #25 inbox UX (light, no redesign): sharper unread/read distinction
  (accent dot + left bar + tint via tokens), per-row "mark as read"
  affordance that marks-without-navigating, clearer "Mark all read"
  label, and a real empty/caught-up state. New Inbox.css is tokenized
  and written one notch more specific than App.css where it overrides
  (Inbox.css injects before App.css under ESM eval order). Data flow,
  API calls, routing-on-click, and badge behavior preserved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 11:31:34 -07:00
Ben Stull 5be2c48afe v0.21.0 foundation: design-token module (#31)
Establish src/styles/tokens.css — the single source of truth for color,
type, spacing, radius, elevation, and motion. Imported first in main.jsx.
Sweep subagents map literal values to these tokens; no appearance change
is intended beyond consolidating near-duplicate grays (operator reviews
before deploy).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 11:26:56 -07:00
Ben Stull cbc9949972 Merge feature/v0.20.0-docs-specs-nav-hierarchy 2026-05-28 10:48:38 -07:00
Ben Stull e0d9ed7c5a Release v0.20.0: /docs/specs surface + nested flyout nav + session body-list removal
Wave 9 follow-up to roadmap item #30 (Session 0017.0 shipped #30 as v0.19.0;
v0.20.0 lands the operator-feedback follow-ups on top).

1. Specs on /docs/specs/<name> (backend docs_specs.py + frontend DocsSpec.jsx
   + DocsSpecsIndex.jsx). Configured via OHM_DOCS_SPECS; framework default
   carries OHM's two specs (rfc-app/SPEC.md + flotilla SPEC.md). Runtime
   fetch from gitea raw with 5-min TTL cache, mirroring docs_sessions.py.

2. Nested flyout nav hierarchy (DocsLayout.jsx). Sessions render as a tree
   with transcripts nested under each session row (labeled by .N ordinal).
   New Specs section between User Guide and Sessions.

3. /docs/sessions/<NNNN> body-list removed (DocsSessionIndex.jsx). Body
   becomes a session-overview card; navigation lives in the left nav.

19 new pytest cases for docs_specs (332 backend total green). Frontend
build clean. Sync frontend/package-lock.json version drift (0.15.0 → 0.20.0)
alongside the VERSION + package.json bump.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 10:48:31 -07:00
Ben Stull 69a166a6f2 Merge feature/v0.19.0-docs-sessions-browser 2026-05-28 09:14:56 -07:00
Ben Stull bb5137f176 CHANGELOG: normalize 0.19.0 header (drop 'v' prefix to match surrounding style) 2026-05-28 09:11:58 -07:00
Ben Stull 477f496cbf Release v0.19.0: /docs nav + on-site sessions browser
VERSION + CHANGELOG bump for roadmap item #30. The frontend
package.json was bumped alongside the frontend slice; this commit
finalizes the canonical VERSION at 0.19.0 and prepends the v0.19.0
CHANGELOG entry with the operator upgrade-steps block
(OHM_SESSION_HISTORY_RAW_BASE + the two TTL knobs; all MAY) and the
note about graceful degradation when the session-history repo is
still flat at deploy time (subsession 0017.2 ships the restructure
in parallel).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 09:07:27 -07:00
Ben Stull 822f4266f6 v0.19.0 frontend: /docs/* route tree + flyout nav + sessions browser
Reorganizes the /docs surface from a single DOCS.md route into a hub
with a left-side flyout nav and three new sub-routes for the on-site
sessions browser (roadmap item #30):

  /docs                      → redirect to /docs/user-guide
  /docs/user-guide           → existing DOCS.md content
  /docs/sessions             → redirect to /docs/sessions/about
  /docs/sessions/about       → README.md of ohm-session-history
  /docs/sessions/<NNNN>      → per-session transcript index
  /docs/sessions/<NNNN>/<f>  → per-transcript view

The flyout is a persistent left sidebar on desktop and a slide-out
drawer on mobile (toggled by a ☰ button in the docs header). Its
session list is driven by the /api/docs/sessions/manifest fetch —
loading shows a skeleton; 502 shows an inline retry; empty manifest
shows only the "About" row.

Each sub-route owns its own empty-state / error handling:
  - 404 transcripts render "This transcript isn't published yet"
    with a link back to the parent session index, no JS crash.
  - 502 (gitea unreachable) renders a retry button.
  - Manifest 404 is mapped to {} server-side so the flyout renders
    cleanly with no error banner when no sessions are published yet.

Analytics (per SPEC §21):
  - new EVENTS.DOC_VIEWED ("Doc Viewed") fires on each sub-route
    mount with `section`: 'user-guide' | 'sessions/about' |
    'sessions/<NNNN>' | 'sessions/<NNNN>/<filename>'.
  - every interactive nav element carries aria-label +
    data-amp-track-name so autocapture rows are readable.

The existing v0.14.0 Docs.jsx component is dropped — its content
moved verbatim into DocsUserGuide.jsx; the new layout subsumes the
back-button + signed-out home affordances it used to carry. All
four new sub-routes reuse the existing MarkdownPreview renderer
(marked + mermaid lazy-load) so no second markdown library lands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 09:07:20 -07:00
Ben Stull 39e57706d9 v0.19.0 backend: /api/docs/sessions/* endpoints + TTL cache
Adds the four read endpoints the v0.19.0 /docs/sessions/* surface
mounts on top of:

- GET /api/docs/sessions/manifest      → sessions.json (title map)
- GET /api/docs/sessions/about         → README.md
- GET /api/docs/sessions/<NNNN>/index  → per-session transcript list
- GET /api/docs/sessions/<NNNN>/<file> → transcript body

The framework mediates the gitea fetch so the rendered surface
inherits the same chrome as the v0.14.0 /docs route and the browser
makes no cross-origin call. Reads are aggressively cached in-process
(60s for the manifest, 5min for content) so the framework doesn't
hammer git.wiggleverse.org under normal traffic. Negative results
(gitea 404) are also cached at the content TTL to absorb the
expected empty-state at deploy-time (the parallel
ohm-session-history repo restructure ships in driver subsession
0017.2). All four endpoints are anonymous-reachable, sibling to
/api/philosophy and /api/docs.

Path validation gates the network: only /^\d{4}$/ session dirs and
the full SESSION-NNNN.M-TRANSCRIPT-...md filename shape pass to the
upstream. Legacy flat-root names (e.g. SESSION-A-TRANSCRIPT.md) and
path-traversal attempts are rejected 400 before any fetch.

18 new tests cover the happy paths, 404 empty-states, 502 upstream
errors, path-validation rejections, and the cache-hit-within-TTL
contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 09:07:01 -07:00
Ben Stull ac3513a686 Merge: CONTRIBUTING.md + SPEC.md §21 analytics chapter (Session 0013.1)
Docs-only merge from feature/contributing-and-spec-analytics @ 213f686:
- CONTRIBUTING.md (407 lines, new) — how to contribute to rfc-app:
  branch naming, CHANGELOG strict-descending, RFC 2119 upgrade-steps,
  SPEC.md §19.2 candidate hygiene, test-coverage expectations,
  analytics instrumentation checklist (rides #21 Part B), operator-
  only gestures (including the 'never ask for secret bytes' rule).
  Cites Sessions E/I/K/L (= 0005.0/0009.0/0011.0/0012.0) as worked
  examples.
- SPEC.md §21 (469 lines, new) — Analytics instrumentation and
  identity. Ten subsections covering event taxonomy, required prop
  families, autocapture-friendly DOM patterns, replay masking,
  consent-gate contract, identity lifecycle (Part C), set vs
  setOnce taxonomy, cohort implications, overlay-binding rule for
  VITE_AMPLITUDE_API_KEY, §19.2 candidates from this chapter.

No code change, no version bump. Authored by subsession M.1
(= 0013.1) of Session M (= 0013.0); reviewed in Session 0014.0;
merged in Session 0014.0 per operator delegation.
2026-05-28 08:29:41 -07:00
Ben Stull 31913b1e53 Merge feature/v0.18.0-email-webhook-hygiene
v0.18.0 — email + webhook hygiene per the proposal at
~/git/ohm-infra/RFC-APP-EMAIL-HYGIENE-PROPOSAL.md. All five
slices ship; 295 tests passing.
2026-05-28 07:39:39 -07:00
Ben Stull 0562d53f86 Release 0.18.0: email + webhook hygiene (roadmap items #18 + #20)
VERSION + frontend/package.json -> 0.18.0. CHANGELOG entry with
the binding Upgrade-steps block per SPEC.md §20.4.

This release lands all five slices of the v0.18.0 proposal at
~/git/ohm-infra/RFC-APP-EMAIL-HYGIENE-PROPOSAL.md:

  Slice 1: build_envelope helper + unit tests.
  Slice 2: migrate send paths; add POST /api/email/unsubscribe
           for RFC 8058 one-click.
  Slice 3: mandatory GITEA_WEBHOOK_SECRET (+ dev-bypass) +
           unknown-repo logging.
  Slice 4: outbound_emails audit table + admin endpoint.
  Slice 5: bounce correlation via Message-ID.

Full suite: 295 passed (was 252 pre-release).

Upgrade-steps (RFC 2119) block in CHANGELOG covers:
  * MUST: GITEA_WEBHOOK_SECRET non-empty at startup.
  * MAY: RFC_APP_INSECURE_WEBHOOKS=1 for local dev only.
  * MAY: EMAIL_UNSUBSCRIBE_MAILTO to route opt-out courtesy mail
         to a different mailbox than EMAIL_FROM.
  * MUST: apply migration 020_outbound_emails.sql (auto-applied
          on next start; no operator action).
  * MUST: rebuild frontend + restart backend.
  * SHOULD: run mail-tester.com probe post-upgrade.
2026-05-28 07:39:33 -07:00
Ben Stull 4666c4abe7 v0.18.0 Slice 5: bounce correlation hook
The `POST /api/webhooks/email-bounce` body accepts a new optional
`message_id` field. When supplied, the handler looks up the
matching row in `outbound_emails` and stamps status='bounced' +
appends "bounce (<kind>)" to the error column. The response
gains a `correlated_id` field (nullable: null when no
message_id was provided or no row matched).

Hard-bounce -> `email_opt_out_all = 1` still fires regardless
(the v1 contract from api_notifications.py:486-498); Slice 5
just adds the per-message audit trail on top.

Providers that don't surface Message-ID get the legacy v1
behavior — match by email, flip the global opt-out, leave
correlated_id null. Providers that replay an old bounce with a
message_id the framework no longer has log an INFO line but
still 200 (the bounce path can't refuse just because a row was
pruned).

Test update: `test_bounce_webhook_refuses_unsigned_when_secret_configured`
in test_e2e_smoke.py was asserting on the exact response shape
{ok, matched}; v0.18.0 adds `correlated_id`. The test now asserts
on the new shape; documented in CHANGELOG.

4 new tests covering: bounce with message_id stamps the row;
unknown message_id is logged + does not crash; bounce without
message_id still flips opt-out (backward compat); bounced rows
surface in the admin endpoint with `?status=bounced`.

Full suite: 295 passed.
2026-05-28 07:36:02 -07:00
Ben Stull 281a844513 v0.18.0 Slice 4: outbound_emails audit table + admin endpoint
Per the v0.18.0 email + webhook hygiene proposal §3:

  * `backend/migrations/020_outbound_emails.sql` — new table
    capturing every send attempt: to_address, from_address,
    subject, kind, sent_at, status ('sent' | 'failed' | 'deferred'
    | 'bounced'), error, notification_id (FK), message_id (for
    Slice 5 bounce correlation).
  * `email.record_outbound()` — best-effort write helper every
    send path calls. Status='sent' on SMTP success, 'failed' on
    exception (with class + message in `error`), 'deferred' on
    the dev-fallback path where SMTP_HOST is unset (the send
    didn't happen but the row records the attempt).
  * `email._deliver` (watcher notifications + bundles), `digest.py`,
    `email_otc.py`, `email_invite.py` — every send path now records.
  * `GET /api/admin/outbound-emails` — admin-only listing,
    filterable by kind / status / to_address. Answers "did this
    person ever get their invite?" without grepping VM logs. No
    admin UI in v0.18.0; operator queries via curl + jq for now.

7 new integration tests covering: OTC / invite / notification all
write rows with matching Message-ID; admin endpoint lists,
filters by kind, filters by to_address (case-insensitive),
refuses non-admins.

Full suite: 291 passed.
2026-05-28 07:32:31 -07:00
Ben Stull d3daa97264 v0.18.0 Slice 3: webhook tightening — mandatory secret + dev-bypass
`GITEA_WEBHOOK_SECRET` is now mandatory at startup. The framework
refuses to load_config() when the env var is empty unless the
operator opts into the dev-bypass with `RFC_APP_INSECURE_WEBHOOKS=1`.
This is the v0.18.0 startup-loud-failure shape — the pre-v0.18.0
"silently accept unsigned POSTs when the secret is empty" path is
the bug the proposal targets.

`webhooks.receive`:
  * Defense in depth: refuses 500 if the secret is empty at request
    time and the dev-bypass is not set (catches the case where
    something mutates env after startup).
  * Logs a loud warning every time a webhook lands under the
    bypass — so a misconfigured production deployment shows up in
    the logs even if the operator missed the warning at boot.
  * Adds an INFO log at the unknown-repo branch (previously
    silently 200-OK'd a hook on a fork or a stale Gitea binding).

`tmp_env` fixture binds a fake secret so the existing 277 tests
boot cleanly; the new test_webhooks_vertical.py exercises both
the production-secret path (valid signature, invalid signature,
missing signature) and the dev-bypass path (config loads with
empty secret when bypass set, refuses without it).

7 new tests; full suite: 284 passed.
2026-05-28 07:27:23 -07:00
Ben Stull e9fdc478f6 v0.18.0 Slice 2: migrate send paths to build_envelope + POST unsubscribe
Five send sites now construct their envelopes through
`email_envelope.build_envelope` instead of building `EmailMessage`
ad hoc:

  * `email_otc.py` — OTC mail (no List-Unsubscribe per the
    proposal's tradeoff: the recipient explicitly requested the
    code, so a list semantic would be wrong).
  * `email_invite.py` — admin/per-RFC invite mail (mailto:-only
    List-Unsubscribe — the invitee isn't a user yet, so no
    per-user opt-out URL exists).
  * `email._deliver` — watcher notifications (full one-click
    unsubscribe: mailto + signed URL + List-Unsubscribe-Post per
    RFC 8058, required by Gmail/Yahoo).
  * `email._send_bundle` — the "while you were away" bundle,
    one-click to the new `all` synthetic category (which lands
    `email_opt_out_all = 1` because the bundle spans multiple
    per-category flags).
  * `digest.py` — same as the bundle: bulk-adjacent, one-click to
    `all`.

`api_notifications.py` gains the POST `/api/email/unsubscribe`
endpoint (the matching receiver for `List-Unsubscribe-Post:
List-Unsubscribe=One-Click`) and accepts the `all` category in
both GET and POST handlers.

`EmailConfig` adds `unsubscribe_mailto` (env: `EMAIL_UNSUBSCRIBE_MAILTO`,
default = `EMAIL_FROM`) so deployments can route unsubscribe
courtesy mail to a humans-monitored mailbox distinct from the
no-reply sender.

The `_SENT` test buffer now also carries `envelope["message"]`
(the `EmailMessage` itself) so new tests can assert on headers
directly. Legacy `to`/`from`/`subject`/`body` keys remain for
backward compatibility.

10 new integration tests across test_otc_vertical /
test_admin_create_user_invite_vertical / test_notifications_vertical
covering: OTC has no List-Unsubscribe; invite has mailto: only;
notification has full one-click; POST one-click flips the
category; `all` category sets global opt-out via both GET and
POST.

Full suite: 277 passed.
2026-05-28 07:24:03 -07:00
Ben Stull 92059f319e v0.18.0 Slice 1: build_envelope helper + unit tests
Per the v0.18.0 email + webhook hygiene proposal
(~/git/ohm-infra/RFC-APP-EMAIL-HYGIENE-PROPOSAL.md §1), this is the
shared envelope builder every send path will call in Slice 2. No
call-site changes yet — Slice 2 migrates email_otc / email_invite /
email._deliver / email._send_bundle to use it.

The helper centralizes the deliverability-critical headers (Date,
Message-ID, Auto-Submitted) and exposes per-kind unsubscribe
semantics (none for OTC, mailto: for invites, full one-click for
watcher notifications) as explicit kwargs rather than buried in
each call site.

15 new unit tests covering: always-present headers, Auto-Submitted
toggle, the three List-Unsubscribe shapes, plain-only vs
multipart/alternative body. Full suite: 267 passed (was 252).
2026-05-28 07:15:06 -07:00
Ben Stull 213f6862d5 docs: CONTRIBUTING.md + SPEC.md §21 analytics chapter
Lands roadmap item #19 (CONTRIBUTING + transcript-linked onboarding)
and #21 Part B (standing analytics instrumentation discipline) as a
single docs commit. No code change, no version bump — these ride a
future release or land as a no-bump docs commit at the operator's
discretion.

CONTRIBUTING.md (new):
  - Project-evolution framing pointing at wiggleverse/ohm-session-history
    as the authoritative how-we-got-here record.
  - Worked-example links into Sessions E (clean small release),
    I (recovery from deploy fault), K (multi-feature wave with
    operator-secret pause), L (squash-merge across three parallel
    features + #21 Part C identity-lifecycle wiring).
  - PR-shape contract: subagents push feature branches; operator
    tags + deploys (the pattern driver sessions use).
  - CHANGELOG strict-descending convention.
  - RFC 2119 keyword discipline for Upgrade-steps blocks (per §20.4).
  - §19.2 candidate discipline — architectural deferrals get noted
    rather than scope-creeping a release.
  - Test-coverage expectations characterized from the actual
    ~250-test backend pytest surface (no frontend test runner today;
    the discipline is build-clean + backend HTTP contract coverage).
  - Operator-only gestures explicitly enumerated (no tagging, no
    deploying, no pin moves, no secret bytes in conversation).
  - Analytics instrumentation checklist (#21 Part B's CONTRIBUTING
    artifact): named events, autocapture-friendly DOM, replay
    masking, PR description discipline.

SPEC.md §21 (new chapter — Analytics instrumentation and identity):
  - Placed AFTER §20 versioning rather than between §15 and §16, to
    avoid renumbering §19.2 / §19.3 — those numbers are load-bearing
    project nouns referenced from CLAUDE.md, transcripts, and prior
    commits. §15 carries a new forward-pointer naming §21 as the
    peer chapter.
  - §21.1 event-taxonomy conventions (Title Case "Subject Verb",
    snake_case props, no PII).
  - §21.2 required prop families per event kind, including the
    hashed-target_email convention for invite-side events that
    target a not-yet-user (#12).
  - §21.3 autocapture-friendly DOM patterns (stable text,
    aria-label on icon-only buttons, data-amp-track-* on
    repeated rows, data-amp-track-suppress for noise surfaces).
  - §21.4 session-replay masking — credentials MUST be masked,
    PII SHOULD be masked, privacy policy MUST match reality.
  - §21.5 consent-gate contract — pre-consent no init / no
    network / no recording; granted→denied → setOptOut(true)
    within one tick.
  - §21.6 identity lifecycle (per #21 Part C) — identify with
    user_id + properties on sign-in; setUserProperties on
    mid-session change; anonymize on sign-out (after the
    User Signed Out track); identify-BEFORE-track on invite-claim
    paths. §21.6.1 set vs setOnce taxonomy with explicit classification
    rule.
  - §21.7 cohort-shape implications (informative — what the
    Amplitude dashboard can actually answer with the conventions).
  - §21.8 secret-vs-public framing for the overlay binding —
    Amplitude browser key is public (bundle-embedded), Turnstile
    secret half is real-secret; canonical pbpaste-pipe gesture
    for the operator.
  - §21.9 new §19.2 candidates surfaced (session-replay-specific
    consent category, bundle-size budget measurement, property-
    shape CI lint, centralized email-hash helper).
  - §21.10 open question (consent-category split timing).

Grounded in real code: the SPEC chapter cites
frontend/src/lib/analytics.js v0.15.0 + the v0.16.0 (AcceptInvitation)
+ v0.17.0 (InviteClaim) inline #21 Part C wiring as worked examples.
RFC 2119 keywords used precisely throughout.
2026-05-28 05:39:46 -07:00
Ben Stull 1456c8b73f Release 0.17.0: admin-create user + invite email (+ #21 Part C Amplitude wiring)
Wave 5. Roadmap item #16. Folds in #21 Part C Amplitude wiring inline.

From the v0.9.0 /admin/users surface, an admin can create a fresh
users row, assign a role (admin/owner/granted-beta-user), include
an optional custom message, and dispatch an invite email carrying
a single-use opaque claim token (256-bit CSPRNG, bcrypt-at-rest,
7-day TTL). The invitee clicks through, lands at /invites/claim,
optionally checks "trust this device for 30 days", and is signed
in without going through OTC (the token in the email is itself
proof of email control).

Backend: migration 019_user_invite_tokens.sql (auto-applied);
backend/app/invites.py (create + claim + list module);
backend/app/email_invite.py (sibling of email_otc); POST
/api/admin/users + GET /api/admin/users/invites in api_admin;
POST /api/invites/claim in main.py's oauth_router (shares
trust-device cookie helper with /auth/otc/verify). 15 new tests in
test_admin_create_user_invite_vertical.py. permission_events row
with event_kind='user_invited' for the audit trail.

Frontend: Admin.jsx Create-user-invite modal + (pending invite)
badge in UserRow; InviteClaim.jsx /invites/claim landing page.
App.jsx route registration. api.js helpers.

Design choices documented in the CHANGELOG: immediate-send (no
admin-review queue); no bulk-invite (deferred); OTC skipped on
first sign-in (token = proof of email control); no users table
changes (discriminator is active user_invite_tokens row, not a
new first_sign_in_at column); refusal cases (self-invite 422,
duplicate email 409, non-owner admin granting owner 422).

Amplitude wiring (inline, #21 Part C):
  - USER_INVITED from CreateUserInviteModal { target_user_id,
    initial_role, custom_message_chars }
  - INVITE_CLAIMED from InviteClaim { invited_by_admin_id,
    initial_role, needs_passcode, trust_device }
  - identify() BEFORE the claim event with properties:
    claim_method 'admin-invite', invited_at (setOnce),
    invited_by_admin_id (setOnce), initial_role (setOnce) —
    so the Amplitude user record is created with the OHM
    user_id from the very first event the invitee fires

Subagent ο shipped the feature on feature/v0.17.0-admin-create-user
(41b0c6a). Driver-side integration squash-merged into main,
hand-resolved 5 files (VERSION, package.json, CHANGELOG —
strict-descending to 0.17.0 → 0.16.0 → 0.15.0; App.jsx — both
new routes kept; api_admin.py — both per-user additive fields
+ pending-invite query both kept). Added inline Amplitude wiring
in CreateUserInviteModal + InviteClaim. 252 backend tests pass
(33 new across #12 + #16 surfaces). Frontend build verified green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 05:10:52 -07:00
Ben Stull ee4925b6ac 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>
2026-05-28 05:06:55 -07:00
Ben Stull 72f8457933 Release 0.15.0: Amplitude Analytics + Session Replay (with #21 Part C identity lifecycle)
Wave 5 / Track A. Roadmap item #13. Folds in #21 Part C user-identity-
lifecycle work inline rather than as a follow-up release (operator
ask: "implement Amplitude best practices from the very get-go").

Wraps @amplitude/unified with:
  - consent-gated lazy init (v0.13.0 cookie banner; SDK never loads
    without explicit analytics opt-in)
  - amplitude.initAll(KEY, { analytics: { autocapture: true },
    sessionReplay: { sampleRate: 1 } }) — vendor-recommended shape
  - identify({ user_id, properties? }) with set vs setOnce semantics
  - setUserProperties(properties) for mid-session state changes
  - anonymize() clears both user_id binding AND property cache

Wired into App.jsx (identify on me.user with role / permission_state /
passcode_set / device_trusted as set; first_sign_in_at / account_created_at
as setOnce; Page Viewed on route change; Sign-out fires User Signed
Out + anonymize), Login.jsx (User Signed In + Beta Access Requested),
and the per-feature surfaces (ProposeModal, PRModal, RFCView,
RFCDiscussionPanel, PRView, Admin).

Binding: VITE_AMPLITUDE_API_KEY via `flotilla overlay set`
(bundle-embedded public key like VITE_TURNSTILE_SITE_KEY — not
secret). Mid-Session-L correction: original dispatch brief specified
secret-set; vendor guidance + bundle visibility settled it as overlay.

PII discipline: no email, no display_name, no gitea_login, no free-text
fields ever sent. Properties are opaque ids + enums + timestamps +
booleans only.

Subagent ξ shipped the wrapper structure + nine-event taxonomy on
feature/v0.15.0-amplitude (0fd8c52 + 6cfbf69 post-correction).
Driver-side integration extended the wrapper with setUserProperties
+ identify properties for the #21 Part C identity-lifecycle work.
Frontend build verified green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 05:04:10 -07:00
Ben Stull b3f1b15f65 Release 0.12.0: CloudFlare Turnstile on OTC email-entry 2026-05-28 03:52:45 -07:00
108 changed files with 19521 additions and 2003 deletions
+1556
View File
File diff suppressed because it is too large Load Diff
+407
View File
@@ -0,0 +1,407 @@
# Contributing to rfc-app
`rfc-app` is the framework that hosts RFC-shaped collections of
documents — one repo per RFC, a meta repo per collection, a web app
that turns the Git substrate into a writeable surface. The Open
Human Model (OHM) deployment at `ohm.wiggleverse.org` is one
instance. The framework is intended to host more.
This document explains how to propose a change to the framework
itself — a new endpoint, a schema migration, a UI affordance, a
spec clarification. For changes to *content* hosted by a specific
deployment (the OHM RFCs, the OHM roadmap), see that deployment's
own contribution guide (e.g. [`ohm-rfc/CONTRIBUTING.md`](https://git.wiggleverse.org/wiggleverse/ohm-rfc/src/branch/main/CONTRIBUTING.md)).
---
## How the project actually evolves
rfc-app is built in the open in the literal sense: **every build
session produces a full transcript** at
[`wiggleverse/ohm-session-history`](https://git.wiggleverse.org/wiggleverse/ohm-session-history)
on `git.wiggleverse.org`. The transcripts are the authoritative
record of how the framework got from one release to the next — the
decisions, the friction, the dead ends, the reasoning. They are not
curated retrospectives; wrong turns stay in.
If you are proposing a change to rfc-app, **read at least the most
recent session transcript before opening a PR.** The transcripts
show what shape a feature lands in, where the spec gets touched,
what the operator pushes back on, and how the release rides into
deployment. A PR that matches that texture is much more likely to
land cleanly than one shaped by the README alone.
Worked examples to start with:
- **Session E** ([transcript](https://git.wiggleverse.org/wiggleverse/ohm-session-history)) —
a clean small release. Read this for the simplest possible release
shape: one feature, one version bump, one upgrade-steps block, no
surprises.
- **Session I** — recovery from a deploy fault. Read this for how
the project handles things going wrong mid-deploy, and for the
honest no-curation discipline.
- **Session K** — a multi-feature wave with one item paused on an
operator-provided secret. Read this for the subagent dispatch
pattern (the model the project uses to ship multiple features in
parallel), and for the binding rule that the assistant **never**
asks the operator to paste secret bytes into the conversation.
- **Session L** — squash-merge integration across three parallel
features (v0.15.0 / v0.16.0 / v0.17.0), with `#21 Part C`
identity-lifecycle Amplitude wiring folded inline across all
three releases. Read this for how cross-cutting concerns (analytics,
observability) get layered into already-in-flight features
without scope-creeping any single release.
The repository where transcripts live —
[`wiggleverse/ohm-session-history`](https://git.wiggleverse.org/wiggleverse/ohm-session-history) —
is the canonical history. The `git log` of `rfc-app` is the artifact;
the transcripts are the story behind it.
---
## How a contribution flows
The framework runs on a **subagents push feature branches; operator
tags and deploys** model. Contributors — whether human or AI agents
running in a Claude Code subsession — open feature branches and
submit PRs. The operator (the person running the deployment) is the
one who merges, tags, bumps `VERSION`, runs `flotilla deploy` (or
the equivalent for non-OHM deployments), and moves the deployment's
`.rfc-app-version` pin. The driver session transcripts inherit
this shape; contributors inherit it from them.
Concretely:
1. Read the most recent session transcript. Understand what just
shipped and what is in flight.
2. Open an Issue first if your change is exploratory, structural,
or might overlap with in-flight work. The operator will name
any collision.
3. Branch from `main`. Name the branch
`feature/<short-description>` for additive work, `fix/<short-
description>` for bug fixes, `docs/<short-description>` for
documentation-only work. The driver sessions use
`feature/v<target-version>-<slug>` (e.g.
`feature/v0.16.0-owner-invite`) — that shape is welcome but not
required for outside contributors, since contributors do not
pick the target version.
4. **Do not bump `VERSION` or `frontend/package.json#version` in
your PR.** The operator picks the target version at integration
time; bumping ahead causes cherry-pick conflicts. The same
applies to the `CHANGELOG.md` entry header — see below.
5. **Do not tag releases, do not run any deploy gesture, do not
touch any deployment's `.rfc-app-version` pin.** The operator
alone owns those gestures. (For OHM specifically: "I'm the only
one that gets to yolo." See the boundary section in
`ohm-rfc/CONTRIBUTING.md`.)
6. Push your branch and open a PR. Describe what you're proposing
and why, in language the operator can paste into the eventual
release commit. If the change touches `SPEC.md`, name which
section(s) and the contract change.
---
## CHANGELOG convention: strict descending
`CHANGELOG.md` is ordered **newest-on-top**. The header line for
the in-progress version goes at the top of the file; older
releases descend below it. This is the binding convention; the
operator hand-resolves the conflict when two parallel feature
branches both insert at the top of the file (the squash-merge
integration that ships parallel-feature waves keeps the strict-
descending shape — see Session K for the cherry-pick mechanics and
Session L for the hand-resolved-with-a-small-script variant).
A new entry has this shape (read the existing 0.15.0 / 0.16.0 /
0.17.0 entries for worked examples):
```markdown
## 0.X.Y — YYYY-MM-DD
**Minor — schema migration auto-applied; no operator action.** This
release ships <one or two sentences naming the feature and why>.
### Added
- **<New module/endpoint/component>** — what it does, where it lives,
why it exists. Include file paths inline so a reader can click through.
### Changed
- **<Existing surface>** — what changed and how a deployment notices.
### Migration
- **`<NNN_name>.sql`** — auto-applied by `db.run_migrations()` on
backend start. <Describe the schema delta in one sentence.>
### Upgrade steps (from 0.(X-1).Y)
- You **MUST** … (per RFC 2119; see SPEC.md §20.4).
- You **MUST NOT**
- You **SHOULD**
- You **MAY**
```
The header version number is filled in by the operator at merge
time. Your PR's CHANGELOG diff can leave the version as
`0.X.Y — YYYY-MM-DD` (literal placeholder), or use a guessed value
the operator overwrites; either is fine.
---
## `Upgrade steps:` blocks use RFC 2119 keywords
If your change requires deployments to do anything when they
upgrade — set an env var, apply a migration, restart a process,
flip an overlay value, accept a behavioral change — your CHANGELOG
entry **must** include an `### Upgrade steps` block, and that
block **must** use the [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119)
/ [RFC 8174](https://www.rfc-editor.org/rfc/rfc8174) keywords as
defined in `SPEC.md` §20.4:
- **MUST** / **SHALL** / **REQUIRED** — without this step the
deployment will not function correctly. Skipping is a regression
the framework does not handle.
- **MUST NOT** / **SHALL NOT** — previously valid, now no longer
supported.
- **SHOULD** / **RECOMMENDED** — the framework's tested path. A
deployment may deviate when it has a reason.
- **SHOULD NOT** / **NOT RECOMMENDED** — discouraged without being
forbidden.
- **MAY** / **OPTIONAL** — an affordance you can take or skip.
Cross-version upgrades (jumping more than one minor) are computed by
the operator composing each intervening release's steps in order.
Each adjacent step must therefore be locally unambiguous — this is
the whole reason the keyword discipline is binding. Avoid words
like "should probably" or "might want to" inside an upgrade step;
either the framework needs the action or it doesn't.
If your change touches the env contract, **also update**
`backend/.env.example` and/or `frontend/.env.example` in the same
PR so the contract and the documentation land together (§20.4).
---
## SPEC.md and §19.2 candidates
`SPEC.md` is the framework's binding spec. It is honest about open
questions — large sections of it carry "§19.2 candidates," which
are decisions the project has deliberately deferred rather than
guessed at.
The discipline: **architectural or process deferrals get noted as
§19.2 candidates rather than scope-creeping a release.** When you
notice that your change opens a question larger than the change
itself (a different DB shape, a new auth contract, a cross-cutting
UX rethink), the right move is usually to land the narrow change
and add a §19.2 candidate naming the larger question. The candidate
documents what was set aside and why, so a future session can pick
it up with context.
Worked examples from recent sessions:
- v0.11.0 (Session K) shipped device trust and surfaced three new
§19.2 candidates: cross-device session revocation, password-
equivalent change invalidating trust, device-trust window
tunables via env. None of those were in the v0.11.0 scope; they
were noted in SPEC.md §19.2 so a future session can address them
on their own terms.
- v0.15.0 (Session L) shipped the Amplitude wrapper and added
candidates around session-replay-specific consent category +
bundle-size measurement, both deferred to the future Part-A audit.
When you spot a deferred decision in your PR's territory, name it
in your PR description and add it to `SPEC.md` §19.2 in the same
diff. Do not silently expand scope to settle it.
---
## Test-coverage expectations
The backend has the load-bearing test suite at
`backend/tests/`. Tests are organized as `*_vertical.py` files,
each covering one feature end-to-end through the FastAPI app
(provisioning fixtures, hitting the HTTP surface, asserting on the
database state). At time of writing, the suite is ~250 tests across
~25 files. Examples:
- `test_admin_create_user_invite_vertical.py` — v0.17.0's
admin-create user + invite + claim flow, 15 tests covering happy
path + every refusal shape + the audit-trail row.
- `test_rfc_invitations_vertical.py` — v0.16.0's per-RFC invite +
accept flow, 18 tests.
- `test_device_trust_vertical.py` — v0.11.0's 30-day device trust,
14 tests including cookie shape, hash-vs-raw-token discipline,
expired / revoked / forged / cross-user invariants.
Expected coverage for a new feature:
- **Backend feature** — one new `test_<feature>_vertical.py` file
that covers the happy path, every documented refusal/error code,
and any cross-surface effect (rows the feature writes to existing
tables, fields it adds to existing endpoints). Reuse fixtures
from neighboring test files (e.g. `test_propose_vertical.py`'s
`FakeGitea` is widely reused).
- **Migration** — verify migrations are reachable from `backend/.venv`
before pushing: `cd backend && PYTHONPATH=. .venv/bin/pytest -q`
exercises `db.run_migrations()` through the fixture setup.
- **Frontend feature** — there is currently no frontend test
runner. The discipline is: keep the change ships-clean
(`cd frontend && npm run build` succeeds), and the backend
vertical test exercises the HTTP contract the frontend
consumes, which is the meaningful behavioral guarantee.
Frontend changes that ride along with a backend feature land
with the backend test as the regression boundary.
- **Bug fix** — add a regression test in the same vertical file
that proves the original failure mode and verifies the fix.
Run the backend suite before pushing. From `backend/`:
```bash
PYTHONPATH=. .venv/bin/pytest -q
```
(The `PYTHONPATH=.` is a known ergonomic gap — see SPEC.md §19.2
candidate; the suite does not pick up `app/` without it.)
If your PR doesn't include tests, the operator will ask for them
before merge unless the change is genuinely test-irrelevant
(documentation, comments, dev-only tooling).
---
## Analytics instrumentation checklist
> *(This section codifies `ohm-rfc/ROADMAP.md` #21 Part B's
> CONTRIBUTING checklist. It is discipline, not a gate — but the
> operator will push back on PRs that skip it.)*
If your PR adds or changes a user-facing feature, walk this
checklist before opening the PR. The instrumentation conventions
themselves are specified in `SPEC.md` §21 (Analytics instrumentation
and identity); this section is the procedural reminder.
1. **What named event(s) does this feature need?**
Open `frontend/src/lib/analytics.js` and look at the `EVENTS`
constant. Does an existing event cover your feature? If not, is
the new event in the spec's "Subject Verb" Title Case form
(`Comment Posted`, `Invitation Sent`)? Are the prop families
consistent with SPEC.md §21's required-prop catalog (opaque
ids only, no PII, enums lowercased like `'otc'` not `'OTC'`)?
2. **Do interactive elements have stable text / ARIA labels /
`data-amp-track-*` so autocapture is meaningful?**
The frontend ships `autocapture: true`, which instruments
every click and form interaction. The *value* of those events
depends on the DOM the SDK sees: a `<button>` with stable
visible text or an `aria-label` shows up as a meaningful
dashboard row; an icon-only `<button>` with no label shows up
as garbage. New components that introduce interactive elements
should either carry meaningful labels (visible text or ARIA) or
carry a `data-amp-track-name="<Stable Name>"` attribute. For
repeated rows (per-RFC lists, comment lists), use a stable
`data-amp-track-*` identifier so per-row click counts aggregate
to the row's identity rather than to a generic label.
3. **Does any new form field need replay masking?**
Session replay records at `sampleRate: 1` (100% of consented
sessions). New form inputs that capture passwords, OTC codes,
tokens, magic-link URLs, or other secret/credential-equivalent
material **MUST** be masked with Amplitude's masking conventions
(the `.amp-mask` class or the `data-amp-mask` attribute,
whichever the wrapper integration expects in this version).
New inputs that capture arguably-PII (email, real name, free-
text drafts) **SHOULD** also be masked; if a deliberate
un-masking decision is taken, document it in the PR description
and in `SPEC.md` §21.
4. **Does the PR description name the instrumentation decisions?**
A one-sentence summary in the PR description — "fires
`Comment Posted` with `{rfc_slug, comment_id}`; no new form
fields, no new replay-masking concerns" — is enough. If the
decision is "we chose not to instrument this," say that too;
the absence of an event is itself a decision the operator
wants visible. The relevant SPEC chapter (§21) is the binding
reference for what shapes are correct.
If your feature touches an identity-meaningful surface (sign-in,
sign-out, invite-claim, role change, account state change), also
walk the **identity lifecycle** contract in SPEC.md §21.6: every
new claim/sign-in path **MUST** call `identify({ user_id, properties })`
BEFORE the first `track()` event on that surface, so the Amplitude
user record is created with the OHM user_id from the very first
event rather than as an anonymous device that retroactively links.
v0.16.0's `AcceptInvitation.jsx` and v0.17.0's `InviteClaim.jsx`
are the worked examples; mirror their shape.
---
## The operator-only gestures
Some gestures are operator-only. Contributors do not perform them;
PRs that perform them get rejected on principle, not on merit:
- **Tagging a release** (`git tag v0.X.Y` + `git push --tags`).
- **Pushing to `main`** after merge (the operator merges; the
framework's `main` branch tracks releases the operator has
shipped).
- **Bumping `VERSION` and `frontend/package.json#version` to the
shipped value.** The operator does this at integration time so
the version line is consistent across the release commit.
- **Running `flotilla deploy` or any equivalent deployment gesture**
in any deployment of rfc-app. Contributors do not deploy.
- **Moving a deployment's `.rfc-app-version` pin.** That pin lives
in the deployment's content repo (e.g. `ohm-rfc/.rfc-app-version`)
and is moved by the deployment's operator. Contributors to that
deployment do not move it; contributors to the framework
certainly do not.
- **Setting secrets** (anywhere — Secret Manager, env files,
`flotilla secret set`, vendor dashboards, anything). The
binding rule baked in mid-Session-K is: **the assistant never
asks the operator to paste secret bytes into a conversation,
even as one offered option**. The corollary for contributors:
do not include secret values in PR descriptions, commit
messages, or issue comments. Reference secrets by their binding
name (`SMTP_PASSWORD`, `AMPLITUDE_API_KEY`) and let the
operator handle the bytes.
If your change requires a new secret or env var, document the
requirement in the CHANGELOG `### Upgrade steps` block in the
RFC 2119 form ("operators **MUST** set `<NEW_VAR>` ...") and
update the `*.env.example` file. The operator will run the
secret/overlay-set gesture themselves at deploy time.
---
## When in doubt
- **Open an Issue first.** Especially for any change that touches
SPEC.md, the auth/permissions model (§6), the storage shape (§4
/ §5), or the deploy contract (§20). The operator (or a future
driver session) will name what they want before you write code.
- **Read the most recent session transcript.** It will tell you
what shipped last and what's in flight.
- **Cite SPEC.md sections in your PR description.** "Touches §15.4
(per-category email toggles) and adds §19.2 candidate around
per-channel mute granularity" gives the operator a map of where
to read.
---
## License
The framework is released under the MIT License (see
[`LICENSE`](./LICENSE)). By contributing, you agree your work
ships under those terms.
---
## See also
- [`SPEC.md`](./SPEC.md) — the framework's binding spec. §19.2
is the deferred-decisions queue; §20 is the versioning + deploy
contract; §21 is the analytics instrumentation contract.
- [`CHANGELOG.md`](./CHANGELOG.md) — release history in strict
descending order. Read recent entries for the shape your PR's
release-commit will take.
- [`PHILOSOPHY.md`](./PHILOSOPHY.md) — what the framework is for.
PRs whose shape conflicts with the philosophy get a longer
conversation than PRs that fit.
- [`wiggleverse/ohm-session-history`](https://git.wiggleverse.org/wiggleverse/ohm-session-history)
— the authoritative record of how the project has actually
evolved, session by session.
+105 -12
View File
@@ -39,23 +39,44 @@ thread. Every write affordance is replaced with a sign-in prompt.
## Signing in
While the framework is in private beta, only invited email addresses
can complete sign-in. If your email is on the allowlist, the
"Sign in" button in the header completes the flow and lands you on
the catalog with full read and write access. If your email is not on
the allowlist, you'll be sent to a short "pending" page explaining
the gate.
Anyone can start the sign-in flow with their own email address — there
is no invite-only allowlist. Sign-in is passwordless:
Once you have an account, you're a **contributor** by default — the
role that grants every write affordance the app exposes, scoped by
the per-RFC and per-branch rules described below.
1. **Enter your email.** If the deployment has human verification
enabled (a Cloudflare Turnstile challenge), you complete it here.
2. **Enter the one-time code.** The app emails you a short numeric
code; entering it signs you in. Codes expire after a few minutes,
and repeated wrong entries briefly lock the email.
3. **Set a passcode (optional).** After your first code sign-in you
can set a passcode. On later visits you sign in with email +
passcode, with the one-time code as the forgot-passcode fallback.
4. **Trust this device (optional).** You can mark a device trusted for
30 days to skip the code/passcode step on it. Trusted devices are
listed in your settings and can be revoked individually or all at
once.
### Getting write access
Signing in gives you an account, but write access is gated. The first
time you sign in you're asked for your first name, last name, and a
short note on why you'd like access; you then land on a "request in
review" page. While your account is **pending**, you can read
everything an anonymous visitor can but cannot write — no chat,
propose, branch, PR, or discussion post. Once an admin **grants** your
account you become a **contributor**, the role that carries every
write affordance the app exposes, scoped by the per-RFC and per-branch
rules described below.
An admin can also create your account ahead of time and email you an
invite link. Clicking it claims the account and signs you in with the
role the admin assigned, skipping the one-time-code step.
---
## Proposing a new RFC
A new RFC begins as a proposal. The "+ Propose new RFC" button at
the bottom of the catalog opens a small modal that collects four
the bottom of the catalog opens a small modal that collects five
things:
- **Title.** The word, concept, or topic this RFC would define.
@@ -65,8 +86,13 @@ things:
inline.
- **Pitch.** One or two paragraphs answering *why this RFC is
needed*. This becomes the body of the entry.
- **Tags.** Optional. The AI suggests tags from the pitch; you can
accept, dismiss, or type your own.
- **Use case.** Optional. *What will you be using this RFC for?*
the concrete application driving the proposal, as distinct from the
abstract case for it. Leaving it blank is fine.
- **Tags.** Optional. If the deployment has AI tag suggestion
enabled, suggested tags appear as you fill the form (with an inline
note that the text you've entered is sent to the model that
generates them); you can accept, dismiss, or type your own.
Submitting the modal does one concrete thing: it opens a pull
request against the framework's meta repository, adding one new
@@ -167,6 +193,51 @@ either requires a contributor account.
---
## Invitations, cross-references, and contribution requests
Three connected surfaces help the right people find and join the
right RFC.
### Owner invitations
An RFC's owner (or an app-wide admin or owner) can invite a specific
person to that RFC from the "Invitations" control in the RFC header.
The invite names an email and a role for *this RFC*:
- **contributor** — can open PRs and join the discussion;
- **discussant** — can join the discussion only.
The invitee gets an email with an accept link; accepting adds them as
a collaborator on that RFC. The invitations panel lists every invite
with its status (pending / accepted / expired / revoked); pending
invites can be revoked. An invitation is per-RFC — it does not change
the invitee's app-wide role, and it cannot lift the pending gate: the
invitee still needs a granted account to write.
### RFC cross-links in PRs and comments
When a PR description or a comment mentions an existing active RFC —
by its ID, its multi-word title, or its slug — the framework renders
that mention as a link to the RFC. The matching is conservative by
design (single common words are never auto-linked), and the links are
computed at read time, so nothing is rewritten in what you typed.
### "Create" and "ask to contribute" offers
The same scan surfaces two affordances inline:
- If a term looks like it should have an RFC but none exists yet, a
reader who has create rights sees a **"create RFC for '<term>'"**
link that opens the propose modal with the title pre-filled.
- If a term matches a *pending* RFC (a super-draft someone already
owns), a signed-in reader sees an **"ask to contribute"** offer
naming the owner. It opens a short request form — who you are, why
you're asking, and optionally what you'd use the RFC for. The
request lands in the owner's inbox; the owner can **accept** (which
sends you an owner invitation) or **decline** (which notifies you).
---
## Working on a branch
Contribute mode flips one branch into edit-enabled. The centre
@@ -505,6 +576,14 @@ Each role is a strict superset of the one below it.
entirely. The framework names a single "owner zero" at
bootstrap.
Between anonymous and contributor sits one transient state:
**pending**. A freshly signed-in account that hasn't been granted
access yet (see [Signing in](#signing-in)) reads everything an
anonymous visitor can, but no write affordance unlocks until an admin
grants it. Granting promotes the account to contributor; an admin can
also revoke a granted account back to a no-write state. These
transitions are recorded in the `permission_events` log.
The practical difference between admin and owner is narrow but
load-bearing: admin is the operational tier — it does the day-to-
day moderation and stewardship work; owner is the tier that
@@ -594,6 +673,20 @@ review.
---
## Privacy and cookies
A consent banner appears on your first visit and lets you choose
which cookie categories to allow — essential always, with analytics
and other categories opt-in. The choice is remembered and can be
changed any time from the privacy/cookies controls in settings.
Analytics only load if you opt in: the framework defers the analytics
SDK behind your consent, so declining means it is never initialized.
The `/privacy` and `/cookies` pages describe what's collected and
why; a deployment can point those pages at its own fuller policy.
---
## Where to learn more
- The framework's *why* lives in [the philosophy
+757 -200
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1 +1 @@
0.11.0
0.31.2
+30
View File
@@ -38,10 +38,22 @@ GITEA_WEBHOOK_SECRET=change-me-to-a-shared-secret
# Comma-separated list of provider keys to enable. Per the §19.2
# per-RFC-model topic, this is app-wide until that topic lands.
ENABLED_MODELS=claude
# ANTHROPIC_API_KEY also powers the §9.1 propose-RFC tag suggestions
# (roadmap #27) — that surface always uses Claude Haiku for cost,
# independent of ENABLED_MODELS. With no key set, tag suggestions are
# simply unavailable (the modal hides the row); the rest of the app is
# unaffected.
ANTHROPIC_API_KEY=
GOOGLE_API_KEY=
OPENAI_API_KEY=
# --- Tag suggestions (§9.1 / roadmap #27) ---
# Per-user rate limit on the suggest-tags endpoint (cost backstop; the
# modal debounces and the endpoint is contributor-gated). Optional —
# these defaults apply when unset.
TAG_SUGGEST_RATE_MAX=30
TAG_SUGGEST_RATE_WINDOW_SECONDS=60
# --- Email (§15.4) ---
# Leave SMTP_HOST unset to use the stdout fallback — the integration
# tests rely on it, and a dev environment without a real SMTP provider
@@ -92,3 +104,21 @@ OTC_TTL_MINUTES=10
# loud-failure shape so the abuse path is visible). Set to 0 to
# disable the cooldown — useful for tests but never in production.
OTC_REQUEST_COOLDOWN_SECONDS=60
# --- v0.12.0: CloudFlare Turnstile gate on OTC dispatch (§6.2, item #10) ---
# Provision a Turnstile site at dash.cloudflare.com → Turnstile → Add
# site. The site key (public) goes in `frontend/.env` as
# VITE_TURNSTILE_SITE_KEY. The secret key (private) goes here and is
# what the backend POSTs to /siteverify alongside the user's response
# token. Leave both unset for dev/test paths; the gate stays open when
# the secret is absent AND TURNSTILE_REQUIRED=false (the default).
CLOUDFLARE_TURNSTILE_SECRET=
# When `true`, /auth/otc/request fails closed (HTTP 500 "auth
# misconfigured") if CLOUDFLARE_TURNSTILE_SECRET is unset. When `false`
# (the default), a missing secret skips verification — useful in dev
# and during the pre-rollout window when the operator hasn't wired
# the secret yet. Flip to `true` once the secret is wired so a future
# config drift surfaces as a loud 500 rather than a silent abuse-
# defense disablement.
TURNSTILE_REQUIRED=false
+361 -1
View File
@@ -15,19 +15,24 @@ import json
from typing import Any
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import PlainTextResponse, Response
from pydantic import BaseModel, Field
from . import (
api_admin,
api_branches,
api_contributions,
api_discussion,
api_graduation,
api_invitations,
api_notifications,
api_prs,
auth,
db,
device_trust as device_trust_mod,
docs as docs_mod,
docs_sessions,
docs_specs,
entry as entry_mod,
cache,
funder,
@@ -35,6 +40,7 @@ from . import (
notify,
philosophy,
providers as providers_mod,
tag_suggest,
)
from .bot import Bot
from .config import Config
@@ -47,6 +53,22 @@ class ProposeBody(BaseModel):
slug: str = Field(min_length=1, max_length=80)
pitch: str = Field(min_length=1)
tags: list[str] = Field(default_factory=list)
# Roadmap #26: optional "What will you be using this RFC for?" — the
# concrete ground-truth use case, distinct from the `pitch`'s abstract
# "why is this needed." Optional (NULL/omitted accepted), no minimum,
# generous cap matching the pitch's free-text body bound.
proposed_use_case: str | None = Field(default=None, max_length=8000)
class SuggestTagsBody(BaseModel):
# Roadmap #27: the partial propose-RFC draft, sent as the user types
# (debounced on the frontend). All fields optional — suggestions
# refine as the draft fills in. `pitch` is the "why is this needed"
# rationale; `use_case` is the #26 optional ground-truth field.
# Bounds mirror the propose body's free-text caps.
title: str = Field(default="", max_length=200)
pitch: str = Field(default="", max_length=8000)
use_case: str = Field(default="", max_length=8000)
class DeclineBody(BaseModel):
@@ -58,6 +80,18 @@ class FunderCredentialBody(BaseModel):
api_key: str = Field(min_length=1, max_length=2048)
class LastStateBody(BaseModel):
# v0.23.0 / roadmap item #29: server-side sign-in state resume.
# `route` is a frontend pathname the user was last on (bounded so a
# hostile client can't stuff arbitrary blobs through). `state` is an
# optional bag of *light* view state (scroll anchors, open tab,
# filter chips). PRIVACY: it MUST NOT carry draft-buffer contents —
# the frontend only ever sends ephemeral view state, and the column
# comment in migration 022 + SPEC §6.2 are the binding contract.
route: str = Field(min_length=1, max_length=2048)
state: dict[str, Any] | None = None
class BetaRequestBody(BaseModel):
# v0.8.0 — captured on the first OTC sign-in. All three fields are
# required so the admin queue has a coherent triage shape.
@@ -102,6 +136,16 @@ 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())
# v0.29.0 (roadmap item #28 Part 3): offer-to-contribute-to-a-pending
# (super-draft) RFC. Reuses the #12 invite flow (api_invitations above)
# on accept; lands the request + owner notifications via §15 notify.
router.include_router(api_contributions.make_router())
# ---------------------------------------------------------------
# §17: /api/health — unauthenticated post-flight probe.
@@ -136,6 +180,177 @@ def make_router(
payload = docs_mod.load()
return {"body": payload["body"]}
# ---------------------------------------------------------------
# v0.19.0 / roadmap item #30 — /api/docs/sessions/*
#
# The framework mediates reads against the public
# `wiggleverse/ohm-session-history` gitea repo so the rendered
# `/docs/sessions/*` surface inherits the same chrome as the
# /docs/user-guide route and doesn't require a cross-origin
# gesture from the frontend. See backend/app/docs_sessions.py
# for the cache shape and env knobs.
#
# The route mapping for the three `status` values returned by
# the fetchers:
#
# "ok" → HTTP 200, payload as documented per endpoint
# "404" → HTTP 200/404 depending on the endpoint (the
# manifest's empty state is 200 + {} so the
# frontend can short-circuit without an error
# banner; transcripts/about return 404 so the
# frontend can render its own empty-state)
# "error" → HTTP 502, {"error": ..., "detail": ...} so the
# frontend retry surface reads as "couldn't reach
# the session-history repo" rather than as a
# generic 5xx.
# ---------------------------------------------------------------
@router.get("/api/docs/sessions/manifest")
async def get_sessions_manifest() -> dict[str, Any]:
result = await docs_sessions.fetch_manifest()
if result["status"] == "ok":
return result["manifest"]
if result["status"] == "404":
# Empty-state contract: render no session rows in the
# flyout but don't show an error banner. The frontend
# treats `{}` as "no sessions published yet".
return {}
raise HTTPException(
status_code=502,
detail={
"error": "session-history fetch failed",
"detail": result.get("detail", "unknown"),
},
)
@router.get("/api/docs/sessions/about")
async def get_sessions_about() -> Response:
result = await docs_sessions.fetch_about()
if result["status"] == "ok":
return PlainTextResponse(
content=result["body"],
media_type="text/markdown; charset=utf-8",
)
if result["status"] == "404":
raise HTTPException(
status_code=404,
detail="session-history README not yet published",
)
raise HTTPException(
status_code=502,
detail={
"error": "session-history fetch failed",
"detail": result.get("detail", "unknown"),
},
)
@router.get("/api/docs/sessions/{nnnn}/index")
async def get_sessions_index(nnnn: str) -> dict[str, Any]:
if not docs_sessions._is_valid_session_dir(nnnn):
# 400 over 404: the request itself is malformed (the
# session directory name doesn't match `^\d{4}$`),
# distinct from "no such session published yet".
raise HTTPException(status_code=400, detail="invalid session directory")
result = await docs_sessions.fetch_session_index(nnnn)
if result["status"] == "ok":
return {"files": result["files"]}
if result["status"] == "404":
raise HTTPException(
status_code=404,
detail="no transcripts published for this session",
)
raise HTTPException(
status_code=502,
detail={
"error": "session-history fetch failed",
"detail": result.get("detail", "unknown"),
},
)
@router.get("/api/docs/sessions/{nnnn}/{filename}")
async def get_sessions_transcript(nnnn: str, filename: str) -> Response:
# Path-shape validation before any network — refuses anything
# that would resolve outside the `NNNN/SESSION-...md` layout
# (e.g. legacy `SESSION-A-TRANSCRIPT.md` at the repo root,
# `../etc/passwd`, or any non-numeric session dir).
if not docs_sessions._is_valid_session_dir(nnnn):
raise HTTPException(status_code=400, detail="invalid session directory")
if not docs_sessions._is_valid_transcript_filename(filename):
raise HTTPException(status_code=400, detail="invalid transcript filename")
result = await docs_sessions.fetch_transcript(nnnn, filename)
if result["status"] == "ok":
return PlainTextResponse(
content=result["body"],
media_type="text/markdown; charset=utf-8",
)
if result["status"] == "404":
raise HTTPException(
status_code=404,
detail="transcript not found",
)
raise HTTPException(
status_code=502,
detail={
"error": "session-history fetch failed",
"detail": result.get("detail", "unknown"),
},
)
# ---------------------------------------------------------------
# v0.20.0 — /api/docs/specs/*
#
# Sibling of the v0.19.0 docs-sessions surface: the framework
# mediates a fetch against the public gitea raw URL for each
# configured spec so the rendered `/docs/specs/*` route inherits
# the same chrome (and the same auth-less reach) as the user
# guide and the session-history browser. See
# backend/app/docs_specs.py for the manifest shape, the env
# knobs, and the cache.
#
# Status-to-HTTP mapping mirrors docs_sessions:
# "ok" → HTTP 200, payload as documented per endpoint
# "404" → HTTP 200 / 404 (manifest 404 doesn't apply here —
# the manifest is derived from env, never 404s; spec
# 404 returns HTTP 404 so the frontend can render
# "spec not yet published / unknown name")
# "error" → HTTP 502
# ---------------------------------------------------------------
@router.get("/api/docs/specs/manifest")
async def get_specs_manifest() -> dict[str, Any]:
# The manifest is derived from env (`OHM_DOCS_SPECS`) and
# never fails — a malformed value falls back to the framework
# default at parse time. So this endpoint always returns 200
# + a list (the framework default is non-empty).
result = docs_specs.fetch_specs_manifest()
return {"specs": result["specs"]}
@router.get("/api/docs/specs/{name}")
async def get_spec(name: str) -> Response:
# Slug validation before any network — refuses `..`, `/`,
# uppercase, whitespace, etc. Same defense-in-depth posture
# as the docs-sessions transcript endpoint.
if not docs_specs._is_valid_name(name):
raise HTTPException(status_code=400, detail="invalid spec name")
result = await docs_specs.fetch_spec(name)
if result["status"] == "ok":
return PlainTextResponse(
content=result["body"],
media_type="text/markdown; charset=utf-8",
)
if result["status"] == "404":
raise HTTPException(
status_code=404,
detail="spec not found",
)
raise HTTPException(
status_code=502,
detail={
"error": "specs fetch failed",
"detail": result.get("detail", "unknown"),
},
)
# ---------------------------------------------------------------
# Auth surface — reads role from our users table per §6.
# ---------------------------------------------------------------
@@ -168,6 +383,28 @@ def make_router(
)
has_passcode = bool(row and row["passcode_hash"])
passcode_set_at = row["passcode_set_at"] if (row and has_passcode) else None
# v0.23.0 / item #29: fold the sign-in-resume state onto the
# same round-trip the frontend already makes on boot. When
# resume is disabled (resume_enabled = 0) we hand back a null
# route so the client never redirects; the stored row stays put
# so re-enabling later resumes the last-known route.
state_row = db.conn().execute(
"SELECT last_route, last_route_state, resume_enabled "
"FROM user_session_state WHERE user_id = ?",
(user.user_id,),
).fetchone()
resume_enabled = bool(state_row["resume_enabled"]) if state_row else True
last_route = (
state_row["last_route"]
if (state_row and resume_enabled)
else None
)
last_route_state = None
if state_row and resume_enabled and state_row["last_route_state"]:
try:
last_route_state = json.loads(state_row["last_route_state"])
except (ValueError, TypeError):
last_route_state = None
return {
"authenticated": True,
"user": {
@@ -184,6 +421,10 @@ def make_router(
"needs_profile": needs_profile,
"has_passcode": has_passcode,
"passcode_set_at": passcode_set_at,
# v0.23.0 / item #29 — sign-in state resume.
"resume_enabled": resume_enabled,
"last_route": last_route,
"last_route_state": last_route_state,
},
}
@@ -253,6 +494,52 @@ def make_router(
notify.fan_out_new_beta_request(requester_user_id=user.user_id)
return {"ok": True}
# ---------------------------------------------------------------
# v0.23.0 (§6.2, roadmap item #29): server-side sign-in state
# resume. The frontend debounce-posts the user's current route +
# a small bag of light view state here on every route change; the
# next sign-in reads `last_route` off `/api/auth/me` and redirects.
#
# Per-user (NOT per-device) — one row per user, keyed on user_id.
# `resume_enabled` is the opt-out flag (default on); when it's 0
# this endpoint no-ops so a user who turned resume off doesn't keep
# silently rewriting their stored route. PRIVACY: the body carries
# route + light state ONLY, never draft-buffer contents (migration
# 022 column comment + SPEC §6.2 are the binding contract).
#
# `require_user` (not `require_contributor`) — a pending/granted
# distinction is irrelevant for "remember where I was", and a
# pending user navigating read-only surfaces should still resume.
# Anonymous callers get the 401 `require_user` raises.
# ---------------------------------------------------------------
@router.put("/api/me/last-state")
async def put_last_state(body: LastStateBody, request: Request) -> dict[str, Any]:
user = auth.require_user(request)
# Respect the opt-out: if a row already exists with resume
# disabled, leave it untouched and report the no-op. A first-
# ever POST (no row yet) defaults to enabled and stores.
existing = db.conn().execute(
"SELECT resume_enabled FROM user_session_state WHERE user_id = ?",
(user.user_id,),
).fetchone()
if existing is not None and not existing["resume_enabled"]:
return {"ok": True, "stored": False}
state_json = json.dumps(body.state) if body.state is not None else None
db.conn().execute(
"""
INSERT INTO user_session_state
(user_id, last_route, last_route_state, last_updated_at)
VALUES (?, ?, ?, datetime('now'))
ON CONFLICT(user_id) DO UPDATE SET
last_route = excluded.last_route,
last_route_state = excluded.last_route_state,
last_updated_at = excluded.last_updated_at
""",
(user.user_id, body.route, state_json),
)
return {"ok": True, "stored": True}
# ---------------------------------------------------------------
# v0.11.0: trust device for 30 days (§6.2, roadmap item #9).
#
@@ -376,12 +663,36 @@ def make_router(
).fetchone()
if row is None:
raise HTTPException(404, "Not found")
return _serialize_rfc(row)
payload = _serialize_rfc(row)
# Roadmap #26: surface the optional propose-time use case on the
# RFC view. The idea PR closes on merge, but the canonical row in
# `proposed_use_cases` persists; look it up by slug (the latest
# 'rfc'-scope row for this slug). NULL == "left blank".
uc = db.conn().execute(
"""
SELECT use_case FROM proposed_use_cases
WHERE scope = 'rfc' AND rfc_slug = ?
ORDER BY id DESC LIMIT 1
""",
(slug,),
).fetchone()
payload["proposed_use_case"] = uc["use_case"] if uc else None
return payload
# ---------------------------------------------------------------
# §7.3 / §9.3: pending ideas
# ---------------------------------------------------------------
def _proposal_use_case(pr_number: int) -> str | None:
"""Roadmap #26: read the optional use case for an idea PR from the
canonical side table. Returns None when none was supplied (the
"left blank" sentinel the frontend renders tastefully)."""
row = db.conn().execute(
"SELECT use_case FROM proposed_use_cases WHERE scope = 'rfc' AND pr_number = ?",
(pr_number,),
).fetchone()
return row["use_case"] if row else None
@router.get("/api/proposals")
async def list_proposals() -> dict[str, Any]:
rows = db.conn().execute(
@@ -401,6 +712,7 @@ def make_router(
"description": r["description"],
"opened_by": r["opened_by"],
"opened_at": r["opened_at"],
"proposed_use_case": _proposal_use_case(r["pr_number"]),
}
for r in rows
]
@@ -449,6 +761,7 @@ def make_router(
"opened_at": row["opened_at"],
"entry": entry_payload,
"affordances": affordances,
"proposed_use_case": _proposal_use_case(pr_number),
}
# ---------------------------------------------------------------
@@ -525,8 +838,55 @@ def make_router(
# cache write is idempotent.)
await cache.refresh_meta_pulls(config, gitea)
# Roadmap #26: persist the optional use case to the canonical,
# reconcile-proof side table keyed by the idea PR number. NULL/
# blank simply writes no row (absence == "left blank"). Done after
# the refresh so the cache row exists; the mirror onto cached_prs
# keeps the cache column in parity for any read that uses it.
use_case = (payload.proposed_use_case or "").strip()
if use_case:
db.conn().execute(
"""
INSERT INTO proposed_use_cases (scope, rfc_slug, pr_number, use_case)
VALUES ('rfc', ?, ?, ?)
ON CONFLICT(scope, pr_number) DO UPDATE SET use_case = excluded.use_case
""",
(slug, pr["number"], use_case),
)
db.conn().execute(
"UPDATE cached_prs SET proposed_use_case = ? WHERE pr_kind = 'idea' AND pr_number = ?",
(use_case, pr["number"]),
)
return {"pr_number": pr["number"], "slug": slug}
# ---------------------------------------------------------------
# §9.1 Slice 2 (roadmap #27): Claude Haiku tag suggestions as the
# propose-RFC fields fill in. The modal debounce-posts the partial
# draft; we constrain Haiku to the corpus's existing tag set and
# return a short ranked list of clickable chips. Gated to
# contributors (same gate as propose) so the cost surface is bounded
# to people who can actually file an RFC; rate-limited per user as a
# backstop. Degrades to an empty list (no error) when no Anthropic
# key is bound, the corpus has no tags yet, or the draft is empty —
# so the modal simply shows nothing extra.
# ---------------------------------------------------------------
@router.post("/api/rfcs/suggest-tags")
async def suggest_rfc_tags(payload: SuggestTagsBody, request: Request) -> dict[str, Any]:
user = auth.require_contributor(request)
if not tag_suggest.rate_limit_ok(user.user_id):
raise HTTPException(429, "Too many tag-suggestion requests; please slow down.")
provider = tag_suggest.haiku_provider(config)
if provider is None:
return {"suggestions": []}
universe = tag_suggest.gather_tag_universe()
draft = tag_suggest.Draft(
title=payload.title, pitch=payload.pitch, use_case=payload.use_case
)
suggestions = tag_suggest.suggest(provider, draft, universe)
return {"suggestions": suggestions}
# ---------------------------------------------------------------
# §9.3: merge / decline / withdraw an idea PR
# ---------------------------------------------------------------
+375 -1
View File
@@ -11,6 +11,8 @@ The endpoints in this module:
- `GET /api/admin/users` list users with role + mute
- `POST /api/admin/users/<id>/role` set role per §6.1
- `POST /api/admin/users/<id>/mute` set the §6.2 write-mute
- `POST /api/admin/users` v0.17.0: create user + invite
- `GET /api/admin/users/invites` v0.17.0: pending invites
- `GET /api/admin/audit` paged `actions` log
- `GET /api/admin/permission-events` paged `permission_events` log
- `GET /api/admin/graduation-queue` super-drafts ready to graduate
@@ -33,8 +35,9 @@ from typing import Any
from fastapi import APIRouter, HTTPException, Query, Request
from pydantic import BaseModel, Field
from . import auth, db
from . import auth, db, email_invite, invites
from .config import Config
from .email import EmailConfig
# ---------------------------------------------------------------------------
@@ -65,6 +68,32 @@ class AllowlistAddBody(BaseModel):
note: str | None = Field(default=None, max_length=200)
class CreateUserInviteBody(BaseModel):
"""v0.17.0 / roadmap item #16 — admin-create user + invite email.
The admin types these fields on the "Create user + invite" modal on
`/admin/users`. The email + role are required; first/last name and
the optional custom message round out the body.
Bounds mirror the rest of the codebase:
* `email`: 320 chars RFC 5321 envelope limit, same as
`OtcRequestBody` / `BetaRequestBody` / `AllowlistAddBody`.
* `first_name` / `last_name`: 120 chars same as the v0.8.0
`BetaRequestBody` capture form.
* `role`: pydantic regex pinned to the §6.1 set so an unknown
role fails at the body bound (422) instead of landing as a
CHECK constraint violation in the migration.
* `custom_message`: 500 chars the brief calls this out as
the max. The frontend modal shows a "remaining chars"
counter to match.
"""
email: str = Field(min_length=3, max_length=320)
first_name: str = Field(default="", max_length=120)
last_name: str = Field(default="", max_length=120)
role: str = Field(pattern="^(owner|admin|contributor)$")
custom_message: str = Field(default="", max_length=500)
# ---------------------------------------------------------------------------
# Router
# ---------------------------------------------------------------------------
@@ -90,6 +119,17 @@ def make_router(config: Config) -> APIRouter:
`permission_decided_by_login` joins the deciding admin row so
the UI can render "granted by @ben" without a second round-trip.
v0.16.0 (roadmap item #12) additive: each user row now carries
an `rfc_invitations` array the per-RFC invitations the user
has accepted. This is the "permission-grant requests from
invited users" hook the roadmap text calls for: when a user
accepts a per-RFC invite and they're not yet platform-granted,
the admin sees "here because @ben invited them to <RFC> as
<role>" alongside their pending row, informing (not deciding)
the platform grant. The two write surfaces remain distinct
the RFC's owner controls per-RFC roles; the admin controls
platform-grant state.
"""
auth.require_admin(request)
rows = db.conn().execute(
@@ -115,6 +155,62 @@ 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"],
})
# v0.17.0 / roadmap item #16: a user row whose `last_seen_at`
# is NULL is one of two things — a brand-new row that was just
# provisioned (rare, and the v0.7.0 OTC verify path stamps
# last_seen_at on the same call that creates the row), or an
# admin-created invite-pending row (v0.17.0 — created by
# `POST /api/admin/users`). We surface a `pending_invite_id`
# field by joining through `user_invite_tokens` so the
# Users tab can render a "(pending invite)" badge alongside
# the role/state controls. Filters to invites that are
# neither expired nor claimed — once the invitee clicks
# through, the badge clears (and `last_seen_at` populates).
pending_invite_rows = db.conn().execute(
"""
SELECT invited_user_id, id AS invite_id, expires_at
FROM user_invite_tokens
WHERE claimed_at IS NULL
AND datetime(expires_at) > datetime('now')
"""
).fetchall()
pending_invites = {
r["invited_user_id"]: {
"invite_id": r["invite_id"],
"expires_at": r["expires_at"],
}
for r in pending_invite_rows
}
return {
"items": [
{
@@ -133,6 +229,217 @@ 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"], []),
# v0.17.0: present iff the row is invited-but-not-
# claimed-yet. The frontend renders a "(pending
# invite)" badge when this is non-null.
"pending_invite": pending_invites.get(r["id"]),
}
for r in rows
]
}
# ----- Create user + invite (v0.17.0 / roadmap item #16) -----
@router.post("/api/admin/users")
async def create_user_with_invite(
body: CreateUserInviteBody, request: Request,
) -> dict[str, Any]:
"""Provision a fresh `users` row with a pre-assigned role + send
an invite email carrying a claim link.
Refusals:
* `422` the admin tries to invite their own email (no
self-invite; symmetric to `set_permission`'s self-flip
refusal and `set_role`'s self-downgrade refusal). Use
the existing role-change channel for self-edits.
* `422` the admin tries to grant `owner` without being
owner themselves. §6.1: owner-zero is the only owner
bootstrap path; new owners come from a sitting owner's
hand. A 422 here matches the message shape; a 403 would
also be defensible, but staying with 422 keeps the
"your input is bad" framing.
* `409` the email already maps to a `users` row. The
admin should use the existing role / grant gestures on
the existing user, not create a duplicate.
* `422` pydantic-level: malformed email, role outside
the §6.1 set, custom_message over 500 chars.
On success:
1. The invitee `users` row lands with the chosen role and
`permission_state='granted'` (admin's hand is the grant)
and `last_seen_at IS NULL` (the "(pending invite)"
discriminator the listing surface joins through).
2. The `user_invite_tokens` row lands with the bcrypt-
hashed opaque token; the raw token rides only in the
email link.
3. The invite email dispatches with subject "You're
invited to <app> by <admin>" and the custom message
embedded in a clearly-delimited block if present.
4. A `permission_events` row records the admin-create
gesture so the §6.5 / `permissions` admin tab carries
the audit trail alongside the existing grant/revoke
flips.
"""
viewer = auth.require_admin(request)
email_clean = body.email.strip().lower()
if "@" not in email_clean or len(email_clean.split("@")[-1]) < 2:
raise HTTPException(422, "Email looks malformed")
# Self-invite refusal. Compare the admin's own email
# case-insensitively against the invite target.
viewer_row = db.conn().execute(
"SELECT email FROM users WHERE id = ?", (viewer.user_id,)
).fetchone()
viewer_email = (viewer_row["email"] or "").strip().lower() if viewer_row else ""
if viewer_email and viewer_email == email_clean:
raise HTTPException(
422,
"You cannot invite yourself — use the role-change channel "
"if you need to edit your own row",
)
# Owner-grant refusal: §6.1 says only a sitting owner can mint
# a new owner. An admin trying to invite-as-owner is refused
# at 422; the admin should ask the owner to issue the invite,
# or invite as `admin` and let the owner promote later.
if body.role == "owner" and viewer.role != "owner":
raise HTTPException(
422,
"Only an owner can invite a new owner — invite as admin and "
"ask the owner to promote, or have the owner issue this invite",
)
# Duplicate-email refusal. A pre-existing row (regardless of
# permission_state) means the admin should use the existing
# role / grant gestures, not create a parallel user.
existing = db.conn().execute(
"SELECT id FROM users WHERE email = ? COLLATE NOCASE LIMIT 1",
(email_clean,),
).fetchone()
if existing is not None:
raise HTTPException(409, "A user with this email already exists")
# Create the invitee row + token row + send the email.
outcome = invites.create_invite(
email=email_clean,
first_name=body.first_name,
last_name=body.last_name,
role=body.role,
custom_message=body.custom_message,
created_by_admin_id=viewer.user_id,
)
# Audit row in permission_events so the admin Permissions tab
# carries the gesture. The before-state is "n/a" (the row
# did not exist); the after-state is the granted role. We
# use a new `event_kind='user_invited'` so the existing
# grant/revoke kinds stay scoped to their flip surface.
db.conn().execute(
"""
INSERT INTO permission_events
(actor_user_id, subject_user_id, event_kind, details)
VALUES (?, ?, 'user_invited', ?)
""",
(
viewer.user_id,
outcome.invited_user_id,
json.dumps({
"email": email_clean,
"role": body.role,
"invite_id": outcome.invite_id,
"custom_message_chars": len(body.custom_message or ""),
}),
),
)
# Build the claim URL using the same APP_URL the email module
# reads. The token rides as a query-string param to the
# frontend route `/invites/claim?token=…`; the frontend POSTs
# it back to `/api/invites/claim` which consumes the row.
cfg = EmailConfig.from_env()
from urllib.parse import urlencode
claim_url = f"{cfg.app_url}/invites/claim?{urlencode({'token': outcome.raw_token})}"
# Fetch the inviter display so the email body can render
# "Ben Stull (ben@example.com) has invited you to …". We
# read off the row fresh rather than trusting the session
# cookie's cached display_name.
inviter_row = db.conn().execute(
"SELECT display_name, email FROM users WHERE id = ?",
(viewer.user_id,),
).fetchone()
inviter_display = (
(inviter_row["display_name"] if inviter_row else "") or viewer.display_name or "An admin"
)
inviter_email_for_body = (inviter_row["email"] if inviter_row else "") or viewer.email or ""
email_invite.send_invite_email(
to_address=email_clean,
claim_url=claim_url,
inviter_display=inviter_display,
inviter_email=inviter_email_for_body,
custom_message=body.custom_message,
)
return {
"ok": True,
"invite_id": outcome.invite_id,
"invited_user_id": outcome.invited_user_id,
"email": email_clean,
"role": body.role,
}
@router.get("/api/admin/users/invites")
async def list_user_invites(request: Request) -> dict[str, Any]:
"""List active (not claimed, not expired) admin-issued invites.
Powers the admin's "I sent these but they haven't been claimed
yet" view. The frontend uses this alongside `list_users` —
the user-listing's `pending_invite` field carries the per-row
flag; this endpoint carries the full invite shape for a
dedicated drill-in surface.
"""
auth.require_admin(request)
rows = invites.list_pending_invites()
# Join through to the admin display names so the surface can
# render "invited by @ben" without a second client call.
admin_ids = {r.created_by_admin_id for r in rows}
admin_lookup: dict[int, dict[str, str]] = {}
if admin_ids:
placeholders = ",".join("?" * len(admin_ids))
admin_rows = db.conn().execute(
f"SELECT id, gitea_login, display_name FROM users "
f"WHERE id IN ({placeholders})",
tuple(admin_ids),
).fetchall()
admin_lookup = {
ar["id"]: {
"gitea_login": ar["gitea_login"] or "",
"display_name": ar["display_name"] or "",
}
for ar in admin_rows
}
return {
"items": [
{
"id": r.id,
"email": r.email,
"role": r.role,
"first_name": r.first_name,
"last_name": r.last_name,
"custom_message": r.custom_message,
"created_at": r.created_at,
"expires_at": r.expires_at,
"invited_user_id": r.invited_user_id,
"created_by_admin_id": r.created_by_admin_id,
"created_by_login": admin_lookup.get(
r.created_by_admin_id, {}
).get("gitea_login", ""),
"created_by_display": admin_lookup.get(
r.created_by_admin_id, {}
).get("display_name", ""),
}
for r in rows
]
@@ -365,6 +672,73 @@ def make_router(config: Config) -> APIRouter:
"has_more": len(rows) == limit,
}
@router.get("/api/admin/outbound-emails")
async def list_outbound_emails(
request: Request,
kind: str | None = None,
status: str | None = None,
to_address: str | None = None,
limit: int = Query(default=100, ge=1, le=500),
before_id: int | None = None,
) -> dict[str, Any]:
"""v0.18.0 Slice 4: read-only inspection of the
`outbound_emails` audit table.
Answers questions like "did this person ever get their
invite?" without grepping VM logs. Filterable by kind
('otc' | 'invite' | 'notification' | 'bundle' | 'digest'),
status ('sent' | 'failed' | 'deferred' | 'bounced'), and
to_address; the latter is exact-match because the audit
question is usually "the specific person who said they
didn't receive it." Per the proposal, no admin UI ships
with v0.18.0 operator queries via curl + jq for now.
"""
auth.require_admin(request)
clauses: list[str] = []
args: list[Any] = []
if kind:
clauses.append("kind = ?")
args.append(kind)
if status:
clauses.append("status = ?")
args.append(status)
if to_address:
clauses.append("LOWER(to_address) = LOWER(?)")
args.append(to_address)
if before_id is not None:
clauses.append("id < ?")
args.append(before_id)
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
rows = db.conn().execute(
f"""
SELECT id, to_address, from_address, subject, kind, sent_at,
status, error, notification_id, message_id
FROM outbound_emails
{where}
ORDER BY id DESC
LIMIT ?
""",
(*args, limit),
).fetchall()
return {
"items": [
{
"id": r["id"],
"to_address": r["to_address"],
"from_address": r["from_address"],
"subject": r["subject"],
"kind": r["kind"],
"sent_at": r["sent_at"],
"status": r["status"],
"error": r["error"],
"notification_id": r["notification_id"],
"message_id": r["message_id"],
}
for r in rows
],
"has_more": len(rows) == limit,
}
@router.get("/api/admin/permission-events")
async def list_permission_events(
request: Request,
+76 -37
View File
@@ -150,7 +150,7 @@ def make_router(
# `refresh_meta_branches` writes is internal scaffolding for the
# §10.1 has-commits-ahead check — the §9.4 dropdown's first
# position is rendered separately as 'canonical body'.
if _is_super_draft(rfc):
if _is_meta_resident(rfc):
branch_rows = db.conn().execute(
"""
SELECT branch_name, head_sha, state, last_commit_at, pinned
@@ -180,7 +180,7 @@ def make_router(
# per-RFC repo. For super-draft: meta_body_edit and meta_metadata
# PRs on the meta repo. Same shape either way — the §9.4 dropdown
# treats both as "open work against this entry."
pr_kinds = ("meta_body_edit", "meta_metadata") if _is_super_draft(rfc) else ("rfc_branch",)
pr_kinds = ("meta_body_edit", "meta_metadata") if _is_meta_resident(rfc) else ("rfc_branch",)
placeholders = ",".join("?" * len(pr_kinds))
pr_rows = db.conn().execute(
f"""
@@ -204,17 +204,18 @@ def make_router(
for r in pr_rows
]
# For super-drafts the cached body is entry.body already (see
# cache._upsert_cached_rfc), so no extraction is needed.
# §9.8 / §13.4 pre-graduation history: for active RFCs, surface
# any `threads` or `changes` rows whose `branch_name` starts with
# `edit-<slug>-` so the breadcrumb dropdown can render the
# affordance as a distinct disclosure alongside main, open
# branches, and open PRs. The slug is the canonical key per §2.3
# before and after graduation, so the query is a straightforward
# lookup — no data movement.
# For meta-resident entries the cached body is entry.body already
# (see cache._upsert_cached_rfc), so no extraction is needed.
# Pre-graduation history is a LEGACY-only affordance: under the
# meta-only topology (§1, §13.4) graduation moves nothing, so an
# active RFC's edit branches are its *current* branches and already
# surface in `branches` above — there is no separate pre-graduation
# set. The disclosure is therefore computed only for a legacy
# per-RFC-repo active entry (`repo` set), where edit branches on the
# meta repo genuinely predate the per-RFC repo and would otherwise
# not appear. After the RFC-0001 fold-back (§13.6) nothing matches.
pre_grad: list[dict[str, Any]] = []
if rfc["state"] == "active":
if rfc["state"] == "active" and rfc["repo"]:
pre_grad_rows = db.conn().execute(
"""
SELECT t.branch_name,
@@ -279,12 +280,33 @@ 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()
if not new_branch:
new_branch = _auto_branch_name(viewer.gitea_login)
_validate_branch_name(new_branch)
# Meta-only topology (§1): an active RFC's branches live on the
# shared meta repo, so the auto name must embed the slug for the
# cache to attribute it (`edit-<slug>-<hex>`, recovered by
# `_slug_from_branch_name`). A legacy per-RFC-repo entry can use
# the slug-free `<login>-draft-<hex>` since every branch there
# belongs to the one RFC. The auto name is trusted (it carries a
# reserved `edit-` prefix by design); only a user-supplied name
# is validated, mirroring `start_edit_branch`.
new_branch = (
_auto_edit_branch_name(slug) if _is_meta_resident(rfc)
else _auto_branch_name(viewer.gitea_login)
)
else:
_validate_branch_name(new_branch)
try:
await bot.cut_branch_from_main(
viewer.as_actor(),
@@ -317,8 +339,10 @@ def make_router(
_ensure_branch_vis(slug, new_branch, creator_user_id=viewer.user_id)
# Make the cache aware immediately so the breadcrumb reflects
# the new branch without waiting for the webhook hop.
await cache.refresh_rfc_repo(config, gitea, slug)
# the new branch without waiting for the webhook hop. Meta-resident
# entries (§1) refresh meta branches; a legacy per-RFC repo refreshes
# its own — `_refresh_cache_for` dispatches on residency.
await _refresh_cache_for(rfc)
return {"branch_name": new_branch, "slug": slug}
@@ -331,6 +355,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()
@@ -1064,14 +1096,14 @@ def make_router(
return row
def _require_rfc_with_repo(slug: str):
"""Used by every branch-scoped endpoint. For active RFCs, a repo is
required. For super-drafts, the meta repo is the implicit target
no per-RFC repo check needed."""
"""Used by every branch-scoped endpoint. Under the meta-only
topology (§1) the meta repo is the implicit target for every
entry super-draft and active alike so there is no per-RFC
repo check. The name is retained for call-site stability; a
withdrawn entry is still rejected."""
row = _require_rfc(slug)
if row["state"] == "withdrawn":
raise HTTPException(409, "RFC is withdrawn")
if row["state"] == "active" and not row["repo"]:
raise HTTPException(409, "RFC has no repo")
return row
def _require_active_rfc(slug: str):
@@ -1089,22 +1121,28 @@ def make_router(
def _is_super_draft(rfc) -> bool:
return rfc["state"] == "super-draft"
def _is_meta_resident(rfc) -> bool:
"""Meta-only topology (§1): an entry lives in the meta repo's
`rfcs/<slug>.md` (super-draft or active-in-place) unless it carries
a legacy per-RFC `repo:` which nothing does after the RFC-0001
fold-back (§13.6)."""
return not rfc["repo"]
def _is_meta_branch_name(name: str) -> bool:
"""A branch name shaped like one of the bot's meta-repo prefixes.
§9.8's pre-graduation history affordance points the new RFC view
at branches matching `edit-<slug>-...` even after the entry is
active; treating those names as meta-repo targets lets the read
path dispatch correctly without a separate endpoint."""
Retained for the legacy per-RFC-repo read path; under meta-only
every entry is already a meta target via `_is_meta_resident`."""
return name != "main" and name.startswith((
"edit-", "edit/", "metadata-", "metadata/", "claim/", "propose/",
"graduate-",
))
def _is_meta_target(rfc, branch: str) -> bool:
"""Either a super-draft branch (active edit branch or the
canonical body) or an active RFC's pre-graduation meta-repo
branch surfaced through the §9.8 history affordance."""
if _is_super_draft(rfc):
"""A meta-resident entry (super-draft or active-in-place, §1)
targets the meta repo for every branch. The branch-name fallback
covers the retired per-RFC-repo case for any legacy entry that
still carries a `repo:`."""
if _is_meta_resident(rfc):
return True
return _is_meta_branch_name(branch)
@@ -1144,7 +1182,7 @@ def make_router(
return entry_mod.serialize(entry)
async def _refresh_cache_for(rfc) -> None:
if _is_super_draft(rfc):
if _is_meta_resident(rfc):
await cache.refresh_meta_repo(config, gitea)
await cache.refresh_meta_branches(config, gitea)
else:
@@ -1253,12 +1291,13 @@ def make_router(
return False
if branch == "main":
return False
# §9.8: pre-graduation history branches are read-only on the
# post-graduation surface. The contributor can re-cut against the
# new repo's main if they still want the work, but the meta-repo
# branches that lived on the super-draft are not editable from
# the active-RFC view.
if rfc["state"] == "active" and _is_meta_branch_name(branch):
# §9.8 (LEGACY per-repo only): pre-graduation history branches are
# read-only on the post-graduation surface of a per-RFC-repo active
# entry. Under the meta-only topology (§1, §13.4) an active RFC's
# `edit-<slug>-…` branches are its *current* editable branches, not
# a frozen pre-graduation set, so this guard applies only when a
# legacy `repo:` is set (nothing, after the RFC-0001 fold-back).
if rfc["state"] == "active" and rfc["repo"] and _is_meta_branch_name(branch):
return False
if viewer.role in ("owner", "admin"):
return True
@@ -1285,7 +1324,7 @@ def make_router(
def _require_can_contribute(slug: str, branch: str, viewer) -> None:
rfc = db.conn().execute(
"SELECT state, owners_json, arbiters_json FROM cached_rfcs WHERE slug = ?",
"SELECT state, repo, owners_json, arbiters_json FROM cached_rfcs WHERE slug = ?",
(slug,),
).fetchone()
if not _can_contribute(rfc, slug, branch, viewer):
+311
View File
@@ -0,0 +1,311 @@
"""v0.29.0 / roadmap #28 Part 3 — offer-to-contribute-to-a-pending-RFC.
When the #28 scanner (see ``rfc_links.py``) matches a term in submitted
PR/comment text to a **pending** RFC a super-draft
(``cached_rfcs.state='super-draft'``: accepted as an idea, owned, with a
contribution surface, but not yet graduated to an active RFC) the
reader is offered an "ask to contribute" popover. This module is the
backend for that flow:
* ``GET /api/rfcs/{slug}/contribution-target`` what the
contribute form needs (RFC title, owner display, the viewer's
eligibility + whether they already have a pending ask).
* ``POST /api/rfcs/{slug}/contribution-requests`` submit the ask
(who-I-am / why / optional use-case); lands a row + one §15
notification per owner.
* ``POST /api/rfcs/{slug}/contribution-requests/{id}/accept`` owner:
accept, which fires #12's owner-invite flow with the requester as the
invitee (opening the RFC's discussion/contribution surface), then
echoes a notification back to the requester.
* ``POST /api/rfcs/{slug}/contribution-requests/{id}/decline`` owner:
decline; the request closes and the requester is notified.
"Pending" is scoped to a super-draft because that is the state with an
owner to route to, a contribution surface to open, and a row in
``cached_rfcs`` for the ``rfc_invitations`` FK the accept path reuses.
Pre-merge idea PRs are deliberately out of scope (no contribution
surface yet) a documented future extension, mirroring the
conservative scoping in ``rfc_links.py``.
"""
from __future__ import annotations
import sqlite3
from typing import Any
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from . import api_invitations, auth, db, notify
# Field caps — generous for free text, bounded so a request row (and the
# notification payload that carries it) can't be used to store unbounded
# blobs. Mirrors the order-of-magnitude of the propose/tag-suggest caps.
_WHO_MAX = 2000
_WHY_MAX = 4000
_USE_CASE_MAX = 4000
_TERM_MAX = 200
class ContributionRequestBody(BaseModel):
# The term in the PR/comment text that surfaced the offer (the
# super-draft's title/slug). Carried for the owner's context line.
matched_term: str = Field(min_length=1, max_length=_TERM_MAX)
who_i_am: str = Field(min_length=1, max_length=_WHO_MAX)
why: str = Field(min_length=1, max_length=_WHY_MAX)
use_case: str | None = Field(default=None, max_length=_USE_CASE_MAX)
def _require_super_draft(slug: str):
"""The contribute surface only operates on a *pending* RFC. 404 on
unknown; 409 on a state that isn't a super-draft (active RFCs use the
Part-1 link, not a contribute offer; withdrawn is closed)."""
row = db.conn().execute(
"SELECT slug, title, state, owners_json, proposed_by FROM cached_rfcs WHERE slug = ?",
(slug,),
).fetchone()
if row is None:
raise HTTPException(404, "RFC not found")
if row["state"] != "super-draft":
raise HTTPException(409, "RFC is not a pending super-draft")
return row
def _require_request(slug: str, request_id: int):
row = db.conn().execute(
"""
SELECT id, rfc_slug, requester_user_id, matched_term, who_i_am, why,
use_case, status
FROM contribution_requests WHERE id = ? AND rfc_slug = ?
""",
(request_id, slug),
).fetchone()
if row is None:
raise HTTPException(404, "Contribution request not found")
return row
def _viewer_relationship(viewer, slug: str) -> str | None:
"""Why this viewer can't *request* to contribute — or None if they can.
Owners/admins already have the RFC; existing collaborators are already
in. Both get a clear 409 rather than a useless self-request."""
if auth.is_rfc_owner(viewer, slug) or viewer.role in ("owner", "admin"):
return "You already own or administer this RFC."
if auth.is_rfc_collaborator(viewer, slug):
return "You're already a collaborator on this RFC."
return None
def make_router() -> APIRouter:
router = APIRouter()
# ---------------------------------------------------------------
# GET — what the contribute form needs to render + gate itself.
# ---------------------------------------------------------------
@router.get("/api/rfcs/{slug}/contribution-target")
async def contribution_target(slug: str, request: Request) -> dict[str, Any]:
row = db.conn().execute(
"SELECT slug, title, state, owners_json, proposed_by FROM cached_rfcs WHERE slug = ?",
(slug,),
).fetchone()
if row is None:
raise HTTPException(404, "RFC not found")
from . import rfc_links # local import: avoid a module import cycle
owner = rfc_links._owner_display(db.conn(), row["owners_json"], row["proposed_by"])
viewer = auth.current_user(request)
eligible = True
reason: str | None = None
already_requested = False
if row["state"] != "super-draft":
eligible, reason = False, "This RFC is no longer pending."
elif viewer is None:
eligible, reason = False, "Sign in to ask to contribute."
elif viewer.permission_state != "granted":
eligible, reason = False, "Your beta access request is in review."
else:
reason = _viewer_relationship(viewer, slug)
if reason is not None:
eligible = False
else:
already_requested = bool(
db.conn().execute(
"""
SELECT 1 FROM contribution_requests
WHERE rfc_slug = ? AND requester_user_id = ? AND status = 'pending'
LIMIT 1
""",
(slug, viewer.user_id),
).fetchone()
)
return {
"slug": row["slug"],
"title": row["title"],
"owner": owner,
"eligible": eligible and not already_requested,
"reason": reason,
"already_requested": already_requested,
}
# ---------------------------------------------------------------
# POST — submit a contribute request.
# ---------------------------------------------------------------
@router.post("/api/rfcs/{slug}/contribution-requests")
async def create_contribution_request(
slug: str, body: ContributionRequestBody, request: Request
) -> dict[str, Any]:
viewer = auth.require_contributor(request)
_require_super_draft(slug)
reason = _viewer_relationship(viewer, slug)
if reason is not None:
raise HTTPException(409, reason)
who_i_am = body.who_i_am.strip()
why = body.why.strip()
use_case = (body.use_case or "").strip() or None
matched_term = body.matched_term.strip()
if not who_i_am or not why:
raise HTTPException(422, "Both 'who I am' and 'why' are required.")
try:
cur = db.conn().execute(
"""
INSERT INTO contribution_requests
(rfc_slug, requester_user_id, matched_term, who_i_am, why, use_case)
VALUES (?, ?, ?, ?, ?, ?)
""",
(slug, viewer.user_id, matched_term, who_i_am, why, use_case),
)
except sqlite3.IntegrityError:
# The partial unique index — one open request per (RFC, user).
raise HTTPException(409, "You already have a pending request to contribute to this RFC.")
request_id = cur.lastrowid
# One actionable notification per owner; stamp the first onto the
# row as the inbox-action handle (any owner can act on the request).
notif_ids = notify.fan_out_contribution_request(
rfc_slug=slug,
requester_user_id=viewer.user_id,
request_id=request_id,
matched_term=matched_term,
who_i_am=who_i_am,
why=why,
use_case=use_case,
)
if notif_ids:
db.conn().execute(
"UPDATE contribution_requests SET notification_id = ? WHERE id = ?",
(notif_ids[0], request_id),
)
return {"id": request_id, "rfc_slug": slug, "status": "pending"}
# ---------------------------------------------------------------
# POST — owner accepts → fire #12's invite flow.
# ---------------------------------------------------------------
@router.post("/api/rfcs/{slug}/contribution-requests/{request_id}/accept")
async def accept_contribution_request(
slug: str, request_id: int, request: Request
) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_super_draft(slug)
if not auth.can_invite_to_rfc(viewer, slug):
raise HTTPException(403, "Only the RFC's owner can act on contribution requests")
req = _require_request(slug, request_id)
if req["status"] != "pending":
raise HTTPException(409, f"This request was already {req['status']}.")
requester = db.conn().execute(
"SELECT id, email FROM users WHERE id = ?", (req["requester_user_id"],)
).fetchone()
if requester is None or not (requester["email"] or "").strip():
raise HTTPException(422, "The requester has no email address on file to invite.")
# Fire #12's owner-invite flow with the requester as the invitee.
# If a pending contributor invitation already exists (the owner
# invited them out-of-band first), reuse it rather than failing.
try:
invitation = api_invitations.issue_invitation(
slug=slug,
inviter_user_id=viewer.user_id,
inviter_display=viewer.display_name or viewer.gitea_login or "An RFC owner",
invitee_email=requester["email"],
role_in_rfc="contributor",
rfc_title=rfc["title"],
)
invitation_id = invitation["id"]
except HTTPException as exc:
if exc.status_code != 409:
raise
existing = db.conn().execute(
"""
SELECT id FROM rfc_invitations
WHERE rfc_slug = ? AND invitee_email = ? COLLATE NOCASE
AND role_in_rfc = 'contributor' AND status = 'pending'
ORDER BY id DESC LIMIT 1
""",
(slug, requester["email"].strip()),
).fetchone()
invitation_id = existing["id"] if existing else None
db.conn().execute(
"""
UPDATE contribution_requests
SET status = 'accepted', decided_at = datetime('now'),
decided_by_user_id = ?, invitation_id = ?
WHERE id = ?
""",
(viewer.user_id, invitation_id, request_id),
)
notify.notify_contribution_decided(
rfc_slug=slug,
requester_user_id=req["requester_user_id"],
decider_user_id=viewer.user_id,
request_id=request_id,
accepted=True,
)
return {"ok": True, "status": "accepted", "invitation_id": invitation_id}
# ---------------------------------------------------------------
# POST — owner declines.
# ---------------------------------------------------------------
@router.post("/api/rfcs/{slug}/contribution-requests/{request_id}/decline")
async def decline_contribution_request(
slug: str, request_id: int, request: Request
) -> dict[str, Any]:
viewer = auth.require_contributor(request)
_require_super_draft(slug)
if not auth.can_invite_to_rfc(viewer, slug):
raise HTTPException(403, "Only the RFC's owner can act on contribution requests")
req = _require_request(slug, request_id)
if req["status"] != "pending":
raise HTTPException(409, f"This request was already {req['status']}.")
db.conn().execute(
"""
UPDATE contribution_requests
SET status = 'declined', decided_at = datetime('now'),
decided_by_user_id = ?
WHERE id = ?
""",
(viewer.user_id, request_id),
)
notify.notify_contribution_decided(
rfc_slug=slug,
requester_user_id=req["requester_user_id"],
decider_user_id=viewer.user_id,
request_id=request_id,
accepted=False,
)
return {"ok": True, "status": "declined"}
return router
+27 -2
View File
@@ -40,7 +40,7 @@ from typing import Any
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from . import auth, chat as chat_layer, db
from . import auth, chat as chat_layer, db, rfc_links
log = logging.getLogger(__name__)
@@ -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
@@ -160,9 +171,17 @@ def make_router() -> APIRouter:
""",
(thread_id,),
).fetchall()
# Roadmap #28 Part 1: enrich each discussion comment with RFC
# auto-link segments scanned against the live accepted-RFC corpus
# (read-time; see rfc_links.py). exclude_slug suppresses self-links
# to this RFC inside its own discussion.
link_index = rfc_links.build_index(db.conn(), exclude_slug=slug)
messages = [_serialize_message(r) for r in rows]
for m in messages:
m["text_segments"] = link_index.segment(m["text"])
return {
"thread": _serialize_thread(thread),
"messages": [_serialize_message(r) for r in rows],
"messages": messages,
}
# -------------------------------------------------------------------
@@ -175,6 +194,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,
+132 -365
View File
@@ -1,26 +1,30 @@
"""Slice 5 API surface — the §13 graduation flow's endpoints and the
in-process orchestrator that runs the §13.3 transactional sequence with
rollback.
"""§13 graduation flow — the meta-only in-place state flip.
Owns four routes per §17:
Under the meta-only topology (SPEC §1), graduation no longer creates a
per-RFC repo. It is a single frontmatter-flipping commit to the entry's
`rfcs/<slug>.md` on the meta repo: open a PR that re-serializes the entry
with `state: active`, the assigned integer `id`, `graduated_at` /
`graduated_by`, and the dialog's owners — **leaving the body unchanged** —
then auto-merge it. There is no repo to create, nothing to seed, and the
body is neither moved nor stripped, so there is no multi-step transaction
and no rollback (§13.3). If the open or merge fails, the entry stays a
super-draft and we clean up the half-open PR/branch (the only artifact a
mid-flip failure can leave behind).
Routes (§17):
- GET /api/rfcs/<slug>/blocking-prs (§13.2 precondition popover)
- GET /api/rfcs/<slug>/graduate/check (§13.2 debounced validator)
- POST /api/rfcs/<slug>/graduate (§13.3 kickoff)
- POST /api/rfcs/<slug>/graduate (§13.3 the flip)
- GET /api/rfcs/<slug>/graduate/progress (§13.3 SSE step stream)
- GET /api/rfcs/<slug>/blocking-prs (informational; no longer a
graduation precondition)
Plus the §13.1 claim PR endpoint (POST /api/rfcs/<slug>/claim), which is
graduation's prerequisite for non-admins per §13.1.
Plus the §13.1 claim PR endpoint (POST /api/rfcs/<slug>/claim).
The orchestrator runs in-process each in-flight graduation lives in a
small `GraduationState` keyed by slug, with an asyncio.Queue feeding the
SSE handler. Per the §13.3 transactional contract, every forward step is
paired with an undo; rollback runs the undos in reverse order from the
last step that completed. §13.4's chat migration is a database semantic
no-op (the threads' `(rfc_slug, branch_name='main')` rows are interpreted
as super-draft canonical-body before graduation and as new-RFC main
afterwards same shape, different meaning), so the only DB work the
sequence does is the audit-log rows the bot's `_log` writes per step.
SSE handler. §13.4's chat/branch/history are a database no-op: every row
is keyed by the slug per §2.3 and stays put across the flip.
"""
from __future__ import annotations
@@ -44,24 +48,18 @@ log = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Step machine
# Step machine — two steps under meta-only: open the flip PR, merge it.
# ---------------------------------------------------------------------------
STEP_KEYS = (
"create_repo",
"seed_files",
"open_pr",
"merge_pr",
"refresh_cache",
)
STEP_LABELS = {
"create_repo": "Create per-RFC repository",
"seed_files": "Seed RFC.md, README.md, and .rfc/metadata.yaml",
"open_pr": "Open meta-repo graduation PR",
"open_pr": "Open graduation PR (flip state to active)",
"merge_pr": "Merge graduation PR",
"refresh_cache": "Refresh catalog and views",
}
@@ -77,8 +75,6 @@ class StepState:
class GraduationState:
slug: str
rfc_id: str
repo_name: str
repo_full: str
owners: list[str]
arbiters: list[str]
steps: list[StepState]
@@ -86,8 +82,6 @@ class GraduationState:
finished: bool = False
succeeded: bool = False
error: str | None = None
rollback_started: bool = False
rollback_steps: list[StepState] = field(default_factory=list)
new_pr_number: int | None = None
graduation_branch: str | None = None
@@ -95,12 +89,9 @@ class GraduationState:
return {
"slug": self.slug,
"rfc_id": self.rfc_id,
"repo_full": self.repo_full,
"steps": [_step_payload(s) for s in self.steps],
"rollback_steps": [_step_payload(s) for s in self.rollback_steps],
"finished": self.finished,
"succeeded": self.succeeded,
"rolled_back": self.rollback_started,
"error": self.error,
"pr_number": self.new_pr_number,
}
@@ -114,7 +105,7 @@ def _step_payload(s: StepState) -> dict:
# is fine; the registry is keyed by slug to refuse concurrent graduations
# of the same entry (the §13.2 atomic re-check is a separate defense
# against a concurrent attempt of a DIFFERENT slug claiming the same
# integer ID or repo name).
# integer ID).
_active: dict[str, GraduationState] = {}
@@ -122,10 +113,10 @@ def _get_active(slug: str) -> GraduationState | None:
return _active.get(slug)
def _new_active(slug: str, *, rfc_id: str, repo_name: str, repo_full: str,
def _new_active(slug: str, *, rfc_id: str,
owners: list[str], arbiters: list[str]) -> GraduationState:
state = GraduationState(
slug=slug, rfc_id=rfc_id, repo_name=repo_name, repo_full=repo_full,
slug=slug, rfc_id=rfc_id,
owners=owners, arbiters=arbiters,
steps=[StepState(key=k, label=STEP_LABELS[k]) for k in STEP_KEYS],
)
@@ -138,17 +129,9 @@ def _new_active(slug: str, *, rfc_id: str, repo_name: str, repo_full: str,
# ---------------------------------------------------------------------------
# §13.2: Gitea repo name pattern. Gitea accepts alphanumerics, dashes,
# dots, and underscores; cannot start with a dot. 100-char cap as a sane
# upper bound — the spec doesn't pin a max but Gitea's enforcement does.
_REPO_NAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,99}$")
_RFC_ID_RE = re.compile(r"^RFC-\d{4,}$")
def _is_valid_repo_name(name: str) -> bool:
return bool(_REPO_NAME_RE.match(name)) and ".." not in name
def _is_valid_rfc_id(rfc_id: str) -> bool:
return bool(_RFC_ID_RE.match(rfc_id))
@@ -167,13 +150,6 @@ def _suggest_next_rfc_id() -> str:
return f"RFC-{nxt:04d}"
def _suggest_repo_name(slug: str, rfc_id: str) -> str:
# rfc-NNNN-<slug> per §13.2's default. Strip the 'RFC-' prefix and
# lowercase the number-pad.
num = rfc_id.split("-", 1)[1] if "-" in rfc_id else "0001"
return f"rfc-{num}-{slug}"
def _rfc_id_taken(rfc_id: str, *, excluding_slug: str) -> bool:
row = db.conn().execute(
"SELECT slug FROM cached_rfcs WHERE rfc_id = ? AND slug != ?",
@@ -189,7 +165,6 @@ def _rfc_id_taken(rfc_id: str, *, excluding_slug: str) -> bool:
class GraduateBody(BaseModel):
rfc_id: str = Field(min_length=5, max_length=40)
repo_name: str = Field(min_length=1, max_length=100)
owners: list[str] = Field(min_length=1)
@@ -206,19 +181,17 @@ def make_router(
router = APIRouter()
# -------------------------------------------------------------------
# §13.2: GET /api/rfcs/<slug>/blocking-prs
# Lists open meta-repo PRs against rfcs/<slug>.md per the precondition
# popover. Returns PR number, title, author, last-activity timestamp,
# and the viewer's available actions (merge, withdraw, open-in-new-tab).
# GET /api/rfcs/<slug>/blocking-prs
# Lists open meta-repo body-edit PRs against rfcs/<slug>.md. Under the
# meta-only topology (§9.8) these no longer block graduation — the body
# is kept, so a body-edit PR coexists with the flip. Retained as an
# informational surface (the dialog can show "N body-edit PRs open").
# -------------------------------------------------------------------
@router.get("/api/rfcs/{slug}/blocking-prs")
async def list_blocking_prs(slug: str, request: Request) -> dict[str, Any]:
viewer = auth.current_user(request)
rfc = _require_super_draft(slug)
# §13's opening paragraph: only body-edit PRs block graduation.
# Bare edit branches without an open PR do not block. The query
# filters cached_prs to open meta_body_edit kinds for this slug.
rows = db.conn().execute(
"""
SELECT pr_number, title, opened_by, opened_at, head_branch, pr_kind
@@ -261,14 +234,15 @@ def make_router(
"open_in_new_tab": True,
},
})
return {"items": items}
# `blocking` is a legacy field name kept for client compatibility;
# under §9.8 these PRs do not block graduation.
return {"items": items, "blocking": False}
# -------------------------------------------------------------------
# §13.2: GET /api/rfcs/<slug>/graduate/check?id=&repo=
# GET /api/rfcs/<slug>/graduate/check?id=
# Inline validation for the Graduate dialog — debounced from the
# client; the dialog calls this as the admin types. Returns per-field
# collision/validity from the catalog cache plus a server-authoritative
# repo-name collision check.
# client. Two fields under meta-only: the integer ID and the owners
# precondition. There is no repo name to validate (§13.2).
# -------------------------------------------------------------------
@router.get("/api/rfcs/{slug}/graduate/check")
@@ -281,16 +255,7 @@ def make_router(
# admins/owners, but the check itself is read-only.
candidate_id = (request.query_params.get("id") or "").strip()
candidate_repo = (request.query_params.get("repo") or "").strip()
owners = json.loads(rfc["owners_json"] or "[]")
blocking_count = db.conn().execute(
"""
SELECT COUNT(*) AS n FROM cached_prs
WHERE rfc_slug = ? AND state = 'open' AND pr_kind = 'meta_body_edit'
""",
(slug,),
).fetchone()["n"]
# ID field
id_payload: dict[str, Any] = {"value": candidate_id, "ok": True, "error": None}
@@ -304,34 +269,6 @@ def make_router(
id_payload["ok"] = False
id_payload["error"] = f"Integer ID {candidate_id} is already taken"
# Repo field — validate pattern then probe Gitea for an existing
# repo of that name under our org. The repo lookup is a single GET
# so it's cheap to call on every keystroke (debounced from the
# client per §13.2).
repo_payload: dict[str, Any] = {"value": candidate_repo, "ok": True, "error": None}
if not candidate_repo:
repo_payload["ok"] = False
repo_payload["error"] = "Repo name is required"
elif not _is_valid_repo_name(candidate_repo):
repo_payload["ok"] = False
repo_payload["error"] = (
"Repo name must be alphanumerics, dashes, dots, or underscores "
"(start with alphanumeric)"
)
else:
try:
existing = await gitea.get_repo(config.gitea_org, candidate_repo)
except GiteaError as e:
# Network/auth flake — surface as a non-fatal hint; the
# atomic server-side check at POST time is the authority.
existing = None
log.warning("graduate_check: Gitea get_repo error: %s", e)
if existing is not None:
repo_payload["ok"] = False
repo_payload["error"] = (
f"Repo `{config.gitea_org}/{candidate_repo}` already exists"
)
# Owners precondition — §13's opening paragraph.
owners_payload: dict[str, Any] = {
"ok": len(owners) > 0,
@@ -340,28 +277,13 @@ def make_router(
"error": None if len(owners) > 0 else "No owners claimed yet",
}
# Blocking PR precondition — §9.8 / §13's opening paragraph.
prs_payload: dict[str, Any] = {
"ok": blocking_count == 0,
"count": blocking_count,
"error": (
None if blocking_count == 0
else f"{blocking_count} open body-edit PR{'' if blocking_count == 1 else 's'} blocking graduation"
),
}
in_flight = _get_active(slug)
any_invalid = not (
id_payload["ok"] and repo_payload["ok"]
and owners_payload["ok"] and prs_payload["ok"]
)
any_invalid = not (id_payload["ok"] and owners_payload["ok"])
return {
"slug": slug,
"id": id_payload,
"repo": repo_payload,
"owners": owners_payload,
"blocking_prs": prs_payload,
"can_submit": (not any_invalid) and (in_flight is None or in_flight.finished),
"in_flight": (
None if in_flight is None
@@ -370,8 +292,8 @@ def make_router(
}
# -------------------------------------------------------------------
# §13.3: POST /api/rfcs/<slug>/graduate
# Atomic re-validation, then kicks off the sequence as an async task.
# POST /api/rfcs/<slug>/graduate
# Atomic re-validation, then kicks off the flip as an async task.
# The client opens GET /graduate/progress on confirm to watch the SSE.
# -------------------------------------------------------------------
@@ -392,9 +314,8 @@ def make_router(
# §13.2 atomic re-validation. The dialog's debounced check runs
# client-side as the admin types; this is the authoritative check
# that closes the dialog-open-to-confirm race.
# that closes the dialog-open-to-confirm race on the integer ID.
rfc_id = body.rfc_id.strip()
repo_name = body.repo_name.strip()
owners = [o.strip() for o in body.owners if o.strip()]
if not owners:
raise HTTPException(422, "Add at least one initial owner")
@@ -402,35 +323,10 @@ def make_router(
raise HTTPException(422, "ID must look like RFC-NNNN (at least four digits)")
if _rfc_id_taken(rfc_id, excluding_slug=slug):
raise HTTPException(409, f"Integer ID {rfc_id} is already taken")
if not _is_valid_repo_name(repo_name):
raise HTTPException(422, "Repo name must be alphanumerics, dashes, dots, or underscores")
try:
existing_repo = await gitea.get_repo(config.gitea_org, repo_name)
except GiteaError as e:
raise HTTPException(502, f"Gitea: {e.detail}")
if existing_repo is not None:
raise HTTPException(409, f"Repo `{config.gitea_org}/{repo_name}` already exists")
# §9.8 precondition gate — enforced before the bot starts the
# sequence so the §13.3 rollback complexity does not grow. An
# open body-edit PR against rfcs/<slug>.md would attempt to
# re-introduce a body to a frontmatter-only entry after step 3.
blocking = db.conn().execute(
"""
SELECT COUNT(*) AS n FROM cached_prs
WHERE rfc_slug = ? AND state = 'open' AND pr_kind = 'meta_body_edit'
""",
(slug,),
).fetchone()["n"]
if blocking > 0:
raise HTTPException(
409,
f"{blocking} open body-edit PR{'' if blocking == 1 else 's'} block graduation",
)
# Read the meta-repo entry once — we need the file's sha for the
# graduation PR's update_file call and the original body so the
# bot can seed RFC.md on the new repo with the migrated body.
# graduation PR's update_file call and the body to carry through
# unchanged (meta-only keeps the body in the entry, §13.3).
fetched = await gitea.read_file(
config.gitea_org, config.meta_repo, f"rfcs/{slug}.md", ref="main",
)
@@ -442,19 +338,17 @@ def make_router(
except Exception as e:
raise HTTPException(500, f"Meta entry malformed: {e}")
repo_full = f"{config.gitea_org}/{repo_name}"
arbiters = json.loads(rfc["arbiters_json"] or "[]") or owners[:1]
# Compose the graduated frontmatter — body stripped, graduation
# fields filled. The serializer is run now so the PR-open step
# has the contents pre-rendered (single source of truth for the
# body migration vs. the meta-entry update).
# Compose the graduated frontmatter — body KEPT, graduation fields
# filled, repo left null (§1). Serialized now so the PR-open step
# has the contents pre-rendered.
graduated_entry = entry_mod.Entry(
slug=slug,
title=super_draft_entry.title,
state="active",
id=rfc_id,
repo=repo_full,
repo=None,
proposed_by=super_draft_entry.proposed_by,
proposed_at=super_draft_entry.proposed_at,
graduated_at=entry_mod.today(),
@@ -462,37 +356,30 @@ def make_router(
owners=owners,
arbiters=arbiters,
tags=list(super_draft_entry.tags),
body="",
models=super_draft_entry.models,
funder=super_draft_entry.funder,
body=super_draft_entry.body,
)
graduated_contents = entry_mod.serialize(graduated_entry)
state = _new_active(
slug, rfc_id=rfc_id, repo_name=repo_name, repo_full=repo_full,
owners=owners, arbiters=arbiters,
slug, rfc_id=rfc_id, owners=owners, arbiters=arbiters,
)
# Audit: graduation started. The terminal `graduate_complete` /
# `graduate_rollback` rows below close the linkable sequence.
# `graduate_failed` rows below close the linkable sequence.
_audit(
viewer.user_id, viewer.gitea_login, "graduate_start",
rfc_slug=slug,
details={
"rfc_id": rfc_id, "repo": repo_full, "owners": owners,
"blocking_prs": blocking,
},
details={"rfc_id": rfc_id, "owners": owners},
)
# Test seam: `?_sync=1` awaits the orchestrator inline so
# integration tests can assert post-conditions without driving
# the SSE. Production clients use the spec-described shape —
# POST returns immediately, the client subscribes to the
# progress SSE.
# the SSE. Production clients POST then subscribe to the SSE.
coro = _orchestrate(
config=config, gitea=gitea, bot=bot,
actor=viewer.as_actor(), state=state,
super_draft_body=super_draft_entry.body,
super_draft_title=super_draft_entry.title,
super_draft_tags=list(super_draft_entry.tags),
graduated_contents=graduated_contents,
meta_file_sha=meta_sha,
)
@@ -505,38 +392,31 @@ def make_router(
"ok": True,
"slug": slug,
"rfc_id": rfc_id,
"repo": repo_full,
"stream_url": f"/api/rfcs/{slug}/graduate/progress",
"finished": state.finished,
"succeeded": state.succeeded,
}
# -------------------------------------------------------------------
# §13.3: GET /api/rfcs/<slug>/graduate/progress
# SSE stream of the step transitions. One event per step transition
# (pending → running → done / failed), plus the trailing rollback
# step's events if any earlier step fails.
# GET /api/rfcs/<slug>/graduate/progress
# SSE stream of the flip's step transitions (open_pr, merge_pr).
# -------------------------------------------------------------------
@router.get("/api/rfcs/{slug}/graduate/progress")
async def graduate_progress(slug: str, request: Request):
# v0.6.0 (item #4): the progress SSE surfaces admin-internal step
# detail (repo name, PR number, rollback steps) that isn't part of
# the v0.3.0 anonymous-read contract for catalog/RFC bodies. The
# corresponding POST /graduate is gated to RFC owners/arbiters and
# app admins/owners via `_can_graduate`; the read SSE shares that
# operator-visible surface, so it requires at least an
# authenticated viewer. We keep the floor at require_user (not
# require_contributor) so a write-muted operator can still observe
# the progress of a graduation they kicked off before being muted.
# The progress SSE surfaces admin-internal step detail (PR number)
# that isn't part of the anonymous-read contract. POST /graduate is
# gated to RFC owners/arbiters and app admins/owners; the read SSE
# shares that operator-visible surface and requires an authenticated
# viewer. We keep the floor at require_user (not require_contributor)
# so a write-muted operator can still observe a graduation they
# kicked off before being muted.
auth.require_user(request)
state = _get_active(slug)
if state is None:
raise HTTPException(404, "No graduation in flight for this slug")
async def event_stream():
# Emit the current snapshot first so a late subscriber sees
# the steps already completed.
yield _sse_event("snapshot", state.to_payload())
if state.finished:
yield _sse_event("done", state.to_payload())
@@ -555,22 +435,16 @@ def make_router(
# §13.1: POST /api/rfcs/<slug>/claim
# Opens a meta-repo PR adding the actor's gitea_login to the entry's
# owners list. Anyone signed in may claim — the merge is gated to
# owners/admins per §13.1 (which collapses to admins for unclaimed
# entries since `owners` is empty).
# owners/admins per §13.1.
# -------------------------------------------------------------------
@router.post("/api/rfcs/{slug}/claim")
async def claim_ownership(slug: str, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_super_draft(slug)
# Refuse if the actor is already in owners — no-op claim.
existing_owners = json.loads(rfc["owners_json"] or "[]")
if viewer.gitea_login in existing_owners:
return {"ok": True, "noop": True}
# Refuse if a claim PR for this actor is already open. The branch
# name `claim/<slug>` collides per actor implicitly since Gitea
# refuses duplicate branch creation; we surface a clean 409 here
# so the client doesn't see a 502.
already = db.conn().execute(
"""
SELECT pr_number FROM cached_prs
@@ -581,8 +455,6 @@ def make_router(
if already:
raise HTTPException(409, f"A claim PR is already open: #{already['pr_number']}")
# Compose the new entry contents — owners list with the claimant
# appended.
fetched = await gitea.read_file(
config.gitea_org, config.meta_repo, f"rfcs/{slug}.md", ref="main",
)
@@ -626,7 +498,7 @@ def make_router(
# ---------------------------------------------------------------------------
# Orchestrator
# Orchestrator — the §13.3 in-place flip
# ---------------------------------------------------------------------------
@@ -637,57 +509,21 @@ async def _orchestrate(
bot: Bot,
actor: Actor,
state: GraduationState,
super_draft_body: str,
super_draft_title: str,
super_draft_tags: list[str],
graduated_contents: str,
meta_file_sha: str,
) -> None:
"""Run §13.3 step by step. Each step:
"""Open the flip PR, then merge it. Two steps, no transaction:
- marks itself `running` and pushes an event
- calls the bot method (which writes to Gitea + audit log)
- marks itself `done` (or `failed`) and pushes another event
- open_pr fails nothing was created; the entry stays a super-draft.
- merge_pr fails close the open PR and delete its branch (the only
artifact a mid-flip failure can leave on the meta repo), then the
entry stays a super-draft.
On failure at step N, every later step is marked `not-reached` and
`_rollback` runs undoes in reverse from N-1 to 1.
There is no rollback of a *merged* flip once the meta-repo merge has
landed, the path forward is §3's `withdraw` (§13.5).
"""
try:
# ----- Step 1: create per-RFC repo -----
await _start(state, "create_repo", f"Creating `{state.repo_full}`…")
try:
await bot.create_rfc_repo_for_graduation(
actor, org=config.gitea_org, repo_name=state.repo_name,
slug=state.slug, title=super_draft_title,
)
except GiteaError as e:
await _fail(state, "create_repo", f"Gitea: {e.detail}")
await _rollback(config=config, gitea=gitea, bot=bot, actor=actor,
state=state, failed_at="create_repo")
return
await _done(state, "create_repo", state.repo_full)
# ----- Step 2: seed RFC.md, README.md, .rfc/metadata.yaml -----
await _start(state, "seed_files", "Writing initial commit on main…")
try:
await bot.seed_graduated_rfc(
actor,
org=config.gitea_org, repo_name=state.repo_name,
slug=state.slug, title=super_draft_title,
rfc_body=super_draft_body, rfc_id=state.rfc_id,
meta_full=config.meta_repo_full,
meta_path=f"rfcs/{state.slug}.md",
owners=state.owners, arbiters=state.arbiters,
tags=super_draft_tags,
)
except GiteaError as e:
await _fail(state, "seed_files", f"Gitea: {e.detail}")
await _rollback(config=config, gitea=gitea, bot=bot, actor=actor,
state=state, failed_at="seed_files")
return
await _done(state, "seed_files", "RFC.md, README.md, .rfc/metadata.yaml")
# ----- Step 3: open graduation PR -----
# ----- Step 1: open the graduation PR (flip frontmatter) -----
await _start(state, "open_pr", "Opening graduation PR…")
try:
pr = await bot.open_graduation_pr(
@@ -696,19 +532,18 @@ async def _orchestrate(
slug=state.slug,
new_file_contents=graduated_contents,
prior_sha=meta_file_sha,
rfc_id=state.rfc_id, repo_full=state.repo_full,
rfc_id=state.rfc_id,
owners=state.owners,
)
except GiteaError as e:
await _fail(state, "open_pr", f"Gitea: {e.detail}")
await _rollback(config=config, gitea=gitea, bot=bot, actor=actor,
state=state, failed_at="open_pr")
await _finish_failed(state, failed_at="open_pr", on_behalf_of=actor.gitea_login)
return
state.new_pr_number = pr["number"]
state.graduation_branch = pr["head"]["ref"]
await _done(state, "open_pr", f"PR #{state.new_pr_number}")
# ----- Step 4: merge the graduation PR -----
# ----- Step 2: merge the graduation PR -----
await _start(state, "merge_pr", f"Merging PR #{state.new_pr_number}")
try:
await bot.merge_graduation_pr(
@@ -720,35 +555,28 @@ async def _orchestrate(
)
except GiteaError as e:
await _fail(state, "merge_pr", f"Gitea: {e.detail}")
await _rollback(config=config, gitea=gitea, bot=bot, actor=actor,
state=state, failed_at="merge_pr")
await _cleanup_unmerged(config=config, bot=bot, actor=actor, state=state)
await _finish_failed(state, failed_at="merge_pr", on_behalf_of=actor.gitea_login)
return
await _done(state, "merge_pr", f"PR #{state.new_pr_number} merged")
# ----- Step 5: refresh the cache so the catalog flips immediately.
# Per §13.3 step 5 the webhook flow is the steady-state path, but
# we refresh inline so the dialog can transition to "graduation
# complete" with the catalog row already showing `active`. A
# cache-refresh failure does not unwind Git state — the
# reconciler will catch up per §4.1.
await _start(state, "refresh_cache", "Refreshing catalog and views…")
# Refresh the cache so the catalog flips immediately. The webhook
# flow is the steady-state path (§13.3); we refresh inline so the
# dialog can transition to "graduation complete" with the catalog
# row already showing `active`. A refresh failure does not unwind
# the merge — the reconciler catches up per §4.1.
try:
await cache.refresh_meta_repo(config, gitea)
await cache.refresh_meta_branches(config, gitea)
await cache.refresh_meta_pulls(config, gitea)
await cache.refresh_rfc_repo(config, gitea, state.slug)
except Exception as e:
log.warning("graduate refresh_cache failed for %s: %s", state.slug, e)
await _done(state, "refresh_cache", f"Cache will catch up via reconciler ({e})")
else:
await _done(state, "refresh_cache", "Catalog and main view updated")
log.warning("graduate cache refresh failed for %s: %s", state.slug, e)
# Terminal success row in the audit log.
_audit(
None, actor.gitea_login, "graduate_complete",
rfc_slug=state.slug,
details={
"rfc_id": state.rfc_id, "repo": state.repo_full,
"rfc_id": state.rfc_id,
"owners": state.owners, "pr_number": state.new_pr_number,
},
)
@@ -757,109 +585,38 @@ async def _orchestrate(
await state.queue.put({"event": "completed", "payload": state.to_payload()})
except Exception as e:
log.exception("graduate: unexpected error for %s", state.slug)
# Best-effort: mark the in-flight step failed, then roll back.
running = next((s for s in state.steps if s.status == "running"), None)
if running is not None:
await _fail(state, running.key, f"unexpected: {e}")
await _rollback(
config=config, gitea=gitea, bot=bot, actor=actor,
state=state, failed_at=running.key if running else "unknown",
await _finish_failed(
state, failed_at=running.key if running else "unknown",
on_behalf_of=actor.gitea_login,
)
finally:
# Push the sentinel so any open SSE handler returns.
await state.queue.put(None)
async def _rollback(
*,
config: Config, gitea: Gitea, bot: Bot, actor: Actor,
state: GraduationState, failed_at: str,
async def _cleanup_unmerged(
*, config: Config, bot: Bot, actor: Actor, state: GraduationState,
) -> None:
"""Run undoes in reverse order from the last completed step. Each
undo emits its own rollback-step event so the dialog can render the
cleanup as a visible step appended to the stack per §13.3."""
state.rollback_started = True
# Mark every step after the failed one as not-reached so the rendered
# stack is honest about what didn't run.
seen_failure = False
for s in state.steps:
if s.status == "failed":
seen_failure = True
continue
if seen_failure and s.status == "pending":
s.status = "not-reached"
# Walk completed steps in reverse and run their inverses.
for s in reversed(state.steps):
if s.status != "done":
continue
undo = _UNDO_BY_STEP.get(s.key)
if undo is None:
continue
rb = StepState(key=f"undo:{s.key}", label=f"Undo: {s.label}",
status="running", detail="")
state.rollback_steps.append(rb)
await state.queue.put({"event": "rollback_step", "payload": state.to_payload()})
try:
detail = await undo(
config=config, gitea=gitea, bot=bot, actor=actor, state=state,
)
except Exception as e:
rb.status = "failed"
rb.detail = f"{e}"
await state.queue.put({"event": "rollback_step", "payload": state.to_payload()})
continue
rb.status = "done"
rb.detail = detail or ""
await state.queue.put({"event": "rollback_step", "payload": state.to_payload()})
_audit(
None, actor.gitea_login, "graduate_rollback",
rfc_slug=state.slug,
details={
"failed_at": failed_at,
"error": state.error,
"rfc_id": state.rfc_id,
"repo": state.repo_full,
"undone": [s.key for s in state.rollback_steps if s.status == "done"],
},
)
state.finished = True
state.succeeded = False
await state.queue.put({"event": "rolled_back", "payload": state.to_payload()})
async def _undo_create_repo(*, config, gitea, bot, actor, state) -> str:
await bot.delete_rfc_repo(
actor, org=config.gitea_org, repo_name=state.repo_name,
slug=state.slug, reason="graduation rollback",
)
return f"Deleted `{state.repo_full}`"
async def _undo_seed_files(*, config, gitea, bot, actor, state) -> str:
# The seed commits live inside the per-RFC repo created in step 1;
# deleting the repo (step 1's undo) reclaims them at the same time.
# We surface a separate rollback step here so the rendered stack
# mirrors the forward steps, but the work is folded into _undo_create_repo.
return "Folded into repo deletion"
async def _undo_open_pr(*, config, gitea, bot, actor, state) -> str:
"""A merge failure leaves the flip PR open on its `graduate-<slug>-<hex>`
branch. Close the PR and delete the branch so failed attempts don't
accumulate on the meta repo. Best-effort failures here are logged,
not surfaced as a separate step (the entry already stays a super-draft).
"""
if state.new_pr_number is None:
return "No PR opened"
await bot.close_graduation_pr(
actor,
org=config.gitea_org, meta_repo=config.meta_repo,
pr_number=state.new_pr_number,
head_branch=state.graduation_branch or "",
slug=state.slug, reason="graduation rollback",
)
# Per the §19.2 "graduation rollback's branch cleanup" candidate
# that Slice 8 settles: delete the dash-suffixed branch on rollback
# so failed-graduation branches don't accumulate on the meta repo.
# The §12 hygiene sweep would catch this eventually, but closing
# the loop here removes the chance of pile-up across retries.
return
try:
await bot.close_graduation_pr(
actor,
org=config.gitea_org, meta_repo=config.meta_repo,
pr_number=state.new_pr_number,
head_branch=state.graduation_branch or "",
slug=state.slug, reason="graduation merge failed",
)
except Exception:
log.exception("graduate cleanup: close PR #%s failed", state.new_pr_number)
branch_name = state.graduation_branch or ""
if branch_name:
try:
@@ -870,24 +627,35 @@ async def _undo_open_pr(*, config, gitea, bot, actor, state) -> str:
branch=branch_name,
slug=state.slug,
action_kind="delete_post_merge_branch",
reason="graduation rollback",
reason="graduation merge failed",
)
except Exception:
log.exception("rollback: delete_branch failed for %s", branch_name)
return f"Closed PR #{state.new_pr_number}"
log.exception("graduate cleanup: delete_branch %s failed", branch_name)
# merge_pr's undo is intentionally absent — once the meta-repo merge has
# landed, graduation is irreversible per §13.5. If we ever reach a merged
# state and a later step fails (which can't happen — refresh_cache failures
# fold into success), there is no clean undo path; the user transitions
# via §3's `withdraw` instead.
_UNDO_BY_STEP = {
"create_repo": _undo_create_repo,
"seed_files": _undo_seed_files,
"open_pr": _undo_open_pr,
}
async def _finish_failed(state: GraduationState, *, failed_at: str, on_behalf_of: str) -> None:
"""Mark any step after the failure as not-reached, write the audit
row, and emit the terminal failed event."""
seen_failure = False
for s in state.steps:
if s.status == "failed":
seen_failure = True
continue
if seen_failure and s.status == "pending":
s.status = "not-reached"
_audit(
None, on_behalf_of, "graduate_failed",
rfc_slug=state.slug,
details={
"failed_at": failed_at,
"error": state.error,
"rfc_id": state.rfc_id,
"pr_number": state.new_pr_number,
},
)
state.finished = True
state.succeeded = False
await state.queue.put({"event": "failed", "payload": state.to_payload()})
# ---------------------------------------------------------------------------
@@ -907,7 +675,7 @@ def _can_graduate(rfc, viewer) -> bool:
def _audit(
actor_user_id: int | None,
on_behalf_of: str,
on_behalf_of: str | None,
action_kind: str,
*,
rfc_slug: str | None = None,
@@ -918,7 +686,7 @@ def _audit(
"""Direct audit-log write for graduation lifecycle events that don't
correspond to a single Gitea write. The per-step Gitea writes log
themselves via the bot's `_log`; this is for the bracketing
`graduate_start` / `graduate_complete` / `graduate_rollback` rows."""
`graduate_start` / `graduate_complete` / `graduate_failed` rows."""
db.conn().execute(
"""
INSERT INTO actions
@@ -935,8 +703,7 @@ def _audit(
json.dumps(details) if details else None,
),
)
# §15 chokepoint per Slice 6: the bracket rows (graduate_start,
# graduate_complete) drive their own notifications per §15.1.
# §15 chokepoint: the bracket rows drive their own notifications.
from . import notify
notify.fan_out_from_action(
actor_user_id=actor_user_id,
+593
View File
@@ -0,0 +1,593 @@
"""v0.16.0 / §6 / §10 — owner-only invite for per-RFC PR or PR-less
discussion (roadmap item #12).
The RFC's owner can invite a specific email to one of two per-RFC roles:
* `contributor` may open PRs against this RFC AND post in its
discussion (PR-permission strictly includes discussion-permission).
* `discussant` may post in this RFC's PR-less discussion only.
Non-invited users keep the v0.6.0 anonymous-read contract: they can
read but cannot write/discuss the RFC. Reads are not narrowed by
this item.
Endpoints:
* `POST /api/rfcs/{slug}/invitations` owner: create + email
* `GET /api/rfcs/{slug}/invitations` owner: list pending/accepted
* `POST /api/rfcs/{slug}/invitations/{id}/revoke` owner: revoke
* `GET /api/invitations/accept` token lookup (signed-in user)
* `POST /api/invitations/accept` token redeem (signed-in user)
The accept endpoints are deliberately platform-scoped (not nested under
the RFC slug) because the user clicking the email link only has the
token and may not even know the slug yet. The GET shape lets the
frontend show a confirmation page ("RFC <X> invited you to be a
<role> accept?") before the POST commits the membership.
Permission gates (composed with `require_contributor`):
* Issue / list / revoke: `auth.can_invite_to_rfc` RFC owner or
platform admin/owner.
* Accept: any platform-granted signed-in user; the gate is the
token, not the role. The token also constrains which email the
accept lands under the accepting user's email must match the
invitation's invitee_email (case-insensitive). This prevents an
invited-but-not-the-account-holder situation from minting a
collaborator row under the wrong identity.
Email shape: a single plain-text body sent via the existing SMTP path
(reuses `EmailConfig.from_env()` like `email_otc.py` does). No
unsubscribe footer the email is transactional and per-invite, not a
recurring notification. No tracking pixel.
Admin-page hook: when an accept lands and the user's
`permission_state` is still `pending`, that signals to the admin's
`/admin/users` queue that the user is here because they accepted a
per-RFC invitation informing (not deciding) the admin's
platform-grant call. v0.16.0 surfaces this via additive columns on
the existing `GET /api/admin/users` listing (see `api_admin.py`'s
diff in the same release) no new endpoint, no restructure.
"""
from __future__ import annotations
import logging
import secrets
import smtplib
from email.message import EmailMessage
from email.utils import formataddr
from typing import Any
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from . import auth, db
from .email import EmailConfig, _SENT
log = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Pydantic bodies
# ---------------------------------------------------------------------------
class CreateInvitationBody(BaseModel):
"""The owner picks an email and a role-in-RFC. No custom-message
field that belongs to item #16's platform-level invite surface,
not here.
We validate the email with a deliberately narrow pattern rather
than `pydantic.EmailStr` to avoid pulling in `email-validator` as
a dependency (and v0.7.0's OTC body does the same — see
`OTCRequestBody`'s shape). The validation here is intentionally
permissive: a local-part, an `@`, and a domain part with no
whitespace. Operator-side typo catching is the job of the email
transport; the framework only guards against obviously malformed
input."""
invitee_email: str = Field(min_length=3, max_length=320,
pattern=r"^[^\s@]+@[^\s@]+$")
role_in_rfc: str = Field(pattern="^(contributor|discussant)$")
class AcceptInvitationBody(BaseModel):
token: str = Field(min_length=1, max_length=200)
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
# 30-day TTL matches the device-trust window the framework already
# ships (v0.11.0). A pending invitation past this is rejected at the
# accept endpoint regardless of the row's `status` column.
INVITATION_TTL_DAYS = 30
# ---------------------------------------------------------------------------
# Router
# ---------------------------------------------------------------------------
def make_router() -> APIRouter:
router = APIRouter()
# ---------------------------------------------------------------
# POST /api/rfcs/<slug>/invitations
# The owner creates an invitation. The endpoint mints the token,
# writes the row, and dispatches the email synchronously. A failure
# to send the email does NOT roll back the row — the owner can
# share the link directly out-of-band if SMTP is briefly down (the
# `GET /api/rfcs/<slug>/invitations` response carries the token
# for that fallback).
# ---------------------------------------------------------------
@router.post("/api/rfcs/{slug}/invitations")
async def create_invitation(slug: str, body: CreateInvitationBody, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_rfc(slug)
if not auth.can_invite_to_rfc(viewer, slug):
raise HTTPException(
403,
"Only the RFC's owner can invite collaborators",
)
return issue_invitation(
slug=slug,
inviter_user_id=viewer.user_id,
inviter_display=viewer.display_name or viewer.gitea_login or "An RFC owner",
invitee_email=body.invitee_email,
role_in_rfc=body.role_in_rfc,
rfc_title=rfc["title"],
)
# ---------------------------------------------------------------
# GET /api/rfcs/<slug>/invitations
# The owner's listing of every invitation on the RFC, regardless
# of status. Carries the token (for the resend / re-share path).
# ---------------------------------------------------------------
@router.get("/api/rfcs/{slug}/invitations")
async def list_invitations(slug: str, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
_require_rfc(slug)
if not auth.can_invite_to_rfc(viewer, slug):
raise HTTPException(
403,
"Only the RFC's owner can view invitations",
)
rows = db.conn().execute(
"""
SELECT i.id, i.invitee_email, i.role_in_rfc, i.status, i.token,
i.expires_at, i.created_at, i.accepted_at,
i.inviter_user_id, i.accepted_by_user_id,
u_inviter.display_name AS inviter_display,
u_inviter.gitea_login AS inviter_login,
u_accept.display_name AS accepted_by_display,
u_accept.gitea_login AS accepted_by_login
FROM rfc_invitations i
LEFT JOIN users u_inviter ON u_inviter.id = i.inviter_user_id
LEFT JOIN users u_accept ON u_accept.id = i.accepted_by_user_id
WHERE i.rfc_slug = ?
ORDER BY i.id DESC
""",
(slug,),
).fetchall()
return {
"items": [
{
"id": r["id"],
"invitee_email": r["invitee_email"],
"role_in_rfc": r["role_in_rfc"],
"status": _effective_status(r),
"token": r["token"],
"expires_at": r["expires_at"],
"created_at": r["created_at"],
"accepted_at": r["accepted_at"],
"inviter_display": r["inviter_display"],
"inviter_login": r["inviter_login"],
"accepted_by_display": r["accepted_by_display"],
"accepted_by_login": r["accepted_by_login"],
}
for r in rows
],
}
# ---------------------------------------------------------------
# POST /api/rfcs/<slug>/invitations/<id>/revoke
# Revokes a pending invitation. Already-accepted invitations
# cannot be "revoked" from this surface — the corresponding
# collaborator-removal surface is a §19.2 candidate; v0.16.0
# only lifts the *pending* link.
# ---------------------------------------------------------------
@router.post("/api/rfcs/{slug}/invitations/{invitation_id}/revoke")
async def revoke_invitation(slug: str, invitation_id: int, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
_require_rfc(slug)
if not auth.can_invite_to_rfc(viewer, slug):
raise HTTPException(
403,
"Only the RFC's owner can revoke invitations",
)
row = db.conn().execute(
"SELECT id, status FROM rfc_invitations WHERE id = ? AND rfc_slug = ?",
(invitation_id, slug),
).fetchone()
if row is None:
raise HTTPException(404, "Invitation not found")
if row["status"] != "pending":
raise HTTPException(
409,
f"Invitation is {row['status']}; only pending invitations can be revoked",
)
db.conn().execute(
"UPDATE rfc_invitations SET status = 'revoked' WHERE id = ?",
(invitation_id,),
)
return {"ok": True, "id": invitation_id, "status": "revoked"}
# ---------------------------------------------------------------
# GET /api/invitations/accept?token=...
# Lookup-only — returns what the invitation grants so the
# frontend can render a confirmation page before the POST. The
# token is required; no token, no peek.
# ---------------------------------------------------------------
@router.get("/api/invitations/accept")
async def preview_invitation(token: str, request: Request) -> dict[str, Any]:
viewer = auth.require_user(request)
row = _lookup_invitation_by_token(token)
if row is None:
raise HTTPException(404, "Invitation not found")
effective = _effective_status(row)
rfc = db.conn().execute(
"SELECT slug, title FROM cached_rfcs WHERE slug = ?", (row["rfc_slug"],),
).fetchone()
return {
"rfc_slug": row["rfc_slug"],
"rfc_title": rfc["title"] if rfc else row["rfc_slug"],
"role_in_rfc": row["role_in_rfc"],
"status": effective,
"invitee_email": row["invitee_email"],
"email_matches_you": (viewer.email or "").strip().lower()
== row["invitee_email"].strip().lower(),
"expires_at": row["expires_at"],
}
# ---------------------------------------------------------------
# POST /api/invitations/accept
# The accept gesture: token → collaborator row.
#
# Requires:
# * an authenticated user (no token-only acceptance — we want
# the per-user audit trail),
# * a valid (pending, non-expired, non-revoked) invitation,
# * the accepting user's email matches invitee_email
# (case-insensitive).
#
# On success the row's status flips to 'accepted' and a
# rfc_collaborators row is inserted (or upgraded if the user
# already had a lower role). Idempotent: re-accepting the same
# already-accepted invitation reads as a 200 no-op with
# `changed=false`.
# ---------------------------------------------------------------
@router.post("/api/invitations/accept")
async def accept_invitation(body: AcceptInvitationBody, request: Request) -> dict[str, Any]:
viewer = auth.require_user(request)
row = _lookup_invitation_by_token(body.token)
if row is None:
raise HTTPException(404, "Invitation not found")
effective = _effective_status(row)
if effective == "revoked":
raise HTTPException(409, "Invitation was revoked")
if effective == "expired":
raise HTTPException(409, "Invitation has expired")
# Email match — case-insensitive. Empty viewer email cannot
# accept (an OAuth-only user with no captured email shape).
viewer_email = (viewer.email or "").strip().lower()
invitee_email = row["invitee_email"].strip().lower()
if not viewer_email or viewer_email != invitee_email:
raise HTTPException(
403,
"This invitation was sent to a different email; sign in with that address",
)
if effective == "accepted":
# Idempotent re-accept — surface the existing collaborator
# row without writing anything new.
collab = db.conn().execute(
"SELECT role_in_rfc FROM rfc_collaborators WHERE rfc_slug = ? AND user_id = ?",
(row["rfc_slug"], viewer.user_id),
).fetchone()
return {
"ok": True,
"changed": False,
"rfc_slug": row["rfc_slug"],
"role_in_rfc": collab["role_in_rfc"] if collab else row["role_in_rfc"],
}
# First-time accept. Flip the invitation; upsert the
# collaborator. We do the upsert with ON CONFLICT so a
# user who already held a lower role gets upgraded, never
# downgraded (the MAX-style precedence is contributor >
# discussant; lower roles never overwrite higher).
with db.tx() as c:
c.execute(
"""
UPDATE rfc_invitations
SET status = 'accepted',
accepted_at = datetime('now'),
accepted_by_user_id = ?
WHERE id = ?
""",
(viewer.user_id, row["id"]),
)
existing = c.execute(
"SELECT role_in_rfc FROM rfc_collaborators WHERE rfc_slug = ? AND user_id = ?",
(row["rfc_slug"], viewer.user_id),
).fetchone()
target_role = _max_role(
existing["role_in_rfc"] if existing else None,
row["role_in_rfc"],
)
if existing is None:
c.execute(
"""
INSERT INTO rfc_collaborators
(rfc_slug, user_id, role_in_rfc, invitation_id)
VALUES (?, ?, ?, ?)
""",
(row["rfc_slug"], viewer.user_id, target_role, row["id"]),
)
elif existing["role_in_rfc"] != target_role:
c.execute(
"""
UPDATE rfc_collaborators
SET role_in_rfc = ?, invitation_id = ?
WHERE rfc_slug = ? AND user_id = ?
""",
(target_role, row["id"], row["rfc_slug"], viewer.user_id),
)
return {
"ok": True,
"changed": True,
"rfc_slug": row["rfc_slug"],
"role_in_rfc": target_role,
}
return router
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _require_rfc(slug: str):
"""The invitation surface only operates on a known, non-withdrawn
RFC. We refuse 404 on unknown and 409 on withdrawn mirrors the
discussion endpoints' `_require_rfc_readable` shape."""
row = db.conn().execute(
"SELECT slug, title, state FROM cached_rfcs WHERE slug = ?", (slug,),
).fetchone()
if row is None:
raise HTTPException(404, "RFC not found")
if row["state"] == "withdrawn":
raise HTTPException(409, "RFC is withdrawn")
return row
def _lookup_invitation_by_token(token: str):
return db.conn().execute(
"""
SELECT id, rfc_slug, inviter_user_id, invitee_email, role_in_rfc,
status, token, expires_at, created_at, accepted_at,
accepted_by_user_id
FROM rfc_invitations
WHERE token = ?
""",
(token,),
).fetchone()
def _effective_status(row) -> str:
"""The row's column status is the authoritative truth except for
`expired` that is derived from `expires_at` at read time so an
unattended cron isn't required to flip rows. A revoked-then-
expired row reads as `revoked` (the explicit gesture wins)."""
column_status = row["status"]
if column_status != "pending":
return column_status
# Compare via SQL so the comparison is in sqlite-time, matching the
# `datetime('now')` insert. A simpler same-process comparison would
# work too, but routing through the DB keeps the timezone handling
# consistent with the inserts.
is_past = db.conn().execute(
"SELECT datetime(?) <= datetime('now') AS past",
(row["expires_at"],),
).fetchone()["past"]
return "expired" if is_past else "pending"
def issue_invitation(
*,
slug: str,
inviter_user_id: int,
inviter_display: str,
invitee_email: str,
role_in_rfc: str,
rfc_title: str,
) -> dict:
"""Mint + persist + email one ``rfc_invitations`` row.
The single chokepoint for issuing an invitation: the owner's manual
`POST /api/rfcs/{slug}/invitations` endpoint and roadmap #28 Part 3's
accept path both route through here, so the dup-guard, token mint,
insert, and transactional email stay identical.
Refuses (409) re-inviting an email that already has a pending
invitation on this RFC at the same role. A different-role re-invite is
allowed (the discussant contributor upgrade) the new row
supersedes the old in the listing's natural ordering, and acceptance
of either picks up the corresponding role.
Returns the new row's dict (including the raw token, for the owner's
out-of-band share / the caller's record-keeping). A send failure logs
and returns; the row stays so the owner can recover via the listing.
"""
invitee_email = invitee_email.strip()
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, inviter_user_id, invitee_email, role_in_rfc, token, f"+{INVITATION_TTL_DAYS} days"),
)
invitation_id = cur.lastrowid
_send_invitation_email(
to_address=invitee_email,
inviter_display=inviter_display,
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,
}
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
+135 -14
View File
@@ -73,6 +73,13 @@ class MarkReadBody(BaseModel):
class BounceBody(BaseModel):
email: str = Field(min_length=3, max_length=320)
kind: str = Field(default="hard") # 'hard' or 'complaint'
# v0.18.0 Slice 5: when the bounce provider includes the
# original Message-ID, the framework correlates it back to
# the matching `outbound_emails` row and stamps
# `status='bounced'`. Optional — providers that don't surface
# the Message-ID still flip the global opt-out via the email
# match, but lose the per-message attribution.
message_id: str | None = Field(default=None, max_length=1000)
class CookieConsentBody(BaseModel):
@@ -443,6 +450,40 @@ def make_router(config: Config) -> APIRouter:
# ----- Email: one-click unsubscribe + bounce webhook -----
# v0.18.0: the category → column map. The `all` synthetic
# category lands the bundle's one-click on the global opt-out
# flag (per `email._send_bundle` in v0.18.0 Slice 2 — a bundle
# spans multiple categories, so a per-category flip wouldn't
# honor the user's intent).
_CATEGORY_COLUMN: dict[str, str] = {
"personal-direct": "email_personal_direct",
"structural": "email_watched_structural",
"admin-actionable": "email_admin_actionable",
"all": "email_opt_out_all",
}
def _apply_unsubscribe(user_id: int, category: str) -> bool:
"""Flip the matching column. Returns True on success, False
if the category is unknown. Idempotent running twice on
the same (user, category) is harmless (it sets the column
to its current value)."""
column = _CATEGORY_COLUMN.get(category)
if column is None:
return False
# `all` sets the flag to 1 (opt out); per-category sets to 0
# (turn that category off). The column semantic is "1 means
# don't send"; the per-category booleans are "1 means do
# send". Different polarities, hence the case split.
if category == "all":
db.conn().execute(
f"UPDATE users SET {column} = 1 WHERE id = ?", (user_id,)
)
else:
db.conn().execute(
f"UPDATE users SET {column} = 0 WHERE id = ?", (user_id,)
)
return True
@router.get("/api/email/unsubscribe")
async def email_unsubscribe(t: str = Query(..., description="Signed token from the email footer")) -> HTMLResponse:
try:
@@ -453,20 +494,51 @@ def make_router(config: Config) -> APIRouter:
"<p>Open the app to manage your notification preferences directly.</p>",
status_code=400,
)
column = {
"personal-direct": "email_personal_direct",
"structural": "email_watched_structural",
"admin-actionable": "email_admin_actionable",
}.get(category)
if column is None:
if not _apply_unsubscribe(user_id, category):
return HTMLResponse(
f"<h1>Unknown category</h1><p>{category}</p>", status_code=400
)
db.conn().execute(f"UPDATE users SET {column} = 0 WHERE id = ?", (user_id,))
return HTMLResponse(
f"<h1>Unsubscribed</h1><p>You will no longer receive {category} emails. "
f"You can re-enable them in your notification preferences.</p>"
)
if category == "all":
body = (
"<h1>Unsubscribed</h1><p>You will no longer receive any email "
"from this app. You can re-enable individual categories from "
"your notification preferences after signing in.</p>"
)
else:
body = (
f"<h1>Unsubscribed</h1><p>You will no longer receive {category} emails. "
f"You can re-enable them in your notification preferences.</p>"
)
return HTMLResponse(body)
@router.post("/api/email/unsubscribe")
async def email_unsubscribe_post(
request: Request,
t: str = Query(..., description="Signed token from the List-Unsubscribe header"),
) -> dict[str, Any]:
"""v0.18.0: RFC 8058 one-click endpoint.
Gmail and Yahoo POST `List-Unsubscribe=One-Click` (as a
form-encoded body) to the URL in the `List-Unsubscribe`
header when the user clicks their MUA's "Unsubscribe"
button. The endpoint MUST accept POST (per the
`List-Unsubscribe-Post` header we advertise) and MUST be
idempotent.
The body content is checked loosely RFC 8058 says it
SHOULD be exactly `List-Unsubscribe=One-Click`, but some
intermediaries strip / re-encode the body, so the
framework accepts any POST to the URL once the token
verifies. The bar is that the token signature carries the
authority; the body is hint-only.
"""
try:
user_id, category = email_mod.verify_unsubscribe_token(t)
except BadSignature:
raise HTTPException(400, "Invalid or expired token")
if not _apply_unsubscribe(user_id, category):
raise HTTPException(400, f"Unknown category: {category}")
return {"ok": True, "category": category}
@router.post("/api/webhooks/email-bounce")
async def email_bounce(body: BounceBody, request: Request) -> dict[str, Any]:
@@ -485,21 +557,70 @@ def make_router(config: Config) -> APIRouter:
# stays unauthenticated for dev (the v1 contract).
import os as _os
expected = _os.environ.get("WEBHOOK_EMAIL_BOUNCE_SECRET", "").strip()
if expected:
# v0.25.0 (audit 0026 M5): fail closed. An unset secret used to
# leave this endpoint fully unauthenticated — anyone could suppress
# any user's mail by POSTing their address (email_opt_out_all flip
# below). Now an unset secret DISABLES the endpoint (503) instead
# of opening it. A dev that genuinely wants it open opts in
# explicitly with RFC_APP_INSECURE_BOUNCE_WEBHOOK=1, mirroring the
# RFC_APP_INSECURE_WEBHOOKS dev-bypass on the Gitea hook.
if not expected:
if _os.environ.get("RFC_APP_INSECURE_BOUNCE_WEBHOOK", "").strip() == "1":
log.warning(
"email-bounce webhook running UNAUTHENTICATED "
"(RFC_APP_INSECURE_BOUNCE_WEBHOOK=1) — never set this in production"
)
else:
log.error(
"email-bounce webhook refused: WEBHOOK_EMAIL_BOUNCE_SECRET is unset "
"(set the secret to enable, or RFC_APP_INSECURE_BOUNCE_WEBHOOK=1 for dev)"
)
raise HTTPException(503, "Bounce webhook not configured")
else:
received = request.headers.get("X-Webhook-Secret", "")
import hmac as _hmac
if not received or not _hmac.compare_digest(expected, received):
raise HTTPException(401, "Invalid webhook signature")
# v0.18.0 Slice 5: correlate the bounce back to the
# matching outbound_emails row if the provider supplied
# the Message-ID. The hard-bounce -> global-opt-out
# logic below still fires regardless; this is an
# additional audit signal.
correlated_row_id: int | None = None
if body.message_id:
correlated = db.conn().execute(
"SELECT id FROM outbound_emails WHERE message_id = ?",
(body.message_id,),
).fetchone()
if correlated is not None:
correlated_row_id = correlated["id"]
db.conn().execute(
"UPDATE outbound_emails SET status = 'bounced', "
"error = COALESCE(error, '') || ? WHERE id = ?",
(f"bounce ({body.kind})", correlated_row_id),
)
log.info(
"email-bounce: correlated message_id=%s -> outbound_emails.id=%s",
body.message_id, correlated_row_id,
)
else:
log.info(
"email-bounce: message_id=%s did not match any "
"outbound_emails row (provider may be replaying an old bounce, "
"or the row was pruned)",
body.message_id,
)
row = db.conn().execute(
"SELECT id FROM users WHERE LOWER(email) = LOWER(?)", (body.email,),
).fetchone()
if row is None:
return {"ok": True, "matched": False}
return {"ok": True, "matched": False, "correlated_id": correlated_row_id}
db.conn().execute(
"UPDATE users SET email_opt_out_all = 1 WHERE id = ?", (row["id"],),
)
log.info("email-bounce: opted out user %s (%s)", row["id"], body.kind)
return {"ok": True, "matched": True}
return {"ok": True, "matched": True, "correlated_id": correlated_row_id}
return router
+78 -12
View File
@@ -23,7 +23,7 @@ from typing import Any
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from . import auth, cache, chat as chat_layer, db, entry as entry_mod, funder, models_resolver
from . import auth, cache, chat as chat_layer, db, entry as entry_mod, funder, models_resolver, rfc_links
from .bot import Bot
from .config import Config
from .gitea import Gitea, GiteaError
@@ -42,6 +42,11 @@ RFC_FILE_PATH = "RFC.md"
class OpenPRBody(BaseModel):
title: str = Field(min_length=1, max_length=240)
description: str = Field(max_length=8000)
# Roadmap #26: optional "What will you be using this change for?" —
# the concrete ground-truth use case sibling to the required
# "why is this change needed" (the `description`). Optional, generous
# cap matching the description bound.
proposed_use_case: str | None = Field(default=None, max_length=8000)
class PRDescriptionBody(BaseModel):
@@ -112,6 +117,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")
@@ -162,6 +178,26 @@ def make_router(
raise HTTPException(502, f"Gitea: {e.detail}")
await _refresh_after_pr_write(rfc)
# Roadmap #26: persist the optional use case to the canonical,
# reconcile-proof side table keyed by the PR number. Blank/omitted
# writes no row (absence == "left blank"). The mirror onto
# cached_prs keeps the cache column in parity.
use_case = (body.proposed_use_case or "").strip()
if use_case:
db.conn().execute(
"""
INSERT INTO proposed_use_cases (scope, rfc_slug, pr_number, use_case)
VALUES ('pr', ?, ?, ?)
ON CONFLICT(scope, pr_number) DO UPDATE SET use_case = excluded.use_case
""",
(slug, pr["number"], use_case),
)
db.conn().execute(
"UPDATE cached_prs SET proposed_use_case = ? WHERE rfc_slug = ? AND pr_number = ?",
(use_case, slug, pr["number"]),
)
return {"pr_number": pr["number"], "slug": slug, "branch": branch}
# -------------------------------------------------------------------
@@ -177,6 +213,12 @@ def make_router(
path = _file_path_for(rfc)
head_branch = pr_row["head_branch"]
# Roadmap #28 Part 1: build the RFC auto-link index once for this
# PR view (read-time enrichment against the live accepted-RFC
# corpus; see rfc_links.py). exclude_slug suppresses self-links to
# this RFC inside its own PR.
link_index = rfc_links.build_index(db.conn(), exclude_slug=slug)
# §11.3: PRs are always public; no visibility check.
main_fetched = await gitea.read_file(owner, repo, path, ref="main")
main_body = _extract_body(rfc, (main_fetched or ("", ""))[0])
@@ -223,6 +265,13 @@ def make_router(
for r in msg_rows:
messages_by_thread.setdefault(r["thread_id"], []).append(_serialize_message(r))
# Roadmap #28 Part 1: enrich every comment with RFC auto-link
# segments (read-time; see rfc_links.py). The description is
# enriched alongside it in the return dict below.
for _msgs in messages_by_thread.values():
for _m in _msgs:
_m["text_segments"] = link_index.segment(_m["text"])
# Per-user seen cursor per §10.3. Anonymous viewers get no
# cursor — they always see "everything new" but cannot advance
# the cursor (no row to write to).
@@ -289,6 +338,8 @@ def make_router(
"pr_number": pr_number,
"title": pr_row["title"],
"description": pr_row["description"],
"description_segments": link_index.segment(pr_row["description"]),
"proposed_use_case": _pr_use_case(pr_number),
"state": pr_row["state"],
"opened_by": pr_row["opened_by"],
"opened_at": pr_row["opened_at"],
@@ -552,7 +603,7 @@ def make_router(
repo=repo,
slug=slug,
file_path=_file_path_for(rfc),
is_super_draft=_is_super_draft(rfc),
is_super_draft=_is_meta_resident(rfc),
original_branch=original_branch,
resolution_branch=resolution_branch,
)
@@ -620,32 +671,36 @@ def make_router(
"""Used by the §10 PR-flow read and write paths. Per §17's routing-
collapse rule, a super-draft RFC also routes here its body-edit
PRs are meta-repo PRs with pr_kind='meta_body_edit', but the API
surface is identical."""
surface is identical. Under the meta-only topology (§1) an active
RFC is meta-resident too (repo is null) that is normal, not an
error, so there is no per-RFC-repo precondition."""
row = _require_rfc(slug)
if row["state"] not in ("active", "super-draft"):
raise HTTPException(409, f"RFC is {row['state']}")
if row["state"] == "active" and not row["repo"]:
raise HTTPException(409, "RFC has no repo")
return row
def _is_super_draft(rfc) -> bool:
return rfc["state"] == "super-draft"
def _is_meta_resident(rfc) -> bool:
"""Meta-only topology (§1): an entry lives in the meta repo's
`rfcs/<slug>.md` (super-draft or active-in-place) unless it carries
a legacy per-RFC `repo:` which nothing does after the RFC-0001
fold-back (§13.6). Drives the body/path/repo dispatch below."""
return not rfc["repo"]
def _owner_repo(rfc) -> tuple[str, str]:
if _is_super_draft(rfc):
if _is_meta_resident(rfc):
return config.gitea_org, config.meta_repo
owner, repo = rfc["repo"].split("/", 1)
return owner, repo
def _file_path_for(rfc) -> str:
if _is_super_draft(rfc):
if _is_meta_resident(rfc):
return f"rfcs/{rfc['slug']}.md"
return RFC_FILE_PATH
def _extract_body(rfc, file_contents: str) -> str:
"""For super-draft entries the file on disk is the full
"""For meta-resident entries the file on disk is the full
frontmatter+body envelope; the editable body is entry.body."""
if not _is_super_draft(rfc):
if not _is_meta_resident(rfc):
return file_contents
try:
entry = entry_mod.parse(file_contents)
@@ -709,7 +764,7 @@ def make_router(
return row["original_pr_number"] if row else None
async def _refresh_after_pr_write(rfc) -> None:
if _is_super_draft(rfc):
if _is_meta_resident(rfc):
await cache.refresh_meta_repo(config, gitea)
await cache.refresh_meta_branches(config, gitea)
await cache.refresh_meta_pulls(config, gitea)
@@ -751,6 +806,17 @@ def _can_edit_pr_text(rfc, pr_row, viewer) -> bool:
return _can_withdraw(rfc, pr_row, viewer)
def _pr_use_case(pr_number: int) -> str | None:
"""Roadmap #26: the optional propose-PR use case from the canonical
side table, or None when the change was opened without one ("left
blank")."""
row = db.conn().execute(
"SELECT use_case FROM proposed_use_cases WHERE scope = 'pr' AND pr_number = ?",
(pr_number,),
).fetchone()
return row["use_case"] if row else None
def _pr_capabilities(rfc, pr_row, viewer) -> dict:
return {
"can_merge": _can_merge(rfc, viewer) and pr_row["state"] == "open",
+154
View File
@@ -290,5 +290,159 @@ def require_admin(request: Request) -> SessionUser:
return user
# v0.16.0 (roadmap item #12): per-RFC membership helpers.
#
# These don't replace `require_contributor` — they layer on top of it for
# endpoints that an RFC's owner can selectively open up. The "discussion"
# and "PR" write surfaces consult `is_rfc_writer(...)` / `is_rfc_discussant(...)`
# to admit users who are either platform-privileged (admin, RFC owner)
# OR who hold an explicit invitation-accepted per-RFC role.
#
# The platform gate still fires first: a user whose
# `permission_state != 'granted'` cannot write anywhere, invitation or
# not. v0.16.0 doesn't loosen that — a per-RFC invitation is additive
# *within* the granted-platform-user population. (Accepting an
# invitation as a pending user surfaces in the admin-page hook per
# the roadmap text; the platform grant remains the admin's decision.)
def _rfc_owners_set(rfc_slug: str) -> set[str]:
"""The gitea_logins named in the RFC's frontmatter owners array.
Read from `cached_rfcs.owners_json`. Returns an empty set if the RFC
isn't cached (the caller's earlier `_require_rfc_readable` will
already have rejected that case in practice).
"""
import json as _json
row = db.conn().execute(
"SELECT owners_json FROM cached_rfcs WHERE slug = ?", (rfc_slug,),
).fetchone()
if row is None:
return set()
try:
return set(_json.loads(row["owners_json"] or "[]"))
except Exception:
return set()
def is_rfc_owner(user: SessionUser | None, rfc_slug: str) -> bool:
"""True iff the user is named in the RFC's frontmatter `owners`
list. The platform-level admin/owner check is separate; per §6.1 an
app admin/owner has all per-RFC capabilities by construction, but
this predicate is intentionally narrow it answers "is this
person on the RFC's owners line?" and nothing more.
"""
if user is None:
return False
return user.gitea_login in _rfc_owners_set(rfc_slug)
def is_rfc_collaborator(user: SessionUser | None, rfc_slug: str, *, role_in_rfc: str | None = None) -> bool:
"""True iff the user has an accepted per-RFC collaborator row.
`role_in_rfc`:
* None any role qualifies (the discussion-write check uses this
shape: contributor strictly includes discussant).
* 'contributor' only the contributor role qualifies (the PR-write
check uses this shape).
* 'discussant' only the discussant role qualifies (not used by
v0.16.0 endpoints; included for symmetry).
"""
if user is None:
return False
if role_in_rfc is None:
row = db.conn().execute(
"SELECT 1 FROM rfc_collaborators WHERE rfc_slug = ? AND user_id = ? LIMIT 1",
(rfc_slug, user.user_id),
).fetchone()
return row is not None
row = db.conn().execute(
"SELECT 1 FROM rfc_collaborators WHERE rfc_slug = ? AND user_id = ? AND role_in_rfc = ? LIMIT 1",
(rfc_slug, user.user_id, role_in_rfc),
).fetchone()
return row is not None
def can_discuss_rfc(user: SessionUser | None, rfc_slug: str) -> bool:
"""v0.16.0 — admit to PR-less discussion writes on this RFC.
True if ANY of:
* platform admin/owner (the §6.1 maximal-capability path),
* the RFC has no frontmatter owners yet (the gate is open
until an owner exists to set it relevant for super-drafts
pre-§13.1 claim),
* RFC owner (frontmatter `owners` membership),
* accepted per-RFC collaborator at any role (contributor strictly
includes discussant).
Returns False for anonymous viewers and for users whose
`permission_state != 'granted'` the platform-level gate must hold
before any per-RFC layer can apply. The platform gate is also
enforced earlier in the request via `require_contributor`; the
helper here is defensive so callers that compose it with
`current_user` directly still respect the gate.
"""
if user is None:
return False
if user.permission_state != "granted":
return False
if user.role in ("owner", "admin"):
return True
owners = _rfc_owners_set(rfc_slug)
if not owners:
# No owner to gate the invite-list — fall through to the
# platform-granted contract. The first §13.1 claim engages
# the gate; before that, anyone platform-granted can
# contribute (mirrors the v0.5.0 / v0.6.0 contract).
return True
if user.gitea_login in owners:
return True
return is_rfc_collaborator(user, rfc_slug, role_in_rfc=None)
def can_contribute_to_rfc(user: SessionUser | None, rfc_slug: str) -> bool:
"""v0.16.0 — admit to PR-shaped writes on this RFC.
True if ANY of:
* platform admin/owner,
* the RFC has no frontmatter owners yet (gate open until an
owner exists),
* RFC owner,
* accepted per-RFC collaborator at role 'contributor' (a
'discussant' row is NOT sufficient PRs are the
higher-privilege surface).
Same `permission_state` and anonymous-viewer refusals as
`can_discuss_rfc`.
"""
if user is None:
return False
if user.permission_state != "granted":
return False
if user.role in ("owner", "admin"):
return True
owners = _rfc_owners_set(rfc_slug)
if not owners:
# Same fall-through as can_discuss_rfc: until an owner exists,
# the gate is open.
return True
if user.gitea_login in owners:
return True
return is_rfc_collaborator(user, rfc_slug, role_in_rfc="contributor")
def can_invite_to_rfc(user: SessionUser | None, rfc_slug: str) -> bool:
"""v0.16.0 — only RFC owners (frontmatter) and platform admin/owner
can issue invitations. Per-RFC collaborators do not get the
invite-others power; that stays with the RFC's owner."""
if user is None:
return False
if user.permission_state != "granted":
return False
if user.role in ("owner", "admin"):
return True
return is_rfc_owner(user, rfc_slug)
def new_state() -> str:
return secrets.token_urlsafe(16)
+13 -136
View File
@@ -695,111 +695,7 @@ class Bot:
)
return sha
# ----- §13 graduation: per-step primitives and rollback inverses -----
async def create_rfc_repo_for_graduation(
self,
actor: Actor,
*,
org: str,
repo_name: str,
slug: str,
title: str,
) -> dict:
"""§13.3 step 1: create the per-RFC repo.
Empty repo (no auto-init) `seed_graduated_rfc` writes the first
commit on `main`. Returns the Gitea repo payload."""
repo = await self._gitea.create_org_repo(
org, repo_name, description=f"RFC: {title}"
)
_log(
actor,
"graduate_repo_create",
rfc_slug=slug,
details={"repo": f"{org}/{repo_name}", "title": title},
)
return repo
async def seed_graduated_rfc(
self,
actor: Actor,
*,
org: str,
repo_name: str,
slug: str,
title: str,
rfc_body: str,
rfc_id: str,
meta_full: str,
meta_path: str,
owners: list[str],
arbiters: list[str],
tags: list[str],
) -> str:
"""§13.3 step 2: seed RFC.md, README.md, .rfc/metadata.yaml on the
new repo's `main`. Three create_file calls; one audit row.
Returns the final commit sha on main.
"""
import yaml as _yaml
ae = actor.email or f"{actor.gitea_login}@users.noreply"
# 2a) RFC.md — the document. The super-draft's body is migrated
# verbatim per §13.3; if the body is empty we seed a minimal
# placeholder so the editor has something to render on first open.
body = rfc_body.strip() + "\n" if rfc_body.strip() else (
f"# {title}\n\n*RFC.md to be filled in — the super-draft graduated with an empty body.*\n"
)
rfc_msg = _stamp_single(f"Seed RFC.md from super-draft {slug}", actor)
rfc_result = await self._gitea.create_file(
org, repo_name, "RFC.md",
content=body, message=rfc_msg, branch="main",
author_name=actor.display_name, author_email=ae,
)
# 2b) README.md — header pointing back at the meta-repo entry.
readme = (
f"# {rfc_id}{title}\n\n"
f"This repository carries the canonical text of {rfc_id}.\n"
f"The meta-repo entry is `{meta_path}` in `{meta_full}`.\n\n"
f"The RFC body is in `RFC.md`. Contributions go through the\n"
f"app's §8 RFC view — open a branch, propose changes, land a PR.\n"
)
readme_msg = _stamp_single(f"Seed README.md for {rfc_id}", actor)
await self._gitea.create_file(
org, repo_name, "README.md",
content=readme, message=readme_msg, branch="main",
author_name=actor.display_name, author_email=ae,
)
# 2c) .rfc/metadata.yaml — mirror of meta-repo frontmatter for
# future tooling (linting, automation, CI lookups).
meta_yaml = _yaml.safe_dump(
{
"slug": slug, "title": title, "id": rfc_id,
"owners": owners, "arbiters": arbiters, "tags": list(tags),
},
sort_keys=False,
)
meta_msg = _stamp_single(f"Seed .rfc/metadata.yaml for {rfc_id}", actor)
meta_result = await self._gitea.create_file(
org, repo_name, ".rfc/metadata.yaml",
content=meta_yaml, message=meta_msg, branch="main",
author_name=actor.display_name, author_email=ae,
)
last_sha = (
meta_result.get("commit", {}).get("sha")
or rfc_result.get("commit", {}).get("sha")
or ""
)
_log(
actor,
"graduate_repo_seed",
rfc_slug=slug,
branch_name="main",
bot_commit_sha=last_sha,
details={"repo": f"{org}/{repo_name}", "rfc_id": rfc_id},
)
return last_sha
# ----- §13 graduation (meta-only): open + merge the flip PR -----
async def open_graduation_pr(
self,
@@ -811,13 +707,14 @@ class Bot:
new_file_contents: str,
prior_sha: str,
rfc_id: str,
repo_full: str,
owners: list[str],
) -> dict:
"""§13.3 step 3: open a PR against the meta repo that strips the
super-draft body and fills graduation frontmatter fields. Branch
name uses the `graduate-<slug>-<6hex>` shape dash-separated like
the other meta-repo branches per the §19.2 path-routing candidate.
"""§13.3 (meta-only): open a PR against the meta repo that flips the
entry's frontmatter to `state: active` with the integer `id` and
graduation stamps **keeping the body unchanged** (§1 meta-only
topology; no repo is created and no body is stripped). Branch name
uses the `graduate-<slug>-<6hex>` shape dash-separated like the
other meta-repo branches per the §19.2 path-routing candidate.
"""
import secrets
@@ -844,11 +741,11 @@ class Bot:
pr_body_text = (
f"Graduates super-draft `{slug}` to active.\n\n"
f"- ID: `{rfc_id}`\n"
f"- Repo: `{repo_full}`\n"
f"- Owners: {owners_str}\n\n"
f"The meta-repo entry becomes frontmatter-only; the canonical body\n"
f"moves to `RFC.md` in the new repo. The graduation sequence is\n"
f"transactional per §13.3."
f"This is an in-place state flip per the meta-only topology\n"
f"(SPEC §1, §13.3): the entry `rfcs/{slug}.md` keeps its body and\n"
f"stays in the meta repo. Only the frontmatter changes — `state`,\n"
f"`id`, and the graduation stamps."
)
_subject, pr_body = _stamp("", pr_body_text, actor)
pr = await self._gitea.create_pull(
@@ -862,7 +759,7 @@ class Bot:
branch_name=branch,
pr_number=pr["number"],
bot_commit_sha=commit_sha,
details={"pr_title": pr_title, "rfc_id": rfc_id, "repo": repo_full},
details={"pr_title": pr_title, "rfc_id": rfc_id},
)
return pr
@@ -912,27 +809,7 @@ class Bot:
details={"rfc_id": rfc_id},
)
# ----- §13.3 rollback inverses -----
async def delete_rfc_repo(
self,
actor: Actor,
*,
org: str,
repo_name: str,
slug: str,
reason: str,
) -> None:
"""Undo of `create_rfc_repo_for_graduation`. Records `graduate_repo_delete`
in the audit log with the rollback reason so the §13.3 stack's
rendered failure surface can be reconstructed from `actions`."""
await self._gitea.delete_repo(org, repo_name)
_log(
actor,
"graduate_repo_delete",
rfc_slug=slug,
details={"repo": f"{org}/{repo_name}", "reason": reason},
)
# ----- §13.3 (meta-only): cleanup of an unmerged flip PR -----
async def close_graduation_pr(
self,
+42 -4
View File
@@ -219,6 +219,19 @@ async def refresh_rfc_repo(config: Config, gitea: Gitea, slug: str) -> None:
open_pulls, closed_pulls = [], []
for pull in open_pulls + closed_pulls:
head_branch = pull.get("head", {}).get("ref", "")
# Same deleted-branch recovery as refresh_meta_pulls: a merged-and-
# deleted PR's `head.ref` collapses to `refs/pull/<N>/head`. Here
# the slug is known (param), so state still updates correctly and
# no ghost forms — but blindly storing the sentinel would clobber
# the real branch name api_prs.py relies on as a fallback ref when
# the merge commit is gone. Recover it from the stored row.
if not head_branch or head_branch.startswith("refs/pull/"):
prior = db.conn().execute(
"SELECT head_branch FROM cached_prs WHERE repo = ? AND pr_number = ?",
(repo_full, pull["number"]),
).fetchone()
if prior and prior["head_branch"]:
head_branch = prior["head_branch"]
state = _state_from_pull(pull)
gitea_opener = (pull.get("user") or {}).get("login") or ""
opened_by = _resolve_actor(
@@ -329,9 +342,13 @@ async def refresh_meta_branches(config: Config, gitea: Gitea) -> None:
if not slug:
continue
rfc = db.conn().execute(
"SELECT state FROM cached_rfcs WHERE slug = ?", (slug,)
"SELECT state, repo FROM cached_rfcs WHERE slug = ?", (slug,)
).fetchone()
if not rfc or rfc["state"] != "super-draft":
# Meta-only topology (§1): edit branches live on the meta repo for
# every meta-resident entry — super-drafts and active RFCs alike
# (active RFCs are graduated in place and keep editing here, §13).
# A legacy per-RFC repo (repo set) is the only thing excluded.
if not rfc or rfc["repo"] or rfc["state"] not in ("super-draft", "active"):
continue
edit_keys_seen.add((slug, name))
db.conn().execute(
@@ -352,7 +369,8 @@ async def refresh_meta_branches(config: Config, gitea: Gitea) -> None:
# diverges from this single point.
if meta_main_sha:
super_drafts = db.conn().execute(
"SELECT slug FROM cached_rfcs WHERE state = 'super-draft'"
"SELECT slug FROM cached_rfcs "
"WHERE repo IS NULL AND state IN ('super-draft', 'active')"
).fetchall()
for r in super_drafts:
db.conn().execute(
@@ -374,7 +392,8 @@ async def refresh_meta_branches(config: Config, gitea: Gitea) -> None:
SELECT b.rfc_slug, b.branch_name
FROM cached_branches b
JOIN cached_rfcs r ON r.slug = b.rfc_slug
WHERE r.state = 'super-draft'
WHERE r.repo IS NULL
AND r.state IN ('super-draft', 'active')
AND b.state != 'deleted'
AND b.branch_name != 'main'
"""
@@ -431,6 +450,25 @@ async def refresh_meta_pulls(config: Config, gitea: Gitea) -> None:
for pull in open_pulls + closed_pulls:
head_branch = pull.get("head", {}).get("ref", "")
# A merged-and-deleted PR's branch is no longer reported by Gitea
# as its real name — the `head.ref` collapses to the synthetic
# `refs/pull/<N>/head` sentinel (or empty). The slug + kind both
# derive from the branch name, so a deleted branch would parse to
# slug=None and the row would be skipped forever, freezing the
# cached_prs row at its last-seen `state='open'` — a permanent
# ghost "pending idea" for an entry that has actually merged
# (caught when the operator authoring lane in ROADMAP #35 merged
# an idea PR with the branch deleted; the web UX leaves branches
# in place so it never tripped this). Recover the original branch
# from the row we already stored when the PR was open — that row
# retains the real `head_branch` (migration 002).
if not head_branch or head_branch.startswith("refs/pull/"):
prior = db.conn().execute(
"SELECT head_branch FROM cached_prs WHERE repo = ? AND pr_number = ?",
(repo_full, pull["number"]),
).fetchone()
if prior and prior["head_branch"]:
head_branch = prior["head_branch"]
slug = _slug_from_head_branch(head_branch)
if slug is None:
continue
+15 -1
View File
@@ -60,6 +60,20 @@ def load_config() -> Config:
enabled = [m.strip() for m in _optional("ENABLED_MODELS", "claude").split(",") if m.strip()]
# v0.18.0: `GITEA_WEBHOOK_SECRET` is now mandatory (per the
# email + webhook hygiene proposal). An empty value used to
# silently accept unsigned webhook POSTs — that was the
# invisible-failure shape the proposal targets. Now the
# framework refuses to start when the secret is empty unless
# the operator opts into the dev-bypass with
# `RFC_APP_INSECURE_WEBHOOKS=1`. Local-dev deployments without
# a wired Gitea hook set the bypass; production MUST NOT.
insecure_webhooks = os.environ.get("RFC_APP_INSECURE_WEBHOOKS", "").strip() == "1"
if insecure_webhooks:
webhook_secret = _optional("GITEA_WEBHOOK_SECRET")
else:
webhook_secret = _required("GITEA_WEBHOOK_SECRET")
return Config(
gitea_url=_required("GITEA_URL").rstrip("/"),
gitea_bot_user=_required("GITEA_BOT_USER"),
@@ -72,7 +86,7 @@ def load_config() -> Config:
secret_key=_required("SECRET_KEY"),
database_path=database_path,
owner_gitea_login=_optional("OWNER_GITEA_LOGIN"),
webhook_secret=_optional("GITEA_WEBHOOK_SECRET"),
webhook_secret=webhook_secret,
enabled_models=enabled,
anthropic_api_key=_optional("ANTHROPIC_API_KEY"),
google_api_key=_optional("GOOGLE_API_KEY"),
+32 -21
View File
@@ -97,6 +97,13 @@ class IssueOutcome:
raw_token: str
row_id: int
@property
def cookie_value(self) -> str:
"""The value to put in the `rfc_device_trust` cookie: the row-id
selector joined to the raw token (v0.25.0 / audit 0026 M1). The
selector lets `lookup` read one indexed row instead of scanning."""
return f"{self.row_id}.{self.raw_token}"
def _new_token() -> str:
return secrets.token_urlsafe(TOKEN_BYTES)
@@ -173,33 +180,37 @@ def lookup(raw_token: str) -> LookupOutcome:
if not raw:
return LookupOutcome(ok=False, user=None, reason="invalid")
# The unique index on `device_token_hash` would let us SELECT by
# hash if bcrypt were a stable hash, but bcrypt incorporates a
# per-row salt — equal tokens produce different hashes. We walk
# the candidate set instead. In practice the set is small (a
# human has a handful of trusted devices) and bcrypt is cheap on
# the order of milliseconds; the walk is bounded by the user's
# active device count.
# v0.25.0 (audit 0026 M1): the cookie is "<row_id>.<raw_token>". We
# parse the row-id selector and read exactly ONE row by its indexed
# primary key, then bcrypt-check the token against that single row.
#
# We don't pre-filter by `revoked_at IS NULL` here so that a
# token presented for a recently-revoked row produces a
# 'revoked' outcome (the endpoint surfaces a different shape).
# Same for expired: we let the walk hit and classify after.
rows = db.conn().execute(
# The previous shape read EVERY device_trust row (all users, including
# revoked/expired) and bcrypt-checked each — an unauthenticated
# CPU-amplification DoS reachable at /auth/device-trust/start that
# grew without bound as the table accumulated. bcrypt's per-row salt
# is why we can't SELECT by hash; carrying the row-id in the cookie is
# the standard fix (the id is not secret; the token still is).
selector, sep, token = raw.partition(".")
if not sep or not selector.isdigit() or not token:
# Legacy bare-token cookies (pre-v0.25.0) and malformed values land
# here. We refuse rather than fall back to a full-table scan, so
# the amplification path is fully closed; affected users simply
# re-authenticate once via OTC/passcode and get a new cookie.
return LookupOutcome(ok=False, user=None, reason="invalid")
matched = db.conn().execute(
"""
SELECT id, user_id, device_token_hash, expires_at, revoked_at
FROM device_trust
ORDER BY id DESC
WHERE id = ?
""",
).fetchall()
(int(selector),),
).fetchone()
matched = None
for row in rows:
if _check(raw, row["device_token_hash"]):
matched = row
break
if matched is None:
# One bcrypt check, against the selected row only. A wrong/forged token
# for a real id reads as 'unknown' (cookie cleared), same as a missing
# row — a probing client can't distinguish the two.
if matched is None or not _check(token, matched["device_token_hash"]):
return LookupOutcome(ok=False, user=None, reason="unknown")
if matched["revoked_at"] is not None:
+13 -1
View File
@@ -180,7 +180,19 @@ def assemble_for_user(
subject = _subject(eligible, cadence)
body = _body(eligible, cadence, cfg)
sent = email_mod._deliver(cfg, email, subject, body)
# v0.18.0: the digest is the bulk-adjacent surface par excellence
# (it can carry weeks of accumulated activity), so it gets the
# full one-click unsubscribe to the global opt-out. Per-category
# opt-outs are managed from the preferences page; this footer is
# the "stop sending me anything" escape hatch Gmail and Yahoo
# expect for senders at this tier.
unsubscribe_url = email_mod.make_unsubscribe_url(user_id, "all")
sent = email_mod._deliver(
cfg, email, subject, body,
unsubscribe_mailto=cfg.unsubscribe_mailto,
unsubscribe_url=unsubscribe_url,
kind="digest",
)
if not sent:
return False
ids = [r["id"] for r, _ in eligible]
+358
View File
@@ -0,0 +1,358 @@
"""§14 + roadmap item #30 — on-site sessions-history browser source.
Sibling of `docs.py` / `philosophy.py` but with a different read shape:
the bodies here live in the **public** `wiggleverse/ohm-session-history`
gitea repo (transcripts of every OHM build session, published per the
ohm-infra SESSION-PROTOCOL.md), not on disk. The framework mediates
the gitea fetch on behalf of the browser so the rendered `/docs/sessions/*`
surface inherits the same chrome as `/philosophy` and `/docs/user-guide`
and stays free of any cross-origin gestures from the frontend.
Three read endpoints, all anonymous-reachable:
GET /api/docs/sessions/manifest sessions.json (title manifest)
GET /api/docs/sessions/about README.md (the about page)
GET /api/docs/sessions/<NNNN>/<file> a transcript body
GET /api/docs/sessions/<NNNN>/index per-session file listing
All three sit behind a small in-process TTL cache (manifest TTL default
60 s, content TTL default 300 s). Negative results (404 from gitea) are
also cached at the content TTL to avoid hammering gitea when a
deployment hasn't yet been populated with transcripts. The cache key
is the URL path on the gitea raw base (or the contents API for the
per-session listing); the cache lives in-process, plain dict +
`time.monotonic()` check, no external dep.
Env knobs:
OHM_SESSION_HISTORY_RAW_BASE
Override the gitea raw base URL. Default points at OHM's canonical
transcript repo:
https://git.wiggleverse.org/wiggleverse/ohm-session-history/raw/branch/main
The framework-default value is OHM-flavored because OHM is the
only deployment to date a deployment running its own
transcript repo overrides this via flotilla's overlay.
OHM_SESSION_HISTORY_CONTENTS_BASE
Override the gitea contents-API base URL (for the per-session
listing endpoint, which enumerates files inside a `NNNN/` folder).
Default:
https://git.wiggleverse.org/api/v1/repos/wiggleverse/ohm-session-history/contents
OHM_DOCS_SESSIONS_MANIFEST_TTL_SEC
Cache TTL for the manifest (default 60 s). The manifest is small
and changes when a new session is added; 60 s strikes a balance
between freshness and gitea load.
OHM_DOCS_SESSIONS_CONTENT_TTL_SEC
Cache TTL for transcript bodies + README + per-session listings
(default 300 s = 5 minutes). Transcripts are append-only once
published, so 5 minutes of staleness is harmless.
§3 invariant 1 is preserved: the framework holds no secret bytes; the
gitea repo is public, the fetch carries no auth header.
"""
from __future__ import annotations
import logging
import os
import re
import threading
import time
from typing import Any
import httpx
log = logging.getLogger(__name__)
_DEFAULT_RAW_BASE = (
"https://git.wiggleverse.org/wiggleverse/ohm-session-history/raw/branch/main"
)
_DEFAULT_CONTENTS_BASE = (
"https://git.wiggleverse.org/api/v1/repos/wiggleverse/ohm-session-history/contents"
)
_DEFAULT_MANIFEST_TTL_SEC = 60.0
_DEFAULT_CONTENT_TTL_SEC = 300.0
# The transcript filename shape per SESSION-PROTOCOL.md §1. The
# `<start>--<end>` suffix is optional so legacy renamed-letter
# transcripts (e.g. `SESSION-0009.0-TRANSCRIPT.md` without timestamps)
# remain reachable. The `\.\d+(\.\d+)*` after the session number
# accommodates `0017.0`, `0017.1`, `0017.1.1`, etc.
_TRANSCRIPT_FILENAME_RE = re.compile(
r"^SESSION-\d{4}\.\d+(\.\d+)*-TRANSCRIPT"
r"(-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}--\d{4}-\d{2}-\d{2}T\d{2}-\d{2})?"
r"\.md$"
)
_SESSION_DIR_RE = re.compile(r"^\d{4}$")
_HTTP_TIMEOUT_SEC = 5.0
def _env_float(name: str, default: float) -> float:
raw = os.environ.get(name, "").strip()
if not raw:
return default
try:
return float(raw)
except ValueError:
log.warning("invalid %s=%r — falling back to %s", name, raw, default)
return default
def _raw_base() -> str:
return os.environ.get("OHM_SESSION_HISTORY_RAW_BASE", "").strip() or _DEFAULT_RAW_BASE
def _contents_base() -> str:
return (
os.environ.get("OHM_SESSION_HISTORY_CONTENTS_BASE", "").strip()
or _DEFAULT_CONTENTS_BASE
)
def _manifest_ttl() -> float:
return _env_float("OHM_DOCS_SESSIONS_MANIFEST_TTL_SEC", _DEFAULT_MANIFEST_TTL_SEC)
def _content_ttl() -> float:
return _env_float("OHM_DOCS_SESSIONS_CONTENT_TTL_SEC", _DEFAULT_CONTENT_TTL_SEC)
# ---------------------------------------------------------------------------
# In-process TTL cache
# ---------------------------------------------------------------------------
#
# Plain dict + `time.monotonic()` check, no external dep. The cache
# value is a `(stored_at, payload)` tuple; `payload` may carry an
# error-shape sentinel for negative caching (404s). Lock guards
# read-modify-write across worker tasks; entries are immutable once
# stored so reads under the lock are fast.
_lock = threading.Lock()
_cache: dict[str, tuple[float, dict[str, Any]]] = {}
def _cache_get(key: str, ttl_sec: float) -> dict[str, Any] | None:
with _lock:
entry = _cache.get(key)
if entry is None:
return None
stored_at, payload = entry
if time.monotonic() - stored_at > ttl_sec:
# Don't evict here; let _cache_put overwrite on next fetch.
# The stale entry is gated by the TTL check, so it stays
# invisible to readers regardless.
return None
return payload
def _cache_put(key: str, payload: dict[str, Any]) -> None:
with _lock:
_cache[key] = (time.monotonic(), payload)
def reset_cache() -> None:
"""Drop every cached entry. Test seam — not called in production."""
with _lock:
_cache.clear()
# ---------------------------------------------------------------------------
# Public fetch surface
# ---------------------------------------------------------------------------
#
# Each fetcher returns a `{status, ...}` dict. `status` is one of:
# "ok" — payload field carries the body
# "404" — gitea returned 404 (or content was missing)
# "error" — gitea returned 5xx, timed out, or returned malformed data
#
# The route layer maps these onto HTTP responses; keeping the mapping
# out of this module makes the cache transparent to the test harness.
async def _http_get(url: str) -> tuple[int, str]:
"""Perform a single GET against `url`; return (status_code, body).
On timeout or network error, returns (599, error_message). The 599
pseudo-status maps to a 502 at the route layer the same way an
upstream 5xx does the caller doesn't care which leg of the
network broke.
"""
try:
async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT_SEC) as client:
r = await client.get(url)
return r.status_code, r.text
except httpx.HTTPError as e:
log.warning("gitea fetch failed for %s: %s", url, e)
return 599, f"fetch error: {e}"
def _is_valid_session_dir(nnnn: str) -> bool:
return bool(_SESSION_DIR_RE.match(nnnn))
def _is_valid_transcript_filename(filename: str) -> bool:
return bool(_TRANSCRIPT_FILENAME_RE.match(filename))
async def fetch_manifest() -> dict[str, Any]:
"""Fetch and parse `sessions.json` from the public repo.
Returns one of:
{"status": "ok", "manifest": {...}} successful parse
{"status": "404"} gitea 404 (empty state)
{"status": "error", "detail": "..."} 5xx / timeout / bad JSON
"""
cache_key = "manifest"
cached = _cache_get(cache_key, _manifest_ttl())
if cached is not None:
return cached
url = f"{_raw_base()}/sessions.json"
status, body = await _http_get(url)
if status == 200:
try:
import json
data = json.loads(body)
except (json.JSONDecodeError, ValueError) as e:
payload: dict[str, Any] = {
"status": "error",
"detail": f"sessions.json malformed: {e}",
}
# Don't cache parse errors — give the upstream a chance to
# fix the file without waiting for TTL expiry.
return payload
if not isinstance(data, dict):
return {
"status": "error",
"detail": "sessions.json is not a JSON object",
}
payload = {"status": "ok", "manifest": data}
_cache_put(cache_key, payload)
return payload
if status == 404:
payload = {"status": "404"}
_cache_put(cache_key, payload)
return payload
return {"status": "error", "detail": f"upstream returned {status}"}
async def fetch_about() -> dict[str, Any]:
"""Fetch the repo's README.md (rendered as the /docs/sessions/about page).
Returns one of:
{"status": "ok", "body": "..."}
{"status": "404"}
{"status": "error", "detail": "..."}
"""
cache_key = "about:README.md"
cached = _cache_get(cache_key, _content_ttl())
if cached is not None:
return cached
url = f"{_raw_base()}/README.md"
status, body = await _http_get(url)
if status == 200:
payload: dict[str, Any] = {"status": "ok", "body": body}
_cache_put(cache_key, payload)
return payload
if status == 404:
payload = {"status": "404"}
_cache_put(cache_key, payload)
return payload
return {"status": "error", "detail": f"upstream returned {status}"}
async def fetch_transcript(nnnn: str, filename: str) -> dict[str, Any]:
"""Fetch a single transcript body from `{nnnn}/{filename}` in the repo.
The caller is expected to have validated `nnnn` and `filename`
against `_is_valid_session_dir` / `_is_valid_transcript_filename`
before calling this invalid paths shouldn't reach the network.
"""
cache_key = f"transcript:{nnnn}/{filename}"
cached = _cache_get(cache_key, _content_ttl())
if cached is not None:
return cached
url = f"{_raw_base()}/{nnnn}/{filename}"
status, body = await _http_get(url)
if status == 200:
payload: dict[str, Any] = {"status": "ok", "body": body}
_cache_put(cache_key, payload)
return payload
if status == 404:
payload = {"status": "404"}
_cache_put(cache_key, payload)
return payload
return {"status": "error", "detail": f"upstream returned {status}"}
async def fetch_session_index(nnnn: str) -> dict[str, Any]:
"""List the transcript filenames inside the `{nnnn}/` folder.
Uses gitea's contents API (one HTTP per session-index page-view per
cache-TTL) rather than the raw URL there's no flat way to list a
folder via the raw mount.
Returns one of:
{"status": "ok", "files": ["SESSION-...md", ...]}
{"status": "404"}
{"status": "error", "detail": "..."}
Only filenames that match `_is_valid_transcript_filename` are
surfaced sibling files (e.g. an attached `notes.md`) are ignored
so the /docs/sessions/<NNNN> page never lists a non-transcript
masquerading as one.
"""
cache_key = f"index:{nnnn}"
cached = _cache_get(cache_key, _content_ttl())
if cached is not None:
return cached
url = f"{_contents_base()}/{nnnn}"
status, body = await _http_get(url)
if status == 200:
try:
import json
data = json.loads(body)
except (json.JSONDecodeError, ValueError) as e:
return {
"status": "error",
"detail": f"contents API response malformed: {e}",
}
if not isinstance(data, list):
return {
"status": "error",
"detail": "contents API returned non-list",
}
files: list[str] = []
for entry in data:
if not isinstance(entry, dict):
continue
if entry.get("type") != "file":
continue
name = entry.get("name")
if not isinstance(name, str):
continue
if _is_valid_transcript_filename(name):
files.append(name)
files.sort()
payload: dict[str, Any] = {"status": "ok", "files": files}
_cache_put(cache_key, payload)
return payload
if status == 404:
payload = {"status": "404"}
_cache_put(cache_key, payload)
return payload
return {"status": "error", "detail": f"upstream returned {status}"}
+326
View File
@@ -0,0 +1,326 @@
"""v0.20.0 — on-site framework-specs surface source.
Sibling of `docs_sessions.py` (v0.19.0 / roadmap item #30): the
framework mediates a gitea fetch on behalf of the browser so the
rendered `/docs/specs/*` surface inherits the same chrome as
`/docs/user-guide` and `/docs/sessions/*` and stays free of any
cross-origin gestures from the frontend.
Two read endpoints, both anonymous-reachable:
GET /api/docs/specs/manifest the configured spec list
GET /api/docs/specs/<name> a single spec body (markdown)
The framework-default manifest is OHM-flavored (rfc-app's own SPEC.md
+ flotilla's SPEC.md on `git.wiggleverse.org`) for the same reason
`docs_sessions.py`'s defaults are: OHM is the only deployment to
date. A deployment running its own spec set overrides the manifest
via the `OHM_DOCS_SPECS` env var (set through flotilla's overlay).
History is intentionally not surfaced here the operator-stated
intent is "current version only; git is the history surface".
Per-spec entries carry three fields:
name URL-safe slug (`[a-z0-9-]+`) the path segment
title human-readable label shown in the nav and the page header
url the upstream raw URL the framework fetches
Validation:
- The configured list must be a JSON array of `{name, title, url}`
objects. A malformed `OHM_DOCS_SPECS` value (bad JSON, wrong
shape, invalid slug) logs a warning and falls back to the default
so a typo in the overlay doesn't crash startup.
- Each `name` is checked against `^[a-z0-9-]+$` before the manifest
is accepted. The route layer also validates the path-bound `name`
parameter before any network call, so a malformed URL never
reaches the cache or the upstream.
Cache shape mirrors `docs_sessions.py`: in-process `dict` + monotonic
TTL check, negative results (404) cached, no external dep. The
manifest is cheap (parsed from an env var, no network), so it has no
TTL every request re-derives it. Per-spec content has a 5-minute
default TTL (env-tunable via `OHM_DOCS_SPECS_CONTENT_TTL_SEC`).
§3 invariant 1 is preserved: the framework holds no secret bytes;
the upstream specs are public-repo raw URLs, the fetch carries no
auth header.
"""
from __future__ import annotations
import json
import logging
import os
import re
import threading
import time
from typing import Any
import httpx
log = logging.getLogger(__name__)
# The framework-default spec set. OHM-flavored per the same precedent
# `docs_sessions.py` set: the only live deployment is OHM, so the
# default points there. A deployment running its own specs overrides
# `OHM_DOCS_SPECS` via the overlay.
_DEFAULT_SPECS: list[dict[str, str]] = [
{
"name": "rfc-app",
"title": "rfc-app SPEC",
"url": (
"https://git.wiggleverse.org/ben.stull/rfc-app/"
"raw/branch/main/SPEC.md"
),
},
{
"name": "flotilla",
"title": "flotilla SPEC",
"url": (
"https://git.wiggleverse.org/wiggleverse/ohm-rfc-app-flotilla/"
"raw/branch/main/SPEC.md"
),
},
]
_DEFAULT_CONTENT_TTL_SEC = 300.0
# URL-safe slug. Matches `docs_sessions.py`'s `_SESSION_DIR_RE` spirit
# (rejecting anything that could resolve outside the intended layout)
# but with the lowercase-alphanumeric-plus-dash shape the manifest
# enforces. Path traversal (`..`), separators (`/`), tilde, uppercase,
# and whitespace all fail this regex; the route layer rejects 400
# before any cache or network call.
_NAME_RE = re.compile(r"^[a-z0-9-]+$")
_HTTP_TIMEOUT_SEC = 5.0
def _env_float(name: str, default: float) -> float:
raw = os.environ.get(name, "").strip()
if not raw:
return default
try:
return float(raw)
except ValueError:
log.warning("invalid %s=%r — falling back to %s", name, raw, default)
return default
def _content_ttl() -> float:
return _env_float("OHM_DOCS_SPECS_CONTENT_TTL_SEC", _DEFAULT_CONTENT_TTL_SEC)
def _is_valid_name(name: str) -> bool:
"""Slug guard for path-bound `name` parameters.
Mirrors `docs_sessions._is_valid_session_dir`'s contract: the
route layer calls this before any network or cache work, so a
malformed name never escapes the FastAPI surface.
"""
return bool(isinstance(name, str) and _NAME_RE.match(name))
def _parse_spec_entry(entry: Any) -> dict[str, str] | None:
"""Validate a single manifest entry; return None if invalid.
Required fields: `name`, `title`, `url`. All three must be
non-empty strings; `name` must match `_NAME_RE`. The validator is
strict: an entry that fails any check is dropped from the manifest
(and the caller logs at warning level).
"""
if not isinstance(entry, dict):
return None
name = entry.get("name")
title = entry.get("title")
url = entry.get("url")
if not isinstance(name, str) or not _is_valid_name(name):
return None
if not isinstance(title, str) or not title.strip():
return None
if not isinstance(url, str) or not url.strip():
return None
return {"name": name, "title": title.strip(), "url": url.strip()}
def _load_configured_specs() -> list[dict[str, str]]:
"""Parse `OHM_DOCS_SPECS` (if set) or return the default list.
Malformed JSON or wrong-shape values log a warning and fall back
to the default the deployment continues to render the spec
surface rather than crashing startup. The strict validation (each
entry's name slug, presence of all three fields) drops bad entries
one-by-one; if every entry is dropped, the default applies.
"""
raw = os.environ.get("OHM_DOCS_SPECS", "").strip()
if not raw:
return list(_DEFAULT_SPECS)
try:
parsed = json.loads(raw)
except (json.JSONDecodeError, ValueError) as e:
log.warning(
"OHM_DOCS_SPECS is not valid JSON (%s) — falling back to default", e
)
return list(_DEFAULT_SPECS)
if not isinstance(parsed, list):
log.warning(
"OHM_DOCS_SPECS must be a JSON array — falling back to default"
)
return list(_DEFAULT_SPECS)
out: list[dict[str, str]] = []
seen: set[str] = set()
for entry in parsed:
validated = _parse_spec_entry(entry)
if validated is None:
log.warning(
"OHM_DOCS_SPECS entry %r failed validation — dropped", entry
)
continue
if validated["name"] in seen:
log.warning(
"OHM_DOCS_SPECS has duplicate name %r — dropped", validated["name"]
)
continue
seen.add(validated["name"])
out.append(validated)
if not out:
log.warning(
"OHM_DOCS_SPECS yielded no valid entries — falling back to default"
)
return list(_DEFAULT_SPECS)
return out
# ---------------------------------------------------------------------------
# In-process TTL cache
# ---------------------------------------------------------------------------
#
# Same shape as `docs_sessions.py`: plain dict + `time.monotonic()` check,
# no external dep. The cache value is a `(stored_at, payload)` tuple;
# `payload` may carry an error-shape sentinel for negative caching (404s).
# Lock guards read-modify-write across worker tasks; entries are immutable
# once stored so reads under the lock are fast.
_lock = threading.Lock()
_cache: dict[str, tuple[float, dict[str, Any]]] = {}
def _cache_get(key: str, ttl_sec: float) -> dict[str, Any] | None:
with _lock:
entry = _cache.get(key)
if entry is None:
return None
stored_at, payload = entry
if time.monotonic() - stored_at > ttl_sec:
return None
return payload
def _cache_put(key: str, payload: dict[str, Any]) -> None:
with _lock:
_cache[key] = (time.monotonic(), payload)
def reset_cache() -> None:
"""Drop every cached entry. Test seam — not called in production."""
with _lock:
_cache.clear()
# ---------------------------------------------------------------------------
# Public fetch surface
# ---------------------------------------------------------------------------
#
# Each fetcher returns a `{status, ...}` dict, same convention as
# `docs_sessions.py`:
# "ok" — payload field carries the body / manifest
# "404" — gitea returned 404 (or the configured name doesn't exist)
# "error" — gitea returned 5xx, timed out, or returned malformed data
#
# The route layer maps these onto HTTP responses; keeping the mapping
# out of this module makes the cache transparent to the test harness.
async def _http_get(url: str) -> tuple[int, str]:
"""Perform a single GET against `url`; return (status_code, body).
On timeout or network error, returns (599, error_message). The 599
pseudo-status maps to a 502 at the route layer the same way an
upstream 5xx does the caller doesn't care which leg of the
network broke.
"""
try:
async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT_SEC) as client:
r = await client.get(url)
return r.status_code, r.text
except httpx.HTTPError as e:
log.warning("specs fetch failed for %s: %s", url, e)
return 599, f"fetch error: {e}"
def fetch_specs_manifest() -> dict[str, Any]:
"""Return the configured spec manifest.
The manifest is derived from the `OHM_DOCS_SPECS` env var (or the
framework default if unset / malformed) and carries no network
work it's safe to call on every request. The return shape mirrors
the docs_sessions manifest endpoint for frontend consistency:
{"status": "ok", "specs": [{"name", "title", "url"}, ...]}
The "url" field is exposed in the manifest so the frontend can
offer a "view source on gitea" affordance alongside each rendered
spec (operator-stated intent: "include the history so you can see
it in git" — that gesture lives in the source link, not on the
rendered page).
"""
specs = _load_configured_specs()
return {"status": "ok", "specs": specs}
async def fetch_spec(name: str) -> dict[str, Any]:
"""Fetch a single spec body by its manifest `name`.
The caller is expected to have validated `name` against
`_is_valid_name` before calling this an invalid name shouldn't
reach the network. We re-check inside as defense-in-depth: a
bogus name here returns the same `{status: "404"}` shape so the
route layer's `404 → HTTP 404` mapping handles it uniformly.
Returns one of:
{"status": "ok", "body": "..."}
{"status": "404"} no such spec OR upstream 404
{"status": "error", "detail": "..."} upstream 5xx / timeout
"""
if not _is_valid_name(name):
return {"status": "404"}
cache_key = f"spec:{name}"
cached = _cache_get(cache_key, _content_ttl())
if cached is not None:
return cached
specs = _load_configured_specs()
match = next((s for s in specs if s["name"] == name), None)
if match is None:
# Cache the negative — a deployment with an unstable manifest
# would still benefit from the TTL window, and the cached 404
# is automatically displaced when the next request happens
# after TTL expiry.
payload: dict[str, Any] = {"status": "404"}
_cache_put(cache_key, payload)
return payload
url = match["url"]
status, body = await _http_get(url)
if status == 200:
payload = {"status": "ok", "body": body}
_cache_put(cache_key, payload)
return payload
if status == 404:
payload = {"status": "404"}
_cache_put(cache_key, payload)
return payload
return {"status": "error", "detail": f"upstream returned {status}"}
+165 -10
View File
@@ -24,7 +24,6 @@ import os
import smtplib
from dataclasses import dataclass
from datetime import datetime, time, timezone
from email.message import EmailMessage
from email.utils import formataddr
from itertools import groupby
from typing import Any
@@ -33,6 +32,7 @@ from urllib.parse import urlencode
from itsdangerous import BadSignature, URLSafeSerializer
from . import db
from .email_envelope import build_envelope
log = logging.getLogger(__name__)
@@ -69,6 +69,7 @@ class EmailConfig:
app_url: str
bundle_threshold: int
enabled: bool
unsubscribe_mailto: str
@classmethod
def from_env(cls) -> "EmailConfig":
@@ -84,6 +85,16 @@ class EmailConfig:
app_url=os.environ.get("APP_URL", "http://localhost:8000").rstrip("/"),
bundle_threshold=int(os.environ.get("EMAIL_BUNDLE_THRESHOLD", "5")),
enabled=os.environ.get("EMAIL_ENABLED", "1") not in ("0", "false", "False"),
# v0.18.0: the `List-Unsubscribe: <mailto:…>` target on
# invite + notification mail. Defaults to the From
# address when unset; a deployment can route opt-out
# mail to a separate mailbox (e.g., a humans-monitored
# account distinct from the no-reply notifications
# sender) by setting this explicitly.
unsubscribe_mailto=os.environ.get(
"EMAIL_UNSUBSCRIBE_MAILTO",
os.environ.get("EMAIL_FROM", "notifications@wiggleverse.local"),
).strip(),
)
@@ -98,6 +109,14 @@ def _signer() -> URLSafeSerializer:
def make_unsubscribe_url(user_id: int, category: str) -> str:
"""Build the §15.4 per-category one-click URL.
`category` is one of `personal-direct`, `structural`,
`admin-actionable` (the three per-category flags) or `all`
(v0.18.0: the bundle path, which sets `email_opt_out_all = 1`
because a bundle covers multiple categories and a per-category
opt-out wouldn't honor the user's intent).
"""
cfg = EmailConfig.from_env()
token = _signer().dumps({"u": user_id, "c": category})
qs = urlencode({"t": token})
@@ -250,7 +269,21 @@ def _send_one(user: Any, notif_id: int, payload: dict, category: str) -> None:
return
subject = _subject(payload)
body = _body(payload, user["id"], category, cfg)
sent = _deliver(cfg, user["email"], subject, body)
# v0.18.0: notification mail is bulk-adjacent (a watcher can
# accumulate dozens of structural events on a busy RFC), so it
# carries the full one-click unsubscribe — Gmail and Yahoo
# require this for senders at OHM's volume tier per RFC 8058.
unsubscribe_url = make_unsubscribe_url(user["id"], category)
sent = _deliver(
cfg,
user["email"],
subject,
body,
unsubscribe_mailto=cfg.unsubscribe_mailto,
unsubscribe_url=unsubscribe_url,
kind="notification",
notification_id=notif_id,
)
if not sent:
return
db.conn().execute(
@@ -305,23 +338,65 @@ def _deep_link(payload: dict, cfg: EmailConfig) -> str:
return cfg.app_url
def _deliver(cfg: EmailConfig, to_address: str, subject: str, body: str) -> bool:
def _deliver(
cfg: EmailConfig,
to_address: str,
subject: str,
body: str,
*,
unsubscribe_mailto: str | None = None,
unsubscribe_url: str | None = None,
kind: str = "notification",
notification_id: int | None = None,
) -> bool:
"""Build the envelope via the shared `build_envelope` helper and
hand it to SMTP.
The `_SENT` buffer carries the helper's `EmailMessage` under
`message` plus the legacy `to`/`from`/`subject`/`body` keys for
backward-compatibility with tests that read those directly.
Newer tests can assert on the header surface by inspecting
`envelope["message"]`.
v0.18.0 Slice 4: also writes one row to `outbound_emails`
capturing the attempt. status='sent' on success, 'failed' on
SMTP exception, 'deferred' on the dev-fallback path (no
SMTP_HOST configured the send didn't happen, but the row
records the attempt so the admin endpoint can answer "did the
framework try?").
"""
msg = build_envelope(
to_address=to_address,
from_address=cfg.from_address,
from_name=cfg.from_name,
subject=subject,
body_plain=body,
unsubscribe_mailto=unsubscribe_mailto,
unsubscribe_url=unsubscribe_url,
)
envelope = {
"to": to_address,
"from": formataddr((cfg.from_name, cfg.from_address)),
"subject": subject,
"body": body,
"message": msg,
"kind": kind,
}
_SENT.append(envelope)
message_id = msg["Message-ID"]
if not cfg.smtp_host:
log.info("email (stdout fallback): to=%s subject=%s", to_address, subject)
record_outbound(
to_address=to_address,
from_address=cfg.from_address,
subject=subject,
kind=kind,
status="deferred",
message_id=message_id,
notification_id=notification_id,
)
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:
@@ -331,12 +406,78 @@ def _deliver(cfg: EmailConfig, to_address: str, subject: str, body: str) -> bool
smtp.send_message(msg)
finally:
smtp.quit()
record_outbound(
to_address=to_address,
from_address=cfg.from_address,
subject=subject,
kind=kind,
status="sent",
message_id=message_id,
notification_id=notification_id,
)
return True
except Exception:
except Exception as exc:
log.exception("email send failed: to=%s subject=%s", to_address, subject)
record_outbound(
to_address=to_address,
from_address=cfg.from_address,
subject=subject,
kind=kind,
status="failed",
error=f"{type(exc).__name__}: {exc}",
message_id=message_id,
notification_id=notification_id,
)
return False
def record_outbound(
*,
to_address: str,
from_address: str,
subject: str,
kind: str,
status: str,
error: str | None = None,
notification_id: int | None = None,
message_id: str | None = None,
) -> int | None:
"""v0.18.0 Slice 4: write one row to `outbound_emails`.
Returns the inserted row's id, or `None` if the DB connection
isn't initialized (which happens in unit tests that don't boot
the full app the write is best-effort and never raises).
"""
try:
cur = db.conn().execute(
"""
INSERT INTO outbound_emails
(to_address, from_address, subject, kind, sent_at, status,
error, notification_id, message_id)
VALUES (?, ?, ?, ?, datetime('now'), ?, ?, ?, ?)
""",
(
to_address,
from_address,
subject,
kind,
status,
error,
notification_id,
message_id,
),
)
return cur.lastrowid
except RuntimeError:
# db.conn() raises RuntimeError if init() hasn't been called.
# Pure-helper unit tests for build_envelope hit this path; the
# audit row is best-effort and not part of the contract.
return None
except Exception:
log.exception("outbound_emails write failed: to=%s subject=%s", to_address, subject)
return None
# ---------------------------------------------------------------------------
# Quiet-hours release pass — called from the digest job
# ---------------------------------------------------------------------------
@@ -440,13 +581,27 @@ def _send_bundle(cfg: EmailConfig, user: Any, emailable: list) -> int:
for r, _cat, extras in group_rows:
summary = _summary_for(r["event_kind"], r["actor_display"], r["rfc_title"], extras)
sections.append(f" · {summary}")
# v0.18.0: the bundle covers multiple categories, so a
# per-category opt-out can't honor the user's intent. The
# `all` category lands at the §15.4 endpoint and sets
# `email_opt_out_all = 1`.
unsubscribe_url = make_unsubscribe_url(user["id"], "all")
body = (
"Activity on RFCs you watch, accumulated during your quiet hours:\n"
+ "\n".join(sections)
+ f"\n\nOpen your inbox: {cfg.app_url}/inbox\n"
+ f"Manage all preferences: {cfg.app_url}/settings/notifications\n"
+ f"Unsubscribe from all email: {unsubscribe_url}\n"
)
sent = _deliver(
cfg,
user["email"],
subject,
body,
unsubscribe_mailto=cfg.unsubscribe_mailto,
unsubscribe_url=unsubscribe_url,
kind="bundle",
)
sent = _deliver(cfg, user["email"], subject, body)
if not sent:
return 0
ids = [r["id"] for r, _, _ in emailable]
+155
View File
@@ -0,0 +1,155 @@
"""v0.18.0 / roadmap items #18 + #20: a shared envelope builder.
Every outbound mail in rfc-app today (OTC, admin-invite, watcher
notification, "while you were away" bundle, per-RFC invite) constructs
its own `email.message.EmailMessage` ad-hoc. The four sites diverged
just enough to be a deliverability hazard: missing `Date`, missing
`Message-ID`, no `Auto-Submitted`, no `List-Unsubscribe` on the
bulk-adjacent paths, no `multipart/alternative` body.
This module is the one place an `EmailMessage` is constructed. Every
send path imports `build_envelope` and calls it; the headers that
matter for inbox placement (Date, Message-ID, Auto-Submitted) land
uniformly, and the per-kind variations (unsubscribe semantics,
HTML alternative) are explicit arguments rather than buried in
each call site.
Per the v0.18.0 proposal at `~/git/ohm-infra/RFC-APP-EMAIL-HYGIENE-PROPOSAL.md`,
the per-kind unsubscribe matrix is:
* OTC: no `List-Unsubscribe` (the recipient explicitly requested
the code; advertising an unsubscribe header would imply OHM has
them on a list, which it doesn't).
* Admin invite / per-RFC invite: `mailto:` form only (the
recipient isn't a user yet, so there's no per-user opt-out row
to flip; the operator handles ad-hoc opt-outs manually).
* Watcher notification / bundle: full `mailto:` + signed-URL
`List-Unsubscribe` plus `List-Unsubscribe-Post:
List-Unsubscribe=One-Click` per RFC 8058 (Gmail and Yahoo
enforce this for bulk-adjacent senders).
The `is_transactional` flag governs `Auto-Submitted: auto-generated`,
which prevents auto-responder loops on every kind of mail we send.
All five mail kinds today are transactional in the SMTP sense (no
human is at the From mailbox watching for replies), so the default
is True; the argument is exposed for future symmetry.
"""
from __future__ import annotations
from email.message import EmailMessage
from email.utils import formataddr, formatdate, make_msgid
def build_envelope(
*,
to_address: str,
from_address: str,
from_name: str,
subject: str,
body_plain: str,
body_html: str | None = None,
reply_to: str | None = None,
unsubscribe_mailto: str | None = None,
unsubscribe_url: str | None = None,
is_transactional: bool = True,
msgid_domain: str | None = None,
) -> EmailMessage:
"""Compose an `EmailMessage` with hardened headers.
`to_address` / `from_address` are bare RFC 5322 addresses;
`from_name` is the display label that goes through `formataddr`
so spaces / commas in the display string are encoded correctly.
`body_plain` is mandatory. `body_html` is **reserved and not yet
enabled** (security-audit-0026 I3): no send path supplies it today
every rfc-app mail is plain text and passing it raises
`NotImplementedError`. The parameter is kept in the signature for
documented future symmetry: when HTML mail is enabled it will land
as the second part of a `multipart/alternative` body (text/plain
first per RFC 2046, so a plain-text client picking the first part
still gets the readable text). Enabling it is a deliberate act the
caller MUST HTML-escape any user content into `body_html` first (cf.
the C1 stored-XSS class: a mail client renders the HTML) and remove
the guard below in the same change.
`reply_to`, when set, lets a send path point replies at a
different mailbox than the From line (e.g., a watcher
notification with From=notifications@... but Reply-To=
ohm@... so a confused recipient who hits Reply lands at a
monitored mailbox).
`unsubscribe_mailto` / `unsubscribe_url` populate
`List-Unsubscribe`. If `unsubscribe_url` is set, the helper also
emits `List-Unsubscribe-Post: List-Unsubscribe=One-Click` per
RFC 8058 Gmail and Yahoo POST that payload on the user's
one-click action. (Send paths that wire `unsubscribe_url`
therefore MUST also expose a matching POST endpoint that accepts
the same token; see `api_notifications.py:email_unsubscribe`.)
`msgid_domain` defaults to the @-domain of `from_address` so
Message-IDs are aligned with the sending domain by default. A
deployment that wants the Message-ID domain to track a different
surface (e.g., a tracking-domain that's separate from the From
domain) can override.
`Date` is RFC 5322 formatted via `email.utils.formatdate`; the
`localtime=True` setting picks the running process's local
timezone, which is what every popular MUA does too. (A
deployment running in UTC stamps UTC; that's correct, not a
bug.)
"""
msg = EmailMessage()
msg["From"] = formataddr((from_name, from_address))
msg["To"] = to_address
msg["Subject"] = subject
msg["Date"] = formatdate(localtime=True)
# If the caller didn't pin a Message-ID domain, derive it from the
# From address. `make_msgid` accepts None and falls back to the
# local hostname, which is the wrong shape for a deliverable
# message (the hostname might be `gke-pool-xxx`); a deployment
# without a configured From would surface that as a build-time
# config error elsewhere, so the fallback here is just defensive.
if msgid_domain is None:
if "@" in from_address:
msgid_domain = from_address.split("@", 1)[1]
else:
msgid_domain = "localhost"
msg["Message-ID"] = make_msgid(domain=msgid_domain)
if reply_to:
msg["Reply-To"] = reply_to
if is_transactional:
# RFC 3834: prevents auto-responders (vacation replies, etc.)
# from triggering on this message. Every kind of mail rfc-app
# sends today is transactional in this sense.
msg["Auto-Submitted"] = "auto-generated"
if unsubscribe_mailto or unsubscribe_url:
parts: list[str] = []
if unsubscribe_mailto:
parts.append(f"<mailto:{unsubscribe_mailto}>")
if unsubscribe_url:
parts.append(f"<{unsubscribe_url}>")
msg["List-Unsubscribe"] = ", ".join(parts)
if unsubscribe_url:
# RFC 8058 one-click. Gmail and Yahoo POST the payload
# `List-Unsubscribe=One-Click` to the URL on the user's
# one-click action; the matching POST endpoint must be
# idempotent and not require auth. See
# `api_notifications.py` for the receiver.
msg["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click"
if body_html is not None:
# I3 (security-audit-0026): the multipart/alternative HTML path
# is intentionally NOT enabled. No send path passes `body_html`
# today, and emitting an HTML body built from user-supplied
# content without escaping it first would reintroduce the C1
# stored-XSS class in the mail channel (the recipient's client
# renders the HTML). Fail loudly here rather than silently
# shipping HTML: enabling HTML mail is a deliberate change that
# MUST HTML-escape user content at the call site and remove this
# guard together. The text/plain path below is the only live one.
raise NotImplementedError(
"HTML email is not enabled (security-audit-0026 I3): do not "
"pass body_html until user content is HTML-escaped at the "
"call site and this guard is intentionally removed."
)
msg.set_content(body_plain)
return msg
+166
View File
@@ -0,0 +1,166 @@
"""Outbound admin-invite email — a thin wrapper over the existing SMTP layer.
v0.17.0 / roadmap item #16: when an admin uses `POST /api/admin/users` to
create-with-invite, this module composes and sends the invite envelope.
Structurally distinct from:
* `email_otc.py` (v0.7.0) that one carries a credential the user
just requested; this one carries a credential the admin is sending
unsolicited.
* `email.py` (§15.4 notification mailer) that one is inbox-driven,
bundled, with category opt-outs; this one is a single transactional
outbound to a person who does not yet have an inbox.
* v0.9.0's `new_beta_request` admin notification — that one is
invitee-to-admin (an existing pending user asking to be let in);
this one is admin-to-invitee (an admin reaching out to seed access).
So this module reuses `EmailConfig.from_env()` for the SMTP plumbing
and the From identity, but writes its own envelope. In dev (no
SMTP_HOST set), the envelope is logged at INFO level and pushed to
the same `_SENT` buffer the notification mailer uses, so the
integration tests can assert on the outbound shape without standing
up an SMTP server.
The send is synchronous. The admin endpoint returns 200 on the
create-row half regardless of send outcome a transient SMTP
failure should not roll back the invite (an admin can re-send via a
future "resend invite" gesture, deferred to a follow-up release).
"""
from __future__ import annotations
import logging
import smtplib
from email.utils import formataddr
from .email import EmailConfig, _SENT, record_outbound
from .email_envelope import build_envelope
log = logging.getLogger(__name__)
def send_invite_email(
*,
to_address: str,
claim_url: str,
inviter_display: str,
inviter_email: str,
custom_message: str = "",
) -> bool:
"""Compose and send the admin-invite email. Returns True on the
happy path; False on SMTP failure. The notifier-side buffer
`_SENT` is appended either way so tests can assert on content.
The body names the inviting admin, embeds the optional custom
message in a clearly delimited block if present, and ships the
claim link. The subject names the inviter so the recipient can
recognize the sender at a glance in their inbox preview.
"""
cfg = EmailConfig.from_env()
subject = _subject(inviter_display, cfg)
body = _body(claim_url, inviter_display, inviter_email, custom_message, cfg)
# v0.18.0: invite mail carries a `List-Unsubscribe: <mailto:…>`
# only (no signed URL) — the invitee isn't a user yet, so there
# is no per-user opt-out row to flip. The operator handles
# ad-hoc opt-outs from the mailto: target. Per the proposal's
# "Tradeoff discussion": the invite was unsolicited from the
# recipient's perspective, so the courtesy header is right;
# but it can't be a one-click URL because the row doesn't
# exist yet.
msg = build_envelope(
to_address=to_address,
from_address=cfg.from_address,
from_name=cfg.from_name,
subject=subject,
body_plain=body,
unsubscribe_mailto=cfg.unsubscribe_mailto,
)
envelope = {
"to": to_address,
"from": formataddr((cfg.from_name, cfg.from_address)),
"subject": subject,
"body": body,
"kind": "invite",
"message": msg,
}
_SENT.append(envelope)
message_id = msg["Message-ID"]
if not cfg.enabled:
log.info("invite email disabled (EMAIL_ENABLED=0): to=%s", to_address)
record_outbound(
to_address=to_address, from_address=cfg.from_address,
subject=subject, kind="invite", status="deferred", message_id=message_id,
)
return True
if not cfg.smtp_host:
# Dev fallback: surface the claim URL at INFO so the operator can
# complete a claim flow without an SMTP relay. In production
# SMTP_HOST is always set per OHM's overlay.
log.info("invite email (stdout fallback): to=%s claim_url=%s", to_address, claim_url)
record_outbound(
to_address=to_address, from_address=cfg.from_address,
subject=subject, kind="invite", status="deferred", message_id=message_id,
)
return True
try:
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()
record_outbound(
to_address=to_address, from_address=cfg.from_address,
subject=subject, kind="invite", status="sent", message_id=message_id,
)
return True
except Exception as exc:
log.exception("invite email send failed: to=%s", to_address)
record_outbound(
to_address=to_address, from_address=cfg.from_address,
subject=subject, kind="invite", status="failed",
error=f"{type(exc).__name__}: {exc}", message_id=message_id,
)
return False
def _subject(inviter_display: str, cfg: EmailConfig) -> str:
"""e.g. "You're invited to Wiggleverse by Ben Stull"."""
inviter = inviter_display or "an admin"
return f"You're invited to {cfg.from_name} by {inviter}"
def _body(
claim_url: str,
inviter_display: str,
inviter_email: str,
custom_message: str,
cfg: EmailConfig,
) -> str:
inviter = inviter_display or "An admin"
inviter_suffix = f" ({inviter_email})" if inviter_email else ""
message_block = ""
if custom_message.strip():
# Indent the custom message so it reads as a clearly-delimited
# quote rather than running together with the framework's
# framing text. Per-line indent keeps multi-line messages
# visually grouped in plain-text mail clients.
indented = "\n".join(f" {line}" for line in custom_message.strip().splitlines())
message_block = f"\nA personal note from {inviter}:\n\n{indented}\n"
return (
f"{inviter}{inviter_suffix} has invited you to {cfg.from_name}.\n"
f"{message_block}\n"
f"Click the link below to claim your account and sign in.\n"
f"This link is single-use and expires in 7 days.\n\n"
f" {claim_url}\n\n"
f"If you weren't expecting this invitation, you can ignore this\n"
f"email — no account becomes active until you click the link.\n\n"
f"---\n"
f"{cfg.from_name} · {cfg.app_url}\n"
)
+34 -8
View File
@@ -23,10 +23,10 @@ from __future__ import annotations
import logging
import smtplib
from email.message import EmailMessage
from email.utils import formataddr
from .email import EmailConfig, _SENT
from .email import EmailConfig, _SENT, record_outbound
from .email_envelope import build_envelope
log = logging.getLogger(__name__)
@@ -44,31 +44,48 @@ def send_otc_email(to_address: str, code: str) -> bool:
cfg = EmailConfig.from_env()
subject = f"Your sign-in code for {cfg.from_name}"
body = _body(code, cfg)
# v0.18.0: OTC mail carries NO List-Unsubscribe — the recipient
# explicitly requested the code; advertising an unsubscribe
# header would imply OHM has them on a list, which it doesn't.
# See the proposal's "Tradeoff discussion" for the binding
# rationale.
msg = build_envelope(
to_address=to_address,
from_address=cfg.from_address,
from_name=cfg.from_name,
subject=subject,
body_plain=body,
)
envelope = {
"to": to_address,
"from": formataddr((cfg.from_name, cfg.from_address)),
"subject": subject,
"body": body,
"kind": "otc",
"message": msg,
}
_SENT.append(envelope)
message_id = msg["Message-ID"]
if not cfg.enabled:
log.info("otc email disabled (EMAIL_ENABLED=0): to=%s", to_address)
record_outbound(
to_address=to_address, from_address=cfg.from_address,
subject=subject, kind="otc", status="deferred", message_id=message_id,
)
return True
if not cfg.smtp_host:
# Dev fallback: surface the code at INFO so the operator can
# complete a sign-in flow without an SMTP relay. In production
# SMTP_HOST is always set per OHM's overlay.
log.info("otc email (stdout fallback): to=%s code=%s", to_address, code)
record_outbound(
to_address=to_address, from_address=cfg.from_address,
subject=subject, kind="otc", status="deferred", message_id=message_id,
)
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:
@@ -78,9 +95,18 @@ def send_otc_email(to_address: str, code: str) -> bool:
smtp.send_message(msg)
finally:
smtp.quit()
record_outbound(
to_address=to_address, from_address=cfg.from_address,
subject=subject, kind="otc", status="sent", message_id=message_id,
)
return True
except Exception:
except Exception as exc:
log.exception("otc email send failed: to=%s", to_address)
record_outbound(
to_address=to_address, from_address=cfg.from_address,
subject=subject, kind="otc", status="failed",
error=f"{type(exc).__name__}: {exc}", message_id=message_id,
)
return False
+8 -6
View File
@@ -284,9 +284,10 @@ async def _delete_branch_via_bot(
reason: str,
) -> bool:
"""Call `bot.delete_branch` with the system actor. Resolves the
`(org, repo)` pair from the slug: super-draft edit branches and
graduation branches live on the meta repo; active-RFC branches
live on the per-RFC repo named by `cached_rfcs.repo`.
`(org, repo)` pair from the slug: under the meta-only topology (§1)
every meta-resident entry's edit branches and graduation branches
live on the meta repo; a legacy per-RFC repo (a `repo:` that survives
from before the fold-back, §13.6) is named by `cached_rfcs.repo`.
Returns True on a clean delete; False if the rfc row is missing
(we leave the branch row in place a subsequent reconciler sweep
@@ -297,12 +298,13 @@ async def _delete_branch_via_bot(
if rfc is None:
log.warning("hygiene: cannot delete %s/%s — slug missing from cache", slug, branch)
return False
if rfc["state"] == "super-draft":
if not rfc["repo"]:
owner, repo = config.gitea_org, config.meta_repo
elif rfc["state"] == "active" and rfc["repo"] and "/" in rfc["repo"]:
elif "/" in rfc["repo"]:
owner, repo = rfc["repo"].split("/", 1)
else:
log.warning("hygiene: cannot resolve repo for %s state=%s", slug, rfc["state"])
log.warning("hygiene: cannot resolve repo for %s state=%s repo=%r",
slug, rfc["state"], rfc["repo"])
return False
try:
await bot.delete_branch(
+425
View File
@@ -0,0 +1,425 @@
"""§6.1 / v0.17.0: admin-create user with role + invite email (roadmap item #16).
Distinguishes from the v0.8.0 self-serve beta-access flow:
* **Self-serve (v0.8.0)** anyone with an email can request OTC sign-in;
a fresh `users` row lands in `permission_state='pending'`; an admin
grants or revokes via the v0.9.0 user-management page.
* **Admin-create (v0.17.0)** an admin types first/last/email/role
*before* the invitee has signed in. The framework provisions the
`users` row with the chosen role and `permission_state='granted'`
(the admin's hand is the grant) and `last_seen_at IS NULL` as the
"invited but not yet arrived" discriminator. An invite-token row
lands in `user_invite_tokens`; the admin's chosen `custom_message`
(if any) rides in the email body alongside the claim link.
* **Claim flow** the invitee clicks the link, which lands them at
`/invites/claim?token=`. The page POSTs `/api/invites/claim` with
the token. The framework verifies the token (not expired, not
claimed, hash matches), marks the row claimed, signs the user in,
and returns a payload telling the frontend whether to route to
passcode-set (if v0.10.0 passcode flow is in play and the user has
no passcode yet) or to `/`. **No OTC roundtrip** clicking the
unique token in the email is itself proof of email control, per
the roadmap. This is the intentional UX shortcut for first
sign-in; subsequent sign-ins use the standard OTC / passcode
paths.
The shape:
* `create_invite(...)` provision the invitee `users` row + the
`user_invite_tokens` row, return the raw token for the admin
endpoint to put in the outbound email link.
* `claim(raw_token)` validate the token, mark it claimed, return
the `SessionUser` the endpoint signs in. Distinguishes the failure
modes (`expired`, `claimed`, `unknown`, `invalid`) so the endpoint
can map them to HTTP 410 vs HTTP 404 cleanly.
* `list_pending_invites()` return active invites for the admin
listing surface. Filters out claimed + expired rows so the surface
only shows live invites.
Token shape: opaque DB token (256 bits of CSPRNG entropy via
`secrets.token_urlsafe(32)`), bcrypt-hashed at rest. Opaque chosen
over JWT because revocation is then a single SQL UPDATE a JWT
would be stateless but harder to invalidate, and admin-issued
invites are exactly the kind of thing an admin should be able to
yank back. The raw token only ever lives in the outbound email link
and the inbound claim body; server-side storage is the hash.
TTL: hard-coded to 7 days via `INVITE_TOKEN_TTL_DAYS`. Env-var
configurability is a §19.2 candidate the constant is exposed
here as a single point of edit if a deployment wants to override.
The 500-char ceiling on `custom_message` is enforced at the
Pydantic body level in `api_admin.py`; this module trusts what
the endpoint hands it.
"""
from __future__ import annotations
import logging
import secrets
from dataclasses import dataclass
import bcrypt
from . import db
from .auth import SessionUser
log = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Tunables — intentionally hard-coded in v0.17.0 (§19.2 candidate to env-ify).
# ---------------------------------------------------------------------------
INVITE_TOKEN_TTL_DAYS = 7
# 256 bits of CSPRNG entropy. `secrets.token_urlsafe(32)` yields ~43
# URL-safe characters; the bcrypt hash is what's stored, so the raw
# token only ever lives in the outbound email link.
TOKEN_BYTES = 32
# Free-text ceiling for the admin's optional custom message. Matched
# at the Pydantic body bound in `api_admin.py`; mentioned here so the
# bound is documented in one place.
CUSTOM_MESSAGE_MAX_LENGTH = 500
# ---------------------------------------------------------------------------
# Token + hash helpers (mirror device_trust.py shape)
# ---------------------------------------------------------------------------
def _new_token() -> str:
return secrets.token_urlsafe(TOKEN_BYTES)
def _hash(token: str) -> str:
return bcrypt.hashpw(token.encode("utf-8"), bcrypt.gensalt()).decode("ascii")
def _check(token: str, token_hash: str) -> bool:
try:
return bcrypt.checkpw(token.encode("utf-8"), token_hash.encode("ascii"))
except (ValueError, TypeError):
return False
# ---------------------------------------------------------------------------
# Create
# ---------------------------------------------------------------------------
@dataclass
class CreateOutcome:
"""The shape returned from `create_invite`.
`raw_token` is what the admin endpoint puts in the outbound email
link; it never appears in storage. `invite_id` is the surrogate
key for the admin's "invites I've sent" listing. `invited_user_id`
is the freshly-provisioned `users` row id so the admin surface can
join through to the user-management page.
"""
raw_token: str
invite_id: int
invited_user_id: int
def create_invite(
*,
email: str,
first_name: str,
last_name: str,
role: str,
custom_message: str,
created_by_admin_id: int,
) -> CreateOutcome:
"""Provision the invitee `users` row + the `user_invite_tokens` row.
Caller (`api_admin.py`) is responsible for the admin-only auth check,
the self-email refusal (422), and the duplicate-email refusal (409).
This function trusts what it's handed and writes both rows
transactionally the v0.10.0 `passcode.py` / v0.11.0 `device_trust.py`
helpers follow the same separation-of-concerns pattern.
The invitee `users` row is provisioned with:
* `permission_state='granted'` the admin's hand is the grant;
the v0.8.0 self-serve `pending` queue is for the other path.
* `last_seen_at = NULL` the discriminator for "invited but
not yet arrived" per the §16 / roadmap design. Every sign-in
path stamps `last_seen_at` to now, so a NULL value means the
invited user has not clicked through yet.
* `gitea_id = NULL`, `gitea_login = NULL` same as a v0.7.0
OTC-provisioned user; the OAuth identity is grandfathered if
the user ever lands through that path.
* `display_name` defaults to "<first> <last>" (or local-part of
email if both are empty) so the user-management page reads a
sensible label before the user has signed in.
* `first_name` / `last_name` / `beta_request_reason` the
first two from the admin's typed values; reason stays blank
(this user did not self-request access).
"""
email_clean = email.strip()
first_clean = (first_name or "").strip()
last_clean = (last_name or "").strip()
display = " ".join(p for p in (first_clean, last_clean) if p).strip()
if not display:
display = email_clean.split("@", 1)[0] or email_clean
# 1. Provision the invitee users row. The grant is the admin's
# hand; no permission_events row is necessary for the grant itself
# (we are not transitioning from pending → granted, we are landing
# a fresh row directly into granted).
#
# Note on the "pending invite" discriminator: the brief floated
# `first_sign_in_at NULL` / `last_seen_at NULL` as the marker the
# admin user-management page reads off the row to render the
# "(pending invite)" badge. The schema didn't cooperate — the
# existing `users.last_seen_at` column is NOT NULL with a
# `datetime('now')` default (see `migrations/001_users_and_audit.sql`),
# and there is no `first_sign_in_at` column. Rather than introduce
# a schema migration to add one (the brief explicitly said "likely
# no `users` table changes"), the discriminator is the existence of
# an active row in `user_invite_tokens` joined on `invited_user_id`.
# The admin listing's `pending_invite` field joins through that
# table; the claim flow stamps `claimed_at` on the invite row,
# which clears the badge naturally. This shape keeps the
# discriminator scoped to the v0.17.0 surface and avoids
# double-tracking against an existing column.
cur = db.conn().execute(
"""
INSERT INTO users (
gitea_id, gitea_login, email, display_name, avatar_url,
role, permission_state, first_name, last_name
)
VALUES (NULL, NULL, ?, ?, '', ?, 'granted', ?, ?)
""",
(email_clean, display, role, first_clean, last_clean),
)
invited_user_id = cur.lastrowid
# 2. Mint the token, hash it, write the invite row.
raw = _new_token()
h = _hash(raw)
cur = db.conn().execute(
f"""
INSERT INTO user_invite_tokens (
email, role, first_name, last_name, custom_message,
token_hash, expires_at, created_by_admin_id, invited_user_id
)
VALUES (?, ?, ?, ?, ?, ?, datetime('now', '+{INVITE_TOKEN_TTL_DAYS} days'), ?, ?)
""",
(
email_clean,
role,
first_clean,
last_clean,
(custom_message or "").strip(),
h,
created_by_admin_id,
invited_user_id,
),
)
invite_id = cur.lastrowid
return CreateOutcome(
raw_token=raw,
invite_id=invite_id,
invited_user_id=invited_user_id,
)
# ---------------------------------------------------------------------------
# Claim
# ---------------------------------------------------------------------------
@dataclass
class ClaimOutcome:
"""The result of `claim`.
`user` is populated only on success. `reason` distinguishes the
failure modes so the endpoint can return distinct HTTP statuses
(HTTP 410 for expired/claimed the token is dead; HTTP 400 for
unknown/invalid the request shape is wrong).
"""
ok: bool
user: SessionUser | None
reason: str # 'ok' | 'invalid' | 'unknown' | 'expired' | 'claimed'
invite_id: int | None = None
def claim(raw_token: str) -> ClaimOutcome:
"""Validate the presented token and consume it.
Walks the active invite rows looking for a bcrypt hash match.
Mirrors `device_trust.lookup`: bcrypt's per-row salt means we
cannot SELECT by hash, but the set is small (a deployment's
outstanding invites at any moment) and bcrypt is cheap on the
order of milliseconds.
On a hit:
* Mark the row claimed (stamp `claimed_at = now`,
`claimed_by_user_id = invited_user_id` the admin's
pre-provisioned row is the claimant).
* Stamp `last_seen_at = now` on the user row so the v0.9.0
admin user-management page no longer shows "(pending invite)".
* Return a populated `SessionUser` for the endpoint to sign in.
On a miss:
* `unknown` no row matched. The token may have been forged or
the invite was admin-revoked.
* `expired` row matched but `expires_at` is in the past.
* `claimed` row matched but `claimed_at` is non-NULL. The
token was already consumed; the user must contact the admin
for a fresh invite.
* `invalid` the token string itself was empty or unparseable.
"""
raw = (raw_token or "").strip()
if not raw:
return ClaimOutcome(ok=False, user=None, reason="invalid")
rows = db.conn().execute(
"""
SELECT id, token_hash, expires_at, claimed_at, invited_user_id, role
FROM user_invite_tokens
ORDER BY id DESC
"""
).fetchall()
matched = None
for row in rows:
if _check(raw, row["token_hash"]):
matched = row
break
if matched is None:
return ClaimOutcome(ok=False, user=None, reason="unknown")
if matched["claimed_at"] is not None:
return ClaimOutcome(
ok=False, user=None, reason="claimed", invite_id=matched["id"],
)
expired = db.conn().execute(
"SELECT datetime(?) < datetime('now') AS expired",
(matched["expires_at"],),
).fetchone()["expired"]
if expired:
return ClaimOutcome(
ok=False, user=None, reason="expired", invite_id=matched["id"],
)
# Consume the row before signing in so a parallel claim of the same
# token cannot double-sign-in. (Mirrors `otc.verify_code`'s consume-
# before-provision shape.)
db.conn().execute(
"""
UPDATE user_invite_tokens
SET claimed_at = datetime('now'),
claimed_by_user_id = invited_user_id
WHERE id = ?
""",
(matched["id"],),
)
# Stamp last_seen_at on the user row so the user's activity stamp
# is current after the claim (mirroring otc.verify_code's
# last-seen update on the provision path). The "(pending invite)"
# badge's clear is driven by the invite row's `claimed_at`
# transition above; this update is for the general user-listing's
# recency ordering.
db.conn().execute(
"UPDATE users SET last_seen_at = datetime('now') WHERE id = ?",
(matched["invited_user_id"],),
)
user_row = db.conn().execute(
"""
SELECT id, gitea_id, gitea_login, email, display_name, avatar_url,
role, permission_state
FROM users
WHERE id = ?
""",
(matched["invited_user_id"],),
).fetchone()
if user_row is None:
# The invitee user row was deleted between create_invite and
# claim (shouldn't happen under the FK ON DELETE CASCADE — the
# cascade would drop the invite row too — be defensive anyway).
return ClaimOutcome(
ok=False, user=None, reason="unknown", invite_id=matched["id"],
)
return ClaimOutcome(
ok=True,
user=SessionUser(
user_id=user_row["id"],
gitea_id=user_row["gitea_id"] or 0,
gitea_login=user_row["gitea_login"] or "",
display_name=user_row["display_name"],
email=user_row["email"] or "",
avatar_url=user_row["avatar_url"] or "",
role=user_row["role"],
permission_state=user_row["permission_state"] or "granted",
),
reason="ok",
invite_id=matched["id"],
)
# ---------------------------------------------------------------------------
# List pending invites — for the admin's review surface
# ---------------------------------------------------------------------------
@dataclass
class PendingInviteRow:
"""The shape the `GET /api/admin/users/invites` endpoint returns.
Note the absence of `token_hash` the hash is structurally private,
and the surface has no use for it. The raw token is also not on
the listing; it lives only in the email link.
"""
id: int
email: str
role: str
first_name: str
last_name: str
custom_message: str
created_at: str
expires_at: str
created_by_admin_id: int
invited_user_id: int
def list_pending_invites() -> list[PendingInviteRow]:
"""Active invites (not claimed, not expired), freshest first.
The admin's "I sent these but they haven't been claimed yet" view.
Filters mirror the `device_trust.list_for_user` shape: the surface
only shows live records the framework would actually accept on a
presented token.
"""
rows = db.conn().execute(
"""
SELECT id, email, role, first_name, last_name, custom_message,
created_at, expires_at, created_by_admin_id, invited_user_id
FROM user_invite_tokens
WHERE claimed_at IS NULL
AND datetime(expires_at) > datetime('now')
ORDER BY created_at DESC, id DESC
"""
).fetchall()
return [
PendingInviteRow(
id=row["id"],
email=row["email"],
role=row["role"],
first_name=row["first_name"] or "",
last_name=row["last_name"] or "",
custom_message=row["custom_message"] or "",
created_at=row["created_at"],
expires_at=row["expires_at"],
created_by_admin_id=row["created_by_admin_id"],
invited_user_id=row["invited_user_id"],
)
for row in rows
]
+198 -18
View File
@@ -7,6 +7,7 @@ no need for a separate worker.
from __future__ import annotations
import logging
import os
import secrets
from contextlib import asynccontextmanager
@@ -24,9 +25,12 @@ from . import (
digest,
email_otc,
hygiene,
invites as invites_mod,
otc,
passcode as passcode_mod,
providers as providers_mod,
ratelimit,
turnstile,
webhooks,
)
from .bot import Bot
@@ -39,6 +43,14 @@ log = logging.getLogger("rfc_app")
class OtcRequestBody(BaseModel):
email: str = Field(min_length=3, max_length=320)
# v0.12.0 / roadmap item #10: CloudFlare Turnstile token from the
# frontend widget. Optional in the body so a deployment that has
# not yet wired the Turnstile site key (or a dev environment with
# the widget intentionally skipped) still routes through the same
# endpoint; the backend turnstile.verify_token call decides whether
# to admit the request based on `TURNSTILE_REQUIRED` + presence of
# the secret.
turnstile_token: str | None = Field(default=None, max_length=4096)
class OtcVerifyBody(BaseModel):
@@ -63,6 +75,25 @@ class PasscodeVerifyBody(BaseModel):
trust_device: bool = False
class InviteClaimBody(BaseModel):
"""v0.17.0 / roadmap item #16 — claim an admin-issued invite token.
The frontend `/invites/claim?token=` page reads the token from
the URL and POSTs it here. The body bound matches the
`secrets.token_urlsafe(32)` output shape (~43 URL-safe chars);
the upper bound stays generous in case `TOKEN_BYTES` is ever
raised. The token-shape is opaque to this layer `invites.claim`
bcrypt-checks it against the active candidate set.
"""
token: str = Field(min_length=1, max_length=512)
# v0.11.0-style opt-in: the claim flow's "trust this device" gesture
# is bundled here so the invitee can land trusted on first sign-in
# without an extra roundtrip. Defaults to false so the gesture is
# explicit (the frontend modal renders a checkbox alongside the
# claim CTA).
trust_device: bool = False
@asynccontextmanager
async def lifespan(app: FastAPI):
config = load_config()
@@ -113,12 +144,20 @@ def create_app() -> FastAPI:
# eagerly via load_config(). Everything else waits for lifespan.
config = load_config()
app = FastAPI(lifespan=lifespan)
# v0.25.0 (audit 0026 M4): the session cookie is the primary 30-day
# auth credential and must carry `Secure` in production so it never
# travels cleartext. Default to Secure; a dev box serving over plain
# http opts out with SESSION_COOKIE_SECURE=false. Production (OHM is
# HTTPS-only with an HTTP->HTTPS 301) leaves this unset → Secure on.
session_secure = os.environ.get("SESSION_COOKIE_SECURE", "true").strip().lower() not in (
"0", "false", "no", "off",
)
app.add_middleware(
SessionMiddleware,
secret_key=config.secret_key,
session_cookie="rfc_session",
max_age=60 * 60 * 24 * 30,
https_only=False,
https_only=session_secure,
)
return app
@@ -126,24 +165,25 @@ def create_app() -> FastAPI:
app = create_app()
def _set_device_trust_cookie(response: Response, raw_token: str) -> None:
def _set_device_trust_cookie(response: Response, cookie_value: str) -> None:
"""Attach the v0.11.0 device-trust cookie to the response.
HttpOnly + Secure + SameSite=Lax + 30-day Max-Age + Path=/. The
cookie value is the raw token; server-side storage is the hash.
The cookie is "essential" per the v0.13.0 cookie-consent contract
(it is part of authentication), so we set it regardless of the
user's analytics / other-cookies choice.
HttpOnly + Secure + SameSite=Lax + 30-day Max-Age + Path=/. As of
v0.25.0 (audit 0026 M1) the value is `IssueOutcome.cookie_value`
"<row_id>.<raw_token>" so `device_trust.lookup` can read one indexed
row instead of scanning; server-side storage remains the bcrypt hash
of the token half only. The cookie is "essential" per the v0.13.0
cookie-consent contract (it is part of authentication), so we set it
regardless of the user's analytics / other-cookies choice.
Secure=True means the cookie is only ever sent over HTTPS. The
SessionMiddleware in `create_app` keeps `https_only=False` for
dev parity, but the device-trust cookie holds a 30-day credential
and must not travel cleartext production deployments serve over
HTTPS, so Secure on the device-trust cookie is non-negotiable.
Secure=True means the cookie is only ever sent over HTTPS the
device-trust cookie holds a 30-day credential and must never travel
cleartext. (The session cookie now also defaults to Secure; see M4 in
`create_app`.)
"""
response.set_cookie(
key=device_trust_mod.COOKIE_NAME,
value=raw_token,
value=cookie_value,
max_age=device_trust_mod.COOKIE_MAX_AGE_SECONDS,
path="/",
secure=True,
@@ -215,7 +255,31 @@ def _oauth_router(config) -> APIRouter:
# ---------------------------------------------------------------
@router.post("/auth/otc/request")
async def otc_request(body: OtcRequestBody):
async def otc_request(body: OtcRequestBody, request: Request):
# v0.25.0 (audit 0026 H1/L2): per-IP brake at the cheapest point,
# before the Turnstile network call or any bcrypt/SMTP work.
if not ratelimit.otc_request_limiter.allow(ratelimit.client_key(request)):
raise HTTPException(429, "Too many requests; please wait a few minutes")
# v0.12.0 / roadmap item #10: gate the request on a successful
# Turnstile siteverify before the bcrypt hash + SMTP send. The
# check runs first so a failed challenge spends no rate budget
# and produces no envelope. When the operator has not wired the
# secret AND TURNSTILE_REQUIRED=false (the default), the gate
# opens — see `backend/app/turnstile.py` for the full matrix.
client_ip = request.client.host if request.client else None
ts = await turnstile.verify_token(body.turnstile_token, client_ip=client_ip)
if not ts.ok:
if ts.reason == "misconfigured":
# TURNSTILE_REQUIRED=true but the secret is unset. This
# is an operator/config problem, not a client problem;
# surface as 500 so the operator notices in their logs
# rather than blaming the user's browser.
raise HTTPException(500, "auth misconfigured")
# missing-token / failed / network → uniform 400 so the
# response does not enumerate which leg of the challenge
# broke. The reason is in the server logs.
raise HTTPException(400, "verification failed")
outcome = otc.request_code(body.email)
if outcome.reason == "cooldown":
# Loud failure per the rate-limit primitive — the abuse
@@ -229,9 +293,25 @@ def _oauth_router(config) -> APIRouter:
@router.post("/auth/otc/verify")
async def otc_verify(body: OtcVerifyBody, request: Request, response: Response):
# v0.25.0 (audit 0026 H1): per-IP brake against fan-out guessing,
# plus the per-email lockout enforced inside otc.verify_code.
ip = ratelimit.client_key(request)
if not ratelimit.verify_limiter.allow(ip):
raise HTTPException(429, "Too many attempts; please wait a few minutes")
result = otc.verify_code(body.email, body.code)
if result.reason == "locked":
raise HTTPException(
423,
{
"detail": "Too many failed attempts; wait a few minutes or request a new code",
"locked_until": result.locked_until,
},
)
if not result.ok or result.user is None:
raise HTTPException(400, "Invalid or expired code")
# Legit sign-in: clear this IP's window so a user who fat-fingered
# a couple of codes isn't left throttled.
ratelimit.verify_limiter.reset(ip)
auth.store_session(request, result.user)
# v0.8.0: surface `needs_profile` so the Login.jsx surface can
# decide whether to advance to the first/last/why capture step
@@ -264,7 +344,7 @@ def _oauth_router(config) -> APIRouter:
if body.trust_device:
ua = request.headers.get("user-agent", "")
outcome = device_trust_mod.issue(result.user.user_id, ua)
_set_device_trust_cookie(response, outcome.raw_token)
_set_device_trust_cookie(response, outcome.cookie_value)
return {
"ok": True,
"user": {
@@ -288,12 +368,17 @@ def _oauth_router(config) -> APIRouter:
# ---------------------------------------------------------------
@router.get("/auth/passcode/check")
async def passcode_check(email: str = ""):
async def passcode_check(request: Request, email: str = ""):
"""Does this email have a passcode set? Anonymous endpoint —
the Login.jsx flow calls this after the user types their email
to decide whether to render a passcode input or fall back to
OTC. We surface only the boolean; lockout state, the hash, and
the set-at stamp are not leaked here."""
the set-at stamp are not leaked here.
v0.25.0 (audit 0026 L3): per-IP rate limit so the has-passcode
boolean can't be bulk-harvested to enumerate accounts."""
if not ratelimit.check_limiter.allow(ratelimit.client_key(request)):
raise HTTPException(429, "Too many requests; please wait a few minutes")
status = passcode_mod.passcode_status(email)
return {"has_passcode": status.has_passcode}
@@ -327,6 +412,11 @@ def _oauth_router(config) -> APIRouter:
v0.11.0: the body's `trust_device` flag, if true, mints a
fresh device-trust row and sets the long-lived cookie. Same
opt-in contract as `/auth/otc/verify`."""
# v0.25.0 (audit 0026 H1): per-IP brake in front of the per-account
# passcode lockout, so fan-out across emails is throttled too.
ip = ratelimit.client_key(request)
if not ratelimit.verify_limiter.allow(ip):
raise HTTPException(429, "Too many attempts; please wait a few minutes")
result = passcode_mod.verify_passcode(body.email, body.passcode)
if result.reason == "locked":
raise HTTPException(
@@ -338,11 +428,12 @@ def _oauth_router(config) -> APIRouter:
)
if not result.ok or result.user is None:
raise HTTPException(400, "Invalid passcode")
ratelimit.verify_limiter.reset(ip)
auth.store_session(request, result.user)
if body.trust_device:
ua = request.headers.get("user-agent", "")
outcome = device_trust_mod.issue(result.user.user_id, ua)
_set_device_trust_cookie(response, outcome.raw_token)
_set_device_trust_cookie(response, outcome.cookie_value)
return {
"ok": True,
"user": {
@@ -353,6 +444,95 @@ def _oauth_router(config) -> APIRouter:
},
}
# ---------------------------------------------------------------
# v0.17.0: admin-create user + invite claim (§6.1, roadmap item #16).
#
# The admin-create surface lives at POST /api/admin/users (see
# api_admin.py); this endpoint is the corresponding claim path the
# invitee hits when they click the link in their invite email.
# The frontend route `/invites/claim?token=…` reads the token from
# the URL and POSTs it here.
#
# The claim itself is the first-sign-in for the invitee: clicking
# the unique token in the email is proof of email control per the
# roadmap, so this endpoint skips the OTC step entirely on first
# sign-in. The session cookie lands; the response tells the
# frontend whether to route to passcode-set (if v0.10.0 passcode
# flow is in play and the user has not yet set a passcode) or to
# home.
#
# The endpoint is anonymous-reachable: the entire point is to
# establish the session, so we do not gate it on `require_user`.
# The trust-device opt-in mirrors the v0.11.0 OTC/passcode verify
# contract (the body's `trust_device` flag, when true, mints a
# fresh device-trust row on the same response so the invitee
# lands trusted on their first device).
# ---------------------------------------------------------------
@router.post("/api/invites/claim")
async def invites_claim(body: InviteClaimBody, request: Request, response: Response):
result = invites_mod.claim(body.token)
if result.reason == "expired":
# The token's TTL window passed without a claim. HTTP 410
# (Gone) so the frontend can render a "this invite has
# expired — please contact the admin for a fresh one"
# message distinct from the generic invalid-token shape.
raise HTTPException(410, "This invite has expired")
if result.reason == "claimed":
# The token was already consumed. HTTP 410 for the same
# reason — the row is dead either way.
raise HTTPException(410, "This invite has already been claimed")
if not result.ok or result.user is None:
# 'unknown' / 'invalid' — the token does not match any
# active invite row. HTTP 400 so it reads distinct from
# the dead-token shape above.
raise HTTPException(400, "Invalid invite token")
# Establish the session. From here on the invitee is signed
# in as the pre-provisioned user row carrying their
# pre-assigned role.
auth.store_session(request, result.user)
# v0.11.0 — opt-in device trust on the claim response. Same
# contract as OTC/passcode verify: when the body's flag is
# true, the server mints a fresh device-trust row and sets
# the long-lived cookie, so the invitee skips the email step
# on subsequent visits to the same browser.
if body.trust_device:
ua = request.headers.get("user-agent", "")
outcome = device_trust_mod.issue(result.user.user_id, ua)
_set_device_trust_cookie(response, outcome.cookie_value)
# Has the user already set a passcode? (Could only happen via
# an admin pre-population path that doesn't exist yet, but
# the response shape mirrors `/api/auth/me` so the frontend
# can read it without a second call.) If `needs_passcode` is
# true and v0.10.0 passcode flow is in play, the frontend
# routes to /settings/notifications#sign-in to set a passcode
# immediately; otherwise it routes to /.
row = db.conn().execute(
"SELECT passcode_hash FROM users WHERE id = ?",
(result.user.user_id,),
).fetchone()
has_passcode = bool(row and row["passcode_hash"])
return {
"ok": True,
"user": {
"id": result.user.user_id,
"display_name": result.user.display_name,
"email": result.user.email,
"role": result.user.role,
"permission_state": result.user.permission_state,
},
# Roadmap §16: the claim flow skips OTC entirely; the
# natural next step is passcode-set (so the invitee can
# sign back in without needing an email roundtrip on their
# second visit). The frontend uses this hint to decide
# whether to route to the passcode-set screen or to home.
"needs_passcode": not has_passcode,
}
# ---------------------------------------------------------------
# v0.11.0: trust device for 30 days (§6.2, roadmap item #9).
#
+95
View File
@@ -270,6 +270,86 @@ def fan_out_new_beta_request(
)
def fan_out_contribution_request(
*,
rfc_slug: str,
requester_user_id: int,
request_id: int,
matched_term: str,
who_i_am: str,
why: str,
use_case: str | None,
) -> list[int]:
"""Roadmap #28 Part 3: a reader asked to contribute to a pending
(super-draft) RFC. Land one actionable notification per owner and
return their ids (the caller stamps the first onto the request row as
the inbox-action handle).
Personal-direct: the owner is the named subject of the request, so the
§15.4 email gate consults `email_personal_direct` exactly as for the
other owner-facing personal events no new preference column is
needed. The request's three free-text fields ride along in the payload
so the inbox row can show the full ask inline without a second fetch.
Actor is the requester per §15.9.
"""
requester = db.conn().execute(
"SELECT display_name FROM users WHERE id = ?", (requester_user_id,)
).fetchone()
display = (requester["display_name"] if requester else None) or "Someone"
details = {
"request_id": request_id,
"matched_term": matched_term,
"requester_user_id": requester_user_id,
"requester_display": display,
"who_i_am": who_i_am,
"why": why,
"use_case": use_case or "",
}
notif_ids: list[int] = []
for recipient_id in _entry_owner_user_ids(rfc_slug):
if recipient_id == requester_user_id:
continue
notif_ids.append(
_emit_one(
recipient_user_id=recipient_id,
event_kind="contribution_request_on_pending_rfc",
category=CATEGORY_PERSONAL,
actor_user_id=requester_user_id,
rfc_slug=rfc_slug,
branch_name=None,
pr_number=None,
details=details,
)
)
return notif_ids
def notify_contribution_decided(
*,
rfc_slug: str,
requester_user_id: int,
decider_user_id: int,
request_id: int,
accepted: bool,
) -> None:
"""Roadmap #28 Part 3: tell the requester an owner accepted or declined
their contribute request. On accept the requester also receives the
#12 invitation email out-of-band; this inbox row is the in-app echo
that points them at it."""
_emit_one(
recipient_user_id=requester_user_id,
event_kind=(
"contribution_request_accepted" if accepted else "contribution_request_declined"
),
category=CATEGORY_PERSONAL,
actor_user_id=decider_user_id,
rfc_slug=rfc_slug,
branch_name=None,
pr_number=None,
details={"request_id": request_id},
)
def fan_out_chat_message(
*,
actor_user_id: int,
@@ -769,6 +849,16 @@ def render_summary(event_kind: str, actor_display: str | None, rfc_title: str |
return f"{actor} began graduating {title}."
if event_kind == "pr_conflict_with_main":
return f"{actor} started a resolution branch on {title}."
if event_kind == "contribution_request_on_pending_rfc":
# Roadmap #28 Part 3: owner-facing, actionable. The term is the
# super-draft reference that surfaced the offer; the inbox row
# renders Accept/Decline beneath this line.
term = extras.get("matched_term") or title
return f"{actor} wants to contribute to your pending RFC for '{term}'."
if event_kind == "contribution_request_accepted":
return f"{actor} accepted your request to contribute to {title} — check your email to accept the invitation."
if event_kind == "contribution_request_declined":
return f"{actor} declined your request to contribute to {title}."
if event_kind == "new_beta_request":
# v0.9.0: framework-scoped, not RFC-scoped. The actor (the
# requester) and the captured full name + email read as
@@ -884,6 +974,11 @@ def list_inbox(
"read_at": row["read_at"],
"category": extras.get("category"),
"summary": render_summary(row["event_kind"], row["actor_display"], row["rfc_title"], extras),
# The row's payload, surfaced for kinds that render inline
# detail (e.g. #28 Part 3's contribute-request who/why/use-case
# + Accept/Decline). Safe to expose: a recipient only ever sees
# their own notifications.
"extras": extras,
})
if bundled:
+76
View File
@@ -85,6 +85,15 @@ def _cooldown_seconds() -> int:
return 60
# v0.25.0 / security audit 0026 (H1): per-email OTC verify lockout,
# mirroring the passcode path (passcode.py). Five consecutive wrong codes
# for an email lock its OTC verify for 15 minutes. The per-IP limiter in
# ratelimit.py is the primary brute-force brake; this is the durable,
# passcode-parity layer.
LOCKOUT_AFTER_FAILED_ATTEMPTS = 5
LOCKOUT_DURATION_MINUTES = 15
# ---------------------------------------------------------------------------
# Code generation + hashing
# ---------------------------------------------------------------------------
@@ -206,6 +215,61 @@ class VerifyOutcome:
ok: bool
user: SessionUser | None
reason: str
# v0.25.0 (H1): ISO-8601 stamp when reason == 'locked'.
locked_until: str | None = None
def _verify_lockout_until(email: str) -> str | None:
"""Return the active lockout stamp for `email`, or None if not locked.
Clears an elapsed lockout (and resets the counter) as a side effect so
the next failure starts a fresh budget mirrors passcode.verify_passcode.
"""
row = db.conn().execute(
"SELECT failed_attempts, locked_until FROM otc_verify_state WHERE email = ?",
(email,),
).fetchone()
if row is None or not row["locked_until"]:
return None
still_locked = db.conn().execute(
"SELECT datetime(?) > datetime('now') AS locked", (row["locked_until"],),
).fetchone()["locked"]
if still_locked:
return row["locked_until"]
db.conn().execute(
"UPDATE otc_verify_state SET failed_attempts = 0, locked_until = NULL WHERE email = ?",
(email,),
)
return None
def _record_verify_failure(email: str) -> None:
"""Increment the per-email failure counter; stamp a lockout once it
crosses the threshold. Mirrors the passcode lockout shape."""
db.conn().execute(
"""
INSERT INTO otc_verify_state (email, failed_attempts)
VALUES (?, 1)
ON CONFLICT(email) DO UPDATE SET failed_attempts = failed_attempts + 1
""",
(email,),
)
count = db.conn().execute(
"SELECT failed_attempts FROM otc_verify_state WHERE email = ?", (email,),
).fetchone()["failed_attempts"]
if count >= LOCKOUT_AFTER_FAILED_ATTEMPTS:
db.conn().execute(
f"""
UPDATE otc_verify_state
SET locked_until = datetime('now', '+{LOCKOUT_DURATION_MINUTES} minutes')
WHERE email = ?
""",
(email,),
)
def _clear_verify_state(email: str) -> None:
db.conn().execute("DELETE FROM otc_verify_state WHERE email = ?", (email,))
def verify_code(email: str, code: str) -> VerifyOutcome:
@@ -214,6 +278,13 @@ def verify_code(email: str, code: str) -> VerifyOutcome:
if not email or not code:
return VerifyOutcome(ok=False, user=None, reason="invalid")
# v0.25.0 (H1): refuse before spending any bcrypt if this email is in
# its OTC-verify lockout window. The passcode path is unaffected — a
# locked-out OTC user can still set/use a passcode, and vice versa.
locked_until = _verify_lockout_until(email)
if locked_until:
return VerifyOutcome(ok=False, user=None, reason="locked", locked_until=locked_until)
rows = db.conn().execute(
"""
SELECT id, code_hash, expires_at, consumed_at
@@ -237,6 +308,9 @@ def verify_code(email: str, code: str) -> VerifyOutcome:
break
if matched is None:
# A genuine wrong guess against this email — the brute-force
# signal. Count it toward the lockout threshold (H1).
_record_verify_failure(email)
return VerifyOutcome(ok=False, user=None, reason="wrong")
if matched["consumed_at"] is not None:
@@ -255,6 +329,8 @@ def verify_code(email: str, code: str) -> VerifyOutcome:
"UPDATE otc_codes SET consumed_at = datetime('now') WHERE id = ?",
(matched["id"],),
)
# Success wipes the per-email failure counter (H1).
_clear_verify_state(email)
user = provision_or_link_user(email)
return VerifyOutcome(ok=True, user=user, reason="ok")
+15
View File
@@ -184,6 +184,21 @@ def load_providers(env: dict) -> dict[str, BaseProvider]:
return providers
def construct_haiku(api_key: str) -> AnthropicProvider:
"""A dedicated Claude Haiku provider, independent of the
`ENABLED_MODELS` chat-picker universe.
The §9.1 tag-suggestion surface (roadmap #27) always wants the
cheap + fast model regardless of which models the operator surfaces
in the §8.12 picker, so it constructs Haiku directly from the
operator's Anthropic key rather than going through `load_providers`.
The model id is sourced from the same `_CLAUDE_VARIANTS` table the
picker uses, so a model-string bump lands in one place.
"""
model, name = _CLAUDE_VARIANTS["claude-haiku"]
return AnthropicProvider(api_key=api_key, model=model, display_name=name)
def load_from_config(config) -> dict[str, BaseProvider]:
"""Convenience adapter so callers can pass our Config dataclass directly."""
env = {
+92
View File
@@ -0,0 +1,92 @@
"""In-process per-IP sliding-window rate limiter (security audit 0026, H1).
The auth verify endpoints (`/auth/otc/verify`, `/auth/passcode/verify`)
had no per-IP brake, so an attacker could fan out guesses against a
target identity bounded only by bcrypt cost. This module is the brake.
It is deliberately tiny: §4.2 says the app is a single process with a
colocated SQLite file, so an in-memory dict of `key -> deque[timestamps]`
is sufficient and needs no shared store. State resets on restart, which
fails *open* for a brief window acceptable because the per-email OTC
lockout (`otc_verify_state`) and the passcode lockout both persist in the
database and carry the durable guarantee; this limiter is the
anti-fan-out layer on top.
Chosen over a per-identity lockout *as the primary control* because a
per-IP window throttles the attacker without letting them grief a victim
by locking that victim's account (the known downside of identity
lockouts). Both layers run together.
"""
from __future__ import annotations
import threading
import time
from collections import defaultdict, deque
class SlidingWindowLimiter:
"""Allow at most `max_events` per `window_seconds` per key.
`allow(key)` records an event and returns True if the key is still
within budget, False if it has exceeded it. Timestamps use a
monotonic clock so the limiter is immune to wall-clock jumps.
"""
def __init__(self, max_events: int, window_seconds: float) -> None:
self.max_events = max_events
self.window_seconds = window_seconds
self._events: dict[str, deque[float]] = defaultdict(deque)
self._lock = threading.Lock()
def allow(self, key: str) -> bool:
now = time.monotonic()
cutoff = now - self.window_seconds
with self._lock:
q = self._events[key]
while q and q[0] < cutoff:
q.popleft()
if len(q) >= self.max_events:
return False
q.append(now)
# Opportunistic cleanup so idle keys don't accumulate forever.
if not q:
self._events.pop(key, None)
return True
def reset(self, key: str) -> None:
"""Drop a key's window — e.g. after a successful sign-in so a
legitimate user who fat-fingered a few times isn't throttled."""
with self._lock:
self._events.pop(key, None)
# Module-level limiters shared across requests (one process, so module
# state is the natural home). Tunables are intentionally generous enough
# not to bother a human retyping a code, tight enough to kill fan-out:
# * verify: 10 attempts / 5 min / IP across the auth verify surfaces.
# * otc request: 5 sends / 5 min / IP (Turnstile is the primary gate;
# this is defense in depth against a solved-challenge replay loop).
verify_limiter = SlidingWindowLimiter(max_events=10, window_seconds=300)
otc_request_limiter = SlidingWindowLimiter(max_events=5, window_seconds=300)
# /auth/passcode/check is an anonymous has-passcode oracle (audit 0026 L3).
# It's a legitimate Login-flow affordance, so the budget is generous —
# enough for a human typing emails, tight enough to stop bulk scraping.
check_limiter = SlidingWindowLimiter(max_events=30, window_seconds=300)
def _reset_all_for_tests() -> None:
"""Clear every module-level limiter's window. Test support only — the
limiters are process-global singletons, so without a per-test reset
one test's requests bleed into the next and later tests trip the
budget (429). Not called in production."""
for lim in (verify_limiter, otc_request_limiter, check_limiter):
with lim._lock:
lim._events.clear()
def client_key(request) -> str:
"""Best-effort client identity for limiting. Behind nginx the app is
started with `--forwarded-allow-ips 127.0.0.1`, so `request.client.host`
reflects the real client IP via Uvicorn's ProxyHeaders handling."""
client = getattr(request, "client", None)
return client.host if client and client.host else "unknown"
+333
View File
@@ -0,0 +1,333 @@
"""Roadmap #28 — scan submitted prose for RFC-shaped references.
The scanner splits a plain-text PR description / comment body into a list
of *segments* the frontend renders: plain-text runs interleaved with
typed link segments. The backend never emits HTML the frontend maps
each segment onto a React node so the surface is XSS-safe by
construction and independent of any HTML-sanitization layer.
Three buckets, one scan (Parts 13):
* ``{"type": "rfc", ...}`` Part 1. The term matches an
**accepted** (``state='active'``) RFC; renders as a link to it.
* ``{"type": "rfc-pending", ...}`` Part 3. The term matches a
**pending** RFC a super-draft (``state='super-draft'``: accepted
as an idea but not yet graduated to an active RFC) which has an
owner and a contribution surface. Renders as an "ask to contribute"
affordance carrying the owner's display name.
* ``{"type": "rfc-candidate", ...}`` Part 2. The term is a
strong-candidate that does **not** yet have a defining RFC. Renders
(for a viewer with create rights) as a "create RFC for '<term>'"
affordance that pre-fills the propose flow.
Precedence at any position is active > pending > candidate, then
longest-match-first an active link always wins over a contribute offer
which always wins over a create offer for the same span.
**Read-time enrichment, not submit-time persistence** (unchanged from
Part 1): drafts are never scanned, only submitted content on the read
paths, so links/offers track the *live* corpus. The active-RFC corpus,
super-draft corpus, and tag taxonomy are all small and cache-resident,
so building the index and scanning a 20k-char body per read is cheap.
**Matching stays conservative by design.** A reference links/offers only
when it is unlikely to be coincidental:
* ``rfc_id`` tokens (e.g. ``RFC-0001``) inherently specific.
* Multi-word titles (containing whitespace, e.g. ``Open Human Model``).
* Hyphenated slugs (containing ``-``, e.g. ``open-human-model``).
Single common-word titles/slugs are deliberately NOT matched they
would turn every prose occurrence into an affordance.
**Part 2 candidate heuristic.** A candidate term is a **multi-word tag**
from the #27 tag taxonomy (the de-facto set of tags the corpus already
carries) that has no defining RFC (no active or super-draft RFC whose
slug or title is that term). Multi-word is the same false-positive guard
the title rule uses: a single common tag word (``identity``) would be
far too noisy. Broader candidate detection capitalized multi-word
phrases mined from the text, terms repeated across recently-touched PRs,
or the #27 Haiku (``ANTHROPIC_API_KEY``) pathway — is a sanctioned but
deferred extension; the conservative tag-taxonomy heuristic is chosen
here to match Part 1's false-positive-averse philosophy.
"""
from __future__ import annotations
import json
import re
from typing import Any, Iterable, NamedTuple
class Term(NamedTuple):
"""One match key plus what to emit when it hits.
``key`` is the lowercase span to match (word-boundary, longest-first).
``kind`` is ``'active' | 'pending' | 'candidate'`` and selects the
emitted segment shape. ``slug``/``title`` carry the target RFC (active
+ pending); ``owner`` is the pending RFC's owner display name;
``term`` is the candidate's canonical display spelling.
"""
key: str
kind: str = "active"
slug: str = ""
title: str = ""
owner: str = ""
term: str = ""
# Lower number = higher precedence when two keys of equal length match at
# the same position. A real link beats a contribute offer beats a create
# offer.
_KIND_PRIORITY = {"active": 0, "pending": 1, "candidate": 2}
def _coerce(t: Term | tuple) -> Term:
"""Accept the legacy ``(key, slug, title)`` 3-tuple (treated as an
active term) alongside :class:`Term`, so direct unit-test callers and
older call sites keep working."""
if isinstance(t, Term):
return t
key, slug, title = t # legacy active 3-tuple
return Term(key=key, kind="active", slug=slug, title=title)
def _is_word_char(c: str) -> bool:
"""Word-boundary test. Hyphen and underscore count as word chars so a
match can't begin or end in the middle of a kebab/snake token."""
return c.isalnum() or c in ("-", "_")
def _emit(term: Term, label: str) -> dict[str, Any]:
"""The segment dict for a matched ``term``; ``label`` preserves source
casing."""
if term.kind == "pending":
return {
"type": "rfc-pending",
"slug": term.slug,
"label": label,
"title": term.title,
"owner": term.owner,
}
if term.kind == "candidate":
return {"type": "rfc-candidate", "label": label, "term": term.term}
return {"type": "rfc", "slug": term.slug, "label": label, "title": term.title}
def segment_text(text: str | None, terms: Iterable[Term | tuple]) -> list[dict[str, Any]]:
"""Split ``text`` into text / link segments against ``terms``.
``terms`` are :class:`Term` objects (or legacy ``(key, slug, title)``
active 3-tuples). Matching is case-insensitive, respects word
boundaries on both ends, and prefers the longest key then higher
:data:`_KIND_PRIORITY` at any position.
Always returns at least one segment; for empty/None input that is a
single empty text segment, so callers can render uniformly.
"""
ordered = sorted(
(_coerce(t) for t in terms),
key=lambda t: (-len(t.key), _KIND_PRIORITY.get(t.kind, 9)),
)
if not text:
return [{"type": "text", "text": text or ""}]
out: list[dict[str, Any]] = []
buf: list[str] = []
low = text.lower()
n = len(text)
i = 0
while i < n:
match: tuple[Term, int] | None = None
for term in ordered:
klen = len(term.key)
if klen == 0 or not low.startswith(term.key, i):
continue
before = text[i - 1] if i > 0 else ""
after = text[i + klen] if i + klen < n else ""
if _is_word_char(before) or _is_word_char(after):
continue
match = (term, klen)
break
if match is not None:
term, klen = match
if buf:
out.append({"type": "text", "text": "".join(buf)})
buf = []
out.append(_emit(term, text[i:i + klen]))
i += klen
else:
buf.append(text[i])
i += 1
if buf:
out.append({"type": "text", "text": "".join(buf)})
return out
def _keys_for(slug: str, title: str, rfc_id: str | None) -> Iterable[str]:
"""The match keys an RFC contributes. See the module docstring for why
each gate exists (conservative, false-positive-averse)."""
if rfc_id:
rid = rfc_id.strip()
if len(rid) >= 2:
yield rid.lower()
if title:
t = title.strip()
# Multi-word titles only — a single common word is too noisy.
if len(t) >= 2 and (" " in t or "\t" in t):
yield t.lower()
if slug:
s = slug.strip()
# Hyphenated slugs only — a single-token slug is a bare word.
if len(s) >= 2 and "-" in s:
yield s.lower()
def _slugify(term: str) -> str:
"""Deterministic kebab-case — mirrors the propose modal's slugify so a
tag's would-be slug compares correctly against existing RFC slugs."""
return re.sub(r"-+$", "", re.sub(r"^-+", "", re.sub(r"[^a-z0-9]+", "-", term.lower().strip())))
class LinkIndex:
"""A reusable term index built once per request and applied to many
bodies (a PR's description plus every comment on it)."""
def __init__(self, terms: Iterable[Term | tuple]):
# Coerce + order once; segment_text re-sorts defensively but a
# pre-sorted list keeps the per-body cost to the scan itself.
self._terms: list[Term] = sorted(
(_coerce(t) for t in terms),
key=lambda t: (-len(t.key), _KIND_PRIORITY.get(t.kind, 9)),
)
def __bool__(self) -> bool:
return bool(self._terms)
def segment(self, text: str | None) -> list[dict[str, Any]]:
return segment_text(text, self._terms)
def _owner_display(conn, owners_json: str | None, proposed_by: str | None) -> str:
"""The display name to show for a pending RFC's owner. First entry of
``owners_json`` resolved to its user row's display name, falling back
to the bare login, then ``proposed_by``, then a neutral noun."""
login = None
try:
owners = json.loads(owners_json or "[]")
if isinstance(owners, list):
login = next((o for o in owners if isinstance(o, str) and o.strip()), None)
except (ValueError, TypeError):
login = None
if login:
row = conn.execute(
"SELECT display_name FROM users WHERE gitea_login = ?", (login,)
).fetchone()
if row and row["display_name"]:
return row["display_name"]
return login
return (proposed_by or "").strip() or "the proposer"
def _tag_universe(conn) -> list[str]:
"""Distinct tags across the cached corpus (the #27 de-facto taxonomy),
preserving original spelling; case-deduped."""
rows = conn.execute("SELECT tags_json FROM cached_rfcs").fetchall()
out: list[str] = []
seen: set[str] = set()
for r in rows:
try:
tags = json.loads(r["tags_json"] or "[]")
except (ValueError, TypeError):
continue
if not isinstance(tags, list):
continue
for t in tags:
if not isinstance(t, str):
continue
tag = t.strip()
low = tag.lower()
if tag and low not in seen:
seen.add(low)
out.append(tag)
return out
def build_index(
conn,
*,
exclude_slug: str | None = None,
include_pending: bool = True,
include_candidates: bool = True,
) -> LinkIndex:
"""Build a :class:`LinkIndex` over the three buckets.
``exclude_slug`` drops the RFC the surrounding surface is itself scoped
to, so an RFC's own title/id/slug don't self-link (or self-offer)
inside its own PR or discussion. Precedence is enforced by insertion
order active keys are added first and a later bucket never overrides
an already-claimed key.
"""
terms: list[Term] = []
seen: set[str] = set()
def add(key: str, term: Term) -> None:
if key in seen:
return
seen.add(key)
terms.append(term)
# --- Part 1: accepted (active) RFCs. ORDER BY slug makes key
# de-duplication deterministic when two RFCs would contribute the
# same key (first slug wins). ---
active_rows = conn.execute(
"SELECT slug, title, rfc_id FROM cached_rfcs WHERE state = 'active' ORDER BY slug"
).fetchall()
# Track every slug + title that *has* a defining RFC, so Part 2 never
# offers to create one that already exists (active or pending).
defined_slugs: set[str] = set()
defined_titles: set[str] = set()
for r in active_rows:
slug = r["slug"]
defined_slugs.add((slug or "").lower())
defined_titles.add((r["title"] or "").strip().lower())
if exclude_slug is not None and slug == exclude_slug:
continue
title = r["title"] or ""
rfc_id = r["rfc_id"] if "rfc_id" in r.keys() else None
for key in _keys_for(slug, title, rfc_id):
add(key, Term(key=key, kind="active", slug=slug, title=title))
# --- Part 3: pending (super-draft) RFCs. ---
pending_rows = conn.execute(
"""
SELECT slug, title, rfc_id, owners_json, proposed_by
FROM cached_rfcs WHERE state = 'super-draft' ORDER BY slug
"""
).fetchall()
for r in pending_rows:
slug = r["slug"]
defined_slugs.add((slug or "").lower())
defined_titles.add((r["title"] or "").strip().lower())
if not include_pending:
continue
if exclude_slug is not None and slug == exclude_slug:
continue
title = r["title"] or ""
rfc_id = r["rfc_id"] if "rfc_id" in r.keys() else None
owner = _owner_display(conn, r["owners_json"], r["proposed_by"])
for key in _keys_for(slug, title, rfc_id):
add(key, Term(key=key, kind="pending", slug=slug, title=title, owner=owner))
# --- Part 2: strong-candidate terms with no defining RFC. ---
if include_candidates:
for tag in _tag_universe(conn):
low = tag.lower()
# Conservative: multi-word tags only (same guard as titles).
if " " not in tag and "\t" not in tag:
continue
if low in defined_titles or low in defined_slugs or _slugify(tag) in defined_slugs:
continue
add(low, Term(key=low, kind="candidate", term=tag))
return LinkIndex(terms)
+244
View File
@@ -0,0 +1,244 @@
"""Roadmap #27 — Claude Haiku tag suggestions for the propose-RFC modal.
A cheap, fast assist: given the partial RFC draft a user is typing, ask
Claude Haiku to recommend tags drawn ONLY from the tags already in use
across the corpus. v1 has no curated tag list tags are free-form chip
input (§9.1) so "the taxonomy" is the de-facto set of distinct tags
the existing RFCs already carry. The model is constrained to that set
and MUST NOT invent new tags; taxonomy extension (letting the model
propose genuinely new tags) is a deferred follow-up per the roadmap.
Why Haiku specifically: cost. Picking a few reasonable tags from a known
set is well within Haiku's range, and the modal fires this repeatedly as
the user types, so the per-call price has to stay small.
The whole surface degrades to silence rather than error: no Anthropic
key, no provider, an empty corpus, a rate-limited caller, an empty
draft, or an unparseable model reply all yield an empty suggestion list.
The propose modal hides its suggestion row on an empty list, so the
fallback is simply the existing free-form chip input with nothing extra
shown.
"""
from __future__ import annotations
import json
import logging
import os
import time
from dataclasses import dataclass
from . import db
from .providers import BaseProvider, construct_haiku
log = logging.getLogger(__name__)
# Bound the universe handed to the model so a large corpus can't blow up
# the prompt size (and the cost). The most-common tags matter most.
_UNIVERSE_CAP = 200
# How many suggestions we ever return to the modal.
DEFAULT_MAX_SUGGESTIONS = 6
@dataclass
class Draft:
"""The partial propose-RFC draft. `pitch` is the "why is this needed"
rationale; `use_case` is the #26 optional ground-truth field."""
title: str = ""
pitch: str = ""
use_case: str = ""
def is_empty(self) -> bool:
return not (self.title.strip() or self.pitch.strip() or self.use_case.strip())
def haiku_provider(config) -> BaseProvider | None:
"""Construct a dedicated Claude Haiku provider from the operator's
Anthropic key, or return None when no key is configured.
None means "suggestions unavailable" the caller returns an empty
list and the modal shows nothing. This is the seam tests monkeypatch
to inject a fake provider without a real key. There is no RFC slug at
propose time, so the §6.7 per-RFC funder path deliberately does not
apply: tag suggestion always runs on the operator's own key.
"""
key = getattr(config, "anthropic_api_key", "") or ""
if not key:
return None
return construct_haiku(key)
def gather_tag_universe(cap: int = _UNIVERSE_CAP) -> list[str]:
"""Every distinct tag in use across the cached corpus, most-common
first (ties broken alphabetically for determinism), capped.
This is the universe the model is constrained to. An empty corpus
yields an empty list, which short-circuits suggestion to silence.
"""
rows = db.conn().execute("SELECT tags_json FROM cached_rfcs").fetchall()
counts: dict[str, int] = {}
for r in rows:
try:
tags = json.loads(r["tags_json"] or "[]")
except (ValueError, TypeError):
continue
if not isinstance(tags, list):
continue
for t in tags:
if not isinstance(t, str):
continue
tag = t.strip()
if tag:
counts[tag] = counts.get(tag, 0) + 1
ranked = sorted(counts, key=lambda t: (-counts[t], t.lower()))
return ranked[:cap]
# ---------------------------------------------------------------------------
# Rate limiting — in-process, per-user sliding window.
#
# Cost control, not security: the `require_contributor` gate already
# bounds callers to authenticated beta users, and the modal debounces.
# This is the backstop against a stuck/abusive client hammering the
# endpoint. In-memory is sufficient (single-process uvicorn on the VM)
# and resets on restart, which is fine for a cost guard.
# ---------------------------------------------------------------------------
_CALLS: dict[int, list[float]] = {}
def _rate_config() -> tuple[int, float]:
try:
max_calls = int(os.environ.get("TAG_SUGGEST_RATE_MAX", "30"))
except ValueError:
max_calls = 30
try:
window = float(os.environ.get("TAG_SUGGEST_RATE_WINDOW_SECONDS", "60"))
except ValueError:
window = 60.0
return max_calls, window
def rate_limit_ok(user_id: int, *, now: float | None = None) -> bool:
"""True if this call is within the per-user window; records the call.
`now` is injectable for tests (defaults to a monotonic clock)."""
max_calls, window = _rate_config()
t = time.monotonic() if now is None else now
calls = _CALLS.setdefault(user_id, [])
cutoff = t - window
calls[:] = [c for c in calls if c > cutoff]
if len(calls) >= max_calls:
return False
calls.append(t)
return True
def reset_rate_limits() -> None:
"""Test seam — clear the in-process window state."""
_CALLS.clear()
# ---------------------------------------------------------------------------
# Prompt + parse.
# ---------------------------------------------------------------------------
_SYSTEM = (
"You suggest topic tags for a draft RFC — a structured proposal "
"document in a collection. You are given the draft's title, its "
"rationale, an optional use case, and the exact set of tags already "
"in use across the collection. Choose the tags from that set that "
"best fit the draft.\n"
"Rules:\n"
"- Choose ONLY from the provided tag set. Never invent a tag.\n"
"- Order best-fit first. Omit weak fits rather than padding the list.\n"
"- Return at most {max} tags.\n"
"Respond with ONLY a JSON array, no prose, of the form:\n"
'[{{"tag": "<exact tag from the set>", "confidence": <number between 0 and 1>}}]\n'
"If no tag in the set fits the draft, return []."
)
def _build_messages(draft: Draft, universe: list[str], max_suggestions: int):
system = _SYSTEM.format(max=max_suggestions)
parts: list[str] = []
if draft.title.strip():
parts.append(f"Title: {draft.title.strip()[:300]}")
if draft.pitch.strip():
parts.append(f"Why this RFC is needed:\n{draft.pitch.strip()[:4000]}")
if draft.use_case.strip():
parts.append(f"What it will be used for:\n{draft.use_case.strip()[:4000]}")
parts.append(
"Tags already in use (choose only from these):\n" + ", ".join(universe)
)
return system, [{"role": "user", "content": "\n\n".join(parts)}]
def _extract_json_array(text: str):
start = text.find("[")
end = text.rfind("]")
if start == -1 or end == -1 or end < start:
return None
try:
return json.loads(text[start : end + 1])
except ValueError:
return None
def parse_reply(text: str, universe: list[str], max_suggestions: int) -> list[dict]:
"""Parse the model reply into a clean, universe-constrained list.
Tolerant of the model returning bare strings or objects, extra prose
around the JSON, unknown/invented tags (dropped), duplicate tags
(deduped), and missing/garbage confidences (defaulted + clamped).
"""
data = _extract_json_array(text or "")
if not isinstance(data, list):
return []
# Map back to the canonical spelling in the universe, case-insensitively,
# so a model that lowercases a tag still resolves to the real one.
canonical = {t.lower(): t for t in universe}
out: list[dict] = []
seen: set[str] = set()
for item in data:
if isinstance(item, dict):
raw = item.get("tag")
conf = item.get("confidence")
elif isinstance(item, str):
raw, conf = item, None
else:
continue
if not isinstance(raw, str):
continue
tag = canonical.get(raw.strip().lower())
if tag is None or tag in seen:
continue
try:
c = float(conf) if conf is not None else 0.5
except (ValueError, TypeError):
c = 0.5
c = max(0.0, min(1.0, c))
out.append({"tag": tag, "confidence": round(c, 3)})
seen.add(tag)
if len(out) >= max_suggestions:
break
return out
def suggest(
provider: BaseProvider,
draft: Draft,
universe: list[str],
max_suggestions: int = DEFAULT_MAX_SUGGESTIONS,
) -> list[dict]:
"""Orchestrate one suggestion call. Returns [] for an empty draft or
empty universe (no model call), and for any provider/parse failure."""
if draft.is_empty() or not universe:
return []
system, history = _build_messages(draft, universe, max_suggestions)
try:
text = provider.send(system, history)
except Exception as exc: # provider/network failure → silent empty
log.warning("tag-suggest provider failed: %s", exc)
return []
return parse_reply(text, universe, max_suggestions)
+166
View File
@@ -0,0 +1,166 @@
"""§6.2 / v0.12.0 / roadmap item #10: CloudFlare Turnstile siteverify.
The OTC request endpoint (`/auth/otc/request`) is the abuse hot path
of the auth surface since v0.7.0 the per-email cooldown stops the
trivial back-to-back loop, but it does not stop a distributed scraper
that fans out across a large invitee list to harvest the "this email
is admitted vs. this email is not" signal indirectly (timing
differences, SMTP bounce-rate observation). v0.12.0 gates the request
endpoint behind a one-step browser-side Turnstile challenge before the
bcrypt hash + SMTP send.
Stateless: no DB writes, no schema change. The siteverify call to
CloudFlare lives entirely in this module; the endpoint handler in
`main.py` thin-wraps `verify_token`.
Tunables (read at call time so tests can monkeypatch):
* `CLOUDFLARE_TURNSTILE_SECRET` the operator-provisioned secret
key from the Turnstile dashboard. Lives in GCP Secret Manager in
production; absent in tests (which monkeypatch the siteverify
transport). When unset, the behavior depends on `TURNSTILE_REQUIRED`:
- `TURNSTILE_REQUIRED=true` fail closed (`misconfigured`).
- `TURNSTILE_REQUIRED=false` (default) skip verification entirely
and admit the request. This is the dev/test path and the
"operator hasn't wired the secret yet" path; production
deployments **should** set `TURNSTILE_REQUIRED=true` once the
secret is in place so a regression in the secret wiring fails
loudly instead of silently disabling abuse defense.
* `TURNSTILE_REQUIRED` `true` / `false` (default `false`).
When `false` and the secret is absent, the gate is open. When
`true` and the secret is absent, the endpoint refuses with a
misconfigured-auth shape rather than silently letting requests
through.
* `TURNSTILE_SITEVERIFY_URL` points at the real CloudFlare
endpoint by default. Override in tests to redirect at a mock
URL when `httpx.MockTransport` isn't ergonomic for the case.
The siteverify contract is documented at
https://developers.cloudflare.com/turnstile/get-started/server-side-validation/.
We POST `secret` + `response` (and optionally `remoteip`) as form
fields and read back `{"success": true|false, ...}`. Any network /
parse failure on the siteverify call is treated as a verification
failure (`network`) the abuse path is to skip the challenge, so
"can't reach CloudFlare" defaults to "refuse the request" when
`TURNSTILE_REQUIRED=true`, and "admit" when `TURNSTILE_REQUIRED=false`.
"""
from __future__ import annotations
import logging
import os
from dataclasses import dataclass
import httpx
log = logging.getLogger(__name__)
SITEVERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
def _secret() -> str:
return os.environ.get("CLOUDFLARE_TURNSTILE_SECRET", "").strip()
def _required() -> bool:
raw = os.environ.get("TURNSTILE_REQUIRED", "").strip().lower()
return raw in ("1", "true", "yes", "on")
def _siteverify_url() -> str:
return os.environ.get("TURNSTILE_SITEVERIFY_URL", "").strip() or SITEVERIFY_URL
async def _siteverify_post(url: str, data: dict) -> httpx.Response:
"""Perform the siteverify POST on an `httpx.AsyncClient`.
Isolated as a narrow seam (I4, security-audit-0026): the call is
awaited so a slow CloudFlare response can't block the event loop,
and tests patch *this function* rather than the shared
`httpx.AsyncClient` (which other modules gitea, docs also
construct, so a global patch would break app boot).
"""
async with httpx.AsyncClient(timeout=10.0) as client:
return await client.post(url, data=data)
@dataclass
class VerifyOutcome:
"""Result of a Turnstile siteverify call.
`ok`: the request **may proceed**. True both for "siteverify said
success" and for "no secret configured AND not required" (the
dev/test soft-fail path).
`reason`: one of
* 'ok' siteverify returned success.
* 'skipped' no secret configured, TURNSTILE_REQUIRED=false.
The gate is open; the endpoint admits the request.
* 'misconfigured' TURNSTILE_REQUIRED=true but no secret in env.
The endpoint fails closed with 500.
* 'missing-token' the client did not send a token at all and
verification is required.
* 'failed' siteverify returned success=false. The
endpoint refuses with 400.
* 'network' siteverify call raised. Treated as a failure
under TURNSTILE_REQUIRED=true.
"""
ok: bool
reason: str
async def verify_token(token: str | None, *, client_ip: str | None = None) -> VerifyOutcome:
"""Validate a Turnstile token against CloudFlare's siteverify endpoint.
Returns a VerifyOutcome describing whether the calling endpoint
should proceed. The endpoint maps `ok=False` to an HTTP status per
the `reason`:
* 'misconfigured' 500 "auth misconfigured"
* 'missing-token' / 'failed' / 'network' 400 "verification failed"
Async (I4, security-audit-0026): the siteverify call is awaited on an
`httpx.AsyncClient` so a slow CloudFlare response can't block the
event loop (the prior synchronous `httpx.post` stalled the single
worker for up to the 10s timeout). Callers must `await` it.
Tests monkeypatch `_siteverify_post` (the narrow async seam) to avoid
touching the real CloudFlare endpoint and to keep the patch off the
shared `httpx.AsyncClient`. No real keys are ever embedded in tests.
"""
secret = _secret()
required = _required()
if not secret:
if required:
log.warning("Turnstile required but CLOUDFLARE_TURNSTILE_SECRET is unset; failing closed")
return VerifyOutcome(ok=False, reason="misconfigured")
# Dev/test/soft-fail path: no secret, not required → gate is open.
return VerifyOutcome(ok=True, reason="skipped")
if not token or not token.strip():
# Secret is set, so verification is in force. A missing token
# is a hard refuse — the frontend should have rendered the
# widget and collected one.
return VerifyOutcome(ok=False, reason="missing-token")
data = {"secret": secret, "response": token.strip()}
if client_ip:
data["remoteip"] = client_ip
try:
response = await _siteverify_post(_siteverify_url(), data)
payload = response.json()
except Exception as exc: # network, JSON parse, etc.
log.warning("Turnstile siteverify call failed: %s", exc)
return VerifyOutcome(ok=False, reason="network")
if payload.get("success") is True:
return VerifyOutcome(ok=True, reason="ok")
# `error-codes` is a list of strings on failure; we log the codes
# for the operator without surfacing them to the client.
log.info("Turnstile siteverify rejected token: %s", payload.get("error-codes"))
return VerifyOutcome(ok=False, reason="failed")
+33 -1
View File
@@ -12,6 +12,7 @@ import hashlib
import hmac
import json
import logging
import os
from fastapi import APIRouter, Header, HTTPException, Request
@@ -40,7 +41,27 @@ def make_router(config: Config, gitea: Gitea) -> APIRouter:
x_gitea_signature: str = Header(default=""),
):
body = await request.body()
if config.webhook_secret:
# v0.18.0: defense in depth. config.py refuses to start
# when the secret is empty unless `RFC_APP_INSECURE_WEBHOOKS=1`
# is set; this branch catches the dev-bypass case (the only
# path where `config.webhook_secret` can be empty) and surfaces
# it loudly to the client. A POST that lands here with an
# empty secret on a production deployment indicates a
# mis-configuration (somebody flipped the bypass in prod),
# and the loud 500 is the proposal's whole point.
insecure = os.environ.get("RFC_APP_INSECURE_WEBHOOKS", "").strip() == "1"
if not config.webhook_secret:
if not insecure:
log.error(
"webhook receiver misconfigured: GITEA_WEBHOOK_SECRET is empty "
"and RFC_APP_INSECURE_WEBHOOKS=1 is not set"
)
raise HTTPException(status_code=500, detail="Webhook receiver misconfigured")
log.warning(
"webhook receiver running with RFC_APP_INSECURE_WEBHOOKS=1 — "
"signature verification is DISABLED. Production deployments MUST NOT set this."
)
else:
if not _verify_signature(body, x_gitea_signature, config.webhook_secret):
raise HTTPException(status_code=401, detail="Invalid signature")
@@ -68,6 +89,17 @@ def make_router(config: Config, gitea: Gitea) -> APIRouter:
slug = _slug_for_repo(repo_full)
if slug:
await cache.refresh_rfc_repo(config, gitea, slug)
else:
# v0.18.0: the proposal's "unknown-repo logging"
# gesture — a hook on a fork or a stale repo binding
# used to silently 200-OK here, hiding the
# misconfiguration. Now the operator sees it in
# the log.
log.info(
"webhook received for unknown repo: repo_full=%s event=%s "
"(no cached_rfcs row matched; hook may be on a fork or stale)",
repo_full, event,
)
except Exception:
log.exception("webhook refresh failed")
raise HTTPException(status_code=500, detail="Refresh failed")
+177
View File
@@ -0,0 +1,177 @@
-- §6 / §10 / v0.16.0: owner-only invite for per-RFC contribution +
-- discussion (roadmap item #12).
--
-- Distinct from a platform-level grant (`users.permission_state`,
-- v0.8.0 / item #6). This row is per-RFC membership: the RFC's owner
-- invites a specific email to either open PRs against that RFC
-- (`role_in_rfc='contributor'`) or to participate in the RFC's PR-less
-- discussion only (`role_in_rfc='discussant'`). Non-invited users keep
-- the v0.6.0 anonymous-read contract — they can read but cannot
-- write/discuss that specific RFC.
--
-- Coordinates with item #16's parallel work this wave: that item
-- adds platform-wide invitation tokens; this one adds per-RFC
-- collaboration rows. To avoid table-name + concept collisions the
-- two surfaces are scoped distinctly — this migration owns slot 018
-- and names everything `rfc_*` (RFC-scoped); #16 will use a later
-- slot and name its tables under a different prefix (`invite_tokens`
-- or similar) at the user/platform level.
--
-- Tables in this migration:
--
-- * `rfc_invitations` — one row per (rfc, invitee_email) invite
-- issued by the RFC's owner. Carries the role-in-RFC the
-- invitation grants, the opaque token the email link encodes,
-- the lifecycle state, and the audit trail (who invited, when
-- accepted, by which user_id if any).
--
-- * `rfc_collaborators` — one row per (rfc, user_id, role_in_rfc)
-- after an invitation is accepted. This is the table the
-- write-gate consults: "is the viewer named here for this RFC?"
-- Separating the two means the invitation row carries the
-- issue/accept lifecycle while the collaborator row is the
-- compact membership-check substrate. A grant via collaborator
-- can exist independently of a live invitation (admin-only
-- direct insert is a §19.2 candidate; v0.16.0 only writes
-- collaborator rows via the accept path).
--
-- Authorization model the application layer enforces on top of these
-- rows (not encoded in SQL — the schema is just storage):
--
-- * Writes (open PR, post discussion message, open discussion
-- thread) to an RFC require ONE of:
-- (a) the viewer is named in this RFC's `rfc_collaborators`
-- with the appropriate role_in_rfc, OR
-- (b) the viewer holds a globally privileged role (admin,
-- owner of the platform) per the existing §6 helpers, OR
-- (c) the viewer is named in the RFC's frontmatter owners
-- list (the §6 RFC-owner concept, which already grants
-- the maximal per-RFC capability).
--
-- * Reads remain on the v0.6.0 anonymous-read contract — anyone
-- can read any non-withdrawn RFC. Item #12 does not narrow this.
--
-- * Only the RFC's owner (per `cached_rfcs.owners_json`) can
-- invite. App admins/owners also can (they have the maximal
-- per-RFC capability by construction).
--
-- Storage shape — `rfc_invitations`:
--
-- * `id` — surrogate key; the revoke-by-id surface addresses a
-- single row without leaking the token shape.
--
-- * `rfc_slug` — TEXT NOT NULL; the RFC the invitation scopes to.
-- We FK against `cached_rfcs(slug)` so a withdrawn/deleted RFC
-- cascades its invitations away cleanly. The §4 cache contract
-- says cached_rfcs is rebuildable from Gitea; per the same
-- contract, invitations are app-truth (no Git substrate), so
-- the cascade is the right direction.
--
-- * `inviter_user_id` — the owner who issued the invite. ON
-- DELETE SET NULL because losing the inviter's user row should
-- not cascade-delete invitations they sent (the row stays as
-- audit; the UI renders "by (deleted user)" the same way the
-- audit log does for orphaned actors).
--
-- * `invitee_email` — TEXT NOT NULL; the email the invitation
-- was sent to. Stored verbatim (case-preserved) so the email
-- body can address the invitee in their original shape; the
-- accept path matches case-insensitively.
--
-- * `role_in_rfc` — CHECK in {'contributor' | 'discussant'}.
-- `contributor` lets the user open PRs against the RFC AND
-- post in its discussion (PR-permission strictly includes
-- discussion-permission); `discussant` only lets them post
-- in discussion. Future roles (e.g., 'arbiter') would be
-- additions; v0.16.0 ships the two.
--
-- * `status` — CHECK in {'pending' | 'accepted' | 'revoked' |
-- 'expired'}. Default 'pending'. `accepted` flips on the
-- accept endpoint; `revoked` on the owner's revoke gesture;
-- `expired` lazily on read (the accept endpoint refuses a
-- row whose expires_at has passed, regardless of the column
-- value).
--
-- * `token` — opaque high-entropy string the email link
-- encodes. Stored verbatim (not hashed) because the
-- invitation token is single-use and lower-stakes than a
-- session token: it grants per-RFC role only, and is bounded
-- by expires_at. Hashing the token here is a §19.2 candidate
-- if/when the threat model demands it. UNIQUE so the accept
-- path is a single-row lookup.
--
-- * `expires_at` — TEXT timestamp. Set to `created_at + 30 days`
-- at insert time by the application layer. Accept refuses past
-- this point; the row can still be revoked or re-issued.
--
-- * `created_at` — when the invitation was issued.
--
-- * `accepted_at` — when the invitee accepted (NULL until then).
--
-- * `accepted_by_user_id` — the user row that accepted. NULL
-- until acceptance. On a fresh email (no platform user yet)
-- the accept endpoint requires the invitee to sign in first
-- via the v0.7.0 OTC path; that path provisions the user row,
-- after which the accept call lands the user_id here.
--
-- Indexing:
--
-- * UNIQUE on `token` so the accept lookup is a primary-key-shape
-- hit and accidental collisions are detectable at insert time.
-- * (rfc_slug, status) for the owner's "list pending/accepted for
-- this RFC" surface — the most frequent query.
-- * (invitee_email, status) for a future cross-RFC "show me my
-- pending invites" inbox; v0.16.0 doesn't ship that surface but
-- the index slot is cheap and aligned with the data shape.
--
-- Storage shape — `rfc_collaborators`:
--
-- * `id` — surrogate key.
-- * `rfc_slug` — TEXT NOT NULL FK cached_rfcs(slug) ON DELETE CASCADE.
-- * `user_id` — INTEGER NOT NULL FK users(id) ON DELETE CASCADE.
-- A deleted user loses every per-RFC role automatically (mirrors
-- the device_trust / passcode cascade shape).
-- * `role_in_rfc` — same CHECK as the invitation table.
-- * `invitation_id` — INTEGER FK rfc_invitations(id) ON DELETE
-- SET NULL. Audit pointer to the row that minted this
-- collaborator; NULL is allowed so a future admin-direct grant
-- path (a §19.2 candidate) can mint a collaborator with no
-- originating invitation. v0.16.0 always populates this.
-- * `created_at` — when the collaborator row was minted.
--
-- Indexing on collaborators:
-- * UNIQUE on (rfc_slug, user_id) — a single user can hold at most
-- one role per RFC. Re-accepting an invitation upgrades the row
-- (discussant → contributor) but never duplicates.
-- * (user_id) for "what RFCs am I a collaborator on?" reads.
CREATE TABLE rfc_invitations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rfc_slug TEXT NOT NULL REFERENCES cached_rfcs(slug) ON DELETE CASCADE,
inviter_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
invitee_email TEXT NOT NULL,
role_in_rfc TEXT NOT NULL CHECK (role_in_rfc IN ('contributor', 'discussant')),
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'accepted', 'revoked', 'expired')),
token TEXT NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
accepted_at TEXT,
accepted_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL
);
CREATE UNIQUE INDEX idx_rfc_invitations_token ON rfc_invitations (token);
CREATE INDEX idx_rfc_invitations_rfc_status ON rfc_invitations (rfc_slug, status);
CREATE INDEX idx_rfc_invitations_email_status ON rfc_invitations (invitee_email, status);
CREATE TABLE rfc_collaborators (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rfc_slug TEXT NOT NULL REFERENCES cached_rfcs(slug) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role_in_rfc TEXT NOT NULL CHECK (role_in_rfc IN ('contributor', 'discussant')),
invitation_id INTEGER REFERENCES rfc_invitations(id) ON DELETE SET NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE UNIQUE INDEX idx_rfc_collaborators_unique ON rfc_collaborators (rfc_slug, user_id);
CREATE INDEX idx_rfc_collaborators_user ON rfc_collaborators (user_id);
@@ -0,0 +1,105 @@
-- §6.1 / v0.17.0: admin-create user with role + invite email (roadmap item #16).
--
-- Distinguishes from the v0.8.0 / v0.9.0 self-serve beta-access shape:
-- here an *admin* creates a `users` row *before* the invited person has
-- ever signed in, assigns them a role at creation time, and sends them
-- an invite email carrying a claim link. The invitee clicks the link,
-- the claim flow consumes the token (which is itself proof of email
-- control), the row is marked claimed, and the user is signed in
-- inheriting the pre-set role.
--
-- Migration slot 019 is allocated to this release. Slot 018 is reserved
-- for the parallel #12 release (per-RFC invitation, owner-only) shipping
-- in the same wave; the two features live in distinct tables
-- (`user_invite_tokens` here vs. `rfc_invitations` there) so they
-- coexist cleanly. Slot 016 was reserved+skipped by Session K during
-- v0.9.0 integration; slot 017 is the v0.11.0 device-trust table.
--
-- Open-question decisions settled in this release (see CHANGELOG):
-- * No `users` table changes — the brief floated `first_sign_in_at`
-- / `last_seen_at IS NULL` as the "(pending invite)" discriminator,
-- but the existing `users.last_seen_at` is NOT NULL with a
-- `datetime('now')` default (migrations/001) and there is no
-- `first_sign_in_at` column. Rather than land a schema migration to
-- introduce one, the discriminator is the existence of an active
-- (not-claimed, not-expired) row in `user_invite_tokens` joined on
-- `invited_user_id`. The admin user-listing carries a
-- `pending_invite` field populated via that join; on claim, the
-- invite row's `claimed_at` populates and the badge clears.
-- No new `permission_state` value is introduced either.
-- * The token is opaque (random URL-safe string, bcrypt-hashed at
-- rest), not a JWT, so admin revocation by row UPDATE works
-- without distributing a key-rotation gesture.
-- * The TTL is a constant (`INVITE_TOKEN_TTL_DAYS = 7` in
-- `backend/app/invites.py`); env-var configurability is a follow-up.
-- * Immediate-send (no admin-review-then-send queue) ships in this
-- release; admin-preview is a future enhancement.
-- * Bulk-invite (CSV paste) is deferred to a follow-up release;
-- v0.17.0 is one-at-a-time.
--
-- Storage shape:
--
-- * `id` — surrogate key. Lets the admin "pending invites" listing
-- address a row without leaking the token shape.
-- * `email` — the address the invite was sent to (case-insensitive
-- match at claim time, persisted verbatim for the audit trail).
-- * `role` — the role the invitee inherits on first sign-in. Pinned
-- via CHECK to the same set the §6.1 role flip accepts
-- (`owner` / `admin` / `contributor`) so a future role-set drift
-- fails loudly at insert rather than provisioning a ghost role.
-- * `first_name` / `last_name` — captured at create time so the
-- invitee skips the v0.8.0 capture-form step on first sign-in.
-- * `custom_message` — optional free-text from the admin (max 500
-- chars enforced at the API layer); embedded verbatim in the
-- email body if present.
-- * `token_hash` — bcrypt hash of the random opaque token. The
-- raw token only ever lives in the outbound email link and the
-- inbound claim body; server-side storage is the hash.
-- * `expires_at` — `created_at + 7 days` (default at the app layer
-- via `INVITE_TOKEN_TTL_DAYS`). A row past this stamp is dead;
-- the claim path refuses with HTTP 410.
-- * `created_at` — when the admin issued the invite.
-- * `created_by_admin_id` — FK into users(id) for the admin who
-- created the invite (no cascade; if the admin's row is deleted
-- the invite history stays so the audit trail survives).
-- * `claimed_at` — non-NULL once the invitee successfully claims.
-- A second claim attempt against an already-claimed row returns
-- HTTP 410.
-- * `claimed_by_user_id` — FK into users(id) for the user row
-- that consumed the token. In the common case this equals the
-- freshly-provisioned row that was created at invite time; the
-- FK lets the admin's "claimed" list join through.
-- * `invited_user_id` — FK into users(id) for the pre-provisioned
-- row. Created at invite time with `last_seen_at IS NULL` so the
-- v0.9.0 admin user-management page can render a "(pending
-- invite)" badge alongside existing users.
--
-- Indexing:
-- * Unique index on `token_hash` documents the no-collision
-- invariant (256 bits of CSPRNG entropy; collision is
-- structurally impossible, the unique constraint catches a
-- bug at insert time).
-- * Index on `(email, claimed_at)` so the "is this email already
-- invited?" pre-check the admin endpoint runs is a covering walk.
-- * Index on `(created_by_admin_id, created_at DESC)` for the
-- admin's "invites I've sent" listing.
CREATE TABLE user_invite_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'contributor')),
first_name TEXT NOT NULL DEFAULT '',
last_name TEXT NOT NULL DEFAULT '',
custom_message TEXT NOT NULL DEFAULT '',
token_hash TEXT NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
created_by_admin_id INTEGER NOT NULL REFERENCES users(id),
claimed_at TEXT,
claimed_by_user_id INTEGER REFERENCES users(id),
invited_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX idx_user_invite_tokens_hash ON user_invite_tokens (token_hash);
CREATE INDEX idx_user_invite_tokens_email ON user_invite_tokens (email, claimed_at);
CREATE INDEX idx_user_invite_tokens_admin ON user_invite_tokens (created_by_admin_id, created_at DESC);
@@ -0,0 +1,30 @@
-- v0.18.0 Slice 4: outbound_emails audit table.
--
-- Per the v0.18.0 email + webhook hygiene proposal §3, every send
-- helper writes a row to this table before returning, regardless
-- of outcome. status='sent' on success, 'failed' on exception,
-- 'deferred' on the dev-fallback path (no SMTP_HOST configured).
--
-- The table is queried by `GET /api/admin/outbound-emails` to
-- answer "did this person ever get their invite?" without having
-- to grep VM logs, and by the v0.18.0 Slice 5 bounce-correlation
-- hook (which looks up message_id when a POST lands at
-- /api/webhooks/email-bounce and marks the matching row
-- status='bounced').
CREATE TABLE IF NOT EXISTS outbound_emails (
id INTEGER PRIMARY KEY,
to_address TEXT NOT NULL,
from_address TEXT NOT NULL,
subject TEXT NOT NULL,
kind TEXT NOT NULL, -- 'otc' | 'invite' | 'notification' | 'bundle' | 'digest' | 'rfc-invite'
sent_at TEXT NOT NULL, -- ISO 8601, time the send was attempted
status TEXT NOT NULL, -- 'sent' | 'failed' | 'deferred' | 'bounced'
error TEXT, -- exception class + message if status='failed'
notification_id INTEGER, -- nullable FK to notifications.id for the watcher path
message_id TEXT -- the Message-ID header value, for bounce correlation
);
CREATE INDEX IF NOT EXISTS idx_outbound_emails_to ON outbound_emails(to_address);
CREATE INDEX IF NOT EXISTS idx_outbound_emails_sent_at ON outbound_emails(sent_at);
CREATE INDEX IF NOT EXISTS idx_outbound_emails_message ON outbound_emails(message_id);
@@ -0,0 +1,47 @@
-- Roadmap #26 (rfc-app v0.22.0): the optional "What will you be using
-- this for?" capture on the two propose surfaces.
--
-- The roadmap's framing names "the rfcs table" and "the PR-metadata
-- table" for a `proposed_use_case TEXT NULL` column. In this deployment
-- those two surfaces are the cache tables `cached_rfcs` and `cached_prs`
-- (002_cache.sql). We add the nullable column to each, matching the
-- existing naming convention (no NOT NULL, no default — NULL is the
-- "left blank" sentinel the view surfaces render tastefully).
--
-- BUT: those tables are *cache*, rebuilt from Gitea by the §4.1
-- reconciler (cache.py). The reconciler's INSERT...ON CONFLICT DO UPDATE
-- sets only the columns it knows about, so an unlisted column is
-- preserved on the update path — yet a propose/open never *writes* the
-- column through the cache (the write path is endpoint -> Gitea ->
-- reconcile, and the reconciler does not carry this field). So the cache
-- column alone would always read NULL.
--
-- The durable home is therefore a dedicated app-truth table the propose
-- /open endpoints write directly (keyed by the PR number, which is the
-- stable identity for both idea PRs and rfc_branch PRs) and the view
-- endpoints read back. This is not cache — it is canonical and survives
-- any reconcile. The cache columns are added too for parity with the
-- roadmap's literal shape and for any future reconciler that learns to
-- carry the field, but the side table is the source of truth read at
-- view time.
ALTER TABLE cached_rfcs ADD COLUMN proposed_use_case TEXT;
ALTER TABLE cached_prs ADD COLUMN proposed_use_case TEXT;
-- Canonical, reconcile-proof store. One row per propose/open that
-- supplied a use case. `scope` distinguishes the propose-RFC surface
-- ('rfc') from the propose-PR-against-an-RFC surface ('pr'); `pr_number`
-- is the join key the endpoints already have in hand. NULL/omitted use
-- cases simply never write a row here, so absence == "left blank".
CREATE TABLE proposed_use_cases (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scope TEXT NOT NULL CHECK (scope IN ('rfc', 'pr')),
rfc_slug TEXT NOT NULL,
pr_number INTEGER NOT NULL,
use_case TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (scope, pr_number)
);
CREATE INDEX idx_proposed_use_cases_lookup ON proposed_use_cases (scope, pr_number);
CREATE INDEX idx_proposed_use_cases_slug ON proposed_use_cases (scope, rfc_slug);
@@ -0,0 +1,54 @@
-- v0.23.0 / roadmap item #29: server-side sign-in state resume.
--
-- Track each authenticated user's last-viewed route + a small bag of
-- "light" component state so that the *next* sign-in can land the user
-- back where they left off, rather than always dropping them on the
-- empty-state home view.
--
-- Storage shape (one row per user — per-user, NOT per-device, per the
-- #29 "safe default"):
--
-- * `user_id` — PRIMARY KEY and FK into users(id) with cascade on
-- delete. INTEGER to match users.id (INTEGER PRIMARY KEY
-- AUTOINCREMENT). A deleted user automatically loses their stored
-- resume state. One row per user means a later sign-in on any
-- device resumes the most-recently-recorded route — the per-user
-- model the roadmap asks for.
--
-- * `last_route` — the frontend pathname the user was last on
-- (e.g. "/rfc/open-human-model"). TEXT, nullable until the first
-- route-change POST lands. NEVER contains draft-buffer contents —
-- it is a route only. See SPEC §6.2 "Sign-in state resume
-- (privacy)".
--
-- * `last_route_state` — a JSON-encoded bag of *light* component
-- state (scroll anchors, open-tab selection, filter chips, etc.).
-- SQLite has no native JSONB; we store JSON as TEXT exactly as the
-- rest of the app stores its JSON blobs (json.dumps / json.loads,
-- cf. permission_events.details, actions.details). Nullable.
-- PRIVACY INVARIANT: this column MUST NOT carry draft-buffer text,
-- PR bodies, comment drafts, or any user-typed content — only
-- ephemeral view state safe to replay. The PUT handler is the
-- enforcement point; the column comment is the contract.
--
-- * `resume_enabled` — the per-user opt-out flag. 1 (default) means
-- "resume me where I left off"; 0 means "always land on home". The
-- PUT handler no-ops the upsert when this is 0, and the read path
-- refuses to hand back a stored route when this is 0. A
-- profile-settings toggle UI to flip this is a follow-up (the
-- column + default-on behavior ship now); see CHANGELOG v0.23.0.
--
-- * `last_updated_at` — TEXT timestamp, app convention
-- `datetime('now')`, matching device_trust.last_seen_at /
-- users.last_seen_at. Refreshed on every successful upsert.
--
-- No new env vars. The debounce interval for the frontend route-change
-- POST is a frontend constant (~1s), not a server knob.
CREATE TABLE user_session_state (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
last_route TEXT,
last_route_state TEXT, -- JSON-encoded light state, nullable
resume_enabled INTEGER NOT NULL DEFAULT 1,
last_updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
@@ -0,0 +1,18 @@
-- v0.25.0 / security audit 0026, finding H1.
--
-- The OTC verify path had no attempt-limit or lockout, unlike the
-- passcode path (015_passcode.sql gave users.passcode_failed_attempts +
-- passcode_locked_until). This table gives the OTC verify endpoint the
-- same per-identity lockout shape. It is keyed by email rather than
-- user_id because an OTC sign-in may not have a users row yet — the row
-- is provisioned only on a *successful* verify, so the lockout state has
-- to survive independently of it.
--
-- The per-IP rate limiter (app/ratelimit.py) is the primary brute-force
-- defense; this table is the parity layer that mirrors the passcode
-- lockout and persists across restarts.
CREATE TABLE IF NOT EXISTS otc_verify_state (
email TEXT PRIMARY KEY,
failed_attempts INTEGER NOT NULL DEFAULT 0,
locked_until TEXT
);
@@ -0,0 +1,59 @@
-- v0.29.0 / roadmap #28 Part 3 — offer-to-contribute-to-a-pending-RFC.
--
-- When the #28 scanner matches a term in submitted PR/comment text to a
-- *pending* RFC (a super-draft: accepted-as-an-idea but not yet graduated
-- to an active RFC), the reader is offered a "ask to contribute" popover.
-- Submitting it lands a row here AND a notification in each owner's §15
-- inbox; the owner can accept (which fires #12's owner-invite flow with
-- the requester as the invitee) or decline (the requester is notified and
-- the request closes).
--
-- A "pending RFC" is scoped to a super-draft (cached_rfcs.state =
-- 'super-draft'): it is in cached_rfcs (so the rfc_invitations FK that the
-- accept path reuses resolves), it carries owners (owners_json) to route
-- the request to, and it already has a discussion/contribution surface to
-- open. Pre-merge idea PRs (not yet in cached_rfcs, no contribution
-- surface) are deliberately out of scope — see backend/app/rfc_links.py.
--
-- The request row is the persistent record; the inbox notification is the
-- owner-facing actionable surface keyed back to it via `notification_id`.
CREATE TABLE IF NOT EXISTS contribution_requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rfc_slug TEXT NOT NULL
REFERENCES cached_rfcs(slug) ON DELETE CASCADE,
requester_user_id INTEGER NOT NULL
REFERENCES users(id) ON DELETE CASCADE,
-- The term in the PR/comment text that surfaced the offer (e.g. the
-- super-draft's title). Carried for the owner's context line and the
-- requester's "what RFC" anchor; not a foreign key.
matched_term TEXT NOT NULL,
-- The three contribute-request fields (§15 / #26 vocabulary).
-- `who_i_am` and `why` are required; `use_case` mirrors #26's
-- optional ground-truth field.
who_i_am TEXT NOT NULL,
why TEXT NOT NULL,
use_case TEXT,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'accepted', 'declined')),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
decided_at TEXT,
decided_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
-- The rfc_invitations row minted on accept (the #12 reuse), and the
-- owner-facing notification row that carries the Accept/Decline action.
invitation_id INTEGER REFERENCES rfc_invitations(id) ON DELETE SET NULL,
notification_id INTEGER REFERENCES notifications(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_contribution_requests_rfc
ON contribution_requests(rfc_slug, status);
CREATE INDEX IF NOT EXISTS idx_contribution_requests_requester
ON contribution_requests(requester_user_id, status);
-- At most one open (pending) request per (RFC, requester): a second ask
-- while one is still pending is a 409, not a duplicate row. A decided
-- request (accepted/declined) does not block a fresh ask later.
CREATE UNIQUE INDEX IF NOT EXISTS idx_contribution_requests_one_open
ON contribution_requests(rfc_slug, requester_user_id)
WHERE status = 'pending';
+19
View File
@@ -0,0 +1,19 @@
"""Shared pytest fixtures for the backend suite.
Added in v0.27.0 (security audit 0026) alongside the new per-IP rate
limiter. The limiters in `app.ratelimit` are process-global singletons,
so their state survives across tests within a run; without a reset, the
accumulated requests from earlier tests exhaust the budget and later
tests see spurious 429s. This autouse fixture gives every test a clean
limiter window.
"""
import pytest
from app import ratelimit
@pytest.fixture(autouse=True)
def _reset_rate_limiters():
ratelimit._reset_all_for_tests()
yield
ratelimit._reset_all_for_tests()
@@ -0,0 +1,728 @@
"""End-to-end integration tests for v0.17.0's admin-create user +
invite-email + claim-flow vertical (roadmap item #16, §6.1).
The release lands three halves of the same surface:
* **Admin-create user** at `POST /api/admin/users`. The admin types
email, first/last name, role, and an optional custom message. The
framework provisions the invitee `users` row (granted, with the
chosen role) and writes a `user_invite_tokens` row carrying the
bcrypt-hashed opaque token. The "pending invite" discriminator is
the active `user_invite_tokens` row joined on `invited_user_id`,
not a NULL column on `users` (the existing `last_seen_at` column
is NOT NULL). An invite email dispatches via the existing SMTP
relay.
* **Pending-invite admin listing** at `GET /api/admin/users/invites`.
Lists active (not claimed, not expired) invites for the admin's
"I sent these but they haven't been claimed yet" view.
* **Claim** at `POST /api/invites/claim`. The invitee POSTs the token
they got via email; the framework verifies, marks the row claimed,
signs them in (skipping OTC on first sign-in per the roadmap), and
returns a `needs_passcode` hint for the frontend to route to the
passcode-set screen.
The tests prove:
* The happy path: admin creates invite row + email envelope land
invitee claims with the token session is established.
* Non-admin caller is refused 403.
* Self-invite is refused 422.
* Duplicate email is refused 409.
* Owner-grant by non-owner is refused 422.
* Malformed role is refused 422 (pydantic regex).
* Custom message over 500 chars is refused 422 (pydantic max_length).
* Claim with valid token: signs in + marks row claimed.
* Claim with expired token: HTTP 410.
* Claim with already-claimed token: HTTP 410.
* Claim with unknown token: HTTP 400.
* The admin-create gesture writes a `permission_events` row with
event_kind='user_invited'.
* The user listing surfaces the `pending_invite` field for invited-
but-not-yet-claimed users, and clears it after claim.
"""
from __future__ import annotations
import json
from test_propose_vertical import ( # noqa: F401 — fixtures land via import
FakeGitea,
app_with_fake_gitea,
provision_user_row,
sign_in_as,
tmp_env,
)
def _reset_outbound():
from app import email as email_mod
email_mod.reset_sent_envelopes()
def _outbound_invite_envelopes(to_address: str | None = None) -> list[dict]:
"""Pull the invite-kind envelopes off the shared notifier buffer.
Mirrors the OTC code-extraction helper in
test_admin_users_vertical.py invite emails land in the same
`_SENT` buffer with `kind='invite'`.
"""
from app import email as email_mod
out = []
for env in email_mod.sent_envelopes():
if env.get("kind") != "invite":
continue
if to_address is not None and env["to"] != to_address:
continue
out.append(env)
return out
def _extract_claim_url(envelope: dict) -> str:
"""Pull the claim URL out of the invite email body."""
for line in envelope["body"].splitlines():
line = line.strip()
if line.startswith("http") and "/invites/claim" in line:
return line
raise AssertionError(f"no claim URL in envelope body: {envelope['body']!r}")
def _extract_claim_token(envelope: dict) -> str:
"""Pull the `token` query-string param out of the claim URL."""
from urllib.parse import urlparse, parse_qs
url = _extract_claim_url(envelope)
qs = parse_qs(urlparse(url).query)
return qs["token"][0]
# ---------------------------------------------------------------------------
# Admin create + invite — happy path
# ---------------------------------------------------------------------------
def test_admin_create_user_invite_happy_path(app_with_fake_gitea):
"""Admin creates → user row + invite-token row + email envelope all
land; the response carries the created ids and the inviter is the
admin who issued the gesture."""
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=100, login="adminzero", role="admin")
sign_in_as(
client, user_id=100, gitea_login="adminzero",
display_name="Admin Zero", role="admin",
email="adminzero@test",
)
_reset_outbound()
r = client.post(
"/api/admin/users",
json={
"email": "invitee@example.com",
"first_name": "Inv",
"last_name": "Tee",
"role": "contributor",
"custom_message": "We chatted at the conference — welcome!",
},
)
assert r.status_code == 200, r.text
body = r.json()
assert body["ok"] is True
assert body["email"] == "invitee@example.com"
assert body["role"] == "contributor"
assert body["invite_id"] > 0
assert body["invited_user_id"] > 0
# User row exists with the chosen role + granted. The "pending
# invite" discriminator is the active `user_invite_tokens` row,
# not a NULL column on `users` — see the invites.create_invite
# docstring for the reasoning.
row = db.conn().execute(
"SELECT role, permission_state, first_name, last_name "
"FROM users WHERE email = ? COLLATE NOCASE",
("invitee@example.com",),
).fetchone()
assert row is not None
assert row["role"] == "contributor"
assert row["permission_state"] == "granted"
assert row["first_name"] == "Inv"
assert row["last_name"] == "Tee"
# Invite-token row exists with the matching ids and the custom
# message persisted verbatim.
invite = db.conn().execute(
"SELECT email, role, custom_message, created_by_admin_id, "
"invited_user_id, claimed_at FROM user_invite_tokens WHERE id = ?",
(body["invite_id"],),
).fetchone()
assert invite is not None
assert invite["email"] == "invitee@example.com"
assert invite["role"] == "contributor"
assert invite["custom_message"] == "We chatted at the conference — welcome!"
assert invite["created_by_admin_id"] == 100
assert invite["invited_user_id"] == body["invited_user_id"]
assert invite["claimed_at"] is None
# Email envelope landed with the invite kind and embeds the
# custom message + claim URL. The inviter display name comes
# off the DB row (which provision_user_row sets to
# login.capitalize()), not the sign_in_as cookie payload.
envelopes = _outbound_invite_envelopes(to_address="invitee@example.com")
assert len(envelopes) == 1
env = envelopes[0]
assert "Adminzero" in env["subject"] or "Adminzero" in env["body"]
assert "We chatted at the conference — welcome!" in env["body"]
# Claim URL is well-formed.
url = _extract_claim_url(env)
assert "/invites/claim?token=" in url
# `permission_events` row landed with event_kind='user_invited'.
ev = db.conn().execute(
"SELECT actor_user_id, subject_user_id, event_kind, details "
"FROM permission_events WHERE event_kind = 'user_invited'"
).fetchall()
assert len(ev) == 1
assert ev[0]["actor_user_id"] == 100
assert ev[0]["subject_user_id"] == body["invited_user_id"]
details = json.loads(ev[0]["details"])
assert details["email"] == "invitee@example.com"
assert details["role"] == "contributor"
# ---------------------------------------------------------------------------
# Refusals on the admin-create endpoint
# ---------------------------------------------------------------------------
def test_admin_create_user_invite_refuses_non_admin(app_with_fake_gitea):
"""A contributor caller is refused 403; an anonymous caller 401."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=110, login="contrib", role="contributor")
sign_in_as(
client, user_id=110, gitea_login="contrib",
display_name="Contrib", role="contributor",
)
r = client.post(
"/api/admin/users",
json={"email": "x@y.com", "role": "contributor"},
)
assert r.status_code == 403, r.text
client.cookies.clear()
r = client.post(
"/api/admin/users",
json={"email": "x@y.com", "role": "contributor"},
)
assert r.status_code == 401
def test_admin_create_user_invite_refuses_self_email(app_with_fake_gitea):
"""An admin trying to invite their own email is refused 422 —
self-invite is the wrong channel; the role-change endpoint exists
for self-edits."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=120, login="adm", role="admin")
# Manually set the admin's email since provision_user_row's
# fixture uses login@test; this is what we'll try to self-invite.
from app import db
db.conn().execute(
"UPDATE users SET email = ? WHERE id = ?",
("selfinviter@example.com", 120),
)
sign_in_as(
client, user_id=120, gitea_login="adm",
display_name="Adm", role="admin",
email="selfinviter@example.com",
)
r = client.post(
"/api/admin/users",
json={
"email": "selfinviter@example.com",
"role": "contributor",
},
)
assert r.status_code == 422, r.text
assert "yourself" in r.json()["detail"].lower()
def test_admin_create_user_invite_refuses_duplicate_email(app_with_fake_gitea):
"""An admin trying to invite an email that already maps to a users
row is refused 409 the existing role / grant gestures are the
right surface for an existing user."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=130, login="adminD", role="admin")
provision_user_row(user_id=131, login="existingone", role="contributor")
sign_in_as(
client, user_id=130, gitea_login="adminD",
display_name="Admin D", role="admin",
)
# provision_user_row sets email to <login>@test, so:
r = client.post(
"/api/admin/users",
json={
"email": "existingone@test",
"role": "contributor",
},
)
assert r.status_code == 409, r.text
def test_admin_create_user_invite_owner_grant_refused_for_non_owner(app_with_fake_gitea):
"""An admin (not owner) trying to invite a fresh user as `owner` is
refused 422 §6.1's owner-zero is the only bootstrap path."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=140, login="adminNoOwner", role="admin")
sign_in_as(
client, user_id=140, gitea_login="adminNoOwner",
display_name="Admin", role="admin",
)
r = client.post(
"/api/admin/users",
json={
"email": "wouldbeowner@example.com",
"role": "owner",
},
)
assert r.status_code == 422, r.text
def test_admin_create_user_invite_owner_can_invite_as_owner(app_with_fake_gitea):
"""A sitting owner can invite a fresh user as `owner` — the §6.1
role-grant channel. Sanity check that the owner-grant path itself
works, paired with the refusal above."""
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=150, login="ownerzero", role="owner")
sign_in_as(
client, user_id=150, gitea_login="ownerzero",
display_name="Owner Zero", role="owner",
)
r = client.post(
"/api/admin/users",
json={
"email": "newowner@example.com",
"role": "owner",
},
)
assert r.status_code == 200, r.text
row = db.conn().execute(
"SELECT role FROM users WHERE email = ? COLLATE NOCASE",
("newowner@example.com",),
).fetchone()
assert row["role"] == "owner"
def test_admin_create_user_invite_refuses_malformed_role(app_with_fake_gitea):
"""The pydantic regex refuses any role outside the §6.1 set."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=160, login="adminR", role="admin")
sign_in_as(
client, user_id=160, gitea_login="adminR",
display_name="Admin R", role="admin",
)
r = client.post(
"/api/admin/users",
json={
"email": "ok@example.com",
"role": "superuser",
},
)
assert r.status_code == 422
def test_admin_create_user_invite_refuses_long_custom_message(app_with_fake_gitea):
"""Custom message over the 500-char ceiling is refused 422."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=170, login="adminM", role="admin")
sign_in_as(
client, user_id=170, gitea_login="adminM",
display_name="Admin M", role="admin",
)
r = client.post(
"/api/admin/users",
json={
"email": "ok@example.com",
"role": "contributor",
"custom_message": "x" * 501,
},
)
assert r.status_code == 422
# ---------------------------------------------------------------------------
# Claim flow
# ---------------------------------------------------------------------------
def test_claim_with_valid_token_signs_in_and_marks_claimed(app_with_fake_gitea):
"""End-to-end: admin creates → invitee posts the token to
/api/invites/claim session lands + row marked claimed +
last_seen_at stamps on the user row (the pending-invite
discriminator clears)."""
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=200, login="adminC", role="admin")
sign_in_as(
client, user_id=200, gitea_login="adminC",
display_name="Admin C", role="admin",
)
_reset_outbound()
r = client.post(
"/api/admin/users",
json={
"email": "claimant@example.com",
"first_name": "Clai",
"last_name": "Mant",
"role": "contributor",
},
)
assert r.status_code == 200
invite_id = r.json()["invite_id"]
invited_user_id = r.json()["invited_user_id"]
env = _outbound_invite_envelopes("claimant@example.com")[0]
token = _extract_claim_token(env)
# The invitee's request is anonymous (they have no session
# yet). We clear the admin's session cookie to simulate this.
client.cookies.clear()
r = client.post(
"/api/invites/claim",
json={"token": token},
)
assert r.status_code == 200, r.text
body = r.json()
assert body["ok"] is True
assert body["user"]["id"] == invited_user_id
assert body["user"]["role"] == "contributor"
assert body["user"]["permission_state"] == "granted"
# The user has no passcode set yet → frontend should route to
# passcode-set per the roadmap.
assert body["needs_passcode"] is True
# Row marked claimed; last_seen_at populated.
invite = db.conn().execute(
"SELECT claimed_at, claimed_by_user_id FROM user_invite_tokens "
"WHERE id = ?",
(invite_id,),
).fetchone()
assert invite["claimed_at"] is not None
assert invite["claimed_by_user_id"] == invited_user_id
user_row = db.conn().execute(
"SELECT last_seen_at FROM users WHERE id = ?",
(invited_user_id,),
).fetchone()
assert user_row["last_seen_at"] is not None
def test_claim_with_expired_token_returns_410(app_with_fake_gitea):
"""A token whose `expires_at` has passed surfaces as HTTP 410."""
from fastapi.testclient import TestClient
from app import db, invites
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=210, login="adminE", role="admin")
sign_in_as(
client, user_id=210, gitea_login="adminE",
display_name="Admin E", role="admin",
)
_reset_outbound()
# Create the invite, then back-date the expires_at to the past.
r = client.post(
"/api/admin/users",
json={
"email": "expired@example.com",
"role": "contributor",
},
)
assert r.status_code == 200
invite_id = r.json()["invite_id"]
db.conn().execute(
"UPDATE user_invite_tokens SET expires_at = datetime('now', '-1 day') "
"WHERE id = ?",
(invite_id,),
)
env = _outbound_invite_envelopes("expired@example.com")[0]
token = _extract_claim_token(env)
client.cookies.clear()
r = client.post("/api/invites/claim", json={"token": token})
assert r.status_code == 410, r.text
assert "expired" in r.json()["detail"].lower()
def test_claim_with_already_claimed_token_returns_410(app_with_fake_gitea):
"""Re-claiming an already-consumed token surfaces as HTTP 410."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=220, login="adminA", role="admin")
sign_in_as(
client, user_id=220, gitea_login="adminA",
display_name="Admin A", role="admin",
)
_reset_outbound()
r = client.post(
"/api/admin/users",
json={
"email": "twice@example.com",
"role": "contributor",
},
)
assert r.status_code == 200
env = _outbound_invite_envelopes("twice@example.com")[0]
token = _extract_claim_token(env)
client.cookies.clear()
# First claim succeeds.
r = client.post("/api/invites/claim", json={"token": token})
assert r.status_code == 200
# Second claim, with the same token, refuses with 410.
client.cookies.clear()
r = client.post("/api/invites/claim", json={"token": token})
assert r.status_code == 410, r.text
assert "already" in r.json()["detail"].lower()
def test_claim_with_unknown_token_returns_400(app_with_fake_gitea):
"""A token that doesn't match any active invite is HTTP 400."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
# No invite ever created; the token is whatever the attacker
# types in. The endpoint should refuse without disclosing
# whether the token "looked" right.
r = client.post(
"/api/invites/claim",
json={"token": "totally-made-up-token-string-that-is-not-real"},
)
assert r.status_code == 400, r.text
# ---------------------------------------------------------------------------
# Pending-invite admin listing
# ---------------------------------------------------------------------------
def test_pending_invites_listing_shows_active_invites_only(app_with_fake_gitea):
"""The `GET /api/admin/users/invites` listing filters to active
invites claimed and expired rows do not surface here (the admin
user-listing carries the per-row pending-invite badge for the
living rows; once claimed, the badge clears)."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=300, login="adminL", role="admin")
sign_in_as(
client, user_id=300, gitea_login="adminL",
display_name="Admin L", role="admin",
)
_reset_outbound()
# Create three invites: one stays pending, one we'll claim, one
# we'll back-date to expired.
for email in ("alive@ex.co", "claimed@ex.co", "expired@ex.co"):
r = client.post(
"/api/admin/users",
json={"email": email, "role": "contributor"},
)
assert r.status_code == 200
# Claim the middle one.
env = _outbound_invite_envelopes("claimed@ex.co")[0]
token_claim = _extract_claim_token(env)
# Expire the third one.
from app import db
db.conn().execute(
"UPDATE user_invite_tokens SET expires_at = datetime('now', '-1 day') "
"WHERE email = 'expired@ex.co'"
)
# The admin's session is still on the cookie. Claim works
# anonymously; we clear and restore.
admin_cookie = client.cookies.get("rfc_session")
client.cookies.clear()
r = client.post("/api/invites/claim", json={"token": token_claim})
assert r.status_code == 200
client.cookies.set("rfc_session", admin_cookie)
r = client.get("/api/admin/users/invites")
assert r.status_code == 200, r.text
items = r.json()["items"]
emails = sorted(i["email"] for i in items)
assert emails == ["alive@ex.co"]
def test_pending_invite_badge_clears_after_claim(app_with_fake_gitea):
"""The `/api/admin/users` listing surfaces `pending_invite` while
the invite is unclaimed; after the invitee claims, the row's
pending_invite is null."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=310, login="adminB", role="admin")
sign_in_as(
client, user_id=310, gitea_login="adminB",
display_name="Admin B", role="admin",
)
_reset_outbound()
r = client.post(
"/api/admin/users",
json={"email": "badgey@ex.co", "role": "contributor"},
)
assert r.status_code == 200
invited_id = r.json()["invited_user_id"]
# Before claim — pending_invite is populated.
r = client.get("/api/admin/users")
assert r.status_code == 200
row = next(u for u in r.json()["items"] if u["id"] == invited_id)
assert row["pending_invite"] is not None
assert row["pending_invite"]["invite_id"] > 0
# Claim.
env = _outbound_invite_envelopes("badgey@ex.co")[0]
token = _extract_claim_token(env)
admin_cookie = client.cookies.get("rfc_session")
client.cookies.clear()
r = client.post("/api/invites/claim", json={"token": token})
assert r.status_code == 200
client.cookies.set("rfc_session", admin_cookie)
# After claim — pending_invite is null.
r = client.get("/api/admin/users")
assert r.status_code == 200
row = next(u for u in r.json()["items"] if u["id"] == invited_id)
assert row["pending_invite"] is None
def test_pending_invites_listing_admin_only(app_with_fake_gitea):
"""The listing requires admin/owner; contributor gets 403."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=320, login="contribL", role="contributor")
sign_in_as(
client, user_id=320, gitea_login="contribL",
display_name="Contrib L", role="contributor",
)
r = client.get("/api/admin/users/invites")
assert r.status_code == 403
# ---------------------------------------------------------------------------
# v0.18.0: invite-envelope header shape — Slice 2
#
# Invite mail goes through `build_envelope` and MUST land Date,
# Message-ID, Auto-Submitted, AND a `List-Unsubscribe: <mailto:…>`
# (no URL — the invitee isn't a user yet, so no per-user opt-out
# row exists). The mailto: target is the operator's `EMAIL_FROM`
# by default; the operator can override via `EMAIL_UNSUBSCRIBE_MAILTO`.
# ---------------------------------------------------------------------------
def _provision_admin_and_send_invite(client, app_with_fake_gitea_fixture, *, to: str = "headers@ex.co"):
provision_user_row(user_id=400, login="adminH", role="admin")
sign_in_as(
client, user_id=400, gitea_login="adminH",
display_name="Admin H", role="admin",
email="adminh@test",
)
_reset_outbound()
r = client.post(
"/api/admin/users",
json={
"email": to,
"first_name": "Header",
"last_name": "Test",
"role": "contributor",
"custom_message": "",
},
)
assert r.status_code == 200, r.text
return _outbound_invite_envelopes(to)[-1]
def test_invite_envelope_sets_date_messageid_autosubmitted(app_with_fake_gitea):
from fastapi.testclient import TestClient
from email.utils import parsedate_to_datetime
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
env = _provision_admin_and_send_invite(client, (app, _fake))
msg = env["message"]
assert parsedate_to_datetime(msg["Date"]) is not None
assert msg["Message-ID"].startswith("<") and msg["Message-ID"].endswith(">")
assert msg["Auto-Submitted"] == "auto-generated"
def test_invite_envelope_has_mailto_list_unsubscribe_only(app_with_fake_gitea):
"""The invitee isn't a user yet — no per-user opt-out URL is
available. The `List-Unsubscribe` MUST be a mailto: form, and
the `List-Unsubscribe-Post` header MUST be absent (the
one-click semantic requires a URL the MUA can POST to)."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
env = _provision_admin_and_send_invite(client, (app, _fake))
msg = env["message"]
lu = msg["List-Unsubscribe"]
assert lu is not None and lu.startswith("<mailto:")
# No URL part — invite is mailto-only.
assert "https://" not in lu and "http://" not in lu
assert msg["List-Unsubscribe-Post"] is None
def test_invite_envelope_respects_email_unsubscribe_mailto_override(app_with_fake_gitea, monkeypatch):
"""When `EMAIL_UNSUBSCRIBE_MAILTO` is set, the mailto: target on
`List-Unsubscribe` honors it (lets a deployment route opt-outs
to a humans-monitored mailbox distinct from the no-reply
sender)."""
from fastapi.testclient import TestClient
monkeypatch.setenv("EMAIL_UNSUBSCRIBE_MAILTO", "ohm@wiggleverse.org?subject=remove")
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
env = _provision_admin_and_send_invite(client, (app, _fake))
msg = env["message"]
assert "ohm@wiggleverse.org?subject=remove" in msg["List-Unsubscribe"]
@@ -0,0 +1,236 @@
"""v0.29.0 / roadmap #28 Parts 2 & 3 — create-RFC offers + contribute-to-
pending requests.
Two layers, mirroring test_rfc_links_vertical.py:
* The PR-view scanner surfaces `rfc-pending` (Part 3) and `rfc-candidate`
(Part 2) segments alongside Part 1's `rfc` links.
* The contribute-request flow end-to-end: a non-owner asks, each owner
gets an actionable §15 notification, accept fires #12's invite flow,
decline notifies the requester.
Reuses the FakeGitea + seed/session helpers from the existing suites.
"""
from __future__ import annotations
import json
from fastapi.testclient import TestClient
from app import db
from test_propose_vertical import ( # noqa: F401
FakeGitea,
app_with_fake_gitea,
provision_user_row,
sign_in_as,
tmp_env,
)
from test_rfc_view_vertical import SEED_BODY, seed_active_rfc
from test_super_draft_vertical import seed_super_draft
from test_rfc_links_vertical import _open_pr_on
def _set_owner(slug: str, login: str) -> None:
db.conn().execute(
"UPDATE cached_rfcs SET owners_json = ? WHERE slug = ?",
(json.dumps([login]), slug),
)
def _set_tags(slug: str, tags: list[str]) -> None:
db.conn().execute(
"UPDATE cached_rfcs SET tags_json = ? WHERE slug = ?",
(json.dumps(tags), slug),
)
def _segs(segments, kind):
return [s for s in segments if s["type"] == kind]
# ---------------------------------------------------------------------------
# Part 2 + Part 3 — scanner surfaces on the PR view
# ---------------------------------------------------------------------------
def test_pending_and_candidate_segments_on_pr(app_with_fake_gitea):
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
# Host active RFC (OHM — single word, contributes no keys itself)
# carrying a multi-word tag with no defining RFC: the Part 2
# candidate. And a pending super-draft owned by alice: the Part 3
# contribute target.
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
_set_tags("ohm", ["memory model", "identity"])
seed_super_draft(fake, slug="open-human-model", title="Open Human Model",
pitch="A framework for representing humans.", proposed_by="alice")
_set_owner("open-human-model", "alice")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
pr_number = _open_pr_on(
client, fake, host_slug="ohm",
description="This builds on the Open Human Model and the memory model.",
)
pr = client.get(f"/api/rfcs/ohm/prs/{pr_number}").json()
segs = pr["description_segments"]
pending = _segs(segs, "rfc-pending")
assert len(pending) == 1
assert pending[0]["slug"] == "open-human-model"
assert pending[0]["label"] == "Open Human Model"
assert pending[0]["owner"] == "Alice" # display_name of the owner
candidate = _segs(segs, "rfc-candidate")
assert len(candidate) == 1
assert candidate[0]["term"] == "memory model"
# "identity" is a single-word tag — deliberately NOT a candidate.
assert all("identity" not in s.get("term", "") for s in candidate)
# ---------------------------------------------------------------------------
# Part 3 — the contribute-request flow
# ---------------------------------------------------------------------------
def _seed_pending_owned_by_alice(fake):
provision_user_row(user_id=2, login="alice", role="contributor")
provision_user_row(user_id=3, login="bob", role="contributor")
seed_super_draft(fake, slug="open-human-model", title="Open Human Model",
pitch="A framework.", proposed_by="alice")
_set_owner("open-human-model", "alice")
_REQUEST = {
"matched_term": "Open Human Model",
"who_i_am": "Bob, a researcher",
"why": "I have relevant prior work to bring.",
"use_case": "Building an identity tool.",
}
def test_request_accept_invites_and_notifies(app_with_fake_gitea):
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_seed_pending_owned_by_alice(fake)
# Bob asks to contribute.
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob",
role="contributor", email="bob@test")
r = client.post("/api/rfcs/open-human-model/contribution-requests", json=_REQUEST)
assert r.status_code == 200, r.text
request_id = r.json()["id"]
assert r.json()["status"] == "pending"
# A second ask while pending is a 409, not a duplicate row.
assert client.post("/api/rfcs/open-human-model/contribution-requests",
json=_REQUEST).status_code == 409
# Alice (owner) sees the actionable notification with full detail.
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice",
role="contributor", email="alice@test")
inbox = client.get("/api/notifications").json()
reqs = [i for i in inbox["items"]
if i["event_kind"] == "contribution_request_on_pending_rfc"]
assert len(reqs) == 1
assert "wants to contribute" in reqs[0]["summary"]
assert reqs[0]["extras"]["who_i_am"] == "Bob, a researcher"
assert reqs[0]["extras"]["request_id"] == request_id
# Alice accepts → #12 invitation minted for bob's email.
acc = client.post(f"/api/rfcs/open-human-model/contribution-requests/{request_id}/accept")
assert acc.status_code == 200, acc.text
assert acc.json()["status"] == "accepted"
assert acc.json()["invitation_id"]
inv = db.conn().execute(
"SELECT invitee_email, role_in_rfc, status FROM rfc_invitations "
"WHERE rfc_slug = 'open-human-model'"
).fetchone()
assert inv["invitee_email"] == "bob@test"
assert inv["role_in_rfc"] == "contributor"
assert inv["status"] == "pending"
# The request is settled — re-accepting is a 409.
assert client.post(
f"/api/rfcs/open-human-model/contribution-requests/{request_id}/accept"
).status_code == 409
# Bob gets the accepted echo in his inbox.
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob",
role="contributor", email="bob@test")
bob_kinds = [i["event_kind"] for i in client.get("/api/notifications").json()["items"]]
assert "contribution_request_accepted" in bob_kinds
def test_decline_notifies_requester(app_with_fake_gitea):
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_seed_pending_owned_by_alice(fake)
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob",
role="contributor", email="bob@test")
request_id = client.post(
"/api/rfcs/open-human-model/contribution-requests", json=_REQUEST
).json()["id"]
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice",
role="contributor", email="alice@test")
dec = client.post(f"/api/rfcs/open-human-model/contribution-requests/{request_id}/decline")
assert dec.status_code == 200, dec.text
assert dec.json()["status"] == "declined"
row = db.conn().execute(
"SELECT status FROM contribution_requests WHERE id = ?", (request_id,)
).fetchone()
assert row["status"] == "declined"
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob",
role="contributor", email="bob@test")
bob_kinds = [i["event_kind"] for i in client.get("/api/notifications").json()["items"]]
assert "contribution_request_declined" in bob_kinds
def test_owner_cannot_request_own_rfc(app_with_fake_gitea):
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_seed_pending_owned_by_alice(fake)
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice",
role="contributor", email="alice@test")
r = client.post("/api/rfcs/open-human-model/contribution-requests", json=_REQUEST)
assert r.status_code == 409
assert "own" in r.json()["detail"].lower()
def test_request_on_active_rfc_rejected(app_with_fake_gitea):
# The contribute offer only exists for pending super-drafts; an active
# RFC uses the Part-1 link instead.
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=3, login="bob", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob",
role="contributor", email="bob@test")
r = client.post("/api/rfcs/ohm/contribution-requests", json=_REQUEST)
assert r.status_code == 409
def test_contribution_target_eligibility(app_with_fake_gitea):
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_seed_pending_owned_by_alice(fake)
# Anonymous: not eligible, told to sign in.
t = client.get("/api/rfcs/open-human-model/contribution-target").json()
assert t["eligible"] is False
assert "sign in" in (t["reason"] or "").lower()
assert t["owner"] == "Alice"
# Bob: eligible until he has a pending ask, then not.
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob",
role="contributor", email="bob@test")
assert client.get("/api/rfcs/open-human-model/contribution-target").json()["eligible"] is True
client.post("/api/rfcs/open-human-model/contribution-requests", json=_REQUEST)
after = client.get("/api/rfcs/open-human-model/contribution-target").json()
assert after["already_requested"] is True
assert after["eligible"] is False
@@ -0,0 +1,379 @@
"""v0.19.0 / roadmap item #30 — `/api/docs/sessions/*` endpoints.
The framework mediates reads against the public
`wiggleverse/ohm-session-history` gitea repo so the rendered
`/docs/sessions/*` surface inherits the same chrome as `/docs/user-guide`.
This test suite covers the four endpoints + the in-process TTL cache,
mocking the upstream HTTP via `httpx.MockTransport` (the same shape the
rest of the test suite uses for Gitea).
The tests do NOT spin up the full FakeGitea they only need to mock
the gitea raw URL surface (and the contents API for the session-index
endpoint). Each test owns its mock transport so we can dial in 200 /
404 / 5xx / timeout responses per case.
Path-validation tests intentionally bypass the network a malformed
`nnnn` or `filename` MUST be rejected at the route layer before any
upstream call is made.
"""
from __future__ import annotations
import json
import httpx
import pytest
from fastapi.testclient import TestClient
from app import docs_sessions
# Reuse the proven app-construction fixtures from the proposal vertical
# (same shape every test file in this repo uses).
from test_propose_vertical import ( # noqa: F401
app_with_fake_gitea,
tmp_env,
)
# ---------------------------------------------------------------------------
# Test scaffolding
# ---------------------------------------------------------------------------
class _UpstreamHandler:
"""Records every URL the docs_sessions module fetched and returns
canned responses keyed by URL substring. Allows the test to assert
on call count (for cache verification) without needing a full Gitea
simulator.
`calls` tracks only URLs that hit the session-history host (the
`OHM_SESSION_HISTORY_*` bases) so reconciler/Gitea-side calls which
also pass through this handler because `httpx.AsyncClient` is a
shared attribute the gitea-side fixture also monkeypatches don't
inflate the count we use for cache-hit assertions.
"""
_SESSION_HOST_MARKERS = ("ohm-session-history", "wiggleverse/ohm-session-history")
def __init__(self, responses: dict[str, tuple[int, str]]):
self.responses = responses
self.calls: list[str] = []
def __call__(self, request: httpx.Request) -> httpx.Response:
url = str(request.url)
if any(m in url for m in self._SESSION_HOST_MARKERS):
self.calls.append(url)
for key, (status, body) in self.responses.items():
if key in url:
return httpx.Response(status, text=body)
# Default: 404. Lets tests skip declaring "the rest is 404".
return httpx.Response(404, text="not found")
@pytest.fixture
def patched_httpx(monkeypatch):
"""Provide a hook the test can call to install a MockTransport.
Returns a closure: `install(handler)` patches
`app.docs_sessions.httpx.AsyncClient` so every constructed client
uses the handler's transport.
NB: the upstream `app_with_fake_gitea` fixture also patches
`httpx.AsyncClient` (to route gitea calls to a FakeGitea handler),
and because `httpx` is a single shared module, that patch mutates
the *same* `AsyncClient` attribute we're about to overwrite. We
therefore import the unpatched class directly from the
`httpx._client` module so our install path can construct a fresh
real client around our MockTransport without going through the
FakeGitea wrapper.
"""
from httpx._client import AsyncClient as RealAsyncClient
def install(handler):
def patched(*args, **kwargs):
kwargs["transport"] = httpx.MockTransport(handler)
return RealAsyncClient(*args, **kwargs)
monkeypatch.setattr("app.docs_sessions.httpx.AsyncClient", patched)
return handler
yield install
@pytest.fixture
def app(app_with_fake_gitea):
"""Wrap the shared app fixture, resetting the docs-sessions cache so
cross-test state can't leak. Returns just the FastAPI app — the
fake-Gitea handle is irrelevant for the docs-sessions surface.
"""
docs_sessions.reset_cache()
fastapi_app, _fake = app_with_fake_gitea
return fastapi_app
# ---------------------------------------------------------------------------
# Manifest endpoint
# ---------------------------------------------------------------------------
def test_manifest_happy_path(app, patched_httpx):
manifest_body = json.dumps(
{
"0001": {"title": "Bootstrap"},
"0014": {"title": "Wave 7 driver"},
}
)
patched_httpx(
_UpstreamHandler({"sessions.json": (200, manifest_body)})
)
with TestClient(app) as client:
r = client.get("/api/docs/sessions/manifest")
assert r.status_code == 200, r.text
payload = r.json()
assert payload == {
"0001": {"title": "Bootstrap"},
"0014": {"title": "Wave 7 driver"},
}
def test_manifest_empty_state(app, patched_httpx):
"""A 404 from gitea means the manifest hasn't been published yet.
The endpoint returns HTTP 200 + `{}` so the frontend can render the
no-sessions-yet state without an error banner.
"""
patched_httpx(_UpstreamHandler({"sessions.json": (404, "not found")}))
with TestClient(app) as client:
r = client.get("/api/docs/sessions/manifest")
assert r.status_code == 200, r.text
assert r.json() == {}
def test_manifest_upstream_5xx_returns_502(app, patched_httpx):
patched_httpx(_UpstreamHandler({"sessions.json": (500, "internal")}))
with TestClient(app) as client:
r = client.get("/api/docs/sessions/manifest")
assert r.status_code == 502, r.text
body = r.json()
assert body["detail"]["error"] == "session-history fetch failed"
# ---------------------------------------------------------------------------
# About endpoint
# ---------------------------------------------------------------------------
def test_about_happy_path(app, patched_httpx):
readme = "# OHM session history\n\nWelcome.\n"
patched_httpx(_UpstreamHandler({"README.md": (200, readme)}))
with TestClient(app) as client:
r = client.get("/api/docs/sessions/about")
assert r.status_code == 200, r.text
assert "text/markdown" in r.headers["content-type"]
assert r.text == readme
def test_about_404(app, patched_httpx):
patched_httpx(_UpstreamHandler({"README.md": (404, "")}))
with TestClient(app) as client:
r = client.get("/api/docs/sessions/about")
assert r.status_code == 404, r.text
def test_about_upstream_5xx_returns_502(app, patched_httpx):
patched_httpx(_UpstreamHandler({"README.md": (503, "down")}))
with TestClient(app) as client:
r = client.get("/api/docs/sessions/about")
assert r.status_code == 502, r.text
# ---------------------------------------------------------------------------
# Transcript endpoint
# ---------------------------------------------------------------------------
def test_transcript_happy_path(app, patched_httpx):
body = "# Session 0017.1 — Transcript\n\nbody.\n"
fname = "SESSION-0017.1-TRANSCRIPT-2026-05-28T08-50--2026-05-28T11-20.md"
patched_httpx(_UpstreamHandler({fname: (200, body)}))
with TestClient(app) as client:
r = client.get(f"/api/docs/sessions/0017/{fname}")
assert r.status_code == 200, r.text
assert "text/markdown" in r.headers["content-type"]
assert r.text == body
def test_transcript_404(app, patched_httpx):
fname = "SESSION-9999.0-TRANSCRIPT-2026-01-01T00-00--2026-01-01T00-01.md"
patched_httpx(_UpstreamHandler({})) # everything 404s
with TestClient(app) as client:
r = client.get(f"/api/docs/sessions/9999/{fname}")
assert r.status_code == 404, r.text
def test_transcript_rejects_invalid_session_dir(app, patched_httpx):
"""`nnnn` must be exactly 4 digits. `abcd` fails before any
network call.
"""
handler = _UpstreamHandler({})
patched_httpx(handler)
with TestClient(app) as client:
r = client.get(
"/api/docs/sessions/abcd/"
"SESSION-0001.0-TRANSCRIPT-2026-01-01T00-00--2026-01-01T00-01.md"
)
assert r.status_code == 400, r.text
assert handler.calls == [], "rejected path must not hit the network"
def test_transcript_rejects_path_traversal(app, patched_httpx):
"""A filename that doesn't match the SESSION-NNNN.M-TRANSCRIPT regex
is rejected. `../etc/passwd` doesn't match; neither does the legacy
flat-root `SESSION-A-TRANSCRIPT.md`.
"""
handler = _UpstreamHandler({})
patched_httpx(handler)
with TestClient(app) as client:
# Path traversal — but FastAPI normalizes `..` in the path before
# routing, so this resolves to /api/docs/sessions/0001/etc/passwd
# which routes to the same handler with filename=etc/passwd, and
# gets rejected as an invalid transcript filename. Even if the
# normalization didn't apply (some intermediary), the regex
# check rejects anything not matching the SESSION- prefix.
r = client.get(
"/api/docs/sessions/0001/etc%2Fpasswd"
)
# 400 (filename validation) or 404 (path didn't match the
# route); both reject before any network call. Either is
# acceptable — what matters is that we never fetched it.
assert r.status_code in (400, 404), r.text
assert handler.calls == [], "rejected path must not hit the network"
def test_transcript_rejects_legacy_flat_filename(app, patched_httpx):
"""Legacy `SESSION-A-TRANSCRIPT.md` (letter form) doesn't match the
numeric regex by design, since post-#23 transcripts live in
`NNNN/` folders with numeric names. Reject 400.
"""
handler = _UpstreamHandler({})
patched_httpx(handler)
with TestClient(app) as client:
r = client.get("/api/docs/sessions/0001/SESSION-A-TRANSCRIPT.md")
assert r.status_code == 400, r.text
assert handler.calls == [], "rejected path must not hit the network"
def test_transcript_upstream_5xx_returns_502(app, patched_httpx):
fname = "SESSION-0001.0-TRANSCRIPT-2026-01-01T00-00--2026-01-01T00-01.md"
patched_httpx(_UpstreamHandler({fname: (502, "bad gateway")}))
with TestClient(app) as client:
r = client.get(f"/api/docs/sessions/0001/{fname}")
assert r.status_code == 502, r.text
# ---------------------------------------------------------------------------
# Session-index endpoint
# ---------------------------------------------------------------------------
def test_session_index_happy_path(app, patched_httpx):
"""The contents API returns a JSON list of file entries. The
endpoint filters to entries that match the transcript regex and
sorts them.
"""
# Two transcripts (driver + subagent) + a non-transcript sibling
# that must be filtered out.
listing = json.dumps(
[
{
"name": "SESSION-0017.0-TRANSCRIPT-"
"2026-05-28T08-30--2026-05-28T12-00.md",
"type": "file",
},
{
"name": "SESSION-0017.1-TRANSCRIPT-"
"2026-05-28T08-50--2026-05-28T11-20.md",
"type": "file",
},
{"name": "notes.md", "type": "file"}, # not a transcript
{"name": "attached-dir", "type": "dir"}, # not a file
]
)
patched_httpx(_UpstreamHandler({"/contents/0017": (200, listing)}))
with TestClient(app) as client:
r = client.get("/api/docs/sessions/0017/index")
assert r.status_code == 200, r.text
files = r.json()["files"]
assert files == [
"SESSION-0017.0-TRANSCRIPT-2026-05-28T08-30--2026-05-28T12-00.md",
"SESSION-0017.1-TRANSCRIPT-2026-05-28T08-50--2026-05-28T11-20.md",
]
def test_session_index_404(app, patched_httpx):
patched_httpx(_UpstreamHandler({})) # everything 404s
with TestClient(app) as client:
r = client.get("/api/docs/sessions/9999/index")
assert r.status_code == 404, r.text
def test_session_index_rejects_invalid_session_dir(app, patched_httpx):
handler = _UpstreamHandler({})
patched_httpx(handler)
with TestClient(app) as client:
r = client.get("/api/docs/sessions/abc/index")
assert r.status_code == 400, r.text
assert handler.calls == [], "rejected path must not hit the network"
# ---------------------------------------------------------------------------
# Cache behavior
# ---------------------------------------------------------------------------
def test_manifest_cache_hits_within_ttl(app, patched_httpx, monkeypatch):
"""Two consecutive manifest calls within the TTL window should
issue exactly one HTTP request to gitea.
"""
# Generous TTL so the test never races.
monkeypatch.setenv("OHM_DOCS_SESSIONS_MANIFEST_TTL_SEC", "60")
handler = _UpstreamHandler(
{"sessions.json": (200, json.dumps({"0001": {"title": "x"}}))}
)
patched_httpx(handler)
with TestClient(app) as client:
r1 = client.get("/api/docs/sessions/manifest")
r2 = client.get("/api/docs/sessions/manifest")
assert r1.status_code == 200
assert r2.status_code == 200
assert len(handler.calls) == 1, (
f"expected one upstream call, got {handler.calls}"
)
def test_transcript_cache_hits_within_ttl(app, patched_httpx, monkeypatch):
monkeypatch.setenv("OHM_DOCS_SESSIONS_CONTENT_TTL_SEC", "300")
fname = "SESSION-0001.0-TRANSCRIPT-2026-01-01T00-00--2026-01-01T00-01.md"
handler = _UpstreamHandler({fname: (200, "# body\n")})
patched_httpx(handler)
with TestClient(app) as client:
r1 = client.get(f"/api/docs/sessions/0001/{fname}")
r2 = client.get(f"/api/docs/sessions/0001/{fname}")
assert r1.status_code == 200
assert r2.status_code == 200
assert len(handler.calls) == 1
def test_transcript_404_is_cached(app, patched_httpx, monkeypatch):
"""Negative caching: a 404 result is cached at the content TTL so a
deployment with no published transcripts doesn't hammer gitea on
every navigation. Documented in `docs_sessions.fetch_transcript`.
"""
monkeypatch.setenv("OHM_DOCS_SESSIONS_CONTENT_TTL_SEC", "300")
fname = "SESSION-9999.0-TRANSCRIPT-2026-01-01T00-00--2026-01-01T00-01.md"
handler = _UpstreamHandler({}) # everything 404s
patched_httpx(handler)
with TestClient(app) as client:
r1 = client.get(f"/api/docs/sessions/9999/{fname}")
r2 = client.get(f"/api/docs/sessions/9999/{fname}")
assert r1.status_code == 404
assert r2.status_code == 404
assert len(handler.calls) == 1, "negative caching should suppress the 2nd call"
+469
View File
@@ -0,0 +1,469 @@
"""v0.20.0 — `/api/docs/specs/*` endpoints.
Sibling of `test_docs_sessions_vertical.py`. The framework mediates
reads of the configured framework-spec URLs (default: rfc-app's own
SPEC.md + flotilla's SPEC.md on `git.wiggleverse.org`) so the
`/docs/specs/*` surface inherits the same chrome as
`/docs/user-guide` and `/docs/sessions/*`.
This file covers:
- The manifest endpoint with the framework default
- The manifest endpoint with an overridden `OHM_DOCS_SPECS` JSON value
- Slug validation at the route layer (rejects `..`, `/`, `~`,
uppercase, whitespace, path traversal attempts)
- Gitea 200 / 404 / 5xx response mapping
- Negative caching (404 is cached, not re-fetched within TTL)
- Malformed `OHM_DOCS_SPECS` fallback to the default + a logged
warning (asserted by caplog)
- A manifest entry that fails per-entry validation (bad slug,
missing field) is dropped, with the rest of the list retained
Mocking approach: same as docs_sessions `httpx.MockTransport`
substituted into `app.docs_specs.httpx.AsyncClient` via a fixture.
"""
from __future__ import annotations
import json
import logging
import httpx
import pytest
from fastapi.testclient import TestClient
from app import docs_specs
# Reuse the proven app-construction fixtures from the proposal vertical
# (same shape every test file in this repo uses).
from test_propose_vertical import ( # noqa: F401
app_with_fake_gitea,
tmp_env,
)
# ---------------------------------------------------------------------------
# Test scaffolding
# ---------------------------------------------------------------------------
class _UpstreamHandler:
"""Records every URL the docs_specs module fetched and returns
canned responses keyed by URL substring. Lets the test assert on
call count (for cache verification) without booting a full upstream
simulator.
`calls` tracks only URLs that hit a host configured in the spec
manifest under test so unrelated httpx clients (gitea-side
fixtures, etc.) don't inflate the count we use for cache-hit
assertions. We marker-match on substrings the manifest carries.
"""
def __init__(
self,
responses: dict[str, tuple[int, str]],
host_markers: tuple[str, ...] = ("rfc-app", "flotilla", "specs.example"),
):
self.responses = responses
self.host_markers = host_markers
self.calls: list[str] = []
def __call__(self, request: httpx.Request) -> httpx.Response:
url = str(request.url)
if any(m in url for m in self.host_markers):
self.calls.append(url)
for key, (status, body) in self.responses.items():
if key in url:
return httpx.Response(status, text=body)
# Default: 404. Lets tests skip declaring "the rest is 404".
return httpx.Response(404, text="not found")
@pytest.fixture
def patched_httpx(monkeypatch):
"""Provide a hook the test can call to install a MockTransport.
Same shape as the docs_sessions fixture `app_with_fake_gitea`
monkeypatches `httpx.AsyncClient` for the gitea side, so we
construct from the unpatched class directly to avoid the
FakeGitea wrapper.
"""
from httpx._client import AsyncClient as RealAsyncClient
def install(handler):
def patched(*args, **kwargs):
kwargs["transport"] = httpx.MockTransport(handler)
return RealAsyncClient(*args, **kwargs)
monkeypatch.setattr("app.docs_specs.httpx.AsyncClient", patched)
return handler
yield install
@pytest.fixture
def app(app_with_fake_gitea):
"""Reset the docs-specs cache so cross-test state can't leak."""
docs_specs.reset_cache()
fastapi_app, _fake = app_with_fake_gitea
return fastapi_app
# ---------------------------------------------------------------------------
# Manifest endpoint
# ---------------------------------------------------------------------------
def test_manifest_default(app, monkeypatch):
"""With `OHM_DOCS_SPECS` unset, the manifest endpoint returns the
framework default (rfc-app + flotilla).
"""
monkeypatch.delenv("OHM_DOCS_SPECS", raising=False)
with TestClient(app) as client:
r = client.get("/api/docs/specs/manifest")
assert r.status_code == 200, r.text
payload = r.json()
assert "specs" in payload
names = [s["name"] for s in payload["specs"]]
assert names == ["rfc-app", "flotilla"]
# The default URLs point at the OHM-canonical gitea raw paths.
assert all("git.wiggleverse.org" in s["url"] for s in payload["specs"])
def test_manifest_overridden(app, monkeypatch):
"""A deployment overriding `OHM_DOCS_SPECS` gets its custom list.
The manifest is parsed per-request from the env var (no startup
binding) so a runtime overlay change is visible without a
restart same shape as the docs_sessions env knobs.
"""
custom = json.dumps(
[
{
"name": "custom-spec",
"title": "Custom Spec",
"url": "https://specs.example.org/CUSTOM.md",
}
]
)
monkeypatch.setenv("OHM_DOCS_SPECS", custom)
with TestClient(app) as client:
r = client.get("/api/docs/specs/manifest")
assert r.status_code == 200, r.text
payload = r.json()
assert payload == {
"specs": [
{
"name": "custom-spec",
"title": "Custom Spec",
"url": "https://specs.example.org/CUSTOM.md",
}
]
}
def test_manifest_malformed_json_falls_back(app, monkeypatch, caplog):
"""A non-JSON value in `OHM_DOCS_SPECS` logs a warning and the
endpoint falls back to the framework default. Startup is
unaffected the deployment continues to render the spec surface
rather than crashing on the typo.
"""
monkeypatch.setenv("OHM_DOCS_SPECS", "{not-json")
with caplog.at_level(logging.WARNING, logger="app.docs_specs"):
with TestClient(app) as client:
r = client.get("/api/docs/specs/manifest")
assert r.status_code == 200, r.text
payload = r.json()
names = [s["name"] for s in payload["specs"]]
assert names == ["rfc-app", "flotilla"]
assert any(
"OHM_DOCS_SPECS is not valid JSON" in rec.message
for rec in caplog.records
), f"expected a logged warning; got {[r.message for r in caplog.records]}"
def test_manifest_non_list_falls_back(app, monkeypatch, caplog):
"""`OHM_DOCS_SPECS` must be a JSON array. A JSON object (or any
non-list value) falls back to the default + logs a warning.
"""
monkeypatch.setenv("OHM_DOCS_SPECS", json.dumps({"name": "not-a-list"}))
with caplog.at_level(logging.WARNING, logger="app.docs_specs"):
with TestClient(app) as client:
r = client.get("/api/docs/specs/manifest")
assert r.status_code == 200, r.text
names = [s["name"] for s in r.json()["specs"]]
assert names == ["rfc-app", "flotilla"]
assert any(
"must be a JSON array" in rec.message for rec in caplog.records
)
def test_manifest_drops_invalid_entry_keeps_valid(app, monkeypatch, caplog):
"""Per-entry validation: an entry with a bad slug or missing field
is dropped; valid entries in the same list are retained.
"""
custom = json.dumps(
[
{"name": "Bad Slug", "title": "Bad", "url": "https://x"}, # uppercase + space
{"name": "..", "title": "Traversal", "url": "https://x"}, # path traversal
{"name": "missing-url", "title": "Missing URL"}, # no url
{
"name": "good-spec",
"title": "Good",
"url": "https://specs.example.org/GOOD.md",
},
]
)
monkeypatch.setenv("OHM_DOCS_SPECS", custom)
with caplog.at_level(logging.WARNING, logger="app.docs_specs"):
with TestClient(app) as client:
r = client.get("/api/docs/specs/manifest")
assert r.status_code == 200, r.text
names = [s["name"] for s in r.json()["specs"]]
assert names == ["good-spec"]
# Three drop warnings (one per bad entry).
drops = [r for r in caplog.records if "failed validation" in r.message]
assert len(drops) == 3
def test_manifest_all_invalid_falls_back(app, monkeypatch, caplog):
"""If every entry is dropped, the framework default applies (the
surface never goes empty due to a bad overlay).
"""
monkeypatch.setenv(
"OHM_DOCS_SPECS",
json.dumps([{"name": "BAD"}, {"name": "..", "title": "x", "url": "y"}]),
)
with caplog.at_level(logging.WARNING, logger="app.docs_specs"):
with TestClient(app) as client:
r = client.get("/api/docs/specs/manifest")
assert r.status_code == 200, r.text
names = [s["name"] for s in r.json()["specs"]]
assert names == ["rfc-app", "flotilla"]
assert any(
"yielded no valid entries" in rec.message for rec in caplog.records
)
def test_manifest_drops_duplicate_names(app, monkeypatch, caplog):
"""A duplicate `name` is dropped (the first occurrence wins). The
route layer's `/api/docs/specs/{name}` path lookup is by name, so
duplicates would otherwise be ambiguous.
"""
custom = json.dumps(
[
{"name": "x", "title": "First", "url": "https://specs.example/1"},
{"name": "x", "title": "Second", "url": "https://specs.example/2"},
]
)
monkeypatch.setenv("OHM_DOCS_SPECS", custom)
with caplog.at_level(logging.WARNING, logger="app.docs_specs"):
with TestClient(app) as client:
r = client.get("/api/docs/specs/manifest")
payload = r.json()
assert [s["title"] for s in payload["specs"]] == ["First"]
assert any("duplicate name" in rec.message for rec in caplog.records)
# ---------------------------------------------------------------------------
# Spec endpoint — happy + error paths
# ---------------------------------------------------------------------------
def test_spec_happy_path(app, patched_httpx, monkeypatch):
monkeypatch.setenv(
"OHM_DOCS_SPECS",
json.dumps(
[
{
"name": "rfc-app",
"title": "rfc-app SPEC",
"url": "https://specs.example.org/rfc-app/SPEC.md",
}
]
),
)
body = "# rfc-app SPEC\n\nSection 1...\n"
patched_httpx(_UpstreamHandler({"rfc-app/SPEC.md": (200, body)}))
with TestClient(app) as client:
r = client.get("/api/docs/specs/rfc-app")
assert r.status_code == 200, r.text
assert "text/markdown" in r.headers["content-type"]
assert r.text == body
def test_spec_upstream_404(app, patched_httpx, monkeypatch):
monkeypatch.setenv(
"OHM_DOCS_SPECS",
json.dumps(
[
{
"name": "missing-spec",
"title": "Missing",
"url": "https://specs.example.org/missing.md",
}
]
),
)
patched_httpx(_UpstreamHandler({})) # everything 404s
with TestClient(app) as client:
r = client.get("/api/docs/specs/missing-spec")
assert r.status_code == 404, r.text
def test_spec_upstream_5xx_returns_502(app, patched_httpx, monkeypatch):
monkeypatch.setenv(
"OHM_DOCS_SPECS",
json.dumps(
[
{
"name": "broken-spec",
"title": "Broken",
"url": "https://specs.example.org/broken.md",
}
]
),
)
patched_httpx(_UpstreamHandler({"broken.md": (500, "internal")}))
with TestClient(app) as client:
r = client.get("/api/docs/specs/broken-spec")
assert r.status_code == 502, r.text
body = r.json()
assert body["detail"]["error"] == "specs fetch failed"
def test_spec_unknown_name_returns_404(app, patched_httpx, monkeypatch):
"""A name that doesn't appear in the manifest returns 404 without
touching the network. The handler treats "no such configured spec"
and "upstream 404" as the same outcome both render the same
"spec not found" empty state on the frontend.
"""
monkeypatch.setenv(
"OHM_DOCS_SPECS",
json.dumps(
[
{
"name": "rfc-app",
"title": "rfc-app",
"url": "https://specs.example.org/x.md",
}
]
),
)
handler = _UpstreamHandler({})
patched_httpx(handler)
with TestClient(app) as client:
r = client.get("/api/docs/specs/does-not-exist")
assert r.status_code == 404, r.text
assert handler.calls == [], "unknown-name lookup must not hit the network"
# ---------------------------------------------------------------------------
# Spec endpoint — slug validation
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"raw_name",
[
"UPPER", # uppercase
"spaces here", # whitespace (post-decoding)
"with~tilde", # tilde
"with.dot", # dot
"with_under", # underscore (not allowed by [a-z0-9-]+)
],
)
def test_spec_rejects_invalid_name(app, patched_httpx, raw_name):
"""Names that don't match `^[a-z0-9-]+$` are rejected with 400 at
the route layer before any network or cache work.
Note: `..` is intentionally not in this list because the URL-
parsing layer collapses `/api/docs/specs/..` to `/api/docs/specs`
before the handler is reached the path-traversal protection is
therefore framework-level (httpx/urllib's path normalizer) rather
than route-layer. The slug-validation guard still rejects any
`..` that *would* reach the handler (e.g. via an env-configured
manifest entry); see `test_manifest_drops_invalid_entry_keeps_valid`
for that path.
"""
handler = _UpstreamHandler({})
patched_httpx(handler)
with TestClient(app) as client:
from urllib.parse import quote
r = client.get(f"/api/docs/specs/{quote(raw_name, safe='')}")
assert r.status_code == 400, r.text
assert handler.calls == [], "rejected name must not hit the network"
def test_spec_rejects_slash_in_name(app, patched_httpx):
"""A literal `/` in the path can't make it through the path
parameter FastAPI routes it as a separate segment. The check
here is that the request never reaches an upstream fetch.
"""
handler = _UpstreamHandler({})
patched_httpx(handler)
with TestClient(app) as client:
# `/api/docs/specs/sub/path` — the second segment makes this
# not match the `/{name}` route at all; FastAPI returns 404.
r = client.get("/api/docs/specs/sub/path")
assert r.status_code == 404, r.text
assert handler.calls == [], "non-matching path must not hit the network"
# ---------------------------------------------------------------------------
# Cache behavior
# ---------------------------------------------------------------------------
def test_spec_cache_hits_within_ttl(app, patched_httpx, monkeypatch):
monkeypatch.setenv("OHM_DOCS_SPECS_CONTENT_TTL_SEC", "300")
monkeypatch.setenv(
"OHM_DOCS_SPECS",
json.dumps(
[
{
"name": "rfc-app",
"title": "rfc-app",
"url": "https://specs.example.org/rfc-app/SPEC.md",
}
]
),
)
handler = _UpstreamHandler({"rfc-app/SPEC.md": (200, "# body\n")})
patched_httpx(handler)
with TestClient(app) as client:
r1 = client.get("/api/docs/specs/rfc-app")
r2 = client.get("/api/docs/specs/rfc-app")
assert r1.status_code == 200
assert r2.status_code == 200
assert len(handler.calls) == 1, (
f"expected one upstream call, got {handler.calls}"
)
def test_spec_404_is_cached(app, patched_httpx, monkeypatch):
"""Negative caching: a 404 result is cached at the content TTL so
a deployment with a misconfigured spec URL doesn't hammer the
upstream on every navigation.
"""
monkeypatch.setenv("OHM_DOCS_SPECS_CONTENT_TTL_SEC", "300")
monkeypatch.setenv(
"OHM_DOCS_SPECS",
json.dumps(
[
{
"name": "missing-spec",
"title": "Missing",
"url": "https://specs.example.org/missing.md",
}
]
),
)
handler = _UpstreamHandler({}) # everything 404s
patched_httpx(handler)
with TestClient(app) as client:
r1 = client.get("/api/docs/specs/missing-spec")
r2 = client.get("/api/docs/specs/missing-spec")
assert r1.status_code == 404
assert r2.status_code == 404
assert len(handler.calls) == 1, "negative caching should suppress the 2nd call"
+19 -8
View File
@@ -22,6 +22,7 @@ import pytest
from test_propose_vertical import ( # noqa: F401
FakeGitea,
app_with_fake_gitea,
grant_rfc_collaborator,
provision_user_row,
sign_in_as,
tmp_env,
@@ -118,19 +119,25 @@ def test_full_user_lifecycle_propose_through_hygiene(app_with_fake_gitea):
r = client.post(f"/api/rfcs/ohm/prs/{body_pr}/merge")
assert r.status_code == 200, r.text
# --- 7. Graduate the super-draft. ---
# --- 7. Graduate the super-draft (in-place flip, §13). ---
r = client.post(
"/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0001", "repo_name": "rfc-0001-ohm",
"owners": ["ben"]},
json={"rfc_id": "RFC-0001", "owners": ["ben"]},
)
assert r.status_code == 200, r.text
assert r.json()["succeeded"] is True
d = client.get("/api/rfcs/ohm").json()
assert d["state"] == "active"
assert d["repo"] == "wiggleverse/rfc-0001-ohm"
# Meta-only topology (§1): no per-RFC repo — the active RFC lives
# in its meta entry, `repo` stays null.
assert d["repo"] is None
# --- 8. Alice opens a PR on the now-active RFC's per-RFC repo. ---
# --- 8. Alice opens a PR on the now-active RFC (meta 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={})
@@ -194,8 +201,8 @@ def test_full_user_lifecycle_propose_through_hygiene(app_with_fake_gitea):
)
assert counters["deleted_post_merge"] >= 1, counters
# The branch is gone from FakeGitea + cached row flipped.
assert active_branch not in fake.branches[("wiggleverse", "rfc-0001-ohm")]
# The branch is gone from FakeGitea (meta repo) + cached row flipped.
assert active_branch not in fake.branches[("wiggleverse", "meta")]
cached = db.conn().execute(
"SELECT state FROM cached_branches WHERE rfc_slug = 'ohm' AND branch_name = ?",
(active_branch,),
@@ -225,13 +232,17 @@ def test_bounce_webhook_refuses_unsigned_when_secret_configured(app_with_fake_gi
# With the right header, the call passes the guard. (No matching
# user exists, so we get {matched: False} — that's the v1 contract.)
# v0.18.0 Slice 5: the response now includes `correlated_id`
# (the outbound_emails row id that matched the bounce's
# `message_id`, if one was supplied). The body didn't pass a
# message_id, so correlated_id is None.
r = client.post(
"/api/webhooks/email-bounce",
json={"email": "stranger@example.com", "kind": "hard"},
headers={"X-Webhook-Secret": "shhh"},
)
assert r.status_code == 200, r.text
assert r.json() == {"ok": True, "matched": False}
assert r.json() == {"ok": True, "matched": False, "correlated_id": None}
def test_bounce_webhook_open_when_secret_unset(app_with_fake_gitea):
+179
View File
@@ -0,0 +1,179 @@
"""Unit tests for `app.email_envelope.build_envelope` (v0.18.0 Slice 1).
These tests don't spin up the FastAPI app or touch the DB — they
exercise the helper directly. The integration tests in
test_otc_vertical / test_admin_create_user_invite_vertical /
test_notifications_vertical exercise the helper's *use* via the
shared `_SENT` buffer (the send path appends the envelope dict
before invoking the helper).
"""
from __future__ import annotations
from email.utils import parsedate_to_datetime
import pytest
from app.email_envelope import build_envelope
def _base_kwargs(**overrides):
base = dict(
to_address="recipient@example.com",
from_address="notifications@ohm.wiggleverse.org",
from_name="OHM",
subject="A test subject",
body_plain="Hello, world.\n",
)
base.update(overrides)
return base
# ---------------------------------------------------------------------------
# Always-present headers
# ---------------------------------------------------------------------------
def test_envelope_sets_from_to_subject():
msg = build_envelope(**_base_kwargs())
assert msg["To"] == "recipient@example.com"
assert msg["Subject"] == "A test subject"
# `From` is the display-form: "OHM <notifications@ohm.wiggleverse.org>".
assert "OHM" in msg["From"]
assert "<notifications@ohm.wiggleverse.org>" in msg["From"]
def test_envelope_sets_date_header_parseable():
msg = build_envelope(**_base_kwargs())
raw = msg["Date"]
assert raw, "Date header must be set"
# parsedate_to_datetime raises ValueError on malformed input.
dt = parsedate_to_datetime(raw)
assert dt is not None
def test_envelope_sets_message_id_with_from_domain_by_default():
msg = build_envelope(**_base_kwargs())
mid = msg["Message-ID"]
assert mid, "Message-ID must be set"
# Shape per RFC 5322 / make_msgid: <random@domain>
assert mid.startswith("<") and mid.endswith(">")
assert "@ohm.wiggleverse.org>" in mid
def test_envelope_message_id_domain_override():
msg = build_envelope(**_base_kwargs(msgid_domain="example.test"))
assert "@example.test>" in msg["Message-ID"]
def test_envelope_message_id_falls_back_to_localhost_if_from_has_no_at():
# Defensive: a malformed from_address shouldn't crash the helper.
msg = build_envelope(**_base_kwargs(from_address="bare-no-at-sign"))
assert "@localhost>" in msg["Message-ID"]
# ---------------------------------------------------------------------------
# Auto-Submitted (RFC 3834)
# ---------------------------------------------------------------------------
def test_envelope_sets_auto_submitted_for_transactional_default():
msg = build_envelope(**_base_kwargs())
assert msg["Auto-Submitted"] == "auto-generated"
def test_envelope_omits_auto_submitted_when_transactional_is_false():
msg = build_envelope(**_base_kwargs(is_transactional=False))
assert msg["Auto-Submitted"] is None
# ---------------------------------------------------------------------------
# Reply-To
# ---------------------------------------------------------------------------
def test_envelope_sets_reply_to_when_provided():
msg = build_envelope(**_base_kwargs(reply_to="ohm@wiggleverse.org"))
assert msg["Reply-To"] == "ohm@wiggleverse.org"
def test_envelope_omits_reply_to_when_absent():
msg = build_envelope(**_base_kwargs())
assert msg["Reply-To"] is None
# ---------------------------------------------------------------------------
# List-Unsubscribe (the headers RFC 8058 / Gmail-Yahoo care about)
# ---------------------------------------------------------------------------
def test_envelope_no_list_unsubscribe_when_neither_given():
"""OTC mail: the recipient explicitly requested the code; no
unsubscribe semantics. The header MUST be absent (presence would
imply OHM has the recipient on a list, which it doesn't)."""
msg = build_envelope(**_base_kwargs())
assert msg["List-Unsubscribe"] is None
assert msg["List-Unsubscribe-Post"] is None
def test_envelope_mailto_only_list_unsubscribe():
"""Admin invite / per-RFC invite: `mailto:` form only, no URL.
The recipient isn't a user yet, so there's no per-user opt-out
URL to flip; the operator handles ad-hoc opt-outs manually."""
msg = build_envelope(**_base_kwargs(
unsubscribe_mailto="ohm@wiggleverse.org?subject=remove",
))
assert msg["List-Unsubscribe"] == "<mailto:ohm@wiggleverse.org?subject=remove>"
# NO List-Unsubscribe-Post when only a mailto is present — the
# one-click semantic requires a URL the MUA can POST to.
assert msg["List-Unsubscribe-Post"] is None
def test_envelope_full_one_click_list_unsubscribe():
"""Watcher notification / bundle: `mailto:` + signed-URL +
`List-Unsubscribe-Post: List-Unsubscribe=One-Click`. Gmail and
Yahoo enforce this for bulk-adjacent mail per RFC 8058."""
msg = build_envelope(**_base_kwargs(
unsubscribe_mailto="ohm@wiggleverse.org?subject=remove",
unsubscribe_url="https://ohm.wiggleverse.org/api/email/unsubscribe?t=abc",
))
lu = msg["List-Unsubscribe"]
assert "<mailto:ohm@wiggleverse.org?subject=remove>" in lu
assert "<https://ohm.wiggleverse.org/api/email/unsubscribe?t=abc>" in lu
assert msg["List-Unsubscribe-Post"] == "List-Unsubscribe=One-Click"
def test_envelope_url_only_list_unsubscribe_still_sets_post():
msg = build_envelope(**_base_kwargs(
unsubscribe_url="https://ohm.wiggleverse.org/api/email/unsubscribe?t=abc",
))
assert msg["List-Unsubscribe"] == "<https://ohm.wiggleverse.org/api/email/unsubscribe?t=abc>"
assert msg["List-Unsubscribe-Post"] == "List-Unsubscribe=One-Click"
# ---------------------------------------------------------------------------
# Body shape — plain-only vs multipart/alternative
# ---------------------------------------------------------------------------
def test_envelope_plain_only_body_is_text_plain():
msg = build_envelope(**_base_kwargs())
# No HTML alternative -> single-part text/plain.
assert msg.get_content_type() == "text/plain"
assert msg.get_content().strip() == "Hello, world."
def test_envelope_html_body_is_guarded_not_enabled():
# I3 (security-audit-0026): the HTML/multipart-alternative path is
# intentionally not enabled — passing body_html must fail loudly so
# a future caller can't silently ship unescaped user HTML (the C1
# stored-XSS class in the mail channel). When HTML mail is enabled
# deliberately, this test flips to assert the multipart shape.
with pytest.raises(NotImplementedError):
build_envelope(**_base_kwargs(body_html="<p>Hello, <b>world</b>.</p>"))
def test_envelope_html_none_is_plain_only():
# The guard keys on `is not None`, so the default (None) stays the
# live plain-text path — exercised here to lock the boundary.
msg = build_envelope(**_base_kwargs(body_html=None))
assert msg.get_content_type() == "text/plain"
+174 -195
View File
@@ -1,29 +1,29 @@
"""End-to-end integration tests for the Slice 5 vertical (§13 in full).
"""End-to-end integration tests for the §13 graduation flow under the
meta-only topology (SPEC §1, ROADMAP #36).
Walks the §13.3 transactional sequence end-to-end against the in-process
FakeGitea from test_propose_vertical.py:
Graduation is an in-place state flip on the meta entry no per-RFC repo
is created, the body is kept, and there is no multi-step transaction or
rollback (§13.3). These tests walk it against the in-process FakeGitea
from test_propose_vertical.py:
* Seed an owned super-draft (skipping the propose+merge + §13.1 claim
round-trips already proven by Slice 1 and exercised in
test_claim_opens_meta_pr below for the §13.1 surface itself).
* Seed an owned super-draft (the §13.1 claim flow is exercised
separately in test_claim_opens_meta_pr).
* GET /api/rfcs/<slug>/graduate/check returns per-field validity for
the dialog.
* GET /api/rfcs/<slug>/blocking-prs returns the §9.8 precondition list.
* POST /api/rfcs/<slug>/graduate?_sync=1 runs the five-step sequence
inline. On success: per-RFC repo exists with RFC.md / README.md /
.rfc/metadata.yaml, meta-entry body is stripped, frontmatter is
graduated, cached_rfcs.state is 'active'.
* §9.8 precondition gate refuses the start when a body-edit PR is open.
* Rollback on a mid-sequence failure unwinds repo creation cleanly.
* §13.4 chat migration: whole-doc threads under (slug, 'main') survive
graduation unchanged the rfc_slug is the canonical key per §2.3,
so no data movement is needed.
* §9.8 pre-graduation history: the new RFC's /main response surfaces
edit-branch threads under `pre_graduation_history`.
the two-field dialog (integer id + owners; no repo name).
* POST /api/rfcs/<slug>/graduate?_sync=1 opens + merges the flip PR
inline. On success: NO per-RFC repo, the meta entry is `state:
active` with the body KEPT and `repo` null, cached_rfcs.state flips
to 'active'.
* An open body-edit PR no longer blocks graduation (§9.8) they
coexist.
* An open-PR failure leaves the entry a super-draft (nothing created);
a merge failure cleans up the half-open PR/branch and leaves the
entry a super-draft.
* §13.4: chat threads + edit branches stay put the slug is the
canonical key per §2.3, so nothing moves at the flip.
The orchestrator's `?_sync=1` seam awaits the sequence inline so the
test can assert post-conditions on the same event loop tick. Production
clients use the spec-described SSE shape via `/graduate/progress`.
The orchestrator's `?_sync=1` seam awaits the flip inline so the test can
assert post-conditions on the same event loop tick.
"""
from __future__ import annotations
@@ -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,
@@ -109,7 +110,9 @@ def seed_owned_super_draft(fake: FakeGitea, *, slug: str, title: str, pitch: str
# ---------------------------------------------------------------------------
def test_graduate_check_validates_three_fields(app_with_fake_gitea):
def test_graduate_check_validates_id_and_owners(app_with_fake_gitea):
"""Two-field dialog under meta-only: integer id + owners. No repo
name to validate (§13.2)."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
@@ -120,57 +123,46 @@ def test_graduate_check_validates_three_fields(app_with_fake_gitea):
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner")
# Happy: a fresh RFC-0001 + rfc-0001-ohm repo name.
r = client.get("/api/rfcs/ohm/graduate/check",
params={"id": "RFC-0001", "repo": "rfc-0001-ohm"})
# Happy: a fresh RFC-0001.
r = client.get("/api/rfcs/ohm/graduate/check", params={"id": "RFC-0001"})
assert r.status_code == 200, r.text
d = r.json()
assert d["id"]["ok"] is True
assert d["repo"]["ok"] is True
assert d["owners"]["ok"] is True
assert d["blocking_prs"]["ok"] is True
assert d["can_submit"] is True
# No repo field in the meta-only check response.
assert "repo" not in d
# ID format error — non-numeric tail.
r = client.get("/api/rfcs/ohm/graduate/check",
params={"id": "RFC-abcd", "repo": "rfc-0001-ohm"})
r = client.get("/api/rfcs/ohm/graduate/check", params={"id": "RFC-abcd"})
d = r.json()
assert d["id"]["ok"] is False
assert d["can_submit"] is False
# Repo name pattern error — leading dot.
r = client.get("/api/rfcs/ohm/graduate/check",
params={"id": "RFC-0001", "repo": ".bad"})
d = r.json()
assert d["repo"]["ok"] is False
def test_graduate_check_refuses_when_no_owners(app_with_fake_gitea):
"""An unclaimed super-draft fails the owners precondition; can_submit
flips false even with valid id+repo."""
flips false even with a valid id."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
# No owners — simulates an unclaimed super-draft.
seed_owned_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH, owners=[])
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner")
r = client.get("/api/rfcs/ohm/graduate/check",
params={"id": "RFC-0001", "repo": "rfc-0001-ohm"})
r = client.get("/api/rfcs/ohm/graduate/check", params={"id": "RFC-0001"})
d = r.json()
assert d["owners"]["ok"] is False
assert "No owners" in d["owners"]["error"]
assert d["can_submit"] is False
def test_graduate_happy_path_runs_five_steps_and_flips_state(app_with_fake_gitea):
"""The full §13.3 sequence: create repo, seed files, open PR, merge
PR, refresh cache. End state: cached_rfcs.state='active', the meta
entry's body is stripped, the per-RFC repo has RFC.md, the audit
log carries graduate_start graduate_complete bracketing the
per-step rows."""
def test_graduate_happy_path_flips_in_place_keeping_body(app_with_fake_gitea):
"""The meta-only flip: open + merge a frontmatter PR. End state:
cached_rfcs.state='active', the meta entry's body is KEPT, `repo` is
null, NO per-RFC repo exists, and the audit log carries graduate_start
graduate_pr_open graduate_pr_merge graduate_complete."""
from fastapi.testclient import TestClient
from app import db, entry as entry_mod
@@ -185,60 +177,57 @@ def test_graduate_happy_path_runs_five_steps_and_flips_state(app_with_fake_gitea
r = client.post(
"/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0042", "repo_name": "rfc-0042-ohm",
"owners": ["ben"]},
json={"rfc_id": "RFC-0042", "owners": ["ben"]},
)
assert r.status_code == 200, r.text
d = r.json()
assert d["finished"] is True
assert d["succeeded"] is True
assert d["repo"] == "wiggleverse/rfc-0042-ohm"
# No repo in the response, no per-RFC repo on Gitea.
assert "repo" not in d
assert ("wiggleverse", "rfc-0042-ohm") not in fake.repos
assert not any(
k[1].startswith("rfc-0042") for k in fake.repos
), f"a per-RFC repo was created: {fake.repos}"
# 1. Per-RFC repo exists on Gitea.
assert ("wiggleverse", "rfc-0042-ohm") in fake.repos
# 2. Seed files landed on main.
assert ("wiggleverse", "rfc-0042-ohm", "main", "RFC.md") in fake.files
assert ("wiggleverse", "rfc-0042-ohm", "main", "README.md") in fake.files
assert ("wiggleverse", "rfc-0042-ohm", "main", ".rfc/metadata.yaml") in fake.files
rfc_md = fake.files[("wiggleverse", "rfc-0042-ohm", "main", "RFC.md")]["content"]
assert "Open Human Model is a framework" in rfc_md
# 3. Meta entry body is stripped + frontmatter graduated.
# Meta entry on main: state flipped, body KEPT, repo null.
meta_text = fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
graduated = entry_mod.parse(meta_text)
assert graduated.state == "active"
assert graduated.id == "RFC-0042"
assert graduated.repo == "wiggleverse/rfc-0042-ohm"
assert graduated.repo is None
assert graduated.graduated_by == "ben"
assert graduated.graduated_at # non-empty ISO date
assert graduated.body.strip() == ""
# 5. cached_rfcs.state flipped to active via the inline refresh.
assert "Open Human Model is a framework" in graduated.body
# cached_rfcs flipped to active via the inline refresh; body intact.
cached = db.conn().execute(
"SELECT state, rfc_id, repo, body FROM cached_rfcs WHERE slug = 'ohm'"
).fetchone()
assert cached["state"] == "active"
assert cached["rfc_id"] == "RFC-0042"
assert cached["repo"] == "wiggleverse/rfc-0042-ohm"
# cached body now mirrors RFC.md from the per-RFC repo.
assert cached["repo"] is None
assert "Open Human Model is a framework" in cached["body"]
# Audit log: graduate_start, graduate_repo_create, graduate_repo_seed,
# graduate_pr_open, graduate_pr_merge, graduate_complete, in order.
kinds = [
r["action_kind"]
for r in db.conn().execute(
row["action_kind"]
for row in db.conn().execute(
"SELECT action_kind FROM actions WHERE rfc_slug = 'ohm' ORDER BY id"
)
]
for needed in ("graduate_start", "graduate_repo_create",
"graduate_repo_seed", "graduate_pr_open",
for needed in ("graduate_start", "graduate_pr_open",
"graduate_pr_merge", "graduate_complete"):
assert needed in kinds, f"missing audit row {needed}: {kinds}"
# The retired per-repo steps must NOT appear.
for gone in ("graduate_repo_create", "graduate_repo_seed",
"graduate_repo_delete", "graduate_rollback"):
assert gone not in kinds, f"retired audit row present: {gone}"
def test_graduate_refuses_when_body_edit_pr_open(app_with_fake_gitea):
"""§9.8: an open meta-repo body-edit PR against rfcs/<slug>.md blocks
graduation before the bot starts the sequence §13.3's rollback
complexity does not grow."""
def test_graduate_coexists_with_open_body_edit_pr(app_with_fake_gitea):
"""§9.8 (meta-only): an open meta-repo body-edit PR no longer blocks
graduation the body is kept, so they coexist. /check stays
submittable and the flip succeeds."""
from fastapi.testclient import TestClient
from app import db
@@ -248,10 +237,11 @@ 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"])
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")
# Cut an edit branch and open a body-edit PR (full Slice 4 path).
# Cut an edit branch and open a body-edit PR.
branch = client.post("/api/rfcs/ohm/start-edit-branch", json={}).json()["branch_name"]
view = client.get(f"/api/rfcs/ohm/branches/{branch}").json()
thread_id = view["main_thread_id"]
@@ -275,94 +265,31 @@ def test_graduate_refuses_when_body_edit_pr_open(app_with_fake_gitea):
f"/api/rfcs/ohm/branches/{branch}/open-pr",
json={"title": "Add harm", "description": "Adds harm dimension."},
).json()["pr_number"]
assert pr_number # PR is open
# /blocking-prs surfaces it.
# /check stays submittable despite the open body-edit PR.
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner")
r = client.get("/api/rfcs/ohm/blocking-prs")
items = r.json()["items"]
assert len(items) == 1
assert items[0]["pr_number"] == pr_number
d = client.get("/api/rfcs/ohm/graduate/check", params={"id": "RFC-0001"}).json()
assert "blocking_prs" not in d
assert d["can_submit"] is True
# /check refuses can_submit.
r = client.get("/api/rfcs/ohm/graduate/check",
params={"id": "RFC-0001", "repo": "rfc-0001-ohm"})
d = r.json()
assert d["blocking_prs"]["ok"] is False
assert d["can_submit"] is False
# POST refuses with 409 — the bot never starts the sequence.
# The flip succeeds — coexists with the open body-edit PR.
r = client.post(
"/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0001", "repo_name": "rfc-0001-ohm",
"owners": ["ben"]},
json={"rfc_id": "RFC-0001", "owners": ["ben"]},
)
assert r.status_code == 409
assert "blocking graduation" in r.text or "block" in r.text
def test_graduate_rollback_on_step_2_seed_failure(app_with_fake_gitea):
"""Step 2 (seed files) fails partway → the orchestrator rolls back
step 1 (delete the repo) and records the rollback in the audit log.
The cached_rfcs row stays at 'super-draft'."""
from fastapi.testclient import TestClient
from app import db
from app.bot import Bot
from app.gitea import Gitea, GiteaError
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
seed_owned_super_draft(fake, slug="ohm", title="OHM",
pitch=PITCH, owners=["ben"])
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner")
# Monkey-patch the bot to fail on seed_graduated_rfc. The repo
# has already been created in step 1; the rollback must delete it.
orig_seed = Bot.seed_graduated_rfc
async def boom(self, *args, **kwargs):
raise GiteaError(500, "simulated seed failure for rollback test")
Bot.seed_graduated_rfc = boom
try:
r = client.post(
"/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0003", "repo_name": "rfc-0003-ohm",
"owners": ["ben"]},
)
finally:
Bot.seed_graduated_rfc = orig_seed
assert r.status_code == 200, r.text
d = r.json()
assert d["finished"] is True
assert d["succeeded"] is False
# Repo deleted as the rollback inverse.
assert ("wiggleverse", "rfc-0003-ohm") not in fake.repos
# Meta entry unchanged.
assert r.json()["succeeded"] is True
cached = db.conn().execute(
"SELECT state, rfc_id FROM cached_rfcs WHERE slug = 'ohm'"
"SELECT state FROM cached_rfcs WHERE slug = 'ohm'"
).fetchone()
assert cached["state"] == "super-draft"
assert cached["rfc_id"] is None
# Audit log carries the rollback row.
kinds = [
r["action_kind"]
for r in db.conn().execute(
"SELECT action_kind FROM actions WHERE rfc_slug = 'ohm' ORDER BY id"
)
]
assert "graduate_start" in kinds
assert "graduate_repo_create" in kinds
assert "graduate_repo_delete" in kinds
assert "graduate_rollback" in kinds
assert "graduate_complete" not in kinds
assert cached["state"] == "active"
def test_graduate_rollback_on_step_3_pr_open_failure(app_with_fake_gitea):
"""Step 3 (open PR) fails → the orchestrator rolls back steps 2 and
1 (deleting the repo, which reclaims the seed commits at the same
time). The meta-repo entry is untouched."""
def test_graduate_open_pr_failure_leaves_super_draft(app_with_fake_gitea):
"""An open-PR failure creates nothing — the entry stays a super-draft
with its body intact and no graduation PR on the meta repo."""
from fastapi.testclient import TestClient
from app import db
from app.bot import Bot
@@ -383,19 +310,92 @@ def test_graduate_rollback_on_step_3_pr_open_failure(app_with_fake_gitea):
try:
r = client.post(
"/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0007", "repo_name": "rfc-0007-ohm",
"owners": ["ben"]},
json={"rfc_id": "RFC-0007", "owners": ["ben"]},
)
finally:
Bot.open_graduation_pr = orig_open_pr
assert r.status_code == 200, r.text
assert r.json()["succeeded"] is False
# Repo torn down.
assert ("wiggleverse", "rfc-0007-ohm") not in fake.repos
# Meta entry's body still has the pitch (not stripped).
# Entry untouched: still super-draft, body intact on main.
cached = db.conn().execute(
"SELECT state, rfc_id FROM cached_rfcs WHERE slug = 'ohm'"
).fetchone()
assert cached["state"] == "super-draft"
assert cached["rfc_id"] is None
meta_text = fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
assert "Open Human Model is a framework" in meta_text
kinds = [
row["action_kind"]
for row in db.conn().execute(
"SELECT action_kind FROM actions WHERE rfc_slug = 'ohm' ORDER BY id"
)
]
assert "graduate_start" in kinds
assert "graduate_failed" in kinds
assert "graduate_complete" not in kinds
def test_graduate_merge_failure_cleans_up_pr(app_with_fake_gitea):
"""A merge failure leaves the flip PR open on its dash-suffixed
branch; the orchestrator closes the PR and deletes the branch so
failed attempts don't accumulate. The entry stays a super-draft —
the flip PR's commit was on a branch, not on main."""
from fastapi.testclient import TestClient
from app import db
from app.bot import Bot
from app.gitea import GiteaError
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
seed_owned_super_draft(fake, slug="ohm", title="OHM",
pitch=PITCH, owners=["ben"])
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner")
orig_merge = Bot.merge_graduation_pr
async def boom(self, *args, **kwargs):
raise GiteaError(502, "simulated merge failure")
Bot.merge_graduation_pr = boom
try:
r = client.post(
"/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0009", "owners": ["ben"]},
)
finally:
Bot.merge_graduation_pr = orig_merge
assert r.status_code == 200, r.text
assert r.json()["succeeded"] is False
# Entry stays super-draft on main (the flip never merged).
cached = db.conn().execute(
"SELECT state FROM cached_rfcs WHERE slug = 'ohm'"
).fetchone()
assert cached["state"] == "super-draft"
meta_text = fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
assert "state: super-draft" in meta_text
# The dash-suffixed graduation branch was cleaned up.
grad_branches = [
name for (o, repo), branches in fake.branches.items()
if (o, repo) == ("wiggleverse", "meta")
for name in branches
if name.startswith("graduate-ohm-")
]
assert grad_branches == [], f"leftover graduation branch: {grad_branches}"
kinds = [
row["action_kind"]
for row in db.conn().execute(
"SELECT action_kind FROM actions WHERE rfc_slug = 'ohm' ORDER BY id"
)
]
assert "graduate_pr_open" in kinds
assert "graduate_failed" in kinds
assert "graduate_complete" not in kinds
def test_graduate_refuses_concurrent_graduation(app_with_fake_gitea):
"""A second graduation request for a slug already in-flight is refused."""
@@ -410,17 +410,14 @@ def test_graduate_refuses_concurrent_graduation(app_with_fake_gitea):
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner")
# Seed a synthetic in-flight state so the registry refuses the second.
st = api_graduation._new_active(
"ohm", rfc_id="RFC-0001", repo_name="rfc-0001-ohm",
repo_full="wiggleverse/rfc-0001-ohm", owners=["ben"], arbiters=["ben"],
"ohm", rfc_id="RFC-0001", owners=["ben"], arbiters=["ben"],
)
st.finished = False
try:
r = client.post(
"/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0001", "repo_name": "rfc-0001-ohm",
"owners": ["ben"]},
json={"rfc_id": "RFC-0001", "owners": ["ben"]},
)
assert r.status_code == 409
finally:
@@ -428,11 +425,9 @@ def test_graduate_refuses_concurrent_graduation(app_with_fake_gitea):
def test_chat_threads_survive_graduation_without_data_movement(app_with_fake_gitea):
"""§13.4: chat threads on the super-draft's canonical-body view
(`branch_name='main'`) are interpreted as the new RFC's main-thread
after graduation. The rows don't move — the rfc_slug is canonical
per §2.3 so the same thread surfaces from both before and after
the graduation."""
"""§13.4: chat threads on the entry's main view (`branch_name='main'`)
stay put across the flip the rfc_slug is canonical per §2.3 so the
same thread surfaces from /branches/main before and after graduation."""
from fastapi.testclient import TestClient
from app import db
@@ -444,9 +439,6 @@ def test_chat_threads_survive_graduation_without_data_movement(app_with_fake_git
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner")
# Materialize a whole-doc main thread + a message on it. This
# mirrors what reading the canonical-body view would create
# lazily (§8.12 / api_branches._ensure_branch_chat_thread).
cur = db.conn().execute(
"""
INSERT INTO threads (rfc_slug, branch_name, anchor_kind, thread_kind, created_by)
@@ -462,32 +454,26 @@ def test_chat_threads_survive_graduation_without_data_movement(app_with_fake_git
(thread_id,),
)
# Graduate.
r = client.post(
"/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0099", "repo_name": "rfc-0099-ohm",
"owners": ["ben"]},
json={"rfc_id": "RFC-0099", "owners": ["ben"]},
)
assert r.status_code == 200, r.text
# The thread row's identity is unchanged.
row = db.conn().execute(
"SELECT id, branch_name FROM threads WHERE id = ?", (thread_id,),
).fetchone()
assert row["branch_name"] == "main"
# The new RFC's main view surfaces the same thread id as its
# whole-doc main thread (the entry is now active, the branch
# 'main' now points at the per-RFC repo's main, but the
# `(rfc_slug, branch_name)` key remains the canonical anchor).
r = client.get("/api/rfcs/ohm/branches/main")
assert r.status_code == 200, r.text
assert r.json()["main_thread_id"] == thread_id
def test_pre_graduation_history_surfaces_edit_branch_threads(app_with_fake_gitea):
"""§9.8: after graduation, threads on meta-repo edit branches stay
attached to their original branch_name and surface from the new
RFC's /main response under `pre_graduation_history`."""
def test_edit_branch_surfaces_normally_after_graduation(app_with_fake_gitea):
"""§13.4 (meta-only): after graduation an edit branch is a *current*
branch of the now-active RFC it surfaces in the normal `branches`
list, and there is no separate `pre_graduation_history` set (that
affordance is legacy per-repo only)."""
from fastapi.testclient import TestClient
from app import db
@@ -497,8 +483,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"])
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",
display_name="Alice", role="contributor")
branch = client.post("/api/rfcs/ohm/start-edit-branch", json={}).json()["branch_name"]
@@ -507,30 +493,25 @@ def test_pre_graduation_history_surfaces_edit_branch_threads(app_with_fake_gitea
db.conn().execute(
"""
INSERT INTO thread_messages (thread_id, role, author_user_id, text)
VALUES (?, 'user', 2, 'pre-graduation note on an edit branch')
VALUES (?, 'user', 2, 'note on an edit branch')
""",
(thread_id,),
)
# Ben graduates.
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner")
r = client.post(
"/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0100", "repo_name": "rfc-0100-ohm",
"owners": ["ben"]},
json={"rfc_id": "RFC-0100", "owners": ["ben"]},
)
assert r.status_code == 200, r.text
# /main on the now-active RFC surfaces the pre-graduation history.
r = client.get("/api/rfcs/ohm/main")
d = r.json()
d = client.get("/api/rfcs/ohm/main").json()
assert d["state"] == "active"
hist = d["pre_graduation_history"]
assert len(hist) >= 1
assert any(h["branch_name"] == branch for h in hist)
target = next(h for h in hist if h["branch_name"] == branch)
assert target["message_count"] >= 1
# The edit branch is a current branch; no pre-graduation hop.
assert d["pre_graduation_history"] == []
assert any(b["name"] == branch for b in d["branches"]), \
f"edit branch not in branches: {[b['name'] for b in d['branches']]}"
def test_claim_opens_meta_pr(app_with_fake_gitea):
@@ -554,12 +535,10 @@ def test_claim_opens_meta_pr(app_with_fake_gitea):
d = r.json()
assert d["branch_name"] == "claim/ohm"
# The PR body's diff carries Alice in owners.
text = fake.files[("wiggleverse", "meta", "claim/ohm", "rfcs/ohm.md")]["content"]
ent = entry_mod.parse(text)
assert "alice" in ent.owners
# cached_prs records pr_kind='meta_claim' via refresh_meta_pulls.
row = db.conn().execute(
"SELECT pr_kind FROM cached_prs WHERE pr_number = ?", (d["pr_number"],),
).fetchone()
+9 -10
View File
@@ -339,10 +339,10 @@ def test_hygiene_action_kinds_fire_no_notifications(app_with_fake_gitea):
def test_graduation_rollback_deletes_dash_suffixed_branch(app_with_fake_gitea):
"""§19.2 candidate Slice 8 settles: when graduation rolls back
after step 3 (open_pr), the `graduate-<slug>-<6hex>` branch is
deleted alongside the PR close so failed-graduation branches
don't accumulate on the meta repo across retries."""
"""Meta-only (§13.3): when the flip's merge fails after the PR is
open, the orchestrator closes the PR and deletes its
`graduate-<slug>-<6hex>` branch so failed attempts don't accumulate
on the meta repo across retries."""
from fastapi.testclient import TestClient
from app import db
from app.bot import Bot
@@ -359,24 +359,23 @@ def test_graduation_rollback_deletes_dash_suffixed_branch(app_with_fake_gitea):
sign_in_as(client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner")
# Force a step-4 (merge_pr) failure so step 3 (open_pr) has
# already landed and the rollback exercises the branch cleanup.
# Force a merge_pr failure so the flip PR (open_pr) has already
# landed and the cleanup exercises the branch deletion.
orig_merge = Bot.merge_graduation_pr
async def boom(self, *args, **kwargs):
raise GiteaError(502, "simulated merge failure for rollback test")
raise GiteaError(502, "simulated merge failure for cleanup test")
Bot.merge_graduation_pr = boom
try:
r = client.post(
"/api/rfcs/ohm/graduate?_sync=1",
json={"rfc_id": "RFC-0099", "repo_name": "rfc-0099-ohm",
"owners": ["ben"]},
json={"rfc_id": "RFC-0099", "owners": ["ben"]},
)
finally:
Bot.merge_graduation_pr = orig_merge
assert r.status_code == 200, r.text
assert r.json()["succeeded"] is False
# The dash-suffixed graduation branch was deleted on rollback.
# The dash-suffixed graduation branch was deleted on cleanup.
meta_branches = fake.branches[("wiggleverse", "meta")]
graduation_branches = [n for n in meta_branches if n.startswith("graduate-ohm-")]
assert graduation_branches == [], (
@@ -612,3 +612,127 @@ def test_explicit_watch_set_overrides_auto(app_with_fake_gitea):
# the user put them.
assert row["set_by"] == "explicit"
assert row["state"] == "following"
# ---------------------------------------------------------------------------
# v0.18.0 — envelope headers + RFC 8058 one-click POST endpoint
#
# Watcher notifications are bulk-adjacent (a busy RFC can produce
# dozens of structural events); per the proposal, they MUST carry
# `Date`, `Message-ID`, `Auto-Submitted`, full `List-Unsubscribe`
# (mailto + signed URL), AND `List-Unsubscribe-Post:
# List-Unsubscribe=One-Click` per RFC 8058. Gmail and Yahoo
# enforce this for senders at OHM's volume tier.
# ---------------------------------------------------------------------------
def test_notification_envelope_carries_full_one_click_headers(app_with_fake_gitea):
"""A `proposal_merged` event lands a watcher notification email
with the full one-click unsubscribe shape."""
from fastapi.testclient import TestClient
from email.utils import parsedate_to_datetime
from app import db, email as email_mod
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
provision_user_row(user_id=1, login="ben", role="owner")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor", email="alice@test")
r = client.post("/api/rfcs/propose", json={"title": "OHM", "slug": "ohm", "pitch": PITCH, "tags": []})
assert r.status_code == 200
email_mod.reset_sent_envelopes()
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner", email="ben@test")
merge_r = client.post(f"/api/proposals/{r.json()['pr_number']}/merge")
assert merge_r.status_code == 200, merge_r.text
envelopes = [e for e in email_mod.sent_envelopes() if e["to"] == "alice@test"]
assert envelopes, "watcher notification did not fire"
msg = envelopes[-1]["message"]
# Always-present headers from the helper.
assert parsedate_to_datetime(msg["Date"]) is not None
assert msg["Message-ID"].startswith("<") and msg["Message-ID"].endswith(">")
assert msg["Auto-Submitted"] == "auto-generated"
# Full one-click unsubscribe.
lu = msg["List-Unsubscribe"]
assert lu is not None
assert "<mailto:" in lu
# URL part carries the signed token per make_unsubscribe_url.
assert "/api/email/unsubscribe?t=" in lu
assert msg["List-Unsubscribe-Post"] == "List-Unsubscribe=One-Click"
def test_email_unsubscribe_post_one_click_flips_category_off(app_with_fake_gitea):
"""RFC 8058: Gmail/Yahoo POST `List-Unsubscribe=One-Click` to the
URL in the List-Unsubscribe header. The endpoint MUST accept POST
+ the same token shape as the GET handler + return 200 + flip the
flag."""
from fastapi.testclient import TestClient
from app import db, email as email_mod
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
token = email_mod.make_unsubscribe_url(2, "personal-direct").split("t=", 1)[1]
r = client.post(
f"/api/email/unsubscribe?t={token}",
data={"List-Unsubscribe": "One-Click"},
)
assert r.status_code == 200, r.text
body = r.json()
assert body["ok"] is True
assert body["category"] == "personal-direct"
row = db.conn().execute(
"SELECT email_personal_direct FROM users WHERE id = 2"
).fetchone()
assert row["email_personal_direct"] == 0
def test_email_unsubscribe_post_all_sets_global_opt_out(app_with_fake_gitea):
"""The v0.18.0 `all` synthetic category (used by the bundle +
digest paths) MUST set `email_opt_out_all = 1`."""
from fastapi.testclient import TestClient
from app import db, email as email_mod
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
token = email_mod.make_unsubscribe_url(2, "all").split("t=", 1)[1]
r = client.post(f"/api/email/unsubscribe?t={token}")
assert r.status_code == 200
assert r.json() == {"ok": True, "category": "all"}
row = db.conn().execute(
"SELECT email_opt_out_all FROM users WHERE id = 2"
).fetchone()
assert row["email_opt_out_all"] == 1
def test_email_unsubscribe_get_all_sets_global_opt_out(app_with_fake_gitea):
"""GET handler also accepts the `all` category and lands the
global opt-out (so an MUA that doesn't honor RFC 8058 POST and
just opens the URL in a browser still works)."""
from fastapi.testclient import TestClient
from app import db, email as email_mod
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
token = email_mod.make_unsubscribe_url(2, "all").split("t=", 1)[1]
r = client.get(f"/api/email/unsubscribe?t={token}")
assert r.status_code == 200
assert "Unsubscribed" in r.text
row = db.conn().execute(
"SELECT email_opt_out_all FROM users WHERE id = 2"
).fetchone()
assert row["email_opt_out_all"] == 1
def test_email_unsubscribe_post_refuses_invalid_token(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
r = client.post("/api/email/unsubscribe?t=not-a-valid-token")
assert r.status_code == 400
+50
View File
@@ -347,3 +347,53 @@ def test_otc_re_request_invalidates_prior_unused_code(app_with_fake_gitea, monke
# The new code still works.
r = client.post("/auth/otc/verify", json={"email": "alice@example.com", "code": second})
assert r.status_code == 200
# ---------------------------------------------------------------------------
# v0.18.0: envelope headers — Slice 2
#
# OTC mail goes through `build_envelope` and MUST land Date,
# Message-ID, and Auto-Submitted but MUST NOT carry a
# List-Unsubscribe header (the recipient explicitly requested the
# code; advertising a list semantic would be wrong).
# ---------------------------------------------------------------------------
def _last_otc_envelope():
from app import email as email_mod
otc = [e for e in email_mod.sent_envelopes() if e.get("kind") == "otc"]
assert otc, "no OTC envelope in the buffer"
return otc[-1]
def test_otc_envelope_sets_date_messageid_autosubmitted(app_with_fake_gitea):
from fastapi.testclient import TestClient
from email.utils import parsedate_to_datetime
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
client.post("/auth/otc/request", json={"email": "headers@example.com"})
msg = _last_otc_envelope()["message"]
# Date is RFC 5322 parseable.
assert parsedate_to_datetime(msg["Date"]) is not None
# Message-ID is bracketed and carries the From-address @-domain.
mid = msg["Message-ID"]
assert mid.startswith("<") and mid.endswith(">")
# Auto-Submitted prevents auto-responder loops.
assert msg["Auto-Submitted"] == "auto-generated"
def test_otc_envelope_has_no_list_unsubscribe(app_with_fake_gitea):
"""The recipient explicitly typed their email and asked for a
code; the framework MUST NOT advertise a list semantic on this
mail. Per the v0.18.0 proposal's tradeoff discussion."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
client.post("/auth/otc/request", json={"email": "headers@example.com"})
msg = _last_otc_envelope()["message"]
assert msg["List-Unsubscribe"] is None
assert msg["List-Unsubscribe-Post"] is None
@@ -0,0 +1,368 @@
"""End-to-end integration tests for the v0.18.0 Slice 4
outbound_emails audit table + admin endpoint.
The release adds:
* `backend/migrations/020_outbound_emails.sql` the audit table.
* `record_outbound()` in `email.py` the write helper every send
path calls before returning, capturing status='sent' / 'failed'
/ 'deferred' (the dev-fallback path when SMTP_HOST is unset).
* `GET /api/admin/outbound-emails` admin-only listing, filterable
by kind / status / to_address.
These tests prove:
* Sending OTC / invite / notification mail writes one row per send
(status='deferred' under tests since SMTP_HOST is unset).
* The Message-ID on the row matches the envelope's Message-ID
header (the seam Slice 5 uses for bounce correlation).
* `kind` is populated per send path.
* `GET /api/admin/outbound-emails` lists rows newest-first,
accepts filters, refuses non-admins.
"""
from __future__ import annotations
from test_propose_vertical import ( # noqa: F401
FakeGitea,
app_with_fake_gitea,
provision_user_row,
sign_in_as,
tmp_env,
)
# ---------------------------------------------------------------------------
# Write-on-send wiring
# ---------------------------------------------------------------------------
def test_otc_send_writes_outbound_row_with_message_id(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db, email as email_mod
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
email_mod.reset_sent_envelopes()
r = client.post("/auth/otc/request", json={"email": "newcomer@ex.co"})
assert r.status_code == 200
# Audit row landed.
rows = db.conn().execute(
"SELECT id, to_address, kind, status, message_id, error "
"FROM outbound_emails WHERE to_address = 'newcomer@ex.co'"
).fetchall()
assert len(rows) == 1
row = rows[0]
assert row["kind"] == "otc"
# No SMTP_HOST in tests -> 'deferred', not 'sent'.
assert row["status"] == "deferred"
assert row["error"] is None
# Message-ID matches the envelope's header (the seam Slice 5 uses).
envelopes = [e for e in email_mod.sent_envelopes() if e.get("kind") == "otc"]
assert envelopes
envelope_mid = envelopes[-1]["message"]["Message-ID"]
assert row["message_id"] == envelope_mid
def test_invite_send_writes_outbound_row(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=500, login="adminQ", role="admin")
sign_in_as(
client, user_id=500, gitea_login="adminQ",
display_name="Admin Q", role="admin",
email="adminq@test",
)
r = client.post(
"/api/admin/users",
json={
"email": "invitee@ex.co",
"first_name": "Inv", "last_name": "Itee",
"role": "contributor", "custom_message": "",
},
)
assert r.status_code == 200, r.text
rows = db.conn().execute(
"SELECT kind, status, message_id FROM outbound_emails "
"WHERE to_address = 'invitee@ex.co'"
).fetchall()
assert len(rows) == 1
assert rows[0]["kind"] == "invite"
assert rows[0]["status"] == "deferred"
assert rows[0]["message_id"] is not None
def test_notification_send_writes_outbound_row_with_notification_id(app_with_fake_gitea):
"""Watcher notifications carry a `notification_id` FK so the
admin can join through to the notifications table to see what
triggered the send."""
from fastapi.testclient import TestClient
from app import db, email as email_mod
from test_notifications_vertical import PITCH
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
provision_user_row(user_id=1, login="ben", role="owner")
sign_in_as(
client, user_id=2, gitea_login="alice",
display_name="Alice", role="contributor", email="alice@test",
)
r = client.post("/api/rfcs/propose", json={
"title": "OHM", "slug": "ohm", "pitch": PITCH, "tags": [],
})
email_mod.reset_sent_envelopes()
# Wipe pre-merge audit rows so the assertion below is unambiguous.
db.conn().execute("DELETE FROM outbound_emails")
sign_in_as(
client, user_id=1, gitea_login="ben",
display_name="Ben", role="owner", email="ben@test",
)
merge_r = client.post(f"/api/proposals/{r.json()['pr_number']}/merge")
assert merge_r.status_code == 200, merge_r.text
rows = db.conn().execute(
"SELECT kind, status, notification_id, message_id "
"FROM outbound_emails WHERE to_address = 'alice@test'"
).fetchall()
assert rows, "no outbound_emails row for alice@test"
# At least one notification kind, with a populated FK.
notif_rows = [r for r in rows if r["kind"] == "notification"]
assert notif_rows
for nr in notif_rows:
assert nr["status"] == "deferred"
assert nr["notification_id"] is not None
assert nr["message_id"] is not None
# ---------------------------------------------------------------------------
# Admin endpoint
# ---------------------------------------------------------------------------
def test_admin_outbound_emails_lists_rows(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
# Generate a few rows.
client.post("/auth/otc/request", json={"email": "one@ex.co"})
provision_user_row(user_id=600, login="adminR", role="admin")
sign_in_as(
client, user_id=600, gitea_login="adminR",
display_name="Admin R", role="admin", email="adminr@test",
)
client.post("/api/admin/users", json={
"email": "two@ex.co", "first_name": "T", "last_name": "Wo",
"role": "contributor", "custom_message": "",
})
r = client.get("/api/admin/outbound-emails")
assert r.status_code == 200, r.text
items = r.json()["items"]
kinds = {it["kind"] for it in items}
assert "otc" in kinds
assert "invite" in kinds
# Newest-first.
ids = [it["id"] for it in items]
assert ids == sorted(ids, reverse=True)
def test_admin_outbound_emails_filters_by_kind(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
client.post("/auth/otc/request", json={"email": "filter1@ex.co"})
provision_user_row(user_id=601, login="adminS", role="admin")
sign_in_as(
client, user_id=601, gitea_login="adminS",
display_name="Admin S", role="admin", email="admins@test",
)
client.post("/api/admin/users", json={
"email": "filter2@ex.co", "first_name": "F", "last_name": "Two",
"role": "contributor", "custom_message": "",
})
r = client.get("/api/admin/outbound-emails?kind=otc")
assert r.status_code == 200
items = r.json()["items"]
assert items
assert all(it["kind"] == "otc" for it in items)
def test_admin_outbound_emails_filters_by_to_address(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
client.post("/auth/otc/request", json={"email": "TARGET@ex.co"})
client.post("/auth/otc/request", json={"email": "other@ex.co"})
provision_user_row(user_id=602, login="adminT", role="admin")
sign_in_as(
client, user_id=602, gitea_login="adminT",
display_name="Admin T", role="admin", email="admint@test",
)
# to_address filter is case-insensitive.
r = client.get("/api/admin/outbound-emails?to_address=target@ex.co")
assert r.status_code == 200
items = r.json()["items"]
assert items
assert all(it["to_address"].lower() == "target@ex.co" for it in items)
def test_admin_outbound_emails_refuses_non_admin(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=700, login="contribU", role="contributor")
sign_in_as(
client, user_id=700, gitea_login="contribU",
display_name="Contrib U", role="contributor",
)
r = client.get("/api/admin/outbound-emails")
assert r.status_code == 403
# ---------------------------------------------------------------------------
# v0.18.0 Slice 5: bounce correlation
# ---------------------------------------------------------------------------
def test_bounce_with_message_id_marks_outbound_row_bounced(app_with_fake_gitea):
"""When the bounce body includes the original `message_id`, the
framework looks it up in outbound_emails and stamps
status='bounced' on the matching row. The hard-bounce ->
global-opt-out logic still fires."""
from fastapi.testclient import TestClient
from app import db, email as email_mod
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=800, login="bouncey", role="contributor")
db.conn().execute("UPDATE users SET email = 'bouncey@ex.co' WHERE id = 800")
# Send something to bouncey to land an outbound_emails row.
email_mod.reset_sent_envelopes()
client.post("/auth/otc/request", json={"email": "bouncey@ex.co"})
row = db.conn().execute(
"SELECT id, message_id, status FROM outbound_emails "
"WHERE to_address = 'bouncey@ex.co'"
).fetchone()
assert row is not None
original_id = row["id"]
message_id = row["message_id"]
assert row["status"] == "deferred" # pre-bounce baseline
# Bounce comes in carrying that message_id.
r = client.post(
"/api/webhooks/email-bounce",
json={
"email": "bouncey@ex.co",
"kind": "hard",
"message_id": message_id,
},
)
assert r.status_code == 200
body = r.json()
assert body["matched"] is True
assert body["correlated_id"] == original_id
# Audit row stamped.
post = db.conn().execute(
"SELECT status, error FROM outbound_emails WHERE id = ?",
(original_id,),
).fetchone()
assert post["status"] == "bounced"
assert "bounce (hard)" in (post["error"] or "")
# Hard-bounce global opt-out still fires.
urow = db.conn().execute(
"SELECT email_opt_out_all FROM users WHERE id = 800"
).fetchone()
assert urow["email_opt_out_all"] == 1
def test_bounce_with_unknown_message_id_does_not_crash(app_with_fake_gitea):
"""A message_id the framework doesn't recognize logs but does
NOT 5xx bounce providers replay old bounces, and the
framework can't refuse just because the row was pruned."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
r = client.post(
"/api/webhooks/email-bounce",
json={
"email": "nobody@ex.co",
"kind": "hard",
"message_id": "<not-in-our-db@ex.co>",
},
)
assert r.status_code == 200
assert r.json()["correlated_id"] is None
def test_bounce_without_message_id_still_flips_opt_out(app_with_fake_gitea):
"""Backward compat: providers that don't surface Message-ID
still get the legacy v1 behavior match by email + flip the
global opt-out."""
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=801, login="legacybounce", role="contributor")
db.conn().execute("UPDATE users SET email = 'legacy@ex.co' WHERE id = 801")
r = client.post(
"/api/webhooks/email-bounce",
json={"email": "legacy@ex.co", "kind": "hard"},
)
assert r.status_code == 200
body = r.json()
assert body["matched"] is True
assert body["correlated_id"] is None
urow = db.conn().execute(
"SELECT email_opt_out_all FROM users WHERE id = 801"
).fetchone()
assert urow["email_opt_out_all"] == 1
def test_bounced_rows_show_in_admin_endpoint(app_with_fake_gitea):
"""The admin endpoint surfaces bounced rows alongside the rest;
filtering by `status=bounced` isolates them."""
from fastapi.testclient import TestClient
from app import db, email as email_mod
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=802, login="adminB", role="admin")
sign_in_as(
client, user_id=802, gitea_login="adminB",
display_name="Admin B", role="admin", email="adminb@test",
)
email_mod.reset_sent_envelopes()
client.post("/auth/otc/request", json={"email": "willbounce@ex.co"})
row = db.conn().execute(
"SELECT message_id FROM outbound_emails WHERE to_address = 'willbounce@ex.co'"
).fetchone()
client.post(
"/api/webhooks/email-bounce",
json={"email": "willbounce@ex.co", "kind": "hard", "message_id": row["message_id"]},
)
r = client.get("/api/admin/outbound-emails?status=bounced")
assert r.status_code == 200
items = r.json()["items"]
assert items
assert all(it["status"] == "bounced" for it in items)
assert any(it["to_address"] == "willbounce@ex.co" for it in items)
+11
View File
@@ -21,6 +21,7 @@ import pytest
from test_propose_vertical import ( # noqa: F401
FakeGitea,
app_with_fake_gitea,
grant_rfc_collaborator,
provision_user_row,
sign_in_as,
tmp_env,
@@ -140,6 +141,9 @@ def test_get_pr_returns_three_column_payload(app_with_fake_gitea):
provision_user_row(user_id=3, login="bob", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
# Bob is the non-arbiter contributor — alice is seeded as an RFC owner.
# v0.16.0 (item #12): bob needs an accepted per-RFC contributor
# invitation to cut branches and open PRs on alice's RFC.
grant_rfc_collaborator(user_id=3, rfc_slug="ohm", role_in_rfc="contributor")
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob", role="contributor")
branch, _ = _cut_branch_and_accept_change(
client, fake, slug="ohm",
@@ -292,6 +296,9 @@ def test_merge_by_arbiter_advances_main_and_marks_pr_merged(app_with_fake_gitea)
provision_user_row(user_id=1, login="ben", role="owner")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
# Bob is neither owner nor arbiter — the non-merge baseline.
# v0.16.0 (item #12): bob still needs an accepted contributor
# invitation to cut the branch + open the PR.
grant_rfc_collaborator(user_id=3, rfc_slug="ohm", role_in_rfc="contributor")
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob", role="contributor")
branch, _ = _cut_branch_and_accept_change(
client, fake, slug="ohm",
@@ -364,6 +371,10 @@ def test_resolution_branch_replays_clean_and_supersedes_on_merge(app_with_fake_g
provision_user_row(user_id=3, login="bob", role="contributor")
provision_user_row(user_id=1, login="ben", role="owner")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
# v0.16.0 (item #12): bob (a non-owner contributor) needs an
# accepted per-RFC invitation to cut a branch on alice's RFC.
# Alice is the seeded RFC owner so she doesn't need one.
grant_rfc_collaborator(user_id=3, rfc_slug="ohm", role_in_rfc="contributor")
# Alice cuts a branch and accepts a change on it.
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
+130 -1
View File
@@ -395,6 +395,28 @@ def provision_user_row(*, user_id: int, login: str, role: str) -> None:
)
def grant_rfc_collaborator(*, user_id: int, rfc_slug: str, role_in_rfc: str = "contributor") -> None:
"""v0.16.0 / item #12 test seam: directly insert an accepted-
invitation collaborator row so a non-owner contributor can pass
the per-RFC write gate without going through the email round-trip.
Equivalent in effect to the invitationaccept 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
# ---------------------------------------------------------------------------
@@ -416,8 +438,24 @@ def tmp_env(monkeypatch):
"SECRET_KEY": "test-secret-key-for-cookies",
"DATABASE_PATH": str(db_path),
"OWNER_GITEA_LOGIN": "ben",
"GITEA_WEBHOOK_SECRET": "",
# v0.18.0: `GITEA_WEBHOOK_SECRET` is now mandatory at startup
# per the email + webhook hygiene proposal. Tests bind a fake
# value so the framework boots; tests that want to exercise
# the dev-bypass path monkeypatch `RFC_APP_INSECURE_WEBHOOKS=1`.
"GITEA_WEBHOOK_SECRET": "test-webhook-secret-for-signature-verification",
"ENABLED_MODELS": "claude",
# v0.27.0 (audit 0026 M4): the session cookie now defaults to
# Secure. The TestClient talks plain http://testserver, so a
# Secure cookie is never sent back and every authenticated flow
# would fail. Tests opt out explicitly, exactly as a dev box on
# plain http does.
"SESSION_COOKIE_SECURE": "false",
# v0.27.0 (audit 0026 M5): the bounce webhook fails closed (503)
# when its secret is unset. Tests exercise the legacy behavioral
# path via the documented dev opt-in, mirroring the
# RFC_APP_INSECURE_WEBHOOKS bypass above. Tests that assert the
# fail-closed default delenv this key themselves.
"RFC_APP_INSECURE_BOUNCE_WEBHOOK": "1",
}
for k, v in env.items():
monkeypatch.setenv(k, v)
@@ -539,6 +577,97 @@ def test_propose_to_super_draft_vertical(app_with_fake_gitea):
assert ("merge_proposal", "ben") in kinds
def test_merged_idea_pr_with_deleted_branch_clears_proposal(app_with_fake_gitea):
"""Regression: a merged idea PR whose branch was deleted must not
linger as a 'pending idea' ghost.
Found via the ROADMAP #35 operator authoring lane: merging an idea
PR from the CLI with `--delete-branch` makes Gitea report the PR's
`head.ref` as the synthetic `refs/pull/<N>/head` sentinel instead of
`propose/<slug>`. `refresh_meta_pulls` derives the slug from the
branch name, so the sentinel parsed to slug=None, the row was skipped,
and `cached_prs.state` stayed frozen at 'open' leaving the entry
showing as BOTH a super-draft (cached_rfcs reconciled off the push)
AND a pending idea (cached_prs never updated). The fix recovers the
original branch name from the already-stored cached_prs row.
The web UX never tripped this because it leaves the branch in place
(the repo's default_delete_branch_after_merge is false).
The bug only manifests on an out-of-band merge (the PR merged +
branch deleted directly in Gitea, with the in-app merge endpoint never
reconciling the row while the branch still existed) -- which is exactly
what the #35 CLI lane does. An in-app merge reconciles cached_prs to
'merged' before the branch is gone, so it never trips this; the test
therefore drives the Gitea state directly to reproduce the CLI path.
"""
from fastapi.testclient import TestClient
from app import db, cache, gitea as gitea_mod
from app.config import load_config
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor", email="alice@test")
r = client.post("/api/rfcs/propose", json={
"title": "Informed Consent",
"slug": "informed-consent",
"pitch": "A first-class definition of consent in OHM.",
"tags": [],
})
assert r.status_code == 200, r.text
pr_number = r.json()["pr_number"]
# The proposal is cached as an open idea PR.
items = client.get("/api/proposals").json()["items"]
assert any(i["pr_number"] == pr_number for i in items)
# Out-of-band CLI merge (ROADMAP #35 lane): the PR is merged AND
# its branch deleted directly in Gitea, WITHOUT the in-app merge
# endpoint ever running. So cached_prs still says state='open' and
# Gitea now reports the merged PR's head.ref as the sentinel. This
# is the exact state `rfc-authoring.sh pr-merge --delete-branch`
# leaves behind.
for pr in fake.pulls[("wiggleverse", "meta")]:
if pr["number"] == pr_number:
# land the file on main (the push side already reconciles
# cached_rfcs into a super-draft via the webhook/sweep)
for (o, rp, br, p), data in list(fake.files.items()):
if (o, rp, br) == ("wiggleverse", "meta", "propose/informed-consent"):
fake.files[("wiggleverse", "meta", "main", p)] = dict(data)
pr["state"] = "closed"
pr["merged"] = True
pr["merged_at"] = "2026-05-29T12:13:00Z"
pr["closed_at"] = "2026-05-29T12:13:00Z"
pr["merge_commit_sha"] = fake._next_sha()
pr["head"]["ref"] = f"refs/pull/{pr_number}/head"
fake.branches[("wiggleverse", "meta")].pop("propose/informed-consent", None)
# The reconcile sweep runs (a later webhook, or the 5-min safety net).
import asyncio
cfg = load_config()
gclient = gitea_mod.Gitea(cfg)
asyncio.run(cache.refresh_meta_repo(cfg, gclient))
asyncio.run(cache.refresh_meta_pulls(cfg, gclient))
# The bug: this used to still list informed-consent (frozen 'open'
# row, slug unparseable from the sentinel). The fix recovers the
# stored branch name, so the row reconciles to merged and the ghost
# is gone.
assert client.get("/api/proposals").json()["items"] == []
# And the cached_prs row is correctly merged, not a frozen 'open'.
row = db.conn().execute(
"SELECT state FROM cached_prs WHERE pr_number = ?", (pr_number,)
).fetchone()
assert row["state"] == "merged", f"expected merged, got {row['state']}"
# The super-draft itself is unaffected — still in the catalog.
items = client.get("/api/rfcs").json()["items"]
assert any(i["slug"] == "informed-consent" and i["state"] == "super-draft" for i in items)
def test_slug_uniqueness_enforced(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
@@ -0,0 +1,161 @@
"""End-to-end vertical for roadmap #26 (rfc-app v0.22.0): the optional
"What will you be using this for?" capture on the two propose surfaces.
Reuses the FakeGitea + session helpers from test_propose_vertical.py and
the active-RFC seed from test_rfc_view_vertical.py. Proves:
(a) propose-RFC persists and returns `proposed_use_case` when supplied,
and the value survives onto the merged super-draft's RFC view;
(b) propose-RFC accepts a NULL / omitted use case ("left blank");
(c) propose-PR persists and returns `proposed_use_case` when supplied,
and accepts a NULL / omitted one.
"""
from __future__ import annotations
from test_propose_vertical import ( # noqa: F401
FakeGitea,
app_with_fake_gitea,
provision_user_row,
sign_in_as,
tmp_env,
)
from test_pr_flow_vertical import _cut_branch_and_accept_change
from test_rfc_view_vertical import SEED_BODY, seed_active_rfc
# ---------------------------------------------------------------------------
# propose-RFC
# ---------------------------------------------------------------------------
def test_propose_rfc_persists_and_returns_use_case(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
provision_user_row(user_id=1, login="ben", role="owner")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor", email="alice@test")
r = client.post("/api/rfcs/propose", json={
"title": "Open Human Model",
"slug": "open-human-model",
"pitch": "A shared definition of what we mean by *human*.",
"tags": ["identity"],
"proposed_use_case": "Wiring OHM into the OpenXML consent surface.",
})
assert r.status_code == 200, r.text
pr_number = r.json()["pr_number"]
# The pending-idea list carries the use case.
items = client.get("/api/proposals").json()["items"]
assert items[0]["proposed_use_case"] == "Wiring OHM into the OpenXML consent surface."
# The pending-idea detail view carries it too.
proposal = client.get(f"/api/proposals/{pr_number}").json()
assert proposal["proposed_use_case"] == "Wiring OHM into the OpenXML consent surface."
# Merge as owner; the use case survives onto the RFC view (looked
# up by slug from the canonical side table, since the idea PR
# closes on merge).
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner", email="ben@test")
r = client.post(f"/api/proposals/{pr_number}/merge")
assert r.status_code == 200, r.text
view = client.get("/api/rfcs/open-human-model").json()
assert view["proposed_use_case"] == "Wiring OHM into the OpenXML consent surface."
def test_propose_rfc_use_case_optional(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=3, login="carol", role="contributor")
sign_in_as(client, user_id=3, gitea_login="carol", display_name="Carol", role="contributor")
# Omitted entirely.
r = client.post("/api/rfcs/propose", json={
"title": "No Use Case", "slug": "no-use-case", "pitch": "p", "tags": [],
})
assert r.status_code == 200, r.text
pr_a = r.json()["pr_number"]
# Explicit null.
r = client.post("/api/rfcs/propose", json={
"title": "Null Use Case", "slug": "null-use-case", "pitch": "p",
"tags": [], "proposed_use_case": None,
})
assert r.status_code == 200, r.text
pr_b = r.json()["pr_number"]
# Blank/whitespace — treated as "left blank", no row written.
r = client.post("/api/rfcs/propose", json={
"title": "Blank Use Case", "slug": "blank-use-case", "pitch": "p",
"tags": [], "proposed_use_case": " ",
})
assert r.status_code == 200, r.text
pr_c = r.json()["pr_number"]
for pr in (pr_a, pr_b, pr_c):
assert client.get(f"/api/proposals/{pr}").json()["proposed_use_case"] is None
# ---------------------------------------------------------------------------
# propose-PR (against an active RFC)
# ---------------------------------------------------------------------------
def test_propose_pr_persists_and_returns_use_case(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
branch, _ = _cut_branch_and_accept_change(
client, fake, slug="ohm",
original="Open Human Model is a framework for representing humans.",
proposed="Open Human Model is a framework for representing humans across systems.",
)
r = client.post(
f"/api/rfcs/ohm/branches/{branch}/open-pr",
json={
"title": "Tighten the opening",
"description": "Scope to systems.",
"proposed_use_case": "Building a cross-system consent registry.",
},
)
assert r.status_code == 200, r.text
pr_number = r.json()["pr_number"]
pr = client.get(f"/api/rfcs/ohm/prs/{pr_number}").json()
assert pr["proposed_use_case"] == "Building a cross-system consent registry."
def test_propose_pr_use_case_optional(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
branch, _ = _cut_branch_and_accept_change(
client, fake, slug="ohm",
original="It defines consent, trait, and agency in compatible terms.",
proposed="It defines consent, trait, harm, and agency in compatible terms.",
)
# No proposed_use_case key at all.
r = client.post(
f"/api/rfcs/ohm/branches/{branch}/open-pr",
json={"title": "Add harm", "description": "Name harm explicitly."},
)
assert r.status_code == 200, r.text
pr_number = r.json()["pr_number"]
pr = client.get(f"/api/rfcs/ohm/prs/{pr_number}").json()
assert pr["proposed_use_case"] is None
@@ -0,0 +1,658 @@
"""End-to-end integration tests for v0.16.0's owner-only invite for
per-RFC PR or PR-less discussion (roadmap item #12, §6 / §10).
The release lands a per-RFC membership layer:
* `rfc_invitations` issued by the RFC's owner, addressed to an
email, granting one of two roles ('contributor' or 'discussant').
* `rfc_collaborators` the accepted-invitation substrate; the
table the per-RFC write gate consults.
The tests prove:
* Only the RFC's owner (or a platform admin/owner) can invite —
a platform-granted but non-owner user gets 403.
* Creating an invitation lands a row, mints a token, and queues
an envelope on the SMTP buffer.
* Re-inviting the same (email, role) on the same RFC returns 409.
* The accept endpoint requires the accepting user's email to match
the invitee_email (case-insensitive).
* Acceptance lands a rfc_collaborators row and flips the
invitation to 'accepted'.
* Re-accepting the same invitation is idempotent (200, changed=false).
* An expired invitation refuses 409 even if the row's column status
is still 'pending'.
* A revoked invitation refuses 409.
* The owner's listing carries pending + accepted in one response.
* The per-RFC discussion-write gate refuses a non-invited
platform-granted user 403 (was previously 200 before v0.16.0).
* The same gate admits a user who holds an accepted 'discussant'
invitation.
* The same gate admits a user who holds an accepted 'contributor'
invitation (contributor strictly includes discussion).
* The platform admin/owner is admitted regardless of per-RFC
membership (the platform-level capability path).
* The /api/admin/users listing carries `rfc_invitations` per-user
after an acceptance the §17 admin surface hook.
"""
from __future__ import annotations
# Reuse fixtures and helpers from the propose / RFC-view harnesses.
from test_propose_vertical import ( # noqa: F401 — fixtures land via import
FakeGitea,
app_with_fake_gitea,
provision_user_row,
sign_in_as,
tmp_env,
)
from test_rfc_view_vertical import seed_active_rfc, SEED_BODY
def _reset_outbound():
from app import email as email_mod
email_mod.reset_sent_envelopes()
def _invitation_envelopes(to_address: str | None = None) -> list[dict]:
"""Pluck v0.16.0 invitation envelopes out of the shared _SENT buffer.
Same access pattern as the OTC tests use for `kind='otc'`."""
from app import email as email_mod
out = []
for env in email_mod.sent_envelopes():
if env.get("kind") != "rfc_invitation":
continue
if to_address is not None and env["to"] != to_address:
continue
out.append(env)
return out
# ---------------------------------------------------------------------------
# Create / list / revoke (owner-side)
# ---------------------------------------------------------------------------
def test_owner_can_invite_creates_row_and_sends_email(app_with_fake_gitea):
"""The end-to-end create gesture: RFC owner posts an invitation,
a row lands, the token comes back in the response, and an
`rfc_invitation`-kind envelope hits the SMTP buffer."""
from fastapi.testclient import TestClient
from app import db
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
# The frontmatter owner of the seeded RFC is "alice" (per
# seed_active_rfc's default), so we sign in as that user.
provision_user_row(user_id=1, login="alice", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(
client, user_id=1, gitea_login="alice",
display_name="Alice", role="contributor",
)
r = client.post(
"/api/rfcs/ohm/invitations",
json={"invitee_email": "newperson@example.com", "role_in_rfc": "contributor"},
)
assert r.status_code == 200, r.text
body = r.json()
assert body["rfc_slug"] == "ohm"
assert body["invitee_email"] == "newperson@example.com"
assert body["role_in_rfc"] == "contributor"
assert body["status"] == "pending"
assert body["token"] and len(body["token"]) > 16
# Row landed.
row = db.conn().execute(
"SELECT * FROM rfc_invitations WHERE id = ?", (body["id"],),
).fetchone()
assert row["rfc_slug"] == "ohm"
assert row["invitee_email"] == "newperson@example.com"
assert row["inviter_user_id"] == 1
assert row["status"] == "pending"
# Email envelope went out.
envs = _invitation_envelopes("newperson@example.com")
assert len(envs) == 1
assert "OHM" in envs[0]["subject"]
assert body["token"] in envs[0]["body"]
def test_non_owner_cannot_invite(app_with_fake_gitea):
"""A platform-granted user who isn't in the RFC's frontmatter
owners list cannot invite 403. Distinct from the
require_contributor gate (which would be 401 for anonymous)."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
# alice is the RFC owner per the seed; bob is a regular
# platform-granted contributor with no per-RFC role.
provision_user_row(user_id=1, login="alice", role="contributor")
provision_user_row(user_id=2, login="bob", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(
client, user_id=2, gitea_login="bob",
display_name="Bob", role="contributor",
)
r = client.post(
"/api/rfcs/ohm/invitations",
json={"invitee_email": "ignored@example.com", "role_in_rfc": "discussant"},
)
assert r.status_code == 403
def test_platform_admin_can_invite_to_any_rfc(app_with_fake_gitea):
"""Per §6.1 the platform admin/owner role carries the maximal
per-RFC capability, so admins can invite on any RFC even if
they're not in its owners list."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
provision_user_row(user_id=1, login="alice", role="contributor")
provision_user_row(user_id=99, login="adminzero", role="admin")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(
client, user_id=99, gitea_login="adminzero",
display_name="Admin Zero", role="admin",
)
r = client.post(
"/api/rfcs/ohm/invitations",
json={"invitee_email": "another@example.com", "role_in_rfc": "discussant"},
)
assert r.status_code == 200, r.text
def test_anonymous_cannot_invite(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
r = client.post(
"/api/rfcs/ohm/invitations",
json={"invitee_email": "x@example.com", "role_in_rfc": "discussant"},
)
assert r.status_code == 401
def test_re_invite_same_email_and_role_returns_409(app_with_fake_gitea):
"""Refuse a duplicate pending invitation for the same (email, role)
on the same RFC. A different role on the same email is allowed
(the owner may want to upgrade discussant contributor)."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
provision_user_row(user_id=1, login="alice", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(
client, user_id=1, gitea_login="alice",
display_name="Alice", role="contributor",
)
r1 = client.post(
"/api/rfcs/ohm/invitations",
json={"invitee_email": "dup@example.com", "role_in_rfc": "discussant"},
)
assert r1.status_code == 200
r2 = client.post(
"/api/rfcs/ohm/invitations",
json={"invitee_email": "dup@example.com", "role_in_rfc": "discussant"},
)
assert r2.status_code == 409
# Same email, different role is allowed.
r3 = client.post(
"/api/rfcs/ohm/invitations",
json={"invitee_email": "dup@example.com", "role_in_rfc": "contributor"},
)
assert r3.status_code == 200
def test_owner_can_list_invitations(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
provision_user_row(user_id=1, login="alice", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(
client, user_id=1, gitea_login="alice",
display_name="Alice", role="contributor",
)
client.post("/api/rfcs/ohm/invitations",
json={"invitee_email": "a@example.com", "role_in_rfc": "discussant"})
client.post("/api/rfcs/ohm/invitations",
json={"invitee_email": "b@example.com", "role_in_rfc": "contributor"})
r = client.get("/api/rfcs/ohm/invitations")
assert r.status_code == 200, r.text
items = r.json()["items"]
emails = sorted(i["invitee_email"] for i in items)
assert emails == ["a@example.com", "b@example.com"]
assert all(i["status"] == "pending" for i in items)
# The inviter is named.
assert all(i["inviter_login"] == "alice" for i in items)
def test_revoke_pending_invitation_works_already_accepted_refuses(app_with_fake_gitea):
"""Revoke flips a pending invitation to 'revoked'. An already-
accepted invitation refuses 409 accepted membership is removed
via a different (future) surface; the v0.16.0 revoke only lifts
the pending link."""
from fastapi.testclient import TestClient
from app import db
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
provision_user_row(user_id=1, login="alice", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(
client, user_id=1, gitea_login="alice",
display_name="Alice", role="contributor",
)
r = client.post(
"/api/rfcs/ohm/invitations",
json={"invitee_email": "revokee@example.com", "role_in_rfc": "discussant"},
)
invitation_id = r.json()["id"]
r = client.post(f"/api/rfcs/ohm/invitations/{invitation_id}/revoke")
assert r.status_code == 200
assert r.json()["status"] == "revoked"
# Re-revoke refuses 409.
r2 = client.post(f"/api/rfcs/ohm/invitations/{invitation_id}/revoke")
assert r2.status_code == 409
row = db.conn().execute(
"SELECT status FROM rfc_invitations WHERE id = ?", (invitation_id,),
).fetchone()
assert row["status"] == "revoked"
# ---------------------------------------------------------------------------
# Accept (invitee-side)
# ---------------------------------------------------------------------------
def test_accept_invitation_lands_collaborator_row(app_with_fake_gitea):
"""The end-to-end accept gesture: the invitee signs in, posts the
token, and an rfc_collaborators row lands at the issued role."""
from fastapi.testclient import TestClient
from app import db
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
provision_user_row(user_id=1, login="alice", role="contributor")
# provision_user_row sets the email to "<login>@test", so the
# invitee row we'll create needs the same email shape.
provision_user_row(user_id=2, login="newbie", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
# alice (owner) invites newbie@test.
sign_in_as(
client, user_id=1, gitea_login="alice",
display_name="Alice", role="contributor",
)
r = client.post(
"/api/rfcs/ohm/invitations",
json={"invitee_email": "newbie@test", "role_in_rfc": "contributor"},
)
assert r.status_code == 200, r.text
token = r.json()["token"]
# Switch to newbie, accept.
sign_in_as(
client, user_id=2, gitea_login="newbie",
display_name="Newbie", role="contributor",
email="newbie@test",
)
r = client.post("/api/invitations/accept", json={"token": token})
assert r.status_code == 200, r.text
body = r.json()
assert body["ok"] is True
assert body["changed"] is True
assert body["rfc_slug"] == "ohm"
assert body["role_in_rfc"] == "contributor"
# Collaborator row landed; invitation flipped.
collab = db.conn().execute(
"SELECT role_in_rfc FROM rfc_collaborators WHERE rfc_slug = 'ohm' AND user_id = 2",
).fetchone()
assert collab is not None
assert collab["role_in_rfc"] == "contributor"
inv = db.conn().execute(
"SELECT status, accepted_by_user_id FROM rfc_invitations WHERE token = ?",
(token,),
).fetchone()
assert inv["status"] == "accepted"
assert inv["accepted_by_user_id"] == 2
def test_accept_refuses_when_email_does_not_match(app_with_fake_gitea):
"""The accepting user's email must match the invitation's
invitee_email (case-insensitive)."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
provision_user_row(user_id=1, login="alice", role="contributor")
provision_user_row(user_id=2, login="mallory", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(client, user_id=1, gitea_login="alice",
display_name="Alice", role="contributor")
r = client.post(
"/api/rfcs/ohm/invitations",
json={"invitee_email": "intended@example.com", "role_in_rfc": "discussant"},
)
token = r.json()["token"]
# mallory's email is "mallory@test", not "intended@example.com".
sign_in_as(client, user_id=2, gitea_login="mallory",
display_name="Mallory", role="contributor",
email="mallory@test")
r = client.post("/api/invitations/accept", json={"token": token})
assert r.status_code == 403
def test_accept_refuses_revoked_invitation(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
provision_user_row(user_id=1, login="alice", role="contributor")
provision_user_row(user_id=2, login="newbie", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(client, user_id=1, gitea_login="alice",
display_name="Alice", role="contributor")
r = client.post(
"/api/rfcs/ohm/invitations",
json={"invitee_email": "newbie@test", "role_in_rfc": "discussant"},
)
invitation_id = r.json()["id"]
token = r.json()["token"]
client.post(f"/api/rfcs/ohm/invitations/{invitation_id}/revoke")
sign_in_as(client, user_id=2, gitea_login="newbie",
display_name="Newbie", role="contributor",
email="newbie@test")
r = client.post("/api/invitations/accept", json={"token": token})
assert r.status_code == 409
def test_accept_refuses_expired_invitation(app_with_fake_gitea):
"""An invitation past its `expires_at` is refused 409 even if
the row's column status is still 'pending'. We backdate the
expires_at directly to model the elapsed-window state."""
from fastapi.testclient import TestClient
from app import db
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
provision_user_row(user_id=1, login="alice", role="contributor")
provision_user_row(user_id=2, login="newbie", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(client, user_id=1, gitea_login="alice",
display_name="Alice", role="contributor")
r = client.post(
"/api/rfcs/ohm/invitations",
json={"invitee_email": "newbie@test", "role_in_rfc": "discussant"},
)
token = r.json()["token"]
invitation_id = r.json()["id"]
# Backdate.
db.conn().execute(
"UPDATE rfc_invitations SET expires_at = datetime('now', '-1 day') WHERE id = ?",
(invitation_id,),
)
sign_in_as(client, user_id=2, gitea_login="newbie",
display_name="Newbie", role="contributor",
email="newbie@test")
r = client.post("/api/invitations/accept", json={"token": token})
assert r.status_code == 409
def test_accept_is_idempotent_on_re_accept(app_with_fake_gitea):
"""Re-accepting the same already-accepted invitation reads as a
200 no-op with `changed=false`. The collaborator row is unchanged."""
from fastapi.testclient import TestClient
from app import db
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
provision_user_row(user_id=1, login="alice", role="contributor")
provision_user_row(user_id=2, login="newbie", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(client, user_id=1, gitea_login="alice",
display_name="Alice", role="contributor")
r = client.post(
"/api/rfcs/ohm/invitations",
json={"invitee_email": "newbie@test", "role_in_rfc": "discussant"},
)
token = r.json()["token"]
sign_in_as(client, user_id=2, gitea_login="newbie",
display_name="Newbie", role="contributor",
email="newbie@test")
r1 = client.post("/api/invitations/accept", json={"token": token})
assert r1.status_code == 200
assert r1.json()["changed"] is True
r2 = client.post("/api/invitations/accept", json={"token": token})
assert r2.status_code == 200
assert r2.json()["changed"] is False
# Still exactly one collaborator row.
rows = db.conn().execute(
"SELECT COUNT(*) AS n FROM rfc_collaborators WHERE rfc_slug = 'ohm' AND user_id = 2"
).fetchone()
assert rows["n"] == 1
# ---------------------------------------------------------------------------
# Discussion-write gate enforcement
# ---------------------------------------------------------------------------
def test_non_invited_user_cannot_post_to_discussion(app_with_fake_gitea):
"""v0.16.0 narrows the discussion-write gate: a platform-granted
user with no per-RFC role gets 403 when posting to the
discussion. (v0.6.0 left the gate at require_contributor only;
item #12 layers can_discuss_rfc on top.)"""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="alice", role="contributor")
provision_user_row(user_id=2, login="bob", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
# bob is platform-granted but not in OHM's owners list and has
# no invitation. The thread-create surface refuses 403.
sign_in_as(client, user_id=2, gitea_login="bob",
display_name="Bob", role="contributor")
r = client.post(
"/api/rfcs/ohm/discussion/threads",
json={"label": "Question", "message": "Should I be allowed?"},
)
assert r.status_code == 403
def test_invited_discussant_can_post_to_discussion(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
provision_user_row(user_id=1, login="alice", role="contributor")
provision_user_row(user_id=2, login="newbie", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
# alice invites newbie as a discussant.
sign_in_as(client, user_id=1, gitea_login="alice",
display_name="Alice", role="contributor")
r = client.post(
"/api/rfcs/ohm/invitations",
json={"invitee_email": "newbie@test", "role_in_rfc": "discussant"},
)
token = r.json()["token"]
# newbie accepts.
sign_in_as(client, user_id=2, gitea_login="newbie",
display_name="Newbie", role="contributor",
email="newbie@test")
client.post("/api/invitations/accept", json={"token": token})
# newbie can now post to the discussion.
r = client.post(
"/api/rfcs/ohm/discussion/threads",
json={"label": "Question", "message": "Now I can speak."},
)
assert r.status_code == 200, r.text
def test_contributor_role_includes_discussion(app_with_fake_gitea):
"""A 'contributor' per-RFC role strictly includes discussion
permission accepting a contributor invitation admits the user
to the discussion endpoint too."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
provision_user_row(user_id=1, login="alice", role="contributor")
provision_user_row(user_id=2, login="newbie", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(client, user_id=1, gitea_login="alice",
display_name="Alice", role="contributor")
r = client.post(
"/api/rfcs/ohm/invitations",
json={"invitee_email": "newbie@test", "role_in_rfc": "contributor"},
)
token = r.json()["token"]
sign_in_as(client, user_id=2, gitea_login="newbie",
display_name="Newbie", role="contributor",
email="newbie@test")
client.post("/api/invitations/accept", json={"token": token})
r = client.post(
"/api/rfcs/ohm/discussion/threads",
json={"label": "Q", "message": "Hello."},
)
assert r.status_code == 200
def test_platform_admin_can_post_to_discussion_without_invitation(app_with_fake_gitea):
"""Per §6.1 / item #12's permission shape: platform admins/owners
can write to any RFC's discussion regardless of per-RFC
membership."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="alice", role="contributor")
provision_user_row(user_id=99, login="adminzero", role="admin")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(client, user_id=99, gitea_login="adminzero",
display_name="Admin Zero", role="admin")
r = client.post(
"/api/rfcs/ohm/discussion/threads",
json={"label": "Admin chime", "message": "Drive-by from admin."},
)
assert r.status_code == 200
def test_rfc_owner_can_post_to_discussion(app_with_fake_gitea):
"""The frontmatter RFC owner is admitted by virtue of being on
the owners list they don't need to invite themselves."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="alice", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
sign_in_as(client, user_id=1, gitea_login="alice",
display_name="Alice", role="contributor")
r = client.post(
"/api/rfcs/ohm/discussion/threads",
json={"label": "Owner thought", "message": "Kicking off the conversation."},
)
assert r.status_code == 200
# ---------------------------------------------------------------------------
# Admin-page hook (additive on /api/admin/users)
# ---------------------------------------------------------------------------
def test_admin_users_listing_surfaces_per_rfc_invitations(app_with_fake_gitea):
"""v0.16.0 hook into the v0.9.0 admin user-management surface:
each user row carries an `rfc_invitations` array listing the
per-RFC roles they hold. Empty array for users without any."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
provision_user_row(user_id=1, login="alice", role="contributor")
provision_user_row(user_id=2, login="newbie", role="contributor")
provision_user_row(user_id=99, login="adminzero", role="admin")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
# alice invites newbie; newbie accepts.
sign_in_as(client, user_id=1, gitea_login="alice",
display_name="Alice", role="contributor")
r = client.post(
"/api/rfcs/ohm/invitations",
json={"invitee_email": "newbie@test", "role_in_rfc": "contributor"},
)
token = r.json()["token"]
sign_in_as(client, user_id=2, gitea_login="newbie",
display_name="Newbie", role="contributor",
email="newbie@test")
client.post("/api/invitations/accept", json={"token": token})
# Admin lists.
sign_in_as(client, user_id=99, gitea_login="adminzero",
display_name="Admin Zero", role="admin")
r = client.get("/api/admin/users")
assert r.status_code == 200
items = r.json()["items"]
newbie_row = next(i for i in items if i["gitea_login"] == "newbie")
assert isinstance(newbie_row["rfc_invitations"], list)
assert len(newbie_row["rfc_invitations"]) == 1
invite = newbie_row["rfc_invitations"][0]
assert invite["rfc_slug"] == "ohm"
assert invite["role_in_rfc"] == "contributor"
assert invite["inviter_login"] == "alice"
# Users with no invitations carry an empty array, not null.
alice_row = next(i for i in items if i["gitea_login"] == "alice")
assert alice_row["rfc_invitations"] == []
+273
View File
@@ -0,0 +1,273 @@
"""Roadmap #28 Part 1 — auto-link RFC references in PR text + comments.
Two layers:
* Unit tests over the pure scanner (`rfc_links.segment_text` /
`_keys_for` / `LinkIndex`) the matching rules and their
false-positive guards, no DB.
* End-to-end tests that the PR description, PR review comments, and
PR-less discussion comments all surface `*_segments` enriched against
the live accepted-RFC corpus, with self-references suppressed.
Reuses the FakeGitea + session helpers from test_propose_vertical.py and
the active-RFC seed from test_rfc_view_vertical.py.
"""
from __future__ import annotations
from app import rfc_links
from test_propose_vertical import ( # noqa: F401
FakeGitea,
app_with_fake_gitea,
grant_rfc_collaborator,
provision_user_row,
sign_in_as,
tmp_env,
)
from test_rfc_view_vertical import SEED_BODY, seed_active_rfc
from test_pr_flow_vertical import _cut_branch_and_accept_change
# ---------------------------------------------------------------------------
# Unit — the pure scanner
# ---------------------------------------------------------------------------
def _idx(*terms):
"""Build a LinkIndex from raw (key, slug, title) tuples (keys lower)."""
return rfc_links.LinkIndex(list(terms))
def test_empty_text_is_single_empty_segment():
assert rfc_links.segment_text("", []) == [{"type": "text", "text": ""}]
assert rfc_links.segment_text(None, []) == [{"type": "text", "text": ""}]
def test_no_terms_returns_plain_text():
out = rfc_links.segment_text("hello world", [])
assert out == [{"type": "text", "text": "hello world"}]
def test_multiword_title_links_and_preserves_casing():
idx = _idx(("open human model", "open-human-model", "Open Human Model"))
out = idx.segment("See the Open Human Model for details.")
assert out == [
{"type": "text", "text": "See the "},
{"type": "rfc", "slug": "open-human-model", "label": "Open Human Model",
"title": "Open Human Model"},
{"type": "text", "text": " for details."},
]
def test_match_is_case_insensitive():
idx = _idx(("open human model", "open-human-model", "Open Human Model"))
out = idx.segment("see the OPEN HUMAN MODEL")
assert out[-1] == {"type": "rfc", "slug": "open-human-model",
"label": "OPEN HUMAN MODEL", "title": "Open Human Model"}
def test_word_boundary_prevents_substring_match():
# "harm" must not match inside "charming" / "harmless".
idx = _idx(("rfc-0001", "open-human-model", "Open Human Model"))
out = idx.segment("a charming rfc-00012 not real")
# rfc-0001 is a prefix of rfc-00012 but the trailing '2' is a word char,
# so no match — the whole string stays plain text.
assert out == [{"type": "text", "text": "a charming rfc-00012 not real"}]
def test_rfc_id_token_links():
idx = _idx(("rfc-0001", "open-human-model", "Open Human Model"))
out = idx.segment("as established in RFC-0001.")
assert out[1] == {"type": "rfc", "slug": "open-human-model",
"label": "RFC-0001", "title": "Open Human Model"}
def test_longest_match_wins():
# A bare "Open" term and the full title both present; the full title
# (longer) must win at the position.
idx = _idx(
("open", "open", "Open"),
("open human model", "open-human-model", "Open Human Model"),
)
out = idx.segment("the Open Human Model")
assert out[-1]["slug"] == "open-human-model"
assert out[-1]["label"] == "Open Human Model"
def test_pending_term_emits_contribute_segment():
# Part 3: a super-draft match is an `rfc-pending` segment carrying the
# owner display name, not a plain link.
idx = rfc_links.LinkIndex([
rfc_links.Term(key="open human model", kind="pending",
slug="open-human-model", title="Open Human Model", owner="Alice"),
])
out = idx.segment("see Open Human Model please")
assert out[1] == {
"type": "rfc-pending", "slug": "open-human-model",
"label": "Open Human Model", "title": "Open Human Model", "owner": "Alice",
}
def test_candidate_term_emits_create_segment():
# Part 2: a candidate term carries its canonical spelling for the
# propose pre-fill; no slug (no RFC exists yet).
idx = rfc_links.LinkIndex([
rfc_links.Term(key="memory model", kind="candidate", term="Memory Model"),
])
out = idx.segment("the memory model is unspecified")
assert out[1] == {"type": "rfc-candidate", "label": "memory model", "term": "Memory Model"}
def test_kind_precedence_active_beats_pending_beats_candidate():
# All three buckets contribute the same key; the highest-precedence
# kind (active) must win at the position.
key = "open human model"
idx = rfc_links.LinkIndex([
rfc_links.Term(key=key, kind="candidate", term="Open Human Model"),
rfc_links.Term(key=key, kind="pending", slug="ohm-draft", title="Open Human Model", owner="A"),
rfc_links.Term(key=key, kind="active", slug="open-human-model", title="Open Human Model"),
])
out = idx.segment("the Open Human Model")
assert out[-1]["type"] == "rfc"
assert out[-1]["slug"] == "open-human-model"
def test_keys_for_gating():
keys = lambda **kw: set(rfc_links._keys_for(**kw))
# rfc_id always contributes.
assert "rfc-0001" in keys(slug="x", title="X", rfc_id="RFC-0001")
# multi-word title contributes; single common word does NOT.
assert "open human model" in keys(slug="ohm", title="Open Human Model", rfc_id=None)
assert keys(slug="human", title="Human", rfc_id=None) == set()
# hyphenated slug contributes; single-token slug does NOT.
assert "open-human-model" in keys(slug="open-human-model", title="X", rfc_id=None)
assert "ohm" not in keys(slug="ohm", title="OHM", rfc_id=None)
# ---------------------------------------------------------------------------
# End-to-end — enrichment surfaces on the read paths
# ---------------------------------------------------------------------------
def _open_pr_on(client, fake, *, host_slug: str, description: str):
"""Seed branch + accepted change on host_slug and open a PR. Returns
the pr_number."""
# `original` must exist verbatim in SEED_BODY or the accept is "stale".
branch, _ = _cut_branch_and_accept_change(
client, fake, slug=host_slug,
original="It defines consent, trait, and agency in compatible terms.",
proposed="It defines consent, trait, harm, and agency in compatible terms.",
)
r = client.post(
f"/api/rfcs/{host_slug}/branches/{branch}/open-pr",
json={"title": "A change", "description": description},
)
assert r.status_code == 200, r.text
return r.json()["pr_number"]
def _rfc_segments(segments):
return [s for s in segments if s["type"] == "rfc"]
def test_pr_description_autolinks_other_rfc(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
# Two accepted RFCs: a host for the PR + a referenceable target.
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
seed_active_rfc(fake, slug="open-human-model", title="Open Human Model", body=SEED_BODY)
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
pr_number = _open_pr_on(
client, fake, host_slug="ohm",
description="This builds on the Open Human Model definition.",
)
r = client.get(f"/api/rfcs/ohm/prs/{pr_number}")
assert r.status_code == 200, r.text
pr = r.json()
links = _rfc_segments(pr["description_segments"])
assert len(links) == 1
assert links[0]["slug"] == "open-human-model"
assert links[0]["label"] == "Open Human Model"
# The plain text is still present for non-segment callers (the bot
# appends a §6.5 On-behalf-of trailer, so this is a containment check).
assert "This builds on the Open Human Model definition." in pr["description"]
def test_pr_review_comment_autolinked(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
seed_active_rfc(fake, slug="open-human-model", title="Open Human Model", body=SEED_BODY)
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
pr_number = _open_pr_on(client, fake, host_slug="ohm", description="plain.")
r = client.post(
f"/api/rfcs/ohm/prs/{pr_number}/review",
json={"text": "See Open Human Model and RFC-0001.", "anchor_payload": {}},
)
assert r.status_code == 200, r.text
pr = client.get(f"/api/rfcs/ohm/prs/{pr_number}").json()
all_msgs = [m for msgs in pr["messages_by_thread"].values() for m in msgs]
review_msgs = [m for m in all_msgs if "Open Human Model" in (m["text"] or "")]
assert review_msgs, "review comment not found in payload"
links = _rfc_segments(review_msgs[0]["text_segments"])
# Both "Open Human Model" (title) and "RFC-0001" (id) point to the
# one referenceable RFC.
assert {s["slug"] for s in links} == {"open-human-model"}
assert {s["label"] for s in links} == {"Open Human Model", "RFC-0001"}
def test_discussion_comment_autolinked(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
seed_active_rfc(fake, slug="open-human-model", title="Open Human Model", body=SEED_BODY)
# alice is the seeded owner of ohm (owners=["alice"]); grant the
# per-RFC collaborator row explicitly so the #12 discuss gate passes.
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")
r = client.post(
"/api/rfcs/ohm/discussion/threads",
json={"message": "Compare with the Open Human Model."},
)
assert r.status_code == 200, r.text
thread_id = r.json()["thread_id"]
r = client.get(f"/api/rfcs/ohm/discussion/threads/{thread_id}/messages")
assert r.status_code == 200, r.text
msgs = r.json()["messages"]
assert msgs and "text_segments" in msgs[0]
links = _rfc_segments(msgs[0]["text_segments"])
assert len(links) == 1
assert links[0]["slug"] == "open-human-model"
def test_self_reference_not_linked(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
# The host RFC has a multi-word title, so absent exclude_slug it
# WOULD self-link. exclude_slug must suppress it.
seed_active_rfc(fake, slug="open-human-model", title="Open Human Model", body=SEED_BODY)
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
pr_number = _open_pr_on(
client, fake, host_slug="open-human-model",
description="Refines the Open Human Model definition.",
)
pr = client.get(f"/api/rfcs/open-human-model/prs/{pr_number}").json()
assert _rfc_segments(pr["description_segments"]) == []
@@ -0,0 +1,140 @@
"""End-to-end integration tests for the v0.23.0 sign-in state-resume
vertical (§6.2, roadmap item #29).
New behavior: each authenticated user's last-viewed route + a small bag
of light view state is tracked server-side, so the next sign-in can land
them back where they left off rather than on the empty-state home view.
The tests below prove:
* `PUT /api/me/last-state` requires auth an anonymous client gets
401, and nothing is stored.
* An authenticated PUT upserts the route, and the stored route is
read back for that user off `GET /api/auth/me` (`last_route`).
* A second PUT overwrites (upsert, one row per user) the latest
route wins.
* `last_route_state` round-trips as decoded JSON on `/api/auth/me`.
* Per-user isolation: user A's stored route is not visible to user B.
* `resume_enabled = 0` disables resume: the PUT no-ops (does not
rewrite the stored route) and `/api/auth/me` hands back a null
`last_route` even though a stored row exists.
The fakes from `test_propose_vertical` give us a working app harness +
the `sign_in_as` / `provision_user_row` seams.
"""
from __future__ import annotations
from test_propose_vertical import ( # noqa: F401
FakeGitea,
app_with_fake_gitea,
provision_user_row,
sign_in_as,
tmp_env,
)
def test_put_last_state_requires_auth(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
# Anonymous — no session cookie set.
r = client.put("/api/me/last-state", json={"route": "/rfc/open-human-model"})
assert r.status_code == 401
# Nothing landed in the table.
row = db.conn().execute("SELECT COUNT(*) AS n FROM user_session_state").fetchone()
assert row["n"] == 0
def test_put_last_state_upserts_and_reads_back(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor", email="alice@test")
# First POST stores a route + light state.
r = client.put(
"/api/me/last-state",
json={"route": "/rfc/open-human-model", "state": {"tab": "discussion", "scroll": 420}},
)
assert r.status_code == 200
assert r.json()["stored"] is True
# /api/auth/me hands the route + decoded state back.
me = client.get("/api/auth/me").json()
assert me["authenticated"] is True
assert me["user"]["resume_enabled"] is True
assert me["user"]["last_route"] == "/rfc/open-human-model"
assert me["user"]["last_route_state"] == {"tab": "discussion", "scroll": 420}
# A later POST overwrites — one row per user, latest wins.
r = client.put("/api/me/last-state", json={"route": "/proposals/7"})
assert r.status_code == 200
me = client.get("/api/auth/me").json()
assert me["user"]["last_route"] == "/proposals/7"
# state was omitted on the second POST → cleared to null.
assert me["user"]["last_route_state"] is None
def test_last_state_is_per_user(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
provision_user_row(user_id=3, login="bob", role="contributor")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor", email="alice@test")
client.put("/api/me/last-state", json={"route": "/rfc/alice-route"})
# Switch to bob — he has no stored route yet.
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob", role="contributor", email="bob@test")
me = client.get("/api/auth/me").json()
assert me["user"]["last_route"] is None
client.put("/api/me/last-state", json={"route": "/rfc/bob-route"})
me = client.get("/api/auth/me").json()
assert me["user"]["last_route"] == "/rfc/bob-route"
# Back to alice — her route is untouched by bob's write.
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor", email="alice@test")
me = client.get("/api/auth/me").json()
assert me["user"]["last_route"] == "/rfc/alice-route"
def test_resume_disabled_no_ops_put_and_hides_route(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor", email="alice@test")
# Seed a stored row, then flip resume_enabled off directly (the
# profile-settings toggle UI to do this from the client is a
# follow-up; the column + behavior ship now).
client.put("/api/me/last-state", json={"route": "/rfc/before-disable"})
db.conn().execute(
"UPDATE user_session_state SET resume_enabled = 0 WHERE user_id = ?",
(2,),
)
# /api/auth/me reports resume off and hands back a null route
# even though a stored row exists.
me = client.get("/api/auth/me").json()
assert me["user"]["resume_enabled"] is False
assert me["user"]["last_route"] is None
# A PUT while disabled no-ops: stored=False and the stored route
# is NOT rewritten.
r = client.put("/api/me/last-state", json={"route": "/rfc/after-disable"})
assert r.status_code == 200
assert r.json()["stored"] is False
row = db.conn().execute(
"SELECT last_route FROM user_session_state WHERE user_id = ?", (2,)
).fetchone()
assert row["last_route"] == "/rfc/before-disable"
+264
View File
@@ -0,0 +1,264 @@
"""Vertical + unit coverage for roadmap #27 (rfc-app v0.24.0): Claude
Haiku tag suggestions on the propose-RFC modal.
Reuses the FakeGitea + session helpers from test_propose_vertical.py.
The Anthropic call is never made for real tests monkeypatch the
`tag_suggest.haiku_provider` seam with a stub provider whose `send`
returns canned text, so the HTTP contract is exercised without a key.
Proves:
(a) the endpoint is contributor-gated (anon 401);
(b) a contributor gets suggestions, filtered to the corpus tag
universe, with invented tags dropped;
(c) no Anthropic key bound empty list, not an error;
(d) an empty corpus empty list (model is never even called);
(e) the per-user rate limit surfaces as a 429;
plus unit coverage of the universe gather, the reply parser's tolerance,
and the suggest() orchestration short-circuits.
"""
from __future__ import annotations
import json
import pytest
from test_propose_vertical import ( # noqa: F401
FakeGitea,
app_with_fake_gitea,
provision_user_row,
sign_in_as,
tmp_env,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
class StubProvider:
"""A BaseProvider stand-in whose send() returns a fixed string (or
raises, to exercise the graceful-failure path)."""
def __init__(self, reply: str = "[]", *, raises: bool = False):
self.reply = reply
self.raises = raises
self.calls: list[tuple[str, list]] = []
def send(self, system, history):
self.calls.append((system, history))
if self.raises:
raise RuntimeError("boom")
return self.reply
def _seed_tags(slug: str, title: str, tags: list[str], state: str = "active") -> None:
from app import db
db.conn().execute(
"INSERT OR REPLACE INTO cached_rfcs (slug, title, state, tags_json) VALUES (?, ?, ?, ?)",
(slug, title, state, json.dumps(tags)),
)
def _use_provider(monkeypatch, provider) -> None:
monkeypatch.setattr("app.tag_suggest.haiku_provider", lambda config: provider)
@pytest.fixture(autouse=True)
def _reset_rate_limits():
from app import tag_suggest
tag_suggest.reset_rate_limits()
yield
tag_suggest.reset_rate_limits()
# ---------------------------------------------------------------------------
# Endpoint (vertical)
# ---------------------------------------------------------------------------
def test_anonymous_cannot_suggest(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
r = client.post("/api/rfcs/suggest-tags", json={"title": "X"})
assert r.status_code == 401
def test_contributor_gets_filtered_suggestions(app_with_fake_gitea, monkeypatch):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
_seed_tags("ohm", "OHM", ["identity", "schema", "consent"])
_seed_tags("other", "Other", ["identity", "governance"])
# Model returns two real tags (one lowercased to test canonical
# mapping is exact-set anyway), plus one invented tag that MUST
# be dropped.
reply = json.dumps([
{"tag": "identity", "confidence": 0.9},
{"tag": "consent", "confidence": 0.7},
{"tag": "totally-invented", "confidence": 0.99},
])
stub = StubProvider(reply=reply)
_use_provider(monkeypatch, stub)
r = client.post("/api/rfcs/suggest-tags", json={
"title": "Consent and identity",
"pitch": "We need a shared definition of consent tied to identity.",
})
assert r.status_code == 200, r.text
tags = [s["tag"] for s in r.json()["suggestions"]]
assert tags == ["identity", "consent"]
# the model was actually invoked
assert len(stub.calls) == 1
# the universe (deduped distinct tags) was handed to the model
user_msg = stub.calls[0][1][0]["content"]
assert "identity" in user_msg and "governance" in user_msg
def test_no_api_key_returns_empty(app_with_fake_gitea, monkeypatch):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
_seed_tags("ohm", "OHM", ["identity"])
# No key bound (test env has no ANTHROPIC_API_KEY) → provider None.
# (Explicitly assert the seam returns None given the test config.)
from app import tag_suggest
assert tag_suggest.haiku_provider(app.state.config) is None
r = client.post("/api/rfcs/suggest-tags", json={"title": "X", "pitch": "y"})
assert r.status_code == 200, r.text
assert r.json()["suggestions"] == []
def test_empty_corpus_returns_empty_without_calling_model(app_with_fake_gitea, monkeypatch):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
stub = StubProvider(reply=json.dumps([{"tag": "x", "confidence": 1}]))
_use_provider(monkeypatch, stub)
# No cached_rfcs rows → empty universe → suggest() short-circuits.
r = client.post("/api/rfcs/suggest-tags", json={"title": "X", "pitch": "y"})
assert r.status_code == 200, r.text
assert r.json()["suggestions"] == []
assert stub.calls == [] # model never invoked on an empty universe
def test_rate_limit_surfaces_429(app_with_fake_gitea, monkeypatch):
from fastapi.testclient import TestClient
monkeypatch.setenv("TAG_SUGGEST_RATE_MAX", "2")
monkeypatch.setenv("TAG_SUGGEST_RATE_WINDOW_SECONDS", "60")
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
_seed_tags("ohm", "OHM", ["identity"])
_use_provider(monkeypatch, StubProvider(reply="[]"))
body = {"title": "X", "pitch": "y"}
assert client.post("/api/rfcs/suggest-tags", json=body).status_code == 200
assert client.post("/api/rfcs/suggest-tags", json=body).status_code == 200
# Third call inside the window trips the limit.
assert client.post("/api/rfcs/suggest-tags", json=body).status_code == 429
# ---------------------------------------------------------------------------
# Units
# ---------------------------------------------------------------------------
def test_gather_tag_universe_dedupes_and_ranks(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import tag_suggest
app, _fake = app_with_fake_gitea
# The db is initialized in the app's lifespan; enter the client
# context so cached_rfcs exists before we seed it directly.
with TestClient(app):
_seed_tags("a", "A", ["identity", "schema"])
_seed_tags("b", "B", ["identity", " schema ", "consent", ""]) # whitespace + empty
_seed_tags("c", "C", ["identity"])
universe = tag_suggest.gather_tag_universe()
# identity (3) > schema (2) > consent (1); whitespace trimmed/merged,
# empties dropped.
assert universe == ["identity", "schema", "consent"]
def test_parse_reply_tolerates_junk():
from app import tag_suggest
universe = ["identity", "schema", "consent"]
# Prose around the JSON, an invented tag, a bare string, a dup, a
# missing confidence, and a garbage confidence.
text = (
"Sure! Here are the tags:\n"
'[{"tag": "identity", "confidence": 0.9}, '
'{"tag": "invented", "confidence": 1}, '
'"schema", '
'{"tag": "identity", "confidence": 0.5}, '
'{"tag": "consent"}, '
'{"tag": "consent", "confidence": "high"}]\n'
"Hope that helps!"
)
out = tag_suggest.parse_reply(text, universe, max_suggestions=6)
tags = [s["tag"] for s in out]
assert tags == ["identity", "schema", "consent"] # invented dropped, deduped
by_tag = {s["tag"]: s["confidence"] for s in out}
assert by_tag["identity"] == 0.9
assert by_tag["schema"] == 0.5 # bare string defaults to 0.5
assert by_tag["consent"] == 0.5 # missing/garbage confidence → 0.5
def test_parse_reply_empty_on_unparseable():
from app import tag_suggest
assert tag_suggest.parse_reply("no json here", ["a"], 6) == []
assert tag_suggest.parse_reply("", ["a"], 6) == []
assert tag_suggest.parse_reply("[]", ["a"], 6) == []
def test_parse_reply_respects_max():
from app import tag_suggest
universe = ["a", "b", "c", "d", "e"]
text = json.dumps([{"tag": t, "confidence": 0.5} for t in universe])
out = tag_suggest.parse_reply(text, universe, max_suggestions=3)
assert [s["tag"] for s in out] == ["a", "b", "c"]
def test_suggest_short_circuits_empty_draft():
from app import tag_suggest
stub = StubProvider(reply=json.dumps([{"tag": "a", "confidence": 1}]))
draft = tag_suggest.Draft(title=" ", pitch="", use_case="")
assert tag_suggest.suggest(stub, draft, ["a"]) == []
assert stub.calls == [] # never called for an empty draft
def test_suggest_returns_empty_on_provider_failure():
from app import tag_suggest
stub = StubProvider(raises=True)
draft = tag_suggest.Draft(title="Real title", pitch="a reason")
assert tag_suggest.suggest(stub, draft, ["identity"]) == []
+251
View File
@@ -0,0 +1,251 @@
"""End-to-end integration tests for the v0.12.0 CloudFlare Turnstile
gate on `/auth/otc/request` (§6.2 / roadmap item #10).
The release gates the OTC request endpoint behind a one-step
browser-side Turnstile challenge before the bcrypt hash + SMTP send.
The tests prove:
* Happy path: with the secret set, a valid token admits the request
and the OTC envelope lands.
* Failure path: with the secret set, a token siteverify rejects
refuses the request with 400 and produces no envelope.
* Missing-token: with the secret set, a request without a token
refuses with 400.
* Missing-secret-soft: with the secret unset AND
`TURNSTILE_REQUIRED=false` (the v0.12.0 default), the request
admits this is the dev / "operator hasn't wired it yet" path.
* Missing-secret-hard: with the secret unset AND
`TURNSTILE_REQUIRED=true`, the request refuses with 500
"auth misconfigured" the production fail-closed path once
the operator has flipped the policy.
The Turnstile siteverify call is mocked at the `httpx.post` boundary
inside `app.turnstile` so no real keys are needed and no real
CloudFlare call is made. The Gitea fakes from `test_propose_vertical`
remain in scope so the rest of the app boots cleanly.
"""
from __future__ import annotations
from types import SimpleNamespace
import pytest
from test_propose_vertical import ( # noqa: F401
FakeGitea,
app_with_fake_gitea,
tmp_env,
)
def _reset_outbound():
from app import email as email_mod
email_mod.reset_sent_envelopes()
def _outbound_otc_envelopes(to_address: str | None = None) -> list[dict]:
from app import email as email_mod
out = []
for env in email_mod.sent_envelopes():
if env.get("kind") != "otc":
continue
if to_address is not None and env["to"] != to_address:
continue
out.append(env)
return out
def _patch_siteverify(monkeypatch, *, success: bool, error_codes: list[str] | None = None):
"""Replace `turnstile._siteverify_post` with an async stub that
returns the requested success shape. The stub does not touch the
real CloudFlare endpoint and never sees a real secret.
I4 (security-audit-0026): the siteverify call is now awaited on an
`httpx.AsyncClient`, isolated behind the `_siteverify_post` seam.
Patching that narrow function (rather than the shared
`httpx.AsyncClient`, which gitea/docs also construct) keeps app boot
intact.
"""
captured = {}
async def fake_post(url, data):
captured["url"] = url
captured["data"] = data
body = {"success": bool(success)}
if error_codes is not None:
body["error-codes"] = error_codes
return SimpleNamespace(json=lambda: body)
from app import turnstile as turnstile_mod
monkeypatch.setattr(turnstile_mod, "_siteverify_post", fake_post)
return captured
# ---------------------------------------------------------------------------
# Happy path: secret set, token valid → admit + OTC envelope lands
# ---------------------------------------------------------------------------
def test_otc_request_admits_when_turnstile_token_is_valid(app_with_fake_gitea, monkeypatch):
from fastapi.testclient import TestClient
monkeypatch.setenv("CLOUDFLARE_TURNSTILE_SECRET", "test-secret-not-real")
monkeypatch.setenv("TURNSTILE_REQUIRED", "true")
captured = _patch_siteverify(monkeypatch, success=True)
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
r = client.post(
"/auth/otc/request",
json={"email": "alice@example.com", "turnstile_token": "fake-token-abc"},
)
assert r.status_code == 200, r.text
# The siteverify call was made with the secret + the token we sent.
assert captured["data"]["secret"] == "test-secret-not-real"
assert captured["data"]["response"] == "fake-token-abc"
# And the OTC dispatch ran — exactly one envelope to the address.
envs = _outbound_otc_envelopes("alice@example.com")
assert len(envs) == 1
# ---------------------------------------------------------------------------
# Failure path: secret set, siteverify says success=false → 400 + no envelope
# ---------------------------------------------------------------------------
def test_otc_request_refuses_when_turnstile_siteverify_fails(app_with_fake_gitea, monkeypatch):
from fastapi.testclient import TestClient
monkeypatch.setenv("CLOUDFLARE_TURNSTILE_SECRET", "test-secret-not-real")
monkeypatch.setenv("TURNSTILE_REQUIRED", "true")
_patch_siteverify(monkeypatch, success=False, error_codes=["invalid-input-response"])
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
r = client.post(
"/auth/otc/request",
json={"email": "alice@example.com", "turnstile_token": "fake-bad-token"},
)
assert r.status_code == 400, r.text
# The OTC bcrypt + SMTP path did not run — no envelope was buffered.
assert _outbound_otc_envelopes("alice@example.com") == []
# ---------------------------------------------------------------------------
# Missing-token: secret set, no token → 400 + no envelope
# ---------------------------------------------------------------------------
def test_otc_request_refuses_when_turnstile_token_is_missing(app_with_fake_gitea, monkeypatch):
from fastapi.testclient import TestClient
monkeypatch.setenv("CLOUDFLARE_TURNSTILE_SECRET", "test-secret-not-real")
monkeypatch.setenv("TURNSTILE_REQUIRED", "true")
# Even though we patch httpx.post, the missing-token check fires
# before the siteverify call — so the patch is here only as a
# safety net in case the implementation regresses to making the
# network call anyway.
_patch_siteverify(monkeypatch, success=False)
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
r = client.post(
"/auth/otc/request",
json={"email": "alice@example.com"}, # no turnstile_token field at all
)
assert r.status_code == 400, r.text
assert _outbound_otc_envelopes("alice@example.com") == []
# ---------------------------------------------------------------------------
# Missing-secret-soft: no secret, TURNSTILE_REQUIRED=false (default) → admit
# ---------------------------------------------------------------------------
def test_otc_request_admits_when_secret_unset_and_not_required(app_with_fake_gitea, monkeypatch):
"""v0.12.0 default: the operator has not yet wired the Turnstile
secret and has not enabled `TURNSTILE_REQUIRED`. The gate stays
open this is the dev / test / pre-rollout path. Once the
operator confirms the secret is in place and flips
`TURNSTILE_REQUIRED=true`, missing-secret becomes fail-closed
(covered in test_otc_request_refuses_when_required_but_secret_unset).
"""
from fastapi.testclient import TestClient
monkeypatch.delenv("CLOUDFLARE_TURNSTILE_SECRET", raising=False)
monkeypatch.delenv("TURNSTILE_REQUIRED", raising=False)
# The siteverify call inside turnstile must not be made in this path —
# patch the seam to a sentinel that explodes if it ever runs
# (I4: the seam is now `_siteverify_post`, not module-level httpx.post).
from app import turnstile as turnstile_mod
async def must_not_be_called(*a, **kw):
raise AssertionError("siteverify should not run when no secret is configured")
monkeypatch.setattr(turnstile_mod, "_siteverify_post", must_not_be_called)
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
r = client.post(
"/auth/otc/request",
json={"email": "alice@example.com"},
)
assert r.status_code == 200, r.text
# The OTC path ran end-to-end — one envelope to the address.
assert len(_outbound_otc_envelopes("alice@example.com")) == 1
# ---------------------------------------------------------------------------
# Missing-secret-hard: no secret, TURNSTILE_REQUIRED=true → 500 "misconfigured"
# ---------------------------------------------------------------------------
def test_otc_request_refuses_when_required_but_secret_unset(app_with_fake_gitea, monkeypatch):
"""Once the operator has flipped `TURNSTILE_REQUIRED=true` to lock
down production, a missing secret stops being a soft-fail and
becomes a fail-closed 500. This is the regression-detection shape
the §20.4 upgrade-steps MAY block calls out flip the flag once
the secret is wired so a future config drift fails loudly instead
of silently disabling abuse defense.
"""
from fastapi.testclient import TestClient
monkeypatch.delenv("CLOUDFLARE_TURNSTILE_SECRET", raising=False)
monkeypatch.setenv("TURNSTILE_REQUIRED", "true")
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
r = client.post(
"/auth/otc/request",
json={"email": "alice@example.com", "turnstile_token": "doesnt-matter"},
)
assert r.status_code == 500, r.text
assert _outbound_otc_envelopes("alice@example.com") == []
# ---------------------------------------------------------------------------
# I4 (security-audit-0026): verify_token is a coroutine — calling it returns
# an awaitable, not a VerifyOutcome. Locks the async contract so a revert to
# the synchronous event-loop-blocking shape fails here, not just in the
# integration paths.
# ---------------------------------------------------------------------------
def test_verify_token_is_async_and_soft_skips_without_secret(monkeypatch):
import asyncio
from app import turnstile as turnstile_mod
monkeypatch.delenv("CLOUDFLARE_TURNSTILE_SECRET", raising=False)
monkeypatch.delenv("TURNSTILE_REQUIRED", raising=False)
coro = turnstile_mod.verify_token("any-token")
assert asyncio.iscoroutine(coro), "verify_token must be a coroutine (I4)"
outcome = asyncio.run(coro)
# No secret + not required → the gate stays open without any network call.
assert outcome.ok is True
assert outcome.reason == "skipped"
+205
View File
@@ -0,0 +1,205 @@
"""End-to-end integration tests for the Gitea webhook receiver
(v0.18.0 Slice 3 webhook tightening per the email + webhook
hygiene proposal).
The release changes the receiver from "verifies the signature only
when a secret is configured; silently accepts unsigned POSTs
otherwise" to "requires the secret unless `RFC_APP_INSECURE_WEBHOOKS=1`
is set as an explicit dev-bypass." The startup-time check lives in
`config.load_config()`; the request-time check lives in
`webhooks.receive`.
These tests prove:
* The framework refuses to start when `GITEA_WEBHOOK_SECRET` is
empty and the dev-bypass is not set.
* The dev-bypass (`RFC_APP_INSECURE_WEBHOOKS=1`) lets the
framework boot with an empty secret AND lets webhook POSTs
land without signature verification (a loud-warning log line
surfaces, but the request is accepted).
* Default path (secret bound): a POST with a valid signature
lands; a POST with an invalid signature gets 401; a POST with
no signature gets 401.
* Unknown-repo POSTs surface in the log (the "stale Gitea hook"
case the proposal targets).
"""
from __future__ import annotations
import hashlib
import hmac
import json
import logging
import pytest
from test_propose_vertical import ( # noqa: F401
FakeGitea,
app_with_fake_gitea,
tmp_env,
)
# ---------------------------------------------------------------------------
# Startup-time secret check (config.load_config)
# ---------------------------------------------------------------------------
def test_config_refuses_to_load_with_empty_secret_and_no_bypass(monkeypatch, tmp_path):
"""The framework MUST refuse to start when `GITEA_WEBHOOK_SECRET`
is empty unless `RFC_APP_INSECURE_WEBHOOKS=1` is set. This is
the v0.18.0 startup-loud-failure shape silent acceptance was
the bug."""
monkeypatch.setenv("GITEA_URL", "http://gitea.test")
monkeypatch.setenv("GITEA_BOT_USER", "rfc-bot")
monkeypatch.setenv("GITEA_BOT_TOKEN", "bot-token")
monkeypatch.setenv("GITEA_ORG", "wiggleverse")
monkeypatch.setenv("OAUTH_CLIENT_ID", "cid")
monkeypatch.setenv("OAUTH_CLIENT_SECRET", "csec")
monkeypatch.setenv("SECRET_KEY", "test-secret-key")
monkeypatch.setenv("DATABASE_PATH", str(tmp_path / "test.db"))
monkeypatch.setenv("GITEA_WEBHOOK_SECRET", "")
monkeypatch.delenv("RFC_APP_INSECURE_WEBHOOKS", raising=False)
from app.config import load_config
with pytest.raises(RuntimeError, match="GITEA_WEBHOOK_SECRET"):
load_config()
def test_config_loads_with_empty_secret_when_bypass_is_set(monkeypatch, tmp_path):
"""The explicit `RFC_APP_INSECURE_WEBHOOKS=1` opt-in lets the
framework boot with an empty webhook secret. This is the
local-dev escape hatch."""
monkeypatch.setenv("GITEA_URL", "http://gitea.test")
monkeypatch.setenv("GITEA_BOT_USER", "rfc-bot")
monkeypatch.setenv("GITEA_BOT_TOKEN", "bot-token")
monkeypatch.setenv("GITEA_ORG", "wiggleverse")
monkeypatch.setenv("OAUTH_CLIENT_ID", "cid")
monkeypatch.setenv("OAUTH_CLIENT_SECRET", "csec")
monkeypatch.setenv("SECRET_KEY", "test-secret-key")
monkeypatch.setenv("DATABASE_PATH", str(tmp_path / "test.db"))
monkeypatch.setenv("GITEA_WEBHOOK_SECRET", "")
monkeypatch.setenv("RFC_APP_INSECURE_WEBHOOKS", "1")
from app.config import load_config
cfg = load_config() # MUST NOT raise
assert cfg.webhook_secret == ""
def test_config_loads_with_secret_set(monkeypatch, tmp_path):
"""Sanity: the happy path (secret bound, bypass not set) loads
cleanly."""
monkeypatch.setenv("GITEA_URL", "http://gitea.test")
monkeypatch.setenv("GITEA_BOT_USER", "rfc-bot")
monkeypatch.setenv("GITEA_BOT_TOKEN", "bot-token")
monkeypatch.setenv("GITEA_ORG", "wiggleverse")
monkeypatch.setenv("OAUTH_CLIENT_ID", "cid")
monkeypatch.setenv("OAUTH_CLIENT_SECRET", "csec")
monkeypatch.setenv("SECRET_KEY", "test-secret-key")
monkeypatch.setenv("DATABASE_PATH", str(tmp_path / "test.db"))
monkeypatch.setenv("GITEA_WEBHOOK_SECRET", "my-real-secret")
monkeypatch.delenv("RFC_APP_INSECURE_WEBHOOKS", raising=False)
from app.config import load_config
cfg = load_config()
assert cfg.webhook_secret == "my-real-secret"
# ---------------------------------------------------------------------------
# Request-time signature verification (webhooks.receive)
#
# The default `app_with_fake_gitea` fixture binds
# `GITEA_WEBHOOK_SECRET=test-webhook-secret-for-signature-verification`,
# so these tests exercise the production path.
# ---------------------------------------------------------------------------
_SECRET = "test-webhook-secret-for-signature-verification"
def _sign(body: bytes) -> str:
return hmac.new(_SECRET.encode("utf-8"), body, hashlib.sha256).hexdigest()
def test_webhook_post_with_valid_signature_accepted(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
body = json.dumps({"repository": {"full_name": "wiggleverse/meta"}}).encode()
sig = _sign(body)
r = client.post(
"/api/webhooks/gitea",
content=body,
headers={
"X-Gitea-Event": "push",
"X-Gitea-Signature": sig,
"Content-Type": "application/json",
},
)
assert r.status_code == 200, r.text
def test_webhook_post_with_invalid_signature_refused_401(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
body = json.dumps({"repository": {"full_name": "wiggleverse/meta"}}).encode()
r = client.post(
"/api/webhooks/gitea",
content=body,
headers={
"X-Gitea-Event": "push",
"X-Gitea-Signature": "0" * 64, # wrong signature
"Content-Type": "application/json",
},
)
assert r.status_code == 401
def test_webhook_post_with_missing_signature_refused_401(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
body = json.dumps({"repository": {"full_name": "wiggleverse/meta"}}).encode()
r = client.post(
"/api/webhooks/gitea",
content=body,
headers={
"X-Gitea-Event": "push",
"Content-Type": "application/json",
},
)
assert r.status_code == 401
# ---------------------------------------------------------------------------
# Unknown-repo logging (the "stale hook on a fork" surface)
# ---------------------------------------------------------------------------
def test_webhook_unknown_repo_logs_at_info(app_with_fake_gitea, caplog):
"""Per the proposal: a hook on a fork or a stale Gitea binding
used to silently 200-OK. v0.18.0 surfaces it as an INFO log."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
body = json.dumps({"repository": {"full_name": "someone-else/unrelated"}}).encode()
sig = _sign(body)
with caplog.at_level(logging.INFO, logger="app.webhooks"):
r = client.post(
"/api/webhooks/gitea",
content=body,
headers={
"X-Gitea-Event": "push",
"X-Gitea-Signature": sig,
"Content-Type": "application/json",
},
)
assert r.status_code == 200 # the handler still 200s; surface is the log line
assert any(
"unknown repo" in rec.message and "someone-else/unrelated" in rec.message
for rec in caplog.records
), f"expected unknown-repo log line; got: {[r.message for r in caplog.records]}"
+50
View File
@@ -18,6 +18,56 @@ server {
listen [::]:80;
server_name ohm.wiggleverse.org;
# v0.25.0 security hardening (audit 0026 M2/L8)
#
# NOTE: certbot promotes THIS server block to the HTTPS listener
# (`listen 443 ssl`) and adds a separate port-80 → 443 redirect
# block (see the install comment above). These response headers
# therefore ride into the HTTPS server block on the VM. They use
# `add_header ... always` so they also apply to nginx-generated
# error responses (4xx/5xx), not just 200s.
#
# `server_tokens off` (L8) — suppress the nginx version in the
# Server header and on error pages so we don't advertise the
# build to scanners.
server_tokens off;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Content-Security-Policy (M2). Tuned to what the SPA actually loads:
# - default-src 'self': everything not called out below is same-origin.
# - script-src 'self' + challenges.cloudflare.com: the only external
# <script> tag the app injects is the CloudFlare Turnstile widget
# (frontend/src/components/TurnstileWidget.jsx). Amplitude and
# mermaid are BUNDLED (dynamic `import()` from node_modules, served
# from 'self'), so they need no extra script origin — *.amplitude.com
# is listed defensively in case a future SDK build script-injects.
# script-src DELIBERATELY OMITS 'unsafe-inline' — no inline <script>
# is used, so we keep XSS-via-inline-script blocked.
# - style-src 'unsafe-inline' IS REQUIRED by the current build: the
# JSX uses inline `style={...}` attributes throughout and mermaid
# injects <style> blocks at render time. Removing it would break
# layout; tightening this is a future build-side change (nonce/hash).
# - img-src 'self' data: https: — markdown/RFC bodies may embed remote
# images and data: URIs; svg/mermaid output uses data: too.
# - font-src 'self' data: — bundled fonts plus data: webfonts.
# - connect-src 'self' + *.amplitude.com + challenges.cloudflare.com:
# the app's API/auth/SSE are same-origin (nginx proxy); Amplitude
# Analytics + Session Replay (shipped at sampleRate 1) POST to
# *.amplitude.com; Turnstile verifies via challenges.cloudflare.com.
# - worker-src 'self' blob: — Amplitude Session Replay spins up a
# Web Worker from a blob: URL for capture/compression; without
# blob: here session replay breaks for every consenting user.
# - frame-src challenges.cloudflare.com — the Turnstile challenge
# renders in an iframe from that origin.
# - frame-ancestors 'none' — clickjacking defense, pairs with
# X-Frame-Options DENY for older agents.
# - base-uri 'self'; object-src 'none' — lock down <base>/<object>.
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://challenges.cloudflare.com https://*.amplitude.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https://*.amplitude.com https://challenges.cloudflare.com; worker-src 'self' blob:; frame-src https://challenges.cloudflare.com; frame-ancestors 'none'; base-uri 'self'; object-src 'none'" always;
# Static SPA assets live in the Vite build output. The systemd unit
# runs as user `rfc-app`; make sure nginx (usually `www-data`) can
# read this path. Either group-add www-data into rfc-app's group, or
+27
View File
@@ -42,5 +42,32 @@ ProtectHome=true
PrivateTmp=true
ReadWritePaths=/opt/rfc-app/backend/data
# v0.25.0 security hardening (audit 0026 L4) — defense-in-depth.
# The service binds 127.0.0.1:8000 and runs plain CPython
# (FastAPI/uvicorn + sqlite + bcrypt + httpx), so it needs no
# capabilities and no exotic syscalls.
CapabilityBoundingSet=
AmbientCapabilities=
PrivateDevices=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectKernelLogs=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=true
LockPersonality=true
# MemoryDenyWriteExecute=true blocks W^X memory — safe for stock
# CPython (no JIT) and the pure-Python/C-extension stack here, but
# would break a JIT or a C-ext that mmaps W+X. Watch the first
# restart's journal for a crash; if uvicorn fails to come up,
# comment this one line out and reload.
MemoryDenyWriteExecute=true
RestrictRealtime=true
RestrictSUIDSGID=true
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
SystemCallArchitectures=native
UMask=0077
[Install]
WantedBy=multi-user.target
+36
View File
@@ -49,3 +49,39 @@ VITE_PRIVACY_POLICY_URL=
# Examples:
# VITE_COOKIES_POLICY_URL=https://wiggleverse.org/cookies
VITE_COOKIES_POLICY_URL=
# v0.12.0 / roadmap item #10: CloudFlare Turnstile site key (public).
# Provision a Turnstile site at dash.cloudflare.com → Turnstile → Add
# site. The site key (this var) is embedded into the frontend bundle at
# build time and rendered by the Turnstile widget on the /login email-
# entry step. The secret key (private) lives in the backend env as
# CLOUDFLARE_TURNSTILE_SECRET — see backend/.env.example. Leave unset
# in dev to skip the widget; the backend's TURNSTILE_REQUIRED policy
# decides what happens to a tokenless request.
#
# Examples:
# VITE_TURNSTILE_SITE_KEY=0x4AAAAAAA...
VITE_TURNSTILE_SITE_KEY=
# v0.15.0 / roadmap item #13: Amplitude project API key (public).
# Embedded in the frontend bundle at build time and used by the
# analytics wrapper (`frontend/src/lib/analytics.js`) — which loads
# `@amplitude/unified` (Analytics + Session Replay) when the user
# has granted analytics consent (v0.13.0 cookie banner). Provision
# an Amplitude project at app.amplitude.com → Projects → New, copy
# the API key.
#
# Public by design: Amplitude browser keys are bundle-embedded
# (visible in dev tools), same nature as VITE_TURNSTILE_SITE_KEY
# (also public; the truly-secret half of that Turnstile pair is
# CLOUDFLARE_TURNSTILE_SECRET on the backend). For deployments
# behind flotilla, bind via `flotilla overlay set <deployment>
# VITE_AMPLITUDE_API_KEY=<key>` — NOT `flotilla secret set`. The
# vendor's installation wizard shows the key inline as a literal
# string in the init call, confirming the public framing. Leave
# unset in dev; the wrapper logs one console warning and no-ops
# (the app continues to work).
#
# Examples:
# VITE_AMPLITUDE_API_KEY=01234567890abcdef01234567890abcd
VITE_AMPLITUDE_API_KEY=
+530 -10
View File
@@ -1,13 +1,14 @@
{
"name": "rfc-app-frontend",
"version": "0.11.0",
"version": "0.24.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "rfc-app-frontend",
"version": "0.11.0",
"version": "0.24.0",
"dependencies": {
"@amplitude/unified": "^1.1.9",
"@codemirror/commands": "^6.10.3",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/language": "^6.12.3",
@@ -17,6 +18,7 @@
"@tiptap/pm": "^3.5.0",
"@tiptap/react": "^3.5.0",
"@tiptap/starter-kit": "^3.5.0",
"dompurify": "^3.2.4",
"marked": "^18.0.4",
"mermaid": "^11.15.0",
"react": "^19.2.6",
@@ -30,6 +32,360 @@
"vite": "^8.0.12"
}
},
"node_modules/@amplitude/analytics-browser": {
"version": "2.42.4",
"resolved": "https://registry.npmjs.org/@amplitude/analytics-browser/-/analytics-browser-2.42.4.tgz",
"integrity": "sha512-q1XUlaKQkLq2CFx8xsVEc+uekOwHlnDYyaMBzlQDf2vcEaPaQDb7LzJ7z4CFs4Jn9FyBGDNo4w3IYjv9L6xjGA==",
"license": "MIT",
"dependencies": {
"@amplitude/analytics-core": "2.48.2",
"@amplitude/plugin-autocapture-browser": "1.27.2",
"@amplitude/plugin-custom-enrichment-browser": "0.1.9",
"@amplitude/plugin-event-property-attribution-browser": "0.2.1",
"@amplitude/plugin-network-capture-browser": "1.10.1",
"@amplitude/plugin-page-url-enrichment-browser": "0.7.11",
"@amplitude/plugin-page-view-tracking-browser": "2.11.1",
"@amplitude/plugin-web-vitals-browser": "1.1.33",
"tslib": "^2.4.1"
}
},
"node_modules/@amplitude/analytics-client-common": {
"version": "2.4.48",
"resolved": "https://registry.npmjs.org/@amplitude/analytics-client-common/-/analytics-client-common-2.4.48.tgz",
"integrity": "sha512-jdRvu8ux3aIf74FvTDZuSFR1mutzdrIg1ebXYqpKizs9upXz1AJnHClkldSw9i4yu924AJ2wudxq6dccHWlNiA==",
"license": "MIT",
"dependencies": {
"@amplitude/analytics-connector": "^1.4.8",
"@amplitude/analytics-core": "2.48.2",
"@amplitude/analytics-types": "2.11.1",
"tslib": "^2.4.1"
}
},
"node_modules/@amplitude/analytics-connector": {
"version": "1.6.4",
"resolved": "https://registry.npmjs.org/@amplitude/analytics-connector/-/analytics-connector-1.6.4.tgz",
"integrity": "sha512-SpIv0IQMNIq6SH3UqFGiaZyGSc7PBZwRdq7lvP0pBxW8i4Ny+8zwI0pV+VMfMHQwWY3wdIbWw5WQphNjpdq1/Q==",
"license": "MIT"
},
"node_modules/@amplitude/analytics-core": {
"version": "2.48.2",
"resolved": "https://registry.npmjs.org/@amplitude/analytics-core/-/analytics-core-2.48.2.tgz",
"integrity": "sha512-r9O+hsTnTsDa1p6QdyC0KbBPXupzoWz9053RQB9XQz8078LM+5KCMbCKYOrSYniH4DH/OM2kOUEdJlwdxIl/IA==",
"license": "MIT",
"dependencies": {
"@amplitude/analytics-connector": "^1.6.4",
"@types/zen-observable": "0.8.3",
"safe-json-stringify": "1.2.0",
"tslib": "^2.4.1",
"zen-observable": "0.10.0"
}
},
"node_modules/@amplitude/analytics-types": {
"version": "2.11.1",
"resolved": "https://registry.npmjs.org/@amplitude/analytics-types/-/analytics-types-2.11.1.tgz",
"integrity": "sha512-wFEgb0t99ly2uJKm5oZ28Lti0Kh5RecR5XBkwfUpDzn84IoCIZ8GJTsMw/nThu8FZFc7xFDA4UAt76zhZKrs9A==",
"license": "MIT"
},
"node_modules/@amplitude/engagement-browser": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@amplitude/engagement-browser/-/engagement-browser-1.0.9.tgz",
"integrity": "sha512-zvPr0L5aLlOS3nG8scIkEEDMVK2y3MaMbgjYhMfYruhMpfsC/U0apov22nEc1RRrTwve2awEXruPRKf1TysqrQ==",
"license": "MIT",
"dependencies": {
"@amplitude/analytics-types": "^2.0.0"
}
},
"node_modules/@amplitude/experiment-core": {
"version": "0.13.1",
"resolved": "https://registry.npmjs.org/@amplitude/experiment-core/-/experiment-core-0.13.1.tgz",
"integrity": "sha512-ZHvR0dxTltasp8MiMcQ6qKsY20mWnODoy3oebGad6qaRR1ywpUi8IuLf5AwLTM35ZwgzEUTn9TEIWKLHpDwHMw==",
"license": "MIT",
"dependencies": {
"js-base64": "^3.7.5"
}
},
"node_modules/@amplitude/experiment-js-client": {
"version": "1.21.1",
"resolved": "https://registry.npmjs.org/@amplitude/experiment-js-client/-/experiment-js-client-1.21.1.tgz",
"integrity": "sha512-chE/4qQG/5Cgl93Wqj1NEdgOL5LkqySLlfk1EN0f+7bJa52HpkGFALA2FeCNYf31Z5CglEeKX6dUMgL7y33SIw==",
"license": "MIT",
"dependencies": {
"@amplitude/analytics-connector": "^1.6.4",
"@amplitude/experiment-core": "^0.13.1",
"@amplitude/ua-parser-js": "^0.7.31",
"base64-js": "1.5.1",
"unfetch": "4.1.0"
}
},
"node_modules/@amplitude/plugin-autocapture-browser": {
"version": "1.27.2",
"resolved": "https://registry.npmjs.org/@amplitude/plugin-autocapture-browser/-/plugin-autocapture-browser-1.27.2.tgz",
"integrity": "sha512-UTA/0IDw/f2nnK+S1XILqoI5pgUgMTEZokDS6+pC4wuYtmOS9uNAgKuyajzjW12uobybMHRpv7xLjCJ5khKGAg==",
"license": "MIT",
"dependencies": {
"@amplitude/analytics-core": "2.48.2",
"tslib": "^2.4.1"
}
},
"node_modules/@amplitude/plugin-custom-enrichment-browser": {
"version": "0.1.9",
"resolved": "https://registry.npmjs.org/@amplitude/plugin-custom-enrichment-browser/-/plugin-custom-enrichment-browser-0.1.9.tgz",
"integrity": "sha512-wemh2Tw3zgQ7sa7MUNyMGz9OR6VjTG4tlAMrLlDKbQ4tVkgNI3oAwOF7+0BA8qzgeMXX6iw+CEKaE+EC/okkuQ==",
"license": "MIT",
"dependencies": {
"@amplitude/analytics-core": "2.48.2",
"tslib": "^2.4.1"
}
},
"node_modules/@amplitude/plugin-event-property-attribution-browser": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/@amplitude/plugin-event-property-attribution-browser/-/plugin-event-property-attribution-browser-0.2.1.tgz",
"integrity": "sha512-xqBCZe0DYsKyQ1eELN2LM8adXwRE2eOi3SnvSu9SkS0GDXBYWinuPCuLqyc/3uD5hY2FLACWvakpU0tr7GDJgg==",
"license": "MIT",
"dependencies": {
"@amplitude/analytics-core": "2.48.2",
"tslib": "^2.4.1"
}
},
"node_modules/@amplitude/plugin-experiment-browser": {
"version": "1.0.0-beta.28",
"resolved": "https://registry.npmjs.org/@amplitude/plugin-experiment-browser/-/plugin-experiment-browser-1.0.0-beta.28.tgz",
"integrity": "sha512-NQz267zLi7vl2G2lx10yUrEoGOCe5K9iqcPSIjbTavGu/XGvsmqLDqBHhg+EkdEMAPwypoXnmtPEs3RMhX+1MA==",
"license": "MIT",
"dependencies": {
"@amplitude/analytics-core": "2.48.2",
"@amplitude/experiment-js-client": "^1.15.5"
}
},
"node_modules/@amplitude/plugin-network-capture-browser": {
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/@amplitude/plugin-network-capture-browser/-/plugin-network-capture-browser-1.10.1.tgz",
"integrity": "sha512-jROIAkUDPd25A/t8W5MpmsTiBat2qoJbCMoNBKKxLMNEaE8VYbheflByWLkm4enbHgWS7OveWy0i3Oc7uPCfAg==",
"license": "MIT",
"dependencies": {
"@amplitude/analytics-core": "2.48.2",
"tslib": "^2.4.1"
}
},
"node_modules/@amplitude/plugin-page-url-enrichment-browser": {
"version": "0.7.11",
"resolved": "https://registry.npmjs.org/@amplitude/plugin-page-url-enrichment-browser/-/plugin-page-url-enrichment-browser-0.7.11.tgz",
"integrity": "sha512-u9JhUP/VenJifCSbdTz2YZZiXAphs3efzd+qx1SRAIU6d1swPh0g/GVw3sTwvH+4MZtw3SwVC1OFxmz+f2QVyA==",
"license": "MIT",
"dependencies": {
"@amplitude/analytics-core": "2.48.2",
"tslib": "^2.4.1"
}
},
"node_modules/@amplitude/plugin-page-view-tracking-browser": {
"version": "2.11.1",
"resolved": "https://registry.npmjs.org/@amplitude/plugin-page-view-tracking-browser/-/plugin-page-view-tracking-browser-2.11.1.tgz",
"integrity": "sha512-tfXg6Uir6X1XuWsOOXE/EgZ9NvM7i2ktDdagydSrFN6OyVkMvqdjPKUZSSUPuHtOoomboi3WaZsTUfq1jkWP3w==",
"license": "MIT",
"dependencies": {
"@amplitude/analytics-core": "2.48.2",
"tslib": "^2.4.1"
}
},
"node_modules/@amplitude/plugin-session-replay-browser": {
"version": "1.31.0",
"resolved": "https://registry.npmjs.org/@amplitude/plugin-session-replay-browser/-/plugin-session-replay-browser-1.31.0.tgz",
"integrity": "sha512-b7kyYVEdW3EMR6cPXCfld+h8nQsuAR5o6vum8Glu+ofhFDfG4wj/mTJ0ITEaNbsJCfXniKQ3kFgTe6hTtxSFGQ==",
"license": "MIT",
"dependencies": {
"@amplitude/analytics-client-common": "2.4.48",
"@amplitude/analytics-core": "2.48.2",
"@amplitude/analytics-types": "2.11.1",
"@amplitude/rrweb-plugin-console-record": "2.0.0-alpha.40",
"@amplitude/rrweb-record": "2.0.0-alpha.40",
"@amplitude/session-replay-browser": "1.44.0",
"idb-keyval": "^6.2.1",
"tslib": "^2.4.1"
}
},
"node_modules/@amplitude/plugin-web-vitals-browser": {
"version": "1.1.33",
"resolved": "https://registry.npmjs.org/@amplitude/plugin-web-vitals-browser/-/plugin-web-vitals-browser-1.1.33.tgz",
"integrity": "sha512-33FzxMH1Lr2lhvr5DDy3xD1HHWEI4KPLQsMUXqDTldkLl/ENNeBWcsljQTTDJipmRdS32I79KJhuHRNaoXd6fg==",
"license": "MIT",
"dependencies": {
"@amplitude/analytics-core": "2.48.2",
"tslib": "^2.4.1",
"web-vitals": "5.1.0"
}
},
"node_modules/@amplitude/rrdom": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@amplitude/rrdom/-/rrdom-2.1.0.tgz",
"integrity": "sha512-2dAtxXL02usBV2CSOnScLd3WoVqWaeiGpxN8LuXJ0r/NpLJkW1k876v2tRKAz5NrxPwSdjihsMmwCIXHpJhHfA==",
"license": "MIT",
"dependencies": {
"@amplitude/rrweb-snapshot": "^2.1.0"
}
},
"node_modules/@amplitude/rrweb": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@amplitude/rrweb/-/rrweb-2.1.1.tgz",
"integrity": "sha512-6uA+5VE/VHumaXPXTTLGRogd/K9MDwd01jGteppeLzsX0PvqlDyY5aIi35yh9+q1iS6ciPBn/2NRg0lg4cFIlw==",
"license": "MIT",
"dependencies": {
"@amplitude/rrdom": "^2.1.0",
"@amplitude/rrweb-snapshot": "^2.1.0",
"@amplitude/rrweb-types": "^2.1.0",
"@amplitude/rrweb-utils": "^2.1.0",
"@types/css-font-loading-module": "0.0.7",
"@xstate/fsm": "^1.4.0",
"base64-arraybuffer": "^1.0.1",
"mitt": "^3.0.0"
}
},
"node_modules/@amplitude/rrweb-packer": {
"version": "2.0.0-alpha.40",
"resolved": "https://registry.npmjs.org/@amplitude/rrweb-packer/-/rrweb-packer-2.0.0-alpha.40.tgz",
"integrity": "sha512-Btb6b9pS1IvDMbvyYxpUdTk9NRJugSoJjRCl7R6jP/iSlPWXoveJIwHaNFAS9ZmWUEK7HhyBJ8bKGFN3giUsDg==",
"license": "MIT",
"dependencies": {
"@amplitude/rrweb-types": "^2.0.0-alpha.40",
"fflate": "^0.4.4"
}
},
"node_modules/@amplitude/rrweb-plugin-console-record": {
"version": "2.0.0-alpha.40",
"resolved": "https://registry.npmjs.org/@amplitude/rrweb-plugin-console-record/-/rrweb-plugin-console-record-2.0.0-alpha.40.tgz",
"integrity": "sha512-vtY7T/kGFl62nC1u7ZUXQvU7ulB70cZGVHPRN/SO9fzVfsY7y6rCmBfoc2jS5KmISdlgkVzMjY2r/EE2Gk9AQA==",
"license": "MIT",
"peerDependencies": {
"@amplitude/rrweb": "^2.0.0-alpha.40"
}
},
"node_modules/@amplitude/rrweb-record": {
"version": "2.0.0-alpha.40",
"resolved": "https://registry.npmjs.org/@amplitude/rrweb-record/-/rrweb-record-2.0.0-alpha.40.tgz",
"integrity": "sha512-5cJhQwzhymJWX5/XOtpWK0h2NLq9+t2YiO6ub0cdZ9F5AZizaRbsVH88int07DfX0YiXTKWbISezVuduCLqgSQ==",
"license": "MIT",
"dependencies": {
"@amplitude/rrweb": "^2.0.0-alpha.40",
"@amplitude/rrweb-types": "^2.0.0-alpha.40"
}
},
"node_modules/@amplitude/rrweb-snapshot": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@amplitude/rrweb-snapshot/-/rrweb-snapshot-2.1.0.tgz",
"integrity": "sha512-xYQvOW73ig+5M7caqilA8j0S6MHWUULLeJNK+2VVvUqv8mr4FMT2DUAQiVBGCImNlb9Gu2rLUfCScMnVxn+EDg==",
"license": "MIT",
"dependencies": {
"postcss": "^8.4.38"
}
},
"node_modules/@amplitude/rrweb-types": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@amplitude/rrweb-types/-/rrweb-types-2.1.0.tgz",
"integrity": "sha512-S73tBI/04A6HCHgnrUNeeVOvnDTEoQnNrmZGyrZncJwRlTIX+6BQSYtBFofMag8GnAy9gA+NtC0TL0CnluOWBw==",
"license": "MIT"
},
"node_modules/@amplitude/rrweb-utils": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@amplitude/rrweb-utils/-/rrweb-utils-2.1.0.tgz",
"integrity": "sha512-dTCDnSiMMHZ10utYHJ8dSd/xkjFgdF67y74PkOzAPcCKW1rLxyJYcFOA3uPL2b7cIVVmoel/5NTp5eflaUaJfQ==",
"license": "MIT"
},
"node_modules/@amplitude/session-replay-browser": {
"version": "1.44.0",
"resolved": "https://registry.npmjs.org/@amplitude/session-replay-browser/-/session-replay-browser-1.44.0.tgz",
"integrity": "sha512-8Ruep2TTDMcfVMKurSpBbVclBK/v8Lb3aSHFsYd/xOQ1E3CaKoAu39pplli28NWoUcW7unyVE7khkOa2zzn0Lw==",
"license": "MIT",
"dependencies": {
"@amplitude/analytics-client-common": "2.4.48",
"@amplitude/analytics-core": "2.48.2",
"@amplitude/analytics-types": "2.11.1",
"@amplitude/experiment-core": "0.7.2",
"@amplitude/rrweb-packer": "2.0.0-alpha.40",
"@amplitude/rrweb-plugin-console-record": "2.0.0-alpha.40",
"@amplitude/rrweb-record": "2.0.0-alpha.40",
"@amplitude/rrweb-types": "2.0.0-alpha.40",
"@amplitude/rrweb-utils": "2.0.0-alpha.40",
"@amplitude/targeting": "0.2.0",
"@rollup/plugin-replace": "^6.0.1",
"idb": "8.0.0",
"tslib": "^2.4.1"
}
},
"node_modules/@amplitude/session-replay-browser/node_modules/@amplitude/experiment-core": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/@amplitude/experiment-core/-/experiment-core-0.7.2.tgz",
"integrity": "sha512-Wc2NWvgQ+bLJLeF0A9wBSPIaw0XuqqgkPKsoNFQrmS7r5Djd56um75In05tqmVntPJZRvGKU46pAp8o5tdf4mA==",
"license": "MIT",
"dependencies": {
"js-base64": "^3.7.5"
}
},
"node_modules/@amplitude/session-replay-browser/node_modules/@amplitude/rrweb-types": {
"version": "2.0.0-alpha.40",
"resolved": "https://registry.npmjs.org/@amplitude/rrweb-types/-/rrweb-types-2.0.0-alpha.40.tgz",
"integrity": "sha512-rP7CBDkzXupxOA7ukvC+zDYLuCtsz54TuJKC4+5O72Jsz4YdokLznKZRG34P6zXozfhGU0261qckk87lLY6mKQ==",
"license": "MIT"
},
"node_modules/@amplitude/session-replay-browser/node_modules/@amplitude/rrweb-utils": {
"version": "2.0.0-alpha.40",
"resolved": "https://registry.npmjs.org/@amplitude/rrweb-utils/-/rrweb-utils-2.0.0-alpha.40.tgz",
"integrity": "sha512-i1CCt6MCjlqoeNc+1Hse5bz+ZbASaWaIJ0WdJZvnQjUCHH29Xy/QFouyOuor73RZ+UWX4s2tYSrUIdmBepXk3w==",
"license": "MIT"
},
"node_modules/@amplitude/targeting": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@amplitude/targeting/-/targeting-0.2.0.tgz",
"integrity": "sha512-/50ywTrC4hfcfJVBbh5DFbqMPPfaIOivZeb5Gb+OGM03QrA+lsUqdvtnKLNuWtceD4H6QQ2KFzPJ5aAJLyzVDA==",
"license": "MIT",
"dependencies": {
"@amplitude/analytics-client-common": ">=1 <3",
"@amplitude/analytics-core": ">=1 <3",
"@amplitude/analytics-types": ">=1 <3",
"@amplitude/experiment-core": "0.7.2",
"idb": "^8.0.0",
"tslib": "^2.4.1"
}
},
"node_modules/@amplitude/targeting/node_modules/@amplitude/experiment-core": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/@amplitude/experiment-core/-/experiment-core-0.7.2.tgz",
"integrity": "sha512-Wc2NWvgQ+bLJLeF0A9wBSPIaw0XuqqgkPKsoNFQrmS7r5Djd56um75In05tqmVntPJZRvGKU46pAp8o5tdf4mA==",
"license": "MIT",
"dependencies": {
"js-base64": "^3.7.5"
}
},
"node_modules/@amplitude/ua-parser-js": {
"version": "0.7.33",
"resolved": "https://registry.npmjs.org/@amplitude/ua-parser-js/-/ua-parser-js-0.7.33.tgz",
"integrity": "sha512-wKEtVR4vXuPT9cVEIJkYWnlF++Gx3BdLatPBM+SZ1ztVIvnhdGBZR/mn9x/PzyrMcRlZmyi6L56I2J3doVBnjA==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/ua-parser-js"
},
{
"type": "paypal",
"url": "https://paypal.me/faisalman"
}
],
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/@amplitude/unified": {
"version": "1.1.9",
"resolved": "https://registry.npmjs.org/@amplitude/unified/-/unified-1.1.9.tgz",
"integrity": "sha512-YPgQbp/vDQ92GshHs2hfUxoeRnR3rRBWCoQ6wXgFjXQ1uiJf2tP0CBZWdrCStSDuhcpo2rsCz/Ek2LGq5J6SIQ==",
"license": "MIT",
"dependencies": {
"@amplitude/analytics-browser": "2.42.4",
"@amplitude/analytics-core": "2.48.2",
"@amplitude/engagement-browser": "^1.0.3",
"@amplitude/plugin-experiment-browser": "1.0.0-beta.28",
"@amplitude/plugin-session-replay-browser": "1.31.0"
}
},
"node_modules/@antfu/install-pkg": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz",
@@ -264,6 +620,12 @@
"import-meta-resolve": "^4.2.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"license": "MIT"
},
"node_modules/@lezer/common": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
@@ -657,6 +1019,49 @@
"dev": true,
"license": "MIT"
},
"node_modules/@rollup/plugin-replace": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-6.0.3.tgz",
"integrity": "sha512-J4RZarRvQAm5IF0/LwUUg+obsm+xZhYnbMXmXROyoSE1ATJe3oXSb9L5MMppdxP2ylNSjv6zFBwKYjcKMucVfA==",
"license": "MIT",
"dependencies": {
"@rollup/pluginutils": "^5.0.1",
"magic-string": "^0.30.3"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
},
"peerDependenciesMeta": {
"rollup": {
"optional": true
}
}
},
"node_modules/@rollup/pluginutils": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz",
"integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==",
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0",
"estree-walker": "^2.0.2",
"picomatch": "^4.0.2"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
},
"peerDependenciesMeta": {
"rollup": {
"optional": true
}
}
},
"node_modules/@tiptap/core": {
"version": "3.23.6",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.23.6.tgz",
@@ -1109,6 +1514,12 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/css-font-loading-module": {
"version": "0.0.7",
"resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.7.tgz",
"integrity": "sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==",
"license": "MIT"
},
"node_modules/@types/d3": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
@@ -1362,6 +1773,12 @@
"@types/d3-selection": "*"
}
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
"license": "MIT"
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
@@ -1399,6 +1816,12 @@
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@types/zen-observable": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/@types/zen-observable/-/zen-observable-0.8.3.tgz",
"integrity": "sha512-fbF6oTd4sGGy0xjHPKAt+eS2CrxJ3+6gQ3FGcBoIJR2TLAyCkCyI8JqZNy+FeON0AhVgNJoUumVoZQjBFUqHkw==",
"license": "MIT"
},
"node_modules/@upsetjs/venn.js": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@upsetjs/venn.js/-/venn.js-2.0.0.tgz",
@@ -1435,6 +1858,41 @@
}
}
},
"node_modules/@xstate/fsm": {
"version": "1.6.5",
"resolved": "https://registry.npmjs.org/@xstate/fsm/-/fsm-1.6.5.tgz",
"integrity": "sha512-b5o1I6aLNeYlU/3CPlj/Z91ybk1gUsKT+5NAJI+2W4UjvS5KLG28K9v5UvNoFVjHV8PajVZ00RH3vnjyQO7ZAw==",
"license": "MIT"
},
"node_modules/base64-arraybuffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/commander": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
@@ -2021,6 +2479,12 @@
"benchmarks"
]
},
"node_modules/estree-walker": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"license": "MIT"
},
"node_modules/fast-equals": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
@@ -2048,6 +2512,12 @@
}
}
},
"node_modules/fflate": {
"version": "0.4.8",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.8.tgz",
"integrity": "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==",
"license": "MIT"
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -2081,6 +2551,18 @@
"node": ">=0.10.0"
}
},
"node_modules/idb": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/idb/-/idb-8.0.0.tgz",
"integrity": "sha512-l//qvlAKGmQO31Qn7xdzagVPPaHTxXx199MhrAFuVBTPqydcPYBWjkrbv4Y0ktB+GmWOiwHl237UUOrLmQxLvw==",
"license": "ISC"
},
"node_modules/idb-keyval": {
"version": "6.2.4",
"resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.4.tgz",
"integrity": "sha512-D/NzHWUmYJGXi++z67aMSrnisb9A3621CyRK5G89JyTlN13C8xf0g04DLxUKMufPem3e3L2JAXR6Z00OWy183Q==",
"license": "Apache-2.0"
},
"node_modules/import-meta-resolve": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz",
@@ -2100,6 +2582,12 @@
"node": ">=12"
}
},
"node_modules/js-base64": {
"version": "3.7.8",
"resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz",
"integrity": "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==",
"license": "BSD-3-Clause"
},
"node_modules/katex": {
"version": "0.16.47",
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz",
@@ -2421,6 +2909,15 @@
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
"license": "MIT"
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/marked": {
"version": "18.0.4",
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.4.tgz",
@@ -2474,11 +2971,16 @@
"node": ">= 20"
}
},
"node_modules/mitt": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
"dev": true,
"funding": [
{
"type": "github",
@@ -2515,14 +3017,12 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
@@ -2551,7 +3051,6 @@
"version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -2828,6 +3327,12 @@
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
"license": "BSD-3-Clause"
},
"node_modules/safe-json-stringify": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/safe-json-stringify/-/safe-json-stringify-1.2.0.tgz",
"integrity": "sha512-gH8eh2nZudPQO6TytOvbxnuhYBOvDBBLW52tz5q6X58lJcd/tkmqFR+5Z9adS8aJtURSXWThWy/xJtJwixErvg==",
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
@@ -2850,7 +3355,6 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
@@ -2907,9 +3411,13 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD",
"optional": true
"license": "0BSD"
},
"node_modules/unfetch": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/unfetch/-/unfetch-4.1.0.tgz",
"integrity": "sha512-crP/n3eAPUJxZXM9T80/yv0YhkTEx2K1D3h7D1AJM6fzsWZrxdyRuLN0JH/dkZh1LNH8LxCnBzoPFCPbb2iGpg==",
"license": "MIT"
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
@@ -3016,6 +3524,18 @@
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/web-vitals": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.1.0.tgz",
"integrity": "sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==",
"license": "Apache-2.0"
},
"node_modules/zen-observable": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/zen-observable/-/zen-observable-0.10.0.tgz",
"integrity": "sha512-iI3lT0iojZhKwT5DaFy2Ce42n3yFcLdFyOh01G7H0flMY60P8MJuVFEoJoNwXlmAyQ45GrjL6AcZmmlv8A5rbw==",
"license": "MIT"
}
}
}
+3 -1
View File
@@ -1,7 +1,7 @@
{
"name": "rfc-app-frontend",
"private": true,
"version": "0.11.0",
"version": "0.31.2",
"type": "module",
"scripts": {
"dev": "vite",
@@ -9,6 +9,7 @@
"preview": "vite preview"
},
"dependencies": {
"@amplitude/unified": "^1.1.9",
"@codemirror/commands": "^6.10.3",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/language": "^6.12.3",
@@ -18,6 +19,7 @@
"@tiptap/pm": "^3.5.0",
"@tiptap/react": "^3.5.0",
"@tiptap/starter-kit": "^3.5.0",
"dompurify": "^3.2.4",
"marked": "^18.0.4",
"mermaid": "^11.15.0",
"react": "^19.2.6",
+1073 -688
View File
File diff suppressed because it is too large Load Diff
+179 -10
View File
@@ -1,19 +1,30 @@
import { useEffect, useState } from 'react'
import { Routes, Route, Link, useNavigate } from 'react-router-dom'
import { useEffect, useRef, useState } from 'react'
import { Routes, Route, Link, Navigate, useLocation, useNavigate, useSearchParams } from 'react-router-dom'
import { getMe, subscribeToNotifications } from './api'
import { anonymize, EVENTS, identify, track } from './lib/analytics'
import { useLastState } from './lib/useLastState'
import Catalog from './components/Catalog.jsx'
import Inbox from './components/Inbox.jsx'
import RFCView from './components/RFCView.jsx'
import PRView from './components/PRView.jsx'
import ProposalView from './components/ProposalView.jsx'
import ProposeModal from './components/ProposeModal.jsx'
import ContributeRequestForm from './components/ContributeRequestForm.jsx'
import Landing from './components/Landing.jsx'
import Login from './components/Login.jsx'
import BetaPending from './components/BetaPending.jsx'
import Philosophy from './components/Philosophy.jsx'
import Docs from './components/Docs.jsx'
import DocsLayout from './components/DocsLayout.jsx'
import DocsUserGuide from './components/DocsUserGuide.jsx'
import DocsSessionsAbout from './components/DocsSessionsAbout.jsx'
import DocsSessionIndex from './components/DocsSessionIndex.jsx'
import DocsSessionTranscript from './components/DocsSessionTranscript.jsx'
import DocsSpec from './components/DocsSpec.jsx'
import DocsSpecsIndex from './components/DocsSpecsIndex.jsx'
import NotificationSettings from './components/NotificationSettings.jsx'
import Admin from './components/Admin.jsx'
import AcceptInvitation from './components/AcceptInvitation.jsx'
import InviteClaim from './components/InviteClaim.jsx'
import ToastHost, { showToast } from './components/ToastHost.jsx'
import CookieConsentBanner from './components/CookieConsentBanner.jsx'
import Privacy from './pages/Privacy.jsx'
@@ -33,7 +44,87 @@ export default function App() {
// "Privacy & cookies" tab dispatches a `rfc-app:cookie-consent-reopen`
// event that bumps this.
const [consentReopenTick, setConsentReopenTick] = useState(0)
// v0.23.0 / item #29 flips true once the #21-Part-C identify effect
// has fired (or once we've confirmed there's no authenticated user to
// identify). useLastState gates its resume redirect on this so the
// redirect always happens AFTER identify, preserving identify-then-
// track ordering.
const [identifyReady, setIdentifyReady] = useState(false)
const navigate = useNavigate()
const location = useLocation()
// #28 Parts 23: the LinkedText create/contribute affordances route via
// query params so they need no prop-threading from deep in a comment
// list. `?propose=<term>` opens the propose modal pre-filled;
// `?contribute=<slug>&term=<term>` opens the contribute-request form.
const [searchParams, setSearchParams] = useSearchParams()
const proposeParam = searchParams.get('propose')
const contributeSlug = searchParams.get('contribute')
const contributeTerm = searchParams.get('term')
const clearParams = (...keys) => {
const next = new URLSearchParams(searchParams)
keys.forEach(k => next.delete(k))
setSearchParams(next, { replace: true })
}
// v0.15.0 Page Viewed event taxonomy. We fire on every
// route change; the analytics wrapper itself decides whether
// anything ships out (consent + key check). The first fire is
// also covered because `location` is set on mount.
const lastPathRef = useRef(null)
useEffect(() => {
const path = location.pathname + (location.search || '')
if (lastPathRef.current === path) return
lastPathRef.current = path
track(EVENTS.PAGE_VIEWED, { path: location.pathname })
}, [location.pathname, location.search])
// v0.15.0 + #21 Part C bind the authenticated user id AND
// durable user properties to the analytics session when sign-in
// lands; reset on sign-out (viewer flips to null). The wrapper
// queues these calls until consent + init resolve, so the order
// is safe even on a cold load.
//
// Property bag passed to identify (set vs setOnce per #21 Part C):
// set: role, permission_state, passcode_set, device_trusted
// (these can change mid-account-life refresh each sign-in)
// setOnce: first_sign_in_at, account_created_at
// (immutable user-history markers set on the first
// sign-in that observes them, never overwritten)
//
// PII discipline: NO email, NO display_name, NO gitea_login passed
// through Amplitude only sees opaque ids + enums + timestamps +
// booleans.
const lastUserIdRef = useRef(null)
useEffect(() => {
const uid = me?.authenticated ? me.user?.id : null
const viewer = me?.authenticated ? me.user : null
if (uid != null && lastUserIdRef.current !== uid) {
lastUserIdRef.current = uid
const props = {}
if (viewer?.role != null) props.role = viewer.role
if (viewer?.permission_state != null) props.permission_state = viewer.permission_state
if (viewer?.passcode_set != null) props.passcode_set = !!viewer.passcode_set
if (viewer?.device_trusted != null) props.device_trusted = !!viewer.device_trusted
if (viewer?.first_sign_in_at) props.first_sign_in_at = ['__setOnce__', viewer.first_sign_in_at]
if (viewer?.created_at) props.account_created_at = ['__setOnce__', viewer.created_at]
identify({ user_id: String(uid), properties: props })
// v0.23.0 / item #29 identify has now fired for this sign-in;
// release useLastState's resume redirect (it waits on this).
setIdentifyReady(true)
} else if (uid == null && lastUserIdRef.current != null) {
// Sign-out edge App-level reset is handled separately by the
// sign-out gesture that fires User Signed Out. Clear our local
// memo so a fresh sign-in re-fires identify.
lastUserIdRef.current = null
}
}, [me?.authenticated, me?.user?.id, me?.user?.role, me?.user?.permission_state, me?.user?.passcode_set, me?.user?.device_trusted])
// v0.23.0 / item #29 once `me` has resolved, if there's no
// authenticated user there is nothing to identify, so release the
// resume gate immediately (anonymous boots have no resume to do, but
// the hook still needs the gate resolved to be a clean no-op).
useEffect(() => {
if (me != null && !me.authenticated) setIdentifyReady(true)
}, [me])
useEffect(() => {
const handler = () => setConsentReopenTick(t => t + 1)
@@ -48,6 +139,18 @@ export default function App() {
.finally(() => setLoading(false))
}, [])
// v0.23.0 / item #29 server-side sign-in state resume. The hook
// debounce-posts the current route for authenticated users and, once
// identify has fired, redirects a fresh sign-in (which hard-lands on
// "/") to the user's stored last route. Anonymous users: no-op.
useLastState({
authenticated: !!me?.authenticated,
pathname: location.pathname,
identifyReady,
lastRoute: me?.authenticated ? me.user?.last_route : null,
navigate,
})
// §15.3 subscribe to the live SSE stream for authenticated viewers
// so the badge counter and the toast surface stay in lockstep with
// the inbox. Tabs that miss an event because they were closed pick
@@ -129,9 +232,17 @@ export default function App() {
<button
className="inbox-trigger"
onClick={() => setInboxOpen(o => !o)}
title="Notifications inbox (§15.2)"
aria-label="Inbox"
title="Inbox (§15.2)"
>
<span aria-hidden>📮</span>
<svg
width="18" height="18" viewBox="0 0 24 24"
fill="none" stroke="currentColor" strokeWidth="1.75"
strokeLinecap="round" strokeLinejoin="round" aria-hidden
>
<path d="M4 5h16a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1Z" />
<path d="m3.5 6.5 8.5 6 8.5-6" />
</svg>
{unreadCount > 0 && (
<span className="badge">{unreadCount > 99 ? '99+' : unreadCount}</span>
)}
@@ -141,7 +252,20 @@ export default function App() {
<>
<span className="user-name">{viewer.display_name}</span>
<span className={`user-role-badge role-${viewer.role}`}>{viewer.role}</span>
<a className="btn-link" href="/auth/logout">Sign out</a>
<a
className="btn-link"
href="/auth/logout"
onClick={() => {
// v0.15.0 fire the sign-out event before the
// hard nav. The wrapper's track() is sync-enqueue;
// the underlying SDK flush is best-effort across
// navigation. anonymize() clears the user binding
// so any post-nav anonymous events on the next
// page aren't attributed to the prior user.
track(EVENTS.USER_SIGNED_OUT)
anonymize()
}}
>Sign out</a>
</>
) : (
<Link className="btn-signin-header" to="/login" title="Private beta — only invited emails can sign in">
@@ -156,8 +280,24 @@ export default function App() {
<Route path="/welcome" element={<Landing />} />
<Route path="/login" element={<Login />} />
<Route path="/beta-pending" element={<BetaPending viewer={viewer} />} />
{/* v0.16.0 (item #12): per-RFC invitation acceptance landing.
Anonymous viewers see a sign-in prompt; signed-in users
see the preview + accept gesture. */}
<Route path="/invitations/accept" element={
<PolicyShell><AcceptInvitation viewer={viewer} /></PolicyShell>
} />
{/* v0.17.0 roadmap item #16. The claim landing page for
admin-issued invites. Anonymous-reachable; the call
itself establishes the session on success. */}
<Route path="/invites/claim" element={<InviteClaim />} />
<Route path="/philosophy" element={<PhilosophyWithSidebar viewer={viewer} />} />
<Route path="/docs" element={<DocsWithSidebar viewer={viewer} />} />
{/* v0.19.0 / roadmap item #30 /docs/* is a hub with sub-nav.
The bare /docs path redirects to the user guide; sessions
browser lives at /docs/sessions/*. See DocsLayout.jsx
for the flyout shape and CHANGELOG v0.19.0 for the
upgrade path. */}
<Route path="/docs" element={<Navigate to="/docs/user-guide" replace />} />
<Route path="/docs/*" element={<DocsWithSidebar viewer={viewer} />} />
{/* §14.5 / §14.6: cookie-consent companions to /philosophy.
Available to anonymous and authenticated viewers alike. */}
<Route path="/privacy" element={<PolicyShell><Privacy /></PolicyShell>} />
@@ -187,17 +327,26 @@ export default function App() {
} />
</Routes>
</div>
{proposeOpen && viewer && (
{(proposeOpen || proposeParam != null) && viewer && (
<ProposeModal
viewer={viewer}
onClose={() => setProposeOpen(false)}
initialTitle={proposeParam || ''}
onClose={() => { setProposeOpen(false); clearParams('propose') }}
onSubmitted={({ pr_number }) => {
setProposeOpen(false)
clearParams('propose')
setCatalogVersion(v => v + 1)
navigate(`/proposals/${pr_number}`)
}}
/>
)}
{contributeSlug && viewer && (
<ContributeRequestForm
slug={contributeSlug}
term={contributeTerm || ''}
onClose={() => clearParams('contribute', 'term')}
/>
)}
{inboxOpen && viewer && (
<Inbox onClose={() => setInboxOpen(false)} lastChangeTick={inboxTick} />
)}
@@ -227,9 +376,29 @@ function PhilosophyWithSidebar({ viewer }) {
}
function DocsWithSidebar({ viewer }) {
// v0.19.0 / roadmap item #30 the `/docs/*` surface is a flyout
// shell with sub-routes. The shell (sidebar + content area) is the
// DocsLayout outlet host; the sub-routes mount their respective
// pages into the outlet. Bare `/docs/sessions` redirects to the
// sessions about page so deep-linkers and the flyout's "Sessions"
// header both land somewhere coherent.
return (
<main className="chrome-pane">
<Docs authenticated={!!viewer} />
<Routes>
<Route element={<DocsLayout authenticated={!!viewer} />}>
<Route index element={<Navigate to="user-guide" replace />} />
<Route path="user-guide" element={<DocsUserGuide />} />
<Route path="sessions" element={<Navigate to="about" replace />} />
<Route path="sessions/about" element={<DocsSessionsAbout />} />
<Route path="sessions/:nnnn" element={<DocsSessionIndex />} />
<Route path="sessions/:nnnn/:filename" element={<DocsSessionTranscript />} />
{/* v0.20.0 /docs/specs/* surface (framework spec + flotilla spec
at runtime via gitea raw). Bare /docs/specs lands on the
client-side redirect to the first configured spec. */}
<Route path="specs" element={<DocsSpecsIndex />} />
<Route path="specs/:name" element={<DocsSpec />} />
</Route>
</Routes>
</main>
)
}
+273 -10
View File
@@ -25,17 +25,44 @@ export async function getMe() {
return jsonOrThrow(res)
}
// ── v0.23.0: sign-in state resume (§6.2, roadmap item #29) ───────────────
//
// The route-change hook (useLastState) debounce-posts the user's current
// route + a small bag of *light* view state here for authenticated users.
// The next sign-in reads `last_route` off `/api/auth/me` and redirects.
// Privacy: `state` carries ephemeral view state ONLY — never draft-buffer
// contents (see SPEC §6.2). Best-effort: callers ignore failures (an
// offline/401 POST must never disrupt navigation).
export async function putLastState(route, state) {
const body = { route }
if (state != null) body.state = state
const res = await fetch('/api/me/last-state', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
return jsonOrThrow(res)
}
// ── v0.7.0: email + one-time-code sign-in (§6.2) ─────────────────────────
//
// The legacy /auth/login → /auth/callback OAuth flow remains during the
// migration — the new UI just no longer points at it primarily. These
// two helpers drive the Login.jsx surface.
export async function requestOtc(email) {
export async function requestOtc(email, { turnstileToken } = {}) {
// v0.12.0 / roadmap item #10: when the Turnstile widget has produced
// a token, send it alongside the email so the backend can siteverify
// before the OTC dispatch. The backend treats a missing token as
// either soft-fail (no secret wired AND TURNSTILE_REQUIRED=false)
// or hard-fail (verification required) — the frontend stays
// uninvolved in the policy.
const body = { email }
if (turnstileToken) body.turnstile_token = turnstileToken
const res = await fetch('/auth/otc/request', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
body: JSON.stringify(body),
})
return jsonOrThrow(res)
}
@@ -158,15 +185,81 @@ export async function getProposal(prNumber) {
return jsonOrThrow(await fetch(`/api/proposals/${prNumber}`))
}
export async function proposeRFC({ title, slug, pitch, tags }) {
export async function proposeRFC({ title, slug, pitch, tags, proposedUseCase }) {
const res = await fetch('/api/rfcs/propose', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, slug, pitch, tags: tags || [] }),
// #26: proposed_use_case is optional; send null when blank so the
// backend treats it as "left blank".
body: JSON.stringify({
title,
slug,
pitch,
tags: tags || [],
proposed_use_case: proposedUseCase || null,
}),
})
return jsonOrThrow(res)
}
// Roadmap #27: Claude Haiku tag suggestions for the propose-RFC modal.
// Returns a (possibly empty) array of { tag, confidence }. Deliberately
// forgiving — any non-OK response (rate limit, transient error, no key
// configured server-side) resolves to [] so the modal just shows nothing
// rather than surfacing an error for what is a best-effort assist.
export async function suggestTags({ title, pitch, useCase }) {
try {
const res = await fetch('/api/rfcs/suggest-tags', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: title || '',
pitch: pitch || '',
use_case: useCase || '',
}),
})
if (!res.ok) return []
const data = await res.json()
return Array.isArray(data.suggestions) ? data.suggestions : []
} catch {
return []
}
}
// Roadmap #28 Part 3: offer-to-contribute-to-a-pending-RFC.
// `contributionTarget` feeds the contribute form (RFC title, owner
// display, the viewer's eligibility); `requestContribution` submits the
// ask; accept/decline are the owner's inbox actions.
export async function contributionTarget(slug) {
return jsonOrThrow(await fetch(`/api/rfcs/${slug}/contribution-target`))
}
export async function requestContribution(slug, { matchedTerm, whoIAm, why, useCase }) {
const res = await fetch(`/api/rfcs/${slug}/contribution-requests`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
matched_term: matchedTerm,
who_i_am: whoIAm,
why,
use_case: useCase || null,
}),
})
return jsonOrThrow(res)
}
export async function acceptContributionRequest(slug, requestId) {
return jsonOrThrow(await fetch(
`/api/rfcs/${slug}/contribution-requests/${requestId}/accept`, { method: 'POST' },
))
}
export async function declineContributionRequest(slug, requestId) {
return jsonOrThrow(await fetch(
`/api/rfcs/${slug}/contribution-requests/${requestId}/decline`, { method: 'POST' },
))
}
export async function mergeProposal(prNumber) {
const res = await fetch(`/api/proposals/${prNumber}/merge`, { method: 'POST' })
return jsonOrThrow(res)
@@ -314,6 +407,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
@@ -395,18 +530,19 @@ export async function listBlockingPRs(slug) {
return jsonOrThrow(await fetch(`/api/rfcs/${slug}/blocking-prs`))
}
export async function graduateCheck(slug, { id, repo }) {
export async function graduateCheck(slug, { id }) {
// Meta-only topology (§13.2): two fields — integer id + owners. No
// repo name to validate.
const params = new URLSearchParams()
if (id != null) params.set('id', id)
if (repo != null) params.set('repo', repo)
return jsonOrThrow(await fetch(`/api/rfcs/${slug}/graduate/check?${params}`))
}
export async function startGraduation(slug, { rfcId, repoName, owners }) {
export async function startGraduation(slug, { rfcId, owners }) {
const res = await fetch(`/api/rfcs/${slug}/graduate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rfc_id: rfcId, repo_name: repoName, owners }),
body: JSON.stringify({ rfc_id: rfcId, owners }),
})
return jsonOrThrow(res)
}
@@ -442,13 +578,14 @@ export async function draftPRText(slug, branch) {
return jsonOrThrow(res)
}
export async function openPR(slug, branch, { title, description }) {
export async function openPR(slug, branch, { title, description, proposedUseCase }) {
const res = await fetch(
`/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/open-pr`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, description }),
// #26: proposed_use_case is optional; null when blank.
body: JSON.stringify({ title, description, proposed_use_case: proposedUseCase || null }),
},
)
return jsonOrThrow(res)
@@ -670,6 +807,85 @@ export async function getDocs() {
return jsonOrThrow(await fetch('/api/docs'))
}
// ---------------------------------------------------------------------------
// v0.19.0 / roadmap item #30 — /api/docs/sessions/* surface
// ---------------------------------------------------------------------------
//
// The framework mediates reads against the public
// `wiggleverse/ohm-session-history` gitea repo so the rendered
// `/docs/sessions/*` surface inherits the same chrome as
// `/docs/user-guide`. Three text-bearing endpoints return markdown
// (Content-Type: text/markdown) and the manifest returns JSON. We
// wrap each into a small helper.
//
// 404 from `getSessionAbout` / `getSessionTranscript` / `getSessionIndex`
// throws an Error with `.status === 404` so the UI can render its own
// empty-state. 502 (gitea unreachable) throws `.status === 502` so
// the UI can offer a retry button.
export async function getSessionsManifest() {
// Manifest 404 is mapped server-side to HTTP 200 + `{}` so this
// helper never throws on the empty-state path.
return jsonOrThrow(await fetch('/api/docs/sessions/manifest'))
}
async function _textOrThrow(res) {
if (!res.ok) {
let detail = ''
try {
const body = await res.json()
detail = body.detail || JSON.stringify(body)
} catch {
detail = await res.text()
}
const error = new Error(detail || `HTTP ${res.status}`)
error.status = res.status
throw error
}
return res.text()
}
export async function getSessionsAbout() {
return _textOrThrow(await fetch('/api/docs/sessions/about'))
}
export async function getSessionTranscript(nnnn, filename) {
return _textOrThrow(await fetch(
`/api/docs/sessions/${encodeURIComponent(nnnn)}/${encodeURIComponent(filename)}`
))
}
export async function getSessionIndex(nnnn) {
return jsonOrThrow(await fetch(
`/api/docs/sessions/${encodeURIComponent(nnnn)}/index`
))
}
// ---------------------------------------------------------------------------
// v0.20.0 — /api/docs/specs/* surface
// ---------------------------------------------------------------------------
//
// Sibling of the docs-sessions helpers above. The framework mediates
// reads against the configured spec URLs (default: rfc-app's own
// SPEC.md + flotilla's SPEC.md on `git.wiggleverse.org`) so the
// `/docs/specs/*` route inherits the same chrome as `/docs/user-guide`
// and `/docs/sessions/*`. The manifest endpoint always returns 200 +
// {specs: [...]} — a malformed `OHM_DOCS_SPECS` env var falls back to
// the framework default at parse time on the backend.
//
// 404 from `getSpec` throws `.status === 404`; 502 throws `.status === 502`,
// matching the docs-sessions helper convention.
export async function getSpecsManifest() {
return jsonOrThrow(await fetch('/api/docs/specs/manifest'))
}
export async function getSpec(name) {
return _textOrThrow(await fetch(
`/api/docs/specs/${encodeURIComponent(name)}`
))
}
// ---------------------------------------------------------------------------
// Slice 7: admin neighborhood (§17 admin/* + user search for the §15.8 mute
// typeahead).
@@ -749,6 +965,53 @@ export async function removeAllowlistEmail(email) {
}))
}
// v0.17.0 — roadmap item #16. Admin-create user + invite email with
// optional custom message. The frontend modal on /admin/users wires
// these two helpers; the claim helper drives the /invites/claim page
// that the invitee lands on when they click the email link.
//
// `createUserInvite` returns `{ ok, invite_id, invited_user_id, email,
// role }`. The 409 path (duplicate email) and 422 path (self-invite,
// owner-grant-by-non-owner, malformed input) surface as thrown errors
// via `jsonOrThrow` so the modal can render the server's message.
//
// `listUserInvites` returns the active-invites list for the admin's
// "I sent these but they haven't been claimed yet" view. Active means
// not claimed and not expired; once the invitee clicks through, the
// row clears here and the user-listing's `pending_invite` badge
// vanishes alongside.
export async function createUserInvite({ email, first_name, last_name, role, custom_message }) {
return jsonOrThrow(await fetch('/api/admin/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email,
first_name: first_name || '',
last_name: last_name || '',
role,
custom_message: custom_message || '',
}),
}))
}
export async function listUserInvites() {
return jsonOrThrow(await fetch('/api/admin/users/invites'))
}
// Claim an admin-issued invite token. Anonymous endpoint — the invitee
// is not yet signed in; this call establishes the session on success.
// `trustDevice` mirrors the v0.11.0 OTC/passcode opt-in: when true,
// the server mints a fresh device-trust row + sets the long-lived
// cookie so the invitee skips OTC on their next visit.
export async function claimInvite(token, { trustDevice = false } = {}) {
return jsonOrThrow(await fetch('/api/invites/claim', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, trust_device: !!trustDevice }),
}))
}
export async function searchUsers(q) {
const params = new URLSearchParams()
if (q) params.set('q', q)
@@ -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 (
<div className="accept-invitation">
<h1>Invitation link is malformed</h1>
<p>No <code>token</code> parameter was found. Ask the person who
invited you to re-send the link.</p>
<p><Link to="/">Return to the catalog</Link></p>
</div>
)
}
if (!viewer) {
return (
<div className="accept-invitation">
<h1>Sign in to accept your invitation</h1>
<p>
You've been invited to collaborate on an RFC. Sign in first so we
can attach the membership to your account, then return to this
link.
</p>
<p>
<Link to="/login" className="btn-primary">Sign in</Link>
</p>
</div>
)
}
if (previewError) {
return (
<div className="accept-invitation">
<h1>Invitation unavailable</h1>
<p>{previewError}</p>
<p><Link to="/">Return to the catalog</Link></p>
</div>
)
}
if (!preview) {
return <div className="accept-invitation">Loading invitation</div>
}
const { rfc_title, rfc_slug, role_in_rfc, status, invitee_email, email_matches_you } = preview
if (status === 'revoked') {
return (
<div className="accept-invitation">
<h1>Invitation revoked</h1>
<p>
The owner of <strong>{rfc_title}</strong> revoked this invitation.
Ask them to re-issue it if you should still have access.
</p>
<p><Link to={`/rfc/${rfc_slug}`}>Read the RFC anyway</Link></p>
</div>
)
}
if (status === 'expired') {
return (
<div className="accept-invitation">
<h1>Invitation expired</h1>
<p>
This invitation to <strong>{rfc_title}</strong> has expired. Ask
the RFC's owner to issue a fresh one.
</p>
<p><Link to={`/rfc/${rfc_slug}`}>Read the RFC anyway</Link></p>
</div>
)
}
if (status === 'accepted') {
return (
<div className="accept-invitation">
<h1>Already accepted</h1>
<p>
You've already accepted this invitation. You can{' '}
<Link to={`/rfc/${rfc_slug}`}>open {rfc_title}</Link> now.
</p>
</div>
)
}
if (!email_matches_you) {
return (
<div className="accept-invitation">
<h1>This invitation is for a different account</h1>
<p>
This invitation was sent to <strong>{invitee_email}</strong>. You're
currently signed in as <strong>{viewer.email || viewer.gitea_login}</strong>.
Sign out and sign back in with the invited address to accept.
</p>
<p><a className="btn-link" href="/auth/logout">Sign out</a></p>
</div>
)
}
return (
<div className="accept-invitation">
<h1>Join {rfc_title}</h1>
<p>
You've been invited to <strong>{rfc_title}</strong> as a{' '}
<strong>{role_in_rfc}</strong>.
</p>
<p style={{ color: '#666' }}>
{role_in_rfc === 'contributor'
? 'Contributors can open PRs against this RFC and join its discussion.'
: 'Discussants can post in this RFC\'s discussion.'}
</p>
{acceptError && <div className="error-banner">{acceptError}</div>}
<p>
<button
type="button"
className="btn-primary"
onClick={handleAccept}
disabled={accepting}
>
{accepting ? 'Accepting…' : `Accept and open ${rfc_title}`}
</button>
</p>
<p>
<Link to={`/rfc/${rfc_slug}`}>or just read the RFC without accepting</Link>
</p>
</div>
)
}
+247 -5
View File
@@ -23,7 +23,15 @@ import {
listAllowlist,
addAllowlistEmail,
removeAllowlistEmail,
createUserInvite,
} from '../api.js'
import { EVENTS, track } from '../lib/analytics.js'
// v0.17.0 roadmap item #16. The max length the backend enforces
// (Pydantic body bound + `invites.CUSTOM_MESSAGE_MAX_LENGTH`); kept
// here so the modal's "remaining chars" counter stays in lockstep
// with the server-side bound.
const CUSTOM_MESSAGE_MAX_LENGTH = 500
const TABS = [
{ path: 'users', label: 'Users' },
@@ -89,6 +97,10 @@ function UsersTab() {
const [busy, setBusy] = useState({})
const [error, setError] = useState(null)
const [stateFilter, setStateFilter] = useState('all')
// v0.17.0 roadmap item #16. The "Create user + invite" modal's
// open/closed state. The modal is local to UsersTab (it only opens
// from the header button) and refreshes the listing on success.
const [inviteModalOpen, setInviteModalOpen] = useState(false)
async function refresh() {
setError(null)
@@ -133,6 +145,12 @@ function UsersTab() {
setError(null)
try {
await setUserPermission(userId, state)
// v0.15.0 analytics: fire on a successful §6.1 grant/revoke.
// action collapses the {pending granted, revoked granted}
// edges onto `grant`, and `granted revoked` onto `revoke`,
// matching the roadmap's two-arm taxonomy.
const action = state === 'granted' ? 'grant' : 'revoke'
track(EVENTS.ADMIN_PERMISSION_DECISION, { action, target_user_id: String(userId) })
// Refresh the full row so permission_decided_{at,by_*} update too.
await refresh()
} catch (e) {
@@ -163,7 +181,20 @@ function UsersTab() {
return (
<div className="admin-tab">
<header className="admin-tab-header">
<h2>Users</h2>
<div className="admin-tab-heading">
<h2>Users</h2>
{/* v0.17.0 roadmap item #16. The "Create user + invite"
affordance opens a modal that provisions a fresh users row
with the chosen role and sends an invite email with a
single-use claim link. */}
<div className="admin-tab-actions">
<button
type="button"
className="btn-primary"
onClick={() => setInviteModalOpen(true)}
>Create user + invite</button>
</div>
</div>
<p className="muted">
The pending bucket is the beta-access review queue (§6.1 /
v0.8.0). Grant or revoke writes to <code>permission_events</code>
@@ -174,6 +205,15 @@ function UsersTab() {
</p>
</header>
{error && <p className="settings-note warning">{error}</p>}
{inviteModalOpen && (
<CreateUserInviteModal
onClose={() => setInviteModalOpen(false)}
onSuccess={async () => {
setInviteModalOpen(false)
await refresh()
}}
/>
)}
<div className="admin-filter-chips">
{STATE_CHIPS.map(chip => (
@@ -224,15 +264,35 @@ function UserRow({ user: u, busy, onChangeRole, onToggleMute, onFlipPermission }
const state = u.permission_state || 'granted'
const fullName = [u.first_name, u.last_name].filter(Boolean).join(' ').trim()
const handle = u.gitea_login ? `@${u.gitea_login}` : (u.email || u.display_name)
// v0.17.0 roadmap item #16. The user's row may also be the
// "(pending invite)" shape: admin-created via POST /api/admin/users,
// not yet claimed via /api/invites/claim. The backend's user-listing
// surfaces this via `pending_invite` (object with invite_id +
// expires_at) or null. The badge sits inline next to the handle so
// the admin sees at a glance which rows are real users vs. unclaimed
// invites.
const pendingInvite = u.pending_invite
// When there's no gitea_login the handle already IS the email, so the
// subline would otherwise repeat it. Only append the email when it adds
// something the handle doesn't already show.
const showEmail = u.email && u.email !== handle
return (
<>
<tr>
<td>
<div className="user-cell">
<span className="user-handle">{handle}</span>
<div className="user-cell-handle">
<span className="user-handle">{handle}</span>
{pendingInvite && (
<span
className="invite-badge"
title={`Admin-created invite; expires ${pendingInvite.expires_at}`}
>pending invite</span>
)}
</div>
<span className="muted">
{fullName || u.display_name}
{u.email ? ` · ${u.email}` : ''}
{showEmail ? ` · ${u.email}` : ''}
</span>
</div>
</td>
@@ -265,8 +325,8 @@ function UserRow({ user: u, busy, onChangeRole, onToggleMute, onFlipPermission }
<span className="muted">N/A</span>
)}
</td>
<td className="muted">{u.created_at || '—'}</td>
<td className="muted">{u.last_seen_at || '—'}</td>
<TimeCell value={u.created_at} />
<TimeCell value={u.last_seen_at} />
</tr>
{state === 'pending' && u.beta_request_reason ? (
<tr className="user-row-reason">
@@ -282,6 +342,21 @@ function UserRow({ user: u, busy, onChangeRole, onToggleMute, onFlipPermission }
)
}
// Render a "YYYY-MM-DD HH:MM:SS" timestamp as an intentional date-over-time
// stack (date prominent, time quiet below) rather than letting a narrow
// column wrap the value mid-string. Falls back to an em-dash when absent.
function TimeCell({ value }) {
if (!value) return <td className="muted"></td>
const [date, ...rest] = String(value).split(' ')
const time = rest.join(' ')
return (
<td className="user-when">
<span className="user-when-date">{date}</span>
{time && <span className="user-when-time muted">{time}</span>}
</td>
)
}
function PermissionCell({ user: u, busy, onFlipPermission }) {
const state = u.permission_state || 'granted'
const decidedSuffix = u.permission_decided_at
@@ -319,6 +394,173 @@ function PermissionCell({ user: u, busy, onFlipPermission }) {
)
}
// Create user + invite modal (v0.17.0 / roadmap item #16)
//
// The "Create user + invite" affordance on the Users tab opens this
// modal. Admin types email, first name, last name, role, and (optionally)
// a custom message to embed in the invite email. On submit, calls
// `POST /api/admin/users` which provisions the row + sends the email.
// The 409 path (duplicate email) and 422 path (self-invite, owner-
// grant-by-non-owner, malformed input) surface the server's message
// inline; the success path closes the modal and refreshes the listing.
//
// The modal lives in this file rather than a separate component
// because it has one caller (UsersTab), reuses the existing modal
// stylesheet from /admin's chrome, and shares the
// CUSTOM_MESSAGE_MAX_LENGTH constant defined at the top of the file.
function CreateUserInviteModal({ onClose, onSuccess }) {
const [email, setEmail] = useState('')
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
const [role, setRole] = useState('contributor')
const [customMessage, setCustomMessage] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
const [success, setSuccess] = useState(null)
const remaining = CUSTOM_MESSAGE_MAX_LENGTH - customMessage.length
async function handleSubmit(event) {
event.preventDefault()
const trimmedEmail = email.trim()
if (!trimmedEmail) {
setError('Email is required')
return
}
setBusy(true)
setError(null)
setSuccess(null)
try {
const result = await createUserInvite({
email: trimmedEmail,
first_name: firstName.trim(),
last_name: lastName.trim(),
role,
custom_message: customMessage,
})
// v0.17.0 + #21 Part C Amplitude wiring. target_user_id is
// the OHM user id the invite-create gesture provisioned;
// initial_role is what the invitee inherits on claim.
// custom_message_chars is a coarse signal of admin effort
// (0 = template-only, 1+ = personalized). No PII.
track(EVENTS.USER_INVITED, {
target_user_id: result.invited_user_id != null
? String(result.invited_user_id) : null,
initial_role: result.role,
custom_message_chars: (customMessage || '').length,
})
setSuccess(`Invite sent to ${result.email} (${result.role}).`)
// Brief delay so the admin sees the success state, then close
// and let the parent refresh the listing.
setTimeout(() => { onSuccess?.() }, 600)
} catch (e) {
setError(e.message || 'Unable to send invite')
} finally {
setBusy(false)
}
}
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal-panel" onClick={e => e.stopPropagation()}>
<header className="modal-header">
<h3>Create user + invite</h3>
<button
type="button"
className="btn-link-quiet"
onClick={onClose}
disabled={busy}
aria-label="Close"
>×</button>
</header>
<p className="muted">
Provisions a fresh user row with the chosen role and sends an
invite email carrying a single-use claim link. The link
expires in 7 days. The invitee clicks through to claim
their account no OTC roundtrip is required on first sign-in.
</p>
<form onSubmit={handleSubmit} className="create-user-invite-form">
<label>
<span>Email</span>
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
required
disabled={busy}
autoFocus
maxLength={320}
/>
</label>
<div className="form-row">
<label>
<span>First name</span>
<input
type="text"
value={firstName}
onChange={e => setFirstName(e.target.value)}
disabled={busy}
maxLength={120}
/>
</label>
<label>
<span>Last name</span>
<input
type="text"
value={lastName}
onChange={e => setLastName(e.target.value)}
disabled={busy}
maxLength={120}
/>
</label>
</div>
<label>
<span>Role</span>
<select
value={role}
onChange={e => setRole(e.target.value)}
disabled={busy}
>
<option value="contributor">Contributor</option>
<option value="admin">Admin</option>
<option value="owner">Owner (owner-only)</option>
</select>
</label>
<label>
<span>
Custom message (optional){' '}
<span className={`muted${remaining < 0 ? ' warning' : ''}`}>
{remaining} chars left
</span>
</span>
<textarea
value={customMessage}
onChange={e => setCustomMessage(e.target.value)}
disabled={busy}
rows={4}
maxLength={CUSTOM_MESSAGE_MAX_LENGTH}
placeholder="Optional — embedded in the invite email."
/>
</label>
{error && <p className="settings-note warning">{error}</p>}
{success && <p className="settings-note success">{success}</p>}
<div className="modal-actions">
<button type="button" onClick={onClose} disabled={busy}>Cancel</button>
<button
type="submit"
className="btn-primary"
disabled={busy || !email.trim() || remaining < 0}
>
{busy ? 'Sending…' : 'Send invite'}
</button>
</div>
</form>
</div>
</div>
)
}
// Private-beta allowlist (`migrations/011_allowlist.sql`)
function AllowlistTab() {
@@ -0,0 +1,163 @@
// ContributeRequestForm.jsx roadmap #28 Part 3.
//
// The "ask to contribute" popover, opened from an `rfc-pending` affordance
// in LinkedText (App reads `?contribute=<slug>&term=<term>`). It loads the
// contribution target (RFC title + owner display + the viewer's
// eligibility), shows the framing line "<owner> is working on an RFC for
// '<term>'", and collects the three #15/#26-vocabulary fields:
//
// * Who I am (required, free-text)
// * Why I'm asking (required, free-text)
// * What I'd use it for (optional, mirrors #26)
//
// Submitting POSTs the request, which lands in each owner's §15 inbox.
// When the viewer isn't eligible (anonymous, already a collaborator, or
// has a pending ask) the form shows the backend's reason instead.
import { useEffect, useState } from 'react'
import { contributionTarget, requestContribution } from '../api'
export default function ContributeRequestForm({ slug, term, onClose }) {
const [target, setTarget] = useState(null)
const [loadError, setLoadError] = useState(null)
const [whoIAm, setWhoIAm] = useState('')
const [why, setWhy] = useState('')
const [useCase, setUseCase] = useState('')
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState(null)
const [done, setDone] = useState(false)
useEffect(() => {
let live = true
contributionTarget(slug)
.then(t => { if (live) setTarget(t) })
.catch(err => { if (live) setLoadError(err.message || 'Could not load this RFC.') })
return () => { live = false }
}, [slug])
async function handleSubmit(e) {
e.preventDefault()
if (!whoIAm.trim() || !why.trim()) return
setSubmitting(true)
setError(null)
try {
await requestContribution(slug, {
matchedTerm: term || target?.title || slug,
whoIAm: whoIAm.trim(),
why: why.trim(),
useCase: useCase.trim() || null,
})
setDone(true)
} catch (err) {
setError(err.message || 'Could not send your request.')
} finally {
setSubmitting(false)
}
}
const owner = target?.owner || 'The owner'
const label = term || target?.title || slug
return (
<div className="modal-overlay" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
<div className="modal">
<div className="modal-header">
<h2>Ask to contribute</h2>
<button className="modal-close" onClick={onClose}>×</button>
</div>
{loadError && (
<div className="modal-body"><p className="field-error">{loadError}</p></div>
)}
{!loadError && done && (
<>
<div className="modal-body">
<p>
Your request has been sent to <strong>{owner}</strong>. You'll hear back
in your inbox; if it's accepted you'll get an invitation by email to
join the RFC.
</p>
</div>
<div className="modal-actions">
<button type="button" className="btn-primary" onClick={onClose}>Done</button>
</div>
</>
)}
{!loadError && !done && target && !target.eligible && (
<>
<div className="modal-body">
<p className="field-help" style={{ marginTop: 0 }}>
{owner} is working on an RFC for <strong>'{label}'</strong>.
</p>
<p>{target.already_requested
? "You've already asked to contribute to this RFC — the owner has your request."
: (target.reason || 'You cannot ask to contribute to this RFC right now.')}</p>
</div>
<div className="modal-actions">
<button type="button" className="btn-secondary" onClick={onClose}>Close</button>
</div>
</>
)}
{!loadError && !done && target && target.eligible && (
<form onSubmit={handleSubmit}>
<div className="modal-body">
<p className="field-help" style={{ marginTop: 0 }}>
<strong>{owner}</strong> is working on an RFC for <strong>'{label}'</strong>.
Tell them a little about why you'd like to contribute.
</p>
<label htmlFor="contribute-who">Who I am</label>
<textarea
id="contribute-who"
value={whoIAm}
onChange={e => setWhoIAm(e.target.value)}
placeholder="Your name and a sentence of context."
rows={2}
autoFocus
required
/>
<label htmlFor="contribute-why">Why I'm asking to contribute</label>
<textarea
id="contribute-why"
value={why}
onChange={e => setWhy(e.target.value)}
placeholder="What you'd bring, or what draws you to this RFC."
rows={3}
required
/>
<label htmlFor="contribute-use-case">What I'd use the RFC for (optional)</label>
<textarea
id="contribute-use-case"
value={useCase}
onChange={e => setUseCase(e.target.value)}
placeholder="The concrete thing you intend to build or do with it. Optional."
rows={2}
/>
{error && <p className="field-error">{error}</p>}
</div>
<div className="modal-actions">
<button type="button" className="btn-secondary" onClick={onClose}>Cancel</button>
<button
type="submit"
className="btn-primary"
disabled={!whoIAm.trim() || !why.trim() || submitting}
>
{submitting ? 'Sending…' : 'Send request'}
</button>
</div>
</form>
)}
{!loadError && !done && !target && (
<div className="modal-body"><p className="field-help">Loading</p></div>
)}
</div>
</div>
)
}
+128
View File
@@ -0,0 +1,128 @@
/* Docs.css docs-surface polish scoped to v0.21.0 / roadmap item #32.
*
* This sheet owns ONLY the classes introduced by item #32 (the
* transcript metadata header and the session-root sibling list). The
* pre-existing docs classes (.docs-article, .docs-empty, .docs-error,
* .docs-source-link, .philosophy-body, .muted) live in App.css and are
* deliberately NOT touched here redefining them would race the #31
* App.css token sweep for the same selectors. Every value below reads
* a token from tokens.css so the new surfaces sit on the same
* spacing/type/color scale as the rest of the docs chrome.
*
* Imported from DocsSessionTranscript.jsx + DocsSessionIndex.jsx (the
* two components that render these elements). CSS custom properties are
* not import-order-sensitive at use time, so the import site doesn't
* matter for correctness.
*/
/* Transcript metadata header
* A compact card above the rendered transcript body: title, the
* started/ended/duration grid, an optional TL;DR, and the external
* "view source" link. */
.docs-transcript-meta {
margin: 0 0 var(--space-9);
padding: var(--space-7);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-surface-sunken);
}
.docs-transcript-meta-title {
margin: 0 0 var(--space-5);
font-size: var(--text-lg);
font-weight: var(--weight-semibold);
line-height: var(--leading-tight);
color: var(--color-text-strong);
font-family: var(--font-mono);
word-break: break-word;
}
.docs-transcript-meta-grid {
margin: 0;
display: grid;
grid-template-columns: max-content 1fr;
gap: var(--space-2) var(--space-7);
align-items: baseline;
}
.docs-transcript-meta-row {
display: contents;
}
.docs-transcript-meta-grid dt {
margin: 0;
font-size: var(--text-xs);
font-weight: var(--weight-semibold);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-muted);
}
.docs-transcript-meta-grid dd {
margin: 0;
font-size: var(--text-base);
color: var(--color-text);
}
.docs-transcript-meta-tldr {
margin: var(--space-6) 0 0;
padding-top: var(--space-6);
border-top: 1px solid var(--color-border);
font-size: var(--text-base);
line-height: var(--leading-relaxed);
color: var(--color-text);
}
.docs-transcript-meta-source {
display: inline-block;
margin-top: var(--space-6);
}
/* Session-root sibling-transcript list
* Rendered above the inlined primary transcript when a session has
* more than one transcript (driver `.0` + subagents). The primary is
* marked "(shown below)"; the rest link to their standalone routes. */
.docs-session-siblings {
margin: 0 0 var(--space-9);
padding: var(--space-6) var(--space-7);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-surface-muted);
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--space-3) var(--space-6);
}
.docs-session-siblings-label {
font-size: var(--text-xs);
font-weight: var(--weight-semibold);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-muted);
}
.docs-session-siblings-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: var(--space-2) var(--space-5);
font-family: var(--font-mono);
font-size: var(--text-base);
}
.docs-session-siblings-list a {
color: var(--color-link);
text-decoration: none;
}
.docs-session-siblings-list a:hover {
color: var(--color-accent-strong);
text-decoration: underline;
}
.docs-session-siblings-current {
color: var(--color-text-muted);
}
-51
View File
@@ -1,51 +0,0 @@
// `/docs` the user-facing guide.
//
// Sibling of Philosophy.jsx: same chrome, same data path, different
// source file. Renders DOCS.md verbatim with light chrome around it.
// Reachable anonymously, same as `/philosophy`, so a visitor can read
// the guide before deciding to sign in.
import { useEffect, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import MarkdownPreview from './MarkdownPreview.jsx'
import { getDocs } from '../api.js'
export default function Docs({ authenticated }) {
const [body, setBody] = useState('')
const [error, setError] = useState(null)
const [loading, setLoading] = useState(true)
const navigate = useNavigate()
useEffect(() => {
let active = true
getDocs()
.then(r => { if (active) setBody(r.body || '') })
.catch(e => { if (active) setError(e.message || String(e)) })
.finally(() => { if (active) setLoading(false) })
return () => { active = false }
}, [])
return (
<div className="philosophy-page">
<header className="philosophy-header">
<button
className="philosophy-back"
onClick={() => (history.length > 1 ? navigate(-1) : navigate('/'))}
>
Back
</button>
<span className="philosophy-title">User guide</span>
{!authenticated && (
<Link className="philosophy-signin" to="/">Home</Link>
)}
</header>
<article className="philosophy-body">
{loading && <p className="muted">Loading</p>}
{error && <p className="error">Could not load the guide: {error}</p>}
{!loading && !error && (
<MarkdownPreview content={body} />
)}
</article>
</div>
)
}
+340
View File
@@ -0,0 +1,340 @@
// DocsLayout.jsx v0.20.0 (was v0.19.0 / roadmap item #30).
//
// Left-side flyout nav + content area for the `/docs/*` route tree:
//
// /docs redirect to /docs/user-guide
// /docs/user-guide DOCS.md (existing v0.14.0 content)
// /docs/specs client-side redirect to first configured spec
// /docs/specs/:name a single framework spec (v0.20.0)
// /docs/sessions redirect to /docs/sessions/about
// /docs/sessions/about README.md from the sessions repo
// /docs/sessions/:nnnn per-session overview (nav-only navigation)
// /docs/sessions/:nnnn/:file per-transcript view
//
// v0.20.0 changes (Session 0018.0):
// - Adds a "Specs" section between User Guide and Sessions, driven
// by `/api/docs/specs/manifest`.
// - Sessions render a nested tree: each session row has the
// session's transcripts nested under it as their own nav rows
// (labeled by `.N` ordinal). The transcript list is fetched per
// session via `/api/docs/sessions/:nnnn/index` (cached server-
// side, so the manifest+index fan-out is cheap on subsequent
// loads). Always-expanded; no collapse toggle (current scale is
// under twenty sessions well under the threshold where lazy
// expansion would pay).
//
// The flyout is a persistent left sidebar on desktop and a slide-out
// drawer on mobile (toggled by the icon button in the docs header).
//
// Amplitude analytics (per SPEC §21):
// - track('Doc Viewed', { section: '...' }) on each sub-route mount;
// the sub-route component owns the fire.
// - flyout buttons + links carry `aria-label` + `data-amp-track-name`
// so autocapture rows are readable rather than ":nth-child(7)".
import { useEffect, useState, useCallback } from 'react'
import { Link, useNavigate, useLocation, Outlet } from 'react-router-dom'
import { getSessionsManifest, getSessionIndex, getSpecsManifest } from '../api.js'
// Extract the `.N` ordinal from a transcript filename:
// "SESSION-0014.1-TRANSCRIPT-...md" "0014.1"
// "SESSION-0013.1.1-TRANSCRIPT-...md" "0013.1.1" (nested subagent)
// Returns the bare filename as fallback if the expected shape isn't
// matched (which shouldn't happen the backend index endpoint
// filters by the same regex).
function transcriptOrdinal(filename) {
const m = /^SESSION-(\d{4}\.\d+(?:\.\d+)*)-TRANSCRIPT/.exec(filename)
return m ? m[1] : filename
}
export default function DocsLayout({ authenticated }) {
const [manifest, setManifest] = useState(null)
const [manifestState, setManifestState] = useState('loading') // loading | ok | error
const [sessionFiles, setSessionFiles] = useState({}) // { nnnn: [filename, ...] }
const [specs, setSpecs] = useState([])
const [specsState, setSpecsState] = useState('loading') // loading | ok | error
const [drawerOpen, setDrawerOpen] = useState(false)
const [reloadTick, setReloadTick] = useState(0)
const navigate = useNavigate()
const location = useLocation()
// Manifest fetch drives the Sessions section.
useEffect(() => {
let active = true
setManifestState('loading')
getSessionsManifest()
.then(payload => {
if (!active) return
setManifest(payload || {})
setManifestState('ok')
})
.catch(() => {
if (!active) return
setManifest({})
setManifestState('error')
})
return () => { active = false }
}, [reloadTick])
// Per-session transcript lists fan out from the manifest. Always-
// expanded means we pre-fetch every session's index alongside the
// manifest, gated on the manifest having loaded successfully. The
// backend's 5-minute content TTL makes the repeat cost negligible.
useEffect(() => {
if (manifestState !== 'ok' || !manifest) return
let active = true
const nnnnList = Object.keys(manifest).sort()
Promise.all(
nnnnList.map(nnnn =>
getSessionIndex(nnnn)
.then(payload => [nnnn, (payload && payload.files) || []])
.catch(() => [nnnn, []])
)
).then(pairs => {
if (!active) return
setSessionFiles(Object.fromEntries(pairs))
})
return () => { active = false }
}, [manifest, manifestState])
// Specs fetch drives the Specs section. Independent of sessions.
useEffect(() => {
let active = true
setSpecsState('loading')
getSpecsManifest()
.then(payload => {
if (!active) return
setSpecs((payload && payload.specs) || [])
setSpecsState('ok')
})
.catch(() => {
if (!active) return
setSpecs([])
setSpecsState('error')
})
return () => { active = false }
}, [reloadTick])
// Close the mobile drawer on every navigation so a click in the nav
// doesn't strand the user on a drawer-open view.
useEffect(() => {
setDrawerOpen(false)
}, [location.pathname])
const retryManifest = useCallback(() => {
setReloadTick(t => t + 1)
}, [])
return (
<div className="docs-layout">
<header className="docs-header">
<button
className="docs-back"
onClick={() => (history.length > 1 ? navigate(-1) : navigate('/'))}
aria-label="Back to previous page"
data-amp-track-name="Docs Back"
>
Back
</button>
<button
className="docs-drawer-toggle"
onClick={() => setDrawerOpen(o => !o)}
aria-label="Toggle docs navigation"
aria-expanded={drawerOpen}
data-amp-track-name="Docs Drawer Toggle"
>
<span aria-hidden></span>
</button>
<span className="docs-title">Docs</span>
{!authenticated && (
<Link
className="docs-signin"
to="/"
aria-label="Home"
data-amp-track-name="Docs Home"
>
Home
</Link>
)}
</header>
<div className={'docs-body' + (drawerOpen ? ' docs-body--drawer-open' : '')}>
<aside className="docs-nav" aria-label="Docs navigation">
<DocsNav
manifest={manifest}
manifestState={manifestState}
sessionFiles={sessionFiles}
specs={specs}
specsState={specsState}
onRetry={retryManifest}
currentPath={location.pathname}
/>
</aside>
<main className="docs-content">
<Outlet />
</main>
</div>
{drawerOpen && (
<button
className="docs-drawer-scrim"
aria-label="Close drawer"
onClick={() => setDrawerOpen(false)}
data-amp-track-name="Docs Drawer Close"
/>
)}
</div>
)
}
function DocsNav({
manifest,
manifestState,
sessionFiles,
specs,
specsState,
onRetry,
currentPath,
}) {
const isActive = (path) => currentPath === path || currentPath.startsWith(path + '/')
const isExactly = (path) => currentPath === path
const sessionKeys = Object.keys(manifest || {}).sort()
return (
<nav className="docs-nav-inner">
<div className="docs-nav-section">
<div className="docs-nav-section-label">Docs</div>
<ul className="docs-nav-list">
<li>
<Link
to="/docs/user-guide"
className={isActive('/docs/user-guide') ? 'active' : ''}
aria-label="User Guide"
data-amp-track-name="Docs Nav User Guide"
>
User Guide
</Link>
</li>
</ul>
</div>
<div className="docs-nav-section">
<div className="docs-nav-section-label">Specs</div>
{specsState === 'loading' && (
<ul className="docs-nav-list docs-nav-skeleton" aria-hidden>
<li><span className="skeleton-row" /></li>
<li><span className="skeleton-row" /></li>
</ul>
)}
{specsState === 'error' && (
<div className="docs-nav-error" role="alert">
<span>Couldn't load specs.</span>
</div>
)}
{specsState === 'ok' && specs.length > 0 && (
<ul className="docs-nav-list">
{specs.map(spec => {
const to = `/docs/specs/${spec.name}`
return (
<li key={spec.name}>
<Link
to={to}
className={isExactly(to) ? 'active' : ''}
aria-label={`Spec: ${spec.title}`}
data-amp-track-name="Docs Nav Spec"
data-amp-track-spec={spec.name}
>
{spec.title}
</Link>
</li>
)
})}
</ul>
)}
</div>
<div className="docs-nav-section">
<div className="docs-nav-section-label">Sessions</div>
<ul className="docs-nav-list">
<li>
<Link
to="/docs/sessions/about"
className={isExactly('/docs/sessions/about') ? 'active' : ''}
aria-label="About sessions"
data-amp-track-name="Docs Nav Sessions About"
>
About
</Link>
</li>
</ul>
{manifestState === 'loading' && (
<ul className="docs-nav-list docs-nav-skeleton" aria-hidden>
<li><span className="skeleton-row" /></li>
<li><span className="skeleton-row" /></li>
<li><span className="skeleton-row" /></li>
</ul>
)}
{manifestState === 'error' && (
<div className="docs-nav-error" role="alert">
<span>Couldn't load session list.</span>
<button
type="button"
onClick={onRetry}
aria-label="Retry session list"
data-amp-track-name="Docs Nav Sessions Retry"
>
Try again
</button>
</div>
)}
{manifestState === 'ok' && sessionKeys.length > 0 && (
<ul className="docs-nav-list docs-nav-list--tree">
{sessionKeys.map(nnnn => {
const entry = manifest[nnnn] || {}
const title = entry.title || ''
const label = title ? `${nnnn}${title}` : nnnn
const to = `/docs/sessions/${nnnn}`
const files = sessionFiles[nnnn] || []
return (
<li key={nnnn}>
<Link
to={to}
className={isExactly(to) ? 'active' : ''}
aria-label={`Session ${nnnn}${title ? ': ' + title : ''}`}
data-amp-track-name="Docs Nav Session"
data-amp-track-session={nnnn}
>
{label}
</Link>
{files.length > 0 && (
<ul className="docs-nav-list docs-nav-list--children">
{files.map(f => {
const tTo = `/docs/sessions/${nnnn}/${f}`
return (
<li key={f}>
<Link
to={tTo}
className={isExactly(tTo) ? 'active' : ''}
aria-label={`Transcript ${transcriptOrdinal(f)}`}
data-amp-track-name="Docs Nav Transcript"
data-amp-track-session={nnnn}
data-amp-track-filename={f}
>
{transcriptOrdinal(f)}
</Link>
</li>
)
})}
</ul>
)}
</li>
)
})}
</ul>
)}
</div>
</nav>
)
}
@@ -0,0 +1,246 @@
// DocsSessionIndex.jsx v0.21.0 (was v0.20.0 / roadmap item #30).
//
// Per-session landing at `/docs/sessions/:nnnn`. v0.20.0 rendered a
// dead-end "N transcript(s) in this session. Select one from the
// navigation." placeholder. v0.21.0 / roadmap item #32 collapses that:
// the session root now renders a transcript INLINE so the URL is never
// an empty stop.
//
// - Exactly one transcript render it inline at the session root.
// - Multiple transcripts render the `.0` driver transcript
// inline (fall back to the first file by
// sort order if there's no `.0`), AND
// list/link the remaining transcripts so
// the siblings are one click away.
//
// The URL stays stable to the session number this is an inline
// render, not a 301/redirect. The per-transcript route
// (`/docs/sessions/:nnnn/:filename`) still exists and is what the
// sibling links and the left-nav transcript rows point at.
//
// The transcript count + filenames come from `/api/docs/sessions/:nnnn/index`
// so the empty-state ("no transcripts yet"), not-found, and error
// paths remain meaningful when the upstream is mid-publish or
// unreachable. The metadata header + body rendering are imported from
// DocsSessionTranscript.jsx so the inline view is byte-identical to the
// standalone per-transcript view.
import { useEffect, useState, useCallback } from 'react'
import { Link, useParams } from 'react-router-dom'
import MarkdownPreview from './MarkdownPreview.jsx'
import {
getSessionsManifest,
getSessionIndex,
getSessionTranscript,
} from '../api.js'
import {
TranscriptMetaHeader,
transcriptOrdinal,
} from './DocsSessionTranscript.jsx'
import { EVENTS, track } from '../lib/analytics'
import './Docs.css'
// Pick the transcript to render inline at the session root: prefer the
// `.0` driver transcript; otherwise the first file by sort order. The
// backend already returns the file list sorted, so `files[0]` is a
// stable fallback.
function pickPrimary(files) {
if (!files || files.length === 0) return null
const driver = files.find(f => /^SESSION-\d{4}\.0-TRANSCRIPT/.test(f))
return driver || files[0]
}
export default function DocsSessionIndex() {
const { nnnn } = useParams()
const [title, setTitle] = useState('')
const [tldr, setTldr] = useState('')
const [files, setFiles] = useState([])
const [status, setStatus] = useState('loading') // loading | ok | notfound | error
const [reloadTick, setReloadTick] = useState(0)
// The inline body for the primary transcript.
const [body, setBody] = useState('')
const [bodyStatus, setBodyStatus] = useState('idle') // idle | loading | ok | notfound | error
useEffect(() => {
track(EVENTS.DOC_VIEWED, { section: `sessions/${nnnn}` })
}, [nnnn])
// Title + optional TL;DR from the manifest cheap, cached server-side.
useEffect(() => {
let active = true
getSessionsManifest()
.then(payload => {
if (!active) return
const entry = (payload && payload[nnnn]) || {}
setTitle(entry.title || '')
// `tldr` is an optional manifest field (string). Absent the
// header renders no TL;DR line (graceful degrade).
setTldr(typeof entry.tldr === 'string' ? entry.tldr : '')
})
.catch(() => {
// Title + TL;DR are decorative; the transcript list + body
// fetches below are the load-bearing ones.
})
return () => { active = false }
}, [nnnn])
// File list from the per-session index endpoint.
useEffect(() => {
let active = true
setStatus('loading')
getSessionIndex(nnnn)
.then(payload => {
if (!active) return
setFiles((payload && payload.files) || [])
setStatus('ok')
})
.catch(e => {
if (!active) return
if (e.status === 404) {
setStatus('notfound')
} else {
setStatus('error')
}
})
return () => { active = false }
}, [nnnn, reloadTick])
const primary = status === 'ok' ? pickPrimary(files) : null
// Fetch the primary transcript body once we know which file it is.
useEffect(() => {
if (!primary) {
setBody('')
setBodyStatus('idle')
return
}
let active = true
setBodyStatus('loading')
getSessionTranscript(nnnn, primary)
.then(text => {
if (!active) return
setBody(text || '')
setBodyStatus('ok')
})
.catch(e => {
if (!active) return
setBodyStatus(e.status === 404 ? 'notfound' : 'error')
})
return () => { active = false }
}, [nnnn, primary, reloadTick])
const retry = useCallback(() => setReloadTick(t => t + 1), [])
const header = title ? `${nnnn}${title}` : `Session ${nnnn}`
const siblings = primary ? files.filter(f => f !== primary) : []
return (
<article className="docs-article">
<h1 className="docs-article-title">{header}</h1>
{status === 'loading' && <p className="muted">Loading</p>}
{status === 'notfound' && (
<div className="docs-empty">
<p>
No transcripts have been published for this session yet.{' '}
<Link
to="/docs/sessions/about"
aria-label="About sessions"
data-amp-track-name="Docs Session Empty About Link"
>
About sessions
</Link>
</p>
</div>
)}
{status === 'error' && (
<div className="docs-error" role="alert">
<p>Couldn't reach the session-history repo.</p>
<button
type="button"
onClick={retry}
aria-label="Retry"
data-amp-track-name="Docs Session Index Retry"
>
Try again
</button>
</div>
)}
{status === 'ok' && files.length === 0 && (
<div className="docs-empty">
<p>This session has no transcripts published.</p>
</div>
)}
{status === 'ok' && primary && (
<>
{siblings.length > 0 && (
<nav className="docs-session-siblings" aria-label="Transcripts in this session">
<span className="docs-session-siblings-label">Transcripts</span>
<ul className="docs-session-siblings-list">
<li>
<span
className="docs-session-siblings-current"
aria-current="true"
>
{transcriptOrdinal(primary)} (shown below)
</span>
</li>
{siblings.map(f => (
<li key={f}>
<Link
to={`/docs/sessions/${nnnn}/${f}`}
aria-label={`Transcript ${transcriptOrdinal(f)}`}
data-amp-track-name="Docs Session Sibling Transcript"
data-amp-track-session={nnnn}
data-amp-track-filename={f}
>
{transcriptOrdinal(f)}
</Link>
</li>
))}
</ul>
</nav>
)}
{bodyStatus === 'loading' && <p className="muted">Loading transcript</p>}
{bodyStatus === 'notfound' && (
<div className="docs-empty">
<p>This transcript isn't published yet.</p>
</div>
)}
{bodyStatus === 'error' && (
<div className="docs-error" role="alert">
<p>Couldn't reach the session-history repo.</p>
<button
type="button"
onClick={retry}
aria-label="Retry"
data-amp-track-name="Docs Session Inline Retry"
>
Try again
</button>
</div>
)}
{bodyStatus === 'ok' && (
<>
<TranscriptMetaHeader
nnnn={nnnn}
filename={primary}
title={title}
tldr={tldr}
/>
<div className="philosophy-body">
<MarkdownPreview content={body} />
</div>
</>
)}
</>
)}
</article>
)
}
@@ -0,0 +1,266 @@
// DocsSessionTranscript.jsx v0.21.0 (was v0.19.0 / roadmap item #30).
//
// Per-transcript view at `/docs/sessions/:nnnn/:filename`. Fetches the
// transcript body via the backend mediator and renders it through the
// shared MarkdownPreview.
//
// v0.21.0 / roadmap item #32:
// - A compact metadata header now sits above the rendered body
// (title, started/ended, duration, optional TL;DR, and a
// "View source on git.wiggleverse.org" external link). The parse
// + render helpers (`parseTranscriptMeta`, `TranscriptMetaHeader`,
// `gitSourceUrl`) are exported here so the session-root inline-
// collapse view (DocsSessionIndex.jsx) reuses the exact same
// rendering for the transcript(s) it inlines.
//
// Empty-state contract:
// 404 "This transcript isn't published yet" with a link back to
// the parent session index
// 502 "Couldn't reach the session-history repo" + retry button
import { useEffect, useState, useCallback } from 'react'
import { Link, useParams } from 'react-router-dom'
import MarkdownPreview from './MarkdownPreview.jsx'
import { getSessionTranscript, getSessionsManifest } from '../api.js'
import { EVENTS, track } from '../lib/analytics'
import './Docs.css'
// The canonical published-repo source URL for a transcript file, per
// SESSION-PROTOCOL.md §1's folder layout (one folder per session).
export function gitSourceUrl(nnnn, filename) {
return (
'https://git.wiggleverse.org/wiggleverse/ohm-session-history/src/branch/main/' +
`${encodeURIComponent(nnnn)}/${encodeURIComponent(filename)}`
)
}
// Extract the `.N` ordinal from a transcript filename:
// "SESSION-0014.1-TRANSCRIPT-...md" "0014.1"
// "SESSION-0013.1.1-TRANSCRIPT-...md" "0013.1.1" (nested subagent)
export function transcriptOrdinal(filename) {
const m = /^SESSION-(\d{4}\.\d+(?:\.\d+)*)-TRANSCRIPT/.exec(filename || '')
return m ? m[1] : filename || ''
}
// Parse the `<start>--<end>` ISO segment out of a transcript filename.
// Per the protocol the segment is `YYYY-MM-DDTHH-MM--YYYY-MM-DDTHH-MM`
// (colons replaced by dashes for filesystem portability, minute
// precision, PST implied). Legacy renamed-letter transcripts omit the
// segment entirely; in that case every derived field comes back null
// and the header degrades gracefully.
//
// Returns { ordinal, start: Date|null, end: Date|null, durationMs: number|null }.
export function parseTranscriptMeta(filename) {
const ordinal = transcriptOrdinal(filename)
const m = /-TRANSCRIPT-(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})--(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})\.md$/.exec(
filename || ''
)
if (!m) {
return { ordinal, start: null, end: null, durationMs: null }
}
const [, sDate, sH, sM, eDate, eH, eM] = m
// Parse as local wall-clock time. The filename carries no timezone
// (PST is implied per the protocol); we render the wall-clock value
// verbatim rather than shifting it, so we build a local Date and read
// it back with the same calendar fields. Duration is a difference of
// two local Dates, so the implied-timezone ambiguity cancels out.
const start = new Date(`${sDate}T${sH}:${sM}:00`)
const end = new Date(`${eDate}T${eH}:${eM}:00`)
const startOk = !Number.isNaN(start.getTime())
const endOk = !Number.isNaN(end.getTime())
const durationMs =
startOk && endOk && end.getTime() >= start.getTime()
? end.getTime() - start.getTime()
: null
return {
ordinal,
start: startOk ? start : null,
end: endOk ? end : null,
durationMs,
}
}
function fmtDateTime(d) {
if (!d) return null
// e.g. "May 28, 2026, 11:11 AM" human-readable, wall-clock.
try {
return d.toLocaleString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
})
} catch {
return d.toISOString()
}
}
function fmtDuration(ms) {
if (ms == null || ms <= 0) return null
const totalMin = Math.round(ms / 60000)
const h = Math.floor(totalMin / 60)
const m = totalMin % 60
if (h > 0 && m > 0) return `${h}h ${m}m`
if (h > 0) return `${h}h`
return `${m}m`
}
// The compact metadata block rendered above every transcript body.
// Shared between the standalone transcript route and the session-root
// inline-collapse view. `tldr` is optional absent rendered nothing
// (graceful degrade, per the manifest schema where `tldr` may be unset).
export function TranscriptMetaHeader({ nnnn, filename, title, tldr }) {
const { ordinal, start, end, durationMs } = parseTranscriptMeta(filename)
const started = fmtDateTime(start)
const ended = fmtDateTime(end)
const duration = fmtDuration(durationMs)
const heading = title ? `${ordinal}${title}` : `Session ${ordinal}`
return (
<header className="docs-transcript-meta">
<h2 className="docs-transcript-meta-title">{heading}</h2>
{(started || ended || duration) && (
<dl className="docs-transcript-meta-grid">
{started && (
<div className="docs-transcript-meta-row">
<dt>Started</dt>
<dd>{started}</dd>
</div>
)}
{ended && (
<div className="docs-transcript-meta-row">
<dt>Ended</dt>
<dd>{ended}</dd>
</div>
)}
{duration && (
<div className="docs-transcript-meta-row">
<dt>Duration</dt>
<dd>{duration}</dd>
</div>
)}
</dl>
)}
{tldr && <p className="docs-transcript-meta-tldr">{tldr}</p>}
<a
className="docs-source-link docs-transcript-meta-source"
href={gitSourceUrl(nnnn, filename)}
target="_blank"
rel="noopener noreferrer"
aria-label={`View transcript ${ordinal} source on git.wiggleverse.org`}
data-amp-track-name="Docs Transcript Source Link"
data-amp-track-session={nnnn}
data-amp-track-filename={filename}
>
View source on git.wiggleverse.org
</a>
</header>
)
}
export default function DocsSessionTranscript() {
const { nnnn, filename } = useParams()
const [body, setBody] = useState('')
const [title, setTitle] = useState('')
const [tldr, setTldr] = useState('')
const [status, setStatus] = useState('loading') // loading | ok | notfound | error
const [reloadTick, setReloadTick] = useState(0)
useEffect(() => {
track(EVENTS.DOC_VIEWED, { section: `sessions/${nnnn}/${filename}` })
}, [nnnn, filename])
// Title + optional tl;dr from the manifest decorative metadata that
// feeds the header. Failure leaves the header showing the bare NNNN
// and no TL;DR; the body fetch below is the load-bearing one.
useEffect(() => {
let active = true
getSessionsManifest()
.then(payload => {
if (!active) return
const entry = (payload && payload[nnnn]) || {}
setTitle(entry.title || '')
setTldr(typeof entry.tldr === 'string' ? entry.tldr : '')
})
.catch(() => {})
return () => { active = false }
}, [nnnn])
useEffect(() => {
let active = true
setStatus('loading')
getSessionTranscript(nnnn, filename)
.then(text => {
if (!active) return
setBody(text || '')
setStatus('ok')
})
.catch(e => {
if (!active) return
if (e.status === 404) {
setStatus('notfound')
} else {
setStatus('error')
}
})
return () => { active = false }
}, [nnnn, filename, reloadTick])
const retry = useCallback(() => setReloadTick(t => t + 1), [])
return (
<article className="docs-article">
<div className="docs-breadcrumbs">
<Link
to={`/docs/sessions/${nnnn}`}
aria-label={`Back to session ${nnnn} index`}
data-amp-track-name="Docs Transcript Back To Index"
>
Session {nnnn}
</Link>
</div>
{status === 'loading' && <p className="muted">Loading</p>}
{status === 'notfound' && (
<div className="docs-empty">
<p>This transcript isn't published yet.</p>
<p>
<Link
to={`/docs/sessions/${nnnn}`}
aria-label={`Back to session ${nnnn}`}
data-amp-track-name="Docs Transcript Back To Session"
>
Back to session {nnnn}
</Link>
</p>
</div>
)}
{status === 'error' && (
<div className="docs-error" role="alert">
<p>Couldn't reach the session-history repo.</p>
<button
type="button"
onClick={retry}
aria-label="Retry"
data-amp-track-name="Docs Transcript Retry"
>
Try again
</button>
</div>
)}
{status === 'ok' && (
<>
<TranscriptMetaHeader
nnnn={nnnn}
filename={filename}
title={title}
tldr={tldr}
/>
<div className="philosophy-body">
<MarkdownPreview content={body} />
</div>
</>
)}
</article>
)
}
@@ -0,0 +1,99 @@
// DocsSessionsAbout.jsx v0.19.0 / roadmap item #30.
//
// Renders the README.md of the public `wiggleverse/ohm-session-history`
// repo at `/docs/sessions/about`. The framework backend mediates the
// gitea fetch (see backend/app/docs_sessions.py); this component
// handles three response paths:
//
// 200 render the markdown via MarkdownPreview
// 404 "About not yet published" empty-state (the upstream README
// doesn't exist yet happens when a deployment hasn't
// restructured its session-history repo yet, expected at
// v0.19.0 deploy time per the CHANGELOG)
// 502 "Couldn't reach the session-history repo" with a retry
// button. The retry just re-invokes the fetch no extra
// backoff because the backend cache already smoothes
// repeated 502s.
import { useEffect, useState, useCallback } from 'react'
import MarkdownPreview from './MarkdownPreview.jsx'
import { getSessionsAbout } from '../api.js'
import { EVENTS, track } from '../lib/analytics'
export default function DocsSessionsAbout() {
const [body, setBody] = useState('')
const [status, setStatus] = useState('loading') // loading | ok | notfound | error
const [reloadTick, setReloadTick] = useState(0)
useEffect(() => {
track(EVENTS.DOC_VIEWED, { section: 'sessions/about' })
}, [])
useEffect(() => {
let active = true
setStatus('loading')
getSessionsAbout()
.then(text => {
if (!active) return
setBody(text || '')
setStatus('ok')
})
.catch(e => {
if (!active) return
if (e.status === 404) {
setStatus('notfound')
} else {
setStatus('error')
}
})
return () => { active = false }
}, [reloadTick])
const retry = useCallback(() => setReloadTick(t => t + 1), [])
return (
<article className="docs-article">
<h1 className="docs-article-title">About sessions</h1>
{status === 'loading' && <p className="muted">Loading</p>}
{status === 'notfound' && (
<div className="docs-empty">
<p>
The session-history About page isn't published yet. Sessions
are still authored once a few have shipped, this page will
render the canonical introduction.
</p>
<p>
In the meantime, browse the source repo directly at{' '}
<a
href="https://git.wiggleverse.org/wiggleverse/ohm-session-history"
target="_blank"
rel="noopener noreferrer"
aria-label="Open session-history repo on gitea"
data-amp-track-name="Docs Sessions About Repo Link"
>
wiggleverse/ohm-session-history
</a>.
</p>
</div>
)}
{status === 'error' && (
<div className="docs-error" role="alert">
<p>Couldn't reach the session-history repo.</p>
<button
type="button"
onClick={retry}
aria-label="Retry"
data-amp-track-name="Docs Sessions About Retry"
>
Try again
</button>
</div>
)}
{status === 'ok' && (
<div className="philosophy-body">
<MarkdownPreview content={body} />
</div>
)}
</article>
)
}
+134
View File
@@ -0,0 +1,134 @@
// DocsSpec.jsx v0.20.0.
//
// Per-spec view at `/docs/specs/:name`. Fetches a configured spec
// body via the backend mediator (which proxies the gitea raw URL)
// and renders it through the shared MarkdownPreview. The page header
// pulls the spec's `title` from the manifest so the breadcrumb-free
// page still names what you're looking at.
//
// Empty-state contract:
// 404 "This spec isn't published yet / unknown name" with a hint
// to pick a configured spec from the nav.
// 502 "Couldn't reach the spec source" + retry button.
//
// History view is intentionally absent the operator's framing for
// v0.20.0 is "current version only; git is the history surface".
// A small "View on gitea" link beside the title points at the
// upstream source URL the manifest carries so the history gesture
// remains one click away.
import { useEffect, useState, useCallback } from 'react'
import { Link, useParams } from 'react-router-dom'
import MarkdownPreview from './MarkdownPreview.jsx'
import { getSpec, getSpecsManifest } from '../api.js'
import { EVENTS, track } from '../lib/analytics'
export default function DocsSpec() {
const { name } = useParams()
const [title, setTitle] = useState('')
const [sourceUrl, setSourceUrl] = useState('')
const [body, setBody] = useState('')
const [status, setStatus] = useState('loading') // loading | ok | notfound | error
const [reloadTick, setReloadTick] = useState(0)
useEffect(() => {
track(EVENTS.DOC_VIEWED, { section: `specs/${name}` })
}, [name])
// Title + upstream URL from the manifest (cheap the manifest is
// derived from an env var on the backend, no network).
useEffect(() => {
let active = true
getSpecsManifest()
.then(payload => {
if (!active) return
const entry = (payload && payload.specs || []).find(s => s.name === name)
setTitle((entry && entry.title) || '')
setSourceUrl((entry && entry.url) || '')
})
.catch(() => {
// Title + source link are decorative; the body fetch below
// is the load-bearing one. A failed manifest fetch just
// leaves the page rendering the bare name.
})
return () => { active = false }
}, [name])
useEffect(() => {
let active = true
setStatus('loading')
getSpec(name)
.then(text => {
if (!active) return
setBody(text || '')
setStatus('ok')
})
.catch(e => {
if (!active) return
if (e.status === 404) {
setStatus('notfound')
} else {
setStatus('error')
}
})
return () => { active = false }
}, [name, reloadTick])
const retry = useCallback(() => setReloadTick(t => t + 1), [])
const header = title || `Spec: ${name}`
return (
<article className="docs-article">
<header className="docs-article-header">
<h1 className="docs-article-title">{header}</h1>
{sourceUrl && (
<a
className="docs-source-link"
href={sourceUrl}
target="_blank"
rel="noopener noreferrer"
aria-label="View spec source on gitea"
data-amp-track-name="Docs Spec Source Link"
data-amp-track-spec={name}
>
View source
</a>
)}
</header>
{status === 'loading' && <p className="muted">Loading</p>}
{status === 'notfound' && (
<div className="docs-empty">
<p>This spec isn't available.</p>
<p>
<Link
to="/docs/user-guide"
aria-label="User guide"
data-amp-track-name="Docs Spec Notfound User Guide Link"
>
Back to the user guide
</Link>
</p>
</div>
)}
{status === 'error' && (
<div className="docs-error" role="alert">
<p>Couldn't reach the spec source.</p>
<button
type="button"
onClick={retry}
aria-label="Retry"
data-amp-track-name="Docs Spec Retry"
>
Try again
</button>
</div>
)}
{status === 'ok' && (
<div className="philosophy-body">
<MarkdownPreview content={body} />
</div>
)}
</article>
)
}
@@ -0,0 +1,82 @@
// DocsSpecsIndex.jsx v0.20.0.
//
// Landing route at `/docs/specs`. The operator-stated body shape for
// the parallel `/docs/sessions/:nnnn` page is "no body list pick a
// transcript from the nav", and the same gesture applies here: the
// `/docs/specs` route either redirects to the first configured spec
// (the common case) or renders a "no specs configured" empty state
// (only reachable if a deployment overrides `OHM_DOCS_SPECS` to an
// empty list the framework default has two entries).
//
// The redirect is client-side because the manifest is a single API
// call away; server-side redirect would require either a backend
// route for the bare `/docs/specs` path (out of scope for v0.20.0)
// or a build-time bake of the first spec name (which couples the
// frontend bundle to the deployment overlay, which we don't do).
import { useEffect, useState } from 'react'
import { Navigate, Link } from 'react-router-dom'
import { getSpecsManifest } from '../api.js'
import { EVENTS, track } from '../lib/analytics'
export default function DocsSpecsIndex() {
const [firstName, setFirstName] = useState(null)
// Tri-state: 'loading' (waiting on manifest), 'redirect' (we have a
// name to redirect to render <Navigate>), 'empty' (no specs
// configured), or 'error' (couldn't load the manifest at all).
const [status, setStatus] = useState('loading')
useEffect(() => {
track(EVENTS.DOC_VIEWED, { section: 'specs' })
}, [])
useEffect(() => {
let active = true
getSpecsManifest()
.then(payload => {
if (!active) return
const specs = (payload && payload.specs) || []
if (specs.length === 0) {
setStatus('empty')
} else {
setFirstName(specs[0].name)
setStatus('redirect')
}
})
.catch(() => {
if (!active) return
setStatus('error')
})
return () => { active = false }
}, [])
if (status === 'redirect' && firstName) {
return <Navigate to={firstName} replace />
}
return (
<article className="docs-article">
<h1 className="docs-article-title">Specs</h1>
{status === 'loading' && <p className="muted">Loading</p>}
{status === 'empty' && (
<div className="docs-empty">
<p>No specs are configured for this deployment.</p>
<p>
<Link
to="/docs/user-guide"
aria-label="User guide"
data-amp-track-name="Docs Specs Empty User Guide Link"
>
Back to the user guide
</Link>
</p>
</div>
)}
{status === 'error' && (
<div className="docs-error" role="alert">
<p>Couldn't load the spec list.</p>
</div>
)}
</article>
)
}
+81
View File
@@ -0,0 +1,81 @@
// DocsUserGuide.jsx v0.19.0 / roadmap item #30.
//
// Renders DOCS.md at `/docs/user-guide`. Was `/docs` before v0.19.0
// (the v0.14.0 single-route Docs.jsx surface, now superseded). The
// content path is unchanged: backend reads `DOCS.md` from disk and
// serves it at `/api/docs`. The body is rendered via the existing
// `MarkdownPreview` (the same component the `/philosophy` route uses,
// so we don't introduce a second markdown library).
// v0.21.0 / roadmap item #31: the loading + error states are brought
// onto the same `.docs-empty` / `.docs-error` convention every other
// docs surface uses (was a bare `<p className="error">`), with a retry
// button so a transient `/api/docs` failure isn't a dead end.
import { useEffect, useState, useCallback } from 'react'
import { Link } from 'react-router-dom'
import MarkdownPreview from './MarkdownPreview.jsx'
import { getDocs } from '../api.js'
import { EVENTS, track } from '../lib/analytics'
export default function DocsUserGuide() {
const [body, setBody] = useState('')
const [status, setStatus] = useState('loading') // loading | ok | error
const [reloadTick, setReloadTick] = useState(0)
useEffect(() => {
track(EVENTS.DOC_VIEWED, { section: 'user-guide' })
}, [])
useEffect(() => {
let active = true
setStatus('loading')
getDocs()
.then(r => {
if (!active) return
setBody(r.body || '')
setStatus('ok')
})
.catch(() => {
if (!active) return
setStatus('error')
})
return () => { active = false }
}, [reloadTick])
const retry = useCallback(() => setReloadTick(t => t + 1), [])
return (
<article className="docs-article">
<h1 className="docs-article-title">User guide</h1>
{status === 'loading' && <p className="muted">Loading</p>}
{status === 'error' && (
<div className="docs-error" role="alert">
<p>Couldn't load the user guide.</p>
<button
type="button"
onClick={retry}
aria-label="Retry"
data-amp-track-name="Docs User Guide Retry"
>
Try again
</button>
<p>
<Link
to="/docs/sessions/about"
aria-label="About sessions"
data-amp-track-name="Docs User Guide Error About Link"
>
About sessions
</Link>
</p>
</div>
)}
{status === 'ok' && (
<div className="philosophy-body">
<MarkdownPreview content={body} />
</div>
)}
</article>
)
}
+2 -2
View File
@@ -17,7 +17,7 @@
import { useEditor, EditorContent, Extension } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import { useEffect, useRef, useCallback } from 'react'
import { marked } from 'marked'
import { renderMarkdown } from '../lib/sanitizeHtml'
import { Plugin, PluginKey } from 'prosemirror-state'
import { Decoration, DecorationSet } from 'prosemirror-view'
@@ -122,7 +122,7 @@ export default function Editor({
useEffect(() => {
if (!editor || content == null) return
const html = marked.parse(content)
const html = renderMarkdown(content)
editor.commands.setContent(html, false)
}, [content, editor])
+36 -118
View File
@@ -1,70 +1,55 @@
// GraduateDialog.jsx the §13.2 Graduate dialog and the §13.3 step stack.
// GraduateDialog.jsx the §13.2 Graduate dialog and the §13.3 flip.
//
// Renders three editable fields (integer ID, repo name, initial owners)
// with debounced server-side validation per §13.2 and a precondition
// popover backed by /blocking-prs for the §9.8 open-body-edit-PR gate.
//
// On confirm, opens the §13.3 SSE stream and renders the five named
// steps with per-step states. On failure, the rollback step's events
// append to the stack and a "What happened" panel renders below until
// the admin dismisses it.
// Meta-only topology (SPEC §1): graduation is an in-place state flip on
// the meta entry no per-RFC repo is created and the body is kept. The
// dialog renders two editable fields (integer ID + initial owners) with
// debounced server-side validation per §13.2. On confirm it opens the
// §13.3 SSE stream and renders the two named steps (open the flip PR,
// merge it). There is no rollback step and no repo-name field.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
graduateCheck,
listBlockingPRs,
openGraduationProgress,
startGraduation,
} from '../api'
const CHECK_DEBOUNCE_MS = 250
const STEP_KEY_ORDER = ['create_repo', 'seed_files', 'open_pr', 'merge_pr', 'refresh_cache']
const STEP_KEY_ORDER = ['open_pr', 'merge_pr']
export default function GraduateDialog({ slug, entry, onClose, onCompleted }) {
// Suggest defaults from the catalog.
const suggestedId = useMemo(() => suggestNextRfcId(entry?.allKnownIds || []), [entry])
const [rfcId, setRfcId] = useState(suggestedId)
const [repoName, setRepoName] = useState(`rfc-${stripPrefix(suggestedId)}-${slug}`)
const [owners, setOwners] = useState(entry?.owners?.length ? entry.owners : [])
const [newOwner, setNewOwner] = useState('')
const [checkResult, setCheckResult] = useState(null)
const [blockingPRs, setBlockingPRs] = useState([])
const [precondPopover, setPrecondPopover] = useState(false)
const [phase, setPhase] = useState('idle') // idle | running | done | rolled_back | error
const [phase, setPhase] = useState('idle') // idle | running | done | failed | error
const [streamState, setStreamState] = useState(null)
const [submitError, setSubmitError] = useState(null)
const esRef = useRef(null)
// Initial blocking-PRs probe + ongoing /check polling.
useEffect(() => {
listBlockingPRs(slug).then(({ items }) => setBlockingPRs(items || [])).catch(() => {})
}, [slug])
useEffect(() => {
const t = setTimeout(() => {
graduateCheck(slug, { id: rfcId, repo: repoName })
graduateCheck(slug, { id: rfcId })
.then(setCheckResult)
.catch(() => {})
}, CHECK_DEBOUNCE_MS)
return () => clearTimeout(t)
}, [slug, rfcId, repoName])
}, [slug, rfcId])
useEffect(() => () => { esRef.current?.close() }, [])
const idError = checkResult?.id?.error || null
const repoError = checkResult?.repo?.error || null
const ownersOk = owners.length > 0
const ownersError = ownersOk ? null : 'Add at least one initial owner'
const blockingError = blockingPRs.length > 0
? `${blockingPRs.length} open body-edit PR${blockingPRs.length === 1 ? '' : 's'} blocking graduation`
: null
// First-blocker tooltip text per §13.2.
const firstBlocker = idError || repoError || ownersError || blockingError
const canSubmit = !firstBlocker && phase === 'idle' && checkResult?.id?.ok && checkResult?.repo?.ok
const firstBlocker = idError || ownersError
const canSubmit = !firstBlocker && phase === 'idle' && checkResult?.id?.ok && ownersOk
const handleAddOwner = useCallback(() => {
const v = newOwner.trim().toLowerCase()
@@ -81,7 +66,7 @@ export default function GraduateDialog({ slug, entry, onClose, onCompleted }) {
setSubmitError(null)
setPhase('running')
try {
await startGraduation(slug, { rfcId, repoName, owners })
await startGraduation(slug, { rfcId, owners })
} catch (err) {
setPhase('idle')
setSubmitError(err.message)
@@ -96,7 +81,7 @@ export default function GraduateDialog({ slug, entry, onClose, onCompleted }) {
// Short hold per §13.3, then dismiss.
setTimeout(() => onCompleted?.(payload), 1500)
} else {
setPhase('rolled_back')
setPhase('failed')
}
}
},
@@ -105,7 +90,7 @@ export default function GraduateDialog({ slug, entry, onClose, onCompleted }) {
setPhase('error')
},
})
}, [slug, rfcId, repoName, owners, onCompleted])
}, [slug, rfcId, owners, onCompleted])
// ----- Render -----
@@ -122,10 +107,10 @@ export default function GraduateDialog({ slug, entry, onClose, onCompleted }) {
{!showStack && (
<div className="modal-body">
<p className="modal-intro">
§13: graduate the super-draft to its own repo. The meta-repo entry
becomes frontmatter-only; the canonical body moves to `RFC.md` in
the new repo. The sequence runs as five transactional steps with
rollback per §13.3.
§13: graduate the super-draft to active. This is an in-place
state flip the entry keeps its body and stays in the meta
repo; only the frontmatter changes (state, integer ID, and the
graduation stamps). No new repository is created.
</p>
<div className="form-row">
@@ -141,19 +126,6 @@ export default function GraduateDialog({ slug, entry, onClose, onCompleted }) {
{idError && <p className="field-error">{idError}</p>}
</div>
<div className="form-row">
<label>Repo name</label>
<input
type="text"
value={repoName}
onChange={(e) => setRepoName(e.target.value.trim())}
placeholder="rfc-NNNN-slug"
disabled={phase !== 'idle'}
/>
<p className="field-help">Becomes `&lt;org&gt;/{repoName || 'rfc-…'}` on Gitea.</p>
{repoError && <p className="field-error">{repoError}</p>}
</div>
<div className="form-row">
<label>Initial owners</label>
<div className="owner-list">
@@ -189,68 +161,24 @@ export default function GraduateDialog({ slug, entry, onClose, onCompleted }) {
</div>
{ownersError && <p className="field-error">{ownersError}</p>}
</div>
{blockingPRs.length > 0 && (
<div className="precondition-block">
<button
type="button"
className="precondition-toggle"
onClick={() => setPrecondPopover(p => !p)}
>
{blockingPRs.length} open body-edit PR{blockingPRs.length === 1 ? '' : 's'} blocking graduation
&nbsp;{precondPopover ? '▾' : '▸'}
</button>
{precondPopover && (
<div className="precondition-popover">
{blockingPRs.map(pr => (
<div key={pr.pr_number} className="precondition-row">
<div className="precondition-row-main">
<strong>PR #{pr.pr_number}</strong> {pr.title || '(no title)'}
<div className="precondition-row-meta">
{pr.author ? `by @${pr.author}` : ''}
{pr.last_activity_at ? ` · ${pr.last_activity_at.slice(0, 10)}` : ''}
</div>
</div>
<div className="precondition-row-actions">
<a
className="btn-link"
href={`/rfc/${slug}/pr/${pr.pr_number}`}
target="_blank"
rel="noreferrer"
>Open </a>
</div>
</div>
))}
<p className="precondition-help">
§9.8: open body-edit PRs would attempt to re-introduce a
body to a frontmatter-only entry after step 3. Resolve
them (merge or withdraw) and re-open this dialog.
</p>
</div>
)}
</div>
)}
</div>
)}
{showStack && (
<div className="modal-body">
<StepStack
steps={streamState?.steps || []}
rollbackSteps={streamState?.rollback_steps || []}
/>
{phase === 'rolled_back' && (
<StepStack steps={streamState?.steps || []} />
{phase === 'failed' && (
<div className="what-happened">
<h3>What happened</h3>
<p>
The graduation could not complete. The app rolled back the
steps that had already run; nothing was left half-applied on
Gitea. Error: <code>{streamState?.error || 'unknown'}</code>.
The graduation could not complete. Because it is a single
in-place flip, nothing was left half-applied `{slug}` stays
a super-draft. Error: <code>{streamState?.error || 'unknown'}</code>.
</p>
<p>
Read the failure detail next to the red step above. Resolve
the underlying cause (a repo-name collision, a network flake,
a concurrent PR landing on `rfcs/{slug}.md`) and try again.
Read the failure detail next to the red step above, resolve
the underlying cause (a concurrent PR landing on{' '}
`rfcs/{slug}.md`, a network flake), and try again.
</p>
</div>
)}
@@ -258,9 +186,8 @@ export default function GraduateDialog({ slug, entry, onClose, onCompleted }) {
<div className="graduation-complete">
<h3>Graduation complete</h3>
<p>
`{slug}` is now active as <strong>{streamState?.rfc_id}</strong>{' '}
at <code>{streamState?.repo_full}</code>. The catalog and the
RFC view reflect the new state.
`{slug}` is now active as <strong>{streamState?.rfc_id}</strong>.
The catalog and the RFC view reflect the new state.
</p>
</div>
)}
@@ -277,23 +204,23 @@ export default function GraduateDialog({ slug, entry, onClose, onCompleted }) {
disabled={!canSubmit}
title={canSubmit ? '' : firstBlocker || ''}
>
Graduate to RFC repo
Graduate
</button>
</>
)}
{phase === 'running' && (
<span className="modal-progress-note">Running graduation sequence</span>
<span className="modal-progress-note">Graduating</span>
)}
{(phase === 'rolled_back' || phase === 'error') && (
{(phase === 'failed' || phase === 'error') && (
<button className="btn-secondary" onClick={onClose}>Close</button>
)}
{phase === 'done' && (
<button className="btn-primary" onClick={() => onCompleted?.(streamState)}>
View the new RFC
View the RFC
</button>
)}
</div>
{submitError && phase !== 'rolled_back' && (
{submitError && phase !== 'failed' && (
<div className="modal-error">Error: {submitError}</div>
)}
</div>
@@ -302,14 +229,10 @@ export default function GraduateDialog({ slug, entry, onClose, onCompleted }) {
}
function StepStack({ steps, rollbackSteps }) {
function StepStack({ steps }) {
return (
<div className="step-stack">
{steps.map(s => <StepRow key={s.key} step={s} />)}
{rollbackSteps.length > 0 && (
<div className="rollback-divider">Rollback</div>
)}
{rollbackSteps.map(s => <StepRow key={s.key} step={s} />)}
</div>
)
}
@@ -350,8 +273,3 @@ function suggestNextRfcId(existing) {
const next = used.size === 0 ? 1 : (Math.max(...used) + 1)
return `RFC-${String(next).padStart(4, '0')}`
}
function stripPrefix(rfcId) {
return rfcId?.startsWith('RFC-') ? rfcId.slice(4) : rfcId
}
+138
View File
@@ -0,0 +1,138 @@
/* Inbox.css §15.2 inbox panel refinements (roadmap #25, light pass).
*
* The base inbox layout/structure lives in App.css (the §15 / Slice 6
* block). This sheet is a TOKENIZED polish layer on top of it: it does
* NOT re-lay-out the panel, it sharpens the unread/read distinction,
* adds the per-row "mark read" affordance + the unread dot, and gives
* the empty/loading states real copy and spacing.
*
* Cascade note: Inbox.jsx is imported by App.jsx (line 6) BEFORE the
* App.css import (line 30), so under ESM depth-first evaluation this
* sheet is injected FIRST and App.css wins on equal specificity. Any
* rule here that must override an App.css value is therefore written
* one notch more specific (e.g. `.inbox-list .inbox-row.unread`).
* New classes that App.css doesn't define need no such guard.
*/
/* ===== Unread vs. read distinction ===== */
/* A clear left accent bar + warmer tint on unread; read rows sit calm. */
.inbox-list .inbox-row {
position: relative;
border-bottom: 1px solid var(--color-border);
transition: background var(--motion-fast) var(--ease-out);
}
.inbox-list .inbox-row.unread {
background: var(--color-warning-bg-soft, var(--c-warning-bg-soft));
box-shadow: inset 3px 0 0 var(--color-accent);
}
.inbox-list .inbox-row.read .inbox-summary {
color: var(--color-text-muted);
font-weight: var(--weight-normal);
}
.inbox-list .inbox-row.unread .inbox-summary {
color: var(--color-text);
font-weight: var(--weight-medium);
}
/* The dot is a NEW affordance: a filled accent dot for unread, hidden
* (but space-reserved) for read so summaries stay column-aligned. */
.inbox-unread-dot {
flex: 0 0 auto;
width: 8px;
height: 8px;
border-radius: var(--radius-pill);
background: var(--color-accent);
}
.inbox-row.read .inbox-unread-dot {
background: transparent;
}
/* ===== Per-row "mark read" affordance ===== */
/* The row is a flex Link followed by this button; pin the button to the
* right edge, revealed on row hover/focus and always visible on touch. */
.inbox-row {
display: flex;
align-items: center;
}
.inbox-row .inbox-row-link {
flex: 1 1 auto;
min-width: 0;
}
.inbox-row-dismiss {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
margin-right: var(--space-5);
padding: 0;
color: var(--color-text-subtle);
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius-md);
cursor: pointer;
opacity: 0;
transition:
opacity var(--motion-fast) var(--ease-out),
color var(--motion-fast) var(--ease-out),
background var(--motion-fast) var(--ease-out),
border-color var(--motion-fast) var(--ease-out);
}
.inbox-row:hover .inbox-row-dismiss,
.inbox-row:focus-within .inbox-row-dismiss,
.inbox-row-dismiss:focus-visible {
opacity: 1;
}
.inbox-row-dismiss:hover {
color: var(--color-success-fg);
background: var(--color-success-bg);
border-color: var(--color-success-bg);
}
.inbox-row-dismiss:focus-visible {
outline: 2px solid var(--color-focus-ring);
outline-offset: 1px;
}
/* Coarse pointers (touch) have no hover; keep the affordance discoverable. */
@media (hover: none) {
.inbox-row-dismiss { opacity: 1; }
}
/* ===== Mark-all-read button ===== */
.inbox-mark-all {
margin-left: auto;
}
/* ===== Empty / loading states ===== */
.inbox-state {
padding: var(--space-9) var(--space-7);
text-align: center;
}
.inbox-empty {
padding: var(--space-11) var(--space-7);
text-align: center;
}
.inbox-empty-title {
margin: 0 0 var(--space-3);
font-size: var(--text-md);
font-weight: var(--weight-semibold);
color: var(--color-text-strong);
}
.inbox-empty .muted {
margin: 0;
font-size: var(--text-base);
line-height: var(--leading-normal);
color: var(--color-text-muted);
}
/* #28 Part 3 actionable contribute-request row: the requester's
who/why/use-case detail plus an Accept/Decline pair. */
.inbox-row-action { display: flex; flex-direction: column; gap: 8px; padding: 12px; }
.inbox-row-action .inbox-row-main { display: flex; align-items: center; gap: 8px; }
.inbox-request-detail { margin-left: 18px; font-size: var(--text-sm); }
.inbox-request-detail p { margin: 2px 0; color: var(--color-text-muted); }
.inbox-request-detail strong { color: var(--color-text); }
.inbox-request-actions { display: flex; gap: 8px; margin-left: 18px; }
.inbox-request-outcome { margin: 0 0 0 18px; }
+122 -11
View File
@@ -11,10 +11,13 @@
import { useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import {
acceptContributionRequest,
declineContributionRequest,
listNotifications,
markNotificationRead,
markNotificationsReadByFilter,
} from '../api.js'
import './Inbox.css'
const CATEGORIES = [
{ value: '', label: 'All categories' },
@@ -56,11 +59,15 @@ export default function Inbox({ onClose, lastChangeTick }) {
return Array.from(seen.entries())
}, [items])
async function markOneRead(item) {
if (item.read_at) return
await markNotificationRead(item.id)
setItems(prev => prev.map(p => p.id === item.id ? { ...p, read_at: new Date().toISOString() } : p))
setUnreadCount(c => Math.max(0, c - 1))
}
async function handleRowClick(item) {
if (!item.read_at) {
await markNotificationRead(item.id)
setItems(prev => prev.map(p => p.id === item.id ? { ...p, read_at: new Date().toISOString() } : p))
}
await markOneRead(item)
}
async function markAllUnderFilter() {
@@ -121,22 +128,36 @@ export default function Inbox({ onClose, lastChangeTick }) {
</label>
<button
className="btn-link"
className="btn-link inbox-mark-all"
onClick={markAllUnderFilter}
disabled={items.every(i => i.read_at)}
title="Mark every notification matching the current filter as read"
>
Mark all read (under filter)
Mark all read
</button>
</div>
<div className="inbox-body">
{loading && <p className="muted">Loading</p>}
{loading && <p className="inbox-state muted">Loading your inbox</p>}
{!loading && items.length === 0 && (
<p className="muted">No notifications match. Try a different filter, or come back later.</p>
<div className="inbox-empty">
<p className="inbox-empty-title">You're all caught up.</p>
<p className="muted">
{filters.unread || filters.rfcSlug || filters.category
? 'Nothing matches the current filters. Clear them to see everything.'
: 'New activity on RFCs you follow will show up here.'}
</p>
</div>
)}
<ul className="inbox-list">
{items.map(item => (
<InboxRow key={item.id} item={item} onClick={handleRowClick} onClose={onClose} />
<InboxRow
key={item.id}
item={item}
onClick={handleRowClick}
onMarkRead={markOneRead}
onClose={onClose}
/>
))}
</ul>
</div>
@@ -145,16 +166,88 @@ export default function Inbox({ onClose, lastChangeTick }) {
)
}
function InboxRow({ item, onClick, onClose }) {
// #28 Part 3: the contribute-request row is the first actionable inbox
// kind it renders the requester's who/why/use-case inline and an
// Accept/Decline pair that fire the owner's decision (accept reuses #12's
// invite flow on the backend).
function ContributionRequestRow({ item, onMarkRead }) {
const unread = !item.read_at
const x = item.extras || {}
const [outcome, setOutcome] = useState(null) // 'accepted' | 'declined'
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
async function act(accept) {
if (busy || outcome) return
setBusy(true)
setError(null)
try {
if (accept) await acceptContributionRequest(item.rfc_slug, x.request_id)
else await declineContributionRequest(item.rfc_slug, x.request_id)
setOutcome(accept ? 'accepted' : 'declined')
await onMarkRead(item)
} catch (err) {
setError(err.message || 'Action failed.')
} finally {
setBusy(false)
}
}
return (
<li className={`inbox-row inbox-row-action ${unread ? 'unread' : 'read'}`}>
<div className="inbox-row-main">
<span className="inbox-unread-dot" aria-hidden />
<span className={`inbox-cat cat-${item.category || 'unknown'}`}>{item.category || '·'}</span>
<span className="inbox-summary">{item.summary}</span>
<span className="inbox-when">{formatWhen(item.created_at)}</span>
</div>
<div className="inbox-request-detail">
{x.who_i_am && <p><strong>Who:</strong> {x.who_i_am}</p>}
{x.why && <p><strong>Why:</strong> {x.why}</p>}
{x.use_case && <p><strong>Use case:</strong> {x.use_case}</p>}
</div>
{error && <p className="field-error">{error}</p>}
{outcome ? (
<p className="inbox-request-outcome muted">
{outcome === 'accepted'
? 'Accepted — an invitation has been sent.'
: 'Declined.'}
</p>
) : (
<div className="inbox-request-actions">
<button type="button" className="btn-primary" disabled={busy || !x.request_id} onClick={() => act(true)}>
Accept
</button>
<button type="button" className="btn-secondary" disabled={busy || !x.request_id} onClick={() => act(false)}>
Decline
</button>
</div>
)}
</li>
)
}
function InboxRow({ item, onClick, onMarkRead, onClose }) {
if (item.event_kind === 'contribution_request_on_pending_rfc') {
return <ContributionRequestRow item={item} onMarkRead={onMarkRead} />
}
const unread = !item.read_at
const target = deepLink(item)
const handle = async () => {
await onClick(item)
if (target) onClose?.()
}
const handleMarkRead = async (e) => {
// Don't let the row's Link fire this affordance only marks read,
// it never navigates.
e.preventDefault()
e.stopPropagation()
await onMarkRead(item)
}
return (
<li className={`inbox-row ${unread ? 'unread' : ''}`}>
<li className={`inbox-row ${unread ? 'unread' : 'read'}`}>
<Link to={target || '#'} onClick={handle} className="inbox-row-link">
<span className="inbox-unread-dot" aria-hidden />
<span className={`inbox-cat cat-${item.category || 'unknown'}`}>{item.category || '·'}</span>
<span className="inbox-summary">{item.summary}</span>
{item.bundled_count > 1 && (
@@ -162,6 +255,24 @@ function InboxRow({ item, onClick, onClose }) {
)}
<span className="inbox-when">{formatWhen(item.created_at)}</span>
</Link>
{unread && (
<button
type="button"
className="inbox-row-dismiss"
onClick={handleMarkRead}
aria-label="Mark as read"
title="Mark as read"
>
{/* check glyph — dependency-free inline SVG */}
<svg
width="14" height="14" viewBox="0 0 24 24"
fill="none" stroke="currentColor" strokeWidth="2.25"
strokeLinecap="round" strokeLinejoin="round" aria-hidden
>
<path d="m5 13 4 4 10-11" />
</svg>
</button>
)}
</li>
)
}
@@ -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/<slug>/invitations, which both writes
// the row and dispatches the email to the invitee. Success
// refreshes the list below and clears the input.
//
// 2. "Existing invitations" every invitation (pending +
// accepted + revoked + expired) on this RFC, with revoke
// buttons on the pending ones. The status of each row is the
// effective status (the backend recomputes expired-from-pending
// at read time so an unattended cron isn't required).
//
// No custom-message field that belongs to item #16's platform-
// level surface, not here. No bulk-invite one email at a time
// keeps the gesture deliberate.
import { useEffect, useState } from 'react'
import {
createRFCInvitation,
listRFCInvitations,
revokeRFCInvitation,
} from '../api'
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
// inviteaccept 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 (
<div className="modal-overlay" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
<div className="modal" style={{ maxWidth: 640 }}>
<div className="modal-header">
<h2>Invitations {rfcTitle || slug}</h2>
<button className="modal-close" onClick={onClose}>×</button>
</div>
<div className="modal-body">
<p style={{ marginTop: 0, color: '#666' }}>
Invite people by email to contribute PRs against this RFC or to
join its discussion. Anyone with the link can read this RFC;
this surface controls who can <em>write</em>.
</p>
<form onSubmit={handleSend} className="invitations-form" style={{ marginTop: 16 }}>
<label htmlFor="invitee-email">Invitee email</label>
<input
id="invitee-email"
type="email"
value={inviteeEmail}
onChange={e => setInviteeEmail(e.target.value)}
placeholder="someone@example.com"
autoFocus
required
/>
<label htmlFor="invitee-role" style={{ marginTop: 10 }}>Role on this RFC</label>
<select
id="invitee-role"
value={roleInRFC}
onChange={e => setRoleInRFC(e.target.value)}
>
{ROLE_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
<div style={{ marginTop: 12, display: 'flex', gap: 8, alignItems: 'center' }}>
<button type="submit" className="btn-primary" disabled={submitting}>
{submitting ? 'Sending…' : 'Send invitation'}
</button>
{submitError && <span style={{ color: '#c33' }}>{submitError}</span>}
{submitSuccess && <span style={{ color: '#383' }}>{submitSuccess}</span>}
</div>
</form>
<hr style={{ margin: '20px 0' }} />
<h3 style={{ margin: '0 0 8px' }}>Existing invitations</h3>
{loadError && <div className="error-banner">{loadError}</div>}
{invitations === null && <div>Loading</div>}
{invitations !== null && invitations.length === 0 && (
<div style={{ color: '#666' }}>No invitations have been sent yet.</div>
)}
{invitations !== null && invitations.length > 0 && (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr>
<th style={{ textAlign: 'left', padding: 4 }}>Email</th>
<th style={{ textAlign: 'left', padding: 4 }}>Role</th>
<th style={{ textAlign: 'left', padding: 4 }}>Status</th>
<th style={{ textAlign: 'left', padding: 4 }}>Sent</th>
<th style={{ padding: 4 }}></th>
</tr>
</thead>
<tbody>
{invitations.map(inv => (
<tr key={inv.id} style={{ borderTop: '1px solid #eee' }}>
<td style={{ padding: 4 }}>{inv.invitee_email}</td>
<td style={{ padding: 4 }}>{inv.role_in_rfc}</td>
<td style={{ padding: 4 }}>
<span className={`invitation-status status-${inv.status}`}>
{inv.status}
</span>
{inv.status === 'accepted' && inv.accepted_by_display && (
<span style={{ color: '#666', marginLeft: 6 }}>
by {inv.accepted_by_display}
</span>
)}
</td>
<td style={{ padding: 4, color: '#666' }}>
{inv.created_at?.slice(0, 10) || ''}
</td>
<td style={{ padding: 4, textAlign: 'right' }}>
{inv.status === 'pending' && (
<button
type="button"
className="btn-link"
onClick={() => handleRevoke(inv.id)}
disabled={revokingId === inv.id}
>
{revokingId === inv.id ? 'Revoking…' : 'Revoke'}
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className="modal-footer">
<button type="button" className="btn-link" onClick={onClose}>Close</button>
</div>
</div>
</div>
)
}
+173
View File
@@ -0,0 +1,173 @@
// v0.17.0 roadmap item #16. The claim flow's landing page.
//
// The admin's invite email carries a link to /invites/claim?token=;
// the invitee clicks through and lands here. The page reads the
// token from the URL, posts it to /api/invites/claim, and on success
// routes either to the passcode-set screen (if v0.10.0 passcode flow
// is in play and the user has no passcode yet) or to home.
//
// Anonymous-reachable: the entire point of the call is to establish
// the session; we do not pre-check authentication.
//
// Failure modes the backend distinguishes:
// * 410 token is expired or already claimed (the row is dead).
// * 400 token doesn't match any active invite (forged, revoked,
// or wiped).
//
// We surface both as the same "this invite link isn't valid" shape
// for the invitee the detail message from the server reads
// distinctively enough that the admin can debug from logs, and the
// invitee just needs to know they should contact the admin for a
// fresh link.
import { useEffect, useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { claimInvite } from '../api.js'
import { EVENTS, identify, track } from '../lib/analytics.js'
export default function InviteClaim() {
const location = useLocation()
const navigate = useNavigate()
const [status, setStatus] = useState('working') // 'working' | 'ok' | 'failed'
const [error, setError] = useState(null)
const [trustDevice, setTrustDevice] = useState(false)
const [submitted, setSubmitted] = useState(false)
const [user, setUser] = useState(null)
const [needsPasscode, setNeedsPasscode] = useState(false)
const params = new URLSearchParams(location.search)
const token = params.get('token') || ''
async function performClaim() {
if (!token) {
setStatus('failed')
setError('No invite token in the URL.')
return
}
setSubmitted(true)
setStatus('working')
setError(null)
try {
const result = await claimInvite(token, { trustDevice })
setUser(result.user)
setNeedsPasscode(!!result.needs_passcode)
// v0.17.0 + #21 Part C identify the new user with their OHM
// user_id BEFORE firing any track() event, so the Amplitude
// user record is created with the OHM id from the first event
// rather than as an anonymous device that retroactively links.
// setOnce on invited_at + invited_by_admin_id + initial_role so
// these are immutable user-history markers on the Amplitude
// record.
if (result.user?.id != null) {
const setOnceProps = {
claim_method: 'admin-invite',
}
if (result.invited_at) setOnceProps.invited_at = ['__setOnce__', result.invited_at]
if (result.invited_by_admin_id != null) {
setOnceProps.invited_by_admin_id = ['__setOnce__', String(result.invited_by_admin_id)]
}
if (result.user.role) setOnceProps.initial_role = ['__setOnce__', result.user.role]
identify({
user_id: String(result.user.id),
properties: setOnceProps,
})
}
track(EVENTS.INVITE_CLAIMED, {
invited_by_admin_id: result.invited_by_admin_id != null
? String(result.invited_by_admin_id) : null,
initial_role: result.user?.role,
needs_passcode: !!result.needs_passcode,
trust_device: trustDevice,
})
setStatus('ok')
} catch (e) {
setStatus('failed')
setError(e.message || 'Unable to claim invite')
}
}
// Pre-flight: if the URL has no token at all, fail fast so the
// invitee sees the missing-token shape immediately rather than
// an empty form.
useEffect(() => {
if (!token) {
setStatus('failed')
setError('This claim link is missing its token.')
}
}, [token])
// On a successful claim, route the user onward. The brief calls
// this out: route to passcode-set if v0.10.0 passcode flow is in
// play and the user has no passcode yet; otherwise route to home.
useEffect(() => {
if (status !== 'ok') return
const timeout = setTimeout(() => {
if (needsPasscode) {
navigate('/settings/notifications#sign-in', { replace: true })
} else {
navigate('/', { replace: true })
}
}, 1200)
return () => clearTimeout(timeout)
}, [status, needsPasscode, navigate])
return (
<div className="invite-claim-page">
<div className="invite-claim-panel">
<h1>Claim your account</h1>
{!submitted && status === 'working' && token && (
<>
<p>
You've been invited to this deployment. Click the button below
to claim your account and sign in. This link is single-use and
expires 7 days after it was sent.
</p>
<label className="claim-trust-toggle">
<input
type="checkbox"
checked={trustDevice}
onChange={e => setTrustDevice(e.target.checked)}
/>
{' '}Trust this device for 30 days (skip the email step on
your next visit from this browser).
</label>
<div className="claim-actions">
<button
type="button"
className="btn-primary"
onClick={performClaim}
>Claim my account</button>
</div>
</>
)}
{submitted && status === 'working' && (
<p>Claiming</p>
)}
{status === 'ok' && (
<>
<p className="settings-note success">
Welcome{user?.display_name ? `, ${user.display_name}` : ''}!
You're signed in.
</p>
<p className="muted">
{needsPasscode
? 'Redirecting you to set a passcode so you can sign in without an email roundtrip next time…'
: 'Redirecting you to the home page…'}
</p>
</>
)}
{status === 'failed' && (
<>
<p className="settings-note warning">
{error || "This invite link isn't valid."}
</p>
<p className="muted">
If you believe this is a mistake, contact the admin who
sent you the invite they can issue a fresh link.
</p>
</>
)}
</div>
</div>
)
}
+91
View File
@@ -0,0 +1,91 @@
// LinkedText.jsx roadmap #28 (Parts 13).
//
// Renders a backend-provided list of text/link segments (see
// backend/app/rfc_links.py). References in PR descriptions and comments
// arrive pre-scanned as structured segments this component maps them
// onto plain text runs, anchors, and inline affordances. It never renders
// HTML from the server (no dangerouslySetInnerHTML), so the surface is
// XSS-safe regardless of what a comment author typed.
//
// Segment types:
// * `rfc` Part 1: a link to an accepted (active) RFC.
// * `rfc-pending` Part 3: the term names a pending (super-draft)
// RFC; a signed-in viewer who isn't its owner gets
// an inline "ask to contribute" affordance routing
// to the contribute form (App reads `?contribute=`).
// * `rfc-candidate` Part 2: a strong-candidate term with no RFC yet;
// a viewer with create rights (`canCreate`) gets a
// "create RFC" affordance routing to the propose
// flow pre-filled (App reads `?propose=`).
//
// Affordances degrade to plain text when the viewer lacks the relevant
// right, so the visible prose is identical for everyone only the
// offered actions differ.
//
// `segments` is the enriched array; `text` is the raw fallback used when
// the field is absent (an older cached response, or a caller that didn't
// pass segments).
import { Link } from 'react-router-dom'
export default function LinkedText({ segments, text, viewer, canCreate }) {
if (!Array.isArray(segments) || segments.length === 0) {
return <>{text ?? ''}</>
}
// A signed-in, beta-granted viewer can ask to contribute; the backend
// re-checks ownership/collaborator status and rejects self-requests.
const canContribute = !!viewer && viewer.permission_state === 'granted'
return (
<>
{segments.map((seg, i) => {
if (seg.type === 'rfc') {
return (
<a
key={i}
className="rfc-autolink"
href={`/rfc/${seg.slug}`}
title={seg.title ? `RFC: ${seg.title}` : undefined}
>
{seg.label}
</a>
)
}
if (seg.type === 'rfc-pending') {
const who = seg.owner || 'Someone'
return (
<span key={i} className="rfc-pending">
{seg.label}
{canContribute && (
<Link
className="rfc-offer rfc-offer-contribute"
to={`?contribute=${encodeURIComponent(seg.slug)}&term=${encodeURIComponent(seg.label)}`}
title={`${who} is working on an RFC for '${seg.label}' — ask to contribute`}
>
ask to contribute
</Link>
)}
</span>
)
}
if (seg.type === 'rfc-candidate') {
const term = seg.term || seg.label
return (
<span key={i} className="rfc-candidate">
{seg.label}
{canCreate && (
<Link
className="rfc-offer rfc-offer-create"
to={`?propose=${encodeURIComponent(term)}`}
title={`Create RFC for '${term}'`}
>
+ create RFC
</Link>
)}
</span>
)
}
return <span key={i}>{seg.text}</span>
})}
</>
)
}
+107 -11
View File
@@ -1,7 +1,17 @@
// Login.jsx the composed sign-in surface (§6.2) after the v0.10.0
// (passcodes, roadmap item #8) rebase onto v0.8.0 (beta-access-request
// capture, §6.1 / §14.1, roadmap item #6). v0.7.0 (roadmap item #5)
// established the email + OTC scaffolding both releases extended.
// Login.jsx the composed sign-in surface (§6.2) after the v0.12.0
// (CloudFlare Turnstile gate on OTC dispatch, roadmap item #10) /
// v0.10.0 (passcodes, roadmap item #8) rebase onto v0.8.0
// (beta-access-request capture, §6.1 / §14.1, roadmap item #6). v0.7.0
// (roadmap item #5) established the email + OTC scaffolding the later
// releases extended.
//
// v0.12.0: the email-entry step renders a Turnstile widget. The token
// it produces is sent to `/auth/otc/request` alongside the email. The
// passcode step also renders a widget for the "Use a code instead"
// fallback dispatch (same backend endpoint, same gate). When the
// `VITE_TURNSTILE_SITE_KEY` build var is unset, the widget renders
// nothing the form still submits and the backend's TURNSTILE_REQUIRED
// policy decides admission.
//
// Four-to-six-step flow (most users see three; the longest path is
// pending-user with no passcode, who never sees the passcode steps):
@@ -74,6 +84,8 @@ import {
setPasscode as apiSetPasscode,
startDeviceTrust,
} from '../api'
import TurnstileWidget, { turnstileEnabled } from './TurnstileWidget'
import { EVENTS, track } from '../lib/analytics'
export default function Login() {
// Steps: 'email' 'passcode' or 'code' (on the OTC path, after
@@ -98,6 +110,18 @@ export default function Login() {
const [reason, setReason] = useState('')
const [status, setStatus] = useState('')
const [busy, setBusy] = useState(false)
// v0.12.0: Turnstile token captured by the widget. `null` means no
// challenge solved yet (or the site key is unset, in which case the
// widget surfaces null on mount). The token is single-use; we clear
// it back to null right after we send it so a second request on the
// same form remount re-challenges. `turnstileReady` is true once the
// widget has produced a token OR the widget is not configured at
// build time (no site key) the submit button reads from it so the
// form locks up when the operator has wired Turnstile but the user
// hasn't solved the challenge yet.
const [turnstileToken, setTurnstileToken] = useState(null)
const turnstileOn = turnstileEnabled()
const turnstileReady = !turnstileOn || !!turnstileToken
const emailRef = useRef(null)
const codeRef = useRef(null)
const passcodeRef = useRef(null)
@@ -127,7 +151,12 @@ export default function Login() {
;(async () => {
try {
await startDeviceTrust()
if (!cancelled) window.location.assign('/')
if (!cancelled) {
// v0.15.0 analytics: device-trust cookie path is one of
// three sign-in methods the taxonomy distinguishes.
track(EVENTS.USER_SIGNED_IN, { method: 'trust-device' })
window.location.assign('/')
}
} catch (_) {
// No trusted device fall through to the email step.
}
@@ -149,13 +178,22 @@ export default function Login() {
setStep('passcode')
setStatus('')
} else {
await requestOtc(email.trim())
await requestOtc(email.trim(), { turnstileToken })
// v0.12.0: the token is single-use; drop it so a re-request
// from the code step (via "Use a different email" back to
// email) starts with a fresh challenge.
setTurnstileToken(null)
setStep('code')
setStatus('Check your inbox — a six-digit code is on the way.')
}
} catch (err) {
// Any failure consumes the token from CloudFlare's side; clear
// so the widget re-renders a fresh challenge on retry.
setTurnstileToken(null)
if (err.status === 429) {
setStatus('Slow down — wait a minute before requesting another code.')
} else if (err.status === 400) {
setStatus("Couldn't verify you're human. Please retry the challenge.")
} else {
setStatus(err.message || 'Could not start sign-in. Try again.')
}
@@ -174,6 +212,10 @@ export default function Login() {
setStatus('')
try {
await verifyPasscode(email.trim(), passcode.trim(), { trustDevice })
// v0.15.0 analytics: passcode is the second of three
// sign-in methods. trust-device gets credited separately when
// the cookie-driven path fires above.
track(EVENTS.USER_SIGNED_IN, { method: 'passcode' })
// Reload so App.jsx's getMe() picks up the fresh session. A
// returning passcode user is by definition already past the
// §6.1 capture step (they couldn't have set a passcode while
@@ -186,17 +228,29 @@ export default function Login() {
// fresh code in the user's inbox immediately.
setPasscode('')
try {
await requestOtc(email.trim())
// v0.12.0: pass whatever token the widget on the passcode
// step has produced. If the operator has Turnstile required
// and the user hasn't solved the passcode-step widget, the
// backend refuses and we bounce them back to email-entry
// with a clear status (see catch below).
await requestOtc(email.trim(), { turnstileToken })
setTurnstileToken(null)
setStep('code')
setStatus(
'Too many failed attempts. We sent a one-time code to your email — use it to sign in.',
)
} catch (e2) {
setTurnstileToken(null)
if (e2.status === 429) {
setStep('code')
setStatus(
'Too many failed attempts. Wait a minute, then request a one-time code to sign in.',
)
} else if (e2.status === 400) {
setStep('email')
setStatus(
'Too many failed attempts. Solve the challenge below to receive a one-time code.',
)
} else {
setStatus(
'Too many failed attempts. Use the "Use a code instead" link to sign in via email.',
@@ -220,6 +274,11 @@ export default function Login() {
setStatus('')
try {
await verifyOtc(email.trim(), code.trim(), { trustDevice })
// v0.15.0 analytics: OTC is the third sign-in method.
// We fire it here regardless of whether the user then lands
// in capture-profile or offer-passcode sign-in has happened
// server-side either way.
track(EVENTS.USER_SIGNED_IN, { method: 'otc' })
// OTC verified the server has signed in the user. Fetch the
// canonical /api/auth/me to decide where to land:
// * needs_profile §6.1 capture (then /beta-pending).
@@ -276,6 +335,11 @@ export default function Login() {
last_name: ln,
beta_request_reason: why,
})
// v0.15.0 analytics: a successful capture-profile submit is
// the moment a beta-access request lands. No PII in the event
// body (no name, no reason text); the count + timestamp is
// what the funnel needs.
track(EVENTS.BETA_ACCESS_REQUESTED)
// Hard-load so App.jsx re-fetches /api/auth/me and picks up
// the captured fields. The user stays permission_state='pending'
// until an admin grants access the next thing they should
@@ -344,17 +408,27 @@ export default function Login() {
async function fallbackToOtc() {
// Manual "Use a code instead" from the passcode step. Same shape
// as the email-step OTC dispatch.
// as the email-step OTC dispatch. v0.12.0: pass through whatever
// Turnstile token the passcode-step widget has produced (or null
// when the widget is disabled at build time).
setBusy(true)
setStatus('')
try {
await requestOtc(email.trim())
await requestOtc(email.trim(), { turnstileToken })
setTurnstileToken(null)
setPasscode('')
setStep('code')
setStatus('Check your inbox — a six-digit code is on the way.')
} catch (err) {
setTurnstileToken(null)
if (err.status === 429) {
setStatus('Slow down — wait a minute before requesting another code.')
} else if (err.status === 400) {
// v0.12.0: the widget rejected or no token was sent. Bounce
// the user back to the email step so they get a fresh
// challenge alongside the email input.
setStep('email')
setStatus("Couldn't verify you're human. Please retry the challenge.")
} else {
setStatus(err.message || 'Could not request a code. Try again.')
}
@@ -387,7 +461,18 @@ export default function Login() {
required
disabled={busy}
/>
<button type="submit" disabled={busy || !email.trim()}>
{/*
v0.12.0: CloudFlare Turnstile widget. Renders nothing
when VITE_TURNSTILE_SITE_KEY is unset (and turnstileReady
defaults to true in that case so the submit gate doesn't
lock up). On every challenge the widget calls onToken
with the fresh token; we feed it to /auth/otc/request.
*/}
<TurnstileWidget onToken={setTurnstileToken} />
<button
type="submit"
disabled={busy || !email.trim() || !turnstileReady}
>
{busy ? 'Checking…' : 'Continue'}
</button>
</form>
@@ -421,6 +506,17 @@ export default function Login() {
/>
<span>Trust this device for 30 days</span>
</label>
{/*
v0.12.0: a second Turnstile widget for the
"Use a code instead" fallback dispatch. The passcode
verify path does not consume a Turnstile token (it has
its own 5-attempt lockout shape from v0.10.0), but if
the user falls back to OTC the same /auth/otc/request
endpoint runs and needs a token. We render the widget
on this step too so the fallback works without bouncing
back to email-entry first.
*/}
<TurnstileWidget onToken={setTurnstileToken} />
<div className="otc-actions">
<button type="submit" disabled={busy || !passcode.trim()}>
{busy ? 'Signing in…' : 'Sign in'}
@@ -429,7 +525,7 @@ export default function Login() {
type="button"
className="btn-link-quiet"
onClick={fallbackToOtc}
disabled={busy}
disabled={busy || !turnstileReady}
>
Use a code instead
</button>
+2 -1
View File
@@ -21,6 +21,7 @@
import { useEffect, useRef, useState, useCallback } from 'react'
import { Marked } from 'marked'
import { sanitizeHtml } from '../lib/sanitizeHtml'
import { decorateAcceptedChanges } from './trackedOverlay.js'
import ChangeTooltip from './ChangeTooltip.jsx'
@@ -98,7 +99,7 @@ export default function MarkdownPreview({
// synchronously with the body itself no flash of un-decorated text.
useEffect(() => {
if (!hostRef.current) return
const html = previewMarked.parse(content || '')
const html = sanitizeHtml(previewMarked.parse(content || ''))
hostRef.current.innerHTML = html
const token = ++renderTokenRef.current
// Reset memo so the new block set re-renders from scratch.
+25 -1
View File
@@ -10,10 +10,13 @@
import { useEffect, useState } from 'react'
import { draftPRText, openPR } from '../api'
import { EVENTS, track } from '../lib/analytics'
export default function PRModal({ slug, branch, branchIsPrivate, onClose, onOpened }) {
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
// #26: optional ground-truth use case for this change.
const [useCase, setUseCase] = useState('')
const [drafting, setDrafting] = useState(true)
const [submitting, setSubmitting] = useState(false)
const [confirmed, setConfirmed] = useState(!branchIsPrivate)
@@ -38,7 +41,14 @@ export default function PRModal({ slug, branch, branchIsPrivate, onClose, onOpen
setSubmitting(true)
setError(null)
try {
const { pr_number } = await openPR(slug, branch, { title: title.trim(), description: description.trim() })
const { pr_number } = await openPR(slug, branch, {
title: title.trim(),
description: description.trim(),
proposedUseCase: useCase.trim() || null,
})
// v0.15.0 analytics: fire on §10.2 PR-open success. slug
// and pr_number are the join keys; title/description stay out.
track(EVENTS.PR_OPENED, { rfc_slug: slug, pr_number })
onOpened?.(pr_number)
} catch (e) {
setError(e.message)
@@ -100,6 +110,20 @@ export default function PRModal({ slug, branch, branchIsPrivate, onClose, onOpen
what was argued, what shifted, what the arbiters are asked
to consider.
</p>
<label className="modal-label">What will you be using this change for? (optional)</label>
<textarea
className="modal-textarea"
value={useCase}
onChange={e => setUseCase(e.target.value)}
placeholder="The concrete thing this change unlocks for you. Optional."
disabled={drafting || submitting}
rows={3}
maxLength={8000}
/>
<p className="field-help">
#26: the concrete ground-truth use case distinct from "why
it's needed" above. Leave blank if you'd rather not say.
</p>
{error && <p className="field-error">{error}</p>}
</div>
<div className="modal-actions">
+23 -2
View File
@@ -22,6 +22,8 @@ import {
startResolutionBranch,
withdrawPR,
} from '../api'
import { EVENTS, track } from '../lib/analytics'
import LinkedText from './LinkedText'
export default function PRView({ viewer }) {
const { slug, prNumber: prNumberParam } = useParams()
@@ -135,6 +137,10 @@ export default function PRView({ viewer }) {
anchorPayload: reviewDraft?.anchorPayload || {},
quote: reviewDraft?.quote || null,
})
// v0.15.0 analytics: fire on §10.4 review-comment success.
// surface=pr distinguishes this from RFC discussion comments.
// No body text or quote material in the event.
track(EVENTS.COMMENT_POSTED, { rfc_slug: slug, pr_number: prNumber, surface: 'pr' })
setReviewText('')
setReviewDraft(null)
await refresh()
@@ -213,8 +219,21 @@ export default function PRView({ viewer }) {
<>
<h1 className="pr-title">{pr.title}</h1>
{pr.description && (
<p className="pr-description">{pr.description}</p>
<p className="pr-description">
<LinkedText segments={pr.description_segments} text={pr.description} viewer={viewer} canCreate={viewer?.permission_state === 'granted'} />
</p>
)}
{/* #26: the optional ground-truth use case for this change,
captured when the PR was opened. Muted "left blank"
treatment when none was supplied. */}
<div className="pr-use-case" style={{ margin: '6px 0', fontSize: 13 }}>
<span style={{ fontWeight: 700, color: '#888', textTransform: 'uppercase', letterSpacing: '0.05em', fontSize: 11 }}>
Intended use case:
</span>{' '}
{pr.proposed_use_case
? <span style={{ whiteSpace: 'pre-wrap' }}>{pr.proposed_use_case}</span>
: <span style={{ color: '#999', fontStyle: 'italic' }}>left blank</span>}
</div>
{pr.capabilities?.can_edit_text && (
<button className="btn-link" onClick={startHeaderEdit}>Edit title & description</button>
)}
@@ -424,7 +443,9 @@ function PRConversation({ threads, messagesByThread, threadsByKind, seenMsgId })
{isNew && <span className="chat-msg-new-pip" title="New since your last visit"></span>}
</div>
{m.quote && <pre className="chat-msg-quote">{m.quote}</pre>}
<div className="chat-msg-body">{m.text}</div>
<div className="chat-msg-body">
<LinkedText segments={m.text_segments} text={m.text} viewer={viewer} canCreate={viewer?.permission_state === 'granted'} />
</div>
</li>
)
})}
+10 -2
View File
@@ -10,7 +10,7 @@
import { useEffect, useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { marked } from 'marked'
import { renderMarkdown } from '../lib/sanitizeHtml'
import { getProposal, mergeProposal, declineProposal, withdrawProposal } from '../api'
export default function ProposalView({ viewer, onChange }) {
@@ -161,8 +161,16 @@ export default function ProposalView({ viewer, onChange }) {
</h3>
<div
className="entry-body"
dangerouslySetInnerHTML={{ __html: marked.parse(data.entry?.body || '') }}
dangerouslySetInnerHTML={{ __html: renderMarkdown(data.entry?.body || '') }}
/>
{/* #26: the optional ground-truth use case the proposer supplied. */}
<h3 style={{ fontSize: 13, fontWeight: 700, color: '#888', textTransform: 'uppercase', letterSpacing: '0.05em', marginTop: 24 }}>
Intended use case
</h3>
{data.proposed_use_case
? <div className="entry-body" dangerouslySetInnerHTML={{ __html: renderMarkdown(data.proposed_use_case) }} />
: <p style={{ color: '#999', fontStyle: 'italic' }}>Left blank by the proposer.</p>}
</article>
)
}
+110 -7
View File
@@ -2,15 +2,23 @@
//
// Title (required) and pitch (required textarea), with a slug field
// that auto-fills from the title via the same deterministic kebab-case
// the backend uses. Tags are chip-input (free-form for slice 1; the
// AI-suggested chips of §9.1 are deferred to Slice 2 when the AI surface
// is wired up).
// the backend uses. Tags are chip-input (free-form), with the §9.1
// Slice 2 AI-suggested chips (roadmap #27) wired in: as the draft fills
// in, the backend asks Claude Haiku for tags drawn from the corpus's
// existing tag set, surfaced as clickable suggestion chips. The assist
// is best-effort it stays silent when unavailable.
//
// The submit button drives the §17 POST /api/rfcs/propose endpoint;
// success navigates the proposer to the pending-idea view per §9.3.
import { useEffect, useState } from 'react'
import { proposeRFC } from '../api'
import { useEffect, useRef, useState } from 'react'
import { proposeRFC, suggestTags } from '../api'
import { EVENTS, track } from '../lib/analytics'
// How long the draft must sit unchanged before we ask for suggestions
// long enough to fire on typing pauses / field-blur, not on every
// keystroke (the backend is also per-user rate-limited as a backstop).
const SUGGEST_DEBOUNCE_MS = 700
function slugify(title) {
return title
@@ -20,26 +28,64 @@ function slugify(title) {
.replace(/^-+|-+$/g, '')
}
export default function ProposeModal({ viewer, onClose, onSubmitted }) {
const [title, setTitle] = useState('')
export default function ProposeModal({ viewer, onClose, onSubmitted, initialTitle = '' }) {
// #28 Part 2: a "create RFC for '<term>'" affordance pre-fills the title
// (App passes the `?propose=<term>` value here); the slug derives from it
// via the same effect that drives manual typing.
const [title, setTitle] = useState(initialTitle)
const [slug, setSlug] = useState('')
const [slugEdited, setSlugEdited] = useState(false)
const [pitch, setPitch] = useState('')
// #26: optional ground-truth use case, sibling to the required pitch.
const [useCase, setUseCase] = useState('')
const [tagInput, setTagInput] = useState('')
const [tags, setTags] = useState([])
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState(null)
// #27: Claude Haiku tag suggestions. `suggestions` is the latest
// ranked list from the backend ({ tag, confidence }); we render the
// subset not already chosen. `suggestedOnce` gates the disclosure +
// row so they only appear after the assist has actually run.
const [suggestions, setSuggestions] = useState([])
const [suggestedOnce, setSuggestedOnce] = useState(false)
useEffect(() => {
if (!slugEdited) setSlug(slugify(title))
}, [title, slugEdited])
// #27: debounced tag-suggestion fetch. Fires after the draft sits
// unchanged for SUGGEST_DEBOUNCE_MS, only once there's something to go
// on (a title). A stale-response guard keeps an earlier in-flight
// request from clobbering a newer one.
const suggestSeq = useRef(0)
useEffect(() => {
if (!title.trim()) {
setSuggestions([])
return
}
const handle = setTimeout(async () => {
const seq = ++suggestSeq.current
const result = await suggestTags({ title, pitch, useCase })
if (seq !== suggestSeq.current) return // a newer request superseded us
setSuggestions(Array.isArray(result) ? result : [])
if (result && result.length) setSuggestedOnce(true)
}, SUGGEST_DEBOUNCE_MS)
return () => clearTimeout(handle)
}, [title, pitch, useCase])
// Suggestions the user hasn't already added.
const freshSuggestions = suggestions.filter(s => !tags.includes(s.tag))
function addTag() {
const t = tagInput.trim()
if (t && !tags.includes(t)) setTags([...tags, t])
setTagInput('')
}
function addSuggested(tag) {
if (!tags.includes(tag)) setTags([...tags, tag])
}
async function handleSubmit(e) {
e.preventDefault()
if (!title.trim() || !slug || !pitch.trim()) return
@@ -51,7 +97,12 @@ export default function ProposeModal({ viewer, onClose, onSubmitted }) {
slug,
pitch: pitch.trim(),
tags,
proposedUseCase: useCase.trim() || null,
})
// v0.15.0 analytics: fire on the §9.1 propose-RFC submit.
// Slug is a stable, low-cardinality identifier (kebab-case
// ascii); title and pitch stay out of the event body.
track(EVENTS.RFC_PROPOSED, { rfc_slug: slug })
onSubmitted?.(result)
} catch (err) {
setError(err.message || 'Submission failed.')
@@ -100,6 +151,19 @@ export default function ProposeModal({ viewer, onClose, onSubmitted }) {
required
/>
<label htmlFor="propose-use-case">What will you be using this RFC for? (optional)</label>
<textarea
id="propose-use-case"
value={useCase}
onChange={e => setUseCase(e.target.value)}
placeholder="The concrete thing you intend to build or do with this RFC. Optional, but it helps ground the work."
rows={3}
/>
<p className="field-help">
The concrete ground-truth use case distinct from "why it's
needed" above. Leave blank if you'd rather not say.
</p>
<label htmlFor="propose-tag">Tags (optional)</label>
<div style={{ display: 'flex', gap: 6, alignItems: 'center', marginBottom: 4 }}>
<input
@@ -131,6 +195,45 @@ export default function ProposeModal({ viewer, onClose, onSubmitted }) {
</div>
)}
{/* #27: Claude Haiku suggested tags. Clickable chips that add
to the tag list; nothing auto-applies. The disclosure is
required the draft text is sent to Anthropic to generate
these so it renders whenever the suggestion row does. */}
{(freshSuggestions.length > 0 || (suggestedOnce && suggestions.length > 0)) && (
<div style={{ marginTop: 4, marginBottom: 14 }}>
{freshSuggestions.length > 0 && (
<>
<p className="field-help" style={{ marginTop: 0, marginBottom: 4 }}>
Suggested tags click to add:
</p>
<div>
{freshSuggestions.map(s => (
<button
key={s.tag}
type="button"
className="entry-tag"
onClick={() => addSuggested(s.tag)}
title={`Add "${s.tag}"`}
style={{
display: 'inline-block',
marginRight: 4,
marginBottom: 4,
border: '1px dashed var(--color-border, #ccc)',
background: 'none',
cursor: 'pointer',
}}
>+ {s.tag}</button>
))}
</div>
</>
)}
<p className="field-help" style={{ marginTop: 4, marginBottom: 0, fontStyle: 'italic' }}>
Suggestions are generated by Claude (Anthropic). The text you've
entered above is sent to Anthropic to produce them.
</p>
</div>
)}
{viewer && (
<p className="field-help" style={{ marginTop: 14, marginBottom: 0 }}>
Owner: <strong>{viewer.display_name || viewer.gitea_login}</strong> you'll be the first owner of this super-draft. Additional owners can claim later (§13.1).
+11 -3
View File
@@ -18,6 +18,8 @@ import {
postDiscussionMessage,
resolveDiscussionThread,
} from '../api'
import { EVENTS, track } from '../lib/analytics'
import LinkedText from './LinkedText'
export default function RFCDiscussionPanel({ slug, viewer }) {
const [threads, setThreads] = useState([])
@@ -100,6 +102,10 @@ export default function RFCDiscussionPanel({ slug, viewer }) {
void message_id
}
setComposer('')
// v0.15.0 analytics: fire on a successful discussion post.
// surface=discussion distinguishes this from PR review comments
// which fire from PRView with surface=pr. No body text.
track(EVENTS.COMMENT_POSTED, { rfc_slug: slug, surface: 'discussion' })
} catch (err) {
setError(err.message)
} finally {
@@ -185,7 +191,7 @@ export default function RFCDiscussionPanel({ slug, viewer }) {
</div>
)}
{activeMessages.map(msg => (
<DiscussionMessage key={msg.id} message={msg} />
<DiscussionMessage key={msg.id} message={msg} viewer={viewer} />
))}
<div ref={bottomRef} />
</div>
@@ -252,7 +258,7 @@ export default function RFCDiscussionPanel({ slug, viewer }) {
)
}
function DiscussionMessage({ message }) {
function DiscussionMessage({ message, viewer }) {
const isSystem = message.role === 'system'
if (isSystem) {
return (
@@ -274,7 +280,9 @@ function DiscussionMessage({ message }) {
{message.quote && (
<div className="discussion-message-quote">"{message.quote}"</div>
)}
<div className="discussion-message-body">{message.text}</div>
<div className="discussion-message-body">
<LinkedText segments={message.text_segments} text={message.text} viewer={viewer} canCreate={viewer?.permission_state === 'granted'} />
</div>
</div>
)
}

Some files were not shown because too many files have changed in this diff Show More