Compare commits

...

4 Commits

Author SHA1 Message Date
Ben Stull 6cfbf69e26 v0.15.0 post-correction: @amplitude/unified + session replay + overlay binding
Mid-Session-L correction to the v0.15.0 release that the dispatched
subagent (Session ξ) shipped. ξ was working from a pre-vendor brief
that specified @amplitude/analytics-browser and treated the API key
as a secret via `flotilla secret set`. Operator subsequently
provisioned the Amplitude project, surfaced the vendor's
recommended installation prompt, and confirmed the key value.
Three downstream changes:

- Package: swap @amplitude/analytics-browser → @amplitude/unified
  (analytics + session replay in one install; vendor-recommended).
- Init call: `amplitude.init(KEY, undefined, { defaultTracking: false })`
  becomes `amplitude.initAll(KEY, { analytics: { autocapture: true },
  sessionReplay: { sampleRate: 1 } })`. Vendor's exact installation-
  wizard shape; gates remain on the v0.13.0 consent banner.
- Binding: Amplitude browser keys are bundle-embedded by design
  (same nature as VITE_TURNSTILE_SITE_KEY from v0.12.0), so the key
  is public, not secret. CHANGELOG MUST step rewritten to bind via
  `flotilla overlay set <deployment> VITE_AMPLITUDE_API_KEY=<key>`
  rather than `flotilla secret set`. The roadmap row #13's
  "new secret: AMPLITUDE_API_KEY" wording predated vendor
  consultation; the roadmap will be updated when this ships.

§19.2 candidate captured in CHANGELOG: split the analytics consent
toggle into a separate session-replay category (recording has a
larger privacy footprint than event counters), follow-up release.

Wrapper structural shape (track/identify/anonymize, queue + drain,
consent-flip → setOptOut, lazy import) is unchanged from ξ's work.
Event taxonomy and Login.jsx / App.jsx / Admin.jsx / etc. instrument
sites are unchanged. Frontend build verified green
(VITE_APP_NAME=… npm run build).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 04:44:33 -07:00
Ben Stull 0fd8c52724 Release 0.15.0: Amplitude analytics (cookie-consent gated)
Roadmap item #13. Ships the frontend Amplitude SDK behind the v0.13.0
cookie/privacy consent gate. The analytics wrapper lives at
`frontend/src/lib/analytics.js` and exposes `track`, `identify`, and
`anonymize` over a stable nine-event taxonomy (Page Viewed, RFC
Viewed, User Signed In / Signed Out, RFC Proposed, PR Opened, Comment
Posted, Beta Access Requested, Admin Permission Decision). The
wrapper reads consent via `getConsent()` / `onConsentChange()` from
`frontend/src/lib/consent.js` (v0.13.0); the SDK module is
dynamically `import()`-ed only after `consent.analytics === true`,
and a later granted→denied flip calls `setOptOut(true)` so events
stop without a page reload. 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 keep working.

Wired into App.jsx (route-change Page Viewed + sign-in identify +
sign-out anonymize), Login.jsx (User Signed In with method =
otc/passcode/trust-device, Beta Access Requested on capture-profile
submit), ProposeModal.jsx (RFC Proposed), RFCView.jsx (RFC Viewed),
PRModal.jsx (PR Opened), RFCDiscussionPanel.jsx (Comment Posted with
surface=discussion), PRView.jsx (Comment Posted with surface=pr),
Admin.jsx (Admin Permission Decision with action=grant/revoke).
Event bodies carry only ids and enums — no titles, no comment
bodies, no names, no emails. The user binding passes only
`String(viewer.id)`.

Secret-vs-overlay binding caveat: Amplitude browser API keys are
visible in the shipped bundle via dev tools. Per the roadmap, the
key is still bound through `flotilla secret set` (rather than
`flotilla overlay set`) to keep all-keys-in-Secret-Manager
regularity for the OHM deployment; the CHANGELOG documents the
choice. Operator pre-deploy gesture (in the Upgrade steps block):
`pbpaste | ... ohm-rfc-app-flotilla secret set ohm-rfc-app
AMPLITUDE_API_KEY` — the wave-paused step before this release can
deploy.

