1456c8b73f
Wave 5. Roadmap item #16. Folds in #21 Part C Amplitude wiring inline.
From the v0.9.0 /admin/users surface, an admin can create a fresh
users row, assign a role (admin/owner/granted-beta-user), include
an optional custom message, and dispatch an invite email carrying
a single-use opaque claim token (256-bit CSPRNG, bcrypt-at-rest,
7-day TTL). The invitee clicks through, lands at /invites/claim,
optionally checks "trust this device for 30 days", and is signed
in without going through OTC (the token in the email is itself
proof of email control).
Backend: migration 019_user_invite_tokens.sql (auto-applied);
backend/app/invites.py (create + claim + list module);
backend/app/email_invite.py (sibling of email_otc); POST
/api/admin/users + GET /api/admin/users/invites in api_admin;
POST /api/invites/claim in main.py's oauth_router (shares
trust-device cookie helper with /auth/otc/verify). 15 new tests in
test_admin_create_user_invite_vertical.py. permission_events row
with event_kind='user_invited' for the audit trail.
Frontend: Admin.jsx Create-user-invite modal + (pending invite)
badge in UserRow; InviteClaim.jsx /invites/claim landing page.
App.jsx route registration. api.js helpers.
Design choices documented in the CHANGELOG: immediate-send (no
admin-review queue); no bulk-invite (deferred); OTC skipped on
first sign-in (token = proof of email control); no users table
changes (discriminator is active user_invite_tokens row, not a
new first_sign_in_at column); refusal cases (self-invite 422,
duplicate email 409, non-owner admin granting owner 422).
Amplitude wiring (inline, #21 Part C):
- USER_INVITED from CreateUserInviteModal { target_user_id,
initial_role, custom_message_chars }
- INVITE_CLAIMED from InviteClaim { invited_by_admin_id,
initial_role, needs_passcode, trust_device }
- identify() BEFORE the claim event with properties:
claim_method 'admin-invite', invited_at (setOnce),
invited_by_admin_id (setOnce), initial_role (setOnce) —
so the Amplitude user record is created with the OHM
user_id from the very first event the invitee fires
Subagent ο shipped the feature on feature/v0.17.0-admin-create-user
(41b0c6a). Driver-side integration squash-merged into main,
hand-resolved 5 files (VERSION, package.json, CHANGELOG —
strict-descending to 0.17.0 → 0.16.0 → 0.15.0; App.jsx — both
new routes kept; api_admin.py — both per-user additive fields
+ pending-invite query both kept). Added inline Amplitude wiring
in CreateUserInviteModal + InviteClaim. 252 backend tests pass
(33 new across #12 + #16 surfaces). Frontend build verified green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2116 lines
108 KiB
Markdown
2116 lines
108 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.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.
|
||
|