Compare commits

..

1 Commits

Author SHA1 Message Date
Ben Stull 41b0c6af99 Release 0.17.0: admin-create user + invite email (custom message; claim-link claim flow)
Roadmap item #16 / §6.1. From the v0.9.0 /admin/users surface, an
admin can now create a user record before that person has ever
signed in — typing first name, last name, email, role, and an
optional custom message — and the framework sends an invite email
carrying a single-use claim link. The invitee clicks through to
/invites/claim?token=…, the token is consumed, the session is
established, and the user is routed to the passcode-set screen on
first sign-in.

New endpoints:
  * POST /api/admin/users — admin-only; provisions the users row +
    user_invite_tokens row + sends the invite email + writes a
    permission_events row with event_kind='user_invited'.
  * GET  /api/admin/users/invites — admin-only; lists active
    (not-claimed, not-expired) invites with the issuing admin.
  * POST /api/invites/claim — anonymous-reachable; validates the
    token, consumes the row, signs the invitee in (skipping OTC
    per the roadmap — clicking the email link is itself proof of
    email control), returns needs_passcode for the frontend's
    route-onward decision.

Schema: migration slot 019 — user_invite_tokens (id, email, role,
first/last name, custom_message, bcrypt token_hash, expires_at,
created_at, created_by_admin_id, claimed_at, claimed_by_user_id,
invited_user_id). Slot 018 reserved for the parallel #12 release
(per-RFC invitation) shipping in the same wave; distinct table
(rfc_invitations there vs. user_invite_tokens here) so they
coexist cleanly. No users-table changes — the brief floated a
NULL-column discriminator for "(pending invite)" but the existing
users.last_seen_at is NOT NULL with a datetime('now') default, so
the discriminator is the active user_invite_tokens row joined on
invited_user_id; the admin user-listing carries a pending_invite
field populated via that join.

Claim route: frontend /invites/claim?token=… (new
InviteClaim.jsx). Anonymous-reachable; renders "Claim my account"
CTA with an optional v0.11.0-style "trust this device" checkbox,
calls the claim endpoint, routes onward.

Token shape: opaque DB token (256 bits CSPRNG via
secrets.token_urlsafe(32), bcrypt-at-rest), not JWT. Opaque
chosen because admin revocation is then a single SQL UPDATE — JWT
would be stateless but harder to invalidate.

Open-question decisions: immediate-send (no admin-review-then-
send queue; future enhancement), no bulk-invite (deferred to
follow-up; v0.17.0 is one-at-a-time), 7-day expiry as a constant
(INVITE_TOKEN_TTL_DAYS in backend/app/invites.py; env-var
configurability is a §19.2 candidate), OTC skipped on first
sign-in (the token in the email is itself proof of email control;
subsequent sign-ins go through OTC / passcode unchanged).

Refusals on POST /api/admin/users:
  * 422 self-invite (use the role-change channel for self-edits)
  * 409 duplicate email (use the existing grant/role gestures)
  * 422 owner-grant by non-owner admin (§6.1 owner-zero is the
    only owner bootstrap path)
  * 422 pydantic — malformed email / unknown role /
    custom_message > 500 chars
  * 403 non-admin caller / 401 anonymous

