Commit Graph

237 Commits

Author SHA1 Message Date
Ben Stull 1558cc3a8b Release v0.30.0: sync user guide (DOCS.md) with the shipped app
Documentation-only minor. The guide had drifted since it was first
written; brought back in sync with v0.7.0–v0.29.0:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v0.17.0
2026-05-28 05:10:52 -07:00
Ben Stull ee4925b6ac Release 0.16.0: owner-only invite for per-RFC contribution + discussion (+ #21 Part C Amplitude wiring)
Wave 5 / Track B. Roadmap item #12. Folds in #21 Part C Amplitude
wiring inline (operator ask: "best practices from the very get-go").

RFC owners can invite specific users to one of two per-RFC roles:
contributor (open PRs + join discussion) or discussant (discussion
only). Non-invited users keep the v0.6.0 anonymous-read contract.
The per-RFC write gate layers on top of the existing
require_contributor gate; a super-draft with no owners yet falls
through to the platform-granted contract, preserving the
v0.6.0/v0.7.0/v0.8.0 contracts in their domains.

Backend: migration 018_rfc_invitations.sql (auto-applied — two
tables: rfc_invitations + rfc_collaborators); api_invitations.py
with five endpoints + transactional email; auth.py helpers
(is_rfc_owner / is_rfc_collaborator / can_discuss_rfc /
can_contribute_to_rfc / can_invite_to_rfc); api_discussion +
api_branches + api_prs gate composition; api_admin.py additive
rfc_invitations[] per user. 237 backend tests pass (18 new in
test_rfc_invitations_vertical.py).

Frontend: InvitationsModal.jsx (owner surface), AcceptInvitation.jsx
(/invitations/accept route), api.js helpers, RFCView.jsx
Invitations button, App.jsx route registration.

Amplitude wiring (inline, #21 Part C):
  - INVITATION_SENT from InvitationsModal { rfc_slug, role_in_rfc }
  - INVITATION_ACCEPTED from AcceptInvitation { rfc_slug, role_in_rfc }
  - identify() BEFORE the accept event with properties: invited_at
    (setOnce), last_invited_to_rfc, last_invite_role_in_rfc,
    claim_method: 'rfc-invite'
  - EVENTS taxonomy extended with INVITATION_SENT + INVITATION_ACCEPTED

No new secrets, no new overlay keys, no operator gesture beyond the
v0.15.0 overlay-set + restart. Frontend build verified green.

Subagent ν shipped the feature on feature/v0.16.0-owner-invite
(a51beec). Driver-side integration squash-merged into main,
hand-resolved VERSION + package.json + CHANGELOG (strict-descending
0.16.0 → 0.15.0), and added the inline Amplitude wiring.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v0.15.0
2026-05-28 05:04:10 -07:00
Ben Stull b3f1b15f65 Release 0.12.0: CloudFlare Turnstile on OTC email-entry v0.12.0 2026-05-28 03:52:45 -07:00
Ben Stull 6fb68a95c7 Release 0.11.0: trust device for 30 days v0.11.0 2026-05-28 03:46:24 -07:00
Ben Stull 7872b921ed Release 0.9.0: admin user-management page + new-request notifications v0.9.0 2026-05-28 03:39:25 -07:00
Ben Stull de28272914 Release 0.14.0: public /docs user guide (DOCS.md served at /docs)
Ships DOCS.md and the /docs route — a plain-prose translation of
SPEC.md for users, distinct from the binding spec. Originating need
was the admin-vs-owner role distinction on /admin/users (§6.1);
response is a single guide that covers the framework's user-facing
surfaces end-to-end (roles, proposing, branches, PRs, graduation,
notifications). Mirrors /philosophy: markdown file at repo root +
sibling backend loader + MarkdownPreview render. No schema migration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v0.14.0
2026-05-28 03:08:28 -07:00