Release 0.10.0: user-set passcodes (OTC stays as fallback)

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-05-28 02:24:38 -07:00
parent ca8ba69acb
commit 55beba5c0a
13 changed files with 1967 additions and 191 deletions
+241 -105
View File
@@ -23,6 +23,247 @@ skip versions are the composition of each intervening adjacent
release's steps in order — no A-to-B path is pre-computed beyond
that.
## 0.13.0 — 2026-05-28
**Minor — schema migration required; new optional env vars.** This
release ships the cookie / privacy consent surface (roadmap item #11,
SPEC §14.5 / §14.6). Every viewer — authenticated and anonymous alike —
now sees a non-modal bottom-of-page banner on first visit asking which
categories of cookies they allow (essential / essential + analytics /
essential + analytics + other). The choice persists in `localStorage`
for anonymous viewers and in a new `cookie_consent` table for
authenticated viewers, with server-side overriding local on sign-in.
The framework also ships default `/privacy` and `/cookies` policy pages
that deployments can layer their own policy URL on top of via two new
optional env vars. No analytics SDK ships in this release — the
consent infrastructure is wired so item #13 (v0.15.0) can read from
`frontend/src/lib/consent.js` when the SDK lands.
### Added
- **Cookie consent banner** (`frontend/src/components/CookieConsentBanner.jsx`).
Non-modal, bottom of viewport. Three single-select choices with
inline descriptions. Visible until the user makes a choice; hides
thereafter. Reachable for revision via the settings surface.
- **Consent helper** (`frontend/src/lib/consent.js`). Exports
`getConsent()`, `hasChosen()`, `onConsentChange(cb)`, `setConsent()`,
`hydrateFromServer()`, `clearLocal()`. Cross-tab sync via the
`storage` event. Item #13's analytics SDK reads consent here before
importing.
- **Privacy and cookies policy pages**
(`frontend/src/pages/Privacy.jsx`, `frontend/src/pages/Cookies.jsx`).
Default minimal policies that describe the framework's stance and
list the cookies the framework sets. Deployments override via the
two new env vars below; the framework's stub always renders above
the link so the framework-level contract stays visible.
- **"Privacy & cookies" tab** in `/settings/notifications` showing
the current consent choice, the recorded-at stamp, and a "Change"
button that re-opens the banner via a custom DOM event.
- **`§17` endpoints** —
- `GET /api/users/me/cookie-consent` — read the current consent
record.
- `PUT /api/users/me/cookie-consent` — write a new consent record.
Upserts a single row per user, stamps `recorded_at` to now,
accepts `essential` for symmetry but always persists it as true.
- **Schema migration** `013_cookie_consent.sql` — new
`cookie_consent` table keyed by `user_id`, three flags
(`essential`, `analytics`, `other_cookies`), and `recorded_at`.
(Renumbered from `012_*` during driver integration because v0.7.0
also added a `012_otc.sql` migration that landed in the integration
order before this one.)
- **SPEC `§14.5` Cookie / privacy consent** — settles the banner
shape, the three-category single-select, the storage shape (local
for anon, server row for authenticated), the precedence rule on
sign-in, and the `consent.js` helper surface for downstream
callers including item #13.
- **SPEC `§14.6` Privacy and cookies policy pages** — settles the
`/privacy` and `/cookies` routes, the framework's stub content, and
the `VITE_PRIVACY_POLICY_URL` / `VITE_COOKIES_POLICY_URL` override
shape.
- **SPEC `§5`** — names the `cookie_consent` table in the canonical
app-tables list.
- **SPEC `§17`** — lists the two new cookie-consent endpoints.
- **SPEC `§19.2`** — surfaces four candidates: policy content via
content-repo file vs env var, GPC / DNT headers, multi-language
consent text, and the item #13 analytics-SDK gating dependency.
### Changed
- **`frontend/.env.example`** — documents the two new optional env
vars `VITE_PRIVACY_POLICY_URL` and `VITE_COOKIES_POLICY_URL`. Unset
is supported; defaults render the framework's stub.
- **`backend/app/api_notifications.py`** — module docstring grew two
endpoint lines; the new endpoints sit alongside the existing
`/api/users/me/*` neighbors.
- **`frontend/src/App.jsx`** — registers `/privacy` and `/cookies`
routes (anonymous-reachable), wires `<CookieConsentBanner>` into
the global chrome, and listens for a `rfc-app:cookie-consent-reopen`
custom event to re-open the banner from the settings surface.
### Upgrade steps (from 0.7.0)
- You **MUST** rebuild the frontend and restart the backend after
upgrading. `frontend/package.json#version` and `VERSION` both move
to `0.13.0` and the build embeds the new env-var contract.
- You **MUST** apply schema migration `013_cookie_consent.sql`. The
migration creates a single new table keyed by `user_id` with three
flag columns and a `recorded_at` stamp. The framework runs
migrations automatically at process start; no manual step is
required beyond restarting the backend so the migration runner
picks the file up.
- You **MAY** set `VITE_PRIVACY_POLICY_URL` to an http(s) URL that
points at your deployment's full privacy policy. The framework's
`/privacy` page renders its built-in stub above a link to the
configured URL. Unset is supported — the stub is sufficient for a
default-config deployment.
- You **MAY** set `VITE_COOKIES_POLICY_URL` to an http(s) URL that
points at your deployment's full cookies policy. Same shape as the
privacy URL.
- You **MAY** announce the new consent banner to your users. Existing
authenticated users will see the banner on their next visit
(because their `cookie_consent` row does not yet exist); their
current sessions remain valid.
## 0.10.0 — 2026-05-28
**Minor — schema migration required; new auth path is additive.**
This release lands user-set passcodes after OTC (roadmap item #8,
SPEC §6.2). After a successful one-time-code sign-in, the user can
set a passcode (420 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 (420) 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 (420) are hard-coded in `backend/app/passcode.py`. See
§19.2 for the env-tunable candidate.
## 0.8.0 — 2026-05-28
**Minor — schema migration required; admission semantics shift.**
@@ -209,107 +450,6 @@ direct DB `UPDATE`.
state is wired in the schema and the auth gate; v0.9.0 ships
the admin UI that flips the column.
## 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.7.0 — 2026-05-28
**Minor — schema migration required; new auth path is additive.**
@@ -897,7 +1037,3 @@ names itself.
- `CLAUDE.md` at the repo root capturing the separation-of-concerns
rule for working sessions.
## 0.1.0 — v1 build
Initial release. See `docs/DEV.md` for the slicing plan and build
history.