55beba5c0a
After a successful OTC sign-in, a contributor can set a passcode (4-20 characters, bcrypt-hashed) and use email + passcode for subsequent sign-ins. OTC remains the structural fallback: five consecutive failed verifies lock the passcode path for 15 minutes (HTTP 423), and a forgotten passcode is recovered by requesting a fresh code. Migration 015_passcode.sql adds four nullable columns to the users table; existing rows pass through as OTC-only and can opt into a passcode from a new Sign-in tab in /settings/notifications. The /login surface is extended to a five-step flow (email → either passcode or OTC code → optional post-OTC passcode offer → optional set-passcode). SPEC corrections per §19.3 rule 2: §6 names the three auth paths, §14.1 documents the stepped login flow, §17 lists the four new /auth/passcode/* endpoints, §19.2 surfaces four new candidates and refreshes the cross-refs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1040 lines
51 KiB
Markdown
1040 lines
51 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.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.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.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.
|
||
|