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:
+241
-105
@@ -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 (4–20 characters) and use email + passcode for
|
||||
subsequent sign-ins. OTC remains the structural fallback: a
|
||||
forgotten passcode is recovered by requesting a fresh code, and
|
||||
five consecutive failed passcode verifies lock the passcode path
|
||||
for 15 minutes (HTTP 423) while leaving the OTC path open. The
|
||||
`/login` surface now consults a new `GET /auth/passcode/check`
|
||||
endpoint after the email step to decide whether to render a
|
||||
passcode input or an OTC code input; an "Use a code instead" link
|
||||
on the passcode step lets the user fall back to OTC manually. The
|
||||
`/settings/notifications` page grew a new "Sign-in" tab where the
|
||||
user can set, change, or remove their passcode.
|
||||
|
||||
### Upgrade steps (from 0.8.0)
|
||||
|
||||
1. **MUST** restart the backend so migration `015_passcode.sql`
|
||||
runs. The migration adds four nullable columns to the `users`
|
||||
table: `passcode_hash`, `passcode_set_at`,
|
||||
`passcode_failed_attempts` (default 0), `passcode_locked_until`.
|
||||
Existing rows pass through with `passcode_hash = NULL`, which
|
||||
the runtime treats as "no passcode set" — every existing user
|
||||
continues to sign in via OTC unchanged, and can opt into a
|
||||
passcode from the new settings tab at any time.
|
||||
2. **MUST** rebuild the frontend so the v0.10.0 `/login` flow and
|
||||
the new settings tab ship. `frontend/package.json#version` and
|
||||
`VERSION` both move to `0.10.0`.
|
||||
3. **SHOULD** announce the new sign-in option to users. Wording
|
||||
suggestion: "You can now set a passcode for faster sign-in.
|
||||
We'll keep emailing one-time codes as a fallback — if you
|
||||
forget your passcode, just request a code as usual."
|
||||
4. **MAY** leave the §6.2 default lockout shape (5 attempts,
|
||||
15-minute window) unchanged. v0.10.0 does not expose env
|
||||
tunables for these; raising or lowering them lives in §19.2
|
||||
as a candidate.
|
||||
|
||||
### Added
|
||||
|
||||
- **`POST /auth/passcode/set`** — authenticated. Body `{passcode}`.
|
||||
Validates length (4–20) and refuses obvious patterns from a small
|
||||
denylist (`0000`, `1234`, `aaaa`, `password`, etc.). bcrypt-hashes
|
||||
the passcode and writes `users.passcode_hash` plus
|
||||
`users.passcode_set_at`. Clears any active lockout and the
|
||||
failure counter (a user setting a fresh passcode is implicitly
|
||||
re-authenticating). Replaces any prior passcode.
|
||||
- **`DELETE /auth/passcode`** — authenticated. Clears the passcode
|
||||
hash and the set-at stamp; the user is back to OTC-only.
|
||||
- **`POST /auth/passcode/verify`** — unauthenticated. Body
|
||||
`{email, passcode}`. Returns HTTP 200 + minimal user payload on
|
||||
success; HTTP 423 with `locked_until` when the account is in the
|
||||
lockout window; HTTP 400 for every other failure (the
|
||||
wrong-passcode and unknown-email modes both collapse to 400 so
|
||||
the response does not enumerate account state).
|
||||
- **`GET /auth/passcode/check`** — unauthenticated. Query param
|
||||
`email`. Returns `{has_passcode: boolean}`. The Login.jsx flow
|
||||
consults this after the email step to decide whether to render a
|
||||
passcode input or fall back to OTC. The response carries only
|
||||
the boolean; lockout state, the hash, and the `passcode_set_at`
|
||||
stamp are not leaked. An unknown email and a known-without-
|
||||
passcode email both return `false`, so the endpoint is
|
||||
account-enumeration-safe.
|
||||
- **Schema migration** `015_passcode.sql` — four ALTER TABLE ADD
|
||||
COLUMN statements on the `users` table:
|
||||
- `passcode_hash TEXT` (nullable) — the bcrypt hash. NULL means
|
||||
"no passcode set".
|
||||
- `passcode_set_at TEXT` (nullable) — ISO-8601 timestamp.
|
||||
- `passcode_failed_attempts INTEGER NOT NULL DEFAULT 0` —
|
||||
consecutive failure counter since last success.
|
||||
- `passcode_locked_until TEXT` (nullable) — lockout window
|
||||
expiry; verify refuses with HTTP 423 while populated and
|
||||
in the future.
|
||||
- **`backend/app/passcode.py`** — the passcode state machine:
|
||||
validation (length + denylist), bcrypt hashing, set/clear,
|
||||
status check, and the verify path with lockout management.
|
||||
- **`frontend/src/components/Login.jsx`** — extended to a five-step
|
||||
surface: email → passcode-or-code → optional post-OTC
|
||||
passcode-offer → optional set-passcode. The "Use a code instead"
|
||||
link on the passcode step re-dispatches an OTC and switches to
|
||||
the code step. A 423 from passcode verify auto-falls back to OTC
|
||||
with a visible status message.
|
||||
- **"Sign-in" tab** in `/settings/notifications` — shows
|
||||
passcode-set status, the recorded `passcode_set_at` stamp when
|
||||
set, and Set / Change / Remove buttons. Mirrors the §14.5
|
||||
"Privacy & cookies" tab pattern.
|
||||
- **SPEC `§6` / `§14.1` / `§17` / `§19.2`** corrections per §19.3
|
||||
rule 2:
|
||||
- §6 names the three current auth paths (OTC, passcode-with-OTC-
|
||||
fallback, OAuth-fallback-during-migration).
|
||||
- §14.1 documents the stepped `/login` surface and the passcode
|
||||
check endpoint.
|
||||
- §17 lists the four new `/auth/passcode/*` endpoints.
|
||||
- §19.2 surfaces four new candidates (passcode policy tunables
|
||||
via env, per-IP rate-limit on `/auth/passcode/verify`,
|
||||
passcode-change "old passcode" challenge, passkey/WebAuthn);
|
||||
the "device-trust 30d" entry's passcode cross-ref is updated;
|
||||
the "first-OTC profile capture" entry's roadmap cross-ref is
|
||||
updated.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`backend/app/main.py`** — registers the four new
|
||||
`/auth/passcode/*` routes on the existing oauth router, alongside
|
||||
the v0.7.0 `/auth/otc/*` routes.
|
||||
- **`backend/app/api.py`** — `/api/auth/me` payload now includes
|
||||
`has_passcode` (boolean) and `passcode_set_at` (string or null)
|
||||
so the settings surface can render the Set / Change / Remove
|
||||
affordances without a second round trip.
|
||||
- **`frontend/src/api.js`** — adds `checkPasscode`, `verifyPasscode`,
|
||||
`setPasscode`, `clearPasscode` helpers, neighboring the v0.7.0
|
||||
`requestOtc` / `verifyOtc` block.
|
||||
- **`frontend/src/components/NotificationSettings.jsx`** — adds the
|
||||
`SignInSection` component between `MutesSection` and
|
||||
`PrivacyCookiesSection`.
|
||||
|
||||
### Tests
|
||||
|
||||
- **`backend/tests/test_passcode_vertical.py`** — 17 new tests
|
||||
cover: set requires session; check returns false for unknown and
|
||||
for set-less users; check returns true after set without leaking
|
||||
other fields; happy-path OTC → set → verify roundtrip; wrong
|
||||
passcode increments the counter without locking; five consecutive
|
||||
failures lock with 423 and persist `passcode_locked_until`;
|
||||
lockout expires and the next attempt clears the counter; the OTC
|
||||
path is unaffected by passcode lockout; clear wipes the hash and
|
||||
set-at; setting a new passcode replaces the prior one and resets
|
||||
the lockout; `passcode_set_at` updates on every set; validation
|
||||
refuses too-short passcodes and denylist patterns; `/api/auth/me`
|
||||
carries `has_passcode` and `passcode_set_at` correctly.
|
||||
|
||||
### Environment variables
|
||||
|
||||
None new. The existing `SECRET_KEY` continues to sign session
|
||||
cookies; passcode hashing reuses the bcrypt dependency added in
|
||||
v0.7.0. The lockout shape (5 attempts, 15 minutes) and the length
|
||||
range (4–20) are hard-coded in `backend/app/passcode.py`. See
|
||||
§19.2 for the env-tunable candidate.
|
||||
|
||||
## 0.8.0 — 2026-05-28
|
||||
|
||||
**Minor — schema migration required; admission semantics shift.**
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user