Compare commits

...

43 Commits

Author SHA1 Message Date
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
95 changed files with 16133 additions and 885 deletions
+1143
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.
+510
View File
@@ -714,6 +714,47 @@ The lighter half ships the structural shape — frontmatter, consent,
resolution, revocation. The heavier half ships the runtime
hardening.
### 6.8 Sign-in state resume (roadmap item #29, v0.23.0)
Signing in lands the user back on their most recently-viewed app
state instead of always on the empty-state home view. The model is
**per-user**, not per-device — the safe default the roadmap calls
for: a sign-in on any device resumes the most-recently-recorded
route. A per-device split and a profile-settings toggle UI are
follow-ups; v0.23.0 ships the column-level opt-out flag
(`resume_enabled`, default on) and the default-on behavior.
**Storage.** A single row per user in `user_session_state`
(migration 022): `user_id` (PK, FK → `users.id`, cascade-delete),
`last_route` (TEXT, the frontend pathname), `last_route_state`
(TEXT, JSON-encoded — SQLite has no native JSONB, so JSON-as-TEXT
matches how the app stores every other JSON blob), `resume_enabled`
(INTEGER, default 1), and `last_updated_at`.
**Wiring.** A debounced (~1s) frontend route-change hook posts the
current route to `PUT /api/me/last-state` for authenticated users
(anonymous: no-op). The stored `last_route` is folded onto the
existing `/api/auth/me` payload (no extra round-trip); on sign-in,
after the §21-Part-C Amplitude `identify` fires, the frontend
`navigate()`s to it. The identify-then-redirect ordering is
preserved: the redirect is gated on identify having fired.
**Stale state.** If the stored route is an RFC since withdrawn or
one the user lost rights to read, the redirect is a graceful no-op:
`navigate(last_route)` lands on whatever that route renders today,
and the existing routing already falls through to the
catalog/empty-state for a missing/unreadable RFC. No special-casing
on the server.
**Privacy (binding).** The stored state is **route + light view
state ONLY** — scroll anchors, open-tab selection, filter chips, and
the like. It **MUST NOT** carry draft-buffer contents, PR/comment
draft text, or any user-typed content. The frontend never sends such
content; the `last_route_state` column comment in migration 022 and
this paragraph are the contract. Resume state is purposely cheap to
discard: a deliberate "clear" (or `resume_enabled = 0`) drops the
user back to today's empty-state behavior.
---
## 7. The left pane
@@ -2284,6 +2325,13 @@ a given signal, the **storage shape** that makes triage tractable, and
the **out-of-session channels** (email, digest) that let asynchrony
actually work.
(The framework's separate **analytics + session-replay** surface —
Amplitude wiring, event taxonomy, identity lifecycle, consent
contract — is a peer cross-cutting concern specified in §21.
Notifications cover in-product signal-of-others-acting-on-your-work;
analytics covers observability of how the product is used. The two
surfaces do not overlap.)
### 15.1 The signal-surface stack
Five surfaces, each with one narrow job:
@@ -4300,3 +4348,465 @@ Downstream deployments, in exchange for the contract above, commit to:
order;
- supply every required env var the framework documents at the
version they are running.
---
## 21. Analytics instrumentation and identity
The framework ships an Amplitude Analytics + Session Replay wrapper
in v0.15.0 (`frontend/src/lib/analytics.js`), gated by the v0.13.0
cookie/privacy consent surface (`frontend/src/lib/consent.js`,
§14.5). This section codifies the conventions that keep the
instrumentation **quality** healthy as features land — taxonomy
shape, autocapture hygiene, replay masking, the consent contract,
and the identity lifecycle. The conventions are framework-neutral:
every deployment of rfc-app that turns on the wrapper inherits
them.
This chapter is placed semantically after §15 (Notifications) and
§16 (deliberately deferred) as a peer cross-cutting framework
concern. It was added after §20 in the chapter sequence to avoid
renumbering the deferred-decisions surface §19.2, which is a
load-bearing project noun referenced across CLAUDE.md, transcripts,
and prior commits.
### 21.1 Event-taxonomy conventions
Events live in the public `EVENTS` constant in
`frontend/src/lib/analytics.js`. Callers **SHOULD** use one of the
named constants rather than firing arbitrary event strings — that
keeps the Amplitude dashboard coherent over time and makes the
taxonomy reviewable as a single source of truth.
- **Name form: Title Case, "Subject Verb".** E.g.
`Comment Posted`, `Invitation Sent`, `RFC Viewed`,
`User Signed In`, `Admin Permission Decision`. Spaces between
words, no punctuation, no leading verbs (use `RFC Proposed`,
not `Propose RFC`). The strings match the Amplitude dashboard
names exactly.
- **Stability.** New events **SHOULD** land via a release, not
ad-hoc — adding an entry to `EVENTS` is a CHANGELOG-worthy
change because it widens the framework's observable surface
(§20.3). Renaming an event after it has shipped breaks the
dashboard's historical continuity; renames **SHOULD** be
treated as a deprecation cycle (ship both, dashboard-migrate,
drop the old one).
- **Opaque ids only in prop values.** Properties **MUST NOT**
carry PII — no email, no display name, no IP, no free-text
field bodies (titles, comment text, RFC drafts). Properties
**SHOULD** be limited to:
- opaque ids: `rfc_slug`, `rfc_id`, `pr_number`,
`target_user_id`, `invited_by_admin_id`, `thread_id`,
`comment_id`;
- enums (lowercased): `method: 'otc' | 'passcode' |
'device-trust' | 'admin-invite' | 'rfc-invite'`;
- booleans: `trust_device`, `needs_passcode`, `passcode_set`;
- timestamps (ISO 8601);
- small bounded integers: `custom_message_chars` (coarse-grained
signal of admin effort, NOT the message text itself).
- **Casing consistency.** Prop keys use `snake_case` (matches the
backend's JSON shape). Enum values use lowercase with hyphens
(`'rfc-invite'`, not `'rfcInvite'` or `'RFC_INVITE'`). Drift
here ruins dashboard aggregation; the operator-side audit
(§21.7 / `ohm-rfc/ROADMAP.md` #21 Part A) checks for it.
The starting taxonomy as of v0.17.0:
```
PAGE_VIEWED: 'Page Viewed'
RFC_VIEWED: 'RFC Viewed'
USER_SIGNED_IN: 'User Signed In'
USER_SIGNED_OUT: 'User Signed Out'
RFC_PROPOSED: 'RFC Proposed'
PR_OPENED: 'PR Opened'
COMMENT_POSTED: 'Comment Posted'
BETA_ACCESS_REQUESTED: 'Beta Access Requested'
ADMIN_PERMISSION_DECISION: 'Admin Permission Decision'
INVITATION_SENT: 'Invitation Sent' # v0.16.0 / #12
INVITATION_ACCEPTED: 'Invitation Accepted' # v0.16.0 / #12
USER_INVITED: 'User Invited' # v0.17.0 / #16
INVITE_CLAIMED: 'Invite Claimed' # v0.17.0 / #16
```
### 21.2 Required prop families per event kind
Each event family carries a small required prop set. These are
load-bearing for the dashboard's cohort analysis; releases that
add a new event in an existing family **SHOULD** carry the
family's required props.
- **Navigation events** (`Page Viewed`, `RFC Viewed`): carry
`path` (string, pathname only — never the query string if it
could carry a token) for `Page Viewed`; carry `rfc_slug` for
`RFC Viewed`. `rfc_id` **MAY** be added when the cached row is
in hand.
- **Auth-state events** (`User Signed In`, `User Signed Out`):
`User Signed In` carries `method` (one of `'otc'`,
`'passcode'`, `'device-trust'`, `'admin-invite'`). `User
Signed Out` carries no props (the identity binding is cleared
separately via `anonymize()`).
- **Authored-action events** (`RFC Proposed`, `PR Opened`,
`Comment Posted`): carry `rfc_slug`. PRs additionally carry
`pr_number` once the row exists. Comments additionally carry
`thread_id`. None carry the body text.
- **Admin-action events** (`Beta Access Requested`,
`Admin Permission Decision`): the latter carries `action`
(lowercase: `'grant'` / `'revoke'`) and `target_user_id`.
- **Invite-side events** (`Invitation Sent`, `User Invited`): fire
from the inviter's signed-in session. `Invitation Sent` (per-RFC,
#12) carries `rfc_slug` + `role_in_rfc`. `User Invited`
(admin-create, #16) carries `target_user_id` (the OHM user_id of
the just-provisioned user) + `initial_role` +
`custom_message_chars` (a bounded integer signal of admin
effort, never the message text). Per #21 Part C: when the
invitee is not yet a user (#12 per-RFC invitations to an email
address that has never signed in), the invite-side event **MAY**
carry a hashed `target_email` fingerprint (SHA-256 of the
normalized lower-cased email) so the invite + claim pair can be
correlated later. Plain-text `target_email` **MUST NOT** be
carried.
- **Claim-side events** (`Invitation Accepted`, `Invite Claimed`):
fire from the invitee's session, immediately after an
`identify({ user_id, properties })` call binds the OHM user_id
to the Amplitude record (see §21.6). The events carry the
invite context (`rfc_slug` + `role_in_rfc` for the per-RFC
shape; `invited_by_admin_id` + `initial_role` + `needs_passcode`
+ `trust_device` for the admin-create shape).
When in doubt, the principle: a property is correctly shaped iff
the operator could publish it in a session transcript without
hesitation.
### 21.3 Autocapture-friendly DOM patterns
The wrapper initializes Amplitude with `analytics.autocapture: true`,
which auto-instruments page views, clicks, and form interactions.
The *value* of those auto-captured events depends entirely on the
DOM the SDK observes. Releases that add interactive UI **SHOULD**
follow these patterns so the dashboard rows are readable rather
than rows like "Click on `<button>` at `:nth-child(7)`".
- **Stable visible text on interactive elements.** Buttons and
links **SHOULD** have stable, human-readable text content (the
same string Amplitude uses to label the row). Avoid generic
labels like "Read more" / "Click here" that lose context.
- **`aria-label` on icon-only buttons.** Icon-only buttons (the
chevron expanders, kebab menus, close `X`s) **MUST** carry a
meaningful `aria-label`. Default autocapture for an unlabeled
icon button reads as garbage. The `aria-label` is also an
accessibility requirement — the two goals align.
- **`data-amp-track-*` for repeating-list per-row identifiers.**
When a list renders many rows of the same shape (RFC rows,
comment rows, PR rows in a listing), per-row interactive elements
**SHOULD** carry a `data-amp-track-name` attribute that
identifies the row's *kind* and a `data-amp-track-*` attribute
carrying the row's stable id. The convention:
```html
<button
data-amp-track-name="RFC Row Expand"
data-amp-track-rfc-slug={slug}
>…</button>
```
This makes per-RFC click counts aggregate to the RFC rather
than to a generic label, and lets the dashboard answer "which
RFCs got the most engagement" rather than "how many buttons
were clicked."
- **`data-amp-track-suppress` for noise surfaces.** Crowded surfaces
(the admin user-listing post-v0.9.0, the RFC discussion panel
during heavy review) **MAY** apply
`data-amp-track-suppress` (or its current equivalent in the
SDK version in use) to elements whose clicks would flood the
dashboard without informing anything. Suppression is a
deliberate decision; document it inline.
### 21.4 Session-replay masking conventions
The wrapper initializes Amplitude with `sessionReplay.sampleRate: 1`
(100% of consented sessions are recorded for full-DOM playback —
vendor-recommended default for new Amplitude deployments). Replay
has a meaningfully larger privacy footprint than event counters,
and the masking discipline is binding.
- **Credentials MUST be masked.** The OTC code input, passcode
input, any password-type field, the Turnstile widget internals,
the magic-link-claim token if it survives in the URL bar
(browser history, screenshot windows) — these **MUST** be masked
with Amplitude's masking convention (the `.amp-mask` class or the
`data-amp-mask` attribute, whichever the wrapper's SDK version
uses; the wrapper's bootstrap comment names the current
convention). Confirm each masking attribute survives the
wrapper init by inspecting a recorded session before each
release that touches an auth input.
- **PII SHOULD be masked or carefully un-masked.** Email-entry
fields, real-name capture fields (the v0.8.0 first/last/why
panel), free-text RFC body drafts, comment-compose text —
each is arguably PII or near-PII. The per-field decision is
the release's responsibility; document the choice in `SPEC.md`
§21 (this section) and in the release CHANGELOG so future
deployments inherit the call rather than re-deciding.
- **Privacy-policy alignment.** The recorded data **MUST** match
what the deployment's privacy / cookies policy claims. If
reality is broader than the document promises, update the policy
text in the same release.
- **Selective redaction.** Amplitude supports field-level mask
classes that hide value while preserving DOM shape (so the
session is replayable but the value is not). Prefer this over
whole-form masking when only a subset is sensitive.
### 21.5 Consent-gate contract
The wrapper is bound to the v0.13.0 cookie/privacy consent surface
(`frontend/src/lib/consent.js`, §14.5). The binding is **load-
bearing**: the SDK and the session-replay recorder **MUST NOT**
load before consent is granted, and any consent revocation **MUST**
take effect within one tick of the consent flip.
The wrapper's bootstrap implements this contract; releases that
touch the analytics surface **MUST** preserve it.
- **Pre-consent: no init, no network, no recording.** If
`consent.analytics === true` is not currently true (either
because the user denied, or because the banner is up and no
decision has been recorded), the wrapper **MUST NOT** import
the Amplitude SDK, **MUST NOT** open any network request to
Amplitude, and **MUST NOT** start any session-replay recording.
The consent-gated lazy `import('@amplitude/unified')` is the
binding implementation; preserve it.
- **Denied → granted: init at the consent moment.** When consent
flips from denied/undecided to granted, the wrapper **MUST**
initialize the SDK at that moment (a new `initAll(KEY, …)` call
through the lazy-import path). Track and identify calls made
before init resolves **MUST** be queued and drained on init,
so the first signed-in user's first event is not dropped on
the cold-load race.
- **Granted → denied: setOptOut(true) within one tick.** When
consent flips from granted to denied mid-session, the wrapper
**MUST** call `amplitude.setOptOut(true)` so subsequent events
are dropped client-side and session replay stops recording.
The wrapper cannot unload the script tag (the SDK is already
in memory), but the SDK's contract for "drop subsequent events"
is `setOptOut(true)`. This **MUST** happen within one tick of
the consent flip (i.e. synchronously inside the
`onConsentChange` handler).
- **No silent re-grant.** A granted → denied → granted sequence
**MUST** call `setOptOut(false)` (re-enabling the SDK that was
paused) rather than firing a second `initAll` (which would
double-init). The wrapper's bootstrap implements this; releases
that touch the consent integration **MUST** preserve the
distinction.
- **Build-time vs. runtime.** The API key is read from
`import.meta.env.VITE_AMPLITUDE_API_KEY` at build time. When
the env var is unset, the wrapper **MUST** log one console
warning and no-op (every public function becomes a deterministic
no-op) so dev environments without an Amplitude account keep
working. The deploy gesture binds the key via the deployment's
overlay verb (for OHM-shape deployments, `flotilla overlay set`);
see §21.8 for the secret-vs-public discussion.
### 21.6 Identity lifecycle (per #21 Part C)
Amplitude's identity model has a specific pattern that the
framework follows verbatim. Every release that touches an
identity-meaningful surface **MUST** observe this pattern. The
pattern shipped inline across v0.15.0 / v0.16.0 / v0.17.0
(Session L's wave); this section codifies the contract so future
releases inherit it.
**On sign-in success** (`App.jsx`'s `me.user` resolution):
- The wrapper's `identify({ user_id, properties })` call **MUST**
carry both the OHM user_id (`amplitude.setUserId(<id>)`
internally) AND the user's durable property bag. `setUserId`
alone is **NOT** sufficient — without properties, the Amplitude
user record carries only the id, and cohort analysis loses the
shape (role distribution, sign-in-method distribution, etc.)
the dashboard depends on.
- Properties **MUST** be a bag of opaque ids, enums, booleans,
and timestamps — no PII (no email, no display name, no IP).
- Properties **MUST** be classified `set` vs `setOnce` deliberately
(see §21.6.1 below).
- The same `identify` call **MUST** be re-issued on every sign-in
(idempotent at Amplitude's side; cheap; corrects any drift in
the mutable property half).
**On user-state change mid-session** (role grant/revoke, trust-
device add, passcode set, beta-permission flip):
- The wrapper's `setUserProperties(properties)` call **MUST** fire
so the Amplitude record stays current. Mid-session state changes
**MUST NOT** wait for the next sign-in to surface — the dashboard
cohort an admin uses to grant permission is the same dashboard
that next sees the granted user's behavior; staleness here breaks
the cohort feedback loop.
**On sign-out**:
- The wrapper's `anonymize()` call (internally `amplitude.reset()`)
**MUST** fire. `reset` clears the device-id linking AND **MUST**
also clear the property cache so the next anonymous session is a
fresh slate (the wrapper's `anonymize()` does both — releases
that touch the wrapper **MUST** preserve this).
- The `User Signed Out` `track()` call **MUST** fire *before*
`anonymize()`, so the sign-out event is correctly attributed to
the signing-out user rather than to the post-reset anonymous
device.
**On invite-claim** (the v0.16.0 per-RFC invite + v0.17.0 admin-
create invite paths):
- The wrapper's `identify({ user_id, properties })` call **MUST**
fire BEFORE the first `track()` event on the claim surface, so
the Amplitude user record is created with the OHM user_id from
the first event. **MUST NOT** fire `track()` first and `identify`
later — that creates an anonymous device record that
retroactively links, and the cohort attribution for
invite-driven onboarding loses precision.
- The invite-context properties (`claim_method`,
`invited_by_admin_id`, `invited_at`, `initial_role`) are
`setOnce` (immutable user-history markers) — see §21.6.1.
**Inviter-side identification on invite-send events**:
- The inviter's `track('Invitation Sent', …)` and
`track('User Invited', …)` events fire from the inviter's
signed-in session, so the `user_id` attribution is already
correct (it's the inviter's id). The event body carries
`target_user_id` (#16 — admin-create, where the future user is
provisioned at create-time) or a hashed `target_email`
fingerprint (#12 — per-RFC invite, where the invitee is not
yet a user) so the invite + claim pair can be correlated later
in the dashboard.
#### 21.6.1 `set` vs `setOnce` taxonomy
Amplitude distinguishes two property-write semantics:
- **`set(k, v)`** — overwrites the property on every call. The
user record reflects the most recent value.
- **`setOnce(k, v)`** — writes only if the property is not
already present. Subsequent calls are no-ops. The user record
reflects the first value ever written.
The wrapper's `applyProperties` function accepts both: a bare
value uses `.set()`; a sentinel-wrapped value
`['__setOnce__', value]` uses `.setOnce()`. Releases that add new
user properties **MUST** classify each one explicitly, by the
following rule:
- A property whose value is **expected to change over the user's
lifetime** is `set`. Examples: `role` (can flip from
`contributor` to `owner`), `permission_state` (pending → granted),
`passcode_set` (false → true), `device_trusted` (changes per
active device). On each sign-in, the latest value is written;
the dashboard always sees current state.
- A property that is an **immutable historical marker** is
`setOnce`. Examples: `first_sign_in_at` (the timestamp of the
user's first observed sign-in — never re-write), `account_
created_at` (the timestamp of provisioning),
`invited_by_admin_id` (the admin who provisioned this user via
the v0.17.0 path — preserved even if the user is later
re-invited or has their role changed), `invited_at` (the
timestamp at which the invite was sent — distinct from
`claim_method` which is also setOnce because once claim_method
is `'admin-invite'`, that's the path this user took).
The classification is part of the release's contract — flipping a
property from `set` to `setOnce` (or vice versa) mid-life corrupts
the user record and **MUST** be avoided. If a property's
semantics genuinely change, retire the old key and introduce a new
one (same deprecation discipline as event renames in §21.1).
### 21.7 Cohort-shape implications (informative)
The conventions above are designed so that the Amplitude dashboard
can answer cohort questions the operator actually asks:
- *"How many users signed in via the admin-invite path in week N
vs. organic OTC?"* — uses `claim_method` (setOnce) on the user
record + `User Signed In` events with `method`.
- *"Of admin-invited users, what fraction set a passcode within
their first session?"* — uses `claim_method` + `passcode_set`
on the user record + `Page Viewed` events to define "session."
- *"Which RFCs have the most owner-invited contributors?"* — uses
per-RFC `Invitation Sent` / `Invitation Accepted` correlated
via `rfc_slug` + `role_in_rfc`.
- *"What's the gap between invite-send and invite-claim, broken
out by inviter?"* — uses `Invitation Sent` (inviter's session,
inviter `user_id`) + `Invitation Accepted` (invitee's session,
invitee `user_id` after the BEFORE-track identify) joined on
`rfc_slug` + inviter (the inviter's id is the same id on both
events because both invite and claim sides observe it).
The Part-A audit (per `ohm-rfc/ROADMAP.md` #21) confirms these
shapes against real data once a week of beta traffic is in. The
audit is a point-in-time pass; this chapter is the standing
discipline that keeps future work in shape.
### 21.8 Secret vs. public — overlay binding
The Amplitude browser API key (`VITE_AMPLITUDE_API_KEY`) is
**bundle-embedded by design**: it appears as a literal string in
the deployment's JavaScript bundle, visible to anyone with browser
dev tools. The vendor's installation prompt embeds it inline. This
puts it in the same category as Cloudflare Turnstile's site key
(`VITE_TURNSTILE_SITE_KEY`, v0.12.0) — public, not secret.
Deployments **MUST** bind such keys via their overlay verb (for
OHM-shape deployments, `flotilla overlay set`), not via the secret
binding. The matching secret half (the Cloudflare Turnstile
**secret** key, `CLOUDFLARE_TURNSTILE_SECRET`, used server-side
for siteverify) is a true secret bound via `flotilla secret set`.
This per-key distinction is the deployment's responsibility; the
framework's `*.env.example` files name the binding for each.
The binding rule baked in mid-Session-K is: **the operator's
secret bytes never enter the conversation with an assistant**,
even as one offered option. The conversation-layer corollary of
the build-pipeline §3-invariant-1 rule from
`ohm-rfc-app-flotilla/SPEC.md` is that sessions publish in full,
and a secret in a transcript is a leaked secret. The canonical
secret-set gesture for OHM is `pbpaste | flotilla secret set
<deployment> <SECRET_NAME>` (the value goes clipboard → stdin →
Secret Manager without ever appearing in shell history or the
model context). Non-OHM deployments inherit the same discipline
through their own deploy tooling.
### 21.9 §19.2 candidates surfaced by this chapter
- **Session-replay-specific consent category.** v0.13.0's cookie
banner has a single `analytics` toggle that gates both event
counters and full-DOM session replay. Recording has a larger
privacy footprint than counters; a separate consent category
for session replay is the cleaner shape. Captured here and in
`ohm-rfc/ROADMAP.md` #21 Part A.
- **Bundle-size budget for the analytics wrapper.** The
`@amplitude/unified` package adds ~150 KB gzipped (the session-
replay recorder is the bulk). The consent-gated lazy import
keeps the cost off the initial bundle for users who haven't
opted in; the post-consent init path has not been measured
for jank. Captured in #21 Part A.
- **Property-shape lint.** The conventions in §21.1 / §21.2 are
enforced today by review discipline. A small lint (CI grep
against `track(` / `identify(` callsites with a property-key
allowlist + a PII-name denylist) is a future affordance that
catches drift mechanically.
- **Hashed `target_email` derivation.** §21.2 names SHA-256 of
the normalized lower-cased email as the hashing function. The
framework does not currently expose a helper for this — a
small `frontend/src/lib/hash.js` or `backend/app/hash.py` that
centralizes the normalization + hash would make the contract
enforceable. Captured here.
### 21.10 Open question
The wrapper currently uses the Amplitude SDK's autocapture +
session-replay defaults. The v0.13.0 consent surface has a single
toggle for "analytics." Splitting the consent into "analytics"
vs "session replay" is a §19.2 candidate (above) but settling it
also requires a privacy-policy update and a re-prompt of
existing consenters. The cleanest moment to do this is the next
material privacy-policy revision; the conventions in §21.4 hold
in the interim.
+1 -1
View File
@@ -1 +1 @@
0.12.0
0.28.0
+12
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
+356 -1
View File
@@ -15,6 +15,7 @@ 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 (
@@ -22,12 +23,15 @@ from . import (
api_branches,
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 +39,7 @@ from . import (
notify,
philosophy,
providers as providers_mod,
tag_suggest,
)
from .bot import Bot
from .config import Config
@@ -47,6 +52,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 +79,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 +135,12 @@ def make_router(
# Contribution still requires a PR (api_prs above); this surface
# is for discussion that does not yet warrant a branch.
router.include_router(api_discussion.make_router())
# v0.16.0 (roadmap item #12): owner-only invite for per-RFC
# contribution + discussion. The RFC's owner can invite specific
# users by email to either open PRs or join the discussion; non-
# invited users keep read access but cannot write (v0.6.0
# contract extended to per-RFC scope).
router.include_router(api_invitations.make_router())
# ---------------------------------------------------------------
# §17: /api/health — unauthenticated post-flight probe.
@@ -136,6 +175,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 +378,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 +416,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 +489,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 +658,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 +707,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 +756,7 @@ def make_router(
"opened_at": row["opened_at"],
"entry": entry_payload,
"affordances": affordances,
"proposed_use_case": _proposal_use_case(pr_number),
}
# ---------------------------------------------------------------
@@ -525,8 +833,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,
+17
View File
@@ -279,6 +279,15 @@ def make_router(
@router.post("/api/rfcs/{slug}/branches/main/promote-to-branch")
async def promote_to_branch(slug: str, body: PromoteToBranchBody, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
# v0.16.0 (item #12): cutting a contribute branch is the
# PR-shaped write surface gate. A platform-granted user who is
# not invited as a per-RFC contributor cannot start work that
# only exists to land in a PR.
if not auth.can_contribute_to_rfc(viewer, slug):
raise HTTPException(
403,
"This RFC's owner has not invited you to contribute PRs",
)
rfc = _require_active_rfc(slug)
owner, repo = _repo_for(rfc)
new_branch = (body.branch_name or "").strip()
@@ -331,6 +340,14 @@ def make_router(
@router.post("/api/rfcs/{slug}/start-edit-branch")
async def start_edit_branch(slug: str, body: StartEditBranchBody, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
# v0.16.0 (item #12): same per-RFC contribute gate as
# promote-to-branch — kicking off a super-draft edit branch is
# also PR-shaped work.
if not auth.can_contribute_to_rfc(viewer, slug):
raise HTTPException(
403,
"This RFC's owner has not invited you to contribute PRs",
)
rfc = _require_super_draft(slug)
owner, repo = _repo_for(rfc)
new_branch = (body.branch_name or "").strip()
+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,
+575
View File
@@ -0,0 +1,575 @@
"""v0.16.0 / §6 / §10 — owner-only invite for per-RFC PR or PR-less
discussion (roadmap item #12).
The RFC's owner can invite a specific email to one of two per-RFC roles:
* `contributor` may open PRs against this RFC AND post in its
discussion (PR-permission strictly includes discussion-permission).
* `discussant` may post in this RFC's PR-less discussion only.
Non-invited users keep the v0.6.0 anonymous-read contract: they can
read but cannot write/discuss the RFC. Reads are not narrowed by
this item.
Endpoints:
* `POST /api/rfcs/{slug}/invitations` owner: create + email
* `GET /api/rfcs/{slug}/invitations` owner: list pending/accepted
* `POST /api/rfcs/{slug}/invitations/{id}/revoke` owner: revoke
* `GET /api/invitations/accept` token lookup (signed-in user)
* `POST /api/invitations/accept` token redeem (signed-in user)
The accept endpoints are deliberately platform-scoped (not nested under
the RFC slug) because the user clicking the email link only has the
token and may not even know the slug yet. The GET shape lets the
frontend show a confirmation page ("RFC <X> invited you to be a
<role> accept?") before the POST commits the membership.
Permission gates (composed with `require_contributor`):
* Issue / list / revoke: `auth.can_invite_to_rfc` RFC owner or
platform admin/owner.
* Accept: any platform-granted signed-in user; the gate is the
token, not the role. The token also constrains which email the
accept lands under the accepting user's email must match the
invitation's invitee_email (case-insensitive). This prevents an
invited-but-not-the-account-holder situation from minting a
collaborator row under the wrong identity.
Email shape: a single plain-text body sent via the existing SMTP path
(reuses `EmailConfig.from_env()` like `email_otc.py` does). No
unsubscribe footer the email is transactional and per-invite, not a
recurring notification. No tracking pixel.
Admin-page hook: when an accept lands and the user's
`permission_state` is still `pending`, that signals to the admin's
`/admin/users` queue that the user is here because they accepted a
per-RFC invitation informing (not deciding) the admin's
platform-grant call. v0.16.0 surfaces this via additive columns on
the existing `GET /api/admin/users` listing (see `api_admin.py`'s
diff in the same release) no new endpoint, no restructure.
"""
from __future__ import annotations
import logging
import secrets
import smtplib
from email.message import EmailMessage
from email.utils import formataddr
from typing import Any
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from . import auth, db
from .email import EmailConfig, _SENT
log = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Pydantic bodies
# ---------------------------------------------------------------------------
class CreateInvitationBody(BaseModel):
"""The owner picks an email and a role-in-RFC. No custom-message
field that belongs to item #16's platform-level invite surface,
not here.
We validate the email with a deliberately narrow pattern rather
than `pydantic.EmailStr` to avoid pulling in `email-validator` as
a dependency (and v0.7.0's OTC body does the same — see
`OTCRequestBody`'s shape). The validation here is intentionally
permissive: a local-part, an `@`, and a domain part with no
whitespace. Operator-side typo catching is the job of the email
transport; the framework only guards against obviously malformed
input."""
invitee_email: str = Field(min_length=3, max_length=320,
pattern=r"^[^\s@]+@[^\s@]+$")
role_in_rfc: str = Field(pattern="^(contributor|discussant)$")
class AcceptInvitationBody(BaseModel):
token: str = Field(min_length=1, max_length=200)
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
# 30-day TTL matches the device-trust window the framework already
# ships (v0.11.0). A pending invitation past this is rejected at the
# accept endpoint regardless of the row's `status` column.
INVITATION_TTL_DAYS = 30
# ---------------------------------------------------------------------------
# Router
# ---------------------------------------------------------------------------
def make_router() -> APIRouter:
router = APIRouter()
# ---------------------------------------------------------------
# POST /api/rfcs/<slug>/invitations
# The owner creates an invitation. The endpoint mints the token,
# writes the row, and dispatches the email synchronously. A failure
# to send the email does NOT roll back the row — the owner can
# share the link directly out-of-band if SMTP is briefly down (the
# `GET /api/rfcs/<slug>/invitations` response carries the token
# for that fallback).
# ---------------------------------------------------------------
@router.post("/api/rfcs/{slug}/invitations")
async def create_invitation(slug: str, body: CreateInvitationBody, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_rfc(slug)
if not auth.can_invite_to_rfc(viewer, slug):
raise HTTPException(
403,
"Only the RFC's owner can invite collaborators",
)
invitee_email = body.invitee_email.strip()
role_in_rfc = body.role_in_rfc
# Refuse re-inviting an email that already has a pending
# invitation on this RFC at the same role. Different-role
# re-invite is allowed (upgrade discussant → contributor)
# — the new row supersedes the old in the UI listing's
# natural ordering, and acceptance of either picks up the
# corresponding role.
existing = db.conn().execute(
"""
SELECT id FROM rfc_invitations
WHERE rfc_slug = ? AND invitee_email = ? COLLATE NOCASE
AND role_in_rfc = ? AND status = 'pending'
LIMIT 1
""",
(slug, invitee_email, role_in_rfc),
).fetchone()
if existing:
raise HTTPException(
409,
f"{invitee_email} already has a pending {role_in_rfc} invitation for this RFC",
)
token = _mint_token()
cur = db.conn().execute(
"""
INSERT INTO rfc_invitations
(rfc_slug, inviter_user_id, invitee_email, role_in_rfc,
token, expires_at)
VALUES (?, ?, ?, ?, ?, datetime('now', ?))
""",
(
slug,
viewer.user_id,
invitee_email,
role_in_rfc,
token,
f"+{INVITATION_TTL_DAYS} days",
),
)
invitation_id = cur.lastrowid
# Send the email — synchronous. A send failure logs and
# returns; the row stays so the owner can recover via the
# listing (which carries the token for an out-of-band share).
_send_invitation_email(
to_address=invitee_email,
inviter_display=viewer.display_name or viewer.gitea_login or "An RFC owner",
rfc_title=rfc["title"],
role_in_rfc=role_in_rfc,
token=token,
)
return {
"id": invitation_id,
"rfc_slug": slug,
"invitee_email": invitee_email,
"role_in_rfc": role_in_rfc,
"status": "pending",
"token": token,
}
# ---------------------------------------------------------------
# GET /api/rfcs/<slug>/invitations
# The owner's listing of every invitation on the RFC, regardless
# of status. Carries the token (for the resend / re-share path).
# ---------------------------------------------------------------
@router.get("/api/rfcs/{slug}/invitations")
async def list_invitations(slug: str, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
_require_rfc(slug)
if not auth.can_invite_to_rfc(viewer, slug):
raise HTTPException(
403,
"Only the RFC's owner can view invitations",
)
rows = db.conn().execute(
"""
SELECT i.id, i.invitee_email, i.role_in_rfc, i.status, i.token,
i.expires_at, i.created_at, i.accepted_at,
i.inviter_user_id, i.accepted_by_user_id,
u_inviter.display_name AS inviter_display,
u_inviter.gitea_login AS inviter_login,
u_accept.display_name AS accepted_by_display,
u_accept.gitea_login AS accepted_by_login
FROM rfc_invitations i
LEFT JOIN users u_inviter ON u_inviter.id = i.inviter_user_id
LEFT JOIN users u_accept ON u_accept.id = i.accepted_by_user_id
WHERE i.rfc_slug = ?
ORDER BY i.id DESC
""",
(slug,),
).fetchall()
return {
"items": [
{
"id": r["id"],
"invitee_email": r["invitee_email"],
"role_in_rfc": r["role_in_rfc"],
"status": _effective_status(r),
"token": r["token"],
"expires_at": r["expires_at"],
"created_at": r["created_at"],
"accepted_at": r["accepted_at"],
"inviter_display": r["inviter_display"],
"inviter_login": r["inviter_login"],
"accepted_by_display": r["accepted_by_display"],
"accepted_by_login": r["accepted_by_login"],
}
for r in rows
],
}
# ---------------------------------------------------------------
# POST /api/rfcs/<slug>/invitations/<id>/revoke
# Revokes a pending invitation. Already-accepted invitations
# cannot be "revoked" from this surface — the corresponding
# collaborator-removal surface is a §19.2 candidate; v0.16.0
# only lifts the *pending* link.
# ---------------------------------------------------------------
@router.post("/api/rfcs/{slug}/invitations/{invitation_id}/revoke")
async def revoke_invitation(slug: str, invitation_id: int, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
_require_rfc(slug)
if not auth.can_invite_to_rfc(viewer, slug):
raise HTTPException(
403,
"Only the RFC's owner can revoke invitations",
)
row = db.conn().execute(
"SELECT id, status FROM rfc_invitations WHERE id = ? AND rfc_slug = ?",
(invitation_id, slug),
).fetchone()
if row is None:
raise HTTPException(404, "Invitation not found")
if row["status"] != "pending":
raise HTTPException(
409,
f"Invitation is {row['status']}; only pending invitations can be revoked",
)
db.conn().execute(
"UPDATE rfc_invitations SET status = 'revoked' WHERE id = ?",
(invitation_id,),
)
return {"ok": True, "id": invitation_id, "status": "revoked"}
# ---------------------------------------------------------------
# GET /api/invitations/accept?token=...
# Lookup-only — returns what the invitation grants so the
# frontend can render a confirmation page before the POST. The
# token is required; no token, no peek.
# ---------------------------------------------------------------
@router.get("/api/invitations/accept")
async def preview_invitation(token: str, request: Request) -> dict[str, Any]:
viewer = auth.require_user(request)
row = _lookup_invitation_by_token(token)
if row is None:
raise HTTPException(404, "Invitation not found")
effective = _effective_status(row)
rfc = db.conn().execute(
"SELECT slug, title FROM cached_rfcs WHERE slug = ?", (row["rfc_slug"],),
).fetchone()
return {
"rfc_slug": row["rfc_slug"],
"rfc_title": rfc["title"] if rfc else row["rfc_slug"],
"role_in_rfc": row["role_in_rfc"],
"status": effective,
"invitee_email": row["invitee_email"],
"email_matches_you": (viewer.email or "").strip().lower()
== row["invitee_email"].strip().lower(),
"expires_at": row["expires_at"],
}
# ---------------------------------------------------------------
# POST /api/invitations/accept
# The accept gesture: token → collaborator row.
#
# Requires:
# * an authenticated user (no token-only acceptance — we want
# the per-user audit trail),
# * a valid (pending, non-expired, non-revoked) invitation,
# * the accepting user's email matches invitee_email
# (case-insensitive).
#
# On success the row's status flips to 'accepted' and a
# rfc_collaborators row is inserted (or upgraded if the user
# already had a lower role). Idempotent: re-accepting the same
# already-accepted invitation reads as a 200 no-op with
# `changed=false`.
# ---------------------------------------------------------------
@router.post("/api/invitations/accept")
async def accept_invitation(body: AcceptInvitationBody, request: Request) -> dict[str, Any]:
viewer = auth.require_user(request)
row = _lookup_invitation_by_token(body.token)
if row is None:
raise HTTPException(404, "Invitation not found")
effective = _effective_status(row)
if effective == "revoked":
raise HTTPException(409, "Invitation was revoked")
if effective == "expired":
raise HTTPException(409, "Invitation has expired")
# Email match — case-insensitive. Empty viewer email cannot
# accept (an OAuth-only user with no captured email shape).
viewer_email = (viewer.email or "").strip().lower()
invitee_email = row["invitee_email"].strip().lower()
if not viewer_email or viewer_email != invitee_email:
raise HTTPException(
403,
"This invitation was sent to a different email; sign in with that address",
)
if effective == "accepted":
# Idempotent re-accept — surface the existing collaborator
# row without writing anything new.
collab = db.conn().execute(
"SELECT role_in_rfc FROM rfc_collaborators WHERE rfc_slug = ? AND user_id = ?",
(row["rfc_slug"], viewer.user_id),
).fetchone()
return {
"ok": True,
"changed": False,
"rfc_slug": row["rfc_slug"],
"role_in_rfc": collab["role_in_rfc"] if collab else row["role_in_rfc"],
}
# First-time accept. Flip the invitation; upsert the
# collaborator. We do the upsert with ON CONFLICT so a
# user who already held a lower role gets upgraded, never
# downgraded (the MAX-style precedence is contributor >
# discussant; lower roles never overwrite higher).
with db.tx() as c:
c.execute(
"""
UPDATE rfc_invitations
SET status = 'accepted',
accepted_at = datetime('now'),
accepted_by_user_id = ?
WHERE id = ?
""",
(viewer.user_id, row["id"]),
)
existing = c.execute(
"SELECT role_in_rfc FROM rfc_collaborators WHERE rfc_slug = ? AND user_id = ?",
(row["rfc_slug"], viewer.user_id),
).fetchone()
target_role = _max_role(
existing["role_in_rfc"] if existing else None,
row["role_in_rfc"],
)
if existing is None:
c.execute(
"""
INSERT INTO rfc_collaborators
(rfc_slug, user_id, role_in_rfc, invitation_id)
VALUES (?, ?, ?, ?)
""",
(row["rfc_slug"], viewer.user_id, target_role, row["id"]),
)
elif existing["role_in_rfc"] != target_role:
c.execute(
"""
UPDATE rfc_collaborators
SET role_in_rfc = ?, invitation_id = ?
WHERE rfc_slug = ? AND user_id = ?
""",
(target_role, row["id"], row["rfc_slug"], viewer.user_id),
)
return {
"ok": True,
"changed": True,
"rfc_slug": row["rfc_slug"],
"role_in_rfc": target_role,
}
return router
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _require_rfc(slug: str):
"""The invitation surface only operates on a known, non-withdrawn
RFC. We refuse 404 on unknown and 409 on withdrawn mirrors the
discussion endpoints' `_require_rfc_readable` shape."""
row = db.conn().execute(
"SELECT slug, title, state FROM cached_rfcs WHERE slug = ?", (slug,),
).fetchone()
if row is None:
raise HTTPException(404, "RFC not found")
if row["state"] == "withdrawn":
raise HTTPException(409, "RFC is withdrawn")
return row
def _lookup_invitation_by_token(token: str):
return db.conn().execute(
"""
SELECT id, rfc_slug, inviter_user_id, invitee_email, role_in_rfc,
status, token, expires_at, created_at, accepted_at,
accepted_by_user_id
FROM rfc_invitations
WHERE token = ?
""",
(token,),
).fetchone()
def _effective_status(row) -> str:
"""The row's column status is the authoritative truth except for
`expired` that is derived from `expires_at` at read time so an
unattended cron isn't required to flip rows. A revoked-then-
expired row reads as `revoked` (the explicit gesture wins)."""
column_status = row["status"]
if column_status != "pending":
return column_status
# Compare via SQL so the comparison is in sqlite-time, matching the
# `datetime('now')` insert. A simpler same-process comparison would
# work too, but routing through the DB keeps the timezone handling
# consistent with the inserts.
is_past = db.conn().execute(
"SELECT datetime(?) <= datetime('now') AS past",
(row["expires_at"],),
).fetchone()["past"]
return "expired" if is_past else "pending"
def _mint_token() -> str:
"""A 256-bit URL-safe token. The token shape is opaque to the
consumer; the email link encodes it as a query param."""
return secrets.token_urlsafe(32)
def _max_role(existing: str | None, new: str) -> str:
"""contributor strictly dominates discussant. A re-accept that
would lower the role is a no-op (the existing role survives)."""
precedence = {"discussant": 0, "contributor": 1}
if existing is None:
return new
if precedence.get(new, 0) > precedence.get(existing, 0):
return new
return existing
# ---------------------------------------------------------------------------
# Email dispatch — transactional, no preferences honored
# ---------------------------------------------------------------------------
def _send_invitation_email(
*,
to_address: str,
inviter_display: str,
rfc_title: str,
role_in_rfc: str,
token: str,
) -> bool:
"""Compose and send the invitation email.
Like `email_otc.send_otc_email`, this writes its own envelope and
reuses `EmailConfig.from_env()` for the SMTP plumbing. The
`_SENT` buffer is appended either way so integration tests can
assert on the outbound shape without a real SMTP server.
Returns True on the happy path / dev fallback; False on SMTP
failure. The caller does not roll back the invitation row on
failure the owner has the token in the create response and on
the listing surface for an out-of-band share.
"""
cfg = EmailConfig.from_env()
subject = f"{inviter_display} invited you to {rfc_title} on {cfg.from_name}"
role_label = (
"open PRs against the RFC and join its discussion"
if role_in_rfc == "contributor"
else "join the RFC's discussion"
)
link = f"{cfg.app_url}/invitations/accept?token={token}"
body = (
f"{inviter_display} invited you to {rfc_title} on {cfg.from_name} as {role_in_rfc}.\n\n"
f"This invitation lets you {role_label}.\n\n"
f"Click to accept (you'll be asked to sign in first if you aren't already):\n\n"
f" {link}\n\n"
f"The invitation expires in {INVITATION_TTL_DAYS} days. If you weren't expecting\n"
f"this, you can safely ignore the email.\n\n"
f"---\n"
f"{cfg.from_name} · {cfg.app_url}\n"
)
envelope = {
"to": to_address,
"from": formataddr((cfg.from_name, cfg.from_address)),
"subject": subject,
"body": body,
"kind": "rfc_invitation",
}
_SENT.append(envelope)
if not cfg.enabled:
log.info("invitation email disabled (EMAIL_ENABLED=0): to=%s", to_address)
return True
if not cfg.smtp_host:
# Dev fallback — surface the link at INFO so the operator can
# complete an accept flow without an SMTP relay.
log.info(
"invitation email (stdout fallback): to=%s rfc=%s role=%s link=%s",
to_address, rfc_title, role_in_rfc, link,
)
return True
try:
msg = EmailMessage()
msg["From"] = envelope["from"]
msg["To"] = to_address
msg["Subject"] = subject
msg.set_content(body)
smtp = smtplib.SMTP(cfg.smtp_host, cfg.smtp_port, timeout=30)
try:
if cfg.smtp_starttls:
smtp.starttls()
if cfg.smtp_user:
smtp.login(cfg.smtp_user, cfg.smtp_password)
smtp.send_message(msg)
finally:
smtp.quit()
return True
except Exception:
log.exception("invitation email send failed: to=%s", to_address)
return False
+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
+63 -1
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"],
@@ -751,6 +802,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)
+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
+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
]
+169 -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,11 @@ from . import (
digest,
email_otc,
hygiene,
invites as invites_mod,
otc,
passcode as passcode_mod,
providers as providers_mod,
ratelimit,
turnstile,
webhooks,
)
@@ -72,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()
@@ -122,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
@@ -135,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,
@@ -225,6 +256,10 @@ def _oauth_router(config) -> APIRouter:
@router.post("/auth/otc/request")
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
@@ -232,7 +267,7 @@ def _oauth_router(config) -> APIRouter:
# 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 = turnstile.verify_token(body.turnstile_token, client_ip=client_ip)
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
@@ -258,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
@@ -293,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": {
@@ -317,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}
@@ -356,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(
@@ -367,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": {
@@ -382,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).
#
+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"
+158
View File
@@ -0,0 +1,158 @@
"""Roadmap #28 Part 1 — auto-link RFC references in submitted prose.
Scans plain-text PR descriptions and comment bodies for references to
existing **accepted** (state='active') RFCs and returns a structured list
of *segments* the frontend renders: plain-text runs interleaved with
``{"type": "rfc", ...}`` link segments. The backend never emits HTML
the frontend maps link segments onto React anchors so the surface is
XSS-safe by construction and independent of any HTML-sanitization layer.
**Read-time enrichment, not submit-time persistence.** The roadmap row
phrases the scan as happening "at submit/post time"; this module instead
enriches on read. The intent the roadmap actually names "not as live
compose preview" — is honored (drafts are never scanned, only submitted
content on the read paths). Read-time was chosen for three reasons:
1. Correctness links track the *live* active-RFC set. A newly-accepted
RFC starts linking in older comments; a withdrawn RFC stops linking
everywhere. Submit-time freezing would drift stale.
2. Zero migration no derived data to store. (A concurrent session
already holds migration 023; staying migration-free keeps this slice
conflict-free as well as simpler.)
3. Cost the active-RFC corpus is small and cache-resident, so building
the term index and scanning a 20k-char body per read is cheap.
**Matching is conservative by design.** Only references that are unlikely
to be coincidental link:
* ``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 or slugs (e.g. a hypothetical RFC titled
"Human") are deliberately NOT auto-linked they would turn every prose
"human" into a link. Surfacing those is the job of the roadmap's
"curated canonical-terms list", an explicit per-deployment opt-in left as
a future extension rather than guessed at here.
"""
from __future__ import annotations
from typing import Any, Iterable
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 segment_text(text: str | None, terms: list[tuple[str, str, str]]) -> list[dict[str, Any]]:
"""Split ``text`` into text / rfc-link segments against ``terms``.
``terms`` is a list of ``(key_lower, slug, title)`` tuples; callers
pass it pre-sorted longest-first so the longest match wins at any
position (so "Open Human Model" wins over a bare "Open"). Matching is
case-insensitive and respects word boundaries on both ends. The
returned ``label`` preserves the source casing.
Always returns at least one segment; for empty/None input that is a
single empty text segment, so callers can render uniformly.
"""
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[str, str, str, int] | None = None
for key, slug, title in terms:
klen = len(key)
if klen == 0 or not low.startswith(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 = (key, slug, title, klen)
break
if match is not None:
_key, slug, title, klen = match
if buf:
out.append({"type": "text", "text": "".join(buf)})
buf = []
out.append({
"type": "rfc",
"slug": slug,
"label": text[i:i + klen],
"title": title,
})
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 active 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()
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: list[tuple[str, str, str]]):
# Longest key first so the longest reference wins at each position.
self._terms = sorted(terms, key=lambda t: len(t[0]), reverse=True)
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 build_index(conn, *, exclude_slug: str | None = None) -> LinkIndex:
"""Build a :class:`LinkIndex` from the accepted (active) RFC corpus.
``exclude_slug`` drops the RFC the surrounding surface is itself scoped
to, so an RFC's own title/id/slug don't self-link inside its own PR or
discussion. ``ORDER BY slug`` makes key de-duplication deterministic
when two RFCs would contribute the same key (first slug wins)."""
rows = conn.execute(
"SELECT slug, title, rfc_id FROM cached_rfcs WHERE state = 'active' ORDER BY slug"
).fetchall()
terms: list[tuple[str, str, str]] = []
seen: set[str] = set()
for r in rows:
slug = r["slug"]
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):
if key in seen:
continue
seen.add(key)
terms.append((key, slug, title))
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)
+23 -5
View File
@@ -73,6 +73,19 @@ 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.
@@ -98,7 +111,7 @@ class VerifyOutcome:
reason: str
def verify_token(token: str | None, *, client_ip: str | None = None) -> VerifyOutcome:
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
@@ -108,9 +121,14 @@ def verify_token(token: str | None, *, client_ip: str | None = None) -> VerifyOu
* 'misconfigured' 500 "auth misconfigured"
* 'missing-token' / 'failed' / 'network' 400 "verification failed"
Tests monkeypatch `httpx.post` (or set `TURNSTILE_SITEVERIFY_URL`
+ a MockTransport client) to avoid touching the real CloudFlare
endpoint. No real keys are ever embedded in tests.
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()
@@ -133,7 +151,7 @@ def verify_token(token: str | None, *, client_ip: str | None = None) -> VerifyOu
data["remoteip"] = client_ip
try:
response = httpx.post(_siteverify_url(), data=data, timeout=10.0)
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)
+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
);
+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,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"
+11 -1
View File
@@ -22,6 +22,7 @@ import pytest
from test_propose_vertical import ( # noqa: F401
FakeGitea,
app_with_fake_gitea,
grant_rfc_collaborator,
provision_user_row,
sign_in_as,
tmp_env,
@@ -131,6 +132,11 @@ def test_full_user_lifecycle_propose_through_hygiene(app_with_fake_gitea):
assert d["repo"] == "wiggleverse/rfc-0001-ohm"
# --- 8. Alice opens a PR on the now-active RFC's per-RFC repo. ---
# v0.16.0 (item #12): ben is the RFC owner now; alice needs a
# per-RFC contributor invitation to cut a branch. In the
# production flow, ben would invite her via /invitations and
# she'd accept; we shortcut to the same end-state.
grant_rfc_collaborator(user_id=2, rfc_slug="ohm", role_in_rfc="contributor")
sign_in_as(client, user_id=2, gitea_login="alice",
display_name="Alice", role="contributor", email="alice@test")
r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={})
@@ -225,13 +231,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"
@@ -34,6 +34,7 @@ import pytest
from test_propose_vertical import ( # noqa: F401
FakeGitea,
app_with_fake_gitea,
grant_rfc_collaborator,
provision_user_row,
sign_in_as,
tmp_env,
@@ -248,6 +249,9 @@ def test_graduate_refuses_when_body_edit_pr_open(app_with_fake_gitea):
provision_user_row(user_id=2, login="alice", role="contributor")
seed_owned_super_draft(fake, slug="ohm", title="OHM",
pitch=PITCH, owners=["ben"])
# v0.16.0 (item #12): ben is the RFC owner; alice needs a per-RFC
# contributor invitation to cut an edit branch on the super-draft.
grant_rfc_collaborator(user_id=2, rfc_slug="ohm", role_in_rfc="contributor")
sign_in_as(client, user_id=2, gitea_login="alice",
display_name="Alice", role="contributor")
@@ -497,6 +501,8 @@ def test_pre_graduation_history_surfaces_edit_branch_threads(app_with_fake_gitea
provision_user_row(user_id=2, login="alice", role="contributor")
seed_owned_super_draft(fake, slug="ohm", title="OHM",
pitch=PITCH, owners=["ben"])
# v0.16.0 (item #12): alice needs per-RFC contributor access.
grant_rfc_collaborator(user_id=2, rfc_slug="ohm", role_in_rfc="contributor")
# Alice cuts an edit branch and starts chatting on it.
sign_in_as(client, user_id=2, gitea_login="alice",
@@ -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")
+39 -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)
@@ -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"] == []
+235
View File
@@ -0,0 +1,235 @@
"""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_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"]) == []
+38 -7
View File
@@ -55,13 +55,19 @@ def _outbound_otc_envelopes(to_address: str | None = None) -> list[dict]:
def _patch_siteverify(monkeypatch, *, success: bool, error_codes: list[str] | None = None):
"""Replace `httpx.post` inside `app.turnstile` with a stub that
"""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 = {}
def fake_post(url, *, data=None, timeout=None, **kwargs):
async def fake_post(url, data):
captured["url"] = url
captured["data"] = data
body = {"success": bool(success)}
@@ -70,7 +76,7 @@ def _patch_siteverify(monkeypatch, *, success: bool, error_codes: list[str] | No
return SimpleNamespace(json=lambda: body)
from app import turnstile as turnstile_mod
monkeypatch.setattr(turnstile_mod.httpx, "post", fake_post)
monkeypatch.setattr(turnstile_mod, "_siteverify_post", fake_post)
return captured
@@ -170,14 +176,15 @@ def test_otc_request_admits_when_secret_unset_and_not_required(app_with_fake_git
monkeypatch.delenv("CLOUDFLARE_TURNSTILE_SECRET", raising=False)
monkeypatch.delenv("TURNSTILE_REQUIRED", raising=False)
# The httpx.post inside turnstile must not be called in this path —
# patch it to a sentinel that explodes if it ever runs.
# 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
def must_not_be_called(*a, **kw):
async def must_not_be_called(*a, **kw):
raise AssertionError("siteverify should not run when no secret is configured")
monkeypatch.setattr(turnstile_mod.httpx, "post", must_not_be_called)
monkeypatch.setattr(turnstile_mod, "_siteverify_post", must_not_be_called)
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
@@ -218,3 +225,27 @@ def test_otc_request_refuses_when_required_but_secret_unset(app_with_fake_gitea,
)
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
+23
View File
@@ -62,3 +62,26 @@ VITE_COOKIES_POLICY_URL=
# 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.12.0",
"version": "0.24.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "rfc-app-frontend",
"version": "0.12.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.12.0",
"version": "0.28.0",
"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",
+990 -685
View File
File diff suppressed because it is too large Load Diff
+155 -9
View File
@@ -1,6 +1,8 @@
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 } 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'
@@ -11,9 +13,17 @@ 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 +43,74 @@ 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()
// 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 +125,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
@@ -110,7 +199,7 @@ export default function App() {
wonders why a conversation is public can reach the answer
in two clicks. Anonymous viewers see it too. */}
<Link to="/philosophy" className="header-about" title="Why this exists (§14)">
About
Philosophy
</Link>
<Link to="/docs" className="header-about" title="User guide">
Docs
@@ -129,9 +218,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 +238,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 +266,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>} />
@@ -227,9 +353,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>
)
}
+224 -4
View File
@@ -25,6 +25,25 @@ 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
@@ -166,15 +185,47 @@ 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 []
}
}
export async function mergeProposal(prNumber) {
const res = await fetch(`/api/proposals/${prNumber}/merge`, { method: 'POST' })
return jsonOrThrow(res)
@@ -322,6 +373,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
@@ -450,13 +543,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)
@@ -678,6 +772,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).
@@ -757,6 +930,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>
)
}
+219
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) {
@@ -172,8 +190,28 @@ function UsersTab() {
retain their v0.7.0 semantics promote to admin to remove a
user's ability to write without silencing them.
</p>
{/* 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>
</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,12 +262,26 @@ 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
return (
<>
<tr>
<td>
<div className="user-cell">
<span className="user-handle">{handle}</span>
{pendingInvite && (
<span
className="invite-badge"
title={`Admin-created invite; expires ${pendingInvite.expires_at}`}
>(pending invite)</span>
)}
<span className="muted">
{fullName || u.display_name}
{u.email ? ` · ${u.email}` : ''}
@@ -319,6 +371,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() {
+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])
+128
View File
@@ -0,0 +1,128 @@
/* 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);
}
+56 -11
View File
@@ -15,6 +15,7 @@ import {
markNotificationRead,
markNotificationsReadByFilter,
} from '../api.js'
import './Inbox.css'
const CATEGORIES = [
{ value: '', label: 'All categories' },
@@ -56,11 +57,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 +126,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 +164,24 @@ export default function Inbox({ onClose, lastChangeTick }) {
)
}
function InboxRow({ item, onClick, onClose }) {
function InboxRow({ item, onClick, onMarkRead, onClose }) {
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 +189,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>
)
}
+38
View File
@@ -0,0 +1,38 @@
// LinkedText.jsx roadmap #28 Part 1.
//
// Renders a backend-provided list of text/rfc-link segments (see
// backend/app/rfc_links.py). RFC references in PR descriptions and
// comments arrive pre-scanned as structured segments this component
// maps them onto plain text runs and anchor elements. It never renders
// HTML from the server (no dangerouslySetInnerHTML), so the surface is
// XSS-safe regardless of what a comment author typed.
//
// `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). Either way the visible text is identical only the
// links differ.
export default function LinkedText({ segments, text }) {
if (!Array.isArray(segments) || segments.length === 0) {
return <>{text ?? ''}</>
}
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>
)
}
return <span key={i}>{seg.text}</span>
})}
</>
)
}
+21 -1
View File
@@ -85,6 +85,7 @@ import {
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
@@ -150,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.
}
@@ -206,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
@@ -264,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).
@@ -320,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
+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} />
</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} />
</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>
)
}
+105 -5
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
@@ -25,21 +33,56 @@ export default function ProposeModal({ viewer, onClose, onSubmitted }) {
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 +94,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 +148,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 +192,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).
@@ -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 {
@@ -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} />
</div>
</div>
)
}
+51 -1
View File
@@ -43,7 +43,9 @@ import RFCDiscussionPanel from './RFCDiscussionPanel.jsx'
import ChangePanel, { diffWords } from './ChangePanel.jsx'
import PRModal from './PRModal.jsx'
import GraduateDialog from './GraduateDialog.jsx'
import InvitationsModal from './InvitationsModal.jsx'
import { claimOwnership } from '../api'
import { EVENTS, track } from '../lib/analytics'
const MANUAL_IDLE_MS = 5 * 60 * 1000 // §8.6 idle window; exact value is impl detail.
const MANUAL_DEBOUNCE_MS = 800
@@ -121,7 +123,15 @@ export default function RFCView({ viewer }) {
const [drawerOpen, setDrawerOpen] = useState(false)
useEffect(() => {
getRFC(slug).then(setEntry).catch(err => setError(err.message))
getRFC(slug).then(entry => {
setEntry(entry)
// v0.15.0 analytics: fire RFC Viewed once per slug load.
// We key on the slug param rather than the loaded entry so a
// re-render doesn't double-fire; the slug is the stable
// identifier. id is included for join-friendliness in the
// Amplitude dashboard.
track(EVENTS.RFC_VIEWED, { rfc_slug: slug, rfc_id: entry?.id })
}).catch(err => setError(err.message))
listModels(slug)
.then(({ models, default: def }) => {
setModels(models || [])
@@ -139,6 +149,11 @@ export default function RFCView({ viewer }) {
const [showMetadataPane, setShowMetadataPane] = useState(false)
const [showGraduateDialog, setShowGraduateDialog] = useState(false)
const [claimError, setClaimError] = useState(null)
// v0.16.0 (item #12): the per-RFC invitations modal. Visible only to
// RFC owners (frontmatter) and platform admin/owner the backend
// gates the underlying endpoints regardless, so a leaked toggle
// can't actually leak anything.
const [showInvitationsModal, setShowInvitationsModal] = useState(false)
// Load main view + branch view whenever slug/branch changes.
useEffect(() => {
@@ -624,6 +639,20 @@ export default function RFCView({ viewer }) {
Graduate to RFC repo
</button>
)}
{/* v0.16.0 (item #12): owner-only invitations affordance.
Shown when the viewer is named in the RFC's frontmatter
`owners` list or holds a platform admin/owner role.
Available on both super-drafts and active RFCs. */}
{viewer && (viewer.role === 'owner' || viewer.role === 'admin' || (entry?.owners || []).includes(viewer.gitea_login)) && (
<button
type="button"
className="btn-link"
onClick={() => setShowInvitationsModal(true)}
title="Invite collaborators to this RFC"
>
Invitations
</button>
)}
</div>
</div>
{claimError && (
@@ -640,6 +669,19 @@ export default function RFCView({ viewer }) {
: 'main is read-only — PRs are the only path to change it. Open a branch to propose edits.'}
</div>
)}
{/* #26: the optional ground-truth use case captured at propose
time. Shown on the canonical (main) view; muted "left blank"
treatment when the proposer didn't supply one. */}
{branchParam === 'main' && (
<div className="rfc-use-case" style={{ margin: '8px 0 16px', padding: '10px 14px', borderLeft: '3px solid #e0e0e0', background: '#fafafa' }}>
<div style={{ fontSize: 11, fontWeight: 700, color: '#888', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4 }}>
Intended use case
</div>
{entry.proposed_use_case
? <div style={{ whiteSpace: 'pre-wrap' }}>{entry.proposed_use_case}</div>
: <span style={{ color: '#999', fontStyle: 'italic' }}>Left blank by the proposer.</span>}
</div>
)}
{inDiscuss && branchParam !== 'main' && (
<div className="discuss-mode-banner">
Discuss mode on <strong>{branchParam}</strong> chat freely;
@@ -856,6 +898,14 @@ export default function RFCView({ viewer }) {
/>
)}
{showInvitationsModal && (
<InvitationsModal
slug={slug}
rfcTitle={entry?.title}
onClose={() => setShowInvitationsModal(false)}
/>
)}
{showMetadataPane && (
<MetadataPaneModal
slug={slug}
+3 -4
View File
@@ -1,8 +1,7 @@
:root {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
Arial, sans-serif;
color: #1a1a1a;
background: #fafaf8;
font-family: var(--font-sans);
color: var(--color-text);
background: var(--color-bg);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
+391
View File
@@ -0,0 +1,391 @@
// analytics.js — v0.15.0 / roadmap item #13.
//
// Wrapper around `@amplitude/unified` (Amplitude Analytics +
// Session Replay) that gates SDK initialization on the user's
// cookie/privacy consent (v0.13.0, `frontend/src/lib/consent.js`,
// SPEC §14.5). The wrapper presents a stable surface to the rest
// of the app:
//
// import { track, identify, anonymize } from './lib/analytics'
//
// track('RFC Viewed', { rfc_slug: 'open-human-model' })
// identify({ user_id: 'u_123' })
// anonymize() // call on sign-out
//
// At first import the wrapper:
// 1. Calls `bootstrap()` once, which reads `getConsent()` and
// subscribes to `onConsentChange()`. If consent.analytics is
// true, it lazily imports the Amplitude SDK and calls
// `amplitude.initAll(API_KEY, { analytics: { autocapture: true },
// sessionReplay: { sampleRate: 1 } })`.
// If consent.analytics is false (or undecided), the SDK is
// not loaded — no network request, no cookies, no session
// replay recording. A later consent change to `true` triggers
// init at that moment.
// 2. The wrapper queues `track()` and `identify()` calls made
// before init finishes (lazy import + consent grant), and
// drains the queue when init completes.
// 3. If the user later flips consent from granted → denied, the
// wrapper calls `amplitude.setOptOut(true)` so subsequent
// events are dropped client-side and session replay stops
// recording (the SDK is still loaded — we cannot unload a
// script — but it stops firing).
//
// Consent precedence ladder:
//
// consent.analytics === true → init + track
// consent.analytics === false → no init; or if already init,
// setOptOut(true)
// consent.recorded_at === null → treat as denied (banner is up;
// the user has not yet chosen)
//
// Session replay scope: this release ships session replay at
// `sampleRate: 1` (100% of sessions are recorded for full-DOM
// playback). That is the vendor-recommended default for new
// Amplitude deployments. The v0.13.0 consent banner's single
// "analytics" toggle gates both events and session replay together —
// a separate consent category for session-replay specifically is a
// §19.2 follow-up.
//
// API key resolution:
//
// The build-time env var `VITE_AMPLITUDE_API_KEY` carries the
// Amplitude project's API key. When it is unset/empty, the
// wrapper logs one console warning and no-ops — every public
// function becomes a deterministic no-op so dev environments
// (and deployments that intentionally don't ship analytics)
// keep working. The deploy gesture wires the key via flotilla's
// `overlay set` verb (see CHANGELOG for the operator gesture):
// Amplitude browser keys are bundle-embedded by design (visible
// to anyone with dev tools, same nature as the v0.12.0
// `VITE_TURNSTILE_SITE_KEY`), so the binding is overlay, not
// secret.
//
// PII discipline:
//
// `identify({ user_id })` SHOULD pass only the opaque server-
// side user id (the `viewer.id` integer or string). DO NOT pass
// email, display name, IP, or any other PII through the SDK.
// Event properties SHOULD likewise stay limited to ids and
// enums; free-text fields (titles, comment bodies) MUST NOT be
// sent.
//
// Event taxonomy: defined in `EVENTS` below. Callers SHOULD use
// one of these names rather than firing arbitrary strings — that
// keeps the Amplitude dashboard coherent over time.
import { getConsent, onConsentChange } from './consent.js'
const API_KEY = import.meta.env.VITE_AMPLITUDE_API_KEY || ''
// Public taxonomy. Keep this short and stable — new entries should
// land via a release, not ad-hoc. The strings match the Amplitude
// dashboard names exactly (Title Case, spaces, no punctuation).
export const EVENTS = Object.freeze({
PAGE_VIEWED: 'Page Viewed',
RFC_VIEWED: 'RFC Viewed',
USER_SIGNED_IN: 'User Signed In',
USER_SIGNED_OUT: 'User Signed Out',
RFC_PROPOSED: 'RFC Proposed',
PR_OPENED: 'PR Opened',
COMMENT_POSTED: 'Comment Posted',
BETA_ACCESS_REQUESTED: 'Beta Access Requested',
ADMIN_PERMISSION_DECISION: 'Admin Permission Decision',
// v0.16.0 / item #12 — per-RFC owner invites.
INVITATION_SENT: 'Invitation Sent',
INVITATION_ACCEPTED: 'Invitation Accepted',
// v0.17.0 / item #16 — admin-create user + invite email.
USER_INVITED: 'User Invited',
INVITE_CLAIMED: 'Invite Claimed',
// v0.19.0 / item #30 — `/docs/*` flyout + sessions browser. Carries
// `section`: 'user-guide' | 'sessions/about' | 'sessions/<NNNN>' |
// 'sessions/<NNNN>/<filename>' so the dashboard can answer which
// docs surfaces get read most. The transcript-section value includes
// the filename so an aggregator can group by `sessions/<NNNN>` or by
// exact transcript.
DOC_VIEWED: 'Doc Viewed',
})
// Internal state.
let _bootstrapped = false
let _amplitude = null // The dynamically imported SDK module.
let _initPromise = null // Pending init (lazy import + sdk.init).
let _initialized = false // True after sdk.init has resolved.
let _warnedNoKey = false
let _pendingUserId = null // identify() called before init resolves.
let _pendingProperties = null // identify({ properties }) or
// setUserProperties() before init.
const _queue = [] // {kind: 'track'|'identify'|'anonymize'|
// 'setUserProperties', ...}
function warnNoKey() {
if (_warnedNoKey) return
_warnedNoKey = true
// eslint-disable-next-line no-console
console.warn(
'[analytics] VITE_AMPLITUDE_API_KEY is unset; analytics events ' +
'and session replay will not be sent. This is expected in dev; ' +
'in production it means the operator has not yet run ' +
'`flotilla overlay set <deployment> VITE_AMPLITUDE_API_KEY=<key>`.',
)
}
function consentGranted() {
const c = getConsent()
return !!(c && c.recorded_at && c.analytics)
}
// Apply a {key: value} property bag as an Amplitude Identify event.
// Used by both `identify({ properties })` and `setUserProperties`.
function applyProperties(props) {
if (!_initialized || !_amplitude || !props) return
try {
const id = new _amplitude.Identify()
for (const [k, v] of Object.entries(props)) {
if (v === undefined || v === null) continue
if (Array.isArray(v) && v.length === 2 && v[0] === '__setOnce__') {
id.setOnce(k, v[1])
} else {
id.set(k, v)
}
}
_amplitude.identify(id)
} catch (_) {
// SDK errors are non-fatal; analytics is best-effort.
}
}
// Drain the queue. Called once init resolves.
function drainQueue() {
if (!_initialized || !_amplitude) return
if (_pendingUserId != null) {
try { _amplitude.setUserId(_pendingUserId) } catch (_) {}
_pendingUserId = null
}
if (_pendingProperties != null) {
applyProperties(_pendingProperties)
_pendingProperties = null
}
while (_queue.length > 0) {
const item = _queue.shift()
try {
if (item.kind === 'track') {
_amplitude.track(item.name, item.props || {})
} else if (item.kind === 'identify') {
if (item.user_id != null) _amplitude.setUserId(item.user_id)
if (item.properties != null) applyProperties(item.properties)
} else if (item.kind === 'setUserProperties') {
applyProperties(item.properties)
} else if (item.kind === 'anonymize') {
_amplitude.reset()
}
} catch (_) {
// SDK errors are non-fatal; analytics is best-effort.
}
}
}
// Lazy import + init. Resolves once the SDK is ready to take events.
// Idempotent: subsequent calls return the same promise.
async function initSdk() {
if (_initPromise) return _initPromise
if (!API_KEY) {
warnNoKey()
// Resolve immediately with a no-op shape; the wrapper's public
// functions check API_KEY and short-circuit, so this never
// actually runs SDK code.
_initPromise = Promise.resolve(null)
return _initPromise
}
_initPromise = (async () => {
try {
const mod = await import('@amplitude/unified')
// The unified package exposes `initAll`, `track`,
// `setUserId`, `reset`, `setOptOut` as named functions.
// We hold the module so the queue drainer can call them
// by name.
_amplitude = mod
// initAll wires up both Analytics and Session Replay in one
// call. Vendor-recommended init shape from the Amplitude
// installation wizard:
// - analytics.autocapture: true — auto-instruments page
// views, session start/end, clicks, and form interactions.
// Our explicit `track('Page Viewed', …)` etc. layer on top
// for app-specific names that survive renames.
// - sessionReplay.sampleRate: 1 — record 100% of sessions
// for full-DOM playback. Gated by the v0.13.0 consent
// banner just like the rest of the SDK; never starts
// recording without explicit analytics opt-in.
const ret = mod.initAll(API_KEY, {
analytics: { autocapture: true },
sessionReplay: { sampleRate: 1 },
})
// initAll returns an AmplitudeReturn with a `.promise` accessor
// (consistent with the legacy `init`). Some unified builds
// resolve synchronously; await defensively.
if (ret && ret.promise) await ret.promise
_initialized = true
drainQueue()
} catch (err) {
// Init failure is non-fatal; keep the wrapper alive so future
// calls no-op. Log once for the operator.
// eslint-disable-next-line no-console
console.warn('[analytics] Amplitude init failed:', err)
_initialized = false
}
return _amplitude
})()
return _initPromise
}
// Bootstrap is called lazily on first track/identify. It wires the
// consent subscription so a later flip from denied→granted triggers
// init at that moment, and granted→denied flips the opt-out.
function bootstrap() {
if (_bootstrapped) return
_bootstrapped = true
if (consentGranted()) {
// Fire-and-forget; the queue catches any events that arrive
// before init resolves.
initSdk()
}
onConsentChange(snapshot => {
const allowed = !!(snapshot && snapshot.recorded_at && snapshot.analytics)
if (allowed && !_initPromise) {
initSdk()
} else if (allowed && _initialized && _amplitude) {
// Re-enable in case we previously opted out.
try { _amplitude.setOptOut(false) } catch (_) {}
} else if (!allowed && _initialized && _amplitude) {
// Granted → denied. Stop firing. We cannot unload the script
// tag; setOptOut is the SDK's contract for "drop subsequent
// events client-side".
try { _amplitude.setOptOut(true) } catch (_) {}
}
})
}
/** Fire a track event. Safe to call before consent / init resolve;
* the call is queued and drained once both are true. Drops the
* event silently if API_KEY is empty (with a one-shot warn) or
* consent.analytics is false. */
export function track(name, props) {
if (!API_KEY) { warnNoKey(); return }
bootstrap()
if (!consentGranted()) return
if (_initialized && _amplitude) {
try { _amplitude.track(name, props || {}) } catch (_) {}
return
}
_queue.push({ kind: 'track', name, props })
}
/** Attach an authenticated user id and optional durable properties.
* Pass `{ user_id: '<opaque-id>', properties?: { role, first_sign_in_at, … } }`.
* DO NOT pass email, display name, or other PII as user_id or in
* properties. Idempotent subsequent calls with the same id are
* cheap; properties are merged into the Amplitude user record.
*
* To mark a property as setOnce (immutable after first write),
* pass `properties: { first_sign_in_at: ['__setOnce__', '2026-05-28T…'] }`.
* Bare values use Amplitude's `.set()` (mutable).
*
* Pattern (per #21 Part C):
* - On sign-in success in App.jsx: identify with viewer.id + the
* durable property bag (role, permission_state, first_sign_in_at
* setOnce, passcode_set, device_trusted_count, account_created_at
* setOnce).
* - On invite-claim success in InviteClaim.jsx / AcceptInvitation.jsx:
* identify with the new viewer.id + invitation-derived properties
* (invited_by_admin_id, invited_at setOnce, initial_role, claim_method)
* BEFORE firing any track() so the Amplitude user record is
* created with the OHM user_id from the first event, not as an
* anonymous device that retroactively links. */
export function identify({ user_id, properties } = {}) {
if (!API_KEY) { warnNoKey(); return }
if (user_id == null && properties == null) return
bootstrap()
if (!consentGranted()) {
// Hold for when consent lands; identify-on-sign-in is a common
// race with the consent banner choice.
if (user_id != null) _pendingUserId = user_id
if (properties != null) {
_pendingProperties = { ..._pendingProperties, ...properties }
}
return
}
if (_initialized && _amplitude) {
try {
if (user_id != null) _amplitude.setUserId(user_id)
if (properties != null) applyProperties(properties)
} catch (_) {}
return
}
if (user_id != null) _pendingUserId = user_id
if (properties != null) {
_pendingProperties = { ..._pendingProperties, ...properties }
}
_queue.push({ kind: 'identify', user_id, properties })
}
/** Update durable user properties on the current Amplitude user
* record mid-session for state changes that shouldn't wait for the
* next sign-in to surface (role grant/revoke, passcode set, device
* trusted, etc.). Same property shape as `identify({ properties })`.
* setOnce values use the `['__setOnce__', value]` sentinel pattern.
* Has no effect if no identify has happened yet set the user_id
* via `identify()` first.
*
* Per #21 Part C: call this from any surface where the user's
* Amplitude-relevant state changes mid-session, so the dashboard
* stays current. */
export function setUserProperties(properties) {
if (!API_KEY) { warnNoKey(); return }
if (properties == null) return
bootstrap()
if (!consentGranted()) {
_pendingProperties = { ..._pendingProperties, ...properties }
return
}
if (_initialized && _amplitude) {
applyProperties(properties)
return
}
_pendingProperties = { ..._pendingProperties, ...properties }
_queue.push({ kind: 'setUserProperties', properties })
}
/** Reset the user binding. Call this on sign-out so the next page
* navigations are attributed to a fresh anonymous device id. Has
* no effect when analytics is disabled.
*
* Per #21 Part C: clears both the user_id binding AND the pending
* property cache, so a subsequent sign-in as a different user
* starts with a fully fresh slate (no carry-over properties from
* the previous user). */
export function anonymize() {
if (!API_KEY) { warnNoKey(); return }
_pendingUserId = null
_pendingProperties = null
bootstrap()
if (!consentGranted()) return
if (_initialized && _amplitude) {
try { _amplitude.reset() } catch (_) {}
return
}
_queue.push({ kind: 'anonymize' })
}
/** Test helper exposed for unit tests, not for app code.
* Resets module-level state so a fresh bootstrap cycle can be
* exercised. */
export function __resetForTests() {
_bootstrapped = false
_amplitude = null
_initPromise = null
_initialized = false
_warnedNoKey = false
_pendingUserId = null
_pendingProperties = null
_queue.length = 0
}
+47
View File
@@ -0,0 +1,47 @@
// sanitizeHtml.js — the single chokepoint for turning user-authored
// markdown into DOM-bound HTML.
//
// Security audit 0026 (finding C1, Critical): every `marked.parse(...)`
// result that reaches an `innerHTML` / `dangerouslySetInnerHTML` sink was
// previously written raw. `marked` passes through embedded HTML and
// `javascript:`/event-handler attributes verbatim, so any user-authored
// document (RFC body, proposal body, proposed_use_case, transcript) was a
// stored-XSS vector — a contributor's payload executed in the session of
// whoever viewed it, including an admin/owner during review.
//
// Fix: route EVERY markdown render through `renderMarkdown` (or, for
// already-rendered HTML, `sanitizeHtml`). DOMPurify's defaults already
// strip <script>, on* event handlers, and javascript:/unsafe-data: URIs;
// we add a hook so any link opening a new tab carries rel="noopener
// noreferrer". The html profile keeps the standard markdown tag set plus
// class + data-* attributes (the latter is what MarkdownPreview's mermaid
// placeholder relies on); mermaid renders its SVG into the DOM *after*
// sanitization and is itself locked down with securityLevel:'strict'.
import DOMPurify from 'dompurify'
import { marked } from 'marked'
let _hookInstalled = false
function ensureHook() {
if (_hookInstalled) return
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
if (node.tagName === 'A' && node.getAttribute('target') === '_blank') {
node.setAttribute('rel', 'noopener noreferrer')
}
})
_hookInstalled = true
}
// Sanitize an already-rendered HTML string. Use when the HTML did not come
// from `marked` (rare) or when a caller parses markdown with a bespoke
// `Marked` instance and only needs the sanitize step.
export function sanitizeHtml(html) {
ensureHook()
return DOMPurify.sanitize(html || '', { USE_PROFILES: { html: true } })
}
// Parse markdown with the shared `marked` and sanitize the result. This is
// the drop-in replacement for `marked.parse(src)` at any HTML sink.
export function renderMarkdown(src) {
return sanitizeHtml(marked.parse(src || ''))
}
+91
View File
@@ -0,0 +1,91 @@
// useLastState — v0.23.0 / roadmap item #29: server-side sign-in state
// resume. Two responsibilities, kept in one small hook so App.jsx's
// surface stays minimal:
//
// 1. Debounce-post the authenticated user's current route to
// `PUT /api/me/last-state` on every route change (~1s debounce so
// a fast click-through doesn't spray the endpoint). Anonymous
// users: no-op. Best-effort: a failed POST never disrupts nav.
//
// 2. On the first authenticated load, read the stored `last_route`
// (handed back on `/api/auth/me`) and `navigate()` to it ONCE.
// Stale routes (a withdrawn RFC, a slug the user lost rights to)
// are not special-cased here: navigating lands on whatever that
// route renders today, and the existing routing already falls
// through to the catalog/empty-state for a missing RFC. Keeping
// this dumb is deliberate (see SPEC §6.2 + the #29 scope note).
//
// Ordering with #21 Part C (Amplitude identify): App.jsx fires
// `identify` in its own effect when `me.user.id` first appears. This
// hook's resume redirect is gated on `identifyReady` — App.jsx flips it
// true only after the identify effect has run — so the redirect always
// happens AFTER identify, preserving the identify-then-track ordering
// the roadmap calls out.
import { useEffect, useRef } from 'react'
import { putLastState } from '../api'
// ~1s debounce on the route-change POST. A frontend constant, not a
// server knob — the backend takes whatever lands.
const DEBOUNCE_MS = 1000
// Routes we never want to resume *to* — auth/landing surfaces that
// would be nonsensical or hostile to drop a returning user onto. We
// still record them (cheap, and the user may legitimately be sitting on
// /docs), but the resume redirect skips them and falls through to the
// default landing. Anything not listed resumes normally.
const NON_RESUMABLE_PREFIXES = ['/login', '/welcome', '/invites/', '/invitations/']
function isResumable(route) {
if (!route || typeof route !== 'string') return false
if (route === '/') return false // "/" is already the default landing
return !NON_RESUMABLE_PREFIXES.some(p => route.startsWith(p))
}
/**
* @param {object} opts
* @param {boolean} opts.authenticated whether a viewer is signed in
* @param {string} opts.pathname current location.pathname
* @param {boolean} opts.identifyReady App flips true after identify fires
* @param {string|null} opts.lastRoute stored route from /api/auth/me
* @param {function} opts.navigate react-router navigate()
*/
export function useLastState({ authenticated, pathname, identifyReady, lastRoute, navigate }) {
// ── 1. Debounced route-change POST ────────────────────────────────
const timerRef = useRef(null)
useEffect(() => {
if (!authenticated) return undefined
if (timerRef.current) clearTimeout(timerRef.current)
timerRef.current = setTimeout(() => {
// Best-effort — swallow failures so an offline/401 POST never
// surfaces as a navigation error.
putLastState(pathname).catch(() => {})
}, DEBOUNCE_MS)
return () => {
if (timerRef.current) clearTimeout(timerRef.current)
}
}, [authenticated, pathname])
// ── 2. One-time resume redirect ───────────────────────────────────
// Fires once, after identify is ready, when there's a resumable
// stored route AND the user is currently sitting on the default
// landing ("/"). We only redirect from "/" so we never yank a user
// who deep-linked somewhere specific (or refreshed mid-RFC) back to
// their last route.
const resumedRef = useRef(false)
useEffect(() => {
if (resumedRef.current) return
if (!authenticated || !identifyReady) return
// Only resume when the app booted on the default landing — a hard
// sign-in nav lands on "/", which is exactly the case we want.
if (pathname !== '/') {
resumedRef.current = true // user deep-linked; don't resume later either
return
}
if (isResumable(lastRoute)) {
resumedRef.current = true
navigate(lastRoute, { replace: true })
} else {
resumedRef.current = true // nothing to resume to; keep default landing
}
}, [authenticated, identifyReady, lastRoute, pathname, navigate])
}
+1
View File
@@ -2,6 +2,7 @@ import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App.jsx'
import './styles/tokens.css'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
+162
View File
@@ -0,0 +1,162 @@
/* tokens.css the design-token foundation for rfc-app's UI.
*
* Roadmap item #31 (comprehensive UX polish). Before this file the app
* had ~98 distinct hardcoded hex colors, font sizes scattered across 16
* values with no scale, and radii across 13 values classic prototype
* sprawl. This module establishes ONE coherent system; the App.css sweep
* (and component-scoped CSS) reference these custom properties instead of
* literal values, so "what color/size/space is this" has a single answer.
*
* Imported FIRST in main.jsx so :root is defined before any other sheet.
* Custom properties are not cascade-order-sensitive at use time, but
* importing first keeps the dependency obvious.
*
* Conventions for anyone sweeping values to these tokens:
* - Map each literal to the NEAREST semantic token, then fall back to a
* primitive ramp step. Consolidating near-duplicate grays is the point.
* - Never invent a new literal in a component; add a token here instead.
* - Spacing/radii/type use the scales below no off-scale px values.
*/
:root {
/* ===== Color primitives — neutral ramp ===== */
--c-white: #ffffff;
--c-gray-50: #fafafa;
--c-gray-100: #f3f4f6;
--c-gray-150: #f0f0ee; /* the app's warm canvas tint */
--c-gray-200: #e5e7eb;
--c-gray-300: #d1d5db;
--c-gray-400: #9ca3af;
--c-gray-500: #6b7280;
--c-gray-600: #4b5563;
--c-gray-700: #374151;
--c-gray-800: #1f2937;
--c-gray-900: #111111;
--c-ink: #1a1a1a; /* near-black used for the header + body text */
/* ===== Color primitives — accent (indigo/violet) ===== */
--c-accent: #5b5bd6;
--c-accent-strong: #4338ca;
--c-violet: #7c3aed;
/* ===== Color primitives — status ===== */
--c-success-fg: #166534;
--c-success-bg: #dcfce7;
--c-danger-fg: #991b1b;
--c-danger-fg-strong: #b91c1c;
--c-danger-bg: #fef2f2;
--c-danger-border: #fecaca;
--c-warning-fg: #92400e;
--c-warning-accent: #b45309;
--c-warning-bg: #fef3c7;
--c-warning-bg-soft:#fffbeb;
/* ===== Semantic colors ===== */
--color-bg: var(--c-gray-150);
--color-surface: var(--c-white);
--color-surface-sunken: var(--c-gray-50);
--color-surface-muted: var(--c-gray-100);
--color-header-bg: var(--c-ink);
--color-text: var(--c-ink);
--color-text-strong: var(--c-gray-900);
--color-text-muted: var(--c-gray-500);
--color-text-subtle: var(--c-gray-400);
--color-text-inverse: var(--c-white);
--color-border: var(--c-gray-200);
--color-border-strong: var(--c-gray-300);
--color-link: var(--c-accent);
--color-accent: var(--c-accent);
--color-accent-strong: var(--c-accent-strong);
--color-accent-contrast: var(--c-white);
--color-success-fg: var(--c-success-fg);
--color-success-bg: var(--c-success-bg);
--color-danger-fg: var(--c-danger-fg);
--color-danger-bg: var(--c-danger-bg);
--color-warning-fg: var(--c-warning-fg);
--color-warning-bg: var(--c-warning-bg);
/* On the dark header, translucent white is the established pattern. */
--color-on-dark-soft: rgba(255, 255, 255, 0.15);
--color-on-dark-hover: rgba(255, 255, 255, 0.25);
--color-on-dark-muted: #dddddd;
--color-focus-ring: rgba(91, 91, 214, 0.45);
/* ===== Type ===== */
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Helvetica, Arial, sans-serif;
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
monospace;
--text-2xs: 10px;
--text-xs: 11px;
--text-sm: 12px;
--text-base: 13px; /* the app's dominant body size */
--text-md: 14px;
--text-lg: 16px;
--text-xl: 18px;
--text-2xl: 22px;
--text-3xl: 28px;
--leading-tight: 1.25;
--leading-normal: 1.5;
--leading-relaxed: 1.65;
--weight-normal: 400;
--weight-medium: 500;
--weight-semibold: 600;
--weight-bold: 700;
/* ===== Spacing scale (4-based, with the 2/6/10 half-steps the app
* already leans on heavily) ===== */
--space-0: 0;
--space-1: 2px;
--space-2: 4px;
--space-3: 6px;
--space-4: 8px;
--space-5: 10px;
--space-6: 12px;
--space-7: 16px;
--space-8: 20px;
--space-9: 24px;
--space-10: 32px;
--space-11: 48px;
--space-12: 64px;
/* ===== Radius ===== */
--radius-xs: 2px;
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--radius-xl: 12px;
--radius-pill: 999px;
/* ===== Elevation ===== */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06);
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.08);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12);
/* ===== Motion ===== */
--motion-fast: 120ms;
--motion-base: 150ms;
--motion-slow: 200ms;
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
/* ===== Layout ===== */
--header-height: 48px;
}
/* Honor reduced-motion globally any transition/animation that reads
* these duration tokens collapses to instant. */
@media (prefers-reduced-motion: reduce) {
:root {
--motion-fast: 0ms;
--motion-base: 0ms;
--motion-slow: 0ms;
}
}