No backend events ship in this release (Amplitude SaaS holds the
events); no schema migration; backend is unchanged. Migration slot
015 remains unused and available for the next minor that needs a
schema bump. New dependency: `@amplitude/analytics-browser`.
`VITE_AMPLITUDE_API_KEY` documented in `frontend/.env.example` with
the binding-choice caveat.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 04:40:04 -07:00
Ben Stull b3f1b15f65 Release 0.12.0: CloudFlare Turnstile on OTC email-entry 2026-05-28 03:52:45 -07:00
Ben Stull 6fb68a95c7 Release 0.11.0: trust device for 30 days 2026-05-28 03:46:24 -07:00
27 changed files with 3531 additions and 75 deletions
+419
View File
@@ -23,6 +23,167 @@ 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.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 })`,
`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.
- **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** (`App.jsx`): when `me.authenticated` lands and a
user id is available, the wrapper's `identify({ user_id })` is
called with `String(viewer.id)`. The sign-out gesture calls
`anonymize()` before the nav. No email, display name, or other PII
is passed through the SDK.
- **`@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
@@ -197,6 +358,264 @@ consent infrastructure is wired so item #13 (v0.15.0) can read from
(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.**
+134 -35
View File
@@ -339,6 +339,16 @@ and exact columns are illustrative; the implementing session can adjust.
on first write and updated on every change. Absence of a row means
"no choice yet" — the banner shows. Anonymous viewers persist their
choice in `localStorage` only, with no corresponding row here.
- `device_trust` — per-row record of the §6.2 device-trust gesture
(v0.11.0, roadmap item #9). One row per `(user, trusted device)`
pair; a user with three trusted devices has three rows. Columns:
`id`, `user_id` (FK users, ON DELETE CASCADE), `device_token_hash`
(bcrypt at rest, with a unique index documenting the no-collision
invariant of the 256-bit CSPRNG token space), `created_at`,
`expires_at` (`created_at + 30 days`), `user_agent` (verbatim,
application-layer-truncated to 1024 chars), `last_seen_at`
(refreshed on every successful lookup), `revoked_at` (NULL means
active). The raw token never lives in this table — only the hash.
**Super-draft scoping.** For rows in `threads` and `changes` where the
entry referenced by `rfc_slug` is in state `super-draft`, `branch_name`
@@ -374,7 +384,24 @@ them:
separate "forgot passcode" flow. The user can remove the passcode
at any time from the §6.2 sign-in settings tab, returning to
OTC-only.
3. **Gitea OAuth fallback (migration only).** The v0.1 OAuth
3. **Device trust (cookie-only, 30 days).** Added in v0.11.0
(roadmap item #9). After a successful OTC or passcode sign-in,
the visitor may check "trust this device for 30 days." The
framework then mints a server-issued opaque token, hashes it
(bcrypt) into the `device_trust` table, and sets a long-lived
HttpOnly + Secure + SameSite=Lax cookie carrying the raw token.
On a subsequent visit, `POST /auth/device-trust/start` resolves
the cookie and re-establishes the session without an OTC /
passcode roundtrip. The user can list and revoke their trusted
devices from the `/settings/notifications` "Trusted devices"
section; a revoked or expired cookie is cleared on the next
request. The cookie is "essential" per §14.5 — it is part of
authentication, not analytics, and is set regardless of the
user's analytics / other-cookies choice. The raw token only
ever lives in the outbound `Set-Cookie` header and the inbound
`Cookie` header; server-side storage is the hash, with
constant-time comparison on lookup.
4. **Gitea OAuth fallback (migration only).** The v0.1 OAuth
callback remains functional during the v0.7.0 window, with a
small "Sign in with Gitea (fallback)" link on `/login` so users
with active OAuth sessions or older invite paths still have a
@@ -2761,9 +2788,19 @@ The follow-up session will refine this. A minimal starting set:
silently if the email wasn't on the `allowed_emails` list (the
v0.3.0 admission gate); v0.8.0 (item #6) removed that check —
admission moved to `permission_state` on the freshly-provisioned
`users` row, asserted at the contributor gate. Per §19.2's
expected next session, this endpoint is the lead-up to the
Cloudflare-Turnstile abuse-mitigation overlay.
`users` row, asserted at the contributor gate. v0.12.0 (item #10)
gates this endpoint behind a CloudFlare Turnstile siteverify call:
the body carries an optional `turnstile_token` field, the server
POSTs `secret` + `response` to `challenges.cloudflare.com/turnstile/
v0/siteverify` before the bcrypt hash + SMTP send, and a failed
challenge refuses with HTTP 400 spending no rate budget. Two env
vars drive the policy: `CLOUDFLARE_TURNSTILE_SECRET` (Secret
Manager) and `TURNSTILE_REQUIRED` (overlay, default `false`). When
the secret is unset and `TURNSTILE_REQUIRED=false`, the gate is
open (the dev / pre-rollout path); when the secret is unset and
`TURNSTILE_REQUIRED=true`, the endpoint refuses with HTTP 500
"auth misconfigured" so a future config drift fails loudly
instead of silently disabling abuse defense.
- `POST /auth/otc/verify` — unauthenticated. Body carries `email` and
`code`. Validates the bcrypt hash against the most-recent unconsumed
non-expired row for the email, marks the row consumed, provisions
@@ -2823,6 +2860,28 @@ The follow-up session will refine this. A minimal starting set:
return HTTP 400 with a generic message; the no-passcode-set
failure also collapses to 400 so the response does not enumerate
account state. v0.10.0.
- `POST /auth/device-trust/start` — unauthenticated. Reads the
`rfc_device_trust` cookie (set previously by an OTC or passcode
verify with `trust_device: true`). On a non-expired, non-revoked
match, re-establishes the session and returns HTTP 200 with the
minimal user payload. On a miss (no cookie, expired, revoked, or
unknown), returns HTTP 401 and clears the stale cookie via the
response's `Set-Cookie` header. The failure modes collapse to
one shape so a probing client cannot enumerate "your row was
revoked" vs. "this token never existed". v0.11.0.
- `GET /api/auth/me/devices` — authenticated. Returns the active
(`revoked_at IS NULL` AND `expires_at > now`) device-trust rows
for the signed-in user: `id`, `created_at`, `expires_at`,
`last_seen_at`, `user_agent`. The bcrypt hash is structurally
private and is never surfaced. v0.11.0.
- `DELETE /api/auth/me/devices/{id}` — authenticated. Stamps
`revoked_at` on the row with id `{id}` belonging to the
signed-in user. The user-id scope is enforced in SQL so a
hostile client cannot revoke another user's row by guessing
ids; a row that does not match returns HTTP 404. v0.11.0.
- `DELETE /api/auth/me/devices` — authenticated. Revokes every
active row for the signed-in user; returns the count revoked.
v0.11.0.
- `GET /api/rfcs` — list entries with state, id, title, slug, repo,
owners, last_active_at, has_open_prs, starred-by-me. Supports
search, sort, filter chips, and the `unclaimed` predicate.
@@ -3903,37 +3962,77 @@ Candidates surfaced during v0.8.0 (open beta-access request flow,
message), and whether the `/auth/login` and `/auth/callback`
routes get a tombstone redirect to `/login` or just 404. Earns
its session once the OTC adoption curve flattens.
- **Device trust (30-day skip).** *Surfaced by v0.7.0 — the
signed-in cookie already lasts 30 days via SessionMiddleware,
but every sign-in still requires a fresh OTC or passcode.* The
roadmap item-#9 candidate adds a "trust this device" affordance
on the verify step that issues a longer-lived rotating token,
so returning visitors on the same device skip both the OTC and
the passcode step. The shape question is whether the trust is a
signed cookie distinct from the session, a row in a `device_trust`
table keyed by a random device-id, or a property of the session
itself; and whether the trust survives password-equivalent events
— v0.10.0's passcode-change and passcode-clear gestures are the
v1 instances — or only survives explicit logout. Earns its
session as the v0.11.0 design pass.
- **Cloudflare Turnstile (or equivalent) on `/auth/otc/request`.**
*Surfaced by v0.7.0 — the endpoint is now the new abuse hot
path.* Per-email cooldown stops the trivial loop; what it
doesn't stop is a distributed scrape that fans out across a
large invitee list to harvest the "this email is admitted vs.
this email is not" signal indirectly (timing differences, SMTP
bounce-rate observation). The roadmap item-#10 candidate gates
the request endpoint behind a one-step browser-side challenge
before the bcrypt hash + SMTP send. Open questions: which
provider (Turnstile is the default since it's free and
privacy-respecting; hCaptcha and reCAPTCHA are also viable);
how the deployment configures it (`TURNSTILE_SITE_KEY` +
`TURNSTILE_SECRET_KEY` env vars, gated by `if
config.turnstile_site_key:` at the handler so existing
deployments don't break); whether the verify endpoint also
gets a challenge (probably yes for parity); and how the test
harness mocks the challenge. Earns its session as the v0.12.0
design pass.
- **Device trust (30-day skip).** *Settled in v0.11.0 (roadmap
item #9). The shape: a distinct `rfc_device_trust` cookie
(HttpOnly + Secure + SameSite=Lax + 30-day Max-Age) carrying a
server-issued opaque token, keyed against a `device_trust` table
whose rows store the bcrypt hash. `POST /auth/device-trust/start`
resolves a presented cookie at next visit. A
`/settings/notifications` "Trusted devices" section lists active
rows with per-row + bulk revoke. The trust outlives a sign-out
(sign-out clears the session cookie, not the device-trust
cookie) and is not affected by passcode set/change/clear — the
next two items below carry the remaining open questions.*
- **Cross-device session revocation surface.** v0.11.0's
`/settings/notifications → Trusted devices` revokes the
long-lived device-trust grants. What it does NOT revoke is an
active session cookie sitting in another browser, or the
v0.10.0 passcode-failure-counter shape, or a stale
password-equivalent that some future release ships. The natural
next step is a single "active sessions and devices" surface
that lists everything currently authenticating as this user —
device-trust rows + active session cookies (if/when the
framework moves to server-side sessions) + future credential
shapes — and lets the user kill any of them with one gesture.
Earns its session when a second cross-cutting concern lands
(the most likely first trigger: future Yubikey / WebAuthn
support, which surfaces another credential to revoke).
- **Password-equivalent change invalidates device trust.** v0.11.0
intentionally leaves device-trust rows live across a passcode
set / change / clear. The argument is structural: the user has
the v0.10.0 lockout, the v0.11.0 per-device revoke list, and a
fresh sign-in path via OTC, so the cookie is not a high-value
bypass relative to the keys-to-the-account a passcode change
signals. The argument against is the conventional "changing a
password should kill every active session" expectation users
bring from other systems. This earns its own session once the
evidence is in: either a security-review finding that says
"this is the wrong default," or user feedback that says "I
expected my old laptop to sign out when I changed my passcode."
- **Device-trust window tunables via env.** v0.11.0 hard-codes
the 30-day window in `backend/app/device_trust.py`
(`TRUST_DURATION_DAYS = 30`). Surfacing it as an env var
(`DEVICE_TRUST_DURATION_DAYS`?) is small and obvious; deferring
follows the same pattern as the v0.10.0 passcode-lockout
hard-coding — name the tunable when a deployment wants it
different rather than shipping a knob that has no operator
asking for it.
- **Cloudflare Turnstile on `/auth/otc/request`.** *Settled in
v0.12.0 (roadmap item #10).* The OTC request endpoint is now
gated behind a Turnstile siteverify call: the frontend renders
the official widget on the `/login` email-entry step (and on the
passcode step for the "Use a code instead" fallback dispatch),
the captured token rides in the request body as
`turnstile_token`, and the backend POSTs `secret` + `response`
to `challenges.cloudflare.com/turnstile/v0/siteverify` before
the bcrypt hash + SMTP send. The widget renders only on the
email-entry / passcode-fallback dispatch points — the OTC
verify step is bottlenecked on email delivery and protected by
the five-minute TTL + single-use row consume, so a second
challenge there would double the rate budget against the same
abuse path without measurably more protection (revisit if bots
adapt to the email-entry challenge specifically). Configured
via `CLOUDFLARE_TURNSTILE_SECRET` (Secret Manager) and
`VITE_TURNSTILE_SITE_KEY` (frontend build-time overlay); a
third var `TURNSTILE_REQUIRED` (default `false`) lets the
operator flip from "soft-fail when secret unset" (the dev /
pre-rollout shape) to "fail-closed when secret unset" (HTTP 500
"auth misconfigured", the production-locked shape). Tests mock
the siteverify HTTP call at the `httpx.post` boundary in
`app.turnstile`. The hCaptcha / reCAPTCHA alternatives noted in
the v0.7.0 surfacing are still viable substitutes for a future
deployment that wants them but the framework's tested path is
Turnstile.
Candidates surfaced during v0.10.0 (user-set passcodes, §6.2 /
roadmap item #8):
+1 -1
View File
@@ -1 +1 @@
0.9.0
0.15.0
+18
View File
@@ -92,3 +92,21 @@ OTC_TTL_MINUTES=10
# loud-failure shape so the abuse path is visible). Set to 0 to
# disable the cooldown — useful for tests but never in production.
OTC_REQUEST_COOLDOWN_SECONDS=60
# --- v0.12.0: CloudFlare Turnstile gate on OTC dispatch (§6.2, item #10) ---
# Provision a Turnstile site at dash.cloudflare.com → Turnstile → Add
# site. The site key (public) goes in `frontend/.env` as
# VITE_TURNSTILE_SITE_KEY. The secret key (private) goes here and is
# what the backend POSTs to /siteverify alongside the user's response
# token. Leave both unset for dev/test paths; the gate stays open when
# the secret is absent AND TURNSTILE_REQUIRED=false (the default).
CLOUDFLARE_TURNSTILE_SECRET=
# When `true`, /auth/otc/request fails closed (HTTP 500 "auth
# misconfigured") if CLOUDFLARE_TURNSTILE_SECRET is unset. When `false`
# (the default), a missing secret skips verification — useful in dev
# and during the pre-rollout window when the operator hasn't wired
# the secret yet. Flip to `true` once the secret is wired so a future
# config drift surfaces as a loud 500 rather than a silent abuse-
# defense disablement.
TURNSTILE_REQUIRED=false
+63
View File
@@ -26,6 +26,7 @@ from . import (
api_prs,
auth,
db,
device_trust as device_trust_mod,
docs as docs_mod,
entry as entry_mod,
cache,
@@ -252,6 +253,68 @@ def make_router(
notify.fan_out_new_beta_request(requester_user_id=user.user_id)
return {"ok": True}
# ---------------------------------------------------------------
# v0.11.0: trust device for 30 days (§6.2, roadmap item #9).
#
# The mint path lives on the OAuth router (issuing the cookie is
# coupled to OTC/passcode verify). This module owns the read/revoke
# surface the /settings/devices page calls.
# ---------------------------------------------------------------
@router.get("/api/auth/me/devices")
async def list_my_devices(request: Request) -> dict[str, Any]:
"""Active device-trust rows for the signed-in user.
Active = not revoked, not expired. The current request's
device (if any) is *not* singled out here the surface
shows the same row shape for every device so the user can
revoke any of them without the page leaking which row
carries the cookie they're using right now.
"""
user = auth.require_user(request)
rows = device_trust_mod.list_for_user(user.user_id)
return {
"items": [
{
"id": r.id,
"created_at": r.created_at,
"expires_at": r.expires_at,
"last_seen_at": r.last_seen_at,
"user_agent": r.user_agent,
}
for r in rows
]
}
@router.delete("/api/auth/me/devices/{device_id}")
async def revoke_my_device(device_id: int, request: Request) -> dict[str, Any]:
"""Revoke a single device-trust row for the signed-in user.
The user-id scope is enforced in SQL so a hostile client
cannot revoke another user's row by guessing ids. A row that
doesn't exist, doesn't belong to this user, or is already
revoked reads as 404 the wrong-vs-already-revoked
distinction would only help a probing client enumerate ids.
"""
user = auth.require_user(request)
ok = device_trust_mod.revoke(user.user_id, device_id)
if not ok:
raise HTTPException(404, "Device not found")
return {"ok": True}
@router.delete("/api/auth/me/devices")
async def revoke_all_my_devices(request: Request) -> dict[str, Any]:
"""Revoke every active device-trust row for the signed-in user.
The user's current request stays authenticated via its
session cookie; the device-trust cookie carried on the
current device is also revoked, but `rfc_session` keeps the
request flow alive until sign-out / expiry.
"""
user = auth.require_user(request)
count = device_trust_mod.revoke_all(user.user_id)
return {"ok": True, "revoked": count}
# ---------------------------------------------------------------
# §7: the catalog
# ---------------------------------------------------------------
+351
View File
@@ -0,0 +1,351 @@
"""§6.2 / v0.11.0: trust device for 30 days (roadmap item #9).
After a successful OTC or passcode sign-in, a contributor may check
"trust this device for 30 days." The framework then issues a
server-issued opaque token, hashes it (bcrypt) for storage in the
`device_trust` table, and sets a long-lived cookie carrying the raw
token. On a subsequent visit, the cookie is presented at
`/auth/device-trust/start`; if a non-expired, non-revoked row matches,
the session is re-established without another OTC / passcode round
trip.
The shape:
* `issue(user_id, user_agent)` mint a fresh CSPRNG token, hash it,
insert a row, and return the raw token + row id so the endpoint
can set the cookie. The 30-day expiry is the only knob; the
`revoked_at` column stays NULL.
* `lookup(raw_token)` walk the user's active rows (the unique
index keys on the hash, so we read a small candidate set), check
the bcrypt hash in constant time, drop any row whose `expires_at`
has passed or whose `revoked_at` is non-NULL, and return the
matched row or None. On a hit, refresh `last_seen_at`.
* `list_for_user(user_id)` return the active rows for the
/settings/devices surface. Revoked + expired rows are filtered out
so the surface only shows live trust grants.
* `revoke(user_id, row_id)` stamp `revoked_at` on the row. The
next lookup refuses the cookie token (the row is dead).
* `revoke_all(user_id)` bulk-revoke every active row for the user.
The /settings/devices surface's "revoke all" button calls this.
Cookie shape: `rfc_device_trust`. HttpOnly, Secure, SameSite=Lax,
Max-Age=2592000 (30 days), Path=/. The cookie value is the raw token;
server-side storage is the hash. The cookie is "essential" per the
v0.13.0 cookie-consent banner (it is part of authentication, not
analytics), so the framework sets it regardless of analytics /
other-cookies choices.
Constant-time comparison: bcrypt's `checkpw` is already constant-time
over the hash bytes. We walk the candidate set linearly with `_check`
which delegates to `bcrypt.checkpw`; no early-exit shortcut leaks
which row was the match.
The raw token never appears in a log line or an exception message;
the helpers carry the token only as a parameter and forget it after
hashing.
The cookie sits orthogonal to the §6.1 `permission_state` gate: a
revoked or pending user with a valid device-trust cookie still
re-establishes their session (the cookie identifies the user, not
their admission state), and the existing `require_contributor` /
`require_admin` dependencies in `auth.py` continue to refuse the
unrelated write surfaces.
"""
from __future__ import annotations
import logging
import secrets
from dataclasses import dataclass
import bcrypt
from . import db
from .auth import SessionUser
log = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Tunables — hard-coded in v0.11.0 (§19.2 candidate to env-ify later).
# ---------------------------------------------------------------------------
TRUST_DURATION_DAYS = 30
COOKIE_NAME = "rfc_device_trust"
COOKIE_MAX_AGE_SECONDS = TRUST_DURATION_DAYS * 24 * 60 * 60
# 256 bits of CSPRNG entropy. `secrets.token_urlsafe(32)` yields ~43
# URL-safe characters; the bcrypt hash is what's stored, so the raw
# token only ever lives in the cookie.
TOKEN_BYTES = 32
# User-Agent header values seen in the wild can be unbounded; clamp
# to a reasonable ceiling so a hostile UA doesn't bloat the row.
USER_AGENT_MAX_LENGTH = 1024
# ---------------------------------------------------------------------------
# Issue
# ---------------------------------------------------------------------------
@dataclass
class IssueOutcome:
"""The shape returned from `issue`.
`raw_token` is the cookie value to send to the client; it never
appears in storage. `row_id` is the surrogate key for the
/settings/devices UI to address the row by id.
"""
raw_token: str
row_id: int
def _new_token() -> str:
return secrets.token_urlsafe(TOKEN_BYTES)
def _hash(token: str) -> str:
return bcrypt.hashpw(token.encode("utf-8"), bcrypt.gensalt()).decode("ascii")
def _check(token: str, token_hash: str) -> bool:
try:
return bcrypt.checkpw(token.encode("utf-8"), token_hash.encode("ascii"))
except (ValueError, TypeError):
return False
def _trim_user_agent(ua: str) -> str:
ua = (ua or "").strip()
if len(ua) > USER_AGENT_MAX_LENGTH:
return ua[:USER_AGENT_MAX_LENGTH]
return ua
def issue(user_id: int, user_agent: str) -> IssueOutcome:
"""Mint a fresh device-trust token + row for `user_id`.
The row's expiry is set 30 days in the future. The hash, not the
raw token, lands in the database. The caller (the endpoint) sets
the cookie with the raw token returned here.
"""
raw = _new_token()
h = _hash(raw)
ua = _trim_user_agent(user_agent)
cur = db.conn().execute(
f"""
INSERT INTO device_trust (user_id, device_token_hash, expires_at, user_agent)
VALUES (?, ?, datetime('now', '+{TRUST_DURATION_DAYS} days'), ?)
""",
(user_id, h, ua),
)
row_id = cur.lastrowid
return IssueOutcome(raw_token=raw, row_id=row_id)
# ---------------------------------------------------------------------------
# Lookup
# ---------------------------------------------------------------------------
@dataclass
class LookupOutcome:
"""The result of `lookup`.
`user` is populated only on a hit. `reason` distinguishes the
failure modes so the endpoint can decide whether to clear the
cookie ('expired', 'revoked', 'unknown') or just refuse ('invalid').
"""
ok: bool
user: SessionUser | None
reason: str # 'ok' | 'invalid' | 'unknown' | 'expired' | 'revoked'
row_id: int | None = None
def lookup(raw_token: str) -> LookupOutcome:
"""Resolve a presented cookie token to a user.
A hit refreshes `last_seen_at` on the matched row. A miss returns
a reason so the endpoint can clear the stale cookie if the row
was revoked or expired (vs. simply unknown, which probably means
the cookie was forged or the row was wiped by a /settings/devices
revoke from another browser).
"""
raw = (raw_token or "").strip()
if not raw:
return LookupOutcome(ok=False, user=None, reason="invalid")
# The unique index on `device_token_hash` would let us SELECT by
# hash if bcrypt were a stable hash, but bcrypt incorporates a
# per-row salt — equal tokens produce different hashes. We walk
# the candidate set instead. In practice the set is small (a
# human has a handful of trusted devices) and bcrypt is cheap on
# the order of milliseconds; the walk is bounded by the user's
# active device count.
#
# We don't pre-filter by `revoked_at IS NULL` here so that a
# token presented for a recently-revoked row produces a
# 'revoked' outcome (the endpoint surfaces a different shape).
# Same for expired: we let the walk hit and classify after.
rows = db.conn().execute(
"""
SELECT id, user_id, device_token_hash, expires_at, revoked_at
FROM device_trust
ORDER BY id DESC
""",
).fetchall()
matched = None
for row in rows:
if _check(raw, row["device_token_hash"]):
matched = row
break
if matched is None:
return LookupOutcome(ok=False, user=None, reason="unknown")
if matched["revoked_at"] is not None:
return LookupOutcome(ok=False, user=None, reason="revoked", row_id=matched["id"])
expired = db.conn().execute(
"SELECT datetime(?) < datetime('now') AS expired",
(matched["expires_at"],),
).fetchone()["expired"]
if expired:
return LookupOutcome(ok=False, user=None, reason="expired", row_id=matched["id"])
# Refresh last-seen so the /settings/devices surface can show the
# user when each device was last active. This is the only write
# the lookup path does on the hot read.
db.conn().execute(
"UPDATE device_trust SET last_seen_at = datetime('now') WHERE id = ?",
(matched["id"],),
)
user_row = db.conn().execute(
"""
SELECT id, gitea_id, gitea_login, email, display_name, avatar_url, role, permission_state
FROM users
WHERE id = ?
""",
(matched["user_id"],),
).fetchone()
if user_row is None:
# The user row was deleted but the device_trust row hadn't
# cascaded yet (shouldn't happen under the FK ON DELETE
# CASCADE — be defensive anyway). Treat as 'unknown' so the
# endpoint clears the cookie.
return LookupOutcome(ok=False, user=None, reason="unknown", row_id=matched["id"])
# Also stamp last_seen_at on the user row so the user's overall
# activity stamp keeps pace with cookie-only sign-ins.
db.conn().execute(
"UPDATE users SET last_seen_at = datetime('now') WHERE id = ?",
(matched["user_id"],),
)
return LookupOutcome(
ok=True,
user=SessionUser(
user_id=user_row["id"],
gitea_id=user_row["gitea_id"] or 0,
gitea_login=user_row["gitea_login"] or "",
display_name=user_row["display_name"],
email=user_row["email"] or "",
avatar_url=user_row["avatar_url"] or "",
role=user_row["role"],
permission_state=user_row["permission_state"] or "granted",
),
reason="ok",
row_id=matched["id"],
)
# ---------------------------------------------------------------------------
# List / revoke (for the /settings/devices surface)
# ---------------------------------------------------------------------------
@dataclass
class DeviceRow:
"""The shape the /settings/devices endpoint returns.
Note the absence of `device_token_hash` the hash is structurally
private, and the surface has no use for it.
"""
id: int
created_at: str
expires_at: str
last_seen_at: str
user_agent: str
def list_for_user(user_id: int) -> list[DeviceRow]:
"""Active device-trust rows for the user, freshest first.
Filters out revoked rows and rows whose expiry has passed; the
surface only shows live trust grants. A user wondering "which
devices are signed in" gets the answer that matches what the
framework would actually accept on a presented cookie.
"""
rows = db.conn().execute(
"""
SELECT id, created_at, expires_at, last_seen_at, user_agent
FROM device_trust
WHERE user_id = ?
AND revoked_at IS NULL
AND datetime(expires_at) > datetime('now')
ORDER BY last_seen_at DESC, id DESC
""",
(user_id,),
).fetchall()
return [
DeviceRow(
id=row["id"],
created_at=row["created_at"],
expires_at=row["expires_at"],
last_seen_at=row["last_seen_at"],
user_agent=row["user_agent"] or "",
)
for row in rows
]
def revoke(user_id: int, row_id: int) -> bool:
"""Revoke a single device-trust row for the given user.
Returns True iff a row was matched (still active, belongs to the
user). The user-id scope is enforced in SQL so a hostile client
cannot revoke another user's row by guessing ids.
"""
cur = db.conn().execute(
"""
UPDATE device_trust
SET revoked_at = datetime('now')
WHERE id = ?
AND user_id = ?
AND revoked_at IS NULL
""",
(row_id, user_id),
)
return cur.rowcount > 0
def revoke_all(user_id: int) -> int:
"""Revoke every active device-trust row for the user. Returns the
count of rows touched.
The /settings/devices "revoke all" button calls this. The user's
current request stays authenticated via its session cookie; the
device-trust cookie on the current device is also revoked, but
the session middleware's `rfc_session` cookie keeps the request
flow alive until the user signs out or the session cookie
expires.
"""
cur = db.conn().execute(
"""
UPDATE device_trust
SET revoked_at = datetime('now')
WHERE user_id = ?
AND revoked_at IS NULL
""",
(user_id,),
)
return cur.rowcount
+164 -6
View File
@@ -10,8 +10,8 @@ import logging
import secrets
from contextlib import asynccontextmanager
from fastapi import APIRouter, FastAPI, HTTPException, Request
from fastapi.responses import RedirectResponse
from fastapi import APIRouter, FastAPI, HTTPException, Request, Response
from fastapi.responses import JSONResponse, RedirectResponse
from pydantic import BaseModel, Field
from starlette.middleware.sessions import SessionMiddleware
@@ -20,12 +20,14 @@ from . import (
auth,
cache,
db,
device_trust as device_trust_mod,
digest,
email_otc,
hygiene,
otc,
passcode as passcode_mod,
providers as providers_mod,
turnstile,
webhooks,
)
from .bot import Bot
@@ -38,11 +40,25 @@ log = logging.getLogger("rfc_app")
class OtcRequestBody(BaseModel):
email: str = Field(min_length=3, max_length=320)
# v0.12.0 / roadmap item #10: CloudFlare Turnstile token from the
# frontend widget. Optional in the body so a deployment that has
# not yet wired the Turnstile site key (or a dev environment with
# the widget intentionally skipped) still routes through the same
# endpoint; the backend turnstile.verify_token call decides whether
# to admit the request based on `TURNSTILE_REQUIRED` + presence of
# the secret.
turnstile_token: str | None = Field(default=None, max_length=4096)
class OtcVerifyBody(BaseModel):
email: str = Field(min_length=3, max_length=320)
code: str = Field(min_length=1, max_length=16)
# v0.11.0 — "trust this device for 30 days" checkbox on the Login.jsx
# OTC step. When true and verify succeeds, the server issues a fresh
# device-trust row and sets the `rfc_device_trust` cookie on the
# response. Defaults to false so existing clients that don't send
# the flag continue to behave the way they did pre-v0.11.0.
trust_device: bool = False
class PasscodeSetBody(BaseModel):
@@ -52,6 +68,8 @@ class PasscodeSetBody(BaseModel):
class PasscodeVerifyBody(BaseModel):
email: str = Field(min_length=3, max_length=320)
passcode: str = Field(min_length=1, max_length=64)
# v0.11.0 — same trust-device opt-in as the OTC verify body.
trust_device: bool = False
@asynccontextmanager
@@ -117,6 +135,48 @@ def create_app() -> FastAPI:
app = create_app()
def _set_device_trust_cookie(response: Response, raw_token: str) -> None:
"""Attach the v0.11.0 device-trust cookie to the response.
HttpOnly + Secure + SameSite=Lax + 30-day Max-Age + Path=/. The
cookie value is the raw token; server-side storage is the hash.
The cookie is "essential" per the v0.13.0 cookie-consent contract
(it is part of authentication), so we set it regardless of the
user's analytics / other-cookies choice.
Secure=True means the cookie is only ever sent over HTTPS. The
SessionMiddleware in `create_app` keeps `https_only=False` for
dev parity, but the device-trust cookie holds a 30-day credential
and must not travel cleartext production deployments serve over
HTTPS, so Secure on the device-trust cookie is non-negotiable.
"""
response.set_cookie(
key=device_trust_mod.COOKIE_NAME,
value=raw_token,
max_age=device_trust_mod.COOKIE_MAX_AGE_SECONDS,
path="/",
secure=True,
httponly=True,
samesite="lax",
)
def _clear_device_trust_cookie(response: Response) -> None:
"""Delete the device-trust cookie on the response.
Used when the framework detects a presented cookie that is
expired, revoked, or otherwise stale the next request from
this device will not carry a dead token.
"""
response.delete_cookie(
key=device_trust_mod.COOKIE_NAME,
path="/",
secure=True,
httponly=True,
samesite="lax",
)
def _oauth_router(config) -> APIRouter:
router = APIRouter()
@@ -164,7 +224,27 @@ def _oauth_router(config) -> APIRouter:
# ---------------------------------------------------------------
@router.post("/auth/otc/request")
async def otc_request(body: OtcRequestBody):
async def otc_request(body: OtcRequestBody, request: Request):
# v0.12.0 / roadmap item #10: gate the request on a successful
# Turnstile siteverify before the bcrypt hash + SMTP send. The
# check runs first so a failed challenge spends no rate budget
# and produces no envelope. When the operator has not wired the
# secret AND TURNSTILE_REQUIRED=false (the default), the gate
# opens — see `backend/app/turnstile.py` for the full matrix.
client_ip = request.client.host if request.client else None
ts = turnstile.verify_token(body.turnstile_token, client_ip=client_ip)
if not ts.ok:
if ts.reason == "misconfigured":
# TURNSTILE_REQUIRED=true but the secret is unset. This
# is an operator/config problem, not a client problem;
# surface as 500 so the operator notices in their logs
# rather than blaming the user's browser.
raise HTTPException(500, "auth misconfigured")
# missing-token / failed / network → uniform 400 so the
# response does not enumerate which leg of the challenge
# broke. The reason is in the server logs.
raise HTTPException(400, "verification failed")
outcome = otc.request_code(body.email)
if outcome.reason == "cooldown":
# Loud failure per the rate-limit primitive — the abuse
@@ -177,7 +257,7 @@ def _oauth_router(config) -> APIRouter:
return {"ok": True}
@router.post("/auth/otc/verify")
async def otc_verify(body: OtcVerifyBody, request: Request):
async def otc_verify(body: OtcVerifyBody, request: Request, response: Response):
result = otc.verify_code(body.email, body.code)
if not result.ok or result.user is None:
raise HTTPException(400, "Invalid or expired code")
@@ -202,6 +282,18 @@ def _oauth_router(config) -> APIRouter:
and not last_name
and not beta_request_reason
)
# v0.11.0 — opt-in device trust. The checkbox lives on the
# Login.jsx OTC step; when true, the server mints a fresh
# device-trust row and sets the long-lived cookie. The cookie
# is "essential" per the v0.13.0 consent contract (it is part
# of authentication, not analytics) so it lands regardless of
# the user's analytics / other-cookies choice. We capture the
# User-Agent at issuance so the /settings/devices surface can
# render a rough device label.
if body.trust_device:
ua = request.headers.get("user-agent", "")
outcome = device_trust_mod.issue(result.user.user_id, ua)
_set_device_trust_cookie(response, outcome.raw_token)
return {
"ok": True,
"user": {
@@ -254,12 +346,16 @@ def _oauth_router(config) -> APIRouter:
return {"ok": True}
@router.post("/auth/passcode/verify")
async def passcode_verify(body: PasscodeVerifyBody, request: Request):
async def passcode_verify(body: PasscodeVerifyBody, request: Request, response: Response):
"""Sign in with email + passcode. Returns the standard session
payload on success; HTTP 423 with `locked_until` when the
account is in the lockout window; HTTP 400 for every other
failure (the wrong-vs-unknown distinction is intentionally
collapsed so a probing client cannot enumerate emails)."""
collapsed so a probing client cannot enumerate emails).
v0.11.0: the body's `trust_device` flag, if true, mints a
fresh device-trust row and sets the long-lived cookie. Same
opt-in contract as `/auth/otc/verify`."""
result = passcode_mod.verify_passcode(body.email, body.passcode)
if result.reason == "locked":
raise HTTPException(
@@ -272,6 +368,10 @@ def _oauth_router(config) -> APIRouter:
if not result.ok or result.user is None:
raise HTTPException(400, "Invalid passcode")
auth.store_session(request, result.user)
if body.trust_device:
ua = request.headers.get("user-agent", "")
outcome = device_trust_mod.issue(result.user.user_id, ua)
_set_device_trust_cookie(response, outcome.raw_token)
return {
"ok": True,
"user": {
@@ -282,4 +382,62 @@ def _oauth_router(config) -> APIRouter:
},
}
# ---------------------------------------------------------------
# v0.11.0: trust device for 30 days (§6.2, roadmap item #9).
#
# The /auth/device-trust/start endpoint resolves a presented
# `rfc_device_trust` cookie. If it matches a non-expired,
# non-revoked row, the session is re-established and the client
# is told to skip OTC/passcode entry. A stale cookie (expired or
# revoked) is cleared on the response. A miss is structurally
# silent — the client falls back to the email step.
#
# The endpoint is anonymous-reachable: a returning visitor with
# the cookie hits this before the email step. We do not gate it
# on a session because the entire point is to establish one.
# ---------------------------------------------------------------
@router.post("/auth/device-trust/start")
async def device_trust_start(request: Request):
"""Sign in via a presented device-trust cookie.
On a hit, re-establishes the session in the cookie store and
returns a user payload shaped like /auth/otc/verify (minus
`needs_profile`, which a returning device-trust user is
structurally past they signed in at least once before).
On a miss, returns 401 + clears the stale cookie. An
'unknown' miss (cookie present but no row matches) also
clears, since the token is dead to the server either way.
Note on response construction: we return a `JSONResponse`
directly rather than raising `HTTPException` on the miss
path because FastAPI's exception handler builds a new
response from scratch and would drop any `set_cookie` /
`delete_cookie` calls. The hand-built `JSONResponse` lets
us attach the cookie-clear header alongside the 401.
"""
raw = request.cookies.get(device_trust_mod.COOKIE_NAME, "")
if not raw:
return JSONResponse({"detail": "No device trust"}, status_code=401)
outcome = device_trust_mod.lookup(raw)
if not outcome.ok or outcome.user is None:
# Clear the stale cookie so subsequent requests don't
# keep replaying a dead token. We surface 401 in all
# cases so a probing client can't tell "your row was
# revoked" from "this token never existed".
response = JSONResponse({"detail": "Device trust invalid"}, status_code=401)
_clear_device_trust_cookie(response)
return response
auth.store_session(request, outcome.user)
return {
"ok": True,
"user": {
"id": outcome.user.user_id,
"display_name": outcome.user.display_name,
"email": outcome.user.email,
"role": outcome.user.role,
"permission_state": outcome.user.permission_state,
},
}
return router
+148
View File
@@ -0,0 +1,148 @@
"""§6.2 / v0.12.0 / roadmap item #10: CloudFlare Turnstile siteverify.
The OTC request endpoint (`/auth/otc/request`) is the abuse hot path
of the auth surface since v0.7.0 the per-email cooldown stops the
trivial back-to-back loop, but it does not stop a distributed scraper
that fans out across a large invitee list to harvest the "this email
is admitted vs. this email is not" signal indirectly (timing
differences, SMTP bounce-rate observation). v0.12.0 gates the request
endpoint behind a one-step browser-side Turnstile challenge before the
bcrypt hash + SMTP send.
Stateless: no DB writes, no schema change. The siteverify call to
CloudFlare lives entirely in this module; the endpoint handler in
`main.py` thin-wraps `verify_token`.
Tunables (read at call time so tests can monkeypatch):
* `CLOUDFLARE_TURNSTILE_SECRET` the operator-provisioned secret
key from the Turnstile dashboard. Lives in GCP Secret Manager in
production; absent in tests (which monkeypatch the siteverify
transport). When unset, the behavior depends on `TURNSTILE_REQUIRED`:
- `TURNSTILE_REQUIRED=true` fail closed (`misconfigured`).
- `TURNSTILE_REQUIRED=false` (default) skip verification entirely
and admit the request. This is the dev/test path and the
"operator hasn't wired the secret yet" path; production
deployments **should** set `TURNSTILE_REQUIRED=true` once the
secret is in place so a regression in the secret wiring fails
loudly instead of silently disabling abuse defense.
* `TURNSTILE_REQUIRED` `true` / `false` (default `false`).
When `false` and the secret is absent, the gate is open. When
`true` and the secret is absent, the endpoint refuses with a
misconfigured-auth shape rather than silently letting requests
through.
* `TURNSTILE_SITEVERIFY_URL` points at the real CloudFlare
endpoint by default. Override in tests to redirect at a mock
URL when `httpx.MockTransport` isn't ergonomic for the case.
The siteverify contract is documented at
https://developers.cloudflare.com/turnstile/get-started/server-side-validation/.
We POST `secret` + `response` (and optionally `remoteip`) as form
fields and read back `{"success": true|false, ...}`. Any network /
parse failure on the siteverify call is treated as a verification
failure (`network`) the abuse path is to skip the challenge, so
"can't reach CloudFlare" defaults to "refuse the request" when
`TURNSTILE_REQUIRED=true`, and "admit" when `TURNSTILE_REQUIRED=false`.
"""
from __future__ import annotations
import logging
import os
from dataclasses import dataclass
import httpx
log = logging.getLogger(__name__)
SITEVERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
def _secret() -> str:
return os.environ.get("CLOUDFLARE_TURNSTILE_SECRET", "").strip()
def _required() -> bool:
raw = os.environ.get("TURNSTILE_REQUIRED", "").strip().lower()
return raw in ("1", "true", "yes", "on")
def _siteverify_url() -> str:
return os.environ.get("TURNSTILE_SITEVERIFY_URL", "").strip() or SITEVERIFY_URL
@dataclass
class VerifyOutcome:
"""Result of a Turnstile siteverify call.
`ok`: the request **may proceed**. True both for "siteverify said
success" and for "no secret configured AND not required" (the
dev/test soft-fail path).
`reason`: one of
* 'ok' siteverify returned success.
* 'skipped' no secret configured, TURNSTILE_REQUIRED=false.
The gate is open; the endpoint admits the request.
* 'misconfigured' TURNSTILE_REQUIRED=true but no secret in env.
The endpoint fails closed with 500.
* 'missing-token' the client did not send a token at all and
verification is required.
* 'failed' siteverify returned success=false. The
endpoint refuses with 400.
* 'network' siteverify call raised. Treated as a failure
under TURNSTILE_REQUIRED=true.
"""
ok: bool
reason: str
def verify_token(token: str | None, *, client_ip: str | None = None) -> VerifyOutcome:
"""Validate a Turnstile token against CloudFlare's siteverify endpoint.
Returns a VerifyOutcome describing whether the calling endpoint
should proceed. The endpoint maps `ok=False` to an HTTP status per
the `reason`:
* 'misconfigured' 500 "auth misconfigured"
* 'missing-token' / 'failed' / 'network' 400 "verification failed"
Tests monkeypatch `httpx.post` (or set `TURNSTILE_SITEVERIFY_URL`
+ a MockTransport client) to avoid touching the real CloudFlare
endpoint. No real keys are ever embedded in tests.
"""
secret = _secret()
required = _required()
if not secret:
if required:
log.warning("Turnstile required but CLOUDFLARE_TURNSTILE_SECRET is unset; failing closed")
return VerifyOutcome(ok=False, reason="misconfigured")
# Dev/test/soft-fail path: no secret, not required → gate is open.
return VerifyOutcome(ok=True, reason="skipped")
if not token or not token.strip():
# Secret is set, so verification is in force. A missing token
# is a hard refuse — the frontend should have rendered the
# widget and collected one.
return VerifyOutcome(ok=False, reason="missing-token")
data = {"secret": secret, "response": token.strip()}
if client_ip:
data["remoteip"] = client_ip
try:
response = httpx.post(_siteverify_url(), data=data, timeout=10.0)
payload = response.json()
except Exception as exc: # network, JSON parse, etc.
log.warning("Turnstile siteverify call failed: %s", exc)
return VerifyOutcome(ok=False, reason="network")
if payload.get("success") is True:
return VerifyOutcome(ok=True, reason="ok")
# `error-codes` is a list of strings on failure; we log the codes
# for the operator without surfacing them to the client.
log.info("Turnstile siteverify rejected token: %s", payload.get("error-codes"))
return VerifyOutcome(ok=False, reason="failed")
+75
View File
@@ -0,0 +1,75 @@
-- §6.2 / v0.11.0: trust device for 30 days (roadmap item #9).
--
-- After a successful OTC or passcode sign-in, the user can check
-- "trust this device for 30 days." The framework then issues a
-- server-issued opaque device-trust token, stores its hash on this
-- table, and sets a long-lived HttpOnly + Secure + SameSite=Lax
-- cookie carrying the raw token. On a subsequent visit, the cookie is
-- presented at `/auth/device-trust/start`; if the server can match the
-- hash to a non-expired non-revoked row, the user is signed in without
-- another OTC / passcode round-trip.
--
-- v0.11.0 introduces no new env vars. The 30-day window is hard-coded
-- in `backend/app/device_trust.py`; raising or lowering it (or making
-- it user-selectable) is a §19.2 candidate, alongside the cross-device
-- session-revocation surface this table will eventually share with the
-- v0.10.0 passcode-lockout shape (see SPEC §19.2 / SESSIONS-AND-DEVICES).
--
-- Storage shape:
--
-- * `id` — surrogate key. Lets the revoke-device UI address a single
-- row by id without leaking the token shape.
-- * `user_id` — FK into users(id) with cascade on delete. A deleted
-- user automatically loses every trusted device.
-- * `device_token_hash` — bcrypt hash of the random opaque token
-- issued at trust-time. The raw token only ever lives in the
-- outbound `Set-Cookie` header and the inbound `Cookie` header;
-- server-side storage is the hash, so a DB compromise does not
-- hand attackers a stash of valid device tokens.
-- * `created_at` — when the row was issued.
-- * `expires_at` — `created_at + 30 days`. A row past this timestamp
-- is dead; the lookup path refuses it without further checks.
-- * `user_agent` — the User-Agent header captured at issuance.
-- Stored verbatim (truncated to 1024 chars at the application
-- layer) so the revoke-device UI can show a rough device label.
-- Not used for any auth decision — purely a hint to the user
-- reviewing their device list.
-- * `last_seen_at` — refreshed every time the row authenticates a
-- request. Lets the revoke-device UI surface "last used 3 days
-- ago" so the user can tell which row corresponds to which
-- device.
-- * `revoked_at` — NULL means active; non-NULL stamps when the user
-- (or admin) revoked the row. Lookups treat any non-NULL value
-- as "this row is dead" without consulting the expiry; the
-- revoke gesture is intentionally one-way (a revoked device must
-- re-trust to come back online).
--
-- Indexing: a unique index on `device_token_hash` so collisions are
-- detectable at insert time (the token space is 256 bits of CSPRNG
-- entropy, so a collision is structurally impossible, but the
-- declaration documents the invariant). A separate index on
-- `(user_id, revoked_at)` so the revoke-device UI's list query is
-- a covering walk.
--
-- The bcrypt dependency reused here was added in v0.7.0 for OTC and
-- extended in v0.10.0 for passcodes; v0.11.0 needs no new dep.
--
-- The cookie shape: `rfc_device_trust` carries the raw token,
-- HttpOnly, Secure, SameSite=Lax, Max-Age=2592000 (30 days). It is
-- "essential" per the v0.13.0 cookie-consent banner (it is part of
-- authentication, not analytics), so it is set regardless of the
-- user's analytics / other-cookies choices.
CREATE TABLE device_trust (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
device_token_hash TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT NOT NULL,
user_agent TEXT NOT NULL DEFAULT '',
last_seen_at TEXT NOT NULL DEFAULT (datetime('now')),
revoked_at TEXT
);
CREATE UNIQUE INDEX idx_device_trust_token_hash ON device_trust (device_token_hash);
CREATE INDEX idx_device_trust_user ON device_trust (user_id, revoked_at);
+494
View File
@@ -0,0 +1,494 @@
"""End-to-end integration tests for the v0.11.0 trust-device vertical
(§6.2, roadmap item #9).
After a successful OTC or passcode sign-in with `trust_device=true`
on the body, the server mints a fresh `device_trust` row and sets the
`rfc_device_trust` cookie. On a subsequent visit, the cookie carries
a session re-established by `POST /auth/device-trust/start`. The
tests below prove:
* `trust_device=false` (default, including omitted) on OTC verify
does NOT set the device-trust cookie and does NOT insert a row.
* `trust_device=true` on OTC verify DOES set the cookie (HttpOnly +
Secure + SameSite=Lax + 30-day Max-Age) and DOES insert a row.
The row's hash is NOT the raw token; only the hash lives in the
database.
* Same shape for passcode verify.
* On a returning visit with the cookie, `POST /auth/device-trust/start`
re-establishes the session `GET /api/auth/me` reads the right
user without an OTC roundtrip.
* `last_seen_at` refreshes on a successful lookup.
* `POST /auth/device-trust/start` with no cookie returns 401.
* `POST /auth/device-trust/start` with a forged / unknown cookie
returns 401 + clears the cookie.
* A revoked row refuses the cookie (401) and clears it.
* An expired row refuses the cookie (401) and clears it.
* `GET /api/auth/me/devices` lists the user's active rows.
* `DELETE /api/auth/me/devices/{id}` revokes a single row.
* `DELETE /api/auth/me/devices/{id}` for another user's row reads 404.
* `DELETE /api/auth/me/devices` revokes every active row.
* Constant-time path: bcrypt.checkpw guards lookup; the raw token
is never written to logs or to the DB.
The fakes from `test_propose_vertical` give us a working app harness.
The OTC envelope buffer from `test_otc_vertical` is reused for the
OTC roundtrips this suite needs.
"""
from __future__ import annotations
import pytest
from test_propose_vertical import ( # noqa: F401
FakeGitea,
app_with_fake_gitea,
provision_user_row,
tmp_env,
)
# ---------------------------------------------------------------------------
# Helpers — mirror the OTC suite's outbound-buffer helpers.
# ---------------------------------------------------------------------------
COOKIE_NAME = "rfc_device_trust"
# The device-trust cookie is set with Secure=True, which httpx (the
# TestClient's underlying transport) will only return on an https
# scheme. We use a `base_url="https://testserver"` so the cookie
# roundtrips faithfully — that mirrors how production deployments
# serve the framework (per the v0.11.0 upgrade-step requiring HTTPS).
HTTPS_BASE = "https://testserver"
def _reset_outbound():
from app import email as email_mod
email_mod.reset_sent_envelopes()
def _outbound_otc_codes(to_address: str | None = None) -> list[str]:
from app import email as email_mod
out = []
for env in email_mod.sent_envelopes():
if env.get("kind") != "otc":
continue
if to_address is not None and env["to"] != to_address:
continue
for line in env["body"].splitlines():
tok = line.strip()
if tok.isdigit() and len(tok) == 6:
out.append(tok)
break
return out
def _sign_in_via_otc(client, email: str, *, trust_device: bool = False) -> None:
r = client.post("/auth/otc/request", json={"email": email})
assert r.status_code == 200, r.text
code = _outbound_otc_codes(email)[-1]
body = {"email": email, "code": code, "trust_device": trust_device}
r = client.post("/auth/otc/verify", json=body)
assert r.status_code == 200, r.text
def _device_rows_for_email(email: str) -> list[dict]:
from app import db
rows = db.conn().execute(
"""
SELECT dt.*
FROM device_trust dt
JOIN users u ON u.id = dt.user_id
WHERE u.email = ? COLLATE NOCASE
ORDER BY dt.id
""",
(email,),
).fetchall()
return [dict(r) for r in rows]
# ---------------------------------------------------------------------------
# trust_device flag controls cookie issuance
# ---------------------------------------------------------------------------
def test_otc_verify_without_trust_device_does_not_issue_cookie(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app, base_url=HTTPS_BASE) as client:
_reset_outbound()
_sign_in_via_otc(client, "alice@example.com", trust_device=False)
# No cookie set on the response.
assert COOKIE_NAME not in {c.name for c in client.cookies.jar}
# No row inserted.
assert _device_rows_for_email("alice@example.com") == []
def test_otc_verify_with_trust_device_issues_cookie_and_row(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app, base_url=HTTPS_BASE) as client:
_reset_outbound()
r = client.post("/auth/otc/request", json={"email": "alice@example.com"})
assert r.status_code == 200, r.text
code = _outbound_otc_codes("alice@example.com")[-1]
r = client.post(
"/auth/otc/verify",
json={"email": "alice@example.com", "code": code, "trust_device": True},
headers={"User-Agent": "Mozilla/5.0 (TestBrowser)"},
)
assert r.status_code == 200, r.text
# Cookie present on the response.
set_cookie = r.headers.get("set-cookie", "")
assert COOKIE_NAME in set_cookie
# Cookie attribute set asserts the spec'd shape. Starlette emits
# the attribute names case-insensitively (`samesite=lax`,
# `httponly`); we normalize when asserting.
lower = set_cookie.lower()
assert "httponly" in lower
assert "secure" in lower
assert "samesite=lax" in lower
assert "max-age=" in lower
# Row inserted; hash is not the raw token.
rows = _device_rows_for_email("alice@example.com")
assert len(rows) == 1
row = rows[0]
assert row["revoked_at"] is None
assert row["user_agent"] == "Mozilla/5.0 (TestBrowser)"
cookie_token = client.cookies.get(COOKIE_NAME)
assert cookie_token
assert cookie_token != row["device_token_hash"]
# bcrypt hash shape (starts with $2)
assert row["device_token_hash"].startswith("$2")
def test_passcode_verify_with_trust_device_issues_cookie(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app, base_url=HTTPS_BASE) as client:
_reset_outbound()
_sign_in_via_otc(client, "alice@example.com")
# Set a passcode.
r = client.post("/auth/passcode/set", json={"passcode": "secret123"})
assert r.status_code == 200, r.text
# Sign out so the passcode verify path is the active sign-in.
client.cookies.clear()
# Passcode verify with trust_device=true issues a row.
r = client.post(
"/auth/passcode/verify",
json={"email": "alice@example.com", "passcode": "secret123", "trust_device": True},
headers={"User-Agent": "Test/Phone"},
)
assert r.status_code == 200, r.text
set_cookie = r.headers.get("set-cookie", "")
assert COOKIE_NAME in set_cookie
rows = _device_rows_for_email("alice@example.com")
assert len(rows) == 1
assert rows[0]["user_agent"] == "Test/Phone"
# ---------------------------------------------------------------------------
# /auth/device-trust/start
# ---------------------------------------------------------------------------
def test_device_trust_start_with_no_cookie_returns_401(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app, base_url=HTTPS_BASE) as client:
r = client.post("/auth/device-trust/start")
assert r.status_code == 401
def test_device_trust_start_with_valid_cookie_establishes_session(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app, base_url=HTTPS_BASE) as client:
_reset_outbound()
# Trust the device.
r = client.post("/auth/otc/request", json={"email": "alice@example.com"})
assert r.status_code == 200, r.text
code = _outbound_otc_codes("alice@example.com")[-1]
r = client.post(
"/auth/otc/verify",
json={"email": "alice@example.com", "code": code, "trust_device": True},
)
assert r.status_code == 200, r.text
trust_cookie = client.cookies.get(COOKIE_NAME)
assert trust_cookie
# Clear the session cookie so only the device-trust cookie is in
# play. We keep `rfc_device_trust` and drop `rfc_session`.
for cookie in list(client.cookies.jar):
if cookie.name != COOKIE_NAME:
client.cookies.jar.clear(cookie.domain, cookie.path, cookie.name)
# The session cookie is gone — /api/auth/me reads anonymous.
me = client.get("/api/auth/me").json()
assert me["authenticated"] is False
# Hit the trust-start endpoint; the cookie re-establishes the session.
r = client.post("/auth/device-trust/start")
assert r.status_code == 200, r.text
assert r.json()["user"]["email"] == "alice@example.com"
# /api/auth/me now reads authenticated.
me = client.get("/api/auth/me").json()
assert me["authenticated"] is True
assert me["user"]["email"] == "alice@example.com"
def test_device_trust_start_refreshes_last_seen_at(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app, base_url=HTTPS_BASE) as client:
_reset_outbound()
r = client.post("/auth/otc/request", json={"email": "alice@example.com"})
assert r.status_code == 200, r.text
code = _outbound_otc_codes("alice@example.com")[-1]
r = client.post(
"/auth/otc/verify",
json={"email": "alice@example.com", "code": code, "trust_device": True},
)
assert r.status_code == 200, r.text
# Force the existing row's last_seen_at into the past so we can
# assert the refresh moved it forward.
db.conn().execute(
"""
UPDATE device_trust
SET last_seen_at = datetime('now', '-7 days')
"""
)
# Hit the start endpoint.
r = client.post("/auth/device-trust/start")
assert r.status_code == 200, r.text
# last_seen_at is now recent (within the last minute).
row = db.conn().execute(
"SELECT last_seen_at, datetime('now') >= datetime(last_seen_at, '-1 minute') AS fresh FROM device_trust LIMIT 1"
).fetchone()
assert row["fresh"] == 1
def test_device_trust_start_with_revoked_row_refuses_and_clears(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app, base_url=HTTPS_BASE) as client:
_reset_outbound()
r = client.post("/auth/otc/request", json={"email": "alice@example.com"})
assert r.status_code == 200, r.text
code = _outbound_otc_codes("alice@example.com")[-1]
r = client.post(
"/auth/otc/verify",
json={"email": "alice@example.com", "code": code, "trust_device": True},
)
assert r.status_code == 200, r.text
# Revoke the row out-of-band.
db.conn().execute(
"UPDATE device_trust SET revoked_at = datetime('now')"
)
# Now the start endpoint refuses + clears the cookie.
r = client.post("/auth/device-trust/start")
assert r.status_code == 401
# The cookie is cleared via a Set-Cookie header with Max-Age=0
# (Starlette's `delete_cookie` shape).
set_cookie = r.headers.get("set-cookie", "")
assert COOKIE_NAME in set_cookie
assert "Max-Age=0" in set_cookie or 'expires=Thu, 01 Jan 1970' in set_cookie.lower().replace("expires=thu", "expires=Thu")
def test_device_trust_start_with_expired_row_refuses_and_clears(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app, base_url=HTTPS_BASE) as client:
_reset_outbound()
r = client.post("/auth/otc/request", json={"email": "alice@example.com"})
assert r.status_code == 200, r.text
code = _outbound_otc_codes("alice@example.com")[-1]
r = client.post(
"/auth/otc/verify",
json={"email": "alice@example.com", "code": code, "trust_device": True},
)
assert r.status_code == 200, r.text
# Backdate the expiry into the past.
db.conn().execute(
"UPDATE device_trust SET expires_at = datetime('now', '-1 day')"
)
r = client.post("/auth/device-trust/start")
assert r.status_code == 401
set_cookie = r.headers.get("set-cookie", "")
assert COOKIE_NAME in set_cookie
def test_device_trust_start_with_forged_cookie_refuses_and_clears(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app, base_url=HTTPS_BASE) as client:
# No real row; just paste a cookie value.
client.cookies.set(COOKIE_NAME, "definitely-not-a-real-token-value-xxx")
r = client.post("/auth/device-trust/start")
assert r.status_code == 401
set_cookie = r.headers.get("set-cookie", "")
assert COOKIE_NAME in set_cookie
# ---------------------------------------------------------------------------
# /api/auth/me/devices — list + revoke
# ---------------------------------------------------------------------------
def test_list_devices_requires_session(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app, base_url=HTTPS_BASE) as client:
r = client.get("/api/auth/me/devices")
assert r.status_code == 401
def test_list_devices_returns_active_rows_only(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app, base_url=HTTPS_BASE) as client:
_reset_outbound()
_sign_in_via_otc(client, "alice@example.com", trust_device=True)
# Add a second trusted device by re-running the verify flow.
# OTC has a per-email cooldown, so drop the cooldown rather
# than waiting it out.
db.conn().execute("UPDATE otc_codes SET consumed_at = datetime('now', '-1 hour'), created_at = datetime('now', '-1 hour')")
r = client.post("/auth/otc/request", json={"email": "alice@example.com"})
assert r.status_code == 200, r.text
code = _outbound_otc_codes("alice@example.com")[-1]
r = client.post(
"/auth/otc/verify",
json={"email": "alice@example.com", "code": code, "trust_device": True},
headers={"User-Agent": "Test/Tablet"},
)
assert r.status_code == 200, r.text
# Revoke one row directly.
db.conn().execute(
"UPDATE device_trust SET revoked_at = datetime('now') WHERE id = 1"
)
# /api/auth/me/devices returns only the un-revoked one.
r = client.get("/api/auth/me/devices")
assert r.status_code == 200, r.text
items = r.json()["items"]
assert len(items) == 1
assert items[0]["user_agent"] == "Test/Tablet"
def test_revoke_single_device_kills_the_row(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app, base_url=HTTPS_BASE) as client:
_reset_outbound()
_sign_in_via_otc(client, "alice@example.com", trust_device=True)
r = client.get("/api/auth/me/devices")
assert r.status_code == 200, r.text
items = r.json()["items"]
assert len(items) == 1
device_id = items[0]["id"]
# Revoke it.
r = client.delete(f"/api/auth/me/devices/{device_id}")
assert r.status_code == 200, r.text
# List is empty.
r = client.get("/api/auth/me/devices")
assert r.json()["items"] == []
# The row in the table has revoked_at populated.
row = db.conn().execute(
"SELECT revoked_at FROM device_trust WHERE id = ?", (device_id,)
).fetchone()
assert row["revoked_at"] is not None
def test_revoke_other_users_device_reads_404(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app, base_url=HTTPS_BASE) as client:
_reset_outbound()
# Alice trusts a device.
_sign_in_via_otc(client, "alice@example.com", trust_device=True)
alice_device_id = client.get("/api/auth/me/devices").json()["items"][0]["id"]
# Bob signs in (without a trusted device of his own).
client.cookies.clear()
db.conn().execute("UPDATE otc_codes SET consumed_at = datetime('now', '-1 hour'), created_at = datetime('now', '-1 hour')")
_sign_in_via_otc(client, "bob@example.com", trust_device=False)
# Bob tries to revoke Alice's row by id.
r = client.delete(f"/api/auth/me/devices/{alice_device_id}")
assert r.status_code == 404
# Alice's row is still active.
row = db.conn().execute(
"SELECT revoked_at FROM device_trust WHERE id = ?", (alice_device_id,)
).fetchone()
assert row["revoked_at"] is None
def test_revoke_all_devices_kills_every_active_row(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app, base_url=HTTPS_BASE) as client:
_reset_outbound()
_sign_in_via_otc(client, "alice@example.com", trust_device=True)
# Add a second device.
db.conn().execute("UPDATE otc_codes SET consumed_at = datetime('now', '-1 hour'), created_at = datetime('now', '-1 hour')")
r = client.post("/auth/otc/request", json={"email": "alice@example.com"})
assert r.status_code == 200, r.text
code = _outbound_otc_codes("alice@example.com")[-1]
r = client.post(
"/auth/otc/verify",
json={"email": "alice@example.com", "code": code, "trust_device": True},
)
assert r.status_code == 200, r.text
# Two active rows.
assert len(client.get("/api/auth/me/devices").json()["items"]) == 2
# Revoke all.
r = client.delete("/api/auth/me/devices")
assert r.status_code == 200, r.text
assert r.json()["revoked"] == 2
# List is empty.
assert client.get("/api/auth/me/devices").json()["items"] == []
+220
View File
@@ -0,0 +1,220 @@
"""End-to-end integration tests for the v0.12.0 CloudFlare Turnstile
gate on `/auth/otc/request` (§6.2 / roadmap item #10).
The release gates the OTC request endpoint behind a one-step
browser-side Turnstile challenge before the bcrypt hash + SMTP send.
The tests prove:
* Happy path: with the secret set, a valid token admits the request
and the OTC envelope lands.
* Failure path: with the secret set, a token siteverify rejects
refuses the request with 400 and produces no envelope.
* Missing-token: with the secret set, a request without a token
refuses with 400.
* Missing-secret-soft: with the secret unset AND
`TURNSTILE_REQUIRED=false` (the v0.12.0 default), the request
admits this is the dev / "operator hasn't wired it yet" path.
* Missing-secret-hard: with the secret unset AND
`TURNSTILE_REQUIRED=true`, the request refuses with 500
"auth misconfigured" the production fail-closed path once
the operator has flipped the policy.
The Turnstile siteverify call is mocked at the `httpx.post` boundary
inside `app.turnstile` so no real keys are needed and no real
CloudFlare call is made. The Gitea fakes from `test_propose_vertical`
remain in scope so the rest of the app boots cleanly.
"""
from __future__ import annotations
from types import SimpleNamespace
import pytest
from test_propose_vertical import ( # noqa: F401
FakeGitea,
app_with_fake_gitea,
tmp_env,
)
def _reset_outbound():
from app import email as email_mod
email_mod.reset_sent_envelopes()
def _outbound_otc_envelopes(to_address: str | None = None) -> list[dict]:
from app import email as email_mod
out = []
for env in email_mod.sent_envelopes():
if env.get("kind") != "otc":
continue
if to_address is not None and env["to"] != to_address:
continue
out.append(env)
return out
def _patch_siteverify(monkeypatch, *, success: bool, error_codes: list[str] | None = None):
"""Replace `httpx.post` inside `app.turnstile` with a stub that
returns the requested success shape. The stub does not touch the
real CloudFlare endpoint and never sees a real secret.
"""
captured = {}
def fake_post(url, *, data=None, timeout=None, **kwargs):
captured["url"] = url
captured["data"] = data
body = {"success": bool(success)}
if error_codes is not None:
body["error-codes"] = error_codes
return SimpleNamespace(json=lambda: body)
from app import turnstile as turnstile_mod
monkeypatch.setattr(turnstile_mod.httpx, "post", fake_post)
return captured
# ---------------------------------------------------------------------------
# Happy path: secret set, token valid → admit + OTC envelope lands
# ---------------------------------------------------------------------------
def test_otc_request_admits_when_turnstile_token_is_valid(app_with_fake_gitea, monkeypatch):
from fastapi.testclient import TestClient
monkeypatch.setenv("CLOUDFLARE_TURNSTILE_SECRET", "test-secret-not-real")
monkeypatch.setenv("TURNSTILE_REQUIRED", "true")
captured = _patch_siteverify(monkeypatch, success=True)
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
r = client.post(
"/auth/otc/request",
json={"email": "alice@example.com", "turnstile_token": "fake-token-abc"},
)
assert r.status_code == 200, r.text
# The siteverify call was made with the secret + the token we sent.
assert captured["data"]["secret"] == "test-secret-not-real"
assert captured["data"]["response"] == "fake-token-abc"
# And the OTC dispatch ran — exactly one envelope to the address.
envs = _outbound_otc_envelopes("alice@example.com")
assert len(envs) == 1
# ---------------------------------------------------------------------------
# Failure path: secret set, siteverify says success=false → 400 + no envelope
# ---------------------------------------------------------------------------
def test_otc_request_refuses_when_turnstile_siteverify_fails(app_with_fake_gitea, monkeypatch):
from fastapi.testclient import TestClient
monkeypatch.setenv("CLOUDFLARE_TURNSTILE_SECRET", "test-secret-not-real")
monkeypatch.setenv("TURNSTILE_REQUIRED", "true")
_patch_siteverify(monkeypatch, success=False, error_codes=["invalid-input-response"])
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
r = client.post(
"/auth/otc/request",
json={"email": "alice@example.com", "turnstile_token": "fake-bad-token"},
)
assert r.status_code == 400, r.text
# The OTC bcrypt + SMTP path did not run — no envelope was buffered.
assert _outbound_otc_envelopes("alice@example.com") == []
# ---------------------------------------------------------------------------
# Missing-token: secret set, no token → 400 + no envelope
# ---------------------------------------------------------------------------
def test_otc_request_refuses_when_turnstile_token_is_missing(app_with_fake_gitea, monkeypatch):
from fastapi.testclient import TestClient
monkeypatch.setenv("CLOUDFLARE_TURNSTILE_SECRET", "test-secret-not-real")
monkeypatch.setenv("TURNSTILE_REQUIRED", "true")
# Even though we patch httpx.post, the missing-token check fires
# before the siteverify call — so the patch is here only as a
# safety net in case the implementation regresses to making the
# network call anyway.
_patch_siteverify(monkeypatch, success=False)
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
r = client.post(
"/auth/otc/request",
json={"email": "alice@example.com"}, # no turnstile_token field at all
)
assert r.status_code == 400, r.text
assert _outbound_otc_envelopes("alice@example.com") == []
# ---------------------------------------------------------------------------
# Missing-secret-soft: no secret, TURNSTILE_REQUIRED=false (default) → admit
# ---------------------------------------------------------------------------
def test_otc_request_admits_when_secret_unset_and_not_required(app_with_fake_gitea, monkeypatch):
"""v0.12.0 default: the operator has not yet wired the Turnstile
secret and has not enabled `TURNSTILE_REQUIRED`. The gate stays
open this is the dev / test / pre-rollout path. Once the
operator confirms the secret is in place and flips
`TURNSTILE_REQUIRED=true`, missing-secret becomes fail-closed
(covered in test_otc_request_refuses_when_required_but_secret_unset).
"""
from fastapi.testclient import TestClient
monkeypatch.delenv("CLOUDFLARE_TURNSTILE_SECRET", raising=False)
monkeypatch.delenv("TURNSTILE_REQUIRED", raising=False)
# The httpx.post inside turnstile must not be called in this path —
# patch it to a sentinel that explodes if it ever runs.
from app import turnstile as turnstile_mod
def must_not_be_called(*a, **kw):
raise AssertionError("siteverify should not run when no secret is configured")
monkeypatch.setattr(turnstile_mod.httpx, "post", must_not_be_called)
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
r = client.post(
"/auth/otc/request",
json={"email": "alice@example.com"},
)
assert r.status_code == 200, r.text
# The OTC path ran end-to-end — one envelope to the address.
assert len(_outbound_otc_envelopes("alice@example.com")) == 1
# ---------------------------------------------------------------------------
# Missing-secret-hard: no secret, TURNSTILE_REQUIRED=true → 500 "misconfigured"
# ---------------------------------------------------------------------------
def test_otc_request_refuses_when_required_but_secret_unset(app_with_fake_gitea, monkeypatch):
"""Once the operator has flipped `TURNSTILE_REQUIRED=true` to lock
down production, a missing secret stops being a soft-fail and
becomes a fail-closed 500. This is the regression-detection shape
the §20.4 upgrade-steps MAY block calls out flip the flag once
the secret is wired so a future config drift fails loudly instead
of silently disabling abuse defense.
"""
from fastapi.testclient import TestClient
monkeypatch.delenv("CLOUDFLARE_TURNSTILE_SECRET", raising=False)
monkeypatch.setenv("TURNSTILE_REQUIRED", "true")
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
r = client.post(
"/auth/otc/request",
json={"email": "alice@example.com", "turnstile_token": "doesnt-matter"},
)
assert r.status_code == 500, r.text
assert _outbound_otc_envelopes("alice@example.com") == []
+36
View File
@@ -49,3 +49,39 @@ VITE_PRIVACY_POLICY_URL=
# Examples:
# VITE_COOKIES_POLICY_URL=https://wiggleverse.org/cookies
VITE_COOKIES_POLICY_URL=
# v0.12.0 / roadmap item #10: CloudFlare Turnstile site key (public).
# Provision a Turnstile site at dash.cloudflare.com → Turnstile → Add
# site. The site key (this var) is embedded into the frontend bundle at
# build time and rendered by the Turnstile widget on the /login email-
# entry step. The secret key (private) lives in the backend env as
# CLOUDFLARE_TURNSTILE_SECRET — see backend/.env.example. Leave unset
# in dev to skip the widget; the backend's TURNSTILE_REQUIRED policy
# decides what happens to a tokenless request.
#
# Examples:
# VITE_TURNSTILE_SITE_KEY=0x4AAAAAAA...
VITE_TURNSTILE_SITE_KEY=
# v0.15.0 / roadmap item #13: Amplitude project API key (public).
# Embedded in the frontend bundle at build time and used by the
# analytics wrapper (`frontend/src/lib/analytics.js`) — which loads
# `@amplitude/unified` (Analytics + Session Replay) when the user
# has granted analytics consent (v0.13.0 cookie banner). Provision
# an Amplitude project at app.amplitude.com → Projects → New, copy
# the API key.
#
# Public by design: Amplitude browser keys are bundle-embedded
# (visible in dev tools), same nature as VITE_TURNSTILE_SITE_KEY
# (also public; the truly-secret half of that Turnstile pair is
# CLOUDFLARE_TURNSTILE_SECRET on the backend). For deployments
# behind flotilla, bind via `flotilla overlay set <deployment>
# VITE_AMPLITUDE_API_KEY=<key>` — NOT `flotilla secret set`. The
# vendor's installation wizard shows the key inline as a literal
# string in the init call, confirming the public framing. Leave
# unset in dev; the wrapper logs one console warning and no-ops
# (the app continues to work).
#
# Examples:
# VITE_AMPLITUDE_API_KEY=01234567890abcdef01234567890abcd
VITE_AMPLITUDE_API_KEY=
+529 -10
View File
@@ -1,13 +1,14 @@
{
"name": "rfc-app-frontend",
"version": "0.9.0",
"version": "0.15.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "rfc-app-frontend",
"version": "0.9.0",
"version": "0.15.0",
"dependencies": {
"@amplitude/unified": "^1.1.9",
"@codemirror/commands": "^6.10.3",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/language": "^6.12.3",
@@ -30,6 +31,360 @@
"vite": "^8.0.12"
}
},
"node_modules/@amplitude/analytics-browser": {
"version": "2.42.4",
"resolved": "https://registry.npmjs.org/@amplitude/analytics-browser/-/analytics-browser-2.42.4.tgz",
"integrity": "sha512-q1XUlaKQkLq2CFx8xsVEc+uekOwHlnDYyaMBzlQDf2vcEaPaQDb7LzJ7z4CFs4Jn9FyBGDNo4w3IYjv9L6xjGA==",
"license": "MIT",
"dependencies": {
"@amplitude/analytics-core": "2.48.2",
"@amplitude/plugin-autocapture-browser": "1.27.2",
"@amplitude/plugin-custom-enrichment-browser": "0.1.9",
"@amplitude/plugin-event-property-attribution-browser": "0.2.1",
"@amplitude/plugin-network-capture-browser": "1.10.1",
"@amplitude/plugin-page-url-enrichment-browser": "0.7.11",
"@amplitude/plugin-page-view-tracking-browser": "2.11.1",
"@amplitude/plugin-web-vitals-browser": "1.1.33",
"tslib": "^2.4.1"
}
},
"node_modules/@amplitude/analytics-client-common": {
"version": "2.4.48",
"resolved": "https://registry.npmjs.org/@amplitude/analytics-client-common/-/analytics-client-common-2.4.48.tgz",
"integrity": "sha512-jdRvu8ux3aIf74FvTDZuSFR1mutzdrIg1ebXYqpKizs9upXz1AJnHClkldSw9i4yu924AJ2wudxq6dccHWlNiA==",
"license": "MIT",
"dependencies": {
"@amplitude/analytics-connector": "^1.4.8",
"@amplitude/analytics-core": "2.48.2",
"@amplitude/analytics-types": "2.11.1",
"tslib": "^2.4.1"
}
},
"node_modules/@amplitude/analytics-connector": {
"version": "1.6.4",
"resolved": "https://registry.npmjs.org/@amplitude/analytics-connector/-/analytics-connector-1.6.4.tgz",
"integrity": "sha512-SpIv0IQMNIq6SH3UqFGiaZyGSc7PBZwRdq7lvP0pBxW8i4Ny+8zwI0pV+VMfMHQwWY3wdIbWw5WQphNjpdq1/Q==",
"license": "MIT"
},
"node_modules/@amplitude/analytics-core": {
"version": "2.48.2",
"resolved": "https://registry.npmjs.org/@amplitude/analytics-core/-/analytics-core-2.48.2.tgz",
"integrity": "sha512-r9O+hsTnTsDa1p6QdyC0KbBPXupzoWz9053RQB9XQz8078LM+5KCMbCKYOrSYniH4DH/OM2kOUEdJlwdxIl/IA==",
"license": "MIT",
"dependencies": {
"@amplitude/analytics-connector": "^1.6.4",
"@types/zen-observable": "0.8.3",
"safe-json-stringify": "1.2.0",
"tslib": "^2.4.1",
"zen-observable": "0.10.0"
}
},
"node_modules/@amplitude/analytics-types": {
"version": "2.11.1",
"resolved": "https://registry.npmjs.org/@amplitude/analytics-types/-/analytics-types-2.11.1.tgz",
"integrity": "sha512-wFEgb0t99ly2uJKm5oZ28Lti0Kh5RecR5XBkwfUpDzn84IoCIZ8GJTsMw/nThu8FZFc7xFDA4UAt76zhZKrs9A==",
"license": "MIT"
},
"node_modules/@amplitude/engagement-browser": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@amplitude/engagement-browser/-/engagement-browser-1.0.9.tgz",
"integrity": "sha512-zvPr0L5aLlOS3nG8scIkEEDMVK2y3MaMbgjYhMfYruhMpfsC/U0apov22nEc1RRrTwve2awEXruPRKf1TysqrQ==",
"license": "MIT",
"dependencies": {
"@amplitude/analytics-types": "^2.0.0"
}
},
"node_modules/@amplitude/experiment-core": {
"version": "0.13.1",
"resolved": "https://registry.npmjs.org/@amplitude/experiment-core/-/experiment-core-0.13.1.tgz",
"integrity": "sha512-ZHvR0dxTltasp8MiMcQ6qKsY20mWnODoy3oebGad6qaRR1ywpUi8IuLf5AwLTM35ZwgzEUTn9TEIWKLHpDwHMw==",
"license": "MIT",
"dependencies": {
"js-base64": "^3.7.5"
}
},
"node_modules/@amplitude/experiment-js-client": {
"version": "1.21.1",
"resolved": "https://registry.npmjs.org/@amplitude/experiment-js-client/-/experiment-js-client-1.21.1.tgz",
"integrity": "sha512-chE/4qQG/5Cgl93Wqj1NEdgOL5LkqySLlfk1EN0f+7bJa52HpkGFALA2FeCNYf31Z5CglEeKX6dUMgL7y33SIw==",
"license": "MIT",
"dependencies": {
"@amplitude/analytics-connector": "^1.6.4",
"@amplitude/experiment-core": "^0.13.1",
"@amplitude/ua-parser-js": "^0.7.31",
"base64-js": "1.5.1",
"unfetch": "4.1.0"
}
},
"node_modules/@amplitude/plugin-autocapture-browser": {
"version": "1.27.2",
"resolved": "https://registry.npmjs.org/@amplitude/plugin-autocapture-browser/-/plugin-autocapture-browser-1.27.2.tgz",
"integrity": "sha512-UTA/0IDw/f2nnK+S1XILqoI5pgUgMTEZokDS6+pC4wuYtmOS9uNAgKuyajzjW12uobybMHRpv7xLjCJ5khKGAg==",
"license": "MIT",
"dependencies": {
"@amplitude/analytics-core": "2.48.2",
"tslib": "^2.4.1"
}
},
"node_modules/@amplitude/plugin-custom-enrichment-browser": {
"version": "0.1.9",
"resolved": "https://registry.npmjs.org/@amplitude/plugin-custom-enrichment-browser/-/plugin-custom-enrichment-browser-0.1.9.tgz",
"integrity": "sha512-wemh2Tw3zgQ7sa7MUNyMGz9OR6VjTG4tlAMrLlDKbQ4tVkgNI3oAwOF7+0BA8qzgeMXX6iw+CEKaE+EC/okkuQ==",
"license": "MIT",
"dependencies": {
"@amplitude/analytics-core": "2.48.2",
"tslib": "^2.4.1"
}
},
"node_modules/@amplitude/plugin-event-property-attribution-browser": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/@amplitude/plugin-event-property-attribution-browser/-/plugin-event-property-attribution-browser-0.2.1.tgz",
"integrity": "sha512-xqBCZe0DYsKyQ1eELN2LM8adXwRE2eOi3SnvSu9SkS0GDXBYWinuPCuLqyc/3uD5hY2FLACWvakpU0tr7GDJgg==",
"license": "MIT",
"dependencies": {
"@amplitude/analytics-core": "2.48.2",
"tslib": "^2.4.1"
}
},
"node_modules/@amplitude/plugin-experiment-browser": {
"version": "1.0.0-beta.28",
"resolved": "https://registry.npmjs.org/@amplitude/plugin-experiment-browser/-/plugin-experiment-browser-1.0.0-beta.28.tgz",
"integrity": "sha512-NQz267zLi7vl2G2lx10yUrEoGOCe5K9iqcPSIjbTavGu/XGvsmqLDqBHhg+EkdEMAPwypoXnmtPEs3RMhX+1MA==",
"license": "MIT",
"dependencies": {
"@amplitude/analytics-core": "2.48.2",
"@amplitude/experiment-js-client": "^1.15.5"
}
},
"node_modules/@amplitude/plugin-network-capture-browser": {
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/@amplitude/plugin-network-capture-browser/-/plugin-network-capture-browser-1.10.1.tgz",
"integrity": "sha512-jROIAkUDPd25A/t8W5MpmsTiBat2qoJbCMoNBKKxLMNEaE8VYbheflByWLkm4enbHgWS7OveWy0i3Oc7uPCfAg==",
"license": "MIT",
"dependencies": {
"@amplitude/analytics-core": "2.48.2",
"tslib": "^2.4.1"
}
},
"node_modules/@amplitude/plugin-page-url-enrichment-browser": {
"version": "0.7.11",
"resolved": "https://registry.npmjs.org/@amplitude/plugin-page-url-enrichment-browser/-/plugin-page-url-enrichment-browser-0.7.11.tgz",
"integrity": "sha512-u9JhUP/VenJifCSbdTz2YZZiXAphs3efzd+qx1SRAIU6d1swPh0g/GVw3sTwvH+4MZtw3SwVC1OFxmz+f2QVyA==",
"license": "MIT",
"dependencies": {
"@amplitude/analytics-core": "2.48.2",
"tslib": "^2.4.1"
}
},
"node_modules/@amplitude/plugin-page-view-tracking-browser": {
"version": "2.11.1",
"resolved": "https://registry.npmjs.org/@amplitude/plugin-page-view-tracking-browser/-/plugin-page-view-tracking-browser-2.11.1.tgz",
"integrity": "sha512-tfXg6Uir6X1XuWsOOXE/EgZ9NvM7i2ktDdagydSrFN6OyVkMvqdjPKUZSSUPuHtOoomboi3WaZsTUfq1jkWP3w==",
"license": "MIT",
"dependencies": {
"@amplitude/analytics-core": "2.48.2",
"tslib": "^2.4.1"
}
},
"node_modules/@amplitude/plugin-session-replay-browser": {
"version": "1.31.0",
"resolved": "https://registry.npmjs.org/@amplitude/plugin-session-replay-browser/-/plugin-session-replay-browser-1.31.0.tgz",
"integrity": "sha512-b7kyYVEdW3EMR6cPXCfld+h8nQsuAR5o6vum8Glu+ofhFDfG4wj/mTJ0ITEaNbsJCfXniKQ3kFgTe6hTtxSFGQ==",
"license": "MIT",
"dependencies": {
"@amplitude/analytics-client-common": "2.4.48",
"@amplitude/analytics-core": "2.48.2",
"@amplitude/analytics-types": "2.11.1",
"@amplitude/rrweb-plugin-console-record": "2.0.0-alpha.40",
"@amplitude/rrweb-record": "2.0.0-alpha.40",
"@amplitude/session-replay-browser": "1.44.0",
"idb-keyval": "^6.2.1",
"tslib": "^2.4.1"
}
},
"node_modules/@amplitude/plugin-web-vitals-browser": {
"version": "1.1.33",
"resolved": "https://registry.npmjs.org/@amplitude/plugin-web-vitals-browser/-/plugin-web-vitals-browser-1.1.33.tgz",
"integrity": "sha512-33FzxMH1Lr2lhvr5DDy3xD1HHWEI4KPLQsMUXqDTldkLl/ENNeBWcsljQTTDJipmRdS32I79KJhuHRNaoXd6fg==",
"license": "MIT",
"dependencies": {
"@amplitude/analytics-core": "2.48.2",
"tslib": "^2.4.1",
"web-vitals": "5.1.0"
}
},
"node_modules/@amplitude/rrdom": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@amplitude/rrdom/-/rrdom-2.1.0.tgz",
"integrity": "sha512-2dAtxXL02usBV2CSOnScLd3WoVqWaeiGpxN8LuXJ0r/NpLJkW1k876v2tRKAz5NrxPwSdjihsMmwCIXHpJhHfA==",
"license": "MIT",
"dependencies": {
"@amplitude/rrweb-snapshot": "^2.1.0"
}
},
"node_modules/@amplitude/rrweb": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@amplitude/rrweb/-/rrweb-2.1.1.tgz",
"integrity": "sha512-6uA+5VE/VHumaXPXTTLGRogd/K9MDwd01jGteppeLzsX0PvqlDyY5aIi35yh9+q1iS6ciPBn/2NRg0lg4cFIlw==",
"license": "MIT",
"dependencies": {
"@amplitude/rrdom": "^2.1.0",
"@amplitude/rrweb-snapshot": "^2.1.0",
"@amplitude/rrweb-types": "^2.1.0",
"@amplitude/rrweb-utils": "^2.1.0",
"@types/css-font-loading-module": "0.0.7",
"@xstate/fsm": "^1.4.0",
"base64-arraybuffer": "^1.0.1",
"mitt": "^3.0.0"
}
},
"node_modules/@amplitude/rrweb-packer": {
"version": "2.0.0-alpha.40",
"resolved": "https://registry.npmjs.org/@amplitude/rrweb-packer/-/rrweb-packer-2.0.0-alpha.40.tgz",
"integrity": "sha512-Btb6b9pS1IvDMbvyYxpUdTk9NRJugSoJjRCl7R6jP/iSlPWXoveJIwHaNFAS9ZmWUEK7HhyBJ8bKGFN3giUsDg==",
"license": "MIT",
"dependencies": {
"@amplitude/rrweb-types": "^2.0.0-alpha.40",
"fflate": "^0.4.4"
}
},
"node_modules/@amplitude/rrweb-plugin-console-record": {
"version": "2.0.0-alpha.40",
"resolved": "https://registry.npmjs.org/@amplitude/rrweb-plugin-console-record/-/rrweb-plugin-console-record-2.0.0-alpha.40.tgz",
"integrity": "sha512-vtY7T/kGFl62nC1u7ZUXQvU7ulB70cZGVHPRN/SO9fzVfsY7y6rCmBfoc2jS5KmISdlgkVzMjY2r/EE2Gk9AQA==",
"license": "MIT",
"peerDependencies": {
"@amplitude/rrweb": "^2.0.0-alpha.40"
}
},
"node_modules/@amplitude/rrweb-record": {
"version": "2.0.0-alpha.40",
"resolved": "https://registry.npmjs.org/@amplitude/rrweb-record/-/rrweb-record-2.0.0-alpha.40.tgz",
"integrity": "sha512-5cJhQwzhymJWX5/XOtpWK0h2NLq9+t2YiO6ub0cdZ9F5AZizaRbsVH88int07DfX0YiXTKWbISezVuduCLqgSQ==",
"license": "MIT",
"dependencies": {
"@amplitude/rrweb": "^2.0.0-alpha.40",
"@amplitude/rrweb-types": "^2.0.0-alpha.40"
}
},
"node_modules/@amplitude/rrweb-snapshot": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@amplitude/rrweb-snapshot/-/rrweb-snapshot-2.1.0.tgz",
"integrity": "sha512-xYQvOW73ig+5M7caqilA8j0S6MHWUULLeJNK+2VVvUqv8mr4FMT2DUAQiVBGCImNlb9Gu2rLUfCScMnVxn+EDg==",
"license": "MIT",
"dependencies": {
"postcss": "^8.4.38"
}
},
"node_modules/@amplitude/rrweb-types": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@amplitude/rrweb-types/-/rrweb-types-2.1.0.tgz",
"integrity": "sha512-S73tBI/04A6HCHgnrUNeeVOvnDTEoQnNrmZGyrZncJwRlTIX+6BQSYtBFofMag8GnAy9gA+NtC0TL0CnluOWBw==",
"license": "MIT"
},
"node_modules/@amplitude/rrweb-utils": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@amplitude/rrweb-utils/-/rrweb-utils-2.1.0.tgz",
"integrity": "sha512-dTCDnSiMMHZ10utYHJ8dSd/xkjFgdF67y74PkOzAPcCKW1rLxyJYcFOA3uPL2b7cIVVmoel/5NTp5eflaUaJfQ==",
"license": "MIT"
},
"node_modules/@amplitude/session-replay-browser": {
"version": "1.44.0",
"resolved": "https://registry.npmjs.org/@amplitude/session-replay-browser/-/session-replay-browser-1.44.0.tgz",
"integrity": "sha512-8Ruep2TTDMcfVMKurSpBbVclBK/v8Lb3aSHFsYd/xOQ1E3CaKoAu39pplli28NWoUcW7unyVE7khkOa2zzn0Lw==",
"license": "MIT",
"dependencies": {
"@amplitude/analytics-client-common": "2.4.48",
"@amplitude/analytics-core": "2.48.2",
"@amplitude/analytics-types": "2.11.1",
"@amplitude/experiment-core": "0.7.2",
"@amplitude/rrweb-packer": "2.0.0-alpha.40",
"@amplitude/rrweb-plugin-console-record": "2.0.0-alpha.40",
"@amplitude/rrweb-record": "2.0.0-alpha.40",
"@amplitude/rrweb-types": "2.0.0-alpha.40",
"@amplitude/rrweb-utils": "2.0.0-alpha.40",
"@amplitude/targeting": "0.2.0",
"@rollup/plugin-replace": "^6.0.1",
"idb": "8.0.0",
"tslib": "^2.4.1"
}
},
"node_modules/@amplitude/session-replay-browser/node_modules/@amplitude/experiment-core": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/@amplitude/experiment-core/-/experiment-core-0.7.2.tgz",
"integrity": "sha512-Wc2NWvgQ+bLJLeF0A9wBSPIaw0XuqqgkPKsoNFQrmS7r5Djd56um75In05tqmVntPJZRvGKU46pAp8o5tdf4mA==",
"license": "MIT",
"dependencies": {
"js-base64": "^3.7.5"
}
},
"node_modules/@amplitude/session-replay-browser/node_modules/@amplitude/rrweb-types": {
"version": "2.0.0-alpha.40",
"resolved": "https://registry.npmjs.org/@amplitude/rrweb-types/-/rrweb-types-2.0.0-alpha.40.tgz",
"integrity": "sha512-rP7CBDkzXupxOA7ukvC+zDYLuCtsz54TuJKC4+5O72Jsz4YdokLznKZRG34P6zXozfhGU0261qckk87lLY6mKQ==",
"license": "MIT"
},
"node_modules/@amplitude/session-replay-browser/node_modules/@amplitude/rrweb-utils": {
"version": "2.0.0-alpha.40",
"resolved": "https://registry.npmjs.org/@amplitude/rrweb-utils/-/rrweb-utils-2.0.0-alpha.40.tgz",
"integrity": "sha512-i1CCt6MCjlqoeNc+1Hse5bz+ZbASaWaIJ0WdJZvnQjUCHH29Xy/QFouyOuor73RZ+UWX4s2tYSrUIdmBepXk3w==",
"license": "MIT"
},
"node_modules/@amplitude/targeting": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@amplitude/targeting/-/targeting-0.2.0.tgz",
"integrity": "sha512-/50ywTrC4hfcfJVBbh5DFbqMPPfaIOivZeb5Gb+OGM03QrA+lsUqdvtnKLNuWtceD4H6QQ2KFzPJ5aAJLyzVDA==",
"license": "MIT",
"dependencies": {
"@amplitude/analytics-client-common": ">=1 <3",
"@amplitude/analytics-core": ">=1 <3",
"@amplitude/analytics-types": ">=1 <3",
"@amplitude/experiment-core": "0.7.2",
"idb": "^8.0.0",
"tslib": "^2.4.1"
}
},
"node_modules/@amplitude/targeting/node_modules/@amplitude/experiment-core": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/@amplitude/experiment-core/-/experiment-core-0.7.2.tgz",
"integrity": "sha512-Wc2NWvgQ+bLJLeF0A9wBSPIaw0XuqqgkPKsoNFQrmS7r5Djd56um75In05tqmVntPJZRvGKU46pAp8o5tdf4mA==",
"license": "MIT",
"dependencies": {
"js-base64": "^3.7.5"
}
},
"node_modules/@amplitude/ua-parser-js": {
"version": "0.7.33",
"resolved": "https://registry.npmjs.org/@amplitude/ua-parser-js/-/ua-parser-js-0.7.33.tgz",
"integrity": "sha512-wKEtVR4vXuPT9cVEIJkYWnlF++Gx3BdLatPBM+SZ1ztVIvnhdGBZR/mn9x/PzyrMcRlZmyi6L56I2J3doVBnjA==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/ua-parser-js"
},
{
"type": "paypal",
"url": "https://paypal.me/faisalman"
}
],
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/@amplitude/unified": {
"version": "1.1.9",
"resolved": "https://registry.npmjs.org/@amplitude/unified/-/unified-1.1.9.tgz",
"integrity": "sha512-YPgQbp/vDQ92GshHs2hfUxoeRnR3rRBWCoQ6wXgFjXQ1uiJf2tP0CBZWdrCStSDuhcpo2rsCz/Ek2LGq5J6SIQ==",
"license": "MIT",
"dependencies": {
"@amplitude/analytics-browser": "2.42.4",
"@amplitude/analytics-core": "2.48.2",
"@amplitude/engagement-browser": "^1.0.3",
"@amplitude/plugin-experiment-browser": "1.0.0-beta.28",
"@amplitude/plugin-session-replay-browser": "1.31.0"
}
},
"node_modules/@antfu/install-pkg": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz",
@@ -264,6 +619,12 @@
"import-meta-resolve": "^4.2.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"license": "MIT"
},
"node_modules/@lezer/common": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
@@ -657,6 +1018,49 @@
"dev": true,
"license": "MIT"
},
"node_modules/@rollup/plugin-replace": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-6.0.3.tgz",
"integrity": "sha512-J4RZarRvQAm5IF0/LwUUg+obsm+xZhYnbMXmXROyoSE1ATJe3oXSb9L5MMppdxP2ylNSjv6zFBwKYjcKMucVfA==",
"license": "MIT",
"dependencies": {
"@rollup/pluginutils": "^5.0.1",
"magic-string": "^0.30.3"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
},
"peerDependenciesMeta": {
"rollup": {
"optional": true
}
}
},
"node_modules/@rollup/pluginutils": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz",
"integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==",
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0",
"estree-walker": "^2.0.2",
"picomatch": "^4.0.2"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
},
"peerDependenciesMeta": {
"rollup": {
"optional": true
}
}
},
"node_modules/@tiptap/core": {
"version": "3.23.6",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.23.6.tgz",
@@ -1109,6 +1513,12 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/css-font-loading-module": {
"version": "0.0.7",
"resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.7.tgz",
"integrity": "sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==",
"license": "MIT"
},
"node_modules/@types/d3": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
@@ -1362,6 +1772,12 @@
"@types/d3-selection": "*"
}
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
"license": "MIT"
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
@@ -1399,6 +1815,12 @@
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@types/zen-observable": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/@types/zen-observable/-/zen-observable-0.8.3.tgz",
"integrity": "sha512-fbF6oTd4sGGy0xjHPKAt+eS2CrxJ3+6gQ3FGcBoIJR2TLAyCkCyI8JqZNy+FeON0AhVgNJoUumVoZQjBFUqHkw==",
"license": "MIT"
},
"node_modules/@upsetjs/venn.js": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@upsetjs/venn.js/-/venn.js-2.0.0.tgz",
@@ -1435,6 +1857,41 @@
}
}
},
"node_modules/@xstate/fsm": {
"version": "1.6.5",
"resolved": "https://registry.npmjs.org/@xstate/fsm/-/fsm-1.6.5.tgz",
"integrity": "sha512-b5o1I6aLNeYlU/3CPlj/Z91ybk1gUsKT+5NAJI+2W4UjvS5KLG28K9v5UvNoFVjHV8PajVZ00RH3vnjyQO7ZAw==",
"license": "MIT"
},
"node_modules/base64-arraybuffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/commander": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
@@ -2021,6 +2478,12 @@
"benchmarks"
]
},
"node_modules/estree-walker": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"license": "MIT"
},
"node_modules/fast-equals": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
@@ -2048,6 +2511,12 @@
}
}
},
"node_modules/fflate": {
"version": "0.4.8",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.8.tgz",
"integrity": "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==",
"license": "MIT"
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -2081,6 +2550,18 @@
"node": ">=0.10.0"
}
},
"node_modules/idb": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/idb/-/idb-8.0.0.tgz",
"integrity": "sha512-l//qvlAKGmQO31Qn7xdzagVPPaHTxXx199MhrAFuVBTPqydcPYBWjkrbv4Y0ktB+GmWOiwHl237UUOrLmQxLvw==",
"license": "ISC"
},
"node_modules/idb-keyval": {
"version": "6.2.4",
"resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.4.tgz",
"integrity": "sha512-D/NzHWUmYJGXi++z67aMSrnisb9A3621CyRK5G89JyTlN13C8xf0g04DLxUKMufPem3e3L2JAXR6Z00OWy183Q==",
"license": "Apache-2.0"
},
"node_modules/import-meta-resolve": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz",
@@ -2100,6 +2581,12 @@
"node": ">=12"
}
},
"node_modules/js-base64": {
"version": "3.7.8",
"resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz",
"integrity": "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==",
"license": "BSD-3-Clause"
},
"node_modules/katex": {
"version": "0.16.47",
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz",
@@ -2421,6 +2908,15 @@
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
"license": "MIT"
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/marked": {
"version": "18.0.4",
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.4.tgz",
@@ -2474,11 +2970,16 @@
"node": ">= 20"
}
},
"node_modules/mitt": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
"dev": true,
"funding": [
{
"type": "github",
@@ -2515,14 +3016,12 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
@@ -2551,7 +3050,6 @@
"version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -2828,6 +3326,12 @@
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
"license": "BSD-3-Clause"
},
"node_modules/safe-json-stringify": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/safe-json-stringify/-/safe-json-stringify-1.2.0.tgz",
"integrity": "sha512-gH8eh2nZudPQO6TytOvbxnuhYBOvDBBLW52tz5q6X58lJcd/tkmqFR+5Z9adS8aJtURSXWThWy/xJtJwixErvg==",
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
@@ -2850,7 +3354,6 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
@@ -2907,9 +3410,13 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD",
"optional": true
"license": "0BSD"
},
"node_modules/unfetch": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/unfetch/-/unfetch-4.1.0.tgz",
"integrity": "sha512-crP/n3eAPUJxZXM9T80/yv0YhkTEx2K1D3h7D1AJM6fzsWZrxdyRuLN0JH/dkZh1LNH8LxCnBzoPFCPbb2iGpg==",
"license": "MIT"
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
@@ -3016,6 +3523,18 @@
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/web-vitals": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.1.0.tgz",
"integrity": "sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==",
"license": "Apache-2.0"
},
"node_modules/zen-observable": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/zen-observable/-/zen-observable-0.10.0.tgz",
"integrity": "sha512-iI3lT0iojZhKwT5DaFy2Ce42n3yFcLdFyOh01G7H0flMY60P8MJuVFEoJoNwXlmAyQ45GrjL6AcZmmlv8A5rbw==",
"license": "MIT"
}
}
}
+2 -1
View File
@@ -1,7 +1,7 @@
{
"name": "rfc-app-frontend",
"private": true,
"version": "0.9.0",
"version": "0.15.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -9,6 +9,7 @@
"preview": "vite preview"
},
"dependencies": {
"@amplitude/unified": "^1.1.9",
"@codemirror/commands": "^6.10.3",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/language": "^6.12.3",
+59
View File
@@ -455,6 +455,65 @@
.otc-fallback a:hover { color: #1a1a1a; text-decoration: underline; }
.otc-fallback-sep { color: #ccc; }
/* v0.11.0 "trust this device for 30 days" checkbox on the verify
step. Sits above the action row, padded so it doesn't crowd the
passcode/code input. */
.otc-trust-device {
display: flex; align-items: center; gap: 8px;
font-size: 13px; color: #444;
margin: 8px 0 4px;
cursor: pointer;
user-select: none;
}
.otc-trust-device input[type="checkbox"] {
width: auto; margin: 0; cursor: pointer;
}
/* v0.11.0 — /settings/devices revoke-device UI. */
.device-list {
list-style: none; padding: 0; margin: 12px 0 0;
}
.device-list-item {
display: flex; align-items: center; justify-content: space-between;
gap: 12px;
border: 1px solid #eee; border-radius: 6px;
padding: 10px 12px; margin: 0 0 8px;
background: #fafafa;
}
.device-list-item .device-meta {
flex: 1; min-width: 0;
}
.device-list-item .device-ua {
font-size: 13px; color: #1a1a1a;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.device-list-item .device-stamps {
font-size: 12px; color: #777;
margin-top: 2px;
}
.device-list-item button {
font-size: 12px; padding: 4px 10px;
border: 1px solid #ccc; border-radius: 4px;
background: white; cursor: pointer;
}
.device-list-item button:hover:not(:disabled) {
background: #f5f5f5;
}
.device-revoke-all {
margin-top: 8px;
font-size: 13px; padding: 6px 12px;
border: 1px solid #cb6a6a; border-radius: 4px;
background: white; color: #cb6a6a; cursor: pointer;
}
.device-revoke-all:hover:not(:disabled) {
background: #fff5f5;
}
.device-empty {
font-size: 13px; color: #777;
background: #fafafa; border: 1px solid #eee; border-radius: 6px;
padding: 12px;
}
/* --- Beta-pending page (post-OAuth-rejection) --- */
.beta-pending {
+47 -3
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import { Routes, Route, Link, useNavigate } from 'react-router-dom'
import { useEffect, useRef, useState } from 'react'
import { Routes, Route, Link, useLocation, useNavigate } from 'react-router-dom'
import { getMe, subscribeToNotifications } from './api'
import { anonymize, EVENTS, identify, track } from './lib/analytics'
import Catalog from './components/Catalog.jsx'
import Inbox from './components/Inbox.jsx'
import RFCView from './components/RFCView.jsx'
@@ -34,6 +35,36 @@ export default function App() {
// event that bumps this.
const [consentReopenTick, setConsentReopenTick] = useState(0)
const navigate = useNavigate()
const location = useLocation()
// v0.15.0 Page Viewed event taxonomy. We fire on every
// route change; the analytics wrapper itself decides whether
// anything ships out (consent + key check). The first fire is
// also covered because `location` is set on mount.
const lastPathRef = useRef(null)
useEffect(() => {
const path = location.pathname + (location.search || '')
if (lastPathRef.current === path) return
lastPathRef.current = path
track(EVENTS.PAGE_VIEWED, { path: location.pathname })
}, [location.pathname, location.search])
// v0.15.0 bind the authenticated user id to the analytics
// session when sign-in lands; reset on sign-out (viewer flips to
// null). The wrapper queues these calls until consent + init
// resolve, so the order is safe even on a cold load.
const lastUserIdRef = useRef(null)
useEffect(() => {
const uid = me?.authenticated ? me.user?.id : null
if (uid != null && lastUserIdRef.current !== uid) {
lastUserIdRef.current = uid
identify({ user_id: String(uid) })
} else if (uid == null && lastUserIdRef.current != null) {
// Sign-out edge App-level reset is handled separately by the
// sign-out gesture that fires User Signed Out. Clear our local
// memo so a fresh sign-in re-fires identify.
lastUserIdRef.current = null
}
}, [me?.authenticated, me?.user?.id])
useEffect(() => {
const handler = () => setConsentReopenTick(t => t + 1)
@@ -141,7 +172,20 @@ export default function App() {
<>
<span className="user-name">{viewer.display_name}</span>
<span className={`user-role-badge role-${viewer.role}`}>{viewer.role}</span>
<a className="btn-link" href="/auth/logout">Sign out</a>
<a
className="btn-link"
href="/auth/logout"
onClick={() => {
// v0.15.0 fire the sign-out event before the
// hard nav. The wrapper's track() is sync-enqueue;
// the underlying SDK flush is best-effort across
// navigation. anonymize() clears the user binding
// so any post-nav anonymous events on the next
// page aren't attributed to the prior user.
track(EVENTS.USER_SIGNED_OUT)
anonymize()
}}
>Sign out</a>
</>
) : (
<Link className="btn-signin-header" to="/login" title="Private beta — only invited emails can sign in">
+48 -6
View File
@@ -31,20 +31,33 @@ export async function getMe() {
// migration — the new UI just no longer points at it primarily. These
// two helpers drive the Login.jsx surface.
export async function requestOtc(email) {
export async function requestOtc(email, { turnstileToken } = {}) {
// v0.12.0 / roadmap item #10: when the Turnstile widget has produced
// a token, send it alongside the email so the backend can siteverify
// before the OTC dispatch. The backend treats a missing token as
// either soft-fail (no secret wired AND TURNSTILE_REQUIRED=false)
// or hard-fail (verification required) — the frontend stays
// uninvolved in the policy.
const body = { email }
if (turnstileToken) body.turnstile_token = turnstileToken
const res = await fetch('/auth/otc/request', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
body: JSON.stringify(body),
})
return jsonOrThrow(res)
}
export async function verifyOtc(email, code) {
export async function verifyOtc(email, code, { trustDevice = false } = {}) {
// v0.11.0 — `trustDevice` is the "trust this device for 30 days"
// checkbox on the Login.jsx OTC step. When true, the server mints
// a fresh device-trust row and sets the long-lived cookie; on
// subsequent visits, the cookie skips the OTC roundtrip via
// `startDeviceTrust()`.
const res = await fetch('/auth/otc/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, code }),
body: JSON.stringify({ email, code, trust_device: !!trustDevice }),
})
return jsonOrThrow(res)
}
@@ -82,15 +95,44 @@ export async function checkPasscode(email) {
return jsonOrThrow(res)
}
export async function verifyPasscode(email, passcode) {
export async function verifyPasscode(email, passcode, { trustDevice = false } = {}) {
// v0.11.0 — same trust-device opt-in as `verifyOtc`.
const res = await fetch('/auth/passcode/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, passcode }),
body: JSON.stringify({ email, passcode, trust_device: !!trustDevice }),
})
return jsonOrThrow(res)
}
// ── v0.11.0: trust device for 30 days (§6.2, roadmap item #9) ─────────────
//
// On a returning visit with a valid device-trust cookie, `startDeviceTrust`
// re-establishes the session without an OTC / passcode roundtrip. The
// cookie is HttpOnly so the client cannot read it; the call is a pure POST
// that the browser attaches the cookie to automatically.
//
// `listMyDevices`, `revokeMyDevice`, and `revokeAllMyDevices` drive the
// /settings/devices revoke-device UI. The signed-in user is the implicit
// subject; the cookie carries the session.
export async function startDeviceTrust() {
const res = await fetch('/auth/device-trust/start', { method: 'POST' })
return jsonOrThrow(res)
}
export async function listMyDevices() {
return jsonOrThrow(await fetch('/api/auth/me/devices'))
}
export async function revokeMyDevice(deviceId) {
return jsonOrThrow(await fetch(`/api/auth/me/devices/${deviceId}`, { method: 'DELETE' }))
}
export async function revokeAllMyDevices() {
return jsonOrThrow(await fetch('/api/auth/me/devices', { method: 'DELETE' }))
}
export async function setPasscode(passcode) {
// Requires an active session — the server returns 401 if not signed
// in. The signed-in user is the implicit subject; the body carries
+7
View File
@@ -24,6 +24,7 @@ import {
addAllowlistEmail,
removeAllowlistEmail,
} from '../api.js'
import { EVENTS, track } from '../lib/analytics.js'
const TABS = [
{ path: 'users', label: 'Users' },
@@ -133,6 +134,12 @@ function UsersTab() {
setError(null)
try {
await setUserPermission(userId, state)
// v0.15.0 analytics: fire on a successful §6.1 grant/revoke.
// action collapses the {pending granted, revoked granted}
// edges onto `grant`, and `granted revoked` onto `revoke`,
// matching the roadmap's two-arm taxonomy.
const action = state === 'granted' ? 'grant' : 'revoke'
track(EVENTS.ADMIN_PERMISSION_DECISION, { action, target_user_id: String(userId) })
// Refresh the full row so permission_decided_{at,by_*} update too.
await refresh()
} catch (e) {
+162 -12
View File
@@ -1,7 +1,17 @@
// Login.jsx the composed sign-in surface (§6.2) after the v0.10.0
// (passcodes, roadmap item #8) rebase onto v0.8.0 (beta-access-request
// capture, §6.1 / §14.1, roadmap item #6). v0.7.0 (roadmap item #5)
// established the email + OTC scaffolding both releases extended.
// Login.jsx the composed sign-in surface (§6.2) after the v0.12.0
// (CloudFlare Turnstile gate on OTC dispatch, roadmap item #10) /
// v0.10.0 (passcodes, roadmap item #8) rebase onto v0.8.0
// (beta-access-request capture, §6.1 / §14.1, roadmap item #6). v0.7.0
// (roadmap item #5) established the email + OTC scaffolding the later
// releases extended.
//
// v0.12.0: the email-entry step renders a Turnstile widget. The token
// it produces is sent to `/auth/otc/request` alongside the email. The
// passcode step also renders a widget for the "Use a code instead"
// fallback dispatch (same backend endpoint, same gate). When the
// `VITE_TURNSTILE_SITE_KEY` build var is unset, the widget renders
// nothing the form still submits and the backend's TURNSTILE_REQUIRED
// policy decides admission.
//
// Four-to-six-step flow (most users see three; the longest path is
// pending-user with no passcode, who never sees the passcode steps):
@@ -72,7 +82,10 @@ import {
checkPasscode,
verifyPasscode,
setPasscode as apiSetPasscode,
startDeviceTrust,
} from '../api'
import TurnstileWidget, { turnstileEnabled } from './TurnstileWidget'
import { EVENTS, track } from '../lib/analytics'
export default function Login() {
// Steps: 'email' 'passcode' or 'code' (on the OTC path, after
@@ -84,12 +97,31 @@ export default function Login() {
const [code, setCode] = useState('')
const [passcode, setPasscode] = useState('')
const [newPasscode, setNewPasscode] = useState('')
// v0.11.0 "trust this device for 30 days" checkbox, shared by the
// OTC and passcode verify steps. The flag rides on the verify POST;
// a checked box mints a device-trust row server-side and sets the
// long-lived `rfc_device_trust` cookie. Defaults off so the user
// makes an explicit choice auth credentials shouldn't persist by
// default.
const [trustDevice, setTrustDevice] = useState(false)
// v0.8.0 capture-profile fields.
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
const [reason, setReason] = useState('')
const [status, setStatus] = useState('')
const [busy, setBusy] = useState(false)
// v0.12.0: Turnstile token captured by the widget. `null` means no
// challenge solved yet (or the site key is unset, in which case the
// widget surfaces null on mount). The token is single-use; we clear
// it back to null right after we send it so a second request on the
// same form remount re-challenges. `turnstileReady` is true once the
// widget has produced a token OR the widget is not configured at
// build time (no site key) the submit button reads from it so the
// form locks up when the operator has wired Turnstile but the user
// hasn't solved the challenge yet.
const [turnstileToken, setTurnstileToken] = useState(null)
const turnstileOn = turnstileEnabled()
const turnstileReady = !turnstileOn || !!turnstileToken
const emailRef = useRef(null)
const codeRef = useRef(null)
const passcodeRef = useRef(null)
@@ -105,6 +137,33 @@ export default function Login() {
else if (step === 'set-passcode') newPasscodeRef.current?.focus()
}, [step])
// v0.11.0 on mount, try the device-trust cookie path. If the
// browser still carries a valid `rfc_device_trust` cookie from a
// prior "trust this device" gesture, the server re-establishes the
// session without any user input and we redirect home. The cookie
// is HttpOnly so we can't peek at it; we just call the endpoint and
// see whether it returns 200. 401 (no cookie / invalid / revoked)
// is the structural-silent case the user proceeds to the email
// step normally. We do not surface any UI about the attempt; a
// failure should be invisible.
useEffect(() => {
let cancelled = false
;(async () => {
try {
await startDeviceTrust()
if (!cancelled) {
// v0.15.0 analytics: device-trust cookie path is one of
// three sign-in methods the taxonomy distinguishes.
track(EVENTS.USER_SIGNED_IN, { method: 'trust-device' })
window.location.assign('/')
}
} catch (_) {
// No trusted device fall through to the email step.
}
})()
return () => { cancelled = true }
}, [])
async function submitEmail(e) {
e.preventDefault()
if (!email.trim() || !email.includes('@')) {
@@ -119,13 +178,22 @@ export default function Login() {
setStep('passcode')
setStatus('')
} else {
await requestOtc(email.trim())
await requestOtc(email.trim(), { turnstileToken })
// v0.12.0: the token is single-use; drop it so a re-request
// from the code step (via "Use a different email" back to
// email) starts with a fresh challenge.
setTurnstileToken(null)
setStep('code')
setStatus('Check your inbox — a six-digit code is on the way.')
}
} catch (err) {
// Any failure consumes the token from CloudFlare's side; clear
// so the widget re-renders a fresh challenge on retry.
setTurnstileToken(null)
if (err.status === 429) {
setStatus('Slow down — wait a minute before requesting another code.')
} else if (err.status === 400) {
setStatus("Couldn't verify you're human. Please retry the challenge.")
} else {
setStatus(err.message || 'Could not start sign-in. Try again.')
}
@@ -143,7 +211,11 @@ export default function Login() {
setBusy(true)
setStatus('')
try {
await verifyPasscode(email.trim(), passcode.trim())
await verifyPasscode(email.trim(), passcode.trim(), { trustDevice })
// v0.15.0 analytics: passcode is the second of three
// sign-in methods. trust-device gets credited separately when
// the cookie-driven path fires above.
track(EVENTS.USER_SIGNED_IN, { method: 'passcode' })
// Reload so App.jsx's getMe() picks up the fresh session. A
// returning passcode user is by definition already past the
// §6.1 capture step (they couldn't have set a passcode while
@@ -156,17 +228,29 @@ export default function Login() {
// fresh code in the user's inbox immediately.
setPasscode('')
try {
await requestOtc(email.trim())
// v0.12.0: pass whatever token the widget on the passcode
// step has produced. If the operator has Turnstile required
// and the user hasn't solved the passcode-step widget, the
// backend refuses and we bounce them back to email-entry
// with a clear status (see catch below).
await requestOtc(email.trim(), { turnstileToken })
setTurnstileToken(null)
setStep('code')
setStatus(
'Too many failed attempts. We sent a one-time code to your email — use it to sign in.',
)
} catch (e2) {
setTurnstileToken(null)
if (e2.status === 429) {
setStep('code')
setStatus(
'Too many failed attempts. Wait a minute, then request a one-time code to sign in.',
)
} else if (e2.status === 400) {
setStep('email')
setStatus(
'Too many failed attempts. Solve the challenge below to receive a one-time code.',
)
} else {
setStatus(
'Too many failed attempts. Use the "Use a code instead" link to sign in via email.',
@@ -189,7 +273,12 @@ export default function Login() {
setBusy(true)
setStatus('')
try {
await verifyOtc(email.trim(), code.trim())
await verifyOtc(email.trim(), code.trim(), { trustDevice })
// v0.15.0 analytics: OTC is the third sign-in method.
// We fire it here regardless of whether the user then lands
// in capture-profile or offer-passcode sign-in has happened
// server-side either way.
track(EVENTS.USER_SIGNED_IN, { method: 'otc' })
// OTC verified the server has signed in the user. Fetch the
// canonical /api/auth/me to decide where to land:
// * needs_profile §6.1 capture (then /beta-pending).
@@ -246,6 +335,11 @@ export default function Login() {
last_name: ln,
beta_request_reason: why,
})
// v0.15.0 analytics: a successful capture-profile submit is
// the moment a beta-access request lands. No PII in the event
// body (no name, no reason text); the count + timestamp is
// what the funnel needs.
track(EVENTS.BETA_ACCESS_REQUESTED)
// Hard-load so App.jsx re-fetches /api/auth/me and picks up
// the captured fields. The user stays permission_state='pending'
// until an admin grants access the next thing they should
@@ -314,17 +408,27 @@ export default function Login() {
async function fallbackToOtc() {
// Manual "Use a code instead" from the passcode step. Same shape
// as the email-step OTC dispatch.
// as the email-step OTC dispatch. v0.12.0: pass through whatever
// Turnstile token the passcode-step widget has produced (or null
// when the widget is disabled at build time).
setBusy(true)
setStatus('')
try {
await requestOtc(email.trim())
await requestOtc(email.trim(), { turnstileToken })
setTurnstileToken(null)
setPasscode('')
setStep('code')
setStatus('Check your inbox — a six-digit code is on the way.')
} catch (err) {
setTurnstileToken(null)
if (err.status === 429) {
setStatus('Slow down — wait a minute before requesting another code.')
} else if (err.status === 400) {
// v0.12.0: the widget rejected or no token was sent. Bounce
// the user back to the email step so they get a fresh
// challenge alongside the email input.
setStep('email')
setStatus("Couldn't verify you're human. Please retry the challenge.")
} else {
setStatus(err.message || 'Could not request a code. Try again.')
}
@@ -357,7 +461,18 @@ export default function Login() {
required
disabled={busy}
/>
<button type="submit" disabled={busy || !email.trim()}>
{/*
v0.12.0: CloudFlare Turnstile widget. Renders nothing
when VITE_TURNSTILE_SITE_KEY is unset (and turnstileReady
defaults to true in that case so the submit gate doesn't
lock up). On every challenge the widget calls onToken
with the fresh token; we feed it to /auth/otc/request.
*/}
<TurnstileWidget onToken={setTurnstileToken} />
<button
type="submit"
disabled={busy || !email.trim() || !turnstileReady}
>
{busy ? 'Checking…' : 'Continue'}
</button>
</form>
@@ -378,6 +493,30 @@ export default function Login() {
required
disabled={busy}
/>
{/* v0.11.0 trust device for 30 days. The checkbox lives
on the verify step so the user makes the trust gesture
in the same breath as signing in. Off by default; the
user opts in deliberately. */}
<label className="otc-trust-device">
<input
type="checkbox"
checked={trustDevice}
onChange={e => setTrustDevice(e.target.checked)}
disabled={busy}
/>
<span>Trust this device for 30 days</span>
</label>
{/*
v0.12.0: a second Turnstile widget for the
"Use a code instead" fallback dispatch. The passcode
verify path does not consume a Turnstile token (it has
its own 5-attempt lockout shape from v0.10.0), but if
the user falls back to OTC the same /auth/otc/request
endpoint runs and needs a token. We render the widget
on this step too so the fallback works without bouncing
back to email-entry first.
*/}
<TurnstileWidget onToken={setTurnstileToken} />
<div className="otc-actions">
<button type="submit" disabled={busy || !passcode.trim()}>
{busy ? 'Signing in…' : 'Sign in'}
@@ -386,7 +525,7 @@ export default function Login() {
type="button"
className="btn-link-quiet"
onClick={fallbackToOtc}
disabled={busy}
disabled={busy || !turnstileReady}
>
Use a code instead
</button>
@@ -420,6 +559,17 @@ export default function Login() {
required
disabled={busy}
/>
{/* v0.11.0 trust device for 30 days. Same shape as the
passcode step; the user opts in deliberately. */}
<label className="otc-trust-device">
<input
type="checkbox"
checked={trustDevice}
onChange={e => setTrustDevice(e.target.checked)}
disabled={busy}
/>
<span>Trust this device for 30 days</span>
</label>
<div className="otc-actions">
<button type="submit" disabled={busy || code.length !== 6}>
{busy ? 'Signing in…' : 'Sign in'}
@@ -32,6 +32,9 @@ import {
getMe,
setPasscode,
clearPasscode,
listMyDevices,
revokeMyDevice,
revokeAllMyDevices,
} from '../api.js'
import { getConsent, onConsentChange, hydrateFromServer } from '../lib/consent.js'
@@ -54,11 +57,126 @@ export default function NotificationSettings({ viewer }) {
<WatchesSection />
<MutesSection viewer={viewer} />
<SignInSection />
<DevicesSection />
<PrivacyCookiesSection />
</div>
)
}
// v0.11.0: trusted devices (§6.2, roadmap item #9)
//
// Lists the user's active device-trust rows and lets them revoke any
// or all. A revoke marks the row dead server-side; the matching
// device's next visit will be refused and the cookie cleared. The
// surface intentionally does not single out the row whose cookie the
// current request carries every row reads identically, so the user
// can revoke "this device" alongside any other from a single page.
function DevicesSection() {
const [devices, setDevices] = useState(null)
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
useEffect(() => {
refresh()
}, [])
async function refresh() {
try {
const { items } = await listMyDevices()
setDevices(items || [])
setError(null)
} catch (e) {
setError(e.message || 'Could not load trusted devices.')
}
}
async function onRevoke(deviceId) {
setBusy(true)
try {
await revokeMyDevice(deviceId)
await refresh()
} catch (e) {
setError(e.message || 'Could not revoke device.')
} finally {
setBusy(false)
}
}
async function onRevokeAll() {
if (!confirm('Revoke trust on every device, including this one? You will be asked to sign in via email next time.')) {
return
}
setBusy(true)
try {
await revokeAllMyDevices()
await refresh()
} catch (e) {
setError(e.message || 'Could not revoke devices.')
} finally {
setBusy(false)
}
}
return (
<SectionShell
title="Trusted devices"
subtitle="Devices where you've checked “Trust this device for 30 days.” Sign-in is automatic on these devices until the trust expires or you revoke it."
>
{devices === null && <p className="settings-note">Loading</p>}
{devices !== null && devices.length === 0 && (
<p className="device-empty">
No trusted devices. Sign in and check Trust this device for 30 days
to add the device you're on now.
</p>
)}
{devices !== null && devices.length > 0 && (
<>
<ul className="device-list">
{devices.map(d => (
<li key={d.id} className="device-list-item">
<div className="device-meta">
<div className="device-ua">{d.user_agent || 'Unknown device'}</div>
<div className="device-stamps">
Trusted {formatStamp(d.created_at)} · last seen {formatStamp(d.last_seen_at)} · expires {formatStamp(d.expires_at)}
</div>
</div>
<button
type="button"
onClick={() => onRevoke(d.id)}
disabled={busy}
title="Revoke trust on this device"
>
Revoke
</button>
</li>
))}
</ul>
<button
type="button"
className="device-revoke-all"
onClick={onRevokeAll}
disabled={busy}
>
Revoke all devices
</button>
</>
)}
{error && <p className="settings-note warning">{error}</p>}
</SectionShell>
)
}
function formatStamp(stamp) {
// The server emits SQLite `datetime('now')` strings (UTC, no
// timezone marker). Parse defensively; fall back to the raw stamp
// if Date can't make sense of it.
if (!stamp) return '—'
const d = new Date(stamp.replace(' ', 'T') + 'Z')
if (Number.isNaN(d.getTime())) return stamp
return d.toLocaleString()
}
// §6.2 sign-in (v0.10.0 / roadmap item #8): passcode management
function SignInSection() {
+4
View File
@@ -10,6 +10,7 @@
import { useEffect, useState } from 'react'
import { draftPRText, openPR } from '../api'
import { EVENTS, track } from '../lib/analytics'
export default function PRModal({ slug, branch, branchIsPrivate, onClose, onOpened }) {
const [title, setTitle] = useState('')
@@ -39,6 +40,9 @@ export default function PRModal({ slug, branch, branchIsPrivate, onClose, onOpen
setError(null)
try {
const { pr_number } = await openPR(slug, branch, { title: title.trim(), description: description.trim() })
// v0.15.0 analytics: fire on §10.2 PR-open success. slug
// and pr_number are the join keys; title/description stay out.
track(EVENTS.PR_OPENED, { rfc_slug: slug, pr_number })
onOpened?.(pr_number)
} catch (e) {
setError(e.message)
+5
View File
@@ -22,6 +22,7 @@ import {
startResolutionBranch,
withdrawPR,
} from '../api'
import { EVENTS, track } from '../lib/analytics'
export default function PRView({ viewer }) {
const { slug, prNumber: prNumberParam } = useParams()
@@ -135,6 +136,10 @@ export default function PRView({ viewer }) {
anchorPayload: reviewDraft?.anchorPayload || {},
quote: reviewDraft?.quote || null,
})
// v0.15.0 analytics: fire on §10.4 review-comment success.
// surface=pr distinguishes this from RFC discussion comments.
// No body text or quote material in the event.
track(EVENTS.COMMENT_POSTED, { rfc_slug: slug, pr_number: prNumber, surface: 'pr' })
setReviewText('')
setReviewDraft(null)
await refresh()
+5
View File
@@ -11,6 +11,7 @@
import { useEffect, useState } from 'react'
import { proposeRFC } from '../api'
import { EVENTS, track } from '../lib/analytics'
function slugify(title) {
return title
@@ -52,6 +53,10 @@ export default function ProposeModal({ viewer, onClose, onSubmitted }) {
pitch: pitch.trim(),
tags,
})
// v0.15.0 analytics: fire on the §9.1 propose-RFC submit.
// Slug is a stable, low-cardinality identifier (kebab-case
// ascii); title and pitch stay out of the event body.
track(EVENTS.RFC_PROPOSED, { rfc_slug: slug })
onSubmitted?.(result)
} catch (err) {
setError(err.message || 'Submission failed.')
@@ -18,6 +18,7 @@ import {
postDiscussionMessage,
resolveDiscussionThread,
} from '../api'
import { EVENTS, track } from '../lib/analytics'
export default function RFCDiscussionPanel({ slug, viewer }) {
const [threads, setThreads] = useState([])
@@ -100,6 +101,10 @@ export default function RFCDiscussionPanel({ slug, viewer }) {
void message_id
}
setComposer('')
// v0.15.0 analytics: fire on a successful discussion post.
// surface=discussion distinguishes this from PR review comments
// which fire from PRView with surface=pr. No body text.
track(EVENTS.COMMENT_POSTED, { rfc_slug: slug, surface: 'discussion' })
} catch (err) {
setError(err.message)
} finally {
+10 -1
View File
@@ -44,6 +44,7 @@ import ChangePanel, { diffWords } from './ChangePanel.jsx'
import PRModal from './PRModal.jsx'
import GraduateDialog from './GraduateDialog.jsx'
import { claimOwnership } from '../api'
import { EVENTS, track } from '../lib/analytics'
const MANUAL_IDLE_MS = 5 * 60 * 1000 // §8.6 idle window; exact value is impl detail.
const MANUAL_DEBOUNCE_MS = 800
@@ -121,7 +122,15 @@ export default function RFCView({ viewer }) {
const [drawerOpen, setDrawerOpen] = useState(false)
useEffect(() => {
getRFC(slug).then(setEntry).catch(err => setError(err.message))
getRFC(slug).then(entry => {
setEntry(entry)
// v0.15.0 analytics: fire RFC Viewed once per slug load.
// We key on the slug param rather than the loaded entry so a
// re-render doesn't double-fire; the slug is the stable
// identifier. id is included for join-friendliness in the
// Amplitude dashboard.
track(EVENTS.RFC_VIEWED, { rfc_slug: slug, rfc_id: entry?.id })
}).catch(err => setError(err.message))
listModels(slug)
.then(({ models, default: def }) => {
setModels(models || [])
+120
View File
@@ -0,0 +1,120 @@
// TurnstileWidget.jsx v0.12.0 / roadmap item #10.
//
// Renders the CloudFlare Turnstile JS widget on the email-entry step of
// `/login`. Reads the site key from `import.meta.env.VITE_TURNSTILE_SITE_KEY`
// (Vite convention VITE_* prefix is build-time embedded). When the
// site key is unset/empty, this component renders nothing and reports
// a `null` token through `onToken` so the parent form can still submit.
// The backend's `TURNSTILE_REQUIRED` policy decides what happens to a
// request that arrives without a token; the frontend is intentionally
// not in that loop. See `backend/app/turnstile.py` for the matrix.
//
// The CloudFlare script is loaded once per page on first widget mount.
// Subsequent mounts (e.g. user goes back to email-entry after a failed
// OTC request) reuse the script tag and re-render the widget on the
// fresh container `div`. Unmounting removes the widget instance via
// `turnstile.remove(widgetId)` so a remount produces a new challenge
// rather than reusing a stale, already-consumed token.
//
// Turnstile contract:
// * `data-callback` fires with the token string on a successful
// challenge; the token is single-use and expires after ~5 minutes.
// * `data-error-callback` fires on a failed challenge (network,
// blocked, etc.); we surface a `null` token so the parent shows
// a retry hint.
// * `data-expired-callback` fires when the token times out before
// submission; we also drop to `null` and re-render so the user
// gets a fresh challenge on retry.
//
// We do **not** import the CloudFlare script at build time; loading it
// dynamically here keeps the bundle clean of an external request the
// page may not need (anonymous viewers reading RFCs never see Login).
import { useEffect, useRef } from 'react'
const TURNSTILE_SCRIPT_URL = 'https://challenges.cloudflare.com/turnstile/v0/api.js'
const SITE_KEY = import.meta.env.VITE_TURNSTILE_SITE_KEY || ''
// Promise-keyed: only one script tag, only one resolution chain.
let scriptLoadPromise = null
function loadTurnstileScript() {
if (typeof window === 'undefined') return Promise.resolve(null)
if (window.turnstile) return Promise.resolve(window.turnstile)
if (scriptLoadPromise) return scriptLoadPromise
scriptLoadPromise = new Promise((resolve, reject) => {
const existing = document.querySelector(`script[src="${TURNSTILE_SCRIPT_URL}"]`)
if (existing) {
existing.addEventListener('load', () => resolve(window.turnstile))
existing.addEventListener('error', reject)
return
}
const script = document.createElement('script')
script.src = TURNSTILE_SCRIPT_URL
script.async = true
script.defer = true
script.addEventListener('load', () => resolve(window.turnstile))
script.addEventListener('error', reject)
document.head.appendChild(script)
})
return scriptLoadPromise
}
export function turnstileEnabled() {
return !!SITE_KEY
}
export default function TurnstileWidget({ onToken, theme = 'auto' }) {
const containerRef = useRef(null)
const widgetIdRef = useRef(null)
useEffect(() => {
if (!SITE_KEY) {
// No site key configured surface a null token immediately so
// the parent form's submit-disabled gate doesn't lock up
// waiting on a challenge that will never arrive. The backend
// decides whether a tokenless request is admitted.
onToken?.(null)
return undefined
}
let cancelled = false
loadTurnstileScript()
.then(turnstile => {
if (cancelled || !turnstile || !containerRef.current) return
widgetIdRef.current = turnstile.render(containerRef.current, {
sitekey: SITE_KEY,
theme,
callback: token => onToken?.(token),
'error-callback': () => onToken?.(null),
'expired-callback': () => onToken?.(null),
})
})
.catch(() => {
// Script load failure surface null so the parent can decide
// what to do (today: still let submit through; the backend
// policy decides admission).
if (!cancelled) onToken?.(null)
})
return () => {
cancelled = true
if (widgetIdRef.current && window.turnstile) {
try {
window.turnstile.remove(widgetIdRef.current)
} catch (_) {
// Already gone or never registered nothing to clean up.
}
widgetIdRef.current = null
}
}
// We intentionally do not list `onToken` in the dependency array;
// a parent re-rendering with a fresh closure should not tear down
// and rebuild the widget (which would consume a fresh challenge).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
if (!SITE_KEY) return null
return <div ref={containerRef} className="turnstile-widget" />
}
+287
View File
@@ -0,0 +1,287 @@
// analytics.js — v0.15.0 / roadmap item #13.
//
// Wrapper around `@amplitude/unified` (Amplitude Analytics +
// Session Replay) that gates SDK initialization on the user's
// cookie/privacy consent (v0.13.0, `frontend/src/lib/consent.js`,
// SPEC §14.5). The wrapper presents a stable surface to the rest
// of the app:
//
// import { track, identify, anonymize } from './lib/analytics'
//
// track('RFC Viewed', { rfc_slug: 'open-human-model' })
// identify({ user_id: 'u_123' })
// anonymize() // call on sign-out
//
// At first import the wrapper:
// 1. Calls `bootstrap()` once, which reads `getConsent()` and
// subscribes to `onConsentChange()`. If consent.analytics is
// true, it lazily imports the Amplitude SDK and calls
// `amplitude.initAll(API_KEY, { analytics: { autocapture: true },
// sessionReplay: { sampleRate: 1 } })`.
// If consent.analytics is false (or undecided), the SDK is
// not loaded — no network request, no cookies, no session
// replay recording. A later consent change to `true` triggers
// init at that moment.
// 2. The wrapper queues `track()` and `identify()` calls made
// before init finishes (lazy import + consent grant), and
// drains the queue when init completes.
// 3. If the user later flips consent from granted → denied, the
// wrapper calls `amplitude.setOptOut(true)` so subsequent
// events are dropped client-side and session replay stops
// recording (the SDK is still loaded — we cannot unload a
// script — but it stops firing).
//
// Consent precedence ladder:
//
// consent.analytics === true → init + track
// consent.analytics === false → no init; or if already init,
// setOptOut(true)
// consent.recorded_at === null → treat as denied (banner is up;
// the user has not yet chosen)
//
// Session replay scope: this release ships session replay at
// `sampleRate: 1` (100% of sessions are recorded for full-DOM
// playback). That is the vendor-recommended default for new
// Amplitude deployments. The v0.13.0 consent banner's single
// "analytics" toggle gates both events and session replay together —
// a separate consent category for session-replay specifically is a
// §19.2 follow-up.
//
// API key resolution:
//
// The build-time env var `VITE_AMPLITUDE_API_KEY` carries the
// Amplitude project's API key. When it is unset/empty, the
// wrapper logs one console warning and no-ops — every public
// function becomes a deterministic no-op so dev environments
// (and deployments that intentionally don't ship analytics)
// keep working. The deploy gesture wires the key via flotilla's
// `overlay set` verb (see CHANGELOG for the operator gesture):
// Amplitude browser keys are bundle-embedded by design (visible
// to anyone with dev tools, same nature as the v0.12.0
// `VITE_TURNSTILE_SITE_KEY`), so the binding is overlay, not
// secret.
//
// PII discipline:
//
// `identify({ user_id })` SHOULD pass only the opaque server-
// side user id (the `viewer.id` integer or string). DO NOT pass
// email, display name, IP, or any other PII through the SDK.
// Event properties SHOULD likewise stay limited to ids and
// enums; free-text fields (titles, comment bodies) MUST NOT be
// sent.
//
// Event taxonomy: defined in `EVENTS` below. Callers SHOULD use
// one of these names rather than firing arbitrary strings — that
// keeps the Amplitude dashboard coherent over time.
import { getConsent, onConsentChange } from './consent.js'
const API_KEY = import.meta.env.VITE_AMPLITUDE_API_KEY || ''
// Public taxonomy. Keep this short and stable — new entries should
// land via a release, not ad-hoc. The strings match the Amplitude
// dashboard names exactly (Title Case, spaces, no punctuation).
export const EVENTS = Object.freeze({
PAGE_VIEWED: 'Page Viewed',
RFC_VIEWED: 'RFC Viewed',
USER_SIGNED_IN: 'User Signed In',
USER_SIGNED_OUT: 'User Signed Out',
RFC_PROPOSED: 'RFC Proposed',
PR_OPENED: 'PR Opened',
COMMENT_POSTED: 'Comment Posted',
BETA_ACCESS_REQUESTED: 'Beta Access Requested',
ADMIN_PERMISSION_DECISION: 'Admin Permission Decision',
})
// Internal state.
let _bootstrapped = false
let _amplitude = null // The dynamically imported SDK module.
let _initPromise = null // Pending init (lazy import + sdk.init).
let _initialized = false // True after sdk.init has resolved.
let _warnedNoKey = false
let _pendingUserId = null // identify() called before init resolves.
const _queue = [] // {kind: 'track'|'identify'|'anonymize', ...}
function warnNoKey() {
if (_warnedNoKey) return
_warnedNoKey = true
// eslint-disable-next-line no-console
console.warn(
'[analytics] VITE_AMPLITUDE_API_KEY is unset; analytics events ' +
'and session replay will not be sent. This is expected in dev; ' +
'in production it means the operator has not yet run ' +
'`flotilla overlay set <deployment> VITE_AMPLITUDE_API_KEY=<key>`.',
)
}
function consentGranted() {
const c = getConsent()
return !!(c && c.recorded_at && c.analytics)
}
// Drain the queue. Called once init resolves.
function drainQueue() {
if (!_initialized || !_amplitude) return
if (_pendingUserId != null) {
try { _amplitude.setUserId(_pendingUserId) } catch (_) {}
_pendingUserId = null
}
while (_queue.length > 0) {
const item = _queue.shift()
try {
if (item.kind === 'track') {
_amplitude.track(item.name, item.props || {})
} else if (item.kind === 'identify') {
if (item.user_id != null) _amplitude.setUserId(item.user_id)
} else if (item.kind === 'anonymize') {
_amplitude.reset()
}
} catch (_) {
// SDK errors are non-fatal; analytics is best-effort.
}
}
}
// Lazy import + init. Resolves once the SDK is ready to take events.
// Idempotent: subsequent calls return the same promise.
async function initSdk() {
if (_initPromise) return _initPromise
if (!API_KEY) {
warnNoKey()
// Resolve immediately with a no-op shape; the wrapper's public
// functions check API_KEY and short-circuit, so this never
// actually runs SDK code.
_initPromise = Promise.resolve(null)
return _initPromise
}
_initPromise = (async () => {
try {
const mod = await import('@amplitude/unified')
// The unified package exposes `initAll`, `track`,
// `setUserId`, `reset`, `setOptOut` as named functions.
// We hold the module so the queue drainer can call them
// by name.
_amplitude = mod
// initAll wires up both Analytics and Session Replay in one
// call. Vendor-recommended init shape from the Amplitude
// installation wizard:
// - analytics.autocapture: true — auto-instruments page
// views, session start/end, clicks, and form interactions.
// Our explicit `track('Page Viewed', …)` etc. layer on top
// for app-specific names that survive renames.
// - sessionReplay.sampleRate: 1 — record 100% of sessions
// for full-DOM playback. Gated by the v0.13.0 consent
// banner just like the rest of the SDK; never starts
// recording without explicit analytics opt-in.
const ret = mod.initAll(API_KEY, {
analytics: { autocapture: true },
sessionReplay: { sampleRate: 1 },
})
// initAll returns an AmplitudeReturn with a `.promise` accessor
// (consistent with the legacy `init`). Some unified builds
// resolve synchronously; await defensively.
if (ret && ret.promise) await ret.promise
_initialized = true
drainQueue()
} catch (err) {
// Init failure is non-fatal; keep the wrapper alive so future
// calls no-op. Log once for the operator.
// eslint-disable-next-line no-console
console.warn('[analytics] Amplitude init failed:', err)
_initialized = false
}
return _amplitude
})()
return _initPromise
}
// Bootstrap is called lazily on first track/identify. It wires the
// consent subscription so a later flip from denied→granted triggers
// init at that moment, and granted→denied flips the opt-out.
function bootstrap() {
if (_bootstrapped) return
_bootstrapped = true
if (consentGranted()) {
// Fire-and-forget; the queue catches any events that arrive
// before init resolves.
initSdk()
}
onConsentChange(snapshot => {
const allowed = !!(snapshot && snapshot.recorded_at && snapshot.analytics)
if (allowed && !_initPromise) {
initSdk()
} else if (allowed && _initialized && _amplitude) {
// Re-enable in case we previously opted out.
try { _amplitude.setOptOut(false) } catch (_) {}
} else if (!allowed && _initialized && _amplitude) {
// Granted → denied. Stop firing. We cannot unload the script
// tag; setOptOut is the SDK's contract for "drop subsequent
// events client-side".
try { _amplitude.setOptOut(true) } catch (_) {}
}
})
}
/** Fire a track event. Safe to call before consent / init resolve;
* the call is queued and drained once both are true. Drops the
* event silently if API_KEY is empty (with a one-shot warn) or
* consent.analytics is false. */
export function track(name, props) {
if (!API_KEY) { warnNoKey(); return }
bootstrap()
if (!consentGranted()) return
if (_initialized && _amplitude) {
try { _amplitude.track(name, props || {}) } catch (_) {}
return
}
_queue.push({ kind: 'track', name, props })
}
/** Attach an authenticated user id. Pass `{ user_id: '<opaque-id>' }`.
* DO NOT pass email or display name. Idempotent subsequent calls
* with the same id are cheap. */
export function identify({ user_id } = {}) {
if (!API_KEY) { warnNoKey(); return }
if (user_id == null) return
bootstrap()
if (!consentGranted()) {
// Hold the id for when consent lands; identify-on-sign-in is a
// common race with the consent banner choice.
_pendingUserId = user_id
return
}
if (_initialized && _amplitude) {
try { _amplitude.setUserId(user_id) } catch (_) {}
return
}
_pendingUserId = user_id
_queue.push({ kind: 'identify', user_id })
}
/** Reset the user binding. Call this on sign-out so the next page
* navigations are attributed to a fresh anonymous device id. Has
* no effect when analytics is disabled. */
export function anonymize() {
if (!API_KEY) { warnNoKey(); return }
_pendingUserId = null
bootstrap()
if (!consentGranted()) return
if (_initialized && _amplitude) {
try { _amplitude.reset() } catch (_) {}
return
}
_queue.push({ kind: 'anonymize' })
}
/** Test helper exposed for unit tests, not for app code.
* Resets module-level state so a fresh bootstrap cycle can be
* exercised. */
export function __resetForTests() {
_bootstrapped = false
_amplitude = null
_initPromise = null
_initialized = false
_warnedNoKey = false
_pendingUserId = null
_queue.length = 0
}