7e595b6e5e
An admin-created invite row showed a Last-seen timestamp identical to
Signed-up, implying the invitee had visited. users.last_seen_at is
NOT NULL DEFAULT (datetime('now')) and the invite INSERT sets neither
timestamp, so both default to row-creation time; last_seen_at only
advances on real authentication. An unclaimed invite has provably never
authenticated (the unclaimed state drives the PENDING INVITE badge), so
the Users tab now renders "Never" for the Last-seen cell of a pending
row. Signed-up (invite-created date) unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
3004 lines
158 KiB
Markdown
3004 lines
158 KiB
Markdown
# Changelog
|
||
|
||
The binding policy for what each kind of version bump means and what
|
||
the changelog has to carry is in [`SPEC.md` §20](./SPEC.md). The
|
||
practical recipe downstream deployments follow when reading this file
|
||
is in [`docs/DEPLOYMENTS.md`](./docs/DEPLOYMENTS.md).
|
||
|
||
The canonical version is the `VERSION` file at the repo root;
|
||
`frontend/package.json#version` mirrors it. Deployments pin to a
|
||
specific framework version in their own `.rfc-app-version` file
|
||
(per §20.5).
|
||
|
||
While the framework is pre-1.0, minor-version bumps may introduce
|
||
breaking changes; the entry below each such bump documents the
|
||
upgrade steps a deployment must apply.
|
||
|
||
Upgrade-steps blocks use the [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119)
|
||
/ [RFC 8174](https://www.rfc-editor.org/rfc/rfc8174) normative-
|
||
language convention per `SPEC.md` §20.4. **MUST** / **SHALL** steps
|
||
are required; **SHOULD** steps are recommended (the framework's
|
||
default and tested path); **MAY** steps are optional. Upgrades that
|
||
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.31.3 — 2026-05-30
|
||
|
||
**Patch — admin Users tab: "Last seen" reads "Never" for unclaimed
|
||
invites. Visual/logic only in `Admin.jsx`. A plain frontend rebuild
|
||
applies it.**
|
||
|
||
An admin-created invite row showed a real-looking "Last seen" timestamp
|
||
identical to "Signed up," implying the invitee had visited when they
|
||
hadn't. Cause: `users.last_seen_at` is `NOT NULL DEFAULT (datetime('now'))`
|
||
(`migrations/001_users_and_audit.sql`) and the invite INSERT
|
||
(`invites.py`) sets neither timestamp, so both default to the
|
||
row-creation instant; `last_seen_at` only advances on a real
|
||
authentication. Since an unclaimed invite has provably never
|
||
authenticated (that unclaimed state is exactly what drives the
|
||
"PENDING INVITE" badge), the Users tab now renders **"Never"** for the
|
||
Last-seen cell of a pending-invite row instead of the misleading
|
||
default. Signed-up (the invite-created date) is unchanged.
|
||
|
||
Upgrade steps: none. **SHOULD** deploy as a normal code deploy.
|
||
|
||
## 0.31.2 — 2026-05-29
|
||
|
||
**Patch — landing (`/`) welcome panel spacing. Visual only: CSS in
|
||
`App.css` (`.welcome`). A plain frontend rebuild applies it.**
|
||
|
||
The welcome read-view was jammed against the catalog divider with no
|
||
top offset and loose, uneven paragraph spacing. Root cause: `.main-pane`
|
||
carries a bare `.main-pane { padding: 0; display: flex }` override (the
|
||
§8 three-column RFC shell) that shadows the earlier padded read-view
|
||
rule, so the pane provides no padding — and `.welcome` (just
|
||
`max-width`) never compensated. The welcome surface now owns its own
|
||
breathing room: 56px top / 48px side gutters, a capped 680px measure,
|
||
a stronger `text-3xl` "Welcome." hero, and even `--space-8` paragraph
|
||
rhythm at `--leading-relaxed`. Applies to both the signed-out and
|
||
signed-in welcome (same `.welcome` class).
|
||
|
||
Upgrade steps: none. **SHOULD** deploy as a normal code deploy.
|
||
|
||
## 0.31.1 — 2026-05-29
|
||
|
||
**Patch — admin Users tab + header UX polish. Visual only: CSS plus
|
||
markup/structure in `Admin.jsx` (no API, schema, config, overlay, or
|
||
secret change). A plain frontend rebuild applies it.**
|
||
|
||
Two latent CSS defects fixed:
|
||
|
||
- **`.invite-badge` had no rule.** The "(pending invite)" marker on
|
||
admin-created-but-unclaimed user rows rendered as bare parenthetical
|
||
text. It's now a quiet amber pill, consistent with the other status
|
||
badges.
|
||
- **`.btn-link-quiet` never reset native button chrome.** Used as a
|
||
bare link-style `<button>` (admin Revoke / Grant / Remove, the modal
|
||
close ×, and link-buttons in Login / BetaPending), it kept the
|
||
browser's default grey button box. The reset that the `.otc-login`
|
||
scope already carried is folded into the base rule, so every
|
||
`btn-link-quiet` is now a true quiet link.
|
||
|
||
Users-tab cleanups, all token-based:
|
||
|
||
- Table column headers no longer wrap (`WRITE-MUTED` was breaking onto
|
||
two lines); timestamps render as an intentional date-over-time stack
|
||
instead of a ragged mid-value wrap; the duplicated email in a row's
|
||
subline (the handle already *is* the email when there's no Gitea
|
||
login) is de-duplicated; the "Create user + invite" action moves
|
||
flush-right beside the title; inline DB-column references in the
|
||
intro copy read as quiet chips.
|
||
|
||
Header:
|
||
|
||
- **Inbox (§15.2) trigger restyled for the dark header.** It carried a
|
||
light-surface treatment — a `gray-200` border and a `gray-50` hover —
|
||
that rendered as a pale box in the nav and went white-background /
|
||
white-icon (invisible) on hover. It now speaks the nav-link
|
||
vocabulary (`.header-about` et al.): borderless, `gray-300` icon
|
||
brightening to white on a faint translucent hover, unread badge
|
||
unchanged.
|
||
|
||
Upgrade steps: none. **SHOULD** deploy as a normal code deploy.
|
||
|
||
## 0.31.0 — 2026-05-29
|
||
|
||
**Minor — meta-only repository topology (SPEC §1, ROADMAP #36). RFCs no
|
||
longer graduate into their own Gitea repositories; every RFC lives in
|
||
its meta-repo entry (`rfcs/<slug>.md`) for its whole life, and
|
||
graduation is an in-place `super-draft → active` state flip that keeps
|
||
the body in the entry. The per-RFC-repo machinery — repo creation,
|
||
`RFC.md`/`README.md`/`.rfc/metadata.yaml` seeding, body-strip, the
|
||
five-step transactional sequence and its rollback — is removed. This is
|
||
the framework change behind a much simpler deployer story: one content
|
||
repository, every RFC under `rfcs/`.**
|
||
|
||
What changed, concretely:
|
||
|
||
- **Graduation is a single flip.** `POST /api/rfcs/<slug>/graduate` now
|
||
opens one meta-repo PR that re-serializes the entry with
|
||
`state: active`, the integer `id`, and `graduated_at`/`graduated_by`
|
||
— **body unchanged, `repo` left null** — then auto-merges it. No repo
|
||
is created, nothing is seeded, and there is no rollback (an open- or
|
||
merge-failure leaves the entry a super-draft; a failed merge's PR and
|
||
branch are cleaned up). The Graduate dialog drops the **Repo name**
|
||
field (two fields now: integer ID + owners) and the progress stack is
|
||
two steps (`open_pr`, `merge_pr`).
|
||
- **Active RFCs edit on the meta repo.** Branch/PR/chat dispatch keys on
|
||
meta-residency (`repo IS NULL`) rather than `state == 'super-draft'`,
|
||
so an active RFC's branches, body-edit PRs, threads, flags, and
|
||
`changes` all live on the meta repo exactly as a super-draft's do.
|
||
`promote-to-branch` names an active RFC's auto-branch
|
||
`edit-<slug>-<hex>` so the shared-repo cache can attribute it.
|
||
- **Open body-edit PRs no longer block graduation** (§9.8) — the body is
|
||
kept, so they coexist with the flip.
|
||
- **`refresh_rfc_repo` and the per-RFC read path are dead** for
|
||
meta-only entries (the reconciler only sweeps entries with a non-null
|
||
`repo`, of which there are none after the fold-back below).
|
||
|
||
**Upgrade steps:**
|
||
|
||
- Deployments **MUST** fold any already-graduated per-RFC-repo RFC back
|
||
into its meta entry before/with this deploy: restore the per-RFC
|
||
`RFC.md` body into `rfcs/<slug>.md`, set `repo: null` (keep
|
||
`state: active` and the integer `id`), and archive the per-RFC repo.
|
||
An entry left with a non-null `repo` keeps using the retained legacy
|
||
read path, but **no new** per-RFC repos are ever created. For OHM,
|
||
RFC-0001 `human` was folded back in driver session 0041.0 (§13.6).
|
||
- No schema migration, no new config, no new secret, no overlay change.
|
||
A plain code deploy applies it; the running reconciler reconciles the
|
||
catalog on its next sweep (≤5 min).
|
||
- The `repo:` frontmatter field and the `/api/rfcs/<slug>/blocking-prs`
|
||
endpoint are **retained** (the field for schema stability + legacy
|
||
entries; the endpoint as an informational, non-blocking surface), so
|
||
no client contract is removed — `graduate/check` simply no longer
|
||
returns a `repo` field and never reports `blocking_prs` as a gate.
|
||
|
||
## 0.30.2 — 2026-05-29
|
||
|
||
**Patch — header nav label: the persistent chrome link reverts from
|
||
"Philosophy" back to "About." Display text only — the route
|
||
(`/philosophy`), the `header-about` class, and the page itself are
|
||
unchanged. A plain frontend rebuild applies it; no schema, API, config,
|
||
overlay, or secret change.**
|
||
|
||
The §14.3 persistent link was relabeled "About" → "Philosophy" in
|
||
v0.21.0. This restores "About" as the neutral, framework-native label
|
||
(the CSS class `header-about` and the surrounding comment already call
|
||
it "the About link"). A deployment that wants a more pointed framing
|
||
can title its own About page in the `PHILOSOPHY.md` content the
|
||
`PHILOSOPHY_PATH` override serves.
|
||
|
||
Upgrade steps: none. **SHOULD** deploy as a normal code deploy.
|
||
|
||
## 0.30.1 — 2026-05-29
|
||
|
||
**Patch — bug fix: a merged idea-PR whose branch was deleted no longer
|
||
lingers as a phantom "pending idea." No schema, API, config, overlay,
|
||
or secret change — a plain code deploy applies it, and the running
|
||
reconciler clears any existing ghost on the next sweep (≤5 min) once
|
||
deployed. Shipped from driver session 0040.0.**
|
||
|
||
`refresh_meta_pulls` (and `refresh_rfc_repo`) recover a PR's slug/kind
|
||
by parsing its Gitea `head.ref`. When a PR is merged **and its branch
|
||
deleted**, Gitea stops reporting the real branch name and returns the
|
||
synthetic `refs/pull/<N>/head` sentinel instead. The slug then parsed
|
||
to `None`, the reconcile loop skipped the row, and `cached_prs.state`
|
||
stayed frozen at `open` forever — so the entry showed as **both** a
|
||
super-draft (the `cached_rfcs` push-event reconcile succeeded) **and** a
|
||
pending idea (the `cached_prs` PR-close reconcile never landed). The fix
|
||
recovers the original branch name from the already-stored `cached_prs`
|
||
row (which retains the real `head_branch` from when the PR was open;
|
||
migration 002) whenever Gitea reports an empty or `refs/pull/` sentinel
|
||
ref. Regression test added in `test_propose_vertical.py`
|
||
(`test_merged_idea_pr_with_deleted_branch_clears_proposal`).
|
||
|
||
Surfaced through the ROADMAP #35 operator authoring lane, which merges
|
||
idea PRs from the CLI with branch-deletion enabled — a path the web UX
|
||
never exercises (it leaves branches in place, so
|
||
`default_delete_branch_after_merge` stays false). The framework should
|
||
not depend on branches outliving their merge, hence the framework-level
|
||
fix rather than a tooling workaround.
|
||
|
||
Upgrade steps: none. **SHOULD** deploy as a normal code deploy; the
|
||
periodic reconciler self-heals any existing phantom on its next sweep.
|
||
|
||
## 0.30.0 — 2026-05-29
|
||
|
||
**Minor — documentation: the user guide (`DOCS.md`, served at
|
||
`/docs/user-guide` via `/api/docs`) brought back in sync with the
|
||
shipped app. No code, schema, API, config, overlay, or secret change —
|
||
a plain code deploy serves the updated guide. Shipped from driver
|
||
session 0037.0.**
|
||
|
||
The guide had drifted since it was first written: it still described
|
||
the pre-OTC email *allowlist* sign-in, listed four propose-RFC fields,
|
||
and predated several shipped surfaces. Updated to match v0.7.0–v0.29.0:
|
||
|
||
- **Signing in** rewritten for the email + one-time-code flow (v0.7.0),
|
||
optional passcode (v0.10.0), trust-this-device for 30 days (v0.11.0),
|
||
optional Cloudflare Turnstile (v0.12.0), and the beta-access request →
|
||
`pending` → admin-`granted` gate (v0.8.0 / #6), plus the admin-create
|
||
+ invite-claim path (v0.17.0 / #16). The vestigial allowlist is no
|
||
longer described as the gate.
|
||
- **Proposing a new RFC** now lists five fields, adding the optional
|
||
"What will you be using this RFC for?" use-case field (#26) and noting
|
||
the AI tag-suggestion disclosure (#27).
|
||
- **Roles & permissions** documents the `pending` state.
|
||
- New **Invitations, cross-references, and contribution requests**
|
||
section covers owner invitations (#12) and the RFC auto-link /
|
||
create-RFC / ask-to-contribute affordances (#28).
|
||
- New **Privacy and cookies** section covers the consent banner and
|
||
consent-gated analytics (#11 / #13).
|
||
|
||
Upgrade steps: none — documentation-only; the change is the `DOCS.md`
|
||
file served verbatim by `/api/docs`. A plain code deploy at this tag
|
||
serves it. A deployment that overrides the guide via `DOCS_PATH`
|
||
supplies its own copy and is unaffected.
|
||
|
||
## 0.29.0 — 2026-05-28
|
||
|
||
**Minor — roadmap #28 Parts 2 + 3: offer-to-create-an-RFC for strong-
|
||
candidate terms, and offer-to-contribute-to-a-pending-RFC. One auto-
|
||
applied migration (024, additive: a new `contribution_requests` table).
|
||
No config/overlay/secret change; no nginx/systemd change. A plain code
|
||
deploy + the auto-migration picks it up. Shipped from driver session
|
||
0033.0.**
|
||
|
||
Both parts extend the v0.26.0 (#28 Part 1) read-time scanner
|
||
(`backend/app/rfc_links.py`) and its renderer
|
||
(`frontend/src/components/LinkedText.jsx`). The scanner now sorts each
|
||
matched term into one of three buckets — active link (Part 1, unchanged),
|
||
pending-RFC contribute offer (Part 3), create-RFC offer (Part 2) — in one
|
||
pass, with precedence active > pending > candidate at any position. The
|
||
backend still emits only structured segments (never HTML), so the surface
|
||
stays XSS-safe by construction.
|
||
|
||
- **Part 2 — create-RFC offers.** A *strong-candidate* term — a
|
||
**multi-word tag** from the #27 tag taxonomy that has no defining RFC
|
||
(no active or super-draft RFC whose slug/title is that term) — renders,
|
||
for a viewer with create rights (`permission_state='granted'`), as an
|
||
inline "+ create RFC" affordance. Clicking opens the propose-RFC modal
|
||
with the term pre-filled as the title (`ProposeModal` gained an
|
||
`initialTitle`; the affordance routes via `?propose=<term>`, read in
|
||
`App.jsx`). The heuristic is deliberately conservative — multi-word is
|
||
the same false-positive guard the title rule uses, so a single common
|
||
tag word (`identity`) is never offered. Broader candidate detection
|
||
(capitalized phrases mined from text, terms repeated across recent PRs,
|
||
or the #27 Haiku `ANTHROPIC_API_KEY` pathway) is a sanctioned but
|
||
deferred extension.
|
||
- **Part 3 — contribute-to-pending offers.** A term matching a *pending*
|
||
RFC — a super-draft (`state='super-draft'`: accepted as an idea, owned,
|
||
with a contribution surface, not yet graduated) — renders, for a
|
||
signed-in non-owner, as an inline "ask to contribute" affordance
|
||
carrying the owner's display name ("<owner> is working on an RFC for
|
||
'<term>'"). It opens a contribute-request form (`?contribute=<slug>`)
|
||
with three fields — **who I am** (required), **why I'm asking**
|
||
(required), **what I'd use it for** (optional, mirroring #26). Submitting
|
||
lands a `contribution_requests` row and one actionable §15 inbox
|
||
notification per owner (new kind `contribution_request_on_pending_rfc`,
|
||
category `personal-direct` — so it reuses the existing
|
||
`email_personal_direct` preference, no new toggle). In the inbox the
|
||
owner sees the requester's who/why/use-case inline with **Accept** /
|
||
**Decline**. Accept fires #12's owner-invite flow with the requester as
|
||
the invitee (a `contributor` `rfc_invitations` row + the existing invite
|
||
email) and echoes a notification back to the requester; Decline closes
|
||
the request and notifies the requester. Pre-merge idea PRs (not yet in
|
||
`cached_rfcs`, no contribution surface) are deliberately out of scope —
|
||
a documented future extension.
|
||
|
||
New endpoints (all under the existing `/api` router):
|
||
`GET /api/rfcs/{slug}/contribution-target`,
|
||
`POST /api/rfcs/{slug}/contribution-requests`,
|
||
`POST /api/rfcs/{slug}/contribution-requests/{id}/accept`,
|
||
`POST /api/rfcs/{slug}/contribution-requests/{id}/decline`.
|
||
|
||
The owner-invite issue path was refactored into one reusable chokepoint,
|
||
`api_invitations.issue_invitation(...)`, shared by the manual invite
|
||
endpoint and Part 3's accept path so the dup-guard, token mint, insert,
|
||
and transactional email stay identical.
|
||
|
||
Upgrade steps:
|
||
|
||
1. Deployments **MUST** apply the auto-run migration `024` (additive: the
|
||
new `contribution_requests` table; no existing table or row is
|
||
touched). The standard deploy path runs pending migrations on start —
|
||
no manual step beyond deploying the new code.
|
||
2. No config, overlay, or secret change is required. The Part 2 candidate
|
||
affordance reuses #27's tag taxonomy; it surfaces only when the corpus
|
||
carries multi-word tags without a defining RFC, and the create
|
||
affordance renders only for beta-granted viewers. Part 3's email reuse
|
||
sends through the existing invitation SMTP path — no new key.
|
||
|
||
## 0.28.0 — 2026-05-28
|
||
|
||
**Minor — security-hardening follow-up (Session-0026 audit, informational
|
||
findings I3 + I4). No operator action required: no migration, no schema
|
||
change, no config/overlay change, no API/behavior change for any caller.
|
||
A plain code deploy picks it up. Shipped from driver session 0032.0.**
|
||
|
||
Two informational findings from the Session-0026 audit, both
|
||
framework-internal defense-in-depth:
|
||
|
||
- **I3 — dead HTML-email branch guarded.** `email_envelope.build_envelope`
|
||
accepted a `body_html=` argument that built a `multipart/alternative`
|
||
body, but no send path ever passed it — every rfc-app mail is plain
|
||
text. An unused branch that would emit HTML built from (potentially
|
||
unescaped) user content is the C1 stored-XSS class waiting in the mail
|
||
channel. The branch is now a loud guard: passing `body_html` raises
|
||
`NotImplementedError`. The argument is kept in the signature for
|
||
documented future symmetry; enabling HTML mail becomes a deliberate
|
||
change that MUST HTML-escape user content at the call site and remove
|
||
the guard in the same commit.
|
||
- **I4 — Turnstile siteverify no longer blocks the event loop.**
|
||
`turnstile.verify_token` was a synchronous function issuing a blocking
|
||
`httpx.post` from inside the async `/auth/otc/request` handler, so a
|
||
slow CloudFlare response stalled the single worker for up to the 10s
|
||
timeout. It is now `async` and awaits the call on an
|
||
`httpx.AsyncClient` (matching the codebase's existing async-httpx
|
||
pattern), isolated behind a narrow `_siteverify_post` seam. The sole
|
||
caller (`main.py`) now `await`s it.
|
||
|
||
Upgrade steps: **none.** Both changes are internal. The `verify_token`
|
||
signature changed from sync to `async` (callers must `await`), but the
|
||
only caller is in-tree (`main.py`) and is updated in this release; no
|
||
deployment-facing surface, config key, or migration is affected.
|
||
|
||
## 0.27.0 — 2026-05-28
|
||
|
||
**Minor — security-hardening release (Session-0026 audit remediation).
|
||
One auto-applied migration (023); one behavior change that re-prompts
|
||
device-trust; deployments MUST re-apply the nginx + systemd files.**
|
||
This is the work cut as the "v0.25.0 security-hardening" branch; it
|
||
reversioned to 0.27.0 because v0.26.0 (#28) took the next slot while it
|
||
was in flight. Shipped from driver session 0030.0.
|
||
|
||
- **C1 (Critical) — stored-XSS closed.** Every markdown→HTML sink now
|
||
routes through one chokepoint, `frontend/src/lib/sanitizeHtml.js`
|
||
(DOMPurify), before any `innerHTML` / `dangerouslySetInnerHTML` write:
|
||
`MarkdownPreview`, both `ProposalView` sinks (entry body +
|
||
`proposed_use_case`), and `Editor`. A hook adds
|
||
`rel="noopener noreferrer"` to `target=_blank` links. `marked` no
|
||
longer passes raw HTML / `javascript:` URIs to the DOM, so a
|
||
contributor can no longer plant a payload that runs in an admin/owner
|
||
session during review.
|
||
- **H1 — OTC verify is rate-limited.** New `backend/app/ratelimit.py`
|
||
(per-IP token buckets) gates `/auth/otc/verify`, `/auth/otc/request`,
|
||
and the passcode check/verify paths; a per-account OTC-verify lockout
|
||
(migration `023_otc_verify_lockout.sql`) mirrors the passcode lockout.
|
||
- **M1 — device-trust lookup no longer table-scans.** The device-trust
|
||
cookie value is now `"<row_id>.<raw_token>"`; `device_trust.lookup`
|
||
reads the one indexed row and bcrypt-checks only it, instead of
|
||
bcrypt-checking every row in the table on each unauthenticated
|
||
`/auth/device-trust/start`.
|
||
- **M2 — HTTP security headers** (CSP, HSTS, X-Frame-Options,
|
||
X-Content-Type-Options, Referrer-Policy) added to the nginx server
|
||
block. **L8/I1**: `server_tokens off` + legacy TLS1.0/1.1 removed.
|
||
- **M4 — session cookie `Secure` by default** (`SESSION_COOKIE_SECURE`,
|
||
defaults on; a dev box on plain http sets it `false`).
|
||
- **M5 — bounce webhook fails closed.** An unset
|
||
`WEBHOOK_EMAIL_BOUNCE_SECRET` now **disables** `/api/webhooks/email-bounce`
|
||
(503) instead of leaving it open; a dev opts back in with
|
||
`RFC_APP_INSECURE_BOUNCE_WEBHOOK=1`.
|
||
- **L2/L3** per-IP cooldown + check-endpoint throttle. **L4** systemd
|
||
sandbox knobs (`CapabilityBoundingSet=`, `ProtectKernel*`,
|
||
`RestrictAddressFamilies`, `SystemCallFilter`, …).
|
||
|
||
Upgrade steps:
|
||
|
||
1. **Migration** — none manual; `023_otc_verify_lockout.sql` auto-applies
|
||
at startup via `db.run_migrations`.
|
||
2. **Device trust (MUST expect re-prompt)** — the cookie format changed,
|
||
so existing "trusted device" cookies no longer match; affected users
|
||
are re-prompted for device verification once. No data migration; old
|
||
rows are simply never matched and age out.
|
||
3. **nginx + systemd (MUST apply out-of-band)** — the deploy gesture does
|
||
**not** install `deploy/nginx/ohm.wiggleverse.org.conf` or
|
||
`deploy/systemd/rfc-app.service`. After deploying the code, copy both
|
||
to their system locations, then `nginx -t && systemctl reload nginx`
|
||
and `systemctl daemon-reload && systemctl restart <unit>`. (M2 headers
|
||
and L4 sandboxing do not take effect until this is done.)
|
||
4. **Bounce webhook (SHOULD)** — bind `WEBHOOK_EMAIL_BOUNCE_SECRET` (or
|
||
set `RFC_APP_INSECURE_BOUNCE_WEBHOOK=1` for dev). Unset → the endpoint
|
||
returns 503 (closed). No legitimate bounce source is wired today, so
|
||
503 is the safe default.
|
||
5. **Session cookie (SHOULD, dev only)** — a deployment served over plain
|
||
http MUST set `SESSION_COOKIE_SECURE=false` or the session cookie
|
||
won't be sent. Production over HTTPS leaves it unset (Secure on).
|
||
|
||
## 0.26.0 — 2026-05-28
|
||
|
||
**Minor — no schema migration, no new secret, no config, no upgrade
|
||
steps. Additive read-time enrichment; deployments inherit it on deploy
|
||
with nothing to set.** Roadmap item #28 **Part 1**: references to existing
|
||
**accepted** RFCs inside PR descriptions and comment text now render as
|
||
inline links to the referenced RFC. Shipped from driver session 0029.0,
|
||
in parallel with the v0.25.0 security-hardening session — hence the
|
||
version-slot gap (0.25.0 is that session's; this took the next free slot
|
||
per the roadmap's "claims the next available version number" rule).
|
||
|
||
Parts 2 (offer-to-create-RFC for strong-candidate terms) and 3
|
||
(offer-to-contribute-to-a-pending-RFC) of item #28 are deliberately **not**
|
||
in this release — Part 1 ships first as the easy win, exactly as the
|
||
roadmap row anticipates.
|
||
|
||
- **Where it links.** The PR review page's description and review
|
||
comments (`GET /api/rfcs/{slug}/prs/{n}`), and the PR-less per-RFC
|
||
discussion comments (`GET /api/rfcs/{slug}/discussion/threads/{id}/messages`).
|
||
Branch-chat (the AI-collaboration surface) is intentionally out of
|
||
scope for Part 1 — a noted follow-up.
|
||
- **Read-time, not submit-time.** The roadmap row phrases the scan as
|
||
happening "at submit/post time"; this ships it as **read-time**
|
||
enrichment instead. The intent the row actually names — "not as live
|
||
compose preview" — holds (drafts are never scanned, only submitted
|
||
content on read). Read-time was chosen because (a) links track the
|
||
*live* accepted-RFC set — a newly-accepted RFC starts linking in older
|
||
comments, a withdrawn one stops linking everywhere — rather than
|
||
freezing stale at submit; (b) it needs **no migration** (no derived
|
||
data to persist); (c) the active-RFC corpus is small and cache-resident,
|
||
so per-read scanning is cheap. (Recorded as a §19.3-rule-2 spec note in
|
||
the session transcript.)
|
||
- **XSS-safe by construction.** The backend returns structured *segments*
|
||
(a list of `{type:"text"}` / `{type:"rfc", slug, label, title}` items),
|
||
never HTML. The new `LinkedText` frontend component maps segments onto
|
||
React text nodes and anchors — no `dangerouslySetInnerHTML` — so a
|
||
comment author cannot inject markup through this path. Every enriched
|
||
field keeps its raw `text`/`description` alongside the `*_segments`, so
|
||
any non-segment-aware caller is unaffected.
|
||
- **Conservative matching (false-positive-averse).** A reference links
|
||
only when it is unlikely to be coincidental: an `rfc_id` token
|
||
(`RFC-0001`), a **multi-word** title (`Open Human Model`), or a
|
||
**hyphenated** slug (`open-human-model`). A single common-word title or
|
||
slug (a hypothetical RFC titled "Human") is **not** auto-linked — that
|
||
would turn every prose "human" into a link. Matching is case-insensitive,
|
||
word-boundary-anchored, longest-match-wins, and suppresses an RFC's
|
||
self-references inside its own PR/discussion. The roadmap's "curated
|
||
canonical-terms list" remains an explicit future opt-in rather than a
|
||
guessed-at default. New module: `backend/app/rfc_links.py`.
|
||
|
||
Tests: 12 new (`test_rfc_links_vertical.py`) — 9 scanner units (gating
|
||
rules, word boundaries, longest-match, case-insensitivity, casing
|
||
preservation, the rfc_id/multi-word/hyphenated gates) plus 3 end-to-end
|
||
(PR description + review comment + discussion comment all surface
|
||
`*_segments`; self-reference suppression). Full suite 363 green; frontend
|
||
builds clean.
|
||
|
||
Upgrade steps:
|
||
|
||
1. None. This release is purely additive: no migration, no new
|
||
environment variable, no secret, no overlay. A deployment picks up RFC
|
||
auto-linking the moment it deploys this version. (RFCs only link once
|
||
they are in the `active` state — proposed/super-draft and withdrawn
|
||
RFCs are never link targets, matching the §11.3 universal-public read
|
||
rule.)
|
||
|
||
## 0.24.0 — 2026-05-28
|
||
|
||
**Minor — one new secret required before this version serves tag
|
||
suggestions; no schema migration; no behavior change for deployments
|
||
that do not bind the key.** Roadmap item #27: as a contributor fills in
|
||
the propose-RFC form, the backend asks Claude Haiku to recommend tags
|
||
drawn from the collection's existing tag set, surfaced as clickable
|
||
suggestion chips. Shipped from driver session 0025.0. This is the §9.1
|
||
"Slice 2" AI-suggested chips that the propose modal has carried a
|
||
deferred placeholder for since Slice 1.
|
||
|
||
- **`POST /api/rfcs/suggest-tags`** (contributor-gated, per-user
|
||
rate-limited). Takes the partial draft (`title`, `pitch`,
|
||
`use_case`) and returns `{ "suggestions": [{ "tag", "confidence" },
|
||
…] }`. The model is constrained to the corpus's existing tags — v1
|
||
tags are free-form chip input, so "the taxonomy" is the de-facto set
|
||
of distinct tags the existing RFCs carry. The model MUST NOT invent
|
||
tags; taxonomy extension is a deliberate out-of-scope follow-up.
|
||
- **Always Claude Haiku, for cost.** Tag suggestion uses Haiku
|
||
regardless of the `ENABLED_MODELS` chat-picker universe, via the new
|
||
`providers.construct_haiku()` factory. There is no RFC slug at propose
|
||
time, so the §6.7 per-RFC funder credential path does not apply — the
|
||
surface runs on the operator's own `ANTHROPIC_API_KEY`.
|
||
- **Degrades to silence, never error.** No key bound, an empty corpus,
|
||
an empty draft, a rate-limited caller, or an unparseable model reply
|
||
all yield an empty list; the propose modal hides its suggestion row,
|
||
and the rest of the app is unaffected. So a deployment that does not
|
||
set the key sees no change at all.
|
||
- **Privacy disclosure.** The modal carries an inline note that the
|
||
draft text is sent to Anthropic to generate the suggestions. This is
|
||
required for honesty (the text leaves the deployment before the RFC
|
||
is submitted); cookie-consent does not gate it because the user has
|
||
actively typed into a draft surface. The exact wording wants a
|
||
counsel pass before OHM's deploy, per the #22 drafting discipline.
|
||
- **Frontend.** `ProposeModal` debounce-posts the draft (700 ms, with a
|
||
stale-response guard); suggestion chips are clickable and add to the
|
||
tag list (nothing auto-applies). `api.suggestTags()` is forgiving —
|
||
any non-OK response resolves to `[]`.
|
||
|
||
Tests: 11 new (`test_tag_suggest_vertical.py`) — contributor-gating,
|
||
universe-constrained filtering, no-key / empty-corpus / rate-limit
|
||
paths, plus units for the universe gather, the tolerant reply parser,
|
||
the max cap, the empty-draft short-circuit, and provider-failure
|
||
fallback. Full suite 351 green.
|
||
|
||
Upgrade steps:
|
||
|
||
1. You **MUST** bind `ANTHROPIC_API_KEY` for the suggestion surface to
|
||
work. On OHM this is an operator gesture — the key never touches the
|
||
conversation. Set it from your own terminal via stdin (so the bytes
|
||
never land in shell history):
|
||
|
||
```bash
|
||
printf '%s' "$(pbpaste)" | .venv/bin/ohm-rfc-app-flotilla \
|
||
secret set ohm-rfc-app ANTHROPIC_API_KEY
|
||
```
|
||
|
||
(The §18 chat stack already reads this same key, so a deployment
|
||
that has chat configured **MAY** already have it bound — confirm with
|
||
`flotilla secret list ohm-rfc-app`.) If the key is absent the app
|
||
still boots and serves normally; tag suggestions are simply
|
||
unavailable until it is set.
|
||
2. You **MAY** tune the per-user rate limit via `TAG_SUGGEST_RATE_MAX`
|
||
(default 30) and `TAG_SUGGEST_RATE_WINDOW_SECONDS` (default 60). The
|
||
defaults apply when unset.
|
||
3. You **SHOULD** have counsel review the modal's disclosure wording
|
||
before exposing the surface to users, per the #22 drafting
|
||
discipline — the copy is honest as written, but the legal review is
|
||
the right call for any "your text is sent to a third party" notice.
|
||
|
||
## 0.23.0 — 2026-05-28
|
||
|
||
Roadmap item #29: signing in lands the user on their most recently
|
||
viewed app state instead of the empty-state home view. Shipped from
|
||
driver session 0022.0. Per-user, server-side, on by default; first-ever
|
||
sign-ins still land on home.
|
||
|
||
1. **Server-side last-state.** A new `user_session_state` table holds one
|
||
row per user: `last_route TEXT`, `last_route_state TEXT` (light view
|
||
state encoded as JSON — SQLite has no native JSONB — never draft-buffer
|
||
contents), `resume_enabled INTEGER NOT NULL DEFAULT 1`, and
|
||
`last_updated_at`. Per-user (not per-device), matching the
|
||
"go to where I was last" intent.
|
||
2. **Route-change capture.** A new frontend hook (`frontend/src/lib/
|
||
useLastState.js`) debounce-posts the current route + light state to
|
||
`PUT /api/me/last-state` for authenticated users only; anonymous
|
||
sessions are a no-op. The handler upserts the user's row and no-ops
|
||
when `resume_enabled` is 0.
|
||
3. **Sign-in redirect.** The stored `last_route` (+ decoded state +
|
||
`resume_enabled`) is folded onto the existing `/api/auth/me` payload
|
||
(no new GET endpoint), so the frontend reads it on boot. On a hard
|
||
sign-in landing (app booted on `/`), the app redirects to the stored
|
||
route. The redirect is gated on the #21-Part-C Amplitude `identify`
|
||
having fired (`identifyReady`), preserving identify-then-track
|
||
ordering — the first event on the resumed route carries the user_id.
|
||
4. **Edge cases.** Stale routes (an RFC since withdrawn or now
|
||
unreadable) are a graceful no-op — existing routing falls through to
|
||
the catalog/empty-state. Opt-out ships at the column level
|
||
(`resume_enabled`); the profile-settings toggle UI is a follow-up.
|
||
Stored state is route + light view state only, never draft contents
|
||
(documented in `SPEC.md` §6.8).
|
||
|
||
Migration `022_user_session_state.sql` creates the table. No new
|
||
environment variables, overlay keys, or secrets.
|
||
|
||
Upgrade steps:
|
||
|
||
1. Deployments **MUST** apply database migrations; `022_user_session_state.sql`
|
||
runs automatically on next boot (the migration runner globs
|
||
`backend/migrations/*.sql` and applies any not yet recorded in
|
||
`schema_migrations`). The migration is additive — a new table — and
|
||
requires no data backfill.
|
||
2. No new environment variables, overlay keys, or secrets. No operator
|
||
action beyond the standard `flotilla deploy ohm-rfc-app`.
|
||
|
||
## 0.22.0 — 2026-05-28
|
||
|
||
Roadmap item #26: an optional **"What will you be using this for?"**
|
||
field on both propose surfaces. Shipped from driver session 0022.0.
|
||
Additive and backward-compatible — no behavior changes for anyone who
|
||
leaves the field blank.
|
||
|
||
1. **Propose-RFC modal.** Below the required "Why is this RFC needed?"
|
||
field (`pitch`) sits a new optional **"What will you be using this
|
||
RFC for?"** textarea. "Needed" is the abstract justification; "using
|
||
it for" is the concrete ground-truth use case — captured without
|
||
being forced.
|
||
2. **Propose-PR modal.** Below the required "Why is this change needed?"
|
||
field (`description`) sits a new optional **"What will you be using
|
||
this change for?"** textarea.
|
||
3. **Display.** The RFC view (`RFCView` / `ProposalView`) and PR review
|
||
view (`PRView`) render the captured use case alongside the existing
|
||
"why" text, with a muted "left blank" treatment when absent.
|
||
4. **Persistence (deployment note).** In this deployment the framework's
|
||
`rfcs` / PR surfaces are the Gitea-backed cache tables `cached_rfcs`
|
||
/ `cached_prs`, rebuilt by the reconciler. Because the propose write
|
||
path is endpoint → Gitea → reconcile (and the reconciler doesn't
|
||
carry the new field), the durable home is a canonical, reconcile-proof
|
||
side table `proposed_use_cases` (keyed by `(scope, pr_number)`) that
|
||
the propose/open endpoints write directly and the view endpoints read
|
||
back. The literal nullable `proposed_use_case` columns named by the
|
||
roadmap are also added to `cached_rfcs` / `cached_prs` for parity and
|
||
any future reconciler that learns to carry the field. NULL/blank use
|
||
cases simply never write a side-table row — absence is "left blank".
|
||
|
||
Migration `021_proposed_use_case.sql` adds the two cache columns
|
||
(`ALTER TABLE … ADD COLUMN proposed_use_case TEXT`), the
|
||
`proposed_use_cases` canonical table, and its lookup indexes. The
|
||
reconciler's upsert (`ON CONFLICT DO UPDATE`) sets only known columns,
|
||
so the added cache columns survive reconciles. Backend validators accept
|
||
the field as NULL/omitted (no required-validation) with an 8000-char cap
|
||
matching the existing PR `description` bound; blank/whitespace is treated
|
||
as absent.
|
||
|
||
Upgrade steps:
|
||
|
||
1. Deployments **MUST** apply database migrations; `021_proposed_use_case.sql`
|
||
runs automatically on next boot (the migration runner globs
|
||
`backend/migrations/*.sql` and applies any not yet recorded in
|
||
`schema_migrations`). The migration is additive — nullable cache
|
||
columns plus a new table — and requires no data backfill.
|
||
2. No new environment variables, overlay keys, or secrets. No operator
|
||
action beyond the standard `flotilla deploy ohm-rfc-app`.
|
||
|
||
## 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.0–v0.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.**
|
||
This release lands admin-create user with role assignment + invite
|
||
email (roadmap item #16, §6.1). From the v0.9.0 `/admin/users` surface,
|
||
an admin can now type first name, last name, email, role, and an
|
||
optional custom message; the framework provisions the `users` row with
|
||
the chosen role and `permission_state='granted'` (the admin's hand is
|
||
the grant) and sends an invite email carrying a single-use claim link.
|
||
The invitee clicks through to `/invites/claim?token=…`, the token is
|
||
verified and consumed, the session is established, and the user is
|
||
routed to the passcode-set screen (per v0.10.0) on first sign-in.
|
||
|
||
Distinct from #12 (which ships in parallel in this wave): #12 is
|
||
per-RFC contribution/discussion membership and uses
|
||
`rfc_invitations` (slot 018). v0.17.0 is platform-level access
|
||
provisioning by an admin and uses `user_invite_tokens` (slot 019).
|
||
Both can coexist; both surface in the same SMTP relay but with
|
||
distinct email templates.
|
||
|
||
Design decisions documented inline (see `backend/app/invites.py`'s
|
||
module docstring + the migration's header comment):
|
||
|
||
* **Token shape: opaque DB token, not JWT.** 256 bits of CSPRNG
|
||
entropy (`secrets.token_urlsafe(32)`), bcrypt-hashed at rest.
|
||
Opaque chosen over JWT because revocation is then a single SQL
|
||
UPDATE — admin-issued invites are exactly the kind of thing an
|
||
admin should be able to yank back without rotating a signing
|
||
key. The raw token only ever lives in the outbound email link
|
||
and the inbound claim body.
|
||
* **TTL: 7 days, hard-coded constant** (`INVITE_TOKEN_TTL_DAYS`
|
||
in `backend/app/invites.py`). Env-var configurability is a
|
||
§19.2 candidate; the constant is exposed as a single point of
|
||
edit if a deployment wants to override.
|
||
* **Immediate send, no admin-review-then-send queue.** Matches
|
||
how the v0.9.0 beta-request admin notification works (single
|
||
SMTP path). Admin-preview-before-send is a future enhancement.
|
||
* **Bulk-invite (CSV paste) deferred.** v0.17.0 is one-at-a-time;
|
||
a follow-up release can layer bulk on top of the same
|
||
`POST /api/admin/users` body shape with minimal disruption.
|
||
* **OTC skipped on first sign-in.** Per the roadmap: clicking the
|
||
unique token in the email is itself proof of email control, so
|
||
the claim flow signs the invitee in directly. Subsequent
|
||
sign-ins go through the standard OTC / passcode paths.
|
||
* **No `users` table changes.** The brief floated
|
||
`first_sign_in_at` / `last_seen_at IS NULL` as the
|
||
"(pending invite)" discriminator, but the existing
|
||
`users.last_seen_at` column is NOT NULL with a `datetime('now')`
|
||
default (migrations/001) and no `first_sign_in_at` column
|
||
exists. Rather than land a schema migration to introduce one,
|
||
the discriminator is the existence of an active (not-claimed,
|
||
not-expired) row in `user_invite_tokens` joined on
|
||
`invited_user_id`. The admin user-listing carries a
|
||
`pending_invite` field populated via that join; on claim, the
|
||
badge clears naturally as the invite row's `claimed_at`
|
||
populates.
|
||
* **Admin-create vs. self-flip refusals.** Self-invite is refused
|
||
422 (use the role-change channel for self-edits). Duplicate
|
||
email is refused 409 (use the existing role / grant gestures on
|
||
the existing user). Owner-grant by a non-owner admin is refused
|
||
422 (§6.1's owner-zero is the only owner bootstrap path; the
|
||
sitting owner must issue the invite).
|
||
|
||
### Added
|
||
|
||
- **`POST /api/admin/users`** (`backend/app/api_admin.py`) — admin-only.
|
||
Body: `{ email, first_name?, last_name?, role, custom_message? }`.
|
||
Provisions the `users` row with the chosen role and writes the
|
||
`user_invite_tokens` row + dispatches the invite email + writes a
|
||
`permission_events` row with `event_kind='user_invited'`. Returns
|
||
`{ ok, invite_id, invited_user_id, email, role }` on success;
|
||
surfaces the four refusals (403/422/409/422) per their distinct
|
||
paths.
|
||
- **`GET /api/admin/users/invites`** — admin-only. Lists active
|
||
(not claimed, not expired) invites with the admin who created them
|
||
joined through for display. Powers the "I sent these but they
|
||
haven't been claimed yet" admin view.
|
||
- **`POST /api/invites/claim`** (`backend/app/main.py` — alongside
|
||
`/auth/otc/verify` and `/auth/device-trust/start` since it shares
|
||
the device-trust cookie helpers). Anonymous-reachable. Body:
|
||
`{ token, trust_device? }`. Validates the token, consumes the
|
||
invite row, signs the user in, optionally mints a device-trust
|
||
cookie, and returns `{ ok, user, needs_passcode }`. The
|
||
`needs_passcode` hint drives the frontend's route-to-passcode-set
|
||
vs. route-to-home decision. Maps token-failure modes to distinct
|
||
HTTP statuses: expired/claimed → 410, unknown/invalid → 400.
|
||
- **`backend/app/invites.py`** — the create + claim + list module.
|
||
Mirrors the `device_trust.py` shape: opaque-token issuance with
|
||
bcrypt-at-rest, candidate-set walk on lookup, dataclass-bracketed
|
||
outcomes (`CreateOutcome` / `ClaimOutcome` / `PendingInviteRow`).
|
||
Carries the `INVITE_TOKEN_TTL_DAYS = 7` constant and the
|
||
`CUSTOM_MESSAGE_MAX_LENGTH = 500` mirror of the API-side bound.
|
||
- **`backend/app/email_invite.py`** — sibling of `email_otc.py`. Reuses
|
||
`EmailConfig.from_env()` for the SMTP plumbing + From identity;
|
||
composes a separate template (subject "You're invited to <app> by
|
||
<admin>"; body names the inviter, embeds the optional custom
|
||
message in a clearly-delimited indented block if present, and
|
||
carries the claim URL). Dev / no-SMTP path logs the envelope to
|
||
the shared `_SENT` buffer so backend tests can assert on the
|
||
outbound shape.
|
||
- **Schema migration `019_user_invite_tokens.sql`** — new
|
||
`user_invite_tokens` table (id, email, role, first_name, last_name,
|
||
custom_message, token_hash, expires_at, created_at,
|
||
created_by_admin_id, claimed_at, claimed_by_user_id,
|
||
invited_user_id). Three indexes: unique on `token_hash`
|
||
(documents the no-collision invariant); `(email, claimed_at)` for
|
||
the "is this email already invited?" pre-check; and
|
||
`(created_by_admin_id, created_at DESC)` for the per-admin
|
||
invites listing. Slot 018 is reserved for the parallel #12
|
||
release shipping in the same wave; slot 016 stays
|
||
reserved-and-skipped per Session K's v0.9.0 integration.
|
||
- **`frontend/src/components/InviteClaim.jsx`** — the
|
||
`/invites/claim?token=…` landing page. Reads the token from the
|
||
URL, renders a "Claim my account" CTA with an optional
|
||
"trust this device for 30 days" checkbox, calls
|
||
`POST /api/invites/claim` on submit, and routes onward
|
||
(`/settings/notifications#sign-in` if `needs_passcode`, else `/`)
|
||
on success. Anonymous-reachable.
|
||
- **"Create user + invite" affordance** on `/admin/users`
|
||
(`frontend/src/components/Admin.jsx`). A header button opens a
|
||
modal with email / first / last / role / custom-message inputs
|
||
(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`).
|
||
Clears automatically on claim as the invite row's `claimed_at`
|
||
populates.
|
||
|
||
### Changed
|
||
|
||
- **`backend/app/api_admin.py`** — imports `invites` + `email_invite`
|
||
+ `EmailConfig`; adds the two new endpoints alongside the existing
|
||
`set_role` / `set_permission` neighbors; extends `list_users` to
|
||
join through `user_invite_tokens` and emit the `pending_invite`
|
||
field on each row. The new `CreateUserInviteBody` pydantic model
|
||
carries the body bounds (320-char email, 120-char first/last,
|
||
regex-pinned role, 500-char custom_message) so malformed input
|
||
fails at the body bound (422) instead of at the SQL layer.
|
||
- **`backend/app/main.py`** — imports `invites as invites_mod`;
|
||
adds the `InviteClaimBody` pydantic model alongside
|
||
`PasscodeVerifyBody`; mounts the `POST /api/invites/claim`
|
||
endpoint in the OAuth router so it can reuse the
|
||
`_set_device_trust_cookie` helper.
|
||
- **`frontend/src/api.js`** — exports `createUserInvite()`,
|
||
`listUserInvites()`, `claimInvite()`. Same fetch shape as the
|
||
rest of the v0.9.0 / v0.10.0 admin neighborhood.
|
||
- **`frontend/src/App.jsx`** — imports `InviteClaim`; registers the
|
||
`/invites/claim` route alongside `/beta-pending` (both are
|
||
anonymous-reachable auth-shape landings).
|
||
|
||
### Migration
|
||
|
||
- **`backend/migrations/019_user_invite_tokens.sql`** — auto-applied
|
||
on next backend start. Single new table with three indexes; no
|
||
changes to existing tables.
|
||
|
||
### Upgrade steps (from 0.14.0)
|
||
|
||
- You **MUST** apply schema migration `019_user_invite_tokens.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.
|
||
- You **MUST** rebuild the frontend and restart the backend after
|
||
upgrading. `frontend/package.json#version` and `VERSION` both
|
||
move to `0.17.0`. No new env vars; no new secrets (the invite
|
||
email rides the existing SMTP relay configured for v0.7.0's OTC
|
||
mail).
|
||
- You **MAY** announce the new admin-create gesture to existing
|
||
admins. Existing user rows are unaffected — the
|
||
`user_invite_tokens` table is empty post-migration, and the
|
||
user-listing's new `pending_invite` field is null on every
|
||
existing row. The bootstrap shape for the very first admin
|
||
account stays the v0.9.0 path (DB-level role flip on an OTC-
|
||
provisioned row); the v0.17.0 admin-create gesture works
|
||
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
|
||
guide that translates `SPEC.md` into plain prose for readers,
|
||
proposers, and contributors. The originating need was the
|
||
admin-vs-owner distinction on the `/admin/users` surface (the §6.1
|
||
role separation was load-bearing but only documented in spec voice);
|
||
the response was a single guide that covers the framework's user-
|
||
facing surfaces end-to-end. Mirrors `/philosophy` end-to-end: a
|
||
markdown file checked into the repo root, served by a sibling backend
|
||
loader, rendered with `MarkdownPreview`. No schema migration. No
|
||
required env-var changes. The new "Docs" header link sits alongside
|
||
the persistent "About" link from §14.3 and is reachable by anonymous
|
||
viewers per the same v0.3.0 anonymous-read contract.
|
||
|
||
### Added
|
||
|
||
- **`DOCS.md`** at the repo root — the user-facing guide. Covers
|
||
reading anonymously, signing in, proposing an RFC, super-drafts vs
|
||
active RFCs, the discussion-vs-contribution distinction (§10.10),
|
||
working on a branch (contribute mode, AI proposals, manual edits,
|
||
flags, branch visibility, contribute grants, hygiene), opening and
|
||
reviewing PRs, graduation (§13), withdrawal and reopening, the AI
|
||
participant (§6.6 / §6.7 / §18), notifications and watch states
|
||
(§15), and the full roles-and-permissions story (§6 in plain
|
||
prose: anonymous / contributor / admin / owner, per-RFC
|
||
owners + arbiters, per-branch contribute grants, the write-mute,
|
||
and the three structurally distinct "mutes"). Framework-neutral —
|
||
no deployment-specific names or corpus references; consistent with
|
||
`CLAUDE.md`'s separation-of-concerns rule.
|
||
- **`backend/app/docs.py`** — sibling loader for `philosophy.py`.
|
||
Reads `DOCS.md` from the repo root with the same disk-first,
|
||
in-process-cached, `refresh()`-on-demand shape. Optional
|
||
`DOCS_PATH` env var points at an alternative source (e.g. a
|
||
meta-repo working-tree clone) for deployments that prefer that.
|
||
- **`§17` endpoint** — `GET /api/docs` returns
|
||
`{ "body": "<DOCS.md verbatim>" }`. Anonymous-reachable, same
|
||
contract as `GET /api/philosophy`.
|
||
- **`frontend/src/components/Docs.jsx`** — the `/docs` reading
|
||
surface. Mirrors `Philosophy.jsx`: chrome with Back / "USER GUIDE" /
|
||
Home affordances, body rendered through `MarkdownPreview`.
|
||
|
||
### Changed
|
||
|
||
- **`backend/app/api.py`** — imports `docs as docs_mod` alongside
|
||
`philosophy` in the relative-import block; registers the new
|
||
`GET /api/docs` handler immediately after `GET /api/philosophy`.
|
||
- **`frontend/src/api.js`** — exports `getDocs()` alongside
|
||
`getPhilosophy()`. Same fetch shape, different endpoint path.
|
||
- **`frontend/src/App.jsx`** — imports `Docs` alongside `Philosophy`,
|
||
registers the `/docs` route alongside `/philosophy`, adds the
|
||
persistent "Docs" header link alongside "About", and adds the
|
||
`DocsWithSidebar` chrome wrapper alongside `PhilosophyWithSidebar`.
|
||
|
||
### Upgrade steps (from 0.13.0)
|
||
|
||
- You **MUST** rebuild the frontend and restart the backend after
|
||
upgrading so the new `/docs` route, the new endpoint, and the new
|
||
loader are picked up. `frontend/package.json#version` and `VERSION`
|
||
both move to `0.14.0`. No schema migration; the new endpoint
|
||
serves a checked-in file.
|
||
- You **MAY** set `DOCS_PATH` to an absolute path if your deployment
|
||
hosts `DOCS.md` outside the framework's repo (e.g. as a sync target
|
||
from a content repo). Unset is supported — the framework's
|
||
`DOCS.md` at the repo root is the default, mirroring how
|
||
`PHILOSOPHY_PATH` works for `/api/philosophy`.
|
||
- You **MAY** customize `DOCS.md` for your deployment if you want
|
||
deployment-specific phrasing layered on top of the framework's
|
||
guide. The file is a regular markdown source; standard `vim`/`git`
|
||
edits suffice. Framework upgrades that ship a new `DOCS.md` will
|
||
show as a normal merge in your deployment-overlay layer.
|
||
|
||
## 0.13.0 — 2026-05-28
|
||
|
||
**Minor — schema migration required; new optional env vars.** This
|
||
release ships the cookie / privacy consent surface (roadmap item #11,
|
||
SPEC §14.5 / §14.6). Every viewer — authenticated and anonymous alike —
|
||
now sees a non-modal bottom-of-page banner on first visit asking which
|
||
categories of cookies they allow (essential / essential + analytics /
|
||
essential + analytics + other). The choice persists in `localStorage`
|
||
for anonymous viewers and in a new `cookie_consent` table for
|
||
authenticated viewers, with server-side overriding local on sign-in.
|
||
The framework also ships default `/privacy` and `/cookies` policy pages
|
||
that deployments can layer their own policy URL on top of via two new
|
||
optional env vars. No analytics SDK ships in this release — the
|
||
consent infrastructure is wired so item #13 (v0.15.0) can read from
|
||
`frontend/src/lib/consent.js` when the SDK lands.
|
||
|
||
### Added
|
||
|
||
- **Cookie consent banner** (`frontend/src/components/CookieConsentBanner.jsx`).
|
||
Non-modal, bottom of viewport. Three single-select choices with
|
||
inline descriptions. Visible until the user makes a choice; hides
|
||
thereafter. Reachable for revision via the settings surface.
|
||
- **Consent helper** (`frontend/src/lib/consent.js`). Exports
|
||
`getConsent()`, `hasChosen()`, `onConsentChange(cb)`, `setConsent()`,
|
||
`hydrateFromServer()`, `clearLocal()`. Cross-tab sync via the
|
||
`storage` event. Item #13's analytics SDK reads consent here before
|
||
importing.
|
||
- **Privacy and cookies policy pages**
|
||
(`frontend/src/pages/Privacy.jsx`, `frontend/src/pages/Cookies.jsx`).
|
||
Default minimal policies that describe the framework's stance and
|
||
list the cookies the framework sets. Deployments override via the
|
||
two new env vars below; the framework's stub always renders above
|
||
the link so the framework-level contract stays visible.
|
||
- **"Privacy & cookies" tab** in `/settings/notifications` showing
|
||
the current consent choice, the recorded-at stamp, and a "Change"
|
||
button that re-opens the banner via a custom DOM event.
|
||
- **`§17` endpoints** —
|
||
- `GET /api/users/me/cookie-consent` — read the current consent
|
||
record.
|
||
- `PUT /api/users/me/cookie-consent` — write a new consent record.
|
||
Upserts a single row per user, stamps `recorded_at` to now,
|
||
accepts `essential` for symmetry but always persists it as true.
|
||
- **Schema migration** `013_cookie_consent.sql` — new
|
||
`cookie_consent` table keyed by `user_id`, three flags
|
||
(`essential`, `analytics`, `other_cookies`), and `recorded_at`.
|
||
(Renumbered from `012_*` during driver integration because v0.7.0
|
||
also added a `012_otc.sql` migration that landed in the integration
|
||
order before this one.)
|
||
- **SPEC `§14.5` Cookie / privacy consent** — settles the banner
|
||
shape, the three-category single-select, the storage shape (local
|
||
for anon, server row for authenticated), the precedence rule on
|
||
sign-in, and the `consent.js` helper surface for downstream
|
||
callers including item #13.
|
||
- **SPEC `§14.6` Privacy and cookies policy pages** — settles the
|
||
`/privacy` and `/cookies` routes, the framework's stub content, and
|
||
the `VITE_PRIVACY_POLICY_URL` / `VITE_COOKIES_POLICY_URL` override
|
||
shape.
|
||
- **SPEC `§5`** — names the `cookie_consent` table in the canonical
|
||
app-tables list.
|
||
- **SPEC `§17`** — lists the two new cookie-consent endpoints.
|
||
- **SPEC `§19.2`** — surfaces four candidates: policy content via
|
||
content-repo file vs env var, GPC / DNT headers, multi-language
|
||
consent text, and the item #13 analytics-SDK gating dependency.
|
||
|
||
### Changed
|
||
|
||
- **`frontend/.env.example`** — documents the two new optional env
|
||
vars `VITE_PRIVACY_POLICY_URL` and `VITE_COOKIES_POLICY_URL`. Unset
|
||
is supported; defaults render the framework's stub.
|
||
- **`backend/app/api_notifications.py`** — module docstring grew two
|
||
endpoint lines; the new endpoints sit alongside the existing
|
||
`/api/users/me/*` neighbors.
|
||
- **`frontend/src/App.jsx`** — registers `/privacy` and `/cookies`
|
||
routes (anonymous-reachable), wires `<CookieConsentBanner>` into
|
||
the global chrome, and listens for a `rfc-app:cookie-consent-reopen`
|
||
custom event to re-open the banner from the settings surface.
|
||
|
||
### Upgrade steps (from 0.7.0)
|
||
|
||
- You **MUST** rebuild the frontend and restart the backend after
|
||
upgrading. `frontend/package.json#version` and `VERSION` both move
|
||
to `0.13.0` and the build embeds the new env-var contract.
|
||
- You **MUST** apply schema migration `013_cookie_consent.sql`. The
|
||
migration creates a single new table keyed by `user_id` with three
|
||
flag columns and a `recorded_at` stamp. The framework runs
|
||
migrations automatically at process start; no manual step is
|
||
required beyond restarting the backend so the migration runner
|
||
picks the file up.
|
||
- You **MAY** set `VITE_PRIVACY_POLICY_URL` to an http(s) URL that
|
||
points at your deployment's full privacy policy. The framework's
|
||
`/privacy` page renders its built-in stub above a link to the
|
||
configured URL. Unset is supported — the stub is sufficient for a
|
||
default-config deployment.
|
||
- You **MAY** set `VITE_COOKIES_POLICY_URL` to an http(s) URL that
|
||
points at your deployment's full cookies policy. Same shape as the
|
||
privacy URL.
|
||
- You **MAY** announce the new consent banner to your users. Existing
|
||
authenticated users will see the banner on their next visit
|
||
(because their `cookie_consent` row does not yet exist); their
|
||
current sessions remain valid.
|
||
|
||
## 0.12.0 — 2026-05-28
|
||
|
||
**Minor — operator action required (new secret + new overlay).**
|
||
CloudFlare Turnstile gates the email-entry step of the OTC sign-in
|
||
flow against automated abuse (roadmap item #10, SPEC §6.2 / §19.2-
|
||
settled). Since v0.7.0 made `/auth/otc/request` the primary human-
|
||
auth path and v0.8.0 opened the request endpoint to any valid email,
|
||
the OTC dispatch became the natural target for distributed scrapers
|
||
fanning out to harvest "this email is admitted vs. this email is
|
||
not" timing/bounce signals. The per-email cooldown stops the trivial
|
||
back-to-back loop; the Turnstile challenge stops the distributed one
|
||
by costing the attacker a browser-side proof-of-humanness on every
|
||
request. The challenge runs before the bcrypt hash + SMTP send so a
|
||
failed verify spends no rate budget and produces no envelope.
|
||
|
||
Scope: the widget renders on the email-entry step of `/login` only.
|
||
The OTC verify step (where the user pastes the six-digit code) is
|
||
already bottlenecked on email delivery and protected by the
|
||
five-minute TTL + single-use consume on the row; a second challenge
|
||
there would double the rate budget against the same abuse path
|
||
without measurably more protection. If bots adapt to defeat the
|
||
email-entry challenge specifically — pushing the abuse vector onto
|
||
the verify step — a future release adds the second widget. The
|
||
widget also renders on the passcode step's "Use a code instead"
|
||
fallback dispatch since that route also calls `/auth/otc/request`.
|
||
|
||
Default policy: `TURNSTILE_REQUIRED=false`. The gate stays open when
|
||
the secret is absent — the dev / test path, and the pre-rollout
|
||
path while the operator is wiring the secret. Once the secret is in
|
||
GCP Secret Manager and the site key is in the overlay, the operator
|
||
**MAY** flip `TURNSTILE_REQUIRED=true` so a future config drift on
|
||
the secret fails loudly (HTTP 500 "auth misconfigured") instead of
|
||
silently disabling abuse defense.
|
||
|
||
No schema migration — Turnstile siteverify is stateless.
|
||
|
||
### Added
|
||
|
||
- **`backend/app/turnstile.py`** — the siteverify caller. POSTs
|
||
`secret` + `response` (+ optional `remoteip`) to
|
||
`https://challenges.cloudflare.com/turnstile/v0/siteverify` and
|
||
returns a `VerifyOutcome` (`ok` boolean + `reason` enum:
|
||
`ok` / `skipped` / `misconfigured` / `missing-token` / `failed` /
|
||
`network`). Tunables read from env at call time so tests
|
||
monkeypatch cleanly: `CLOUDFLARE_TURNSTILE_SECRET`,
|
||
`TURNSTILE_REQUIRED`, and (test-only) `TURNSTILE_SITEVERIFY_URL`.
|
||
- **`frontend/src/components/TurnstileWidget.jsx`** — the React
|
||
wrapper around the official CloudFlare Turnstile JS API. Reads the
|
||
site key from `import.meta.env.VITE_TURNSTILE_SITE_KEY`; renders
|
||
nothing when the var is unset (the form still submits and the
|
||
backend's `TURNSTILE_REQUIRED` policy decides admission). Loads
|
||
the CloudFlare script once per page on first widget mount. Cleans
|
||
up the widget instance on unmount via `turnstile.remove()` so a
|
||
remount produces a fresh challenge rather than reusing a stale,
|
||
already-consumed token.
|
||
- **Backend tests** (`backend/tests/test_turnstile_vertical.py`) —
|
||
five vertical scenarios: happy path (secret + valid token →
|
||
admit), siteverify rejects → 400 + no envelope, missing-token →
|
||
400 + no envelope, missing-secret-soft (default) → admit, and
|
||
missing-secret-hard (`TURNSTILE_REQUIRED=true`) → 500
|
||
"misconfigured". All five mock the siteverify HTTP call via
|
||
`monkeypatch.setattr(turnstile.httpx, "post", …)`; no real
|
||
CloudFlare keys are ever embedded.
|
||
- **SPEC `§6.2`** — names the Turnstile gate on the OTC dispatch as
|
||
the v0.12.0 settled shape; the §19.2 candidate from v0.7.0 closes.
|
||
|
||
### Changed
|
||
|
||
- **`backend/app/main.py`** — `OtcRequestBody` grows an optional
|
||
`turnstile_token` field. The `/auth/otc/request` handler calls
|
||
`turnstile.verify_token` first, before `otc.request_code`, so a
|
||
failed challenge spends no rate budget and produces no envelope.
|
||
The handler maps `misconfigured` → HTTP 500, all other failures
|
||
(`missing-token`, `failed`, `network`) → uniform HTTP 400 so the
|
||
response does not enumerate which leg of the challenge broke.
|
||
- **`frontend/src/api.js`** — `requestOtc` accepts a second arg
|
||
`{ turnstileToken }` and threads it into the request body. The
|
||
positional signature stays backwards-compatible so calls that pass
|
||
only an email still type-check.
|
||
- **`frontend/src/components/Login.jsx`** — the email step and the
|
||
passcode step both render `<TurnstileWidget>`. The submit button
|
||
on the email step is disabled until the widget produces a token
|
||
(when the widget is enabled at build time); the "Use a code
|
||
instead" link on the passcode step has the same gate. A 400 from
|
||
`/auth/otc/request` clears the token and surfaces a "couldn't
|
||
verify you're human, please retry" status. The fallback-from-
|
||
passcode path bounces back to the email step on 400 so the user
|
||
gets a fresh challenge in the natural place.
|
||
- **`backend/.env.example`** — documents `CLOUDFLARE_TURNSTILE_SECRET`
|
||
and `TURNSTILE_REQUIRED` alongside the existing OTC tunables.
|
||
- **`frontend/.env.example`** — documents `VITE_TURNSTILE_SITE_KEY`
|
||
with the operator wire-up procedure (dash.cloudflare.com →
|
||
Turnstile → Add site).
|
||
|
||
### Upgrade steps (from 0.10.0)
|
||
|
||
The operator **MUST** create a CloudFlare Turnstile site
|
||
(dash.cloudflare.com → Turnstile → Add site, choose "Managed" widget
|
||
mode), obtain the site key (public) and secret key (private), and:
|
||
|
||
- You **MUST** `flotilla secret set ohm-rfc-app CLOUDFLARE_TURNSTILE_SECRET`
|
||
(paste the secret key when prompted) before the v0.12.0 deploy.
|
||
The framework reads the secret at request time; deploying v0.12.0
|
||
without the secret leaves the gate in its default soft-fail state
|
||
(every request admitted regardless of token), which means abuse
|
||
defense is silently off.
|
||
- You **MUST** `flotilla overlay set ohm-rfc-app VITE_TURNSTILE_SITE_KEY <site-key>`
|
||
so the frontend build embeds the site key and the widget renders
|
||
on `/login`. The site key is public — it travels in the bundle and
|
||
appears in every browser — so this is the overlay (non-secret)
|
||
layer per the §3 invariant 1 split. Skipping this step leaves
|
||
`/login` with no widget; even after the operator sets the secret,
|
||
the backend would refuse every request as `missing-token` once
|
||
`TURNSTILE_REQUIRED=true` flips.
|
||
- You **MUST** rebuild the frontend and restart the backend after
|
||
upgrading. `frontend/package.json#version` and `VERSION` both move
|
||
to `0.12.0`. No schema migration; Turnstile siteverify is
|
||
stateless. The site-key embed is build-time, so the rebuild after
|
||
the `flotilla overlay set` is what actually wires the widget into
|
||
the bundle the deploy serves.
|
||
- You **MAY** `flotilla overlay set ohm-rfc-app TURNSTILE_REQUIRED true`
|
||
once you've confirmed a real sign-in works end-to-end with the
|
||
widget. The default (`false`) keeps the gate in soft-fail mode so
|
||
a missing-secret regression admits requests rather than 500ing
|
||
every sign-in attempt; flipping to `true` makes a future config
|
||
drift on the secret fail loudly with HTTP 500 instead of silently
|
||
disabling abuse defense. The framework's tested path is the
|
||
flipped-to-true production shape; the default `false` exists for
|
||
the dev / pre-rollout window only.
|
||
- You **MAY** customize the Turnstile widget mode (Managed /
|
||
Non-interactive / Invisible) from the dashboard at any time
|
||
without redeploying — the site key stays the same, and the widget
|
||
picks up the mode change on the next page load. The framework's
|
||
tested path is "Managed" because it gives the operator a visible
|
||
challenge surface to debug against.
|
||
|
||
If either of the two **MUST** secret/overlay steps is skipped, the
|
||
deploy still boots and `/login` still serves; the failure mode is
|
||
that abuse defense is off (default `TURNSTILE_REQUIRED=false`) or
|
||
every sign-in attempt 500s (`TURNSTILE_REQUIRED=true` flipped while
|
||
the secret is unset). The driver pauses the wave at the secret/
|
||
overlay gesture so the operator confirms both are in place before
|
||
the framework version pin moves.
|
||
|
||
## 0.11.0 — 2026-05-28
|
||
|
||
**Minor — schema migration required; no new env vars.** This release
|
||
ships the "trust this device for 30 days" gesture (roadmap item #9,
|
||
SPEC §6.2). After a successful OTC or passcode sign-in, the user
|
||
can check a single checkbox to mint a server-issued opaque
|
||
device-trust token; the token rides as a long-lived HttpOnly +
|
||
Secure + SameSite=Lax cookie, and the matching row's hash lives in a
|
||
new `device_trust` table. On a subsequent visit, the cookie is
|
||
presented at `POST /auth/device-trust/start` — if a non-expired,
|
||
non-revoked row matches, the session is re-established without
|
||
another OTC / passcode roundtrip. A new `/settings/notifications`
|
||
"Trusted devices" section lists active rows (created-at, last-seen,
|
||
expiry, rough UA label) with per-row "Revoke" and a "Revoke all
|
||
devices" button. The cookie is "essential" per the v0.13.0 cookie-
|
||
consent contract — it is part of authentication, not analytics — and
|
||
is set regardless of the user's analytics / other-cookies choice.
|
||
|
||
The session model gains a cookie, not a session-store change: the
|
||
existing `rfc_session` cookie still carries the in-flight session
|
||
state; the new `rfc_device_trust` cookie is consulted only by
|
||
`/auth/device-trust/start` to bootstrap a fresh session on a return
|
||
visit. The raw token only ever lives in the outbound `Set-Cookie`
|
||
header and the inbound `Cookie` header; server-side storage is the
|
||
bcrypt hash; constant-time comparison via `bcrypt.checkpw` on the
|
||
candidate walk. The raw token is never logged.
|
||
|
||
### Added
|
||
|
||
- **`device_trust` table** (`backend/migrations/017_device_trust.sql`).
|
||
Per-row id, `user_id` (FK with cascade), `device_token_hash`
|
||
(bcrypt at rest, unique index documents the no-collision
|
||
invariant), `created_at`, `expires_at` (`created_at + 30 days`),
|
||
`user_agent` (verbatim, app-layer-truncated to 1024 chars),
|
||
`last_seen_at` (refreshed on every successful lookup), `revoked_at`
|
||
(NULL means active). Secondary index on `(user_id, revoked_at)` so
|
||
the /settings list query is a covering walk.
|
||
- **`backend/app/device_trust.py`** — sibling of `otc.py` and
|
||
`passcode.py`. Carries `issue(user_id, user_agent)`,
|
||
`lookup(raw_token)`, `list_for_user(user_id)`, `revoke(user_id,
|
||
row_id)`, and `revoke_all(user_id)`. The 30-day window and the
|
||
cookie name (`rfc_device_trust`) live as module-level constants;
|
||
env-ifying them is a §19.2 candidate.
|
||
- **`§17` endpoints**
|
||
- `POST /auth/device-trust/start` — anonymous-reachable. Reads the
|
||
`rfc_device_trust` cookie; on a hit, signs the user in. On a
|
||
miss (expired, revoked, or unknown), clears the stale cookie and
|
||
returns 401.
|
||
- `GET /api/auth/me/devices` — list active trusted devices for
|
||
the signed-in user.
|
||
- `DELETE /api/auth/me/devices/{id}` — revoke a single row.
|
||
User-id scope enforced in SQL so a hostile client cannot
|
||
revoke another user's row by guessing ids.
|
||
- `DELETE /api/auth/me/devices` — revoke every active row.
|
||
- **OTC and passcode verify bodies** gain an optional
|
||
`trust_device: bool` field (default false). When true and verify
|
||
succeeds, the endpoint mints a fresh device-trust row and sets
|
||
the cookie on the response. Pre-v0.11.0 clients that omit the
|
||
field continue to behave as before.
|
||
- **Login.jsx** gains a "Trust this device for 30 days" checkbox
|
||
on both the OTC and passcode verify steps, plus a silent on-mount
|
||
call to `POST /auth/device-trust/start` so a returning user with
|
||
a valid cookie skips the email step entirely. A failure is
|
||
intentionally invisible — the user proceeds to the normal email
|
||
step.
|
||
- **`/settings/notifications` "Trusted devices" section** — lists
|
||
active rows with per-row "Revoke" + a "Revoke all devices" button
|
||
(with a `confirm()` prompt because the gesture is broad). The
|
||
surface intentionally does not single out the row whose cookie
|
||
the current request carries so a user can revoke "this device"
|
||
alongside any other from one place.
|
||
|
||
### Changed
|
||
|
||
- **`backend/app/main.py`** — the OTC and passcode verify endpoints
|
||
now also accept the `trust_device` flag and accept an injected
|
||
`Response` so they can attach the cookie. Two helpers
|
||
(`_set_device_trust_cookie`, `_clear_device_trust_cookie`) carry
|
||
the cookie attribute set in one place so the contract is
|
||
consistent across endpoints. The `Response` import is added
|
||
alongside the existing FastAPI re-exports.
|
||
- **`backend/app/api.py`** — imports `device_trust as device_trust_mod`
|
||
alongside `auth`/`db`; mounts the three `/api/auth/me/devices*`
|
||
endpoints immediately after `/api/auth/me/beta-request` so the
|
||
auth-shaped neighborhood stays clustered.
|
||
- **`frontend/src/api.js`** — exports `startDeviceTrust()`,
|
||
`listMyDevices()`, `revokeMyDevice(id)`, `revokeAllMyDevices()`.
|
||
`verifyOtc` and `verifyPasscode` accept an optional
|
||
`{ trustDevice }` argument that rides on the POST body.
|
||
|
||
### Upgrade steps (from 0.10.0)
|
||
|
||
- You **MUST** apply schema migration `017_device_trust.sql`. The
|
||
migration creates a single new table with one secondary index;
|
||
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.
|
||
- You **MUST** rebuild the frontend and restart the backend after
|
||
upgrading. `frontend/package.json#version` and `VERSION` both
|
||
move to `0.11.0` and the new `Set-Cookie` shape requires the
|
||
backend to be on the matching version.
|
||
- You **MUST** serve the deployment over HTTPS. The
|
||
`rfc_device_trust` cookie is set with `Secure=True` — a
|
||
cleartext deployment will never receive the cookie back from
|
||
the browser, so the trust gesture will appear to silently fail.
|
||
Production OHM deployments already serve over HTTPS; local
|
||
development against `http://localhost` is unaffected (no cookie
|
||
is set, the OTC/passcode paths continue to work).
|
||
- You **MAY** announce the new feature to your users. Existing
|
||
signed-in sessions are unaffected — the device-trust cookie is
|
||
opt-in on the next sign-in, and a user who never checks the box
|
||
keeps the v0.10.0 behavior verbatim.
|
||
|
||
|
||
## 0.10.0 — 2026-05-28
|
||
|
||
**Minor — schema migration required; new auth path is additive.**
|
||
This release lands user-set passcodes after OTC (roadmap item #8,
|
||
SPEC §6.2). After a successful one-time-code sign-in, the user can
|
||
set a passcode (4–20 characters) and use email + passcode for
|
||
subsequent sign-ins. OTC remains the structural fallback: a
|
||
forgotten passcode is recovered by requesting a fresh code, and
|
||
five consecutive failed passcode verifies lock the passcode path
|
||
for 15 minutes (HTTP 423) while leaving the OTC path open. The
|
||
`/login` surface now consults a new `GET /auth/passcode/check`
|
||
endpoint after the email step to decide whether to render a
|
||
passcode input or an OTC code input; an "Use a code instead" link
|
||
on the passcode step lets the user fall back to OTC manually. The
|
||
`/settings/notifications` page grew a new "Sign-in" tab where the
|
||
user can set, change, or remove their passcode.
|
||
|
||
### Upgrade steps (from 0.8.0)
|
||
|
||
1. **MUST** restart the backend so migration `015_passcode.sql`
|
||
runs. The migration adds four nullable columns to the `users`
|
||
table: `passcode_hash`, `passcode_set_at`,
|
||
`passcode_failed_attempts` (default 0), `passcode_locked_until`.
|
||
Existing rows pass through with `passcode_hash = NULL`, which
|
||
the runtime treats as "no passcode set" — every existing user
|
||
continues to sign in via OTC unchanged, and can opt into a
|
||
passcode from the new settings tab at any time.
|
||
2. **MUST** rebuild the frontend so the v0.10.0 `/login` flow and
|
||
the new settings tab ship. `frontend/package.json#version` and
|
||
`VERSION` both move to `0.10.0`.
|
||
3. **SHOULD** announce the new sign-in option to users. Wording
|
||
suggestion: "You can now set a passcode for faster sign-in.
|
||
We'll keep emailing one-time codes as a fallback — if you
|
||
forget your passcode, just request a code as usual."
|
||
4. **MAY** leave the §6.2 default lockout shape (5 attempts,
|
||
15-minute window) unchanged. v0.10.0 does not expose env
|
||
tunables for these; raising or lowering them lives in §19.2
|
||
as a candidate.
|
||
|
||
### Added
|
||
|
||
- **`POST /auth/passcode/set`** — authenticated. Body `{passcode}`.
|
||
Validates length (4–20) and refuses obvious patterns from a small
|
||
denylist (`0000`, `1234`, `aaaa`, `password`, etc.). bcrypt-hashes
|
||
the passcode and writes `users.passcode_hash` plus
|
||
`users.passcode_set_at`. Clears any active lockout and the
|
||
failure counter (a user setting a fresh passcode is implicitly
|
||
re-authenticating). Replaces any prior passcode.
|
||
- **`DELETE /auth/passcode`** — authenticated. Clears the passcode
|
||
hash and the set-at stamp; the user is back to OTC-only.
|
||
- **`POST /auth/passcode/verify`** — unauthenticated. Body
|
||
`{email, passcode}`. Returns HTTP 200 + minimal user payload on
|
||
success; HTTP 423 with `locked_until` when the account is in the
|
||
lockout window; HTTP 400 for every other failure (the
|
||
wrong-passcode and unknown-email modes both collapse to 400 so
|
||
the response does not enumerate account state).
|
||
- **`GET /auth/passcode/check`** — unauthenticated. Query param
|
||
`email`. Returns `{has_passcode: boolean}`. The Login.jsx flow
|
||
consults this after the email step to decide whether to render a
|
||
passcode input or fall back to OTC. The response carries only
|
||
the boolean; lockout state, the hash, and the `passcode_set_at`
|
||
stamp are not leaked. An unknown email and a known-without-
|
||
passcode email both return `false`, so the endpoint is
|
||
account-enumeration-safe.
|
||
- **Schema migration** `015_passcode.sql` — four ALTER TABLE ADD
|
||
COLUMN statements on the `users` table:
|
||
- `passcode_hash TEXT` (nullable) — the bcrypt hash. NULL means
|
||
"no passcode set".
|
||
- `passcode_set_at TEXT` (nullable) — ISO-8601 timestamp.
|
||
- `passcode_failed_attempts INTEGER NOT NULL DEFAULT 0` —
|
||
consecutive failure counter since last success.
|
||
- `passcode_locked_until TEXT` (nullable) — lockout window
|
||
expiry; verify refuses with HTTP 423 while populated and
|
||
in the future.
|
||
- **`backend/app/passcode.py`** — the passcode state machine:
|
||
validation (length + denylist), bcrypt hashing, set/clear,
|
||
status check, and the verify path with lockout management.
|
||
- **`frontend/src/components/Login.jsx`** — extended to a five-step
|
||
surface: email → passcode-or-code → optional post-OTC
|
||
passcode-offer → optional set-passcode. The "Use a code instead"
|
||
link on the passcode step re-dispatches an OTC and switches to
|
||
the code step. A 423 from passcode verify auto-falls back to OTC
|
||
with a visible status message.
|
||
- **"Sign-in" tab** in `/settings/notifications` — shows
|
||
passcode-set status, the recorded `passcode_set_at` stamp when
|
||
set, and Set / Change / Remove buttons. Mirrors the §14.5
|
||
"Privacy & cookies" tab pattern.
|
||
- **SPEC `§6` / `§14.1` / `§17` / `§19.2`** corrections per §19.3
|
||
rule 2:
|
||
- §6 names the three current auth paths (OTC, passcode-with-OTC-
|
||
fallback, OAuth-fallback-during-migration).
|
||
- §14.1 documents the stepped `/login` surface and the passcode
|
||
check endpoint.
|
||
- §17 lists the four new `/auth/passcode/*` endpoints.
|
||
- §19.2 surfaces four new candidates (passcode policy tunables
|
||
via env, per-IP rate-limit on `/auth/passcode/verify`,
|
||
passcode-change "old passcode" challenge, passkey/WebAuthn);
|
||
the "device-trust 30d" entry's passcode cross-ref is updated;
|
||
the "first-OTC profile capture" entry's roadmap cross-ref is
|
||
updated.
|
||
|
||
### Changed
|
||
|
||
- **`backend/app/main.py`** — registers the four new
|
||
`/auth/passcode/*` routes on the existing oauth router, alongside
|
||
the v0.7.0 `/auth/otc/*` routes.
|
||
- **`backend/app/api.py`** — `/api/auth/me` payload now includes
|
||
`has_passcode` (boolean) and `passcode_set_at` (string or null)
|
||
so the settings surface can render the Set / Change / Remove
|
||
affordances without a second round trip.
|
||
- **`frontend/src/api.js`** — adds `checkPasscode`, `verifyPasscode`,
|
||
`setPasscode`, `clearPasscode` helpers, neighboring the v0.7.0
|
||
`requestOtc` / `verifyOtc` block.
|
||
- **`frontend/src/components/NotificationSettings.jsx`** — adds the
|
||
`SignInSection` component between `MutesSection` and
|
||
`PrivacyCookiesSection`.
|
||
|
||
### Tests
|
||
|
||
- **`backend/tests/test_passcode_vertical.py`** — 17 new tests
|
||
cover: set requires session; check returns false for unknown and
|
||
for set-less users; check returns true after set without leaking
|
||
other fields; happy-path OTC → set → verify roundtrip; wrong
|
||
passcode increments the counter without locking; five consecutive
|
||
failures lock with 423 and persist `passcode_locked_until`;
|
||
lockout expires and the next attempt clears the counter; the OTC
|
||
path is unaffected by passcode lockout; clear wipes the hash and
|
||
set-at; setting a new passcode replaces the prior one and resets
|
||
the lockout; `passcode_set_at` updates on every set; validation
|
||
refuses too-short passcodes and denylist patterns; `/api/auth/me`
|
||
carries `has_passcode` and `passcode_set_at` correctly.
|
||
|
||
### Environment variables
|
||
|
||
None new. The existing `SECRET_KEY` continues to sign session
|
||
cookies; passcode hashing reuses the bcrypt dependency added in
|
||
v0.7.0. The lockout shape (5 attempts, 15 minutes) and the length
|
||
range (4–20) are hard-coded in `backend/app/passcode.py`. See
|
||
§19.2 for the env-tunable candidate.
|
||
|
||
## 0.9.0 — 2026-05-28
|
||
|
||
**Minor — no schema migration; reuses v0.8.0 columns and existing
|
||
SMTP.** This release ships the admin user-management surface at
|
||
`/admin/users` and the new-beta-request notifications that feed
|
||
it (roadmap item #7, SPEC §6.1 / §15 / §17). The two halves
|
||
compose: admins receive an inbox + email signal the moment a
|
||
pending user submits the v0.8.0 capture form; clicking through
|
||
lands on the page where they Grant or Revoke access.
|
||
|
||
The page consumes the v0.8.0 schema columns
|
||
(`permission_state`, `first_name`, `last_name`,
|
||
`beta_request_reason`, `permission_decided_by`,
|
||
`permission_decided_at`) without adding new ones — migration
|
||
slot 016 stays reserved for a future release. The single new
|
||
write endpoint, `POST /api/admin/users/<id>/permission`,
|
||
replaces v0.8.0's documented manual `UPDATE users SET
|
||
permission_state='granted'` gesture with an audited UI flip.
|
||
|
||
Admin notifications ride the existing §15 chokepoint — the
|
||
`new_beta_request` event_kind is added to the enum with
|
||
category `admin-actionable`, fan-out is to every owner / admin
|
||
minus the requester themselves, and the §15.4 email dispatch
|
||
only reaches recipients whose `email_admin_actionable` toggle
|
||
is on (the default for owners + admins). The event is the
|
||
framework's first non-RFC-scoped notification — `rfc_slug` is
|
||
NULL and the email deep-link points `/admin/users` instead of
|
||
`/rfc/<slug>`.
|
||
|
||
Decision on `/admin/allowlist`: the surface stays as a
|
||
sibling sub-tab, not folded into `/admin/users`. The two have
|
||
different keys (allowlist by email pre-sign-up, user list by
|
||
user_id post-sign-up) and a union row would be confusing
|
||
rather than clarifying. The allowlist's fast-path-bypass role
|
||
from v0.8.0 is unchanged; retiring the table outright is
|
||
deferred to a later session (see §19.2).
|
||
|
||
### Upgrade steps (from 0.10.0)
|
||
|
||
1. **MAY** rebuild the frontend. The build is the same shape
|
||
as v0.10.0; the lockfile pins to `0.9.0` so `npm install`
|
||
in `frontend/` updates it cleanly. No new env vars on the
|
||
frontend; the existing `VITE_APP_NAME` requirement carries
|
||
over.
|
||
2. **MAY** restart the backend. No schema migration runs in
|
||
this release — every v0.9.0 column is from
|
||
`014_beta_access.sql` (v0.8.0). Restart only if you want
|
||
the new endpoints registered in this version's binary.
|
||
3. **SHOULD** verify SMTP can reach the deployment's admin
|
||
inbox before the first pending user submits the capture
|
||
form. v0.9.0 reuses the v0.7.0 SMTP configuration
|
||
(`SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`,
|
||
`SMTP_STARTTLS`, `EMAIL_FROM`, `EMAIL_FROM_NAME`); a
|
||
misconfigured deployment will still write the inbox row,
|
||
but the admin won't hear about it through email. No new
|
||
env var is required — the framework reuses the existing
|
||
admin-user list (role IN ('owner', 'admin')) as the
|
||
notification recipients.
|
||
4. **SHOULD** announce the surface to existing admins. Wording
|
||
suggestion: "There's now a Users tab in /admin where you can
|
||
Grant or Revoke beta access — and you'll get an email +
|
||
inbox row when a fresh request lands. The manual SQL gesture
|
||
from v0.8.0 still works but is no longer the documented
|
||
path."
|
||
5. **MAY** drain the existing pending queue through the new UI.
|
||
If your deployment carried pending users through the v0.8.0
|
||
manual-UPDATE window, the Pending bucket on `/admin/users`
|
||
surfaces all of them with their captured profile. Granting
|
||
from the UI stamps `permission_decided_by` /
|
||
`permission_decided_at`, which any prior manual UPDATE
|
||
gestures may have left NULL (no harm done — the v0.8.0
|
||
contract didn't require those stamps).
|
||
|
||
### Added
|
||
|
||
- **`POST /api/admin/users/<id>/permission`** — body
|
||
`{state: 'pending'|'granted'|'revoked'}`. Flips the column,
|
||
stamps `permission_decided_by` + `permission_decided_at`,
|
||
and writes a `permission_events` row with event_kind in
|
||
`{permission_granted, permission_revoked,
|
||
permission_repended}`. Refuses 422 on self-flip (symmetric
|
||
to `set_mute` / `set_role` self-action refusals) and 422
|
||
on invalid state. Returns `{ok, permission_state, changed}`
|
||
where `changed=false` indicates a no-op (the requested
|
||
state already matched).
|
||
- **Widened `GET /api/admin/users` response** carrying
|
||
`permission_state`, `first_name`, `last_name`,
|
||
`beta_request_reason`, `created_at`,
|
||
`permission_decided_at`, plus joined
|
||
`permission_decided_by_login` /
|
||
`permission_decided_by_display`. Sort order surfaces
|
||
`pending` rows first (the admin queue), then `granted`,
|
||
then `revoked`; within a bucket, owners precede admins
|
||
precede contributors, with recency as the tiebreaker.
|
||
- **`new_beta_request` event_kind** in the §15.1 enum. Fired
|
||
by `notify.fan_out_new_beta_request` from the first
|
||
successful `POST /api/auth/me/beta-request` (re-submits
|
||
from the same pending user don't re-fire — the row's
|
||
first-time-complete check guards against carpet-bombing).
|
||
Recipients: every owner + admin minus the requester
|
||
themselves. Category: `admin-actionable`. Deep-link:
|
||
`/admin/users`. Actor: the requester per §15.9.
|
||
- **`/admin/users` page enhancements** in `Admin.jsx`. The
|
||
Users tab gains a state filter chip row (All / Pending /
|
||
Granted / Revoked with counts), a Grant / Revoke control
|
||
column, a Permission state badge, and an expandable
|
||
"why they want access" row beneath each pending user.
|
||
Sign-up timestamp surfaces in a new column.
|
||
- **`frontend/src/api.js#setUserPermission`** — client for
|
||
the new endpoint, neighboring `setUserMute` and
|
||
`setUserRole`.
|
||
- **`/beta-pending` copy update** in `BetaPending.jsx`. The
|
||
"your request is in review" page now honestly references
|
||
the admin-email signal v0.9.0 ships and admits the
|
||
framework does not commit to an SLA — turnaround depends
|
||
on operator availability, and the deployment operator is
|
||
the right person to ask if a wait runs long. No
|
||
deployment-specific text is baked in; per-deployment copy
|
||
lives in §13 of the deployment's repo, not in the
|
||
framework.
|
||
- **`backend/tests/test_admin_users_vertical.py`** — 10 new
|
||
tests covering: a beta-request submission fans
|
||
notifications to every admin + owner (and not to the
|
||
requester or to contributors); the requester's profile
|
||
fields land in the notification payload; re-submitting
|
||
the capture form does not re-fan; the `admin-actionable`
|
||
category mapping is wired; the `/api/admin/users`
|
||
listing carries the v0.8.0 columns with `pending` rows
|
||
sorted first; the permission-flip endpoint promotes
|
||
pending → granted with the right audit shape; the
|
||
endpoint promotes granted → revoked; the endpoint
|
||
refuses self-flip with 422; the endpoint refuses
|
||
non-admin callers with 403 and anonymous callers with
|
||
401; the endpoint refuses invalid states with 422; a
|
||
state-already-matches flip returns `changed=false`
|
||
without writing an audit row.
|
||
|
||
### Changed
|
||
|
||
- **`backend/app/api.py`** — the `POST /api/auth/me/beta-request`
|
||
handler now calls `notify.fan_out_new_beta_request` after the
|
||
capture UPDATE lands, gated on the row not previously having
|
||
all three profile fields populated (so re-submits don't
|
||
re-fan).
|
||
- **`backend/app/api_admin.py`** — the `list_users` query joins
|
||
against `users d ON d.id = u.permission_decided_by` for the
|
||
deciding-admin handle. The new `set_permission` endpoint
|
||
lives alongside `set_role` / `set_mute`.
|
||
- **`backend/app/notify.py`** — adds
|
||
`CATEGORY_ADMIN_ACTIONABLE`, the `fan_out_new_beta_request`
|
||
helper, and the `new_beta_request` arm in `render_summary`.
|
||
- **`backend/app/email.py`** — the `_EVENT_TO_CATEGORY` map
|
||
carries `new_beta_request → admin-actionable`, and
|
||
`_deep_link` routes framework-scoped admin signals to
|
||
`/admin/users` instead of `/rfc/<slug>`.
|
||
- **`frontend/src/components/Admin.jsx`** — the `UsersTab`
|
||
component is rewritten with state filter chips, a per-row
|
||
`UserRow` + `PermissionCell` decomposition, and a
|
||
`useMemo`-cached counts table. The pending-row reason
|
||
blockquote renders as a secondary `<tr>` beneath the user
|
||
row when present.
|
||
- **`frontend/src/components/BetaPending.jsx`** — pending-state
|
||
copy revised to reference the admin email signal honestly.
|
||
- **`frontend/src/App.css`** — new admin-chip / permission-badge
|
||
/ user-row-reason rules.
|
||
- **`SPEC.md`** §6 opening, §15.1 event-kinds enum, §17 admin
|
||
endpoints, §19.2 candidates list — per §19.3 rule-2.
|
||
- **`VERSION`** → `0.9.0`. `frontend/package.json#version` and
|
||
the lockfile mirror.
|
||
|
||
### Environment variables
|
||
|
||
None new. The release reuses the v0.7.0 SMTP configuration
|
||
(`SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`,
|
||
`SMTP_STARTTLS`, `EMAIL_FROM`, `EMAIL_FROM_NAME`,
|
||
`EMAIL_ENABLED`, `EMAIL_BUNDLE_THRESHOLD`) and the existing
|
||
admin-user list (role IN ('owner', 'admin')) as the
|
||
notification recipients. A deployment whose SMTP is misconfigured
|
||
will still see the inbox rows; only the email channel is muted.
|
||
|
||
### Deferred to later releases
|
||
|
||
- **Grant / revoke notification to the user** — symmetric
|
||
signal: when an admin grants or revokes access, fire a
|
||
`personal-direct` notification (event_kind
|
||
`permission_change_affecting_me`, already in the §15.1
|
||
enum) so the affected user sees the state change in
|
||
their inbox and email. v0.9.0 audits the gesture in
|
||
`permission_events` but does not yet escape the
|
||
app-internal log to the user. See §19.2.
|
||
- **Decline-with-reason on revoke** — the current Revoke
|
||
gesture takes only a confirmation; a follow-up release
|
||
may capture a free-text reason in
|
||
`permission_events.details`. See §19.2.
|
||
- **Allowlist deprecation** — the `/admin/allowlist` sub-tab
|
||
stays in place in v0.9.0 (the two surfaces have different
|
||
keys and a union row would be confusing). Retiring the
|
||
table outright is deferred to a session that can re-read
|
||
the post-v0.9.0 operator experience and decide whether
|
||
the fast-path-bypass role is still pulling weight. See
|
||
§19.2.
|
||
|
||
## 0.8.0 — 2026-05-28
|
||
|
||
**Minor — schema migration required; admission semantics shift.**
|
||
This release replaces the v0.3.0 / v0.7.0 `allowed_emails` admission
|
||
gate with an admin-grant flow (roadmap item #6, SPEC §6.1 / §6.2 /
|
||
§14.1 / §17). Anyone with a valid email can sign in via the v0.7.0
|
||
OTC flow; the OTC request endpoint no longer consults the
|
||
allowlist. A fresh user lands in `permission_state='pending'` until
|
||
an admin grants access. The first-OTC sign-in captures first name,
|
||
last name, and a free-text "why I should be included in the beta"
|
||
via a new `POST /api/auth/me/beta-request` endpoint; the captured
|
||
fields populate the same `users` row alongside the OAuth-era
|
||
columns.
|
||
|
||
A pending user has the same read access an anonymous viewer has —
|
||
the catalog, RFC bodies, the philosophy page, and every public
|
||
conversation are reachable. Every write-shaped endpoint
|
||
(`auth.require_contributor` floor) refuses pending users with 403.
|
||
The frontend renders a thin "Your beta access request is in
|
||
review" banner on every page and re-purposes the v0.3.0
|
||
`/beta-pending` page as the post-capture landing surface.
|
||
|
||
Grandfathered behavior: every `users` row at migration time
|
||
carries `permission_state='granted'` via the column default, so
|
||
existing contributors are unaffected. The OAuth fallback at
|
||
`/auth/callback` still consults the v0.3.0 allowlist (legacy
|
||
path); the OTC flow does not.
|
||
|
||
The `allowed_emails` table stays in the schema as a fast-path
|
||
bypass — the v0.3.0 admin UI continues to manage it, but the OTC
|
||
request handler no longer reads it. v0.9.0 (roadmap item #7) is
|
||
expected to ship the admin user-management page that replaces the
|
||
allowlist surface entirely; until then, admin grants are done by
|
||
direct DB `UPDATE`.
|
||
|
||
### Upgrade steps (from 0.13.0)
|
||
|
||
1. **MUST** restart the backend so migration `014_beta_access.sql`
|
||
runs. The migration adds `permission_state` (default `'granted'`,
|
||
so existing rows pass through unaffected), `first_name`,
|
||
`last_name`, `beta_request_reason`, `permission_decided_by`,
|
||
and `permission_decided_at` to the `users` table, plus an index
|
||
on `permission_state` for the pending queue. The migration is
|
||
ALTER-TABLE-based (no table rebuild) — every foreign key and
|
||
existing row passes through untouched.
|
||
2. **MUST** rebuild the frontend. The `Login.jsx` surface now
|
||
runs a conditional third step (the capture form) on fresh OTC
|
||
sign-ins; `BetaPending.jsx` carries the new "your request is
|
||
in review" copy; `App.jsx` renders a thin pending-access
|
||
banner. The build embeds the new `/api/auth/me/beta-request`
|
||
client call.
|
||
3. **SHOULD** announce the new admission flow to existing users.
|
||
Wording suggestion: "We've replaced our email-allowlist gate
|
||
with an admin-review flow. Existing users are unaffected;
|
||
new visitors sign in with their email, tell us a bit about
|
||
themselves, and an admin reviews their request before
|
||
discussion and contribution unlock." Existing sessions
|
||
remain valid.
|
||
4. **SHOULD** plan the admin grant mechanism. v0.8.0 does not
|
||
ship a UI for the grant — v0.9.0 (roadmap item #7) will. For
|
||
the v0.8.0 window, an admin grants access via direct DB
|
||
gesture:
|
||
```sql
|
||
UPDATE users
|
||
SET permission_state = 'granted',
|
||
permission_decided_by = <admin_user_id>,
|
||
permission_decided_at = datetime('now')
|
||
WHERE email = '<approved>';
|
||
```
|
||
The pending queue lives in `SELECT * FROM users WHERE
|
||
permission_state = 'pending' ORDER BY created_at`.
|
||
5. **MUST** decide whether to drain the `allowed_emails` table.
|
||
The OTC request handler no longer consults it; populated
|
||
rows are inert at the request surface. Three operator
|
||
choices, all valid:
|
||
* **Leave as-is** (the framework's default behavior — the
|
||
v0.3.0 admin UI continues to work, the rows stay as a
|
||
fast-path bypass record). Recommended if you anticipate
|
||
v0.9.0's user-management page folding the allowlist UI
|
||
into its surface.
|
||
* **Drain via the existing admin UI** (`/admin/allowlist`)
|
||
— one row at a time, no data loss elsewhere.
|
||
* **Bulk-drain via DB** — `DELETE FROM allowed_emails;`
|
||
drops every row; the table stays.
|
||
6. **MAY** announce write access individually to grandfathered
|
||
users you want to keep at `'granted'`. The default-`'granted'`
|
||
migration means no action is required for them; this step
|
||
exists only if you want to send a "you're still in" message.
|
||
|
||
### Added
|
||
|
||
- **`backend/migrations/014_beta_access.sql`** — adds
|
||
`permission_state` (CHECK in `('pending', 'granted', 'revoked')`,
|
||
default `'granted'`), `first_name`, `last_name`,
|
||
`beta_request_reason`, `permission_decided_by` (FK to users,
|
||
ON DELETE SET NULL), `permission_decided_at` to the `users`
|
||
table. Plus `idx_users_permission_state` for the pending
|
||
queue.
|
||
- **`POST /api/auth/me/beta-request`** — body
|
||
`{first_name, last_name, beta_request_reason}` (all required;
|
||
bounds 120 / 120 / 4000). Writes the fields to the signed-in
|
||
user's row and leaves `permission_state='pending'`. Refuses
|
||
HTTP 409 for already-granted / revoked users; refuses HTTP
|
||
401 for anonymous callers.
|
||
- **`needs_profile` flag** on the `/auth/otc/verify` response.
|
||
`true` iff the user is `permission_state='pending'` AND
|
||
carries no profile fields yet (a fresh OTC sign-in). The
|
||
Login.jsx surface uses the flag to gate the capture step.
|
||
- **`permission_state` field** on the `/api/auth/me` response,
|
||
plus `first_name`, `last_name`, `beta_request_reason`, and
|
||
the same `needs_profile` flag.
|
||
- **First-OTC profile capture step** in `Login.jsx`. Third
|
||
step in the sign-in surface, gated by the verify response's
|
||
`needs_profile` flag.
|
||
- **`/beta-pending` repurpose** in `BetaPending.jsx`. The
|
||
page now reads as "your request is in review" when the
|
||
viewer is pending; the v0.3.0 "private beta" framing
|
||
remains as the anonymous-viewer fallback.
|
||
- **Thin pending-access banner** at the top of every page
|
||
for `permission_state='pending'` viewers (other than
|
||
`/beta-pending` itself).
|
||
- **SPEC `§6` opening / `§6.1` / `§6.2` / `§14.1` / `§17` /
|
||
`§19.2`** corrections per §19.3 rule-2 — the admission
|
||
shift, the orthogonality of permission_state vs role / muted
|
||
/ notification-mutes, the new endpoints, and the
|
||
newly-surfaced §19.2 candidates (admin user-management page,
|
||
allowlist deprecation, admin notification on new request).
|
||
- **`backend/tests/test_beta_access_vertical.py`** — 9 new
|
||
tests covering: a fresh OTC user lands pending with empty
|
||
profile; the capture endpoint populates the fields and
|
||
keeps state pending; the capture endpoint refuses
|
||
anonymous / granted / revoked callers; a pending user is
|
||
refused write endpoints; an admin grant promotes pending →
|
||
granted; a grandfathered user is unaffected by the
|
||
migration; the OTC request endpoint accepts any email
|
||
regardless of allowlist state; the `allowed_emails` table
|
||
is still present in the schema.
|
||
|
||
### Changed
|
||
|
||
- **`backend/app/auth.py#require_contributor`** widens its
|
||
gate to refuse `permission_state != 'granted'` with HTTP
|
||
403. The §6.1 contributor capabilities (propose, branch,
|
||
PR, chat, claim) all funnel through this dependency, so
|
||
the widening covers them transitively. `SessionUser` now
|
||
carries `permission_state` (default `'granted'` for the
|
||
dataclass-default fallback path).
|
||
- **`backend/app/otc.py#request_code`** drops the allowlist
|
||
check from the OTC request flow. The `RequestOutcome`
|
||
shape loses the `'allowlist'` reason (replaced by
|
||
`'sent'` / `'cooldown'` / `'invalid'`).
|
||
- **`backend/app/otc.py#provision_or_link_user`** sets
|
||
`permission_state='pending'` explicitly on a fresh row.
|
||
Grandfathered (link-by-email) users pass through with
|
||
their existing column value.
|
||
- **`backend/app/auth.py#provision_user`** (OAuth fallback)
|
||
now sets `permission_state='granted'` explicitly on a
|
||
fresh row. The OAuth callback still consults the
|
||
`is_allowed_sign_in` allowlist check (the legacy fallback
|
||
path retains its v0.3.0 admission shape during the OAuth
|
||
migration window).
|
||
- **`backend/tests/test_otc_vertical.py`** — the
|
||
`test_otc_request_silently_drops_when_email_not_on_allowlist`
|
||
test (asserted the v0.7.0 allowlist gate) is replaced by
|
||
`test_otc_request_admits_emails_regardless_of_allowlist_population`
|
||
which asserts the v0.8.0 open-request contract. The
|
||
on-list test stays as a regression net for the
|
||
rate-limit / outbound-buffer plumbing.
|
||
- **`SPEC.md`** §6 opening, §6.1, §6.2, §14.1, §17, §19.2
|
||
per §19.3 rule-2.
|
||
- **`VERSION`** → `0.8.0`. `frontend/package.json#version` and
|
||
the lockfile mirror.
|
||
|
||
### Deferred to later releases
|
||
|
||
- **Admin user-management page** at `/admin/users` (item #7,
|
||
v0.9.0) — replaces the manual DB `UPDATE` gesture.
|
||
- **Allowlist UI deprecation** (also v0.9.0) — once the
|
||
admin user-management page lands, the `/admin/allowlist`
|
||
surface and the `allowed_emails` table both retire.
|
||
- **Admin email notification on new beta request** (item #7
|
||
again, v0.9.0).
|
||
- **Revoke gesture in the UI** — the `permission_state='revoked'`
|
||
state is wired in the schema and the auth gate; v0.9.0 ships
|
||
the admin UI that flips the column.
|
||
|
||
## 0.7.0 — 2026-05-28
|
||
|
||
**Minor — schema migration required; new auth path is additive.**
|
||
This release lands email + one-time-code sign-in (roadmap item #5,
|
||
SPEC §6.2) as the primary human-auth gesture. Users sign in by
|
||
typing their email, receiving a six-digit code via email, and
|
||
entering it. The Gitea OAuth callback (`/auth/callback`) remains
|
||
functional during migration — the new UI no longer points at it
|
||
primarily, but a "Sign in with Gitea (fallback)" link survives on
|
||
the new login surface so users with active OAuth sessions or older
|
||
invite paths still have a way in. A future release retires the
|
||
OAuth path entirely once every active user has signed in at least
|
||
once via OTC.
|
||
|
||
The migration path for existing users: first OTC sign-in matches by
|
||
`users.email` (case-insensitive) to the OAuth-era row and reuses
|
||
that row's `id` and `gitea_id`. New users provisioned via OTC carry
|
||
`gitea_id = NULL` and `gitea_login = NULL`. The `gitea_id` linker
|
||
remains the canonical handle for grandfathered users; `email`
|
||
becomes the identity key for everything provisioned after v0.7.0.
|
||
|
||
The Gitea **bot** user + token are still required (server-side git
|
||
operations — repo reads, PR creation — still flow through it). Only
|
||
the operator-facing sign-in surface moves.
|
||
|
||
### Upgrade steps (from 0.6.0)
|
||
|
||
1. **MUST** restart the backend so migration `012_otc.sql` runs.
|
||
The migration rebuilds the `users` table (SQLite cannot ALTER
|
||
COLUMN); existing rows pass through unchanged, but the new
|
||
schema relaxes `gitea_id` / `gitea_login` to nullable (with
|
||
partial unique indexes that ignore NULL) and adds a partial
|
||
unique index on `email`. A new `otc_codes` table is created.
|
||
2. **MUST** confirm the SMTP overlay is set (`SMTP_HOST`,
|
||
`SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`, `SMTP_STARTTLS`,
|
||
`EMAIL_FROM`, `EMAIL_FROM_NAME`). The OHM overlay already
|
||
carries these as of v0.5.0; deployments without them fall back
|
||
to logging the code to stdout (dev-only path — production
|
||
visitors will not receive their codes).
|
||
3. **SHOULD** announce the new email-based sign-in to existing
|
||
users. Wording suggestion: "You can now sign in by entering
|
||
your email and a one-time code we'll send you. Your old
|
||
account is linked automatically the first time you sign in."
|
||
4. **MAY** keep the existing OAuth callback as a fallback path.
|
||
The new login UI surfaces a small "Sign in with Gitea
|
||
(fallback)" link beneath the primary email/code form; a
|
||
deployment that prefers to hide it can override the Login
|
||
component in a future framework release that exposes the link
|
||
behind a feature flag. For v0.7.0, the link is hard-coded.
|
||
|
||
### New environment variables (all optional with defaults)
|
||
|
||
- `OTC_TTL_MINUTES` (default `10`) — how long a one-time code is
|
||
valid after issuance. Re-requesting invalidates the prior code
|
||
immediately regardless of TTL.
|
||
- `OTC_REQUEST_COOLDOWN_SECONDS` (default `60`) — per-email cooldown
|
||
between successive `/auth/otc/request` calls. The endpoint returns
|
||
HTTP 429 when the cooldown blocks a request (the loud-failure
|
||
shape; the abuse path is visible rather than swallowed).
|
||
|
||
No new secrets are required. The existing `SECRET_KEY` continues to
|
||
sign session cookies; OTC codes are bcrypt-hashed at rest using a
|
||
per-row salt the library generates.
|
||
|
||
### Added
|
||
|
||
- **`POST /auth/otc/request`** — body `{email}`. Generates a six-digit
|
||
code, stores its bcrypt hash with an expiry, and dispatches a plain
|
||
text email via the existing SMTP layer. Returns HTTP 200 (`{ok:true}`)
|
||
uniformly so allowlist state is not leaked. Returns HTTP 429 when
|
||
the per-email cooldown blocks the request.
|
||
- **`POST /auth/otc/verify`** — body `{email, code}`. Validates the
|
||
bcrypt hash against the most-recent unconsumed non-expired row, marks
|
||
the row consumed, provisions or links the `users` row by email, and
|
||
stores the session cookie. Returns HTTP 200 on success, HTTP 400 on
|
||
any failure (expired, consumed, wrong, unknown).
|
||
- **`backend/migrations/012_otc.sql`** — creates `otc_codes` and
|
||
rebuilds `users` with nullable `gitea_id` / `gitea_login` plus a
|
||
partial unique index on `email`.
|
||
- **`backend/app/otc.py`** — the OTC request/verify state machine and
|
||
the `provision_or_link_user` linker.
|
||
- **`backend/app/email_otc.py`** — outbound OTC mail composition. Reuses
|
||
the SMTP plumbing from `email.py` (`EmailConfig.from_env()`) and the
|
||
test buffer (`_SENT`) but writes its own envelope (no unsubscribe
|
||
footer, no quiet-hours hold — OTC mail carries a credential and
|
||
ignores notification preferences).
|
||
- **`frontend/src/components/Login.jsx`** — two-step sign-in surface
|
||
at `/login`. Step 1: enter email → request code. Step 2: enter
|
||
six-digit code → verify. Cmd/Ctrl+Enter on the code field submits.
|
||
The previous header "Sign in" link and the `Welcome` component's
|
||
inline link now route to `/login` instead of jumping straight to
|
||
the Gitea OAuth dance.
|
||
- **SPEC `§6.1` / `§6.2` / `§14.1` / `§17` / `§19.2`** corrections per
|
||
§19.3 rule-2 — see below.
|
||
- **`backend/tests/test_otc_vertical.py`** — 11 new tests covering
|
||
the happy path, expired/consumed/wrong codes, the per-email rate
|
||
limit (and its per-email isolation), the allowlist gate, the
|
||
migration link to OAuth-era users, fresh provisioning, and the
|
||
prior-code-invalidation behavior on re-request.
|
||
|
||
### Changed
|
||
|
||
- **`backend/app/auth.py#current_user`** — coerces NULL `gitea_id` and
|
||
NULL `gitea_login` to `0` / `""` so the `SessionUser` shape stays
|
||
stable for OTC-only users. The DB remains the source of truth for
|
||
"is this user OAuth-linked" (`gitea_id IS NOT NULL`).
|
||
- **`backend/requirements.txt`** — adds `bcrypt>=4.2` for OTC code
|
||
hashing. Pure-Python wheels are available on every platform the
|
||
deployment matrix targets; `pip install -r backend/requirements.txt`
|
||
picks it up.
|
||
- **`frontend/src/App.jsx`** — adds the `/login` route and replaces
|
||
the header "Sign in" `<a href="/auth/login">` with `<Link to="/login">`.
|
||
The `Welcome` component's inline sign-in link follows suit.
|
||
- **`frontend/src/components/Landing.jsx`** — the `/welcome` page's
|
||
primary action moves from "Sign in with Gitea" to "Sign in" pointing
|
||
at `/login`.
|
||
|
||
### Deferred to later releases
|
||
|
||
Per the v0.7.0 scope discipline (the foundation for items #6, #8, #9,
|
||
#10), several adjacent capabilities are intentionally not in this
|
||
release and surface as §19.2 candidates:
|
||
|
||
- **First-OTC profile capture** (first name, last name, "why") — item
|
||
#6, expected v0.8.0.
|
||
- **Open beta-access request flow** replacing the allowlist gate —
|
||
also item #6, v0.8.0.
|
||
- **Passcodes** (a long-term reauth token alternative) — item #8,
|
||
expected v0.10.0.
|
||
- **Device-trust 30-day skip** — item #9, expected v0.11.0.
|
||
- **Cloudflare Turnstile** on `/auth/otc/request` — item #10,
|
||
expected v0.12.0.
|
||
- **Removing the Gitea OAuth `/auth/callback` route entirely** — a
|
||
later release after every active user has signed in via OTC.
|
||
|
||
## 0.6.0 — 2026-05-28
|
||
|
||
**Minor — no operator action required.** A sweep-the-edges hardening
|
||
release (roadmap item #4, "anon discuss + contribute off-limits") that
|
||
audits every write-shaped backend endpoint and asserts each one
|
||
enforces an explicit `auth.require_contributor` (or stricter) gate
|
||
before doing any state-changing work. v0.3.0 hid write affordances
|
||
behind a sign-in CTA on the frontend; v0.5.0 added the PR-less
|
||
discussion surface with its own write gate; v0.6.0 sweeps the rest
|
||
and adds a regression test net so future endpoints can't quietly
|
||
ship without a gate. No schema migration, no env-var changes, no
|
||
new dependencies. The only behavioural change is one tightening: the
|
||
`GET /api/rfcs/<slug>/graduate/progress` SSE now requires
|
||
`auth.require_user` since it surfaces operator-visible step detail
|
||
(repo name, PR number, rollback steps) not part of the v0.3.0
|
||
anonymous-read contract for catalog/RFC bodies.
|
||
|
||
### Changed
|
||
|
||
- **`backend/app/api_graduation.py`** — `GET /graduate/progress`
|
||
now calls `auth.require_user(request)` as its first line.
|
||
Anonymous callers receive 401 instead of being able to subscribe
|
||
to a graduation's SSE. The floor is `require_user` (not
|
||
`require_contributor`) so a write-muted operator can still observe
|
||
the progress of a graduation they kicked off before being muted.
|
||
- **SPEC `§6.1`** (`SPEC.md`) — the Anonymous role's bullet now
|
||
documents the v0.6.0 audit: every write-shaped endpoint in §17
|
||
enforces an explicit gate; anonymous writes refuse 401. The list
|
||
of audited write families is recorded in-line.
|
||
- **SPEC `§10.10`** — the discussion-vs-contribution section now
|
||
records that the v0.5.0 write gates have a regression test net
|
||
(`test_anon_offlimits_vertical.py`) added in v0.6.0.
|
||
- **SPEC `§17`** — the `GET /api/rfcs/<slug>/graduate/progress`
|
||
bullet now documents the `require_user` gate added in v0.6.0,
|
||
with the rationale.
|
||
|
||
### Added (tests)
|
||
|
||
- **`backend/tests/test_anon_offlimits_vertical.py`** — twelve new
|
||
tests asserting that every write-shaped endpoint surveyed in the
|
||
v0.6.0 audit refuses anonymous callers with 401, and that the five
|
||
anonymous-read surfaces (health, philosophy, auth/me, catalog,
|
||
RFC view, discussion threads, proposals) stay reachable. Sixty-six
|
||
assertions in total, covering: propose; proposal merge / decline /
|
||
withdraw; branch promote-to-branch / start-edit-branch / metadata /
|
||
manual-flush / visibility / grants (POST + DELETE) / threads (POST)
|
||
/ messages (POST) / resolve / chat-seen / changes (accept / decline
|
||
/ reask) / chat-stream; super-draft start-edit-branch + metadata;
|
||
PR pr-draft / open / seen / review / merge / withdraw /
|
||
description / resolution-branch; discussion thread create + message
|
||
post + resolve; admin role / mute / allowlist (POST + DELETE) plus
|
||
the admin reads; notification preferences / quiet-hours / watch /
|
||
mark-read / user-mute (POST + DELETE); funder credentials (POST +
|
||
DELETE) + consent (POST + DELETE); graduation kickoff + claim +
|
||
progress SSE; PR review page anonymous-readable.
|
||
|
||
### Anonymous-writeable allowlist
|
||
|
||
Two endpoints are intentionally anonymous-by-design. They are not
|
||
audit findings; they are documented here so the contract is
|
||
explicit:
|
||
|
||
- `GET /auth/login` and `GET /auth/callback` — the OAuth
|
||
round-trip. Anonymous-by-design because they ARE the sign-in
|
||
entrypoint.
|
||
- `POST /api/webhooks/gitea` and `POST /api/webhooks/email-bounce`
|
||
— anonymous in the session sense but authenticated by HMAC shared
|
||
secret (`GITEA_WEBHOOK_SECRET` and `WEBHOOK_EMAIL_BOUNCE_SECRET`
|
||
respectively). The webhook receiver is the wrong place for an
|
||
authenticated session; the shared-secret shape is correct.
|
||
|
||
### §19.2 candidates surfaced
|
||
|
||
- None unique to this release. The two pre-existing candidates the
|
||
audit touched — anonymous-read polish for the discussion surface
|
||
(carried from v0.5.0) and the operator-visibility floor on
|
||
graduation progress — were settled here as `require_user` on
|
||
`/graduate/progress` rather than deferred.
|
||
|
||
### Upgrade steps (from 0.5.0)
|
||
|
||
1. The deployment **MUST** rebuild the frontend (`npm install &&
|
||
npm run build`) so the frontend bundle's reported version matches
|
||
the backend's. No new env vars; existing `frontend/.env` is
|
||
sufficient.
|
||
2. The deployment **MUST** restart the backend so the new
|
||
`require_user` gate on `/graduate/progress` is enforced. No
|
||
schema migration runs.
|
||
3. The deployment **MAY** announce the audit completion to
|
||
operators: every write-shaped backend endpoint now enforces an
|
||
explicit gate, and the `test_anon_offlimits_vertical.py` test
|
||
net asserts the contract on every CI run. The audited surfaces
|
||
are listed in the `Added (tests)` section above and in `SPEC.md`
|
||
§6.1.
|
||
4. The deployment **MUST NOT** assume any new envelope behaviour:
|
||
v0.6.0 is purely a hardening release. No schema, no env, no
|
||
dependency changes; the operator's role is reduced to rebuild +
|
||
restart.
|
||
|
||
|
||
## 0.5.0 — 2026-05-27
|
||
|
||
**Minor — no operator action required.** This release wires the
|
||
PR-less per-RFC discussion surface (roadmap item #3, SPEC §10.10).
|
||
An RFC's main view now carries a discussion panel distinct from PR
|
||
comments and branch chat; contribution — proposing edits the document
|
||
will land — still requires opening a PR via the §10.1 affordance.
|
||
The substrate is the existing `threads` / `thread_messages` tables;
|
||
rows with `threads.branch_name IS NULL` scope to the RFC's main view.
|
||
No schema migration is required.
|
||
|
||
### Added
|
||
|
||
- **PR-less discussion endpoints** (`backend/app/api_discussion.py`)
|
||
mounted at `/api/rfcs/<slug>/discussion/...`:
|
||
- `GET /api/rfcs/<slug>/discussion/threads` — list threads where
|
||
`threads.branch_name IS NULL`. Anonymous-readable per the v0.3.0
|
||
contract; the default whole-doc chat thread is materialized
|
||
lazily on first read.
|
||
- `POST /api/rfcs/<slug>/discussion/threads` — open a new
|
||
discussion thread (`thread_kind='chat'`, `anchor_kind='whole-doc'`,
|
||
`branch_name=NULL`). Body: optional `label`, optional first
|
||
`message`. Requires contributor role.
|
||
- `GET /api/rfcs/<slug>/discussion/threads/<thread_id>/messages`
|
||
— read messages on a discussion thread. Anonymous-readable.
|
||
- `POST /api/rfcs/<slug>/discussion/threads/<thread_id>/messages`
|
||
— post a message. Body: `text`, optional `quote`. Requires
|
||
contributor role.
|
||
- `POST /api/rfcs/<slug>/discussion/threads/<thread_id>/resolve`
|
||
— resolve a discussion thread. Permission per §10.10: thread
|
||
creator, RFC owner / arbiter, or app admin / owner.
|
||
- **`RFCDiscussionPanel.jsx`** — the right-column surface on the RFC
|
||
view when the viewer is on `main`. Composer requires sign-in;
|
||
Cmd/Ctrl+Enter sends. Multiple threads surface as pill-shaped
|
||
tabs above the message feed. A "New thread" affordance opens a
|
||
fresh thread on the same RFC.
|
||
- **SPEC `§10.10` PR-less discussion vs. contribution** — settles
|
||
the distinction between discussion (RFC-scoped, no PR, both
|
||
anonymous-readable and contributor-writeable) and contribution
|
||
(still requires a PR via §10.1). Also extends `§5`'s `threads`
|
||
table commentary so the null-branch interpretation is documented
|
||
as actively used rather than reserved scaffold.
|
||
- **SPEC `§17`** — lists the five new `discussion/...` endpoints
|
||
in the illustrative table.
|
||
|
||
### Changed
|
||
|
||
- **`backend/app/chat.py`** — `_fan_out_chat` now passes
|
||
`branch_name` straight through to the notify chokepoint instead of
|
||
coercing `None` to `"main"`. The notifications row carries the
|
||
null through, which preserves the §15.7 reconciler's keying on
|
||
`(rfc_slug, branch_name)` for the eventual discussion-side
|
||
chat-seen advance (deferred to a §19.2 candidate). Existing
|
||
branch-scoped chat continues to pass non-null branch names; the
|
||
change is invisible to that path.
|
||
- **`backend/app/notify.py`** — `fan_out_chat_message`'s
|
||
`branch_name` parameter is now typed `str | None` to match the
|
||
v0.5.0 PR-less shape. Routing rules are unchanged; the inbox prose
|
||
renders identically whether the chat lives on a branch or on the
|
||
RFC's discussion surface, which is the right honest signal.
|
||
- **`RFCView.jsx`** — the right-column panel is now conditional:
|
||
when `branchParam === 'main'`, render `RFCDiscussionPanel`
|
||
(the new PR-less surface); otherwise render the existing
|
||
`ChatPanel` (branch chat unchanged).
|
||
|
||
### §19.2 candidates surfaced
|
||
|
||
- PR-less discussion: range / paragraph anchors (the data model
|
||
permits them; the UI is the deferred part).
|
||
- PR-less discussion: distinct notification `event_kind`s
|
||
(`open_rfc_discussion_thread`, etc.) if usage shows contributors
|
||
want to filter discussion-vs-branch in the §15.2 inbox.
|
||
- PR-less discussion: chat-seen cursor closing the §15.7
|
||
reconciliation loop for the new surface.
|
||
- PR-less discussion: AI participant invocation (discussion-only,
|
||
no `<change>` block side-effects).
|
||
- PR-less discussion: anonymous-read polish to match the v0.6.0
|
||
write-gate hardening (item #4).
|
||
|
||
### Upgrade steps (from 0.4.0)
|
||
|
||
1. The deployment **MUST** rebuild the frontend (`npm install &&
|
||
npm run build`) so `RFCDiscussionPanel.jsx` ships in the bundle.
|
||
No new env vars; existing `frontend/.env` is sufficient.
|
||
2. The deployment **MUST** restart the backend so the new
|
||
`api_discussion` router mounts. No schema migration runs — the
|
||
`threads` table already supports `branch_name IS NULL` per §5,
|
||
and the v0.5.0 build is the first to write rows in that shape.
|
||
3. The deployment **MAY** announce the new surface to its
|
||
contributors: the RFC view's main page now carries a discussion
|
||
panel below the document. Existing branch chat and PR review
|
||
surfaces are unchanged.
|
||
4. The deployment **SHOULD NOT** expect a hardening of the
|
||
anonymous-write gate in v0.5.0 — that lands in v0.6.0 (item #4).
|
||
v0.5.0's write paths already refuse anonymous posts, so no
|
||
pre-emptive operator action is needed.
|
||
|
||
## 0.4.0 — 2026-05-27
|
||
|
||
**Minor — no operator action required beyond rebuild + restart.** The
|
||
proposer of a new RFC is now the implicit first owner of its super-
|
||
draft entry, set automatically at propose time from the session user.
|
||
The §13.1 claim flow remains available for *additional* owners. No
|
||
schema changes, no env-var changes, no API-shape changes; only newly
|
||
proposed RFCs receive the auto-owner — existing super-drafts whose
|
||
`owners:` is empty are unaffected and can still be claimed via §13.1
|
||
as before. This is a §19.3 rule-2 spec correction: `SPEC.md` §9.1,
|
||
§9.2, and §13.1 are updated to reflect the new shape.
|
||
|
||
### Changed
|
||
|
||
- **`POST /api/rfcs/propose`** (`backend/app/api.py`) now sets
|
||
`Entry.owners = [user.gitea_login]` when constructing the new
|
||
super-draft entry, instead of `owners=[]`. The session's
|
||
`gitea_login` is the canonical source; the endpoint never accepted
|
||
an owner field from the request payload and still doesn't.
|
||
- **`SPEC.md` §9.1** narrowed: the "no proposed-owner or working-
|
||
group fields" sentence becomes a proposer-owner-auto / working-
|
||
group-deferred split, with a §19.3 rule-2 note.
|
||
- **`SPEC.md` §9.2** frontmatter shape: `owners: []` → `owners:
|
||
[<proposer.gitea_login>]`, with a §19.3 rule-2 note.
|
||
- **`SPEC.md` §13.1** reframed: claim flow is now a graduation-time
|
||
broadening for additional owners, not a precondition for the
|
||
proposer's own RFC. The §13.1 / §13.2 / §13.3 graduation pipeline
|
||
itself is unchanged — the "at least one owner" precondition for
|
||
graduation still holds and is now satisfied by default.
|
||
|
||
### Upgrade steps (from 0.3.0)
|
||
|
||
1. The deployment **MUST** rebuild and restart per the routine
|
||
deploy steps. No `.env` changes, no schema/migration changes, no
|
||
API-shape changes.
|
||
2. Operators **SHOULD** note that newly proposed RFCs after the
|
||
upgrade carry the proposer in `owners:` automatically. Existing
|
||
super-drafts with empty `owners:` are not migrated; they remain
|
||
claimable via the §13.1 flow exactly as before. No deployment-
|
||
side data action is required.
|
||
3. Deployments **MAY** communicate the UX shift to active proposers
|
||
(the "Claim ownership" affordance no longer applies to your own
|
||
newly proposed RFC), but the affordance simply hides on RFCs the
|
||
viewer already owns, so no operator-side action is required.
|
||
|
||
## 0.3.0 — 2026-05-27
|
||
|
||
**Minor — operator action required if a deployment wants to enable the
|
||
private-beta gate; no action required to stay open.** This release adds
|
||
an email allowlist that, when populated, restricts OAuth sign-in to the
|
||
listed emails while keeping all read paths public. Anonymous visitors
|
||
now see the full app (catalog, RFC bodies, public branch conversations)
|
||
in read-only mode instead of the §14.1 landing-page wall.
|
||
|
||
### Added
|
||
|
||
- **`allowed_emails` table** (`backend/migrations/011_allowlist.sql`).
|
||
Empty list = gate off (any successful OAuth provisions a user, as
|
||
before). Any rows present = gate on (only listed emails, plus
|
||
users already grandfathered by `gitea_id`, may sign in).
|
||
- **Admin → Allowlist tab** at `/admin/allowlist`. Add/remove emails,
|
||
see who added each row and when. Status banner shows whether the
|
||
gate is currently active.
|
||
- **`/beta-pending` page** shown after a rejected OAuth callback. Free-
|
||
text invite-contact line is configurable via the new
|
||
`VITE_BETA_CONTACT` env var (optional; falls back to a generic line).
|
||
- **Beta chips** next to the Discuss/Contribute mode toggle, the Sign
|
||
in link, and the header Sign-in button so anonymous viewers see
|
||
immediately what is gated.
|
||
- **Anonymous read mode** in the React app: the §14.1 Landing page is
|
||
preserved at `/welcome` for deployments that want to link to it, but
|
||
the default route now renders the full app shell with write
|
||
affordances hidden behind a sign-in CTA.
|
||
|
||
### Changed
|
||
|
||
- **`/auth/callback`** now consults `auth.is_allowed_sign_in()` after
|
||
fetching the Gitea profile. Rejected sign-ins clear the OAuth state
|
||
and redirect to `/beta-pending`; the session is not populated.
|
||
- **`Catalog`** receives a `viewer` prop. Anonymous viewers see "Sign
|
||
in to propose (Beta)" instead of "+ Propose New RFC".
|
||
- **`PhilosophyWithSidebar`** now reads `authenticated` from the
|
||
current viewer instead of hardcoded `true`.
|
||
|
||
### Fixed
|
||
|
||
- **Single-finger scroll on the `/philosophy` page** (and any other
|
||
`.chrome-pane`-hosted view: `/admin/*`, `/settings/notifications`)
|
||
was broken on iOS Safari. The `.app` container used `height: 100vh`,
|
||
which on iOS measures the URL-bar-hidden ("largest") viewport — so
|
||
`.app` overflowed what's actually visible. Combined with the
|
||
`body { overflow: hidden }` in `index.css`, this meant single-finger
|
||
touches on the visible area were consumed by the (blocked) page-
|
||
level scroll attempt rather than reaching the nested `.chrome-pane`
|
||
scroll. Two-finger touches bypassed the page-level layer and
|
||
one-finger then worked once the URL bar had collapsed. Switched
|
||
`.app` to `height: 100dvh` (dynamic viewport — adjusts as the URL
|
||
bar shows/hides), with `100vh` retained as a fallback for browsers
|
||
predating iOS 15.4 / Chrome 108.
|
||
|
||
### Upgrade steps (from 0.2.3)
|
||
|
||
1. The deployment **MUST** rebuild the frontend with the new
|
||
`VITE_BETA_CONTACT` env var optionally set in `frontend/.env` (it
|
||
is OK to leave it blank — the `/beta-pending` page falls back to
|
||
a generic line).
|
||
2. The deployment **MUST** restart the backend so migration
|
||
`011_allowlist.sql` runs. No data loss; the new table starts
|
||
empty, which keeps the gate off and preserves existing behavior.
|
||
3. To **enable** the private-beta gate, the deployment operator
|
||
**SHOULD** sign in once (so their `users` row exists and they
|
||
grandfather in by `gitea_id`), then open `/admin/allowlist` and
|
||
add the first invited email. The first row added turns the gate
|
||
on for any user not yet in `users`.
|
||
4. To **stay open**, do nothing — leave `allowed_emails` empty and
|
||
the deployment behaves exactly as 0.2.3.
|
||
5. The deployment **MAY** customise its `/beta-pending` contact line
|
||
by setting `VITE_BETA_CONTACT` (an email, a URL, or a short
|
||
instruction) before the frontend build. Unset is fine.
|
||
|
||
## 0.2.3 — 2026-05-26
|
||
|
||
**Patch — no operator action required.** Rebuild and restart per the
|
||
routine deploy steps; no `.env` changes, no schema changes, no
|
||
behavior changes a deployment would notice in steady state beyond
|
||
the new endpoint below.
|
||
|
||
### Added
|
||
|
||
- **`GET /api/health`** — an unauthenticated probe returning JSON
|
||
`{version, status}` for ops tooling. `version` is the running
|
||
framework version (the contents of `VERSION` per §20.1, read at
|
||
process start and cached as a module-level constant in
|
||
`backend/app/health.py`); `status` is `"ok"` with HTTP 200 in
|
||
v1. The `"degraded"` / HTTP 503 path stays reserved in the
|
||
response shape so a later release can wire real degradation
|
||
conditions without breaking the contract. The version-match
|
||
check is the structural value — a deploy-control-panel polling
|
||
the endpoint after `systemctl restart` catches the failure mode
|
||
where a restart did not pick up the new code. See `SPEC.md` §17
|
||
and the §19.2 settlement.
|
||
|
||
### Upgrade steps (from 0.2.2)
|
||
|
||
1. The deployment **MAY** configure its monitoring (Pingdom,
|
||
Healthchecks.io, the flotilla deploy control panel, etc.) to
|
||
probe `/api/health` and compare the returned `version` against
|
||
the tag last deployed. The endpoint is unauthenticated by
|
||
design — no PII in the payload, no session required.
|
||
|
||
## 0.2.2 — 2026-05-26
|
||
|
||
**Patch — no operator action required.** Rebuild the frontend and
|
||
restart per the routine deploy steps; no `.env` changes, no schema
|
||
changes, no behavior changes a deployment would notice in steady
|
||
state beyond the fix below.
|
||
|
||
### Fixed
|
||
|
||
- **Mermaid blocks rendered as raw code on `/philosophy`.**
|
||
`frontend/src/components/Philosophy.jsx` parsed PHILOSOPHY.md with
|
||
the bare `marked` import and `dangerouslySetInnerHTML`, so
|
||
```` ```mermaid ```` fences fell through as `<pre>` blocks rather
|
||
than rendered diagrams. Swapped to `MarkdownPreview`, which already
|
||
carries the lazy mermaid loader + SVG render path used by the RFC
|
||
body view, so the philosophy surface now renders mermaid the same
|
||
way RFC bodies do. Pre-existing gap, surfaced when a deployment
|
||
authored a mermaid block in its `PHILOSOPHY_PATH` override.
|
||
|
||
## 0.2.1 — 2026-05-26
|
||
|
||
**Patch — no operator action required.** Rebuild the frontend and
|
||
restart per the routine deploy steps; no `.env` changes, no schema
|
||
changes, no behavior changes a deployment would notice in steady
|
||
state.
|
||
|
||
### Fixed
|
||
|
||
- **PR view blank page (React #310).** `frontend/src/components/PRView.jsx`
|
||
declared its `threadsByKind` `useMemo` *after* the early-return
|
||
guard for the loading state (`if (!pr) return …`). On first mount
|
||
`pr` was null so the early return fired and 14 hooks were called;
|
||
on the second render `pr` was populated and execution reached the
|
||
`useMemo`, calling 15 hooks — violating the Rules of Hooks and
|
||
unmounting the page subtree (blank screen). The `useMemo` is now
|
||
declared above the early returns with optional-chaining on `pr`,
|
||
keeping the hook count stable between the loading and loaded
|
||
renders. Pre-existing bug in the post-Contribute-rewrite PR view;
|
||
surfaced in production by 0.2.0's graduation merge race fix
|
||
enabling the workflow that opens this code path.
|
||
|
||
## 0.2.0 — 2026-05-26
|
||
|
||
**Breaking config change.** The frontend now requires
|
||
`VITE_APP_NAME` to be set at build time. `npm run build` fails with a
|
||
clear message if it is missing. There is no default; every deployment
|
||
names itself.
|
||
|
||
### Upgrade steps (from 0.1.0)
|
||
|
||
1. The deployment **MUST** create a `frontend/.env` file before
|
||
building. See `frontend/.env.example` for the contract.
|
||
2. The deployment **MUST** set `VITE_APP_NAME` in `frontend/.env` to
|
||
the user-visible name it wants to ship — the string used as the
|
||
browser tab title, the header brand, and the landing H1. The
|
||
build will fail loudly if this is unset or blank.
|
||
3. The deployment **MUST** rebuild the frontend (`npm install && npm
|
||
run build`) and redeploy the resulting bundle. The previous
|
||
bundle does not read `VITE_APP_NAME`.
|
||
4. The deployment **SHOULD** verify the name appears correctly in
|
||
the browser tab and the header after redeploy before bumping its
|
||
`.rfc-app-version` pin to `0.2.0`.
|
||
5. The deployment **MAY**, in the same upgrade, take the opportunity
|
||
to retire any local overrides it was using to brand the
|
||
pre-0.2.0 hardcoded strings — those overrides are now obsolete.
|
||
6. The deployment **MUST NOT** continue to expect graduation step 4
|
||
to fail on Gitea's "Please try again later" race; that path is
|
||
now handled by `wait_for_mergeable` + bounded retry. Any local
|
||
workaround retrying graduation at the deployment level is now
|
||
redundant and **SHOULD** be removed.
|
||
|
||
### Fixed
|
||
|
||
- **Graduation merge race.** §13.3 step 4 (`merge_pr`) used to call
|
||
Gitea's merge endpoint the instant step 3 returned, which races
|
||
Gitea's background mergeability computation and produces the
|
||
`405 "Please try again later"` response. Step 4 now waits for the
|
||
`mergeable` field to become non-null (bounded by a 30s timeout) and
|
||
retries the merge call up to three times on the transient response.
|
||
See `backend/app/gitea.py#wait_for_mergeable` and
|
||
`backend/app/bot.py#_merge_with_retry`.
|
||
|
||
### Changed
|
||
|
||
- `frontend/src/App.jsx` header brand reads `VITE_APP_NAME` instead of
|
||
a hardcoded string.
|
||
- `frontend/src/components/Landing.jsx` H1 reads `VITE_APP_NAME`
|
||
instead of a hardcoded string. The pitch / deck / attribution copy
|
||
in this file is still deployment-specific and tracked as a follow-up
|
||
for extraction into deployment config.
|
||
- `frontend/index.html` `<title>` is rewritten at build time via Vite's
|
||
HTML transform.
|
||
|
||
### Added
|
||
|
||
- `frontend/.env.example` documenting the new required variable.
|
||
- Top-level `VERSION` file and this `CHANGELOG.md` as the canonical
|
||
release log.
|
||
- `SPEC.md` §20 (versioning and downstream deployments) as the
|
||
binding policy for the framework/deployment relationship.
|
||
- `docs/DEPLOYMENTS.md` as the practical guide for building on
|
||
rfc-app and upgrading existing deployments to new versions.
|
||
- `CLAUDE.md` at the repo root capturing the separation-of-concerns
|
||
rule for working sessions.
|
||
|