15 new backend tests in test_admin_create_user_invite_vertical.py
(happy path, all four refusals, claim with valid / expired /
already-claimed / unknown token, pending-invite badge before and
after claim, listing admin-only). 234 total backend tests pass;
frontend build succeeds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 04:48:02 -07:00
67 changed files with 819 additions and 11013 deletions
-638
View File
@@ -23,280 +23,6 @@ skip versions are the composition of each intervening adjacent
release's steps in order — no A-to-B path is pre-computed beyond
that.
## 0.21.0 — 2026-05-28
UX-polish wave. Roadmap items #31 (comprehensive UX polish — foundation
slice), #24 (header "About" → "Philosophy"), #25 (inbox icon + light UX),
and #32 (session/transcript page polish). Pure frontend; no schema, no
backend changes, no new secret. Shipped from one driver session (0019.0)
via three parallel subagents working on disjoint surfaces.
1. **Design-token foundation (#31).** New `frontend/src/styles/tokens.css`
establishes the app's first coherent design system — semantic color
palette, type scale, spacing scale, radius scale, elevation, and a
motion vocabulary (with `prefers-reduced-motion` honored) — as CSS
custom properties, imported first in `main.jsx`. Before this the app
carried ~98 distinct hardcoded hex colors, font sizes across 16
unscaled values, and radii across 13. `App.css` and `index.css` were
swept to the tokens (~630 color / 280 font-size / 128 radius
references), consolidating near-duplicate grays to the nearest ramp
step and rounding off-scale type to the nearest step. No CSS class was
renamed or removed; an additive `:focus-visible` ring and a subtle
hover/transition layer were added. 35 special-purpose hexes (true
blues/violets, status dots, deep diff-contrast shades) were
deliberately left as literals. This is the polish *foundation*; a
follow-up (#31b) covers the bespoke per-surface re-spacing that wants
operator review against screenshots.
2. **Header: "About" → "Philosophy" (#24).** The persistent header link
now reads "Philosophy" (the route `/philosophy` and its page already
existed; only the label changed).
3. **Inbox icon + light UX (#25).** The header inbox trigger's `📮`
emoji is replaced with a dependency-free inline-SVG envelope icon
(`aria-label="Inbox"`); no icon library was added. The inbox panel
got a light pass — clearer unread/read distinction, mark-all-read and
per-row affordances surfaced, better empty state, tokenized spacing in
a new component-scoped `Inbox.css`. Behavior, filters, deep-links, and
§15 notification data flow are unchanged. A full inbox redesign is
deferred to a #25 follow-up (the operator's reference screenshot did
not transmit).
4. **Session/transcript page polish (#32).** `/docs/sessions/<NNNN>` no
longer dead-ends on a "select a transcript" placeholder: a
single-transcript session renders that transcript inline at the
session root; a multi-transcript session renders its `.0` driver
transcript inline and lists the siblings. Each rendered transcript now
carries a metadata header — session title, Started/Ended (parsed from
the filename's ISO segments), derived Duration, an optional one-line
TL;DR, and a "View source on git.wiggleverse.org" external link to the
canonical raw transcript. The TL;DR reads an optional `tldr` string on
the per-session `sessions.json` manifest entry and degrades gracefully
when absent.
Upgrade steps:
MAY: add a `tldr` string to any per-session entry in
`wiggleverse/ohm-session-history`'s `sessions.json`
(e.g. `"0019": { "title": "…", "tldr": "one-line summary" }`) to surface
a summary in each transcript's metadata header. Absent `tldr` renders
nothing — no deployment action is required. This is a data edit in the
session-history repo, not a `flotilla` gesture.
## 0.20.0 — 2026-05-28
Wave 9 follow-up to roadmap item #30. Three changes bundled into one minor:
1. **Specs on `/docs/specs/<name>`** — a new public surface alongside the user guide that renders the framework's spec corpus at runtime. Configured via the `OHM_DOCS_SPECS` env var; the framework default carries OHM's two specs (`rfc-app/SPEC.md` and `ohm-rfc-app-flotilla/SPEC.md`) fetched from gitea raw URLs with a 5-minute TTL cache. Each spec page renders the current version only — git is the history surface; a "View source" link beside the title points at the upstream raw URL. Bare `/docs/specs` client-side redirects to the first configured spec (or renders a "no specs configured" empty state if the deployment cleared the list).
2. **Nested flyout nav hierarchy**`/docs/*` nav now renders sessions as a tree: each session row has its transcripts nested under it as nav children, labeled by their `.N` ordinal (`0014.0`, `0014.1`, …). Each session's transcript index is fetched alongside the manifest on layout mount (Promise.all over the manifest's keys); the backend's 5-minute content TTL makes the repeat cost negligible. Always-expanded — at the current scale (≤20 sessions) lazy expansion isn't worth the click. A new "Specs" section sits between User Guide and Sessions, populated by the new manifest endpoint.
3. **`/docs/sessions/<NNNN>` body-list removed** — operator preference: navigation lives in the left nav, not in body content. The per-session page is now a session-overview card (title + transcript count + "select a transcript from the navigation" hint). Empty-state, not-found, and error paths preserved; only the inline transcript-link list is gone.
Upgrade steps:
MAY: `flotilla overlay set ohm-rfc-app OHM_DOCS_SPECS='<JSON array>'` to override the configured spec set. Each entry is `{"name": "<slug>", "title": "<human label>", "url": "<gitea raw URL>"}`. Malformed JSON, a non-array root, or an entry that fails validation (missing fields, non-slug `name`) logs a warning and falls back to the framework default; deployment startup is never crashed by a bad value.
MAY: `flotilla overlay set ohm-rfc-app OHM_DOCS_SPECS_CONTENT_TTL_SEC=300` to tune the per-spec content cache TTL.
Note on the `frontend/package-lock.json` version drift fix: the lockfile's top-level and `packages.""` version fields drifted to `0.15.0` somewhere in the v0.16.0v0.19.0 window and weren't caught. This release syncs them to `0.20.0` alongside `frontend/package.json` and `VERSION`. No dependency changes; only the version-string fields move.
## 0.19.0 — 2026-05-28
Roadmap item #30: docs nav with on-site sessions browser. Adds a left-side flyout nav on `/docs/*` and three new public surfaces — `/docs/sessions/about` (renders the session-history README), `/docs/sessions/<NNNN>` (per-session index), `/docs/sessions/<NNNN>/<filename>` (per-transcript view). Backend mediates the fetch from `wiggleverse/ohm-session-history` over gitea raw URLs with a small in-process TTL cache (60 s manifest, 5 min content; both env-tunable). Existing `/docs` content moves to `/docs/user-guide`; bare `/docs` redirects.
Upgrade steps:
MAY: `flotilla overlay set ohm-rfc-app OHM_SESSION_HISTORY_RAW_BASE=<url>` if the deployment points at a non-OHM transcript repo. Default in code matches OHM's `wiggleverse/ohm-session-history`.
MAY: `flotilla overlay set ohm-rfc-app OHM_DOCS_SESSIONS_MANIFEST_TTL_SEC=60` and `OHM_DOCS_SESSIONS_CONTENT_TTL_SEC=300` to tune cache TTLs.
Note: this release depends on the parallel restructure of `wiggleverse/ohm-session-history` into per-session `NNNN/` folders + `README.md` + `sessions.json` (driver session 0017.0, subsession 0017.2). If the repo is still flat at deploy time, `/docs/sessions/about` and the per-session pages return 404 and the route tree degrades to "About not yet published" — no JS crashes; the User Guide remains fully functional.
## 0.18.0 — 2026-05-28
**Minor — schema migration required; one env var now mandatory; no
new secrets.** This release lands the framework-side half of OHM
roadmap items #18 (Secure the SMTP relay + Gitea webhook) and #20
(Email deliverability). It is the framework counterpart to the
operator-side SMTP / DNS hardening covered in the
`EMAIL-AND-WEBHOOK-HARDENING-RUNBOOK.md` companion doc.
The release is shipped in five atomic slices per the v0.18.0 proposal:
1. **`build_envelope` shared helper.** A single place where every
outbound `EmailMessage` is constructed. Lands the
deliverability-critical headers (`Date`, `Message-ID`,
`Auto-Submitted`) uniformly across OTC, invite, watcher
notification, bundle, and digest paths; exposes per-kind
unsubscribe semantics (none for OTC, mailto: for invites, full
one-click for bulk-adjacent paths) as explicit kwargs.
2. **Migrated send paths.** `email_otc.py`, `email_invite.py`,
`email._deliver`, `email._send_bundle`, and `digest.py` now
build their envelopes through the helper. Per-RFC invite
(v0.16.0) rides through `email_invite.py`'s helper and picks
up the change for free. The shared `_SENT` test buffer also
carries the constructed `EmailMessage` under `envelope["message"]`
so tests can assert on the header surface directly.
3. **Webhook handler tightening.** `GITEA_WEBHOOK_SECRET` is now
mandatory at startup — the framework refuses to load_config()
if it's empty, unless the operator explicitly opts into the
dev-bypass with `RFC_APP_INSECURE_WEBHOOKS=1`. The
`/api/webhooks/gitea` receiver carries defense-in-depth checks
that surface the misconfiguration loudly at the request layer
too. Mis-targeted webhooks (a hook on a fork or a stale Gitea
binding) now log at INFO instead of silently 200-OK'ing.
4. **`outbound_emails` audit table + admin endpoint.** Every send
helper writes one row to `outbound_emails` before returning,
capturing the send attempt regardless of outcome
(status='sent' / 'failed' / 'deferred'). The new admin endpoint
`GET /api/admin/outbound-emails` (filterable by kind / status /
to_address) lets the operator answer "did this person ever get
their invite?" without grepping VM logs. No admin UI ships
with v0.18.0; operator queries via curl + jq for now.
5. **Bounce correlation.** The `POST /api/webhooks/email-bounce`
body accepts a new optional `message_id` field; when supplied,
the handler stamps status='bounced' on the matching
`outbound_emails` row and returns the row id as
`correlated_id`. The pre-existing hard-bounce ->
`email_opt_out_all = 1` flow still fires.
The `POST /api/email/unsubscribe` endpoint also lands in Slice 2
as the matching receiver for the new `List-Unsubscribe-Post:
List-Unsubscribe=One-Click` header (Gmail and Yahoo POST that
payload on the user's one-click action per RFC 8058 — the GET
endpoint alone is no longer sufficient for senders at OHM's tier).
### Added
- **`backend/app/email_envelope.py`** — the `build_envelope` helper.
Single source of truth for every outbound `EmailMessage`'s
headers + body shape. Standalone module so tests can exercise it
without booting the FastAPI app.
- **`backend/migrations/020_outbound_emails.sql`** — the audit
table. Single new table with three indexes (to_address, sent_at,
message_id); no changes to existing tables.
- **`email.record_outbound(...)`** — best-effort write helper every
send path calls. Catches `RuntimeError` (so pure-helper unit
tests where `db.init()` was never called don't break) and any
other exception (so the audit write never breaks a send).
- **`GET /api/admin/outbound-emails`** in `api_admin.py`
admin-only listing of `outbound_emails`. Newest-first, filterable
by kind, status, and to_address (case-insensitive). Returns
`{items: [...], has_more}` per the rest of the admin endpoints'
shape.
- **`POST /api/email/unsubscribe`** in `api_notifications.py`
RFC 8058 one-click receiver. Accepts the same `?t=` token as the
GET handler; idempotent; returns 200 + `{ok, category}` on
success.
- **`all` synthetic category** for unsubscribe URLs. Used by the
bundle + digest paths (which can't honor per-category opt-outs
because they span multiple categories); flips
`email_opt_out_all = 1` rather than a per-category column.
### Changed
- **`backend/app/email.py`** — `EmailConfig` gains
`unsubscribe_mailto` (env: `EMAIL_UNSUBSCRIBE_MAILTO`, default
falls back to `EMAIL_FROM`). `_deliver` and `_send_bundle`
build envelopes through `build_envelope` and thread `kind` +
`notification_id` into the audit write.
- **`backend/app/email_otc.py`** + **`email_invite.py`** — both
call `build_envelope` and `record_outbound`. OTC carries no
`List-Unsubscribe` (recipient explicitly requested the code);
invite carries `List-Unsubscribe: <mailto:…>` only (no signed
URL — the invitee isn't a user yet, no per-user opt-out row
exists).
- **`backend/app/digest.py`** — calls `_deliver` with `kind='digest'`
+ the new `all`-category one-click unsubscribe.
- **`backend/app/webhooks.py`** — refuses 500 at request time if
the secret is empty + bypass isn't set; logs a loud warning
per-request when running under the bypass; logs INFO when a
hook targets a repo not in `cached_rfcs`.
- **`backend/app/config.py`** — `load_config()` raises
RuntimeError if `GITEA_WEBHOOK_SECRET` is empty unless
`RFC_APP_INSECURE_WEBHOOKS=1`.
- **`backend/app/api_notifications.py`** — GET unsubscribe handler
accepts the `all` category (sets `email_opt_out_all = 1`).
Bounce webhook body adds optional `message_id` field; response
shape adds `correlated_id` field. (Tests that read the exact
response shape — currently just
`test_bounce_webhook_refuses_unsigned_when_secret_configured`
in test_e2e_smoke.py — updated to assert on the new shape.)
- **`backend/tests/test_propose_vertical.py`** — the shared
`tmp_env` fixture binds a fake `GITEA_WEBHOOK_SECRET` so all
252 pre-v0.18.0 tests boot cleanly under the new mandatory
secret. Tests that want to exercise the dev-bypass path
monkeypatch `RFC_APP_INSECURE_WEBHOOKS=1` explicitly.
### Tests
- 15 new unit tests in `test_email_envelope.py` for the helper.
- 10 new integration tests across `test_otc_vertical`,
`test_admin_create_user_invite_vertical`, and
`test_notifications_vertical` covering: OTC has no
List-Unsubscribe; invite has mailto: only; notification has
full one-click; POST one-click flips per-category; `all` flips
global; respects `EMAIL_UNSUBSCRIBE_MAILTO` override.
- 7 new integration tests in `test_webhooks_vertical.py` covering
the startup-time mandatory-secret check, the dev-bypass, and
the request-time signature verification including the unknown-
repo log line.
- 11 new integration tests in `test_outbound_emails_vertical.py`
covering the audit table write path (OTC / invite / notification),
the admin endpoint (list, filter by kind, filter by to_address,
non-admin refusal), and the bounce correlation (matched
message_id stamps status='bounced'; unknown message_id is
logged; absent message_id falls back to legacy behavior; bounced
rows surface in admin endpoint with `?status=bounced`).
- One test updated for intentional response-shape change:
`test_e2e_smoke.test_bounce_webhook_refuses_unsigned_when_secret_configured`.
Full suite: 295 passed (was 252 pre-v0.18.0).
### Migration
- **`backend/migrations/020_outbound_emails.sql`** — auto-applied
on next backend start. Single new table with three indexes; no
changes to existing tables.
### Upgrade steps (from 0.17.0)
- Operators **MUST** ensure `GITEA_WEBHOOK_SECRET` is set in the
deployment's env. The framework now refuses to start if it's
empty. (For OHM-flotilla deployments,
`flotilla secret list <deployment>` confirms the binding; OHM
has carried this binding since v0.14.0, so the upgrade is
gesture-free for OHM specifically.)
- Operators **MAY** set `RFC_APP_INSECURE_WEBHOOKS=1` to bypass
the requirement in local-dev environments. Production
deployments **MUST NOT** set this; if they do, every webhook
POST logs a loud warning line per request.
- Operators **MAY** set `EMAIL_UNSUBSCRIBE_MAILTO` to route
`List-Unsubscribe: <mailto:…>` opt-out courtesy mail to a
humans-monitored mailbox distinct from the no-reply
`EMAIL_FROM` sender. Default falls back to `EMAIL_FROM`.
- You **MUST** apply schema migration
`020_outbound_emails.sql`. The migration creates a single new
table with three indexes; the framework runs migrations
automatically at process start, so no manual step is required
beyond restarting the backend so the migration runner picks
the file up. Existing deployments pick it up on first start
after upgrade with no operator action required.
- You **MUST** rebuild the frontend and restart the backend
after upgrading. `frontend/package.json#version` and `VERSION`
both move to `0.18.0`. No new secrets (the `outbound_emails`
table writes synchronously to the same SQLite file as every
other write).
- Operators **SHOULD** run a `mail-tester.com` probe against the
upgraded deployment to confirm the new envelope headers
(`Date`, `Message-ID`, `Auto-Submitted`, `List-Unsubscribe`,
`List-Unsubscribe-Post`) land cleanly with the upstream SMTP
relay's DKIM signing. The expected delta from pre-v0.18.0 is
+2-3 points on the spam-score axis (typical 5-6/10 baseline
→ 9+/10 post-upgrade).
## 0.17.0 — 2026-05-28
**Minor — schema migration required; no new env vars; no new secrets.**
@@ -421,24 +147,6 @@ module docstring + the migration's header comment):
(the textarea shows a "chars left" counter against the 500-char
ceiling). On submit, the modal calls
`POST /api/admin/users` and refreshes the user listing.
- **Amplitude wiring** (per `ohm-rfc/ROADMAP.md` #21 Part C, shipped
inline with v0.17.0): `USER_INVITED` event fires from
`CreateUserInviteModal` on successful invite-send with
`{ target_user_id, initial_role, custom_message_chars }` — the
OHM `invited_user_id` returned by `POST /api/admin/users`
becomes the dashboard's binding for the future Amplitude user
record; `custom_message_chars` is a coarse signal of admin
effort (0 = template-only, 1+ = personalized) and carries no
PII. `INVITE_CLAIMED` event fires from `InviteClaim.jsx` on
successful claim with `{ invited_by_admin_id, initial_role,
needs_passcode, trust_device }` — BUT the claim handler first
calls `identify({ user_id, 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, never as an anonymous device that
retroactively links. setOnce semantics preserve the original
invite context even if the invitee later changes roles.
- **"(pending invite)" badge** inline on the user-listing's
per-row handle (rendered when the row's `pending_invite` field
is populated by the backend's join through `user_invite_tokens`).
@@ -495,352 +203,6 @@ module docstring + the migration's header comment):
end-to-end once at least one admin exists.
## 0.16.0 — 2026-05-28
**Minor — schema migration auto-applied; no operator action.** This
release lands the owner-only invite for per-RFC PR or PR-less
discussion (roadmap item #12). The RFC's owner can now invite
specific users by email to one of two per-RFC roles —
`contributor` (open PRs against the RFC AND join its discussion) or
`discussant` (join the discussion only). Non-invited users keep
the v0.6.0 anonymous-read contract: they can read but cannot
write/discuss that RFC. Invitations are token-encoded in a
transactional email; acceptance lands a per-RFC collaborator row
and surfaces in the admin user-management page (additive on the
existing `/api/admin/users` shape) so the platform-grant decision
has the per-RFC context to inform it. The platform-level grant
remains the admin's call — this release adds a per-RFC membership
layer beneath it, not a new platform-grant path.
The per-RFC write gate is layered on top of the existing
`require_contributor` (v0.8.0) gate, not in place of it: a user
must be platform-granted AND hold an accepted per-RFC role (or
be the RFC owner / a platform admin/owner) to write. A super-
draft with no frontmatter owners yet (pre-§13.1 claim) falls
through to the platform-granted contract — there's no owner to
issue invitations, so the gate is open until one exists. This
preserves the v0.6.0 / v0.7.0 / v0.8.0 contracts inside their
domains and confines item #12's change to "an RFC has owners →
those owners decide who writes."
### Added
- **`backend/migrations/018_rfc_invitations.sql`** — two tables.
`rfc_invitations` carries the lifecycle row (issued, accepted,
revoked, expired) with the opaque token the email link encodes,
the inviter, the invitee email, the role-in-RFC, and the 30-day
expiry. `rfc_collaborators` is the accepted-invitation
substrate — the compact (rfc, user, role) shape the write gate
consults. Both tables are FK-cascaded against `cached_rfcs` and
`users` per §5's cascade rules. Indexed for the owner's listing,
the accept-by-token lookup, and the per-user read.
- **`backend/app/api_invitations.py`** — the §17 surface. Five
endpoints: `POST /api/rfcs/{slug}/invitations` (create + email),
`GET /api/rfcs/{slug}/invitations` (owner's listing),
`POST /api/rfcs/{slug}/invitations/{id}/revoke`,
`GET /api/invitations/accept?token=…` (preview), and
`POST /api/invitations/accept` (redeem). The email reuses
`EmailConfig.from_env()` and the `_SENT` buffer the OTC and
notification mailers share — transactional, no preferences
honored, no unsubscribe footer. A failure to send does NOT
roll back the row; the owner has the token on the listing
surface for an out-of-band share.
- **`backend/app/auth.py`** — four helpers. `is_rfc_owner`
reads the frontmatter `owners_json`. `is_rfc_collaborator`
reads the v0.16.0 `rfc_collaborators` table. `can_discuss_rfc`
and `can_contribute_to_rfc` are the composite predicates the
write endpoints consult (platform admin/owner OR no-owners-yet
fall-through OR RFC owner OR per-RFC collaborator at the right
role). `can_invite_to_rfc` is the issue-side predicate (RFC
owner or platform admin/owner only — collaborators don't get
invite power).
- **`frontend/src/components/InvitationsModal.jsx`** — the RFC
owner's surface: an email input + role picker for sending,
and a status table for listing/revoking. Visible only to the
RFC's owner or a platform admin/owner (the backend gates the
endpoints regardless).
- **`frontend/src/components/AcceptInvitation.jsx`** — the
`/invitations/accept?token=…` landing page. Previews what the
invitation grants, refuses on email mismatch / revoked /
expired with a single sentence each, redirects to the RFC's
view on accept.
- **API client (`frontend/src/api.js`)** — five new helpers:
`listRFCInvitations`, `createRFCInvitation`,
`revokeRFCInvitation`, `previewInvitation`, `acceptInvitation`.
- **Amplitude wiring** (per `ohm-rfc/ROADMAP.md` #21 Part C, shipped
inline with v0.16.0): `INVITATION_SENT` event fires from
`InvitationsModal.jsx` on successful send with `{ rfc_slug,
role_in_rfc }`; `INVITATION_ACCEPTED` event fires from
`AcceptInvitation.jsx` on successful accept with the same shape —
but the accept path first calls `identify({ user_id, properties:
{ invited_at (setOnce), last_invited_to_rfc,
last_invite_role_in_rfc, claim_method: 'rfc-invite' } })` so the
Amplitude user record carries the invite context from the moment
of acceptance. No invitee email or other PII enters the event
body — only the slug, role, and the inviter's identity (through
the standard signed-in identify on the inviter's session).
### Changed
- **`backend/app/api.py`** — registers
`api_invitations.make_router()` alongside the existing routers.
- **`backend/app/api_discussion.py`** — `POST .../discussion/threads`
and `POST .../discussion/threads/{thread_id}/messages` now compose
the new `auth.can_discuss_rfc` predicate after the existing
`require_contributor` check. A platform-granted user without a
per-RFC discussion role on an RFC with owners gets 403 with
"This RFC's owner has not invited you to its discussion."
- **`backend/app/api_branches.py`** — `POST .../promote-to-branch`
and `POST .../start-edit-branch` now compose
`auth.can_contribute_to_rfc`. Same shape: platform-granted but
uninvited → 403.
- **`backend/app/api_prs.py`** — `POST .../open-pr` also composes
`auth.can_contribute_to_rfc` so a user whose per-RFC role was
revoked between branch-cut and PR-open is refused at the
ship line.
- **`backend/app/api_admin.py`** — `GET /api/admin/users` carries
a new `rfc_invitations` array per user (empty if none), naming
each accepted per-RFC collaboration with the RFC slug/title,
the role, the inviter, and the timestamp. Additive — the
existing v0.9.0 columns are unchanged; consumers that don't
read the new field see the legacy shape.
- **`frontend/src/App.jsx`** — registers the
`/invitations/accept` route (visible to anonymous + signed-in
viewers; signed-out viewers see a sign-in prompt).
- **`frontend/src/components/RFCView.jsx`** — additive
"Invitations" button in the RFC header strip, visible to RFC
owners and platform admins/owners on both super-drafts and
active RFCs. Mounts the new modal on click.
- **`backend/tests/test_propose_vertical.py`** — adds the
`grant_rfc_collaborator` test helper so v0.5.0/v0.6.0/v0.8.0-era
tests that exercise non-owner contribution can opt into the new
invitation contract without rewriting their setup.
- **`backend/tests/test_pr_flow_vertical.py`,
`backend/tests/test_graduation_vertical.py`,
`backend/tests/test_e2e_smoke.py`** — three tests that signed in
as non-owner contributors now seed an accepted per-RFC
collaborator row first (mirroring the production invite→accept
dance). The test intent is unchanged; the precondition is now
explicit.
### Migration
- **`018_rfc_invitations.sql`** — auto-applied on backend start by
the existing `db.run_migrations()` sweep. The two new tables
are empty at upgrade time; no existing data is touched. No
operator gesture needed.
### Upgrade steps (from 0.15.0, or 0.14.0 if 0.15.0 is skipped)
- You **MUST** rebuild the frontend and restart the backend after
upgrading so the new endpoints, the migration, the gate
composition in `api_discussion`/`api_branches`/`api_prs`, and the
new frontend routes/components are picked up. `frontend/package.json#version`
and `VERSION` both move to `0.16.0`.
- You **MUST NOT** set any new env var — there are no new secrets
and no new overlay keys. The email path reuses the existing
`SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `SMTP_PASSWORD` /
`EMAIL_FROM` / `EMAIL_FROM_NAME` / `APP_URL` / `EMAIL_ENABLED`
variables that the v0.7.0 OTC and v0.5.0 notification paths
already require. Deployments that have those wired need no
configuration change.
- You **MUST NOT** apply the migration manually — the backend's
migration runner picks up `018_rfc_invitations.sql` on next
start. (If you've configured an external migration tool, run it
before starting the backend; the framework's own runner is
idempotent against already-applied migrations.)
- You **SHOULD** inform existing RFC owners that they can now
invite collaborators from the RFC view's header strip. RFCs
with frontmatter owners that pre-date this release see no
behavioral change for the owner; the change is visible to
non-owner contributors who previously could write on any RFC
and now must be invited first.
- You **MAY** seed `rfc_collaborators` rows directly via SQL for
pre-existing per-RFC working relationships you want to
grandfather past the v0.16.0 cutover. The `invitation_id`
column is nullable for exactly this purpose. Production
deployments without that history can ignore this option.
## 0.15.0 — 2026-05-28
**Minor — no schema migration; one new build-time env var bound via
`flotilla overlay set`.** This release ships Amplitude Analytics +
Session Replay instrumentation (roadmap item #13). The frontend
gains a small wrapper around `@amplitude/unified` that gates SDK
initialization on the v0.13.0 cookie/privacy consent — the SDK is
never loaded for visitors who have not granted analytics consent,
no session is recorded, no network request fires; a later consent
flip to `denied` calls `setOptOut(true)` so events and session
replay stop immediately. The wrapper exposes a stable taxonomy of
nine events (Page Viewed, RFC Viewed, User Signed In / Signed Out,
RFC Proposed, PR Opened, Comment Posted, Beta Access Requested, Admin
Permission Decision) wired into the existing routes, the Login flow,
the propose / open-PR / discussion / PR-review surfaces, and the
admin grant/revoke action. Event bodies carry only ids and enums; no
free-text fields (titles, comment bodies, names, emails) are ever
sent. **Session replay** records sessions at `sampleRate: 1` (100%)
— vendor-recommended default; gated by the same v0.13.0 analytics
consent. The Amplitude API key is read from `VITE_AMPLITUDE_API_KEY`
at build time; when unset, the wrapper logs one console warning and
no-ops so dev environments without analytics keep working. No backend
events ship in this release — Amplitude SaaS holds the events,
nothing lands in our DB, no migration.
### Added
- **Analytics wrapper** (`frontend/src/lib/analytics.js`). Public
surface: `track(name, props)`, `identify({ user_id, properties? })`,
`setUserProperties(properties)`, `anonymize()`, the `EVENTS`
taxonomy constant, and a `__resetForTests` helper. Internally
lazy-imports `@amplitude/unified` and calls
`amplitude.initAll(API_KEY, { analytics: { autocapture: true },
sessionReplay: { sampleRate: 1 } })` only after consent is
granted; queues pre-init calls and drains them on init resolve;
flips `setOptOut(true)` on a granted→denied consent change (stops
both analytics events and session replay). The wrapper subscribes
to `onConsentChange()` so a freshly-banner-clicked "analytics on"
flips the SDK live without a page reload.
- **User identity lifecycle** (per `ohm-rfc/ROADMAP.md` #21 Part C —
shipped inline with v0.15.0 instead of waiting for a follow-up).
`identify({ user_id, properties })` accepts a property bag that
applies as an Amplitude `Identify` event with `.set()` semantics
by default; values wrapped as `['__setOnce__', value]` apply with
`.setOnce()` semantics (immutable after first write — for
account-history markers like `first_sign_in_at`). The new
`setUserProperties(properties)` exposes the same property-apply
path for mid-session state changes (role grant/revoke, passcode
set, device trusted) so the Amplitude record stays current without
waiting for the next sign-in. `anonymize()` now 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.
- **Event taxonomy** wired into the app:
- `Page Viewed` — fires from `App.jsx` on every route change with
`path` (`location.pathname`); the location hook owns the firing
and dedupes by path.
- `RFC Viewed` — fires from `RFCView.jsx` once per slug load with
`rfc_slug` and `rfc_id`.
- `User Signed In` — fires from `Login.jsx` with
`method ∈ { 'otc', 'passcode', 'trust-device' }` matching the
three sign-in paths from v0.7.0 / v0.10.0 / v0.11.0.
- `User Signed Out` — fires from `App.jsx`'s "Sign out" click,
followed by `anonymize()` to clear the SDK's user binding before
the hard nav to `/auth/logout`.
- `RFC Proposed` — fires from `ProposeModal.jsx` on submit success
with `rfc_slug`.
- `PR Opened` — fires from `PRModal.jsx` on submit success with
`rfc_slug` and `pr_number`.
- `Comment Posted` — fires from `RFCDiscussionPanel.jsx`
(`surface: 'discussion'`) and from `PRView.jsx`
(`surface: 'pr'`, with `pr_number`) on each post-success.
- `Beta Access Requested` — fires from `Login.jsx` capture-profile
submit success. No PII in the event.
- `Admin Permission Decision` — fires from `Admin.jsx`'s grant /
revoke action with `action ∈ { 'grant', 'revoke' }` and
`target_user_id` (string).
- **User binding + properties** (`App.jsx`): when `me.authenticated`
lands and a user id is available, the wrapper's
`identify({ user_id, properties })` is called with
`String(viewer.id)` AND a durable property bag — `role`,
`permission_state`, `passcode_set`, `device_trusted` (mutable;
refresh each sign-in), plus `first_sign_in_at` and
`account_created_at` (setOnce — immutable user-history markers).
The sign-out gesture calls `anonymize()` before the nav. No email,
display name, gitea_login, or other PII is passed through the SDK —
Amplitude only sees opaque ids, enums, timestamps, booleans.
- **`@amplitude/unified`** dependency added to
`frontend/package.json` (analytics + session replay in one
install). Lockfile updated.
- **`VITE_AMPLITUDE_API_KEY`** documented in `frontend/.env.example`
with the secret-vs-overlay binding caveat (see below).
### Changed
- **`frontend/src/App.jsx`** — adds `useLocation` for the route-change
Page Viewed firing, a `lastUserIdRef` memo to call
`identify` once per signed-in viewer, and an `onClick` handler on
the "Sign out" link that fires `User Signed Out` + `anonymize()`
before the hard nav.
- **`frontend/src/components/Login.jsx`** — fires `User Signed In`
with the appropriate `method` at each of the three sign-in points
(trust-device cookie path, passcode verify success, OTC verify
success), and fires `Beta Access Requested` on capture-profile
submit success.
- **`frontend/src/components/ProposeModal.jsx`** — fires `RFC Proposed`
with `rfc_slug` on submit success.
- **`frontend/src/components/RFCView.jsx`** — fires `RFC Viewed`
inside the `getRFC` resolution so the event is keyed on the slug
param and includes the loaded `rfc_id`.
- **`frontend/src/components/PRModal.jsx`** — fires `PR Opened` with
`rfc_slug` and `pr_number` on submit success.
- **`frontend/src/components/RFCDiscussionPanel.jsx`** — fires
`Comment Posted` with `surface: 'discussion'` on send-success.
- **`frontend/src/components/PRView.jsx`** — fires `Comment Posted`
with `surface: 'pr'` and `pr_number` on review-comment success.
- **`frontend/src/components/Admin.jsx`** — fires
`Admin Permission Decision` on grant/revoke success.
### Migration
- **No schema migration.** Amplitude SaaS holds the events; the
framework's DB is unchanged. Migration slot **015** is unused by
this release and remains available for the next minor that needs a
schema bump.
### Caveat — overlay binding for `VITE_AMPLITUDE_API_KEY`
Amplitude browser API keys are embedded in the frontend bundle at
build time and visible to anyone with browser dev tools. They are
public by design — same nature as the v0.12.0
`VITE_TURNSTILE_SITE_KEY` (also public, also bundle-embedded,
explicitly contrasted with `CLOUDFLARE_TURNSTILE_SECRET` which is
the real secret-half of that pair). The Amplitude installation
guidance from the vendor shows the key inline as a literal string
argument to `initAll(…)`, confirming the public framing. This
release accordingly binds the value via `flotilla overlay set`,
not `flotilla secret set` — the env-var name is `VITE_AMPLITUDE_API_KEY`
(Vite-prefix convention, so the build picks it up directly without
an alias step).
(Roadmap row #13 originally said "new secret: AMPLITUDE_API_KEY";
that wording predated vendor consultation. Mid-Session-L the
operator provisioned the Amplitude project, surfaced the vendor's
recommended init prompt, and the binding settled as overlay. The
roadmap row will be updated to match when #13 ships.)
### Caveat — session replay scope and consent
This release enables Amplitude Session Replay at `sampleRate: 1`
(100% of sessions recorded for full-DOM playback). The vendor's
installation wizard recommends this default for new deployments —
maximum learning during the early phase. The v0.13.0 single
"analytics" consent toggle gates session replay together with
events, so no recording happens without explicit opt-in. A future
release **MAY** split this into a separate consent category for
session replay specifically (recording has a meaningfully larger
privacy footprint than event counters); §19.2 candidate.
### Upgrade steps (from 0.14.0)
- You **MUST** install the new frontend dependency before building:
`cd frontend && npm install` picks up `@amplitude/unified` from
the updated `frontend/package.json` and the refreshed
`package-lock.json`. The lockfile change is committed.
- You **MUST** rebuild the frontend after upgrading so the analytics
wrapper and its consent gate ship to viewers. `frontend/package.json#version`
and `VERSION` both move to `0.15.0`. No schema migration; the
backend is unchanged for this release.
- **MUST**: before deploying, the operator runs `/Users/benstull/projects/wiggleverse/ohm-rfc-app-flotilla/.venv/bin/ohm-rfc-app-flotilla overlay set ohm-rfc-app VITE_AMPLITUDE_API_KEY=<key>` to bind the Amplitude project's public API key. (Receiving the value in the conversation is fine — it's bundle-embedded by design, same as `VITE_TURNSTILE_SITE_KEY`.) The deploy **SHOULD NOT** proceed before this binding exists; if the binding is absent, the frontend's analytics wrapper no-ops with a console warning and the rest of the app continues to function — but no events or session replays are sent.
- You **MAY** leave `VITE_AMPLITUDE_API_KEY` unset in dev environments
— the wrapper detects the empty value and no-ops with a single
console warning. The app, the consent banner, and every other
surface keep working unchanged.
- You **SHOULD** verify after deploy that the Amplitude dashboard
receives events and a session replay when a consenting browser
exercises one of the taxonomy events (the easiest probe: open the
deployed site in an Incognito window, accept analytics on the
consent banner, navigate to an RFC, and watch the project's live
event stream + replay panel).
## 0.14.0 — 2026-05-28
**Minor — no operator action required; new optional env var.** This
release ships `DOCS.md` and the `/docs` route — a public-facing user
-407
View File
@@ -1,407 +0,0 @@
# 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.
-469
View File
@@ -2284,13 +2284,6 @@ 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:
@@ -4307,465 +4300,3 @@ 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.21.0
0.17.0
-181
View File
@@ -15,7 +15,6 @@ 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 (
@@ -23,15 +22,12 @@ 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,
@@ -106,12 +102,6 @@ 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.
@@ -146,177 +136,6 @@ 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.
# ---------------------------------------------------------------
-110
View File
@@ -119,17 +119,6 @@ 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(
@@ -155,36 +144,6 @@ 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
@@ -229,8 +188,6 @@ 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.
@@ -672,73 +629,6 @@ 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,15 +279,6 @@ 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()
@@ -340,14 +331,6 @@ def make_router(
@router.post("/api/rfcs/{slug}/start-edit-branch")
async def start_edit_branch(slug: str, body: StartEditBranchBody, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
# v0.16.0 (item #12): same per-RFC contribute gate as
# promote-to-branch — kicking off a super-draft edit branch is
# also PR-shaped work.
if not auth.can_contribute_to_rfc(viewer, slug):
raise HTTPException(
403,
"This RFC's owner has not invited you to contribute PRs",
)
rfc = _require_super_draft(slug)
owner, repo = _repo_for(rfc)
new_branch = (body.branch_name or "").strip()
-17
View File
@@ -116,17 +116,6 @@ 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
@@ -186,12 +175,6 @@ 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
@@ -1,575 +0,0 @@
"""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
+13 -115
View File
@@ -73,13 +73,6 @@ 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):
@@ -450,40 +443,6 @@ 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:
@@ -494,51 +453,20 @@ def make_router(config: Config) -> APIRouter:
"<p>Open the app to manage your notification preferences directly.</p>",
status_code=400,
)
if not _apply_unsubscribe(user_id, category):
column = {
"personal-direct": "email_personal_direct",
"structural": "email_watched_structural",
"admin-actionable": "email_admin_actionable",
}.get(category)
if column is None:
return HTMLResponse(
f"<h1>Unknown category</h1><p>{category}</p>", status_code=400
)
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}
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>"
)
@router.post("/api/webhooks/email-bounce")
async def email_bounce(body: BounceBody, request: Request) -> dict[str, Any]:
@@ -562,46 +490,16 @@ def make_router(config: Config) -> APIRouter:
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, "correlated_id": correlated_row_id}
return {"ok": True, "matched": False}
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, "correlated_id": correlated_row_id}
return {"ok": True, "matched": True}
return router
-11
View File
@@ -112,17 +112,6 @@ def make_router(
@router.post("/api/rfcs/{slug}/branches/{branch:path}/open-pr")
async def open_pr(slug: str, branch: str, body: OpenPRBody, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
# v0.16.0 (item #12): opening a PR is the canonical PR-shaped
# write — the gate fires here even though the branch-cutting
# entry points also gate, since a user with prior branch access
# who's since had their per-RFC role revoked shouldn't be able
# to ship the PR. The branch-creation gate is the kickoff
# refusal; this one is the post-work refusal.
if not auth.can_contribute_to_rfc(viewer, slug):
raise HTTPException(
403,
"This RFC's owner has not invited you to contribute PRs",
)
rfc = _require_active_rfc(slug)
if branch == "main":
raise HTTPException(409, "PRs open from non-main branches")
-154
View File
@@ -290,159 +290,5 @@ 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)
+1 -15
View File
@@ -60,20 +60,6 @@ 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"),
@@ -86,7 +72,7 @@ def load_config() -> Config:
secret_key=_required("SECRET_KEY"),
database_path=database_path,
owner_gitea_login=_optional("OWNER_GITEA_LOGIN"),
webhook_secret=webhook_secret,
webhook_secret=_optional("GITEA_WEBHOOK_SECRET"),
enabled_models=enabled,
anthropic_api_key=_optional("ANTHROPIC_API_KEY"),
google_api_key=_optional("GOOGLE_API_KEY"),
+1 -13
View File
@@ -180,19 +180,7 @@ def assemble_for_user(
subject = _subject(eligible, cadence)
body = _body(eligible, cadence, cfg)
# 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",
)
sent = email_mod._deliver(cfg, email, subject, body)
if not sent:
return False
ids = [r["id"] for r, _ in eligible]
-358
View File
@@ -1,358 +0,0 @@
"""§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
@@ -1,326 +0,0 @@
"""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}"}
+11 -166
View File
@@ -24,6 +24,7 @@ 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
@@ -32,7 +33,6 @@ from urllib.parse import urlencode
from itsdangerous import BadSignature, URLSafeSerializer
from . import db
from .email_envelope import build_envelope
log = logging.getLogger(__name__)
@@ -69,7 +69,6 @@ class EmailConfig:
app_url: str
bundle_threshold: int
enabled: bool
unsubscribe_mailto: str
@classmethod
def from_env(cls) -> "EmailConfig":
@@ -85,16 +84,6 @@ 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(),
)
@@ -109,14 +98,6 @@ 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})
@@ -269,21 +250,7 @@ def _send_one(user: Any, notif_id: int, payload: dict, category: str) -> None:
return
subject = _subject(payload)
body = _body(payload, user["id"], category, cfg)
# 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,
)
sent = _deliver(cfg, user["email"], subject, body)
if not sent:
return
db.conn().execute(
@@ -338,65 +305,23 @@ def _deep_link(payload: dict, cfg: EmailConfig) -> str:
return cfg.app_url
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,
)
def _deliver(cfg: EmailConfig, to_address: str, subject: str, body: str) -> bool:
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:
@@ -406,76 +331,10 @@ def _deliver(
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 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
log.exception("email send failed: to=%s subject=%s", to_address, subject)
return False
# ---------------------------------------------------------------------------
@@ -581,27 +440,13 @@ 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]
-143
View File
@@ -1,143 +0,0 @@
"""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`, if supplied, lands as the
second part of a `multipart/alternative` body mail clients
that prefer HTML render it; clients that don't fall back to the
plain part. The text/plain part comes first per RFC 2046, so a
plain-text client that picks the first body gets the readable
text.
`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:
# multipart/alternative: text/plain first, text/html second.
# `set_content` sets the first part (and the message's main
# body); `add_alternative` adds the second part and
# restructures the message as multipart/alternative.
msg.set_content(body_plain)
msg.add_alternative(body_html, subtype="html")
else:
msg.set_content(body_plain)
return msg
+8 -38
View File
@@ -31,10 +31,10 @@ from __future__ import annotations
import logging
import smtplib
from email.message import EmailMessage
from email.utils import formataddr
from .email import EmailConfig, _SENT, record_outbound
from .email_envelope import build_envelope
from .email import EmailConfig, _SENT
log = logging.getLogger(__name__)
@@ -59,52 +59,31 @@ def send_invite_email(
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:
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:
@@ -114,18 +93,9 @@ def send_invite_email(
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:
except Exception:
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
+8 -34
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, record_outbound
from .email_envelope import build_envelope
from .email import EmailConfig, _SENT
log = logging.getLogger(__name__)
@@ -44,48 +44,31 @@ 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:
@@ -95,18 +78,9 @@ 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 as exc:
except Exception:
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
+1 -33
View File
@@ -12,7 +12,6 @@ import hashlib
import hmac
import json
import logging
import os
from fastapi import APIRouter, Header, HTTPException, Request
@@ -41,27 +40,7 @@ def make_router(config: Config, gitea: Gitea) -> APIRouter:
x_gitea_signature: str = Header(default=""),
):
body = await request.body()
# 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 config.webhook_secret:
if not _verify_signature(body, x_gitea_signature, config.webhook_secret):
raise HTTPException(status_code=401, detail="Invalid signature")
@@ -89,17 +68,6 @@ 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
@@ -1,177 +0,0 @@
-- §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);
@@ -1,30 +0,0 @@
-- 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);
@@ -647,82 +647,3 @@ def test_pending_invites_listing_admin_only(app_with_fake_gitea):
)
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"]
@@ -1,379 +0,0 @@
"""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
@@ -1,469 +0,0 @@
"""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"
+1 -11
View File
@@ -22,7 +22,6 @@ 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,
@@ -132,11 +131,6 @@ 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={})
@@ -231,17 +225,13 @@ 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, "correlated_id": None}
assert r.json() == {"ok": True, "matched": False}
def test_bounce_webhook_open_when_secret_unset(app_with_fake_gitea):
-173
View File
@@ -1,173 +0,0 @@
"""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
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_with_html_is_multipart_alternative():
msg = build_envelope(**_base_kwargs(body_html="<p>Hello, <b>world</b>.</p>"))
assert msg.get_content_type() == "multipart/alternative"
# Two parts: text/plain first (so plain-text clients picking the
# first part get the readable text), text/html second.
parts = list(msg.iter_parts())
assert len(parts) == 2
assert parts[0].get_content_type() == "text/plain"
assert parts[1].get_content_type() == "text/html"
assert "Hello, world." in parts[0].get_content()
assert "<b>world</b>" in parts[1].get_content()
@@ -34,7 +34,6 @@ 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,
@@ -249,9 +248,6 @@ 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")
@@ -501,8 +497,6 @@ 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,127 +612,3 @@ 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,53 +347,3 @@ 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
@@ -1,368 +0,0 @@
"""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,7 +21,6 @@ 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,
@@ -141,9 +140,6 @@ 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",
@@ -296,9 +292,6 @@ 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",
@@ -371,10 +364,6 @@ 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")
+1 -27
View File
@@ -395,28 +395,6 @@ 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
# ---------------------------------------------------------------------------
@@ -438,11 +416,7 @@ def tmp_env(monkeypatch):
"SECRET_KEY": "test-secret-key-for-cookies",
"DATABASE_PATH": str(db_path),
"OWNER_GITEA_LOGIN": "ben",
# 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",
"GITEA_WEBHOOK_SECRET": "",
"ENABLED_MODELS": "claude",
}
for k, v in env.items():
@@ -1,658 +0,0 @@
"""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"] == []
-205
View File
@@ -1,205 +0,0 @@
"""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]}"
-23
View File
@@ -62,26 +62,3 @@ 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=
+10 -529
View File
@@ -1,14 +1,13 @@
{
"name": "rfc-app-frontend",
"version": "0.21.0",
"version": "0.12.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "rfc-app-frontend",
"version": "0.21.0",
"version": "0.12.0",
"dependencies": {
"@amplitude/unified": "^1.1.9",
"@codemirror/commands": "^6.10.3",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/language": "^6.12.3",
@@ -31,360 +30,6 @@
"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",
@@ -619,12 +264,6 @@
"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",
@@ -1018,49 +657,6 @@
"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",
@@ -1513,12 +1109,6 @@
"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",
@@ -1772,12 +1362,6 @@
"@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",
@@ -1815,12 +1399,6 @@
"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",
@@ -1857,41 +1435,6 @@
}
}
},
"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",
@@ -2478,12 +2021,6 @@
"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",
@@ -2511,12 +2048,6 @@
}
}
},
"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",
@@ -2550,18 +2081,6 @@
"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",
@@ -2581,12 +2100,6 @@
"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",
@@ -2908,15 +2421,6 @@
"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",
@@ -2970,16 +2474,11 @@
"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",
@@ -3016,12 +2515,14 @@
"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"
@@ -3050,6 +2551,7 @@
"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",
@@ -3326,12 +2828,6 @@
"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",
@@ -3354,6 +2850,7 @@
"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"
@@ -3410,13 +2907,9 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"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"
"dev": true,
"license": "0BSD",
"optional": true
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
@@ -3523,18 +3016,6 @@
"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"
}
}
}
+1 -2
View File
@@ -1,7 +1,7 @@
{
"name": "rfc-app-frontend",
"private": true,
"version": "0.21.0",
"version": "0.17.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -9,7 +9,6 @@
"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",
+685 -979
View File
File diff suppressed because it is too large Load Diff
+9 -120
View File
@@ -1,7 +1,6 @@
import { useEffect, useRef, useState } from 'react'
import { Routes, Route, Link, Navigate, useLocation, useNavigate } from 'react-router-dom'
import { useEffect, useState } from 'react'
import { Routes, Route, Link, useNavigate } from 'react-router-dom'
import { getMe, subscribeToNotifications } from './api'
import { anonymize, EVENTS, identify, track } from './lib/analytics'
import Catalog from './components/Catalog.jsx'
import Inbox from './components/Inbox.jsx'
import RFCView from './components/RFCView.jsx'
@@ -12,16 +11,9 @@ 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 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 Docs from './components/Docs.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'
@@ -43,56 +35,6 @@ export default function App() {
// event that bumps this.
const [consentReopenTick, setConsentReopenTick] = useState(0)
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 })
} 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])
useEffect(() => {
const handler = () => setConsentReopenTick(t => t + 1)
@@ -169,7 +111,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)">
Philosophy
About
</Link>
<Link to="/docs" className="header-about" title="User guide">
Docs
@@ -188,17 +130,9 @@ export default function App() {
<button
className="inbox-trigger"
onClick={() => setInboxOpen(o => !o)}
aria-label="Inbox"
title="Inbox (§15.2)"
title="Notifications inbox (§15.2)"
>
<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>
<span aria-hidden>📮</span>
{unreadCount > 0 && (
<span className="badge">{unreadCount > 99 ? '99+' : unreadCount}</span>
)}
@@ -208,20 +142,7 @@ 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"
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>
<a className="btn-link" href="/auth/logout">Sign out</a>
</>
) : (
<Link className="btn-signin-header" to="/login" title="Private beta — only invited emails can sign in">
@@ -236,24 +157,12 @@ export default function App() {
<Route path="/welcome" element={<Landing />} />
<Route path="/login" element={<Login />} />
<Route path="/beta-pending" element={<BetaPending viewer={viewer} />} />
{/* v0.16.0 (item #12): per-RFC invitation acceptance landing.
Anonymous viewers see a sign-in prompt; signed-in users
see the preview + accept gesture. */}
<Route path="/invitations/accept" element={
<PolicyShell><AcceptInvitation viewer={viewer} /></PolicyShell>
} />
{/* 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} />} />
{/* 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} />} />
<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>} />
@@ -323,29 +232,9 @@ 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">
<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>
<Docs authenticated={!!viewer} />
</main>
)
}
-121
View File
@@ -322,48 +322,6 @@ 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
@@ -720,85 +678,6 @@ 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).
@@ -1,207 +0,0 @@
// 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>
)
}
-18
View File
@@ -25,7 +25,6 @@ import {
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
@@ -145,12 +144,6 @@ 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) {
@@ -416,17 +409,6 @@ function CreateUserInviteModal({ onClose, onSuccess }) {
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.
-128
View File
@@ -1,128 +0,0 @@
/* 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
@@ -0,0 +1,51 @@
// `/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
@@ -1,340 +0,0 @@
// 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>
)
}
@@ -1,246 +0,0 @@
// 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>
)
}
@@ -1,266 +0,0 @@
// 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>
)
}
@@ -1,99 +0,0 @@
// 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
@@ -1,134 +0,0 @@
// 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>
)
}
@@ -1,82 +0,0 @@
// 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
@@ -1,81 +0,0 @@
// 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>
)
}
-128
View File
@@ -1,128 +0,0 @@
/* 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);
}
+11 -56
View File
@@ -15,7 +15,6 @@ import {
markNotificationRead,
markNotificationsReadByFilter,
} from '../api.js'
import './Inbox.css'
const CATEGORIES = [
{ value: '', label: 'All categories' },
@@ -57,15 +56,11 @@ 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) {
await markOneRead(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))
}
}
async function markAllUnderFilter() {
@@ -126,36 +121,22 @@ export default function Inbox({ onClose, lastChangeTick }) {
</label>
<button
className="btn-link inbox-mark-all"
className="btn-link"
onClick={markAllUnderFilter}
disabled={items.every(i => i.read_at)}
title="Mark every notification matching the current filter as read"
>
Mark all read
Mark all read (under filter)
</button>
</div>
<div className="inbox-body">
{loading && <p className="inbox-state muted">Loading your inbox</p>}
{loading && <p className="muted">Loading</p>}
{!loading && items.length === 0 && (
<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>
<p className="muted">No notifications match. Try a different filter, or come back later.</p>
)}
<ul className="inbox-list">
{items.map(item => (
<InboxRow
key={item.id}
item={item}
onClick={handleRowClick}
onMarkRead={markOneRead}
onClose={onClose}
/>
<InboxRow key={item.id} item={item} onClick={handleRowClick} onClose={onClose} />
))}
</ul>
</div>
@@ -164,24 +145,16 @@ export default function Inbox({ onClose, lastChangeTick }) {
)
}
function InboxRow({ item, onClick, onMarkRead, onClose }) {
function InboxRow({ item, onClick, 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' : 'read'}`}>
<li className={`inbox-row ${unread ? 'unread' : ''}`}>
<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 && (
@@ -189,24 +162,6 @@ function InboxRow({ item, onClick, onMarkRead, 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>
)
}
@@ -1,202 +0,0 @@
// 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>
)
}
-29
View File
@@ -23,7 +23,6 @@
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()
@@ -51,34 +50,6 @@ export default function InviteClaim() {
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')
+1 -21
View File
@@ -85,7 +85,6 @@ 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
@@ -151,12 +150,7 @@ export default function Login() {
;(async () => {
try {
await startDeviceTrust()
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('/')
}
if (!cancelled) window.location.assign('/')
} catch (_) {
// No trusted device fall through to the email step.
}
@@ -212,10 +206,6 @@ 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
@@ -274,11 +264,6 @@ 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).
@@ -335,11 +320,6 @@ 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
-4
View File
@@ -10,7 +10,6 @@
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('')
@@ -40,9 +39,6 @@ export default function PRModal({ slug, branch, branchIsPrivate, onClose, onOpen
setError(null)
try {
const { pr_number } = await openPR(slug, branch, { title: title.trim(), description: description.trim() })
// 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)
-5
View File
@@ -22,7 +22,6 @@ import {
startResolutionBranch,
withdrawPR,
} from '../api'
import { EVENTS, track } from '../lib/analytics'
export default function PRView({ viewer }) {
const { slug, prNumber: prNumberParam } = useParams()
@@ -136,10 +135,6 @@ 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()
-5
View File
@@ -11,7 +11,6 @@
import { useEffect, useState } from 'react'
import { proposeRFC } from '../api'
import { EVENTS, track } from '../lib/analytics'
function slugify(title) {
return title
@@ -53,10 +52,6 @@ export default function ProposeModal({ viewer, onClose, onSubmitted }) {
pitch: pitch.trim(),
tags,
})
// 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.')
@@ -18,7 +18,6 @@ import {
postDiscussionMessage,
resolveDiscussionThread,
} from '../api'
import { EVENTS, track } from '../lib/analytics'
export default function RFCDiscussionPanel({ slug, viewer }) {
const [threads, setThreads] = useState([])
@@ -101,10 +100,6 @@ 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 {
+1 -38
View File
@@ -43,9 +43,7 @@ import RFCDiscussionPanel from './RFCDiscussionPanel.jsx'
import ChangePanel, { diffWords } from './ChangePanel.jsx'
import PRModal from './PRModal.jsx'
import GraduateDialog from './GraduateDialog.jsx'
import InvitationsModal from './InvitationsModal.jsx'
import { claimOwnership } from '../api'
import { EVENTS, track } from '../lib/analytics'
const MANUAL_IDLE_MS = 5 * 60 * 1000 // §8.6 idle window; exact value is impl detail.
const MANUAL_DEBOUNCE_MS = 800
@@ -123,15 +121,7 @@ export default function RFCView({ viewer }) {
const [drawerOpen, setDrawerOpen] = useState(false)
useEffect(() => {
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))
getRFC(slug).then(setEntry).catch(err => setError(err.message))
listModels(slug)
.then(({ models, default: def }) => {
setModels(models || [])
@@ -149,11 +139,6 @@ 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(() => {
@@ -639,20 +624,6 @@ 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 && (
@@ -885,14 +856,6 @@ export default function RFCView({ viewer }) {
/>
)}
{showInvitationsModal && (
<InvitationsModal
slug={slug}
rfcTitle={entry?.title}
onClose={() => setShowInvitationsModal(false)}
/>
)}
{showMetadataPane && (
<MetadataPaneModal
slug={slug}
+4 -3
View File
@@ -1,7 +1,8 @@
:root {
font-family: var(--font-sans);
color: var(--color-text);
background: var(--color-bg);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
Arial, sans-serif;
color: #1a1a1a;
background: #fafaf8;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
-391
View File
@@ -1,391 +0,0 @@
// 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
}
-1
View File
@@ -2,7 +2,6 @@ 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
@@ -1,162 +0,0 @@
/* 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;
}
}