Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ee4925b6ac | |||
| 72f8457933 | |||
| b3f1b15f65 | |||
| 6fb68a95c7 | |||
| 7872b921ed |
+805
@@ -23,6 +23,351 @@ skip versions are the composition of each intervening adjacent
|
|||||||
release's steps in order — no A-to-B path is pre-computed beyond
|
release's steps in order — no A-to-B path is pre-computed beyond
|
||||||
that.
|
that.
|
||||||
|
|
||||||
|
## 0.16.0 — 2026-05-28
|
||||||
|
|
||||||
|
**Minor — schema migration auto-applied; no operator action.** This
|
||||||
|
release lands the owner-only invite for per-RFC PR or PR-less
|
||||||
|
discussion (roadmap item #12). The RFC's owner can now invite
|
||||||
|
specific users by email to one of two per-RFC roles —
|
||||||
|
`contributor` (open PRs against the RFC AND join its discussion) or
|
||||||
|
`discussant` (join the discussion only). Non-invited users keep
|
||||||
|
the v0.6.0 anonymous-read contract: they can read but cannot
|
||||||
|
write/discuss that RFC. Invitations are token-encoded in a
|
||||||
|
transactional email; acceptance lands a per-RFC collaborator row
|
||||||
|
and surfaces in the admin user-management page (additive on the
|
||||||
|
existing `/api/admin/users` shape) so the platform-grant decision
|
||||||
|
has the per-RFC context to inform it. The platform-level grant
|
||||||
|
remains the admin's call — this release adds a per-RFC membership
|
||||||
|
layer beneath it, not a new platform-grant path.
|
||||||
|
|
||||||
|
The per-RFC write gate is layered on top of the existing
|
||||||
|
`require_contributor` (v0.8.0) gate, not in place of it: a user
|
||||||
|
must be platform-granted AND hold an accepted per-RFC role (or
|
||||||
|
be the RFC owner / a platform admin/owner) to write. A super-
|
||||||
|
draft with no frontmatter owners yet (pre-§13.1 claim) falls
|
||||||
|
through to the platform-granted contract — there's no owner to
|
||||||
|
issue invitations, so the gate is open until one exists. This
|
||||||
|
preserves the v0.6.0 / v0.7.0 / v0.8.0 contracts inside their
|
||||||
|
domains and confines item #12's change to "an RFC has owners →
|
||||||
|
those owners decide who writes."
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **`backend/migrations/018_rfc_invitations.sql`** — two tables.
|
||||||
|
`rfc_invitations` carries the lifecycle row (issued, accepted,
|
||||||
|
revoked, expired) with the opaque token the email link encodes,
|
||||||
|
the inviter, the invitee email, the role-in-RFC, and the 30-day
|
||||||
|
expiry. `rfc_collaborators` is the accepted-invitation
|
||||||
|
substrate — the compact (rfc, user, role) shape the write gate
|
||||||
|
consults. Both tables are FK-cascaded against `cached_rfcs` and
|
||||||
|
`users` per §5's cascade rules. Indexed for the owner's listing,
|
||||||
|
the accept-by-token lookup, and the per-user read.
|
||||||
|
- **`backend/app/api_invitations.py`** — the §17 surface. Five
|
||||||
|
endpoints: `POST /api/rfcs/{slug}/invitations` (create + email),
|
||||||
|
`GET /api/rfcs/{slug}/invitations` (owner's listing),
|
||||||
|
`POST /api/rfcs/{slug}/invitations/{id}/revoke`,
|
||||||
|
`GET /api/invitations/accept?token=…` (preview), and
|
||||||
|
`POST /api/invitations/accept` (redeem). The email reuses
|
||||||
|
`EmailConfig.from_env()` and the `_SENT` buffer the OTC and
|
||||||
|
notification mailers share — transactional, no preferences
|
||||||
|
honored, no unsubscribe footer. A failure to send does NOT
|
||||||
|
roll back the row; the owner has the token on the listing
|
||||||
|
surface for an out-of-band share.
|
||||||
|
- **`backend/app/auth.py`** — four helpers. `is_rfc_owner`
|
||||||
|
reads the frontmatter `owners_json`. `is_rfc_collaborator`
|
||||||
|
reads the v0.16.0 `rfc_collaborators` table. `can_discuss_rfc`
|
||||||
|
and `can_contribute_to_rfc` are the composite predicates the
|
||||||
|
write endpoints consult (platform admin/owner OR no-owners-yet
|
||||||
|
fall-through OR RFC owner OR per-RFC collaborator at the right
|
||||||
|
role). `can_invite_to_rfc` is the issue-side predicate (RFC
|
||||||
|
owner or platform admin/owner only — collaborators don't get
|
||||||
|
invite power).
|
||||||
|
- **`frontend/src/components/InvitationsModal.jsx`** — the RFC
|
||||||
|
owner's surface: an email input + role picker for sending,
|
||||||
|
and a status table for listing/revoking. Visible only to the
|
||||||
|
RFC's owner or a platform admin/owner (the backend gates the
|
||||||
|
endpoints regardless).
|
||||||
|
- **`frontend/src/components/AcceptInvitation.jsx`** — the
|
||||||
|
`/invitations/accept?token=…` landing page. Previews what the
|
||||||
|
invitation grants, refuses on email mismatch / revoked /
|
||||||
|
expired with a single sentence each, redirects to the RFC's
|
||||||
|
view on accept.
|
||||||
|
- **API client (`frontend/src/api.js`)** — five new helpers:
|
||||||
|
`listRFCInvitations`, `createRFCInvitation`,
|
||||||
|
`revokeRFCInvitation`, `previewInvitation`, `acceptInvitation`.
|
||||||
|
- **Amplitude wiring** (per `ohm-rfc/ROADMAP.md` #21 Part C, shipped
|
||||||
|
inline with v0.16.0): `INVITATION_SENT` event fires from
|
||||||
|
`InvitationsModal.jsx` on successful send with `{ rfc_slug,
|
||||||
|
role_in_rfc }`; `INVITATION_ACCEPTED` event fires from
|
||||||
|
`AcceptInvitation.jsx` on successful accept with the same shape —
|
||||||
|
but the accept path first calls `identify({ user_id, properties:
|
||||||
|
{ invited_at (setOnce), last_invited_to_rfc,
|
||||||
|
last_invite_role_in_rfc, claim_method: 'rfc-invite' } })` so the
|
||||||
|
Amplitude user record carries the invite context from the moment
|
||||||
|
of acceptance. No invitee email or other PII enters the event
|
||||||
|
body — only the slug, role, and the inviter's identity (through
|
||||||
|
the standard signed-in identify on the inviter's session).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **`backend/app/api.py`** — registers
|
||||||
|
`api_invitations.make_router()` alongside the existing routers.
|
||||||
|
- **`backend/app/api_discussion.py`** — `POST .../discussion/threads`
|
||||||
|
and `POST .../discussion/threads/{thread_id}/messages` now compose
|
||||||
|
the new `auth.can_discuss_rfc` predicate after the existing
|
||||||
|
`require_contributor` check. A platform-granted user without a
|
||||||
|
per-RFC discussion role on an RFC with owners gets 403 with
|
||||||
|
"This RFC's owner has not invited you to its discussion."
|
||||||
|
- **`backend/app/api_branches.py`** — `POST .../promote-to-branch`
|
||||||
|
and `POST .../start-edit-branch` now compose
|
||||||
|
`auth.can_contribute_to_rfc`. Same shape: platform-granted but
|
||||||
|
uninvited → 403.
|
||||||
|
- **`backend/app/api_prs.py`** — `POST .../open-pr` also composes
|
||||||
|
`auth.can_contribute_to_rfc` so a user whose per-RFC role was
|
||||||
|
revoked between branch-cut and PR-open is refused at the
|
||||||
|
ship line.
|
||||||
|
- **`backend/app/api_admin.py`** — `GET /api/admin/users` carries
|
||||||
|
a new `rfc_invitations` array per user (empty if none), naming
|
||||||
|
each accepted per-RFC collaboration with the RFC slug/title,
|
||||||
|
the role, the inviter, and the timestamp. Additive — the
|
||||||
|
existing v0.9.0 columns are unchanged; consumers that don't
|
||||||
|
read the new field see the legacy shape.
|
||||||
|
- **`frontend/src/App.jsx`** — registers the
|
||||||
|
`/invitations/accept` route (visible to anonymous + signed-in
|
||||||
|
viewers; signed-out viewers see a sign-in prompt).
|
||||||
|
- **`frontend/src/components/RFCView.jsx`** — additive
|
||||||
|
"Invitations" button in the RFC header strip, visible to RFC
|
||||||
|
owners and platform admins/owners on both super-drafts and
|
||||||
|
active RFCs. Mounts the new modal on click.
|
||||||
|
- **`backend/tests/test_propose_vertical.py`** — adds the
|
||||||
|
`grant_rfc_collaborator` test helper so v0.5.0/v0.6.0/v0.8.0-era
|
||||||
|
tests that exercise non-owner contribution can opt into the new
|
||||||
|
invitation contract without rewriting their setup.
|
||||||
|
- **`backend/tests/test_pr_flow_vertical.py`,
|
||||||
|
`backend/tests/test_graduation_vertical.py`,
|
||||||
|
`backend/tests/test_e2e_smoke.py`** — three tests that signed in
|
||||||
|
as non-owner contributors now seed an accepted per-RFC
|
||||||
|
collaborator row first (mirroring the production invite→accept
|
||||||
|
dance). The test intent is unchanged; the precondition is now
|
||||||
|
explicit.
|
||||||
|
|
||||||
|
### Migration
|
||||||
|
|
||||||
|
- **`018_rfc_invitations.sql`** — auto-applied on backend start by
|
||||||
|
the existing `db.run_migrations()` sweep. The two new tables
|
||||||
|
are empty at upgrade time; no existing data is touched. No
|
||||||
|
operator gesture needed.
|
||||||
|
|
||||||
|
### Upgrade steps (from 0.15.0, or 0.14.0 if 0.15.0 is skipped)
|
||||||
|
|
||||||
|
- You **MUST** rebuild the frontend and restart the backend after
|
||||||
|
upgrading so the new endpoints, the migration, the gate
|
||||||
|
composition in `api_discussion`/`api_branches`/`api_prs`, and the
|
||||||
|
new frontend routes/components are picked up. `frontend/package.json#version`
|
||||||
|
and `VERSION` both move to `0.16.0`.
|
||||||
|
- You **MUST NOT** set any new env var — there are no new secrets
|
||||||
|
and no new overlay keys. The email path reuses the existing
|
||||||
|
`SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `SMTP_PASSWORD` /
|
||||||
|
`EMAIL_FROM` / `EMAIL_FROM_NAME` / `APP_URL` / `EMAIL_ENABLED`
|
||||||
|
variables that the v0.7.0 OTC and v0.5.0 notification paths
|
||||||
|
already require. Deployments that have those wired need no
|
||||||
|
configuration change.
|
||||||
|
- You **MUST NOT** apply the migration manually — the backend's
|
||||||
|
migration runner picks up `018_rfc_invitations.sql` on next
|
||||||
|
start. (If you've configured an external migration tool, run it
|
||||||
|
before starting the backend; the framework's own runner is
|
||||||
|
idempotent against already-applied migrations.)
|
||||||
|
- You **SHOULD** inform existing RFC owners that they can now
|
||||||
|
invite collaborators from the RFC view's header strip. RFCs
|
||||||
|
with frontmatter owners that pre-date this release see no
|
||||||
|
behavioral change for the owner; the change is visible to
|
||||||
|
non-owner contributors who previously could write on any RFC
|
||||||
|
and now must be invited first.
|
||||||
|
- You **MAY** seed `rfc_collaborators` rows directly via SQL for
|
||||||
|
pre-existing per-RFC working relationships you want to
|
||||||
|
grandfather past the v0.16.0 cutover. The `invitation_id`
|
||||||
|
column is nullable for exactly this purpose. Production
|
||||||
|
deployments without that history can ignore this option.
|
||||||
|
## 0.15.0 — 2026-05-28
|
||||||
|
|
||||||
|
**Minor — no schema migration; one new build-time env var bound via
|
||||||
|
`flotilla overlay set`.** This release ships Amplitude Analytics +
|
||||||
|
Session Replay instrumentation (roadmap item #13). The frontend
|
||||||
|
gains a small wrapper around `@amplitude/unified` that gates SDK
|
||||||
|
initialization on the v0.13.0 cookie/privacy consent — the SDK is
|
||||||
|
never loaded for visitors who have not granted analytics consent,
|
||||||
|
no session is recorded, no network request fires; a later consent
|
||||||
|
flip to `denied` calls `setOptOut(true)` so events and session
|
||||||
|
replay stop immediately. The wrapper exposes a stable taxonomy of
|
||||||
|
nine events (Page Viewed, RFC Viewed, User Signed In / Signed Out,
|
||||||
|
RFC Proposed, PR Opened, Comment Posted, Beta Access Requested, Admin
|
||||||
|
Permission Decision) wired into the existing routes, the Login flow,
|
||||||
|
the propose / open-PR / discussion / PR-review surfaces, and the
|
||||||
|
admin grant/revoke action. Event bodies carry only ids and enums; no
|
||||||
|
free-text fields (titles, comment bodies, names, emails) are ever
|
||||||
|
sent. **Session replay** records sessions at `sampleRate: 1` (100%)
|
||||||
|
— vendor-recommended default; gated by the same v0.13.0 analytics
|
||||||
|
consent. The Amplitude API key is read from `VITE_AMPLITUDE_API_KEY`
|
||||||
|
at build time; when unset, the wrapper logs one console warning and
|
||||||
|
no-ops so dev environments without analytics keep working. No backend
|
||||||
|
events ship in this release — Amplitude SaaS holds the events,
|
||||||
|
nothing lands in our DB, no migration.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Analytics wrapper** (`frontend/src/lib/analytics.js`). Public
|
||||||
|
surface: `track(name, props)`, `identify({ user_id, properties? })`,
|
||||||
|
`setUserProperties(properties)`, `anonymize()`, the `EVENTS`
|
||||||
|
taxonomy constant, and a `__resetForTests` helper. Internally
|
||||||
|
lazy-imports `@amplitude/unified` and calls
|
||||||
|
`amplitude.initAll(API_KEY, { analytics: { autocapture: true },
|
||||||
|
sessionReplay: { sampleRate: 1 } })` only after consent is
|
||||||
|
granted; queues pre-init calls and drains them on init resolve;
|
||||||
|
flips `setOptOut(true)` on a granted→denied consent change (stops
|
||||||
|
both analytics events and session replay). The wrapper subscribes
|
||||||
|
to `onConsentChange()` so a freshly-banner-clicked "analytics on"
|
||||||
|
flips the SDK live without a page reload.
|
||||||
|
- **User identity lifecycle** (per `ohm-rfc/ROADMAP.md` #21 Part C —
|
||||||
|
shipped inline with v0.15.0 instead of waiting for a follow-up).
|
||||||
|
`identify({ user_id, properties })` accepts a property bag that
|
||||||
|
applies as an Amplitude `Identify` event with `.set()` semantics
|
||||||
|
by default; values wrapped as `['__setOnce__', value]` apply with
|
||||||
|
`.setOnce()` semantics (immutable after first write — for
|
||||||
|
account-history markers like `first_sign_in_at`). The new
|
||||||
|
`setUserProperties(properties)` exposes the same property-apply
|
||||||
|
path for mid-session state changes (role grant/revoke, passcode
|
||||||
|
set, device trusted) so the Amplitude record stays current without
|
||||||
|
waiting for the next sign-in. `anonymize()` now clears both the
|
||||||
|
user_id binding AND the pending-property cache so a subsequent
|
||||||
|
sign-in as a different user starts with a fully fresh slate.
|
||||||
|
- **Event taxonomy** wired into the app:
|
||||||
|
- `Page Viewed` — fires from `App.jsx` on every route change with
|
||||||
|
`path` (`location.pathname`); the location hook owns the firing
|
||||||
|
and dedupes by path.
|
||||||
|
- `RFC Viewed` — fires from `RFCView.jsx` once per slug load with
|
||||||
|
`rfc_slug` and `rfc_id`.
|
||||||
|
- `User Signed In` — fires from `Login.jsx` with
|
||||||
|
`method ∈ { 'otc', 'passcode', 'trust-device' }` matching the
|
||||||
|
three sign-in paths from v0.7.0 / v0.10.0 / v0.11.0.
|
||||||
|
- `User Signed Out` — fires from `App.jsx`'s "Sign out" click,
|
||||||
|
followed by `anonymize()` to clear the SDK's user binding before
|
||||||
|
the hard nav to `/auth/logout`.
|
||||||
|
- `RFC Proposed` — fires from `ProposeModal.jsx` on submit success
|
||||||
|
with `rfc_slug`.
|
||||||
|
- `PR Opened` — fires from `PRModal.jsx` on submit success with
|
||||||
|
`rfc_slug` and `pr_number`.
|
||||||
|
- `Comment Posted` — fires from `RFCDiscussionPanel.jsx`
|
||||||
|
(`surface: 'discussion'`) and from `PRView.jsx`
|
||||||
|
(`surface: 'pr'`, with `pr_number`) on each post-success.
|
||||||
|
- `Beta Access Requested` — fires from `Login.jsx` capture-profile
|
||||||
|
submit success. No PII in the event.
|
||||||
|
- `Admin Permission Decision` — fires from `Admin.jsx`'s grant /
|
||||||
|
revoke action with `action ∈ { 'grant', 'revoke' }` and
|
||||||
|
`target_user_id` (string).
|
||||||
|
- **User binding + properties** (`App.jsx`): when `me.authenticated`
|
||||||
|
lands and a user id is available, the wrapper's
|
||||||
|
`identify({ user_id, properties })` is called with
|
||||||
|
`String(viewer.id)` AND a durable property bag — `role`,
|
||||||
|
`permission_state`, `passcode_set`, `device_trusted` (mutable;
|
||||||
|
refresh each sign-in), plus `first_sign_in_at` and
|
||||||
|
`account_created_at` (setOnce — immutable user-history markers).
|
||||||
|
The sign-out gesture calls `anonymize()` before the nav. No email,
|
||||||
|
display name, gitea_login, or other PII is passed through the SDK —
|
||||||
|
Amplitude only sees opaque ids, enums, timestamps, booleans.
|
||||||
|
- **`@amplitude/unified`** dependency added to
|
||||||
|
`frontend/package.json` (analytics + session replay in one
|
||||||
|
install). Lockfile updated.
|
||||||
|
- **`VITE_AMPLITUDE_API_KEY`** documented in `frontend/.env.example`
|
||||||
|
with the secret-vs-overlay binding caveat (see below).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **`frontend/src/App.jsx`** — adds `useLocation` for the route-change
|
||||||
|
Page Viewed firing, a `lastUserIdRef` memo to call
|
||||||
|
`identify` once per signed-in viewer, and an `onClick` handler on
|
||||||
|
the "Sign out" link that fires `User Signed Out` + `anonymize()`
|
||||||
|
before the hard nav.
|
||||||
|
- **`frontend/src/components/Login.jsx`** — fires `User Signed In`
|
||||||
|
with the appropriate `method` at each of the three sign-in points
|
||||||
|
(trust-device cookie path, passcode verify success, OTC verify
|
||||||
|
success), and fires `Beta Access Requested` on capture-profile
|
||||||
|
submit success.
|
||||||
|
- **`frontend/src/components/ProposeModal.jsx`** — fires `RFC Proposed`
|
||||||
|
with `rfc_slug` on submit success.
|
||||||
|
- **`frontend/src/components/RFCView.jsx`** — fires `RFC Viewed`
|
||||||
|
inside the `getRFC` resolution so the event is keyed on the slug
|
||||||
|
param and includes the loaded `rfc_id`.
|
||||||
|
- **`frontend/src/components/PRModal.jsx`** — fires `PR Opened` with
|
||||||
|
`rfc_slug` and `pr_number` on submit success.
|
||||||
|
- **`frontend/src/components/RFCDiscussionPanel.jsx`** — fires
|
||||||
|
`Comment Posted` with `surface: 'discussion'` on send-success.
|
||||||
|
- **`frontend/src/components/PRView.jsx`** — fires `Comment Posted`
|
||||||
|
with `surface: 'pr'` and `pr_number` on review-comment success.
|
||||||
|
- **`frontend/src/components/Admin.jsx`** — fires
|
||||||
|
`Admin Permission Decision` on grant/revoke success.
|
||||||
|
|
||||||
|
### Migration
|
||||||
|
|
||||||
|
- **No schema migration.** Amplitude SaaS holds the events; the
|
||||||
|
framework's DB is unchanged. Migration slot **015** is unused by
|
||||||
|
this release and remains available for the next minor that needs a
|
||||||
|
schema bump.
|
||||||
|
|
||||||
|
### Caveat — overlay binding for `VITE_AMPLITUDE_API_KEY`
|
||||||
|
|
||||||
|
Amplitude browser API keys are embedded in the frontend bundle at
|
||||||
|
build time and visible to anyone with browser dev tools. They are
|
||||||
|
public by design — same nature as the v0.12.0
|
||||||
|
`VITE_TURNSTILE_SITE_KEY` (also public, also bundle-embedded,
|
||||||
|
explicitly contrasted with `CLOUDFLARE_TURNSTILE_SECRET` which is
|
||||||
|
the real secret-half of that pair). The Amplitude installation
|
||||||
|
guidance from the vendor shows the key inline as a literal string
|
||||||
|
argument to `initAll(…)`, confirming the public framing. This
|
||||||
|
release accordingly binds the value via `flotilla overlay set`,
|
||||||
|
not `flotilla secret set` — the env-var name is `VITE_AMPLITUDE_API_KEY`
|
||||||
|
(Vite-prefix convention, so the build picks it up directly without
|
||||||
|
an alias step).
|
||||||
|
|
||||||
|
(Roadmap row #13 originally said "new secret: AMPLITUDE_API_KEY";
|
||||||
|
that wording predated vendor consultation. Mid-Session-L the
|
||||||
|
operator provisioned the Amplitude project, surfaced the vendor's
|
||||||
|
recommended init prompt, and the binding settled as overlay. The
|
||||||
|
roadmap row will be updated to match when #13 ships.)
|
||||||
|
|
||||||
|
### Caveat — session replay scope and consent
|
||||||
|
|
||||||
|
This release enables Amplitude Session Replay at `sampleRate: 1`
|
||||||
|
(100% of sessions recorded for full-DOM playback). The vendor's
|
||||||
|
installation wizard recommends this default for new deployments —
|
||||||
|
maximum learning during the early phase. The v0.13.0 single
|
||||||
|
"analytics" consent toggle gates session replay together with
|
||||||
|
events, so no recording happens without explicit opt-in. A future
|
||||||
|
release **MAY** split this into a separate consent category for
|
||||||
|
session replay specifically (recording has a meaningfully larger
|
||||||
|
privacy footprint than event counters); §19.2 candidate.
|
||||||
|
|
||||||
|
### Upgrade steps (from 0.14.0)
|
||||||
|
|
||||||
|
- You **MUST** install the new frontend dependency before building:
|
||||||
|
`cd frontend && npm install` picks up `@amplitude/unified` from
|
||||||
|
the updated `frontend/package.json` and the refreshed
|
||||||
|
`package-lock.json`. The lockfile change is committed.
|
||||||
|
- You **MUST** rebuild the frontend after upgrading so the analytics
|
||||||
|
wrapper and its consent gate ship to viewers. `frontend/package.json#version`
|
||||||
|
and `VERSION` both move to `0.15.0`. No schema migration; the
|
||||||
|
backend is unchanged for this release.
|
||||||
|
- **MUST**: before deploying, the operator runs `/Users/benstull/projects/wiggleverse/ohm-rfc-app-flotilla/.venv/bin/ohm-rfc-app-flotilla overlay set ohm-rfc-app VITE_AMPLITUDE_API_KEY=<key>` to bind the Amplitude project's public API key. (Receiving the value in the conversation is fine — it's bundle-embedded by design, same as `VITE_TURNSTILE_SITE_KEY`.) The deploy **SHOULD NOT** proceed before this binding exists; if the binding is absent, the frontend's analytics wrapper no-ops with a console warning and the rest of the app continues to function — but no events or session replays are sent.
|
||||||
|
- You **MAY** leave `VITE_AMPLITUDE_API_KEY` unset in dev environments
|
||||||
|
— the wrapper detects the empty value and no-ops with a single
|
||||||
|
console warning. The app, the consent banner, and every other
|
||||||
|
surface keep working unchanged.
|
||||||
|
- You **SHOULD** verify after deploy that the Amplitude dashboard
|
||||||
|
receives events and a session replay when a consenting browser
|
||||||
|
exercises one of the taxonomy events (the easiest probe: open the
|
||||||
|
deployed site in an Incognito window, accept analytics on the
|
||||||
|
consent banner, navigate to an RFC, and watch the project's live
|
||||||
|
event stream + replay panel).
|
||||||
|
|
||||||
## 0.14.0 — 2026-05-28
|
## 0.14.0 — 2026-05-28
|
||||||
|
|
||||||
**Minor — no operator action required; new optional env var.** This
|
**Minor — no operator action required; new optional env var.** This
|
||||||
@@ -197,6 +542,264 @@ consent infrastructure is wired so item #13 (v0.15.0) can read from
|
|||||||
(because their `cookie_consent` row does not yet exist); their
|
(because their `cookie_consent` row does not yet exist); their
|
||||||
current sessions remain valid.
|
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
|
## 0.10.0 — 2026-05-28
|
||||||
|
|
||||||
**Minor — schema migration required; new auth path is additive.**
|
**Minor — schema migration required; new auth path is additive.**
|
||||||
@@ -337,6 +940,208 @@ v0.7.0. The lockout shape (5 attempts, 15 minutes) and the length
|
|||||||
range (4–20) are hard-coded in `backend/app/passcode.py`. See
|
range (4–20) are hard-coded in `backend/app/passcode.py`. See
|
||||||
§19.2 for the env-tunable candidate.
|
§19.2 for the env-tunable candidate.
|
||||||
|
|
||||||
|
## 0.9.0 — 2026-05-28
|
||||||
|
|
||||||
|
**Minor — no schema migration; reuses v0.8.0 columns and existing
|
||||||
|
SMTP.** This release ships the admin user-management surface at
|
||||||
|
`/admin/users` and the new-beta-request notifications that feed
|
||||||
|
it (roadmap item #7, SPEC §6.1 / §15 / §17). The two halves
|
||||||
|
compose: admins receive an inbox + email signal the moment a
|
||||||
|
pending user submits the v0.8.0 capture form; clicking through
|
||||||
|
lands on the page where they Grant or Revoke access.
|
||||||
|
|
||||||
|
The page consumes the v0.8.0 schema columns
|
||||||
|
(`permission_state`, `first_name`, `last_name`,
|
||||||
|
`beta_request_reason`, `permission_decided_by`,
|
||||||
|
`permission_decided_at`) without adding new ones — migration
|
||||||
|
slot 016 stays reserved for a future release. The single new
|
||||||
|
write endpoint, `POST /api/admin/users/<id>/permission`,
|
||||||
|
replaces v0.8.0's documented manual `UPDATE users SET
|
||||||
|
permission_state='granted'` gesture with an audited UI flip.
|
||||||
|
|
||||||
|
Admin notifications ride the existing §15 chokepoint — the
|
||||||
|
`new_beta_request` event_kind is added to the enum with
|
||||||
|
category `admin-actionable`, fan-out is to every owner / admin
|
||||||
|
minus the requester themselves, and the §15.4 email dispatch
|
||||||
|
only reaches recipients whose `email_admin_actionable` toggle
|
||||||
|
is on (the default for owners + admins). The event is the
|
||||||
|
framework's first non-RFC-scoped notification — `rfc_slug` is
|
||||||
|
NULL and the email deep-link points `/admin/users` instead of
|
||||||
|
`/rfc/<slug>`.
|
||||||
|
|
||||||
|
Decision on `/admin/allowlist`: the surface stays as a
|
||||||
|
sibling sub-tab, not folded into `/admin/users`. The two have
|
||||||
|
different keys (allowlist by email pre-sign-up, user list by
|
||||||
|
user_id post-sign-up) and a union row would be confusing
|
||||||
|
rather than clarifying. The allowlist's fast-path-bypass role
|
||||||
|
from v0.8.0 is unchanged; retiring the table outright is
|
||||||
|
deferred to a later session (see §19.2).
|
||||||
|
|
||||||
|
### Upgrade steps (from 0.10.0)
|
||||||
|
|
||||||
|
1. **MAY** rebuild the frontend. The build is the same shape
|
||||||
|
as v0.10.0; the lockfile pins to `0.9.0` so `npm install`
|
||||||
|
in `frontend/` updates it cleanly. No new env vars on the
|
||||||
|
frontend; the existing `VITE_APP_NAME` requirement carries
|
||||||
|
over.
|
||||||
|
2. **MAY** restart the backend. No schema migration runs in
|
||||||
|
this release — every v0.9.0 column is from
|
||||||
|
`014_beta_access.sql` (v0.8.0). Restart only if you want
|
||||||
|
the new endpoints registered in this version's binary.
|
||||||
|
3. **SHOULD** verify SMTP can reach the deployment's admin
|
||||||
|
inbox before the first pending user submits the capture
|
||||||
|
form. v0.9.0 reuses the v0.7.0 SMTP configuration
|
||||||
|
(`SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`,
|
||||||
|
`SMTP_STARTTLS`, `EMAIL_FROM`, `EMAIL_FROM_NAME`); a
|
||||||
|
misconfigured deployment will still write the inbox row,
|
||||||
|
but the admin won't hear about it through email. No new
|
||||||
|
env var is required — the framework reuses the existing
|
||||||
|
admin-user list (role IN ('owner', 'admin')) as the
|
||||||
|
notification recipients.
|
||||||
|
4. **SHOULD** announce the surface to existing admins. Wording
|
||||||
|
suggestion: "There's now a Users tab in /admin where you can
|
||||||
|
Grant or Revoke beta access — and you'll get an email +
|
||||||
|
inbox row when a fresh request lands. The manual SQL gesture
|
||||||
|
from v0.8.0 still works but is no longer the documented
|
||||||
|
path."
|
||||||
|
5. **MAY** drain the existing pending queue through the new UI.
|
||||||
|
If your deployment carried pending users through the v0.8.0
|
||||||
|
manual-UPDATE window, the Pending bucket on `/admin/users`
|
||||||
|
surfaces all of them with their captured profile. Granting
|
||||||
|
from the UI stamps `permission_decided_by` /
|
||||||
|
`permission_decided_at`, which any prior manual UPDATE
|
||||||
|
gestures may have left NULL (no harm done — the v0.8.0
|
||||||
|
contract didn't require those stamps).
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **`POST /api/admin/users/<id>/permission`** — body
|
||||||
|
`{state: 'pending'|'granted'|'revoked'}`. Flips the column,
|
||||||
|
stamps `permission_decided_by` + `permission_decided_at`,
|
||||||
|
and writes a `permission_events` row with event_kind in
|
||||||
|
`{permission_granted, permission_revoked,
|
||||||
|
permission_repended}`. Refuses 422 on self-flip (symmetric
|
||||||
|
to `set_mute` / `set_role` self-action refusals) and 422
|
||||||
|
on invalid state. Returns `{ok, permission_state, changed}`
|
||||||
|
where `changed=false` indicates a no-op (the requested
|
||||||
|
state already matched).
|
||||||
|
- **Widened `GET /api/admin/users` response** carrying
|
||||||
|
`permission_state`, `first_name`, `last_name`,
|
||||||
|
`beta_request_reason`, `created_at`,
|
||||||
|
`permission_decided_at`, plus joined
|
||||||
|
`permission_decided_by_login` /
|
||||||
|
`permission_decided_by_display`. Sort order surfaces
|
||||||
|
`pending` rows first (the admin queue), then `granted`,
|
||||||
|
then `revoked`; within a bucket, owners precede admins
|
||||||
|
precede contributors, with recency as the tiebreaker.
|
||||||
|
- **`new_beta_request` event_kind** in the §15.1 enum. Fired
|
||||||
|
by `notify.fan_out_new_beta_request` from the first
|
||||||
|
successful `POST /api/auth/me/beta-request` (re-submits
|
||||||
|
from the same pending user don't re-fire — the row's
|
||||||
|
first-time-complete check guards against carpet-bombing).
|
||||||
|
Recipients: every owner + admin minus the requester
|
||||||
|
themselves. Category: `admin-actionable`. Deep-link:
|
||||||
|
`/admin/users`. Actor: the requester per §15.9.
|
||||||
|
- **`/admin/users` page enhancements** in `Admin.jsx`. The
|
||||||
|
Users tab gains a state filter chip row (All / Pending /
|
||||||
|
Granted / Revoked with counts), a Grant / Revoke control
|
||||||
|
column, a Permission state badge, and an expandable
|
||||||
|
"why they want access" row beneath each pending user.
|
||||||
|
Sign-up timestamp surfaces in a new column.
|
||||||
|
- **`frontend/src/api.js#setUserPermission`** — client for
|
||||||
|
the new endpoint, neighboring `setUserMute` and
|
||||||
|
`setUserRole`.
|
||||||
|
- **`/beta-pending` copy update** in `BetaPending.jsx`. The
|
||||||
|
"your request is in review" page now honestly references
|
||||||
|
the admin-email signal v0.9.0 ships and admits the
|
||||||
|
framework does not commit to an SLA — turnaround depends
|
||||||
|
on operator availability, and the deployment operator is
|
||||||
|
the right person to ask if a wait runs long. No
|
||||||
|
deployment-specific text is baked in; per-deployment copy
|
||||||
|
lives in §13 of the deployment's repo, not in the
|
||||||
|
framework.
|
||||||
|
- **`backend/tests/test_admin_users_vertical.py`** — 10 new
|
||||||
|
tests covering: a beta-request submission fans
|
||||||
|
notifications to every admin + owner (and not to the
|
||||||
|
requester or to contributors); the requester's profile
|
||||||
|
fields land in the notification payload; re-submitting
|
||||||
|
the capture form does not re-fan; the `admin-actionable`
|
||||||
|
category mapping is wired; the `/api/admin/users`
|
||||||
|
listing carries the v0.8.0 columns with `pending` rows
|
||||||
|
sorted first; the permission-flip endpoint promotes
|
||||||
|
pending → granted with the right audit shape; the
|
||||||
|
endpoint promotes granted → revoked; the endpoint
|
||||||
|
refuses self-flip with 422; the endpoint refuses
|
||||||
|
non-admin callers with 403 and anonymous callers with
|
||||||
|
401; the endpoint refuses invalid states with 422; a
|
||||||
|
state-already-matches flip returns `changed=false`
|
||||||
|
without writing an audit row.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **`backend/app/api.py`** — the `POST /api/auth/me/beta-request`
|
||||||
|
handler now calls `notify.fan_out_new_beta_request` after the
|
||||||
|
capture UPDATE lands, gated on the row not previously having
|
||||||
|
all three profile fields populated (so re-submits don't
|
||||||
|
re-fan).
|
||||||
|
- **`backend/app/api_admin.py`** — the `list_users` query joins
|
||||||
|
against `users d ON d.id = u.permission_decided_by` for the
|
||||||
|
deciding-admin handle. The new `set_permission` endpoint
|
||||||
|
lives alongside `set_role` / `set_mute`.
|
||||||
|
- **`backend/app/notify.py`** — adds
|
||||||
|
`CATEGORY_ADMIN_ACTIONABLE`, the `fan_out_new_beta_request`
|
||||||
|
helper, and the `new_beta_request` arm in `render_summary`.
|
||||||
|
- **`backend/app/email.py`** — the `_EVENT_TO_CATEGORY` map
|
||||||
|
carries `new_beta_request → admin-actionable`, and
|
||||||
|
`_deep_link` routes framework-scoped admin signals to
|
||||||
|
`/admin/users` instead of `/rfc/<slug>`.
|
||||||
|
- **`frontend/src/components/Admin.jsx`** — the `UsersTab`
|
||||||
|
component is rewritten with state filter chips, a per-row
|
||||||
|
`UserRow` + `PermissionCell` decomposition, and a
|
||||||
|
`useMemo`-cached counts table. The pending-row reason
|
||||||
|
blockquote renders as a secondary `<tr>` beneath the user
|
||||||
|
row when present.
|
||||||
|
- **`frontend/src/components/BetaPending.jsx`** — pending-state
|
||||||
|
copy revised to reference the admin email signal honestly.
|
||||||
|
- **`frontend/src/App.css`** — new admin-chip / permission-badge
|
||||||
|
/ user-row-reason rules.
|
||||||
|
- **`SPEC.md`** §6 opening, §15.1 event-kinds enum, §17 admin
|
||||||
|
endpoints, §19.2 candidates list — per §19.3 rule-2.
|
||||||
|
- **`VERSION`** → `0.9.0`. `frontend/package.json#version` and
|
||||||
|
the lockfile mirror.
|
||||||
|
|
||||||
|
### Environment variables
|
||||||
|
|
||||||
|
None new. The release reuses the v0.7.0 SMTP configuration
|
||||||
|
(`SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`,
|
||||||
|
`SMTP_STARTTLS`, `EMAIL_FROM`, `EMAIL_FROM_NAME`,
|
||||||
|
`EMAIL_ENABLED`, `EMAIL_BUNDLE_THRESHOLD`) and the existing
|
||||||
|
admin-user list (role IN ('owner', 'admin')) as the
|
||||||
|
notification recipients. A deployment whose SMTP is misconfigured
|
||||||
|
will still see the inbox rows; only the email channel is muted.
|
||||||
|
|
||||||
|
### Deferred to later releases
|
||||||
|
|
||||||
|
- **Grant / revoke notification to the user** — symmetric
|
||||||
|
signal: when an admin grants or revokes access, fire a
|
||||||
|
`personal-direct` notification (event_kind
|
||||||
|
`permission_change_affecting_me`, already in the §15.1
|
||||||
|
enum) so the affected user sees the state change in
|
||||||
|
their inbox and email. v0.9.0 audits the gesture in
|
||||||
|
`permission_events` but does not yet escape the
|
||||||
|
app-internal log to the user. See §19.2.
|
||||||
|
- **Decline-with-reason on revoke** — the current Revoke
|
||||||
|
gesture takes only a confirmation; a follow-up release
|
||||||
|
may capture a free-text reason in
|
||||||
|
`permission_events.details`. See §19.2.
|
||||||
|
- **Allowlist deprecation** — the `/admin/allowlist` sub-tab
|
||||||
|
stays in place in v0.9.0 (the two surfaces have different
|
||||||
|
keys and a union row would be confusing). Retiring the
|
||||||
|
table outright is deferred to a session that can re-read
|
||||||
|
the post-v0.9.0 operator experience and decide whether
|
||||||
|
the fast-path-bypass role is still pulling weight. See
|
||||||
|
§19.2.
|
||||||
|
|
||||||
## 0.8.0 — 2026-05-28
|
## 0.8.0 — 2026-05-28
|
||||||
|
|
||||||
**Minor — schema migration required; admission semantics shift.**
|
**Minor — schema migration required; admission semantics shift.**
|
||||||
|
|||||||
@@ -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
|
on first write and updated on every change. Absence of a row means
|
||||||
"no choice yet" — the banner shows. Anonymous viewers persist their
|
"no choice yet" — the banner shows. Anonymous viewers persist their
|
||||||
choice in `localStorage` only, with no corresponding row here.
|
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
|
**Super-draft scoping.** For rows in `threads` and `changes` where the
|
||||||
entry referenced by `rfc_slug` is in state `super-draft`, `branch_name`
|
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
|
separate "forgot passcode" flow. The user can remove the passcode
|
||||||
at any time from the §6.2 sign-in settings tab, returning to
|
at any time from the §6.2 sign-in settings tab, returning to
|
||||||
OTC-only.
|
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
|
callback remains functional during the v0.7.0 window, with a
|
||||||
small "Sign in with Gitea (fallback)" link on `/login` so users
|
small "Sign in with Gitea (fallback)" link on `/login` so users
|
||||||
with active OAuth sessions or older invite paths still have a
|
with active OAuth sessions or older invite paths still have a
|
||||||
@@ -398,9 +425,23 @@ endpoints accept the user. The capture-fields step (first name,
|
|||||||
last name, free-text "why I should be included in the beta") feeds
|
last name, free-text "why I should be included in the beta") feeds
|
||||||
the admin's triage queue. The `allowed_emails` table stays in the
|
the admin's triage queue. The `allowed_emails` table stays in the
|
||||||
schema as a fast-path bypass — the v0.3.0 admin UI still manages
|
schema as a fast-path bypass — the v0.3.0 admin UI still manages
|
||||||
it — but the OTC request path no longer consults it. v0.9.0's
|
it — but the OTC request path no longer consults it. v0.9.0
|
||||||
admin user-management page replaces the allowlist UI and ships the
|
(roadmap item #7) shipped the user-management surface that
|
||||||
pending-queue triage surface.
|
consumes the `permission_state` column: `/admin/users` carries
|
||||||
|
every user with their state, profile, and sign-up reason, plus
|
||||||
|
Grant / Revoke controls that flip the column and write a
|
||||||
|
`permission_events` audit row. The capture-form submission also
|
||||||
|
fans a `new_beta_request` notification out to every admin/owner
|
||||||
|
through the §15 substrate, so the queue surfaces in the inbox +
|
||||||
|
email channels admins already have.
|
||||||
|
|
||||||
|
The `/admin/allowlist` sub-tab stays in place alongside
|
||||||
|
`/admin/users` rather than merging: the two surfaces have
|
||||||
|
different keys (allowlist by email pre-sign-up, user list by
|
||||||
|
user_id post-sign-up) and a union row would be confusing rather
|
||||||
|
than clarifying. The allowlist's role narrowed to "fast-path
|
||||||
|
bypass for known-good emails" with the v0.8.0 admission shift;
|
||||||
|
v0.9.0 retains that role unchanged.
|
||||||
|
|
||||||
### 6.1 Four roles, each a strict superset of the one below
|
### 6.1 Four roles, each a strict superset of the one below
|
||||||
|
|
||||||
@@ -2286,8 +2327,14 @@ signal taxonomy this section commits to. The starting set:
|
|||||||
`graduation_complete`, `graduation_rolled_back`, `rfc_withdrawn`,
|
`graduation_complete`, `graduation_rolled_back`, `rfc_withdrawn`,
|
||||||
`rfc_reopened`, `claim_opened`, `claim_merged`,
|
`rfc_reopened`, `claim_opened`, `claim_merged`,
|
||||||
`permission_change_affecting_me`, `app_wide_mute_set`,
|
`permission_change_affecting_me`, `app_wide_mute_set`,
|
||||||
`app_wide_mute_lifted`, `digest_emitted`. The enum is extensible; the
|
`app_wide_mute_lifted`, `new_beta_request`, `digest_emitted`.
|
||||||
build session adjusts as new gestures are wired in.
|
The enum is extensible; the build session adjusts as new gestures
|
||||||
|
are wired in. The `new_beta_request` event (v0.9.0, roadmap item
|
||||||
|
#7) is framework-scoped rather than RFC-scoped — the row's
|
||||||
|
`rfc_slug` is NULL and the deep-link points `/admin/users`
|
||||||
|
instead of `/rfc/<slug>` — but otherwise rides the standard
|
||||||
|
fan-out chokepoint with category `admin-actionable` so the §15.4
|
||||||
|
email gate only reaches owners/admins.
|
||||||
|
|
||||||
### 15.2 The inbox
|
### 15.2 The inbox
|
||||||
|
|
||||||
@@ -2741,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
|
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 —
|
v0.3.0 admission gate); v0.8.0 (item #6) removed that check —
|
||||||
admission moved to `permission_state` on the freshly-provisioned
|
admission moved to `permission_state` on the freshly-provisioned
|
||||||
`users` row, asserted at the contributor gate. Per §19.2's
|
`users` row, asserted at the contributor gate. v0.12.0 (item #10)
|
||||||
expected next session, this endpoint is the lead-up to the
|
gates this endpoint behind a CloudFlare Turnstile siteverify call:
|
||||||
Cloudflare-Turnstile abuse-mitigation overlay.
|
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
|
- `POST /auth/otc/verify` — unauthenticated. Body carries `email` and
|
||||||
`code`. Validates the bcrypt hash against the most-recent unconsumed
|
`code`. Validates the bcrypt hash against the most-recent unconsumed
|
||||||
non-expired row for the email, marks the row consumed, provisions
|
non-expired row for the email, marks the row consumed, provisions
|
||||||
@@ -2803,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
|
return HTTP 400 with a generic message; the no-passcode-set
|
||||||
failure also collapses to 400 so the response does not enumerate
|
failure also collapses to 400 so the response does not enumerate
|
||||||
account state. v0.10.0.
|
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,
|
- `GET /api/rfcs` — list entries with state, id, title, slug, repo,
|
||||||
owners, last_active_at, has_open_prs, starred-by-me. Supports
|
owners, last_active_at, has_open_prs, starred-by-me. Supports
|
||||||
search, sort, filter chips, and the `unclaimed` predicate.
|
search, sort, filter chips, and the `unclaimed` predicate.
|
||||||
@@ -2945,8 +3024,15 @@ The follow-up session will refine this. A minimal starting set:
|
|||||||
- `POST /api/rfcs/<slug>/prs/<pr_number>/withdraw` — withdraw per §10.8.
|
- `POST /api/rfcs/<slug>/prs/<pr_number>/withdraw` — withdraw per §10.8.
|
||||||
- `POST /api/rfcs/<slug>/prs/<pr_number>/resolution-branch` — cut a
|
- `POST /api/rfcs/<slug>/prs/<pr_number>/resolution-branch` — cut a
|
||||||
fresh resolution branch and replay per §10.9.
|
fresh resolution branch and replay per §10.9.
|
||||||
- `GET /api/admin/users` — list users with role and write-mute state,
|
- `GET /api/admin/users` — list users for the §6 / Slice 7 admin
|
||||||
for the §6 / Slice 7 admin surface.
|
surface. v0.9.0 (roadmap item #7) widened the payload to carry
|
||||||
|
`permission_state`, `first_name`, `last_name`, `beta_request_reason`,
|
||||||
|
`created_at`, `permission_decided_at`, and the joined
|
||||||
|
`permission_decided_by_login` / `permission_decided_by_display`
|
||||||
|
for the user-management page. Sort order surfaces `pending` rows
|
||||||
|
first (the daily admin queue), then `granted`, then `revoked`;
|
||||||
|
within a bucket, owners precede admins precede contributors,
|
||||||
|
with recency as the tiebreaker.
|
||||||
- `POST /api/admin/users/<id>/role` — set role. Only owners may grant
|
- `POST /api/admin/users/<id>/role` — set role. Only owners may grant
|
||||||
or revoke `owner`; admins may flip contributor ↔ admin freely. An
|
or revoke `owner`; admins may flip contributor ↔ admin freely. An
|
||||||
owner-self-demotion is refused on this endpoint; owner succession
|
owner-self-demotion is refused on this endpoint; owner succession
|
||||||
@@ -2955,6 +3041,16 @@ The follow-up session will refine this. A minimal starting set:
|
|||||||
write-mute (not the §15.8 notification mutes). Refused on owners
|
write-mute (not the §15.8 notification mutes). Refused on owners
|
||||||
and admins — for them, the role-change channel is the right
|
and admins — for them, the role-change channel is the right
|
||||||
refusal. Writes a `permission_events` row.
|
refusal. Writes a `permission_events` row.
|
||||||
|
- `POST /api/admin/users/<id>/permission` — v0.9.0 (roadmap item #7).
|
||||||
|
Flip `permission_state` between `pending`, `granted`, and `revoked`.
|
||||||
|
Stamps `permission_decided_by` + `permission_decided_at` on the
|
||||||
|
row and writes a `permission_events` row with event_kind in
|
||||||
|
`{permission_granted, permission_revoked, permission_repended}`.
|
||||||
|
Refuses with 422 if the admin tries to flip their own row
|
||||||
|
(symmetric to the `set_mute` / `set_role` self-action refusals).
|
||||||
|
v0.8.0 shipped the column shape with no admin UI — operators ran
|
||||||
|
a manual `UPDATE users` to grant access; v0.9.0 retires the
|
||||||
|
manual gesture.
|
||||||
- `GET /api/admin/audit` — paged read of the `actions` log with
|
- `GET /api/admin/audit` — paged read of the `actions` log with
|
||||||
filters `action_kind`, `actor_user_id`, `rfc_slug`, plus `before_id`
|
filters `action_kind`, `actor_user_id`, `rfc_slug`, plus `before_id`
|
||||||
for the page boundary. Returns the joined actor login/display so
|
for the page boundary. Returns the joined actor login/display so
|
||||||
@@ -3794,56 +3890,64 @@ on every other page until an admin grants. v0.8.0 (roadmap item
|
|||||||
Candidates surfaced during v0.8.0 (open beta-access request flow,
|
Candidates surfaced during v0.8.0 (open beta-access request flow,
|
||||||
§6.1 / §14.1, item #6):
|
§6.1 / §14.1, item #6):
|
||||||
|
|
||||||
- **Admin user-management page** (`/admin/users`). *Surfaced by
|
- **Admin user-management page** (`/admin/users`). *Shipped in
|
||||||
v0.8.0 — the release ships the pending-state column but no
|
v0.9.0 (roadmap item #7).* The listing surfaces every user with
|
||||||
admin UI to triage it.* For v0.8.0, the admin gesture is an
|
permission_state, profile fields, sign-up reason, and a Grant /
|
||||||
out-of-band `UPDATE users SET permission_state='granted' WHERE
|
Revoke control set; the `POST /api/admin/users/<id>/permission`
|
||||||
email=?`. v0.9.0 (roadmap item #7) is expected to ship the
|
endpoint flips the column and writes a `permission_events` row.
|
||||||
triage queue: a list of `permission_state='pending'` rows
|
v0.9.0 left the `/admin/allowlist` sub-tab in place rather than
|
||||||
sorted by `created_at`, each showing the captured first /
|
merging (see allowlist deprecation below). The grant/revoke
|
||||||
last / why fields, with Grant and Revoke buttons that stamp
|
notify-the-user surface is deferred (see the new candidate
|
||||||
`permission_decided_by` and `permission_decided_at` (schema
|
below).
|
||||||
slots already in place per `migrations/014_beta_access.sql`).
|
- **Allowlist deprecation.** *Decision deferred past v0.9.0.*
|
||||||
The page composes naturally with the existing `/admin/allowlist`
|
v0.9.0 considered merging `/admin/allowlist` into the new
|
||||||
surface — both are admission-control gestures — so v0.9.0 may
|
`/admin/users` page but kept the surface as a sibling sub-tab:
|
||||||
fold the allowlist UI into this page (see next candidate).
|
the two have different keys (allowlist by email pre-sign-up,
|
||||||
Decision points: do grants / revokes also fire email
|
user list by user_id post-sign-up) and a union row would be
|
||||||
notifications to the user (probably yes — the notifications
|
confusing rather than clarifying. The fast-path-bypass role
|
||||||
layer from v0.6.0 has the personal-direct channel for it); is
|
the allowlist has carried since v0.8.0 stays intact; the
|
||||||
there a "decline with reason" gesture that surfaces in the
|
cutover to retire the table outright is a later session.
|
||||||
user's view (probably yes — symmetric with §9.3's
|
Decision points unchanged from v0.8.0: drop the table outright
|
||||||
proposal-decline shape); does the page support bulk grants
|
(a schema migration) or leave it as a non-functional surface
|
||||||
(probably no for v0.9.0 — the queue volume is operator-scale,
|
and remove only the UI (a frontend-only change); how to handle
|
||||||
not user-scale). Earns its session as the v0.9.0 design pass.
|
existing `allowed_emails` rows at the cutover (probably: walk
|
||||||
- **Allowlist deprecation.** *Surfaced by v0.8.0 — the
|
them into the pending queue with `permission_state='granted'`
|
||||||
`allowed_emails` table stays in the schema but the OTC
|
for any matching `users` row, leave unmatched rows as a no-op
|
||||||
request path no longer consults it.* v0.8.0 left the table
|
since v0.8.0 doesn't consult them anymore). Earns its session
|
||||||
and the `/admin/allowlist` UI in place as a fast-path bypass
|
once the v0.9.0 admin queue has run long enough to confirm the
|
||||||
for deployments that want to pre-mark known-good emails (the
|
allowlist's bypass role is no longer pulling weight.
|
||||||
v0.8.0 contract is that those emails still go through the
|
- **Admin notification on new beta request.** *Shipped in v0.9.0
|
||||||
pending-grant flow; the table itself is no longer a gate). A
|
(roadmap item #7).* The `POST /api/auth/me/beta-request`
|
||||||
future release retires both — probably v0.9.0 alongside the
|
handler now calls `notify.fan_out_new_beta_request`, which
|
||||||
admin user-management page, since the two surfaces are
|
fans a `new_beta_request` event (category `admin-actionable`,
|
||||||
functionally redundant once the pending queue lands.
|
rfc_slug NULL) out to every owner / admin. The §15 chokepoint
|
||||||
Decision points: drop the table outright (a schema migration)
|
handles the SSE broadcast and the §15.4 email dispatch; the
|
||||||
or leave it as a non-functional surface and remove only the
|
email reaches only recipients whose `email_admin_actionable`
|
||||||
UI (a frontend-only change); how to handle existing
|
toggle is on (the default for owners + admins).
|
||||||
`allowed_emails` rows at the cutover (probably: walk them
|
- **Grant / revoke notification to the user.** *Surfaced by
|
||||||
into the pending queue with `permission_state='granted'` for
|
v0.9.0.* The new flip endpoint stamps `permission_decided_by` +
|
||||||
any matching `users` row, leave unmatched rows as a no-op
|
writes a `permission_events` row but does not yet signal the
|
||||||
since v0.8.0 doesn't consult them anymore). Earns its
|
affected user that their state changed. A future release could
|
||||||
session as a sub-topic of the v0.9.0 admin user-management
|
fire a `personal-direct` notification (event_kind
|
||||||
pass.
|
`permission_change_affecting_me`, already in the §15.1 enum) so
|
||||||
- **Admin notification on new beta request.** *Surfaced by
|
a granted user sees "Your beta-access request was approved" in
|
||||||
v0.8.0 — the capture endpoint writes to the row but does
|
their inbox and email, and a revoked user sees a parallel
|
||||||
not signal admins.* v0.9.0 candidate (item #7 again): when a
|
refusal notice. Decision points: does revocation include a
|
||||||
`POST /api/auth/me/beta-request` lands, fire an `admin-actionable`
|
reason field (probably yes — symmetric with §9.3's decline
|
||||||
notification (per §15.4's category set) to every owner / admin
|
comment); does grant carry a welcome message (probably no —
|
||||||
so the queue doesn't go stale. The §15 infrastructure already
|
the existing welcome surfaces are sufficient); does the
|
||||||
supports the category; the open question is whether the
|
notification escape the §15.8 mute path (probably yes — it's
|
||||||
notification is per-request (one email per submission) or
|
a personal-direct admission state change). Earns its session
|
||||||
digested (a daily summary). Earns its session alongside the
|
as a follow-up to the v0.9.0 page.
|
||||||
admin user-management page.
|
- **Decline-with-reason on permission revoke.** *Surfaced by
|
||||||
|
v0.9.0.* The current Revoke gesture takes only a confirmation;
|
||||||
|
there is no audit-visible reason captured. A future release
|
||||||
|
could add a free-text reason input that lands in the
|
||||||
|
`permission_events.details` JSON column (no schema change
|
||||||
|
needed — the column is already JSON-shaped). This is the
|
||||||
|
symmetric companion to the §9.3 proposal-decline contract.
|
||||||
|
Earns its session alongside the grant/revoke notification
|
||||||
|
candidate above.
|
||||||
- **Removing the Gitea OAuth fallback.** *Surfaced by v0.7.0.*
|
- **Removing the Gitea OAuth fallback.** *Surfaced by v0.7.0.*
|
||||||
v0.7.0 keeps `/auth/callback` functional and links to it as a
|
v0.7.0 keeps `/auth/callback` functional and links to it as a
|
||||||
"Sign in with Gitea (fallback)" affordance on the new `/login`
|
"Sign in with Gitea (fallback)" affordance on the new `/login`
|
||||||
@@ -3858,37 +3962,77 @@ Candidates surfaced during v0.8.0 (open beta-access request flow,
|
|||||||
message), and whether the `/auth/login` and `/auth/callback`
|
message), and whether the `/auth/login` and `/auth/callback`
|
||||||
routes get a tombstone redirect to `/login` or just 404. Earns
|
routes get a tombstone redirect to `/login` or just 404. Earns
|
||||||
its session once the OTC adoption curve flattens.
|
its session once the OTC adoption curve flattens.
|
||||||
- **Device trust (30-day skip).** *Surfaced by v0.7.0 — the
|
- **Device trust (30-day skip).** *Settled in v0.11.0 (roadmap
|
||||||
signed-in cookie already lasts 30 days via SessionMiddleware,
|
item #9). The shape: a distinct `rfc_device_trust` cookie
|
||||||
but every sign-in still requires a fresh OTC or passcode.* The
|
(HttpOnly + Secure + SameSite=Lax + 30-day Max-Age) carrying a
|
||||||
roadmap item-#9 candidate adds a "trust this device" affordance
|
server-issued opaque token, keyed against a `device_trust` table
|
||||||
on the verify step that issues a longer-lived rotating token,
|
whose rows store the bcrypt hash. `POST /auth/device-trust/start`
|
||||||
so returning visitors on the same device skip both the OTC and
|
resolves a presented cookie at next visit. A
|
||||||
the passcode step. The shape question is whether the trust is a
|
`/settings/notifications` "Trusted devices" section lists active
|
||||||
signed cookie distinct from the session, a row in a `device_trust`
|
rows with per-row + bulk revoke. The trust outlives a sign-out
|
||||||
table keyed by a random device-id, or a property of the session
|
(sign-out clears the session cookie, not the device-trust
|
||||||
itself; and whether the trust survives password-equivalent events
|
cookie) and is not affected by passcode set/change/clear — the
|
||||||
— v0.10.0's passcode-change and passcode-clear gestures are the
|
next two items below carry the remaining open questions.*
|
||||||
v1 instances — or only survives explicit logout. Earns its
|
- **Cross-device session revocation surface.** v0.11.0's
|
||||||
session as the v0.11.0 design pass.
|
`/settings/notifications → Trusted devices` revokes the
|
||||||
- **Cloudflare Turnstile (or equivalent) on `/auth/otc/request`.**
|
long-lived device-trust grants. What it does NOT revoke is an
|
||||||
*Surfaced by v0.7.0 — the endpoint is now the new abuse hot
|
active session cookie sitting in another browser, or the
|
||||||
path.* Per-email cooldown stops the trivial loop; what it
|
v0.10.0 passcode-failure-counter shape, or a stale
|
||||||
doesn't stop is a distributed scrape that fans out across a
|
password-equivalent that some future release ships. The natural
|
||||||
large invitee list to harvest the "this email is admitted vs.
|
next step is a single "active sessions and devices" surface
|
||||||
this email is not" signal indirectly (timing differences, SMTP
|
that lists everything currently authenticating as this user —
|
||||||
bounce-rate observation). The roadmap item-#10 candidate gates
|
device-trust rows + active session cookies (if/when the
|
||||||
the request endpoint behind a one-step browser-side challenge
|
framework moves to server-side sessions) + future credential
|
||||||
before the bcrypt hash + SMTP send. Open questions: which
|
shapes — and lets the user kill any of them with one gesture.
|
||||||
provider (Turnstile is the default since it's free and
|
Earns its session when a second cross-cutting concern lands
|
||||||
privacy-respecting; hCaptcha and reCAPTCHA are also viable);
|
(the most likely first trigger: future Yubikey / WebAuthn
|
||||||
how the deployment configures it (`TURNSTILE_SITE_KEY` +
|
support, which surfaces another credential to revoke).
|
||||||
`TURNSTILE_SECRET_KEY` env vars, gated by `if
|
- **Password-equivalent change invalidates device trust.** v0.11.0
|
||||||
config.turnstile_site_key:` at the handler so existing
|
intentionally leaves device-trust rows live across a passcode
|
||||||
deployments don't break); whether the verify endpoint also
|
set / change / clear. The argument is structural: the user has
|
||||||
gets a challenge (probably yes for parity); and how the test
|
the v0.10.0 lockout, the v0.11.0 per-device revoke list, and a
|
||||||
harness mocks the challenge. Earns its session as the v0.12.0
|
fresh sign-in path via OTC, so the cookie is not a high-value
|
||||||
design pass.
|
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 /
|
Candidates surfaced during v0.10.0 (user-set passcodes, §6.2 /
|
||||||
roadmap item #8):
|
roadmap item #8):
|
||||||
|
|||||||
@@ -92,3 +92,21 @@ OTC_TTL_MINUTES=10
|
|||||||
# loud-failure shape so the abuse path is visible). Set to 0 to
|
# loud-failure shape so the abuse path is visible). Set to 0 to
|
||||||
# disable the cooldown — useful for tests but never in production.
|
# disable the cooldown — useful for tests but never in production.
|
||||||
OTC_REQUEST_COOLDOWN_SECONDS=60
|
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
|
||||||
|
|||||||
@@ -22,15 +22,18 @@ from . import (
|
|||||||
api_branches,
|
api_branches,
|
||||||
api_discussion,
|
api_discussion,
|
||||||
api_graduation,
|
api_graduation,
|
||||||
|
api_invitations,
|
||||||
api_notifications,
|
api_notifications,
|
||||||
api_prs,
|
api_prs,
|
||||||
auth,
|
auth,
|
||||||
db,
|
db,
|
||||||
|
device_trust as device_trust_mod,
|
||||||
docs as docs_mod,
|
docs as docs_mod,
|
||||||
entry as entry_mod,
|
entry as entry_mod,
|
||||||
cache,
|
cache,
|
||||||
funder,
|
funder,
|
||||||
health,
|
health,
|
||||||
|
notify,
|
||||||
philosophy,
|
philosophy,
|
||||||
providers as providers_mod,
|
providers as providers_mod,
|
||||||
)
|
)
|
||||||
@@ -100,6 +103,12 @@ def make_router(
|
|||||||
# Contribution still requires a PR (api_prs above); this surface
|
# Contribution still requires a PR (api_prs above); this surface
|
||||||
# is for discussion that does not yet warrant a branch.
|
# is for discussion that does not yet warrant a branch.
|
||||||
router.include_router(api_discussion.make_router())
|
router.include_router(api_discussion.make_router())
|
||||||
|
# v0.16.0 (roadmap item #12): owner-only invite for per-RFC
|
||||||
|
# contribution + discussion. The RFC's owner can invite specific
|
||||||
|
# users by email to either open PRs or join the discussion; non-
|
||||||
|
# invited users keep read access but cannot write (v0.6.0
|
||||||
|
# contract extended to per-RFC scope).
|
||||||
|
router.include_router(api_invitations.make_router())
|
||||||
|
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
# §17: /api/health — unauthenticated post-flight probe.
|
# §17: /api/health — unauthenticated post-flight probe.
|
||||||
@@ -237,8 +246,82 @@ def make_router(
|
|||||||
user.user_id,
|
user.user_id,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
# v0.9.0 (roadmap item #7): notify every admin/owner of the
|
||||||
|
# fresh request. Only the first submission is the
|
||||||
|
# "newly-pending" gesture — re-submits from the same user
|
||||||
|
# would otherwise carpet the admin inbox. We fire only when
|
||||||
|
# this is the row's first time getting all three fields
|
||||||
|
# populated (the prior row carried at least one NULL).
|
||||||
|
prior = row # captured before the UPDATE above
|
||||||
|
was_already_complete = bool(
|
||||||
|
prior["first_name"] and prior["last_name"] and prior["beta_request_reason"]
|
||||||
|
)
|
||||||
|
if not was_already_complete:
|
||||||
|
notify.fan_out_new_beta_request(requester_user_id=user.user_id)
|
||||||
return {"ok": True}
|
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
|
# §7: the catalog
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
|
|||||||
+160
-4
@@ -50,6 +50,16 @@ class MuteBody(BaseModel):
|
|||||||
muted: bool
|
muted: bool
|
||||||
|
|
||||||
|
|
||||||
|
class PermissionStateBody(BaseModel):
|
||||||
|
# v0.9.0: the admin flip from the user-management page (roadmap
|
||||||
|
# item #7). `pending` is not surfaceable from the admin UI —
|
||||||
|
# only the OTC verify path lands a row in `pending` — but we
|
||||||
|
# accept it in the pattern in case a future restore-to-queue
|
||||||
|
# gesture wants to re-pend a granted user; today the UI only
|
||||||
|
# exposes `granted` and `revoked`.
|
||||||
|
state: str = Field(pattern="^(pending|granted|revoked)$")
|
||||||
|
|
||||||
|
|
||||||
class AllowlistAddBody(BaseModel):
|
class AllowlistAddBody(BaseModel):
|
||||||
email: str = Field(min_length=3, max_length=320)
|
email: str = Field(min_length=3, max_length=320)
|
||||||
note: str | None = Field(default=None, max_length=200)
|
note: str | None = Field(default=None, max_length=200)
|
||||||
@@ -68,15 +78,84 @@ def make_router(config: Config) -> APIRouter:
|
|||||||
|
|
||||||
@router.get("/api/admin/users")
|
@router.get("/api/admin/users")
|
||||||
async def list_users(request: Request) -> dict[str, Any]:
|
async def list_users(request: Request) -> dict[str, Any]:
|
||||||
|
"""v0.9.0: the user-management surface (roadmap item #7).
|
||||||
|
|
||||||
|
The listing carries every column the admin queue needs to triage
|
||||||
|
pending beta-access requests alongside the existing role/mute
|
||||||
|
affordances. Sort order surfaces pending requests first (so the
|
||||||
|
admin lands on the inbox shape), then granted, then revoked;
|
||||||
|
within a state, ownership/role and recency are the tiebreakers
|
||||||
|
so the legacy ordering (owner first, then admin, then by name)
|
||||||
|
is preserved inside the granted bucket.
|
||||||
|
|
||||||
|
`permission_decided_by_login` joins the deciding admin row so
|
||||||
|
the UI can render "granted by @ben" without a second round-trip.
|
||||||
|
|
||||||
|
v0.16.0 (roadmap item #12) additive: each user row now carries
|
||||||
|
an `rfc_invitations` array — the per-RFC invitations the user
|
||||||
|
has accepted. This is the "permission-grant requests from
|
||||||
|
invited users" hook the roadmap text calls for: when a user
|
||||||
|
accepts a per-RFC invite and they're not yet platform-granted,
|
||||||
|
the admin sees "here because @ben invited them to <RFC> as
|
||||||
|
<role>" alongside their pending row, informing (not deciding)
|
||||||
|
the platform grant. The two write surfaces remain distinct —
|
||||||
|
the RFC's owner controls per-RFC roles; the admin controls
|
||||||
|
platform-grant state.
|
||||||
|
"""
|
||||||
auth.require_admin(request)
|
auth.require_admin(request)
|
||||||
rows = db.conn().execute(
|
rows = db.conn().execute(
|
||||||
"""
|
"""
|
||||||
SELECT id, gitea_login, display_name, email, role, muted,
|
SELECT u.id, u.gitea_login, u.display_name, u.email, u.role, u.muted,
|
||||||
created_at, last_seen_at
|
u.created_at, u.last_seen_at,
|
||||||
FROM users
|
u.permission_state, u.first_name, u.last_name,
|
||||||
ORDER BY role = 'owner' DESC, role = 'admin' DESC, display_name COLLATE NOCASE
|
u.beta_request_reason,
|
||||||
|
u.permission_decided_by, u.permission_decided_at,
|
||||||
|
d.gitea_login AS decided_by_login,
|
||||||
|
d.display_name AS decided_by_display
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN users d ON d.id = u.permission_decided_by
|
||||||
|
ORDER BY
|
||||||
|
CASE u.permission_state
|
||||||
|
WHEN 'pending' THEN 0
|
||||||
|
WHEN 'granted' THEN 1
|
||||||
|
WHEN 'revoked' THEN 2
|
||||||
|
ELSE 3
|
||||||
|
END,
|
||||||
|
u.role = 'owner' DESC, u.role = 'admin' DESC,
|
||||||
|
COALESCE(u.last_seen_at, u.created_at) DESC,
|
||||||
|
u.display_name COLLATE NOCASE
|
||||||
"""
|
"""
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
# v0.16.0 — per-user accepted per-RFC invitations. One query
|
||||||
|
# over the full set, indexed bucket-by-user-id in Python so
|
||||||
|
# the per-row attachment below is O(1). Empty array for users
|
||||||
|
# who hold no accepted invitations.
|
||||||
|
invitation_rows = db.conn().execute(
|
||||||
|
"""
|
||||||
|
SELECT c.user_id, c.rfc_slug, c.role_in_rfc, c.created_at,
|
||||||
|
r.title AS rfc_title,
|
||||||
|
i.id AS invitation_id, i.invitee_email,
|
||||||
|
ui.gitea_login AS inviter_login,
|
||||||
|
ui.display_name AS inviter_display
|
||||||
|
FROM rfc_collaborators c
|
||||||
|
LEFT JOIN cached_rfcs r ON r.slug = c.rfc_slug
|
||||||
|
LEFT JOIN rfc_invitations i ON i.id = c.invitation_id
|
||||||
|
LEFT JOIN users ui ON ui.id = i.inviter_user_id
|
||||||
|
ORDER BY c.created_at DESC
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
per_user_invites: dict[int, list[dict]] = {}
|
||||||
|
for ir in invitation_rows:
|
||||||
|
per_user_invites.setdefault(ir["user_id"], []).append({
|
||||||
|
"rfc_slug": ir["rfc_slug"],
|
||||||
|
"rfc_title": ir["rfc_title"] or ir["rfc_slug"],
|
||||||
|
"role_in_rfc": ir["role_in_rfc"],
|
||||||
|
"invited_at": ir["created_at"],
|
||||||
|
"invitation_id": ir["invitation_id"],
|
||||||
|
"invitee_email": ir["invitee_email"],
|
||||||
|
"inviter_login": ir["inviter_login"],
|
||||||
|
"inviter_display": ir["inviter_display"],
|
||||||
|
})
|
||||||
return {
|
return {
|
||||||
"items": [
|
"items": [
|
||||||
{
|
{
|
||||||
@@ -88,6 +167,15 @@ def make_router(config: Config) -> APIRouter:
|
|||||||
"muted": bool(r["muted"]),
|
"muted": bool(r["muted"]),
|
||||||
"created_at": r["created_at"],
|
"created_at": r["created_at"],
|
||||||
"last_seen_at": r["last_seen_at"],
|
"last_seen_at": r["last_seen_at"],
|
||||||
|
"permission_state": r["permission_state"] or "granted",
|
||||||
|
"first_name": r["first_name"] or "",
|
||||||
|
"last_name": r["last_name"] or "",
|
||||||
|
"beta_request_reason": r["beta_request_reason"] or "",
|
||||||
|
"permission_decided_at": r["permission_decided_at"],
|
||||||
|
"permission_decided_by_login": r["decided_by_login"],
|
||||||
|
"permission_decided_by_display": r["decided_by_display"],
|
||||||
|
# v0.16.0 additive — never null, always an array.
|
||||||
|
"rfc_invitations": per_user_invites.get(r["id"], []),
|
||||||
}
|
}
|
||||||
for r in rows
|
for r in rows
|
||||||
]
|
]
|
||||||
@@ -136,6 +224,74 @@ def make_router(config: Config) -> APIRouter:
|
|||||||
)
|
)
|
||||||
return {"ok": True, "role": body.role, "changed": True}
|
return {"ok": True, "role": body.role, "changed": True}
|
||||||
|
|
||||||
|
# ----- Permission state (§6.1, v0.9.0 roadmap item #7) -----
|
||||||
|
|
||||||
|
@router.post("/api/admin/users/{user_id}/permission")
|
||||||
|
async def set_permission(user_id: int, body: PermissionStateBody, request: Request) -> dict[str, Any]:
|
||||||
|
"""Flip a user's `permission_state` between pending/granted/revoked.
|
||||||
|
|
||||||
|
v0.8.0 wired the column shape but shipped no admin UI for it —
|
||||||
|
the grant gesture was a manual `UPDATE users` against the DB.
|
||||||
|
v0.9.0 (roadmap item #7) lands the admin user-management page;
|
||||||
|
this endpoint is its single write surface.
|
||||||
|
|
||||||
|
Audit shape: every flip writes a `permission_events` row with
|
||||||
|
event_kind in {'permission_granted', 'permission_revoked',
|
||||||
|
'permission_repended'} so §6.5's log carries the change. The
|
||||||
|
`permission_decided_by` / `permission_decided_at` columns on
|
||||||
|
the user row are co-stamped so the user listing can render
|
||||||
|
"granted by @ben at <date>" without a second join through
|
||||||
|
the audit table.
|
||||||
|
|
||||||
|
Refuses with 422 if the admin tries to flip their own row
|
||||||
|
(no self-grant / self-revoke; symmetric to set_mute's
|
||||||
|
self-mute refusal and set_role's self-downgrade refusal).
|
||||||
|
"""
|
||||||
|
viewer = auth.require_admin(request)
|
||||||
|
target = db.conn().execute(
|
||||||
|
"SELECT id, role, permission_state FROM users WHERE id = ?",
|
||||||
|
(user_id,),
|
||||||
|
).fetchone()
|
||||||
|
if target is None:
|
||||||
|
raise HTTPException(404, "User not found")
|
||||||
|
if target["id"] == viewer.user_id:
|
||||||
|
raise HTTPException(422, "You cannot change your own permission state")
|
||||||
|
|
||||||
|
before = target["permission_state"] or "granted"
|
||||||
|
after = body.state
|
||||||
|
if before == after:
|
||||||
|
return {"ok": True, "permission_state": after, "changed": False}
|
||||||
|
|
||||||
|
db.conn().execute(
|
||||||
|
"""
|
||||||
|
UPDATE users
|
||||||
|
SET permission_state = ?,
|
||||||
|
permission_decided_by = ?,
|
||||||
|
permission_decided_at = datetime('now')
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(after, viewer.user_id, user_id),
|
||||||
|
)
|
||||||
|
event_kind = {
|
||||||
|
"granted": "permission_granted",
|
||||||
|
"revoked": "permission_revoked",
|
||||||
|
"pending": "permission_repended",
|
||||||
|
}[after]
|
||||||
|
db.conn().execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO permission_events
|
||||||
|
(actor_user_id, subject_user_id, event_kind, details)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
viewer.user_id,
|
||||||
|
user_id,
|
||||||
|
event_kind,
|
||||||
|
json.dumps({"before": before, "after": after}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return {"ok": True, "permission_state": after, "changed": True}
|
||||||
|
|
||||||
# ----- Write-mute (§6.2) -----
|
# ----- Write-mute (§6.2) -----
|
||||||
|
|
||||||
@router.post("/api/admin/users/{user_id}/mute")
|
@router.post("/api/admin/users/{user_id}/mute")
|
||||||
|
|||||||
@@ -279,6 +279,15 @@ def make_router(
|
|||||||
@router.post("/api/rfcs/{slug}/branches/main/promote-to-branch")
|
@router.post("/api/rfcs/{slug}/branches/main/promote-to-branch")
|
||||||
async def promote_to_branch(slug: str, body: PromoteToBranchBody, request: Request) -> dict[str, Any]:
|
async def promote_to_branch(slug: str, body: PromoteToBranchBody, request: Request) -> dict[str, Any]:
|
||||||
viewer = auth.require_contributor(request)
|
viewer = auth.require_contributor(request)
|
||||||
|
# v0.16.0 (item #12): cutting a contribute branch is the
|
||||||
|
# PR-shaped write surface gate. A platform-granted user who is
|
||||||
|
# not invited as a per-RFC contributor cannot start work that
|
||||||
|
# only exists to land in a PR.
|
||||||
|
if not auth.can_contribute_to_rfc(viewer, slug):
|
||||||
|
raise HTTPException(
|
||||||
|
403,
|
||||||
|
"This RFC's owner has not invited you to contribute PRs",
|
||||||
|
)
|
||||||
rfc = _require_active_rfc(slug)
|
rfc = _require_active_rfc(slug)
|
||||||
owner, repo = _repo_for(rfc)
|
owner, repo = _repo_for(rfc)
|
||||||
new_branch = (body.branch_name or "").strip()
|
new_branch = (body.branch_name or "").strip()
|
||||||
@@ -331,6 +340,14 @@ def make_router(
|
|||||||
@router.post("/api/rfcs/{slug}/start-edit-branch")
|
@router.post("/api/rfcs/{slug}/start-edit-branch")
|
||||||
async def start_edit_branch(slug: str, body: StartEditBranchBody, request: Request) -> dict[str, Any]:
|
async def start_edit_branch(slug: str, body: StartEditBranchBody, request: Request) -> dict[str, Any]:
|
||||||
viewer = auth.require_contributor(request)
|
viewer = auth.require_contributor(request)
|
||||||
|
# v0.16.0 (item #12): same per-RFC contribute gate as
|
||||||
|
# promote-to-branch — kicking off a super-draft edit branch is
|
||||||
|
# also PR-shaped work.
|
||||||
|
if not auth.can_contribute_to_rfc(viewer, slug):
|
||||||
|
raise HTTPException(
|
||||||
|
403,
|
||||||
|
"This RFC's owner has not invited you to contribute PRs",
|
||||||
|
)
|
||||||
rfc = _require_super_draft(slug)
|
rfc = _require_super_draft(slug)
|
||||||
owner, repo = _repo_for(rfc)
|
owner, repo = _repo_for(rfc)
|
||||||
new_branch = (body.branch_name or "").strip()
|
new_branch = (body.branch_name or "").strip()
|
||||||
|
|||||||
@@ -116,6 +116,17 @@ def make_router() -> APIRouter:
|
|||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
viewer = auth.require_contributor(request)
|
viewer = auth.require_contributor(request)
|
||||||
_require_rfc_readable(slug)
|
_require_rfc_readable(slug)
|
||||||
|
# v0.16.0 (roadmap item #12): the per-RFC discussion is now a
|
||||||
|
# gated surface. The platform-level `require_contributor` above
|
||||||
|
# ensures the user is signed in + admin-granted; this layer
|
||||||
|
# narrows further to "is this user named for this RFC?" The
|
||||||
|
# 403 here is structurally the v0.6.0 anon-write refusal
|
||||||
|
# extended to non-invited platform users.
|
||||||
|
if not auth.can_discuss_rfc(viewer, slug):
|
||||||
|
raise HTTPException(
|
||||||
|
403,
|
||||||
|
"This RFC's owner has not invited you to its discussion",
|
||||||
|
)
|
||||||
cur = db.conn().execute(
|
cur = db.conn().execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO threads
|
INSERT INTO threads
|
||||||
@@ -175,6 +186,12 @@ def make_router() -> APIRouter:
|
|||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
viewer = auth.require_contributor(request)
|
viewer = auth.require_contributor(request)
|
||||||
_require_rfc_readable(slug)
|
_require_rfc_readable(slug)
|
||||||
|
# v0.16.0 (item #12): same per-RFC gate as create_discussion_thread.
|
||||||
|
if not auth.can_discuss_rfc(viewer, slug):
|
||||||
|
raise HTTPException(
|
||||||
|
403,
|
||||||
|
"This RFC's owner has not invited you to its discussion",
|
||||||
|
)
|
||||||
_require_discussion_thread(slug, thread_id)
|
_require_discussion_thread(slug, thread_id)
|
||||||
message_id = chat_layer.append_user_message(
|
message_id = chat_layer.append_user_message(
|
||||||
thread_id=thread_id,
|
thread_id=thread_id,
|
||||||
|
|||||||
@@ -0,0 +1,575 @@
|
|||||||
|
"""v0.16.0 / §6 / §10 — owner-only invite for per-RFC PR or PR-less
|
||||||
|
discussion (roadmap item #12).
|
||||||
|
|
||||||
|
The RFC's owner can invite a specific email to one of two per-RFC roles:
|
||||||
|
|
||||||
|
* `contributor` — may open PRs against this RFC AND post in its
|
||||||
|
discussion (PR-permission strictly includes discussion-permission).
|
||||||
|
* `discussant` — may post in this RFC's PR-less discussion only.
|
||||||
|
|
||||||
|
Non-invited users keep the v0.6.0 anonymous-read contract: they can
|
||||||
|
read but cannot write/discuss the RFC. Reads are not narrowed by
|
||||||
|
this item.
|
||||||
|
|
||||||
|
Endpoints:
|
||||||
|
|
||||||
|
* `POST /api/rfcs/{slug}/invitations` — owner: create + email
|
||||||
|
* `GET /api/rfcs/{slug}/invitations` — owner: list pending/accepted
|
||||||
|
* `POST /api/rfcs/{slug}/invitations/{id}/revoke` — owner: revoke
|
||||||
|
* `GET /api/invitations/accept` — token lookup (signed-in user)
|
||||||
|
* `POST /api/invitations/accept` — token redeem (signed-in user)
|
||||||
|
|
||||||
|
The accept endpoints are deliberately platform-scoped (not nested under
|
||||||
|
the RFC slug) because the user clicking the email link only has the
|
||||||
|
token and may not even know the slug yet. The GET shape lets the
|
||||||
|
frontend show a confirmation page ("RFC <X> invited you to be a
|
||||||
|
<role> — accept?") before the POST commits the membership.
|
||||||
|
|
||||||
|
Permission gates (composed with `require_contributor`):
|
||||||
|
|
||||||
|
* Issue / list / revoke: `auth.can_invite_to_rfc` — RFC owner or
|
||||||
|
platform admin/owner.
|
||||||
|
* Accept: any platform-granted signed-in user; the gate is the
|
||||||
|
token, not the role. The token also constrains which email the
|
||||||
|
accept lands under — the accepting user's email must match the
|
||||||
|
invitation's invitee_email (case-insensitive). This prevents an
|
||||||
|
invited-but-not-the-account-holder situation from minting a
|
||||||
|
collaborator row under the wrong identity.
|
||||||
|
|
||||||
|
Email shape: a single plain-text body sent via the existing SMTP path
|
||||||
|
(reuses `EmailConfig.from_env()` like `email_otc.py` does). No
|
||||||
|
unsubscribe footer — the email is transactional and per-invite, not a
|
||||||
|
recurring notification. No tracking pixel.
|
||||||
|
|
||||||
|
Admin-page hook: when an accept lands and the user's
|
||||||
|
`permission_state` is still `pending`, that signals to the admin's
|
||||||
|
`/admin/users` queue that the user is here because they accepted a
|
||||||
|
per-RFC invitation — informing (not deciding) the admin's
|
||||||
|
platform-grant call. v0.16.0 surfaces this via additive columns on
|
||||||
|
the existing `GET /api/admin/users` listing (see `api_admin.py`'s
|
||||||
|
diff in the same release) — no new endpoint, no restructure.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import secrets
|
||||||
|
import smtplib
|
||||||
|
from email.message import EmailMessage
|
||||||
|
from email.utils import formataddr
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from . import auth, db
|
||||||
|
from .email import EmailConfig, _SENT
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Pydantic bodies
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class CreateInvitationBody(BaseModel):
|
||||||
|
"""The owner picks an email and a role-in-RFC. No custom-message
|
||||||
|
field — that belongs to item #16's platform-level invite surface,
|
||||||
|
not here.
|
||||||
|
|
||||||
|
We validate the email with a deliberately narrow pattern rather
|
||||||
|
than `pydantic.EmailStr` to avoid pulling in `email-validator` as
|
||||||
|
a dependency (and v0.7.0's OTC body does the same — see
|
||||||
|
`OTCRequestBody`'s shape). The validation here is intentionally
|
||||||
|
permissive: a local-part, an `@`, and a domain part with no
|
||||||
|
whitespace. Operator-side typo catching is the job of the email
|
||||||
|
transport; the framework only guards against obviously malformed
|
||||||
|
input."""
|
||||||
|
invitee_email: str = Field(min_length=3, max_length=320,
|
||||||
|
pattern=r"^[^\s@]+@[^\s@]+$")
|
||||||
|
role_in_rfc: str = Field(pattern="^(contributor|discussant)$")
|
||||||
|
|
||||||
|
|
||||||
|
class AcceptInvitationBody(BaseModel):
|
||||||
|
token: str = Field(min_length=1, max_length=200)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Constants
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
# 30-day TTL matches the device-trust window the framework already
|
||||||
|
# ships (v0.11.0). A pending invitation past this is rejected at the
|
||||||
|
# accept endpoint regardless of the row's `status` column.
|
||||||
|
INVITATION_TTL_DAYS = 30
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Router
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def make_router() -> APIRouter:
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# POST /api/rfcs/<slug>/invitations
|
||||||
|
# The owner creates an invitation. The endpoint mints the token,
|
||||||
|
# writes the row, and dispatches the email synchronously. A failure
|
||||||
|
# to send the email does NOT roll back the row — the owner can
|
||||||
|
# share the link directly out-of-band if SMTP is briefly down (the
|
||||||
|
# `GET /api/rfcs/<slug>/invitations` response carries the token
|
||||||
|
# for that fallback).
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.post("/api/rfcs/{slug}/invitations")
|
||||||
|
async def create_invitation(slug: str, body: CreateInvitationBody, request: Request) -> dict[str, Any]:
|
||||||
|
viewer = auth.require_contributor(request)
|
||||||
|
rfc = _require_rfc(slug)
|
||||||
|
if not auth.can_invite_to_rfc(viewer, slug):
|
||||||
|
raise HTTPException(
|
||||||
|
403,
|
||||||
|
"Only the RFC's owner can invite collaborators",
|
||||||
|
)
|
||||||
|
|
||||||
|
invitee_email = body.invitee_email.strip()
|
||||||
|
role_in_rfc = body.role_in_rfc
|
||||||
|
|
||||||
|
# Refuse re-inviting an email that already has a pending
|
||||||
|
# invitation on this RFC at the same role. Different-role
|
||||||
|
# re-invite is allowed (upgrade discussant → contributor)
|
||||||
|
# — the new row supersedes the old in the UI listing's
|
||||||
|
# natural ordering, and acceptance of either picks up the
|
||||||
|
# corresponding role.
|
||||||
|
existing = db.conn().execute(
|
||||||
|
"""
|
||||||
|
SELECT id FROM rfc_invitations
|
||||||
|
WHERE rfc_slug = ? AND invitee_email = ? COLLATE NOCASE
|
||||||
|
AND role_in_rfc = ? AND status = 'pending'
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(slug, invitee_email, role_in_rfc),
|
||||||
|
).fetchone()
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(
|
||||||
|
409,
|
||||||
|
f"{invitee_email} already has a pending {role_in_rfc} invitation for this RFC",
|
||||||
|
)
|
||||||
|
|
||||||
|
token = _mint_token()
|
||||||
|
cur = db.conn().execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO rfc_invitations
|
||||||
|
(rfc_slug, inviter_user_id, invitee_email, role_in_rfc,
|
||||||
|
token, expires_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, datetime('now', ?))
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
slug,
|
||||||
|
viewer.user_id,
|
||||||
|
invitee_email,
|
||||||
|
role_in_rfc,
|
||||||
|
token,
|
||||||
|
f"+{INVITATION_TTL_DAYS} days",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
invitation_id = cur.lastrowid
|
||||||
|
|
||||||
|
# Send the email — synchronous. A send failure logs and
|
||||||
|
# returns; the row stays so the owner can recover via the
|
||||||
|
# listing (which carries the token for an out-of-band share).
|
||||||
|
_send_invitation_email(
|
||||||
|
to_address=invitee_email,
|
||||||
|
inviter_display=viewer.display_name or viewer.gitea_login or "An RFC owner",
|
||||||
|
rfc_title=rfc["title"],
|
||||||
|
role_in_rfc=role_in_rfc,
|
||||||
|
token=token,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": invitation_id,
|
||||||
|
"rfc_slug": slug,
|
||||||
|
"invitee_email": invitee_email,
|
||||||
|
"role_in_rfc": role_in_rfc,
|
||||||
|
"status": "pending",
|
||||||
|
"token": token,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# GET /api/rfcs/<slug>/invitations
|
||||||
|
# The owner's listing of every invitation on the RFC, regardless
|
||||||
|
# of status. Carries the token (for the resend / re-share path).
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/api/rfcs/{slug}/invitations")
|
||||||
|
async def list_invitations(slug: str, request: Request) -> dict[str, Any]:
|
||||||
|
viewer = auth.require_contributor(request)
|
||||||
|
_require_rfc(slug)
|
||||||
|
if not auth.can_invite_to_rfc(viewer, slug):
|
||||||
|
raise HTTPException(
|
||||||
|
403,
|
||||||
|
"Only the RFC's owner can view invitations",
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = db.conn().execute(
|
||||||
|
"""
|
||||||
|
SELECT i.id, i.invitee_email, i.role_in_rfc, i.status, i.token,
|
||||||
|
i.expires_at, i.created_at, i.accepted_at,
|
||||||
|
i.inviter_user_id, i.accepted_by_user_id,
|
||||||
|
u_inviter.display_name AS inviter_display,
|
||||||
|
u_inviter.gitea_login AS inviter_login,
|
||||||
|
u_accept.display_name AS accepted_by_display,
|
||||||
|
u_accept.gitea_login AS accepted_by_login
|
||||||
|
FROM rfc_invitations i
|
||||||
|
LEFT JOIN users u_inviter ON u_inviter.id = i.inviter_user_id
|
||||||
|
LEFT JOIN users u_accept ON u_accept.id = i.accepted_by_user_id
|
||||||
|
WHERE i.rfc_slug = ?
|
||||||
|
ORDER BY i.id DESC
|
||||||
|
""",
|
||||||
|
(slug,),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": r["id"],
|
||||||
|
"invitee_email": r["invitee_email"],
|
||||||
|
"role_in_rfc": r["role_in_rfc"],
|
||||||
|
"status": _effective_status(r),
|
||||||
|
"token": r["token"],
|
||||||
|
"expires_at": r["expires_at"],
|
||||||
|
"created_at": r["created_at"],
|
||||||
|
"accepted_at": r["accepted_at"],
|
||||||
|
"inviter_display": r["inviter_display"],
|
||||||
|
"inviter_login": r["inviter_login"],
|
||||||
|
"accepted_by_display": r["accepted_by_display"],
|
||||||
|
"accepted_by_login": r["accepted_by_login"],
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# POST /api/rfcs/<slug>/invitations/<id>/revoke
|
||||||
|
# Revokes a pending invitation. Already-accepted invitations
|
||||||
|
# cannot be "revoked" from this surface — the corresponding
|
||||||
|
# collaborator-removal surface is a §19.2 candidate; v0.16.0
|
||||||
|
# only lifts the *pending* link.
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.post("/api/rfcs/{slug}/invitations/{invitation_id}/revoke")
|
||||||
|
async def revoke_invitation(slug: str, invitation_id: int, request: Request) -> dict[str, Any]:
|
||||||
|
viewer = auth.require_contributor(request)
|
||||||
|
_require_rfc(slug)
|
||||||
|
if not auth.can_invite_to_rfc(viewer, slug):
|
||||||
|
raise HTTPException(
|
||||||
|
403,
|
||||||
|
"Only the RFC's owner can revoke invitations",
|
||||||
|
)
|
||||||
|
|
||||||
|
row = db.conn().execute(
|
||||||
|
"SELECT id, status FROM rfc_invitations WHERE id = ? AND rfc_slug = ?",
|
||||||
|
(invitation_id, slug),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(404, "Invitation not found")
|
||||||
|
if row["status"] != "pending":
|
||||||
|
raise HTTPException(
|
||||||
|
409,
|
||||||
|
f"Invitation is {row['status']}; only pending invitations can be revoked",
|
||||||
|
)
|
||||||
|
|
||||||
|
db.conn().execute(
|
||||||
|
"UPDATE rfc_invitations SET status = 'revoked' WHERE id = ?",
|
||||||
|
(invitation_id,),
|
||||||
|
)
|
||||||
|
return {"ok": True, "id": invitation_id, "status": "revoked"}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# GET /api/invitations/accept?token=...
|
||||||
|
# Lookup-only — returns what the invitation grants so the
|
||||||
|
# frontend can render a confirmation page before the POST. The
|
||||||
|
# token is required; no token, no peek.
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/api/invitations/accept")
|
||||||
|
async def preview_invitation(token: str, request: Request) -> dict[str, Any]:
|
||||||
|
viewer = auth.require_user(request)
|
||||||
|
row = _lookup_invitation_by_token(token)
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(404, "Invitation not found")
|
||||||
|
effective = _effective_status(row)
|
||||||
|
rfc = db.conn().execute(
|
||||||
|
"SELECT slug, title FROM cached_rfcs WHERE slug = ?", (row["rfc_slug"],),
|
||||||
|
).fetchone()
|
||||||
|
return {
|
||||||
|
"rfc_slug": row["rfc_slug"],
|
||||||
|
"rfc_title": rfc["title"] if rfc else row["rfc_slug"],
|
||||||
|
"role_in_rfc": row["role_in_rfc"],
|
||||||
|
"status": effective,
|
||||||
|
"invitee_email": row["invitee_email"],
|
||||||
|
"email_matches_you": (viewer.email or "").strip().lower()
|
||||||
|
== row["invitee_email"].strip().lower(),
|
||||||
|
"expires_at": row["expires_at"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# POST /api/invitations/accept
|
||||||
|
# The accept gesture: token → collaborator row.
|
||||||
|
#
|
||||||
|
# Requires:
|
||||||
|
# * an authenticated user (no token-only acceptance — we want
|
||||||
|
# the per-user audit trail),
|
||||||
|
# * a valid (pending, non-expired, non-revoked) invitation,
|
||||||
|
# * the accepting user's email matches invitee_email
|
||||||
|
# (case-insensitive).
|
||||||
|
#
|
||||||
|
# On success the row's status flips to 'accepted' and a
|
||||||
|
# rfc_collaborators row is inserted (or upgraded if the user
|
||||||
|
# already had a lower role). Idempotent: re-accepting the same
|
||||||
|
# already-accepted invitation reads as a 200 no-op with
|
||||||
|
# `changed=false`.
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.post("/api/invitations/accept")
|
||||||
|
async def accept_invitation(body: AcceptInvitationBody, request: Request) -> dict[str, Any]:
|
||||||
|
viewer = auth.require_user(request)
|
||||||
|
row = _lookup_invitation_by_token(body.token)
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(404, "Invitation not found")
|
||||||
|
|
||||||
|
effective = _effective_status(row)
|
||||||
|
if effective == "revoked":
|
||||||
|
raise HTTPException(409, "Invitation was revoked")
|
||||||
|
if effective == "expired":
|
||||||
|
raise HTTPException(409, "Invitation has expired")
|
||||||
|
|
||||||
|
# Email match — case-insensitive. Empty viewer email cannot
|
||||||
|
# accept (an OAuth-only user with no captured email shape).
|
||||||
|
viewer_email = (viewer.email or "").strip().lower()
|
||||||
|
invitee_email = row["invitee_email"].strip().lower()
|
||||||
|
if not viewer_email or viewer_email != invitee_email:
|
||||||
|
raise HTTPException(
|
||||||
|
403,
|
||||||
|
"This invitation was sent to a different email; sign in with that address",
|
||||||
|
)
|
||||||
|
|
||||||
|
if effective == "accepted":
|
||||||
|
# Idempotent re-accept — surface the existing collaborator
|
||||||
|
# row without writing anything new.
|
||||||
|
collab = db.conn().execute(
|
||||||
|
"SELECT role_in_rfc FROM rfc_collaborators WHERE rfc_slug = ? AND user_id = ?",
|
||||||
|
(row["rfc_slug"], viewer.user_id),
|
||||||
|
).fetchone()
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"changed": False,
|
||||||
|
"rfc_slug": row["rfc_slug"],
|
||||||
|
"role_in_rfc": collab["role_in_rfc"] if collab else row["role_in_rfc"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# First-time accept. Flip the invitation; upsert the
|
||||||
|
# collaborator. We do the upsert with ON CONFLICT so a
|
||||||
|
# user who already held a lower role gets upgraded, never
|
||||||
|
# downgraded (the MAX-style precedence is contributor >
|
||||||
|
# discussant; lower roles never overwrite higher).
|
||||||
|
with db.tx() as c:
|
||||||
|
c.execute(
|
||||||
|
"""
|
||||||
|
UPDATE rfc_invitations
|
||||||
|
SET status = 'accepted',
|
||||||
|
accepted_at = datetime('now'),
|
||||||
|
accepted_by_user_id = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(viewer.user_id, row["id"]),
|
||||||
|
)
|
||||||
|
existing = c.execute(
|
||||||
|
"SELECT role_in_rfc FROM rfc_collaborators WHERE rfc_slug = ? AND user_id = ?",
|
||||||
|
(row["rfc_slug"], viewer.user_id),
|
||||||
|
).fetchone()
|
||||||
|
target_role = _max_role(
|
||||||
|
existing["role_in_rfc"] if existing else None,
|
||||||
|
row["role_in_rfc"],
|
||||||
|
)
|
||||||
|
if existing is None:
|
||||||
|
c.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO rfc_collaborators
|
||||||
|
(rfc_slug, user_id, role_in_rfc, invitation_id)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(row["rfc_slug"], viewer.user_id, target_role, row["id"]),
|
||||||
|
)
|
||||||
|
elif existing["role_in_rfc"] != target_role:
|
||||||
|
c.execute(
|
||||||
|
"""
|
||||||
|
UPDATE rfc_collaborators
|
||||||
|
SET role_in_rfc = ?, invitation_id = ?
|
||||||
|
WHERE rfc_slug = ? AND user_id = ?
|
||||||
|
""",
|
||||||
|
(target_role, row["id"], row["rfc_slug"], viewer.user_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"changed": True,
|
||||||
|
"rfc_slug": row["rfc_slug"],
|
||||||
|
"role_in_rfc": target_role,
|
||||||
|
}
|
||||||
|
|
||||||
|
return router
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _require_rfc(slug: str):
|
||||||
|
"""The invitation surface only operates on a known, non-withdrawn
|
||||||
|
RFC. We refuse 404 on unknown and 409 on withdrawn — mirrors the
|
||||||
|
discussion endpoints' `_require_rfc_readable` shape."""
|
||||||
|
row = db.conn().execute(
|
||||||
|
"SELECT slug, title, state FROM cached_rfcs WHERE slug = ?", (slug,),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(404, "RFC not found")
|
||||||
|
if row["state"] == "withdrawn":
|
||||||
|
raise HTTPException(409, "RFC is withdrawn")
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def _lookup_invitation_by_token(token: str):
|
||||||
|
return db.conn().execute(
|
||||||
|
"""
|
||||||
|
SELECT id, rfc_slug, inviter_user_id, invitee_email, role_in_rfc,
|
||||||
|
status, token, expires_at, created_at, accepted_at,
|
||||||
|
accepted_by_user_id
|
||||||
|
FROM rfc_invitations
|
||||||
|
WHERE token = ?
|
||||||
|
""",
|
||||||
|
(token,),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
def _effective_status(row) -> str:
|
||||||
|
"""The row's column status is the authoritative truth except for
|
||||||
|
`expired` — that is derived from `expires_at` at read time so an
|
||||||
|
unattended cron isn't required to flip rows. A revoked-then-
|
||||||
|
expired row reads as `revoked` (the explicit gesture wins)."""
|
||||||
|
column_status = row["status"]
|
||||||
|
if column_status != "pending":
|
||||||
|
return column_status
|
||||||
|
# Compare via SQL so the comparison is in sqlite-time, matching the
|
||||||
|
# `datetime('now')` insert. A simpler same-process comparison would
|
||||||
|
# work too, but routing through the DB keeps the timezone handling
|
||||||
|
# consistent with the inserts.
|
||||||
|
is_past = db.conn().execute(
|
||||||
|
"SELECT datetime(?) <= datetime('now') AS past",
|
||||||
|
(row["expires_at"],),
|
||||||
|
).fetchone()["past"]
|
||||||
|
return "expired" if is_past else "pending"
|
||||||
|
|
||||||
|
|
||||||
|
def _mint_token() -> str:
|
||||||
|
"""A 256-bit URL-safe token. The token shape is opaque to the
|
||||||
|
consumer; the email link encodes it as a query param."""
|
||||||
|
return secrets.token_urlsafe(32)
|
||||||
|
|
||||||
|
|
||||||
|
def _max_role(existing: str | None, new: str) -> str:
|
||||||
|
"""contributor strictly dominates discussant. A re-accept that
|
||||||
|
would lower the role is a no-op (the existing role survives)."""
|
||||||
|
precedence = {"discussant": 0, "contributor": 1}
|
||||||
|
if existing is None:
|
||||||
|
return new
|
||||||
|
if precedence.get(new, 0) > precedence.get(existing, 0):
|
||||||
|
return new
|
||||||
|
return existing
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Email dispatch — transactional, no preferences honored
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _send_invitation_email(
|
||||||
|
*,
|
||||||
|
to_address: str,
|
||||||
|
inviter_display: str,
|
||||||
|
rfc_title: str,
|
||||||
|
role_in_rfc: str,
|
||||||
|
token: str,
|
||||||
|
) -> bool:
|
||||||
|
"""Compose and send the invitation email.
|
||||||
|
|
||||||
|
Like `email_otc.send_otc_email`, this writes its own envelope and
|
||||||
|
reuses `EmailConfig.from_env()` for the SMTP plumbing. The
|
||||||
|
`_SENT` buffer is appended either way so integration tests can
|
||||||
|
assert on the outbound shape without a real SMTP server.
|
||||||
|
|
||||||
|
Returns True on the happy path / dev fallback; False on SMTP
|
||||||
|
failure. The caller does not roll back the invitation row on
|
||||||
|
failure — the owner has the token in the create response and on
|
||||||
|
the listing surface for an out-of-band share.
|
||||||
|
"""
|
||||||
|
cfg = EmailConfig.from_env()
|
||||||
|
subject = f"{inviter_display} invited you to {rfc_title} on {cfg.from_name}"
|
||||||
|
role_label = (
|
||||||
|
"open PRs against the RFC and join its discussion"
|
||||||
|
if role_in_rfc == "contributor"
|
||||||
|
else "join the RFC's discussion"
|
||||||
|
)
|
||||||
|
link = f"{cfg.app_url}/invitations/accept?token={token}"
|
||||||
|
body = (
|
||||||
|
f"{inviter_display} invited you to {rfc_title} on {cfg.from_name} as {role_in_rfc}.\n\n"
|
||||||
|
f"This invitation lets you {role_label}.\n\n"
|
||||||
|
f"Click to accept (you'll be asked to sign in first if you aren't already):\n\n"
|
||||||
|
f" {link}\n\n"
|
||||||
|
f"The invitation expires in {INVITATION_TTL_DAYS} days. If you weren't expecting\n"
|
||||||
|
f"this, you can safely ignore the email.\n\n"
|
||||||
|
f"---\n"
|
||||||
|
f"{cfg.from_name} · {cfg.app_url}\n"
|
||||||
|
)
|
||||||
|
envelope = {
|
||||||
|
"to": to_address,
|
||||||
|
"from": formataddr((cfg.from_name, cfg.from_address)),
|
||||||
|
"subject": subject,
|
||||||
|
"body": body,
|
||||||
|
"kind": "rfc_invitation",
|
||||||
|
}
|
||||||
|
_SENT.append(envelope)
|
||||||
|
|
||||||
|
if not cfg.enabled:
|
||||||
|
log.info("invitation email disabled (EMAIL_ENABLED=0): to=%s", to_address)
|
||||||
|
return True
|
||||||
|
if not cfg.smtp_host:
|
||||||
|
# Dev fallback — surface the link at INFO so the operator can
|
||||||
|
# complete an accept flow without an SMTP relay.
|
||||||
|
log.info(
|
||||||
|
"invitation email (stdout fallback): to=%s rfc=%s role=%s link=%s",
|
||||||
|
to_address, rfc_title, role_in_rfc, link,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
try:
|
||||||
|
msg = EmailMessage()
|
||||||
|
msg["From"] = envelope["from"]
|
||||||
|
msg["To"] = to_address
|
||||||
|
msg["Subject"] = subject
|
||||||
|
msg.set_content(body)
|
||||||
|
smtp = smtplib.SMTP(cfg.smtp_host, cfg.smtp_port, timeout=30)
|
||||||
|
try:
|
||||||
|
if cfg.smtp_starttls:
|
||||||
|
smtp.starttls()
|
||||||
|
if cfg.smtp_user:
|
||||||
|
smtp.login(cfg.smtp_user, cfg.smtp_password)
|
||||||
|
smtp.send_message(msg)
|
||||||
|
finally:
|
||||||
|
smtp.quit()
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
log.exception("invitation email send failed: to=%s", to_address)
|
||||||
|
return False
|
||||||
@@ -112,6 +112,17 @@ def make_router(
|
|||||||
@router.post("/api/rfcs/{slug}/branches/{branch:path}/open-pr")
|
@router.post("/api/rfcs/{slug}/branches/{branch:path}/open-pr")
|
||||||
async def open_pr(slug: str, branch: str, body: OpenPRBody, request: Request) -> dict[str, Any]:
|
async def open_pr(slug: str, branch: str, body: OpenPRBody, request: Request) -> dict[str, Any]:
|
||||||
viewer = auth.require_contributor(request)
|
viewer = auth.require_contributor(request)
|
||||||
|
# v0.16.0 (item #12): opening a PR is the canonical PR-shaped
|
||||||
|
# write — the gate fires here even though the branch-cutting
|
||||||
|
# entry points also gate, since a user with prior branch access
|
||||||
|
# who's since had their per-RFC role revoked shouldn't be able
|
||||||
|
# to ship the PR. The branch-creation gate is the kickoff
|
||||||
|
# refusal; this one is the post-work refusal.
|
||||||
|
if not auth.can_contribute_to_rfc(viewer, slug):
|
||||||
|
raise HTTPException(
|
||||||
|
403,
|
||||||
|
"This RFC's owner has not invited you to contribute PRs",
|
||||||
|
)
|
||||||
rfc = _require_active_rfc(slug)
|
rfc = _require_active_rfc(slug)
|
||||||
if branch == "main":
|
if branch == "main":
|
||||||
raise HTTPException(409, "PRs open from non-main branches")
|
raise HTTPException(409, "PRs open from non-main branches")
|
||||||
|
|||||||
@@ -290,5 +290,159 @@ def require_admin(request: Request) -> SessionUser:
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
# v0.16.0 (roadmap item #12): per-RFC membership helpers.
|
||||||
|
#
|
||||||
|
# These don't replace `require_contributor` — they layer on top of it for
|
||||||
|
# endpoints that an RFC's owner can selectively open up. The "discussion"
|
||||||
|
# and "PR" write surfaces consult `is_rfc_writer(...)` / `is_rfc_discussant(...)`
|
||||||
|
# to admit users who are either platform-privileged (admin, RFC owner)
|
||||||
|
# OR who hold an explicit invitation-accepted per-RFC role.
|
||||||
|
#
|
||||||
|
# The platform gate still fires first: a user whose
|
||||||
|
# `permission_state != 'granted'` cannot write anywhere, invitation or
|
||||||
|
# not. v0.16.0 doesn't loosen that — a per-RFC invitation is additive
|
||||||
|
# *within* the granted-platform-user population. (Accepting an
|
||||||
|
# invitation as a pending user surfaces in the admin-page hook per
|
||||||
|
# the roadmap text; the platform grant remains the admin's decision.)
|
||||||
|
|
||||||
|
|
||||||
|
def _rfc_owners_set(rfc_slug: str) -> set[str]:
|
||||||
|
"""The gitea_logins named in the RFC's frontmatter owners array.
|
||||||
|
|
||||||
|
Read from `cached_rfcs.owners_json`. Returns an empty set if the RFC
|
||||||
|
isn't cached (the caller's earlier `_require_rfc_readable` will
|
||||||
|
already have rejected that case in practice).
|
||||||
|
"""
|
||||||
|
import json as _json
|
||||||
|
row = db.conn().execute(
|
||||||
|
"SELECT owners_json FROM cached_rfcs WHERE slug = ?", (rfc_slug,),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return set()
|
||||||
|
try:
|
||||||
|
return set(_json.loads(row["owners_json"] or "[]"))
|
||||||
|
except Exception:
|
||||||
|
return set()
|
||||||
|
|
||||||
|
|
||||||
|
def is_rfc_owner(user: SessionUser | None, rfc_slug: str) -> bool:
|
||||||
|
"""True iff the user is named in the RFC's frontmatter `owners`
|
||||||
|
list. The platform-level admin/owner check is separate; per §6.1 an
|
||||||
|
app admin/owner has all per-RFC capabilities by construction, but
|
||||||
|
this predicate is intentionally narrow — it answers "is this
|
||||||
|
person on the RFC's owners line?" and nothing more.
|
||||||
|
"""
|
||||||
|
if user is None:
|
||||||
|
return False
|
||||||
|
return user.gitea_login in _rfc_owners_set(rfc_slug)
|
||||||
|
|
||||||
|
|
||||||
|
def is_rfc_collaborator(user: SessionUser | None, rfc_slug: str, *, role_in_rfc: str | None = None) -> bool:
|
||||||
|
"""True iff the user has an accepted per-RFC collaborator row.
|
||||||
|
|
||||||
|
`role_in_rfc`:
|
||||||
|
* None — any role qualifies (the discussion-write check uses this
|
||||||
|
shape: contributor strictly includes discussant).
|
||||||
|
* 'contributor' — only the contributor role qualifies (the PR-write
|
||||||
|
check uses this shape).
|
||||||
|
* 'discussant' — only the discussant role qualifies (not used by
|
||||||
|
v0.16.0 endpoints; included for symmetry).
|
||||||
|
"""
|
||||||
|
if user is None:
|
||||||
|
return False
|
||||||
|
if role_in_rfc is None:
|
||||||
|
row = db.conn().execute(
|
||||||
|
"SELECT 1 FROM rfc_collaborators WHERE rfc_slug = ? AND user_id = ? LIMIT 1",
|
||||||
|
(rfc_slug, user.user_id),
|
||||||
|
).fetchone()
|
||||||
|
return row is not None
|
||||||
|
row = db.conn().execute(
|
||||||
|
"SELECT 1 FROM rfc_collaborators WHERE rfc_slug = ? AND user_id = ? AND role_in_rfc = ? LIMIT 1",
|
||||||
|
(rfc_slug, user.user_id, role_in_rfc),
|
||||||
|
).fetchone()
|
||||||
|
return row is not None
|
||||||
|
|
||||||
|
|
||||||
|
def can_discuss_rfc(user: SessionUser | None, rfc_slug: str) -> bool:
|
||||||
|
"""v0.16.0 — admit to PR-less discussion writes on this RFC.
|
||||||
|
|
||||||
|
True if ANY of:
|
||||||
|
* platform admin/owner (the §6.1 maximal-capability path),
|
||||||
|
* the RFC has no frontmatter owners yet (the gate is open
|
||||||
|
until an owner exists to set it — relevant for super-drafts
|
||||||
|
pre-§13.1 claim),
|
||||||
|
* RFC owner (frontmatter `owners` membership),
|
||||||
|
* accepted per-RFC collaborator at any role (contributor strictly
|
||||||
|
includes discussant).
|
||||||
|
|
||||||
|
Returns False for anonymous viewers and for users whose
|
||||||
|
`permission_state != 'granted'` — the platform-level gate must hold
|
||||||
|
before any per-RFC layer can apply. The platform gate is also
|
||||||
|
enforced earlier in the request via `require_contributor`; the
|
||||||
|
helper here is defensive so callers that compose it with
|
||||||
|
`current_user` directly still respect the gate.
|
||||||
|
"""
|
||||||
|
if user is None:
|
||||||
|
return False
|
||||||
|
if user.permission_state != "granted":
|
||||||
|
return False
|
||||||
|
if user.role in ("owner", "admin"):
|
||||||
|
return True
|
||||||
|
owners = _rfc_owners_set(rfc_slug)
|
||||||
|
if not owners:
|
||||||
|
# No owner to gate the invite-list — fall through to the
|
||||||
|
# platform-granted contract. The first §13.1 claim engages
|
||||||
|
# the gate; before that, anyone platform-granted can
|
||||||
|
# contribute (mirrors the v0.5.0 / v0.6.0 contract).
|
||||||
|
return True
|
||||||
|
if user.gitea_login in owners:
|
||||||
|
return True
|
||||||
|
return is_rfc_collaborator(user, rfc_slug, role_in_rfc=None)
|
||||||
|
|
||||||
|
|
||||||
|
def can_contribute_to_rfc(user: SessionUser | None, rfc_slug: str) -> bool:
|
||||||
|
"""v0.16.0 — admit to PR-shaped writes on this RFC.
|
||||||
|
|
||||||
|
True if ANY of:
|
||||||
|
* platform admin/owner,
|
||||||
|
* the RFC has no frontmatter owners yet (gate open until an
|
||||||
|
owner exists),
|
||||||
|
* RFC owner,
|
||||||
|
* accepted per-RFC collaborator at role 'contributor' (a
|
||||||
|
'discussant' row is NOT sufficient — PRs are the
|
||||||
|
higher-privilege surface).
|
||||||
|
|
||||||
|
Same `permission_state` and anonymous-viewer refusals as
|
||||||
|
`can_discuss_rfc`.
|
||||||
|
"""
|
||||||
|
if user is None:
|
||||||
|
return False
|
||||||
|
if user.permission_state != "granted":
|
||||||
|
return False
|
||||||
|
if user.role in ("owner", "admin"):
|
||||||
|
return True
|
||||||
|
owners = _rfc_owners_set(rfc_slug)
|
||||||
|
if not owners:
|
||||||
|
# Same fall-through as can_discuss_rfc: until an owner exists,
|
||||||
|
# the gate is open.
|
||||||
|
return True
|
||||||
|
if user.gitea_login in owners:
|
||||||
|
return True
|
||||||
|
return is_rfc_collaborator(user, rfc_slug, role_in_rfc="contributor")
|
||||||
|
|
||||||
|
|
||||||
|
def can_invite_to_rfc(user: SessionUser | None, rfc_slug: str) -> bool:
|
||||||
|
"""v0.16.0 — only RFC owners (frontmatter) and platform admin/owner
|
||||||
|
can issue invitations. Per-RFC collaborators do not get the
|
||||||
|
invite-others power; that stays with the RFC's owner."""
|
||||||
|
if user is None:
|
||||||
|
return False
|
||||||
|
if user.permission_state != "granted":
|
||||||
|
return False
|
||||||
|
if user.role in ("owner", "admin"):
|
||||||
|
return True
|
||||||
|
return is_rfc_owner(user, rfc_slug)
|
||||||
|
|
||||||
|
|
||||||
def new_state() -> str:
|
def new_state() -> str:
|
||||||
return secrets.token_urlsafe(16)
|
return secrets.token_urlsafe(16)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -139,6 +139,10 @@ _EVENT_TO_CATEGORY: dict[str, str] = {
|
|||||||
"graduation_complete": "personal-direct",
|
"graduation_complete": "personal-direct",
|
||||||
"super_draft_graduation_ready": "admin-actionable",
|
"super_draft_graduation_ready": "admin-actionable",
|
||||||
"claim_opened": "structural",
|
"claim_opened": "structural",
|
||||||
|
# v0.9.0: roadmap item #7. A fresh beta-access request lands as
|
||||||
|
# an admin-actionable signal so it consults `email_admin_actionable`
|
||||||
|
# and reaches owners/admins only.
|
||||||
|
"new_beta_request": "admin-actionable",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -285,6 +289,13 @@ def _deep_link(payload: dict, cfg: EmailConfig) -> str:
|
|||||||
slug = payload.get("rfc_slug")
|
slug = payload.get("rfc_slug")
|
||||||
pr = payload.get("pr_number")
|
pr = payload.get("pr_number")
|
||||||
branch = payload.get("branch_name")
|
branch = payload.get("branch_name")
|
||||||
|
event_kind = payload.get("event_kind")
|
||||||
|
# v0.9.0: framework-scoped admin signals link to the admin
|
||||||
|
# surface, not /rfc/... The `new_beta_request` event is the
|
||||||
|
# canonical example; future framework-scoped admin events
|
||||||
|
# may reuse the same branch.
|
||||||
|
if event_kind == "new_beta_request":
|
||||||
|
return f"{cfg.app_url}/admin/users"
|
||||||
if slug and pr:
|
if slug and pr:
|
||||||
return f"{cfg.app_url}/rfc/{slug}/pr/{pr}"
|
return f"{cfg.app_url}/rfc/{slug}/pr/{pr}"
|
||||||
if slug and branch:
|
if slug and branch:
|
||||||
|
|||||||
+164
-6
@@ -10,8 +10,8 @@ import logging
|
|||||||
import secrets
|
import secrets
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import APIRouter, FastAPI, HTTPException, Request
|
from fastapi import APIRouter, FastAPI, HTTPException, Request, Response
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import JSONResponse, RedirectResponse
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from starlette.middleware.sessions import SessionMiddleware
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
|
|
||||||
@@ -20,12 +20,14 @@ from . import (
|
|||||||
auth,
|
auth,
|
||||||
cache,
|
cache,
|
||||||
db,
|
db,
|
||||||
|
device_trust as device_trust_mod,
|
||||||
digest,
|
digest,
|
||||||
email_otc,
|
email_otc,
|
||||||
hygiene,
|
hygiene,
|
||||||
otc,
|
otc,
|
||||||
passcode as passcode_mod,
|
passcode as passcode_mod,
|
||||||
providers as providers_mod,
|
providers as providers_mod,
|
||||||
|
turnstile,
|
||||||
webhooks,
|
webhooks,
|
||||||
)
|
)
|
||||||
from .bot import Bot
|
from .bot import Bot
|
||||||
@@ -38,11 +40,25 @@ log = logging.getLogger("rfc_app")
|
|||||||
|
|
||||||
class OtcRequestBody(BaseModel):
|
class OtcRequestBody(BaseModel):
|
||||||
email: str = Field(min_length=3, max_length=320)
|
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):
|
class OtcVerifyBody(BaseModel):
|
||||||
email: str = Field(min_length=3, max_length=320)
|
email: str = Field(min_length=3, max_length=320)
|
||||||
code: str = Field(min_length=1, max_length=16)
|
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):
|
class PasscodeSetBody(BaseModel):
|
||||||
@@ -52,6 +68,8 @@ class PasscodeSetBody(BaseModel):
|
|||||||
class PasscodeVerifyBody(BaseModel):
|
class PasscodeVerifyBody(BaseModel):
|
||||||
email: str = Field(min_length=3, max_length=320)
|
email: str = Field(min_length=3, max_length=320)
|
||||||
passcode: str = Field(min_length=1, max_length=64)
|
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
|
@asynccontextmanager
|
||||||
@@ -117,6 +135,48 @@ def create_app() -> FastAPI:
|
|||||||
app = create_app()
|
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:
|
def _oauth_router(config) -> APIRouter:
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -164,7 +224,27 @@ def _oauth_router(config) -> APIRouter:
|
|||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
|
|
||||||
@router.post("/auth/otc/request")
|
@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)
|
outcome = otc.request_code(body.email)
|
||||||
if outcome.reason == "cooldown":
|
if outcome.reason == "cooldown":
|
||||||
# Loud failure per the rate-limit primitive — the abuse
|
# Loud failure per the rate-limit primitive — the abuse
|
||||||
@@ -177,7 +257,7 @@ def _oauth_router(config) -> APIRouter:
|
|||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
@router.post("/auth/otc/verify")
|
@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)
|
result = otc.verify_code(body.email, body.code)
|
||||||
if not result.ok or result.user is None:
|
if not result.ok or result.user is None:
|
||||||
raise HTTPException(400, "Invalid or expired code")
|
raise HTTPException(400, "Invalid or expired code")
|
||||||
@@ -202,6 +282,18 @@ def _oauth_router(config) -> APIRouter:
|
|||||||
and not last_name
|
and not last_name
|
||||||
and not beta_request_reason
|
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 {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"user": {
|
"user": {
|
||||||
@@ -254,12 +346,16 @@ def _oauth_router(config) -> APIRouter:
|
|||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
@router.post("/auth/passcode/verify")
|
@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
|
"""Sign in with email + passcode. Returns the standard session
|
||||||
payload on success; HTTP 423 with `locked_until` when the
|
payload on success; HTTP 423 with `locked_until` when the
|
||||||
account is in the lockout window; HTTP 400 for every other
|
account is in the lockout window; HTTP 400 for every other
|
||||||
failure (the wrong-vs-unknown distinction is intentionally
|
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)
|
result = passcode_mod.verify_passcode(body.email, body.passcode)
|
||||||
if result.reason == "locked":
|
if result.reason == "locked":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -272,6 +368,10 @@ def _oauth_router(config) -> APIRouter:
|
|||||||
if not result.ok or result.user is None:
|
if not result.ok or result.user is None:
|
||||||
raise HTTPException(400, "Invalid passcode")
|
raise HTTPException(400, "Invalid passcode")
|
||||||
auth.store_session(request, result.user)
|
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 {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"user": {
|
"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
|
return router
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ log = logging.getLogger(__name__)
|
|||||||
CATEGORY_PERSONAL = "personal-direct"
|
CATEGORY_PERSONAL = "personal-direct"
|
||||||
CATEGORY_STRUCTURAL = "structural"
|
CATEGORY_STRUCTURAL = "structural"
|
||||||
CATEGORY_CHURN = "churn"
|
CATEGORY_CHURN = "churn"
|
||||||
|
CATEGORY_ADMIN_ACTIONABLE = "admin-actionable"
|
||||||
|
|
||||||
# Action kinds whose actor's first interaction with a slug triggers
|
# Action kinds whose actor's first interaction with a slug triggers
|
||||||
# auto-watch per §15.6. The substantive-gesture list in the spec is
|
# auto-watch per §15.6. The substantive-gesture list in the spec is
|
||||||
@@ -208,6 +209,67 @@ def fan_out_from_action(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def fan_out_new_beta_request(
|
||||||
|
*,
|
||||||
|
requester_user_id: int,
|
||||||
|
) -> None:
|
||||||
|
"""v0.9.0 (roadmap item #7): announce a fresh beta-access request to
|
||||||
|
every admin/owner.
|
||||||
|
|
||||||
|
Called from `POST /api/auth/me/beta-request` after the row's
|
||||||
|
first/last/why fields are populated. Fan-out shape mirrors the §15
|
||||||
|
chokepoint contract: one row per recipient, written via `_emit_one`
|
||||||
|
so the SSE broadcast + email dispatch run through the same surface
|
||||||
|
every other notification uses. The event has no rfc_slug (it is
|
||||||
|
framework-scoped, not RFC-scoped); the deep-link payload points
|
||||||
|
`/admin/users` instead of `/rfc/<slug>`.
|
||||||
|
|
||||||
|
Actor is the requester per §15.9 (the underlying user, never the
|
||||||
|
bot). Category is `admin-actionable` so the §15.4 email gate
|
||||||
|
consults `email_admin_actionable` (owners/admins-only by
|
||||||
|
construction) and the digest exclusion rules treat it identically
|
||||||
|
to other admin-actionable signals (graduation_ready et al).
|
||||||
|
|
||||||
|
Recipients are owners + admins minus the requester themselves
|
||||||
|
(a self-promotion shouldn't reach the requester's own inbox). The
|
||||||
|
requester is never in the role set in practice — the endpoint
|
||||||
|
refuses 'granted'/'revoked' callers and a fresh OTC user lands
|
||||||
|
`contributor`+`pending` — but we filter regardless so the call
|
||||||
|
is robust to future changes in the auth gate.
|
||||||
|
"""
|
||||||
|
requester = db.conn().execute(
|
||||||
|
"SELECT first_name, last_name, email, display_name FROM users WHERE id = ?",
|
||||||
|
(requester_user_id,),
|
||||||
|
).fetchone()
|
||||||
|
if requester is None:
|
||||||
|
return
|
||||||
|
first = (requester["first_name"] or "").strip()
|
||||||
|
last = (requester["last_name"] or "").strip()
|
||||||
|
email = requester["email"] or ""
|
||||||
|
display = requester["display_name"] or email or "a new user"
|
||||||
|
full_name = (f"{first} {last}").strip() or display
|
||||||
|
details = {
|
||||||
|
"requester_user_id": requester_user_id,
|
||||||
|
"requester_first_name": first,
|
||||||
|
"requester_last_name": last,
|
||||||
|
"requester_email": email,
|
||||||
|
"requester_display": full_name,
|
||||||
|
}
|
||||||
|
for recipient_id in _admin_user_ids():
|
||||||
|
if recipient_id == requester_user_id:
|
||||||
|
continue
|
||||||
|
_emit_one(
|
||||||
|
recipient_user_id=recipient_id,
|
||||||
|
event_kind="new_beta_request",
|
||||||
|
category=CATEGORY_ADMIN_ACTIONABLE,
|
||||||
|
actor_user_id=requester_user_id,
|
||||||
|
rfc_slug=None,
|
||||||
|
branch_name=None,
|
||||||
|
pr_number=None,
|
||||||
|
details=details,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def fan_out_chat_message(
|
def fan_out_chat_message(
|
||||||
*,
|
*,
|
||||||
actor_user_id: int,
|
actor_user_id: int,
|
||||||
@@ -707,6 +769,16 @@ def render_summary(event_kind: str, actor_display: str | None, rfc_title: str |
|
|||||||
return f"{actor} began graduating {title}."
|
return f"{actor} began graduating {title}."
|
||||||
if event_kind == "pr_conflict_with_main":
|
if event_kind == "pr_conflict_with_main":
|
||||||
return f"{actor} started a resolution branch on {title}."
|
return f"{actor} started a resolution branch on {title}."
|
||||||
|
if event_kind == "new_beta_request":
|
||||||
|
# v0.9.0: framework-scoped, not RFC-scoped. The actor (the
|
||||||
|
# requester) and the captured full name + email read as
|
||||||
|
# one self-contained sentence; the inbox row and the email
|
||||||
|
# body share this text per §15.4.
|
||||||
|
full_name = extras.get("requester_display") or actor
|
||||||
|
email_addr = extras.get("requester_email") or ""
|
||||||
|
if email_addr:
|
||||||
|
return f"New beta-access request from {full_name} ({email_addr})."
|
||||||
|
return f"New beta-access request from {full_name}."
|
||||||
return f"{event_kind} on {title}"
|
return f"{event_kind} on {title}"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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);
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
-- §6 / §10 / v0.16.0: owner-only invite for per-RFC contribution +
|
||||||
|
-- discussion (roadmap item #12).
|
||||||
|
--
|
||||||
|
-- Distinct from a platform-level grant (`users.permission_state`,
|
||||||
|
-- v0.8.0 / item #6). This row is per-RFC membership: the RFC's owner
|
||||||
|
-- invites a specific email to either open PRs against that RFC
|
||||||
|
-- (`role_in_rfc='contributor'`) or to participate in the RFC's PR-less
|
||||||
|
-- discussion only (`role_in_rfc='discussant'`). Non-invited users keep
|
||||||
|
-- the v0.6.0 anonymous-read contract — they can read but cannot
|
||||||
|
-- write/discuss that specific RFC.
|
||||||
|
--
|
||||||
|
-- Coordinates with item #16's parallel work this wave: that item
|
||||||
|
-- adds platform-wide invitation tokens; this one adds per-RFC
|
||||||
|
-- collaboration rows. To avoid table-name + concept collisions the
|
||||||
|
-- two surfaces are scoped distinctly — this migration owns slot 018
|
||||||
|
-- and names everything `rfc_*` (RFC-scoped); #16 will use a later
|
||||||
|
-- slot and name its tables under a different prefix (`invite_tokens`
|
||||||
|
-- or similar) at the user/platform level.
|
||||||
|
--
|
||||||
|
-- Tables in this migration:
|
||||||
|
--
|
||||||
|
-- * `rfc_invitations` — one row per (rfc, invitee_email) invite
|
||||||
|
-- issued by the RFC's owner. Carries the role-in-RFC the
|
||||||
|
-- invitation grants, the opaque token the email link encodes,
|
||||||
|
-- the lifecycle state, and the audit trail (who invited, when
|
||||||
|
-- accepted, by which user_id if any).
|
||||||
|
--
|
||||||
|
-- * `rfc_collaborators` — one row per (rfc, user_id, role_in_rfc)
|
||||||
|
-- after an invitation is accepted. This is the table the
|
||||||
|
-- write-gate consults: "is the viewer named here for this RFC?"
|
||||||
|
-- Separating the two means the invitation row carries the
|
||||||
|
-- issue/accept lifecycle while the collaborator row is the
|
||||||
|
-- compact membership-check substrate. A grant via collaborator
|
||||||
|
-- can exist independently of a live invitation (admin-only
|
||||||
|
-- direct insert is a §19.2 candidate; v0.16.0 only writes
|
||||||
|
-- collaborator rows via the accept path).
|
||||||
|
--
|
||||||
|
-- Authorization model the application layer enforces on top of these
|
||||||
|
-- rows (not encoded in SQL — the schema is just storage):
|
||||||
|
--
|
||||||
|
-- * Writes (open PR, post discussion message, open discussion
|
||||||
|
-- thread) to an RFC require ONE of:
|
||||||
|
-- (a) the viewer is named in this RFC's `rfc_collaborators`
|
||||||
|
-- with the appropriate role_in_rfc, OR
|
||||||
|
-- (b) the viewer holds a globally privileged role (admin,
|
||||||
|
-- owner of the platform) per the existing §6 helpers, OR
|
||||||
|
-- (c) the viewer is named in the RFC's frontmatter owners
|
||||||
|
-- list (the §6 RFC-owner concept, which already grants
|
||||||
|
-- the maximal per-RFC capability).
|
||||||
|
--
|
||||||
|
-- * Reads remain on the v0.6.0 anonymous-read contract — anyone
|
||||||
|
-- can read any non-withdrawn RFC. Item #12 does not narrow this.
|
||||||
|
--
|
||||||
|
-- * Only the RFC's owner (per `cached_rfcs.owners_json`) can
|
||||||
|
-- invite. App admins/owners also can (they have the maximal
|
||||||
|
-- per-RFC capability by construction).
|
||||||
|
--
|
||||||
|
-- Storage shape — `rfc_invitations`:
|
||||||
|
--
|
||||||
|
-- * `id` — surrogate key; the revoke-by-id surface addresses a
|
||||||
|
-- single row without leaking the token shape.
|
||||||
|
--
|
||||||
|
-- * `rfc_slug` — TEXT NOT NULL; the RFC the invitation scopes to.
|
||||||
|
-- We FK against `cached_rfcs(slug)` so a withdrawn/deleted RFC
|
||||||
|
-- cascades its invitations away cleanly. The §4 cache contract
|
||||||
|
-- says cached_rfcs is rebuildable from Gitea; per the same
|
||||||
|
-- contract, invitations are app-truth (no Git substrate), so
|
||||||
|
-- the cascade is the right direction.
|
||||||
|
--
|
||||||
|
-- * `inviter_user_id` — the owner who issued the invite. ON
|
||||||
|
-- DELETE SET NULL because losing the inviter's user row should
|
||||||
|
-- not cascade-delete invitations they sent (the row stays as
|
||||||
|
-- audit; the UI renders "by (deleted user)" the same way the
|
||||||
|
-- audit log does for orphaned actors).
|
||||||
|
--
|
||||||
|
-- * `invitee_email` — TEXT NOT NULL; the email the invitation
|
||||||
|
-- was sent to. Stored verbatim (case-preserved) so the email
|
||||||
|
-- body can address the invitee in their original shape; the
|
||||||
|
-- accept path matches case-insensitively.
|
||||||
|
--
|
||||||
|
-- * `role_in_rfc` — CHECK in {'contributor' | 'discussant'}.
|
||||||
|
-- `contributor` lets the user open PRs against the RFC AND
|
||||||
|
-- post in its discussion (PR-permission strictly includes
|
||||||
|
-- discussion-permission); `discussant` only lets them post
|
||||||
|
-- in discussion. Future roles (e.g., 'arbiter') would be
|
||||||
|
-- additions; v0.16.0 ships the two.
|
||||||
|
--
|
||||||
|
-- * `status` — CHECK in {'pending' | 'accepted' | 'revoked' |
|
||||||
|
-- 'expired'}. Default 'pending'. `accepted` flips on the
|
||||||
|
-- accept endpoint; `revoked` on the owner's revoke gesture;
|
||||||
|
-- `expired` lazily on read (the accept endpoint refuses a
|
||||||
|
-- row whose expires_at has passed, regardless of the column
|
||||||
|
-- value).
|
||||||
|
--
|
||||||
|
-- * `token` — opaque high-entropy string the email link
|
||||||
|
-- encodes. Stored verbatim (not hashed) because the
|
||||||
|
-- invitation token is single-use and lower-stakes than a
|
||||||
|
-- session token: it grants per-RFC role only, and is bounded
|
||||||
|
-- by expires_at. Hashing the token here is a §19.2 candidate
|
||||||
|
-- if/when the threat model demands it. UNIQUE so the accept
|
||||||
|
-- path is a single-row lookup.
|
||||||
|
--
|
||||||
|
-- * `expires_at` — TEXT timestamp. Set to `created_at + 30 days`
|
||||||
|
-- at insert time by the application layer. Accept refuses past
|
||||||
|
-- this point; the row can still be revoked or re-issued.
|
||||||
|
--
|
||||||
|
-- * `created_at` — when the invitation was issued.
|
||||||
|
--
|
||||||
|
-- * `accepted_at` — when the invitee accepted (NULL until then).
|
||||||
|
--
|
||||||
|
-- * `accepted_by_user_id` — the user row that accepted. NULL
|
||||||
|
-- until acceptance. On a fresh email (no platform user yet)
|
||||||
|
-- the accept endpoint requires the invitee to sign in first
|
||||||
|
-- via the v0.7.0 OTC path; that path provisions the user row,
|
||||||
|
-- after which the accept call lands the user_id here.
|
||||||
|
--
|
||||||
|
-- Indexing:
|
||||||
|
--
|
||||||
|
-- * UNIQUE on `token` so the accept lookup is a primary-key-shape
|
||||||
|
-- hit and accidental collisions are detectable at insert time.
|
||||||
|
-- * (rfc_slug, status) for the owner's "list pending/accepted for
|
||||||
|
-- this RFC" surface — the most frequent query.
|
||||||
|
-- * (invitee_email, status) for a future cross-RFC "show me my
|
||||||
|
-- pending invites" inbox; v0.16.0 doesn't ship that surface but
|
||||||
|
-- the index slot is cheap and aligned with the data shape.
|
||||||
|
--
|
||||||
|
-- Storage shape — `rfc_collaborators`:
|
||||||
|
--
|
||||||
|
-- * `id` — surrogate key.
|
||||||
|
-- * `rfc_slug` — TEXT NOT NULL FK cached_rfcs(slug) ON DELETE CASCADE.
|
||||||
|
-- * `user_id` — INTEGER NOT NULL FK users(id) ON DELETE CASCADE.
|
||||||
|
-- A deleted user loses every per-RFC role automatically (mirrors
|
||||||
|
-- the device_trust / passcode cascade shape).
|
||||||
|
-- * `role_in_rfc` — same CHECK as the invitation table.
|
||||||
|
-- * `invitation_id` — INTEGER FK rfc_invitations(id) ON DELETE
|
||||||
|
-- SET NULL. Audit pointer to the row that minted this
|
||||||
|
-- collaborator; NULL is allowed so a future admin-direct grant
|
||||||
|
-- path (a §19.2 candidate) can mint a collaborator with no
|
||||||
|
-- originating invitation. v0.16.0 always populates this.
|
||||||
|
-- * `created_at` — when the collaborator row was minted.
|
||||||
|
--
|
||||||
|
-- Indexing on collaborators:
|
||||||
|
-- * UNIQUE on (rfc_slug, user_id) — a single user can hold at most
|
||||||
|
-- one role per RFC. Re-accepting an invitation upgrades the row
|
||||||
|
-- (discussant → contributor) but never duplicates.
|
||||||
|
-- * (user_id) for "what RFCs am I a collaborator on?" reads.
|
||||||
|
|
||||||
|
CREATE TABLE rfc_invitations (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
rfc_slug TEXT NOT NULL REFERENCES cached_rfcs(slug) ON DELETE CASCADE,
|
||||||
|
inviter_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
invitee_email TEXT NOT NULL,
|
||||||
|
role_in_rfc TEXT NOT NULL CHECK (role_in_rfc IN ('contributor', 'discussant')),
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending'
|
||||||
|
CHECK (status IN ('pending', 'accepted', 'revoked', 'expired')),
|
||||||
|
token TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
accepted_at TEXT,
|
||||||
|
accepted_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX idx_rfc_invitations_token ON rfc_invitations (token);
|
||||||
|
CREATE INDEX idx_rfc_invitations_rfc_status ON rfc_invitations (rfc_slug, status);
|
||||||
|
CREATE INDEX idx_rfc_invitations_email_status ON rfc_invitations (invitee_email, status);
|
||||||
|
|
||||||
|
CREATE TABLE rfc_collaborators (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
rfc_slug TEXT NOT NULL REFERENCES cached_rfcs(slug) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
role_in_rfc TEXT NOT NULL CHECK (role_in_rfc IN ('contributor', 'discussant')),
|
||||||
|
invitation_id INTEGER REFERENCES rfc_invitations(id) ON DELETE SET NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX idx_rfc_collaborators_unique ON rfc_collaborators (rfc_slug, user_id);
|
||||||
|
CREATE INDEX idx_rfc_collaborators_user ON rfc_collaborators (user_id);
|
||||||
@@ -0,0 +1,425 @@
|
|||||||
|
"""End-to-end integration tests for v0.9.0's admin user-management page
|
||||||
|
and new-beta-request notifications (roadmap item #7, §6.1 / §15).
|
||||||
|
|
||||||
|
The release lands two halves of the same surface:
|
||||||
|
|
||||||
|
* **Admin notification on new beta request.** When a pending user
|
||||||
|
submits `POST /api/auth/me/beta-request`, every owner/admin
|
||||||
|
receives a `new_beta_request` notification (the §15 substrate
|
||||||
|
insert lands the row; the §15.4 email path dispatches subject to
|
||||||
|
the recipient's `email_admin_actionable` toggle).
|
||||||
|
|
||||||
|
* **Admin user-management surface** at `/admin/users`. The
|
||||||
|
`GET /api/admin/users` listing carries every user with their
|
||||||
|
permission_state, profile fields, sign-up reason, and decision
|
||||||
|
audit. The new `POST /api/admin/users/<id>/permission` endpoint
|
||||||
|
flips the column and writes a `permission_events` row.
|
||||||
|
|
||||||
|
The tests prove:
|
||||||
|
|
||||||
|
* The first beta-request submission fans a `new_beta_request`
|
||||||
|
row out to every admin/owner (and not to the requester
|
||||||
|
themselves). The row carries the captured profile in
|
||||||
|
`payload.extras`.
|
||||||
|
* Re-submitting the form from the same pending user doesn't
|
||||||
|
re-fan (we only notify on the row's first complete state).
|
||||||
|
* `GET /api/admin/users` carries the v0.9.0 columns
|
||||||
|
(permission_state, first/last/reason, decided_by).
|
||||||
|
* `POST /api/admin/users/<id>/permission` flips the state,
|
||||||
|
stamps decided_by/at, and writes a `permission_events` row.
|
||||||
|
* The endpoint refuses self-flip (422) and refuses non-admin
|
||||||
|
callers (403).
|
||||||
|
* The endpoint accepts only the three valid states (422 on
|
||||||
|
anything else).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from test_propose_vertical import ( # noqa: F401 — fixtures land via import
|
||||||
|
FakeGitea,
|
||||||
|
app_with_fake_gitea,
|
||||||
|
provision_user_row,
|
||||||
|
sign_in_as,
|
||||||
|
tmp_env,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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 _provision_pending_user(client, email: str) -> int:
|
||||||
|
"""Sign in a fresh OTC user (lands `pending`) and return their user_id."""
|
||||||
|
from app import db
|
||||||
|
_reset_outbound()
|
||||||
|
client.post("/auth/otc/request", json={"email": email})
|
||||||
|
code = _outbound_otc_codes(email)[-1]
|
||||||
|
client.post("/auth/otc/verify", json={"email": email, "code": code})
|
||||||
|
row = db.conn().execute(
|
||||||
|
"SELECT id FROM users WHERE email = ? COLLATE NOCASE", (email,)
|
||||||
|
).fetchone()
|
||||||
|
return row["id"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Admin notification on beta-request submission
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_beta_request_submission_notifies_every_admin(app_with_fake_gitea):
|
||||||
|
"""First-time submission of a beta-request fans a notification out
|
||||||
|
to every owner and admin. The requester themselves never receives
|
||||||
|
a row (filtered out by user_id even if they happened to be in the
|
||||||
|
admin set, which they aren't in practice — fresh OTC users are
|
||||||
|
`contributor`+`pending`)."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
# Provision two admins and one owner so the fan-out has multiple
|
||||||
|
# targets. The OWNER_GITEA_LOGIN-derived ownership doesn't fire
|
||||||
|
# here (no OAuth round-trip in this path); we seed the role
|
||||||
|
# directly.
|
||||||
|
provision_user_row(user_id=10, login="ownerzero", role="owner")
|
||||||
|
provision_user_row(user_id=11, login="admin_one", role="admin")
|
||||||
|
provision_user_row(user_id=12, login="admin_two", role="admin")
|
||||||
|
provision_user_row(user_id=13, login="contrib_one", role="contributor")
|
||||||
|
|
||||||
|
# Sign in a fresh OTC user → permission_state='pending'.
|
||||||
|
requester_id = _provision_pending_user(client, "newbie@example.com")
|
||||||
|
|
||||||
|
# Capture-form submit.
|
||||||
|
r = client.post(
|
||||||
|
"/api/auth/me/beta-request",
|
||||||
|
json={
|
||||||
|
"first_name": "Newt",
|
||||||
|
"last_name": "Newcomer",
|
||||||
|
"beta_request_reason": "I want to write the Human RFC.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
# Every owner + admin gets a `new_beta_request` notification.
|
||||||
|
# The contributor (id=13) does not. The requester (whoever id
|
||||||
|
# they got) does not.
|
||||||
|
rows = db.conn().execute(
|
||||||
|
"""
|
||||||
|
SELECT recipient_user_id, event_kind, actor_user_id, payload
|
||||||
|
FROM notifications
|
||||||
|
WHERE event_kind = 'new_beta_request'
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
recipients = sorted(r["recipient_user_id"] for r in rows)
|
||||||
|
assert recipients == [10, 11, 12], f"unexpected recipients: {recipients}"
|
||||||
|
# Actor is the requester (§15.9: never the bot).
|
||||||
|
for r in rows:
|
||||||
|
assert r["actor_user_id"] == requester_id
|
||||||
|
import json as _json
|
||||||
|
extras = _json.loads(r["payload"])
|
||||||
|
assert extras["requester_first_name"] == "Newt"
|
||||||
|
assert extras["requester_last_name"] == "Newcomer"
|
||||||
|
assert extras["requester_email"] == "newbie@example.com"
|
||||||
|
|
||||||
|
|
||||||
|
def test_beta_request_resubmit_does_not_re_notify(app_with_fake_gitea):
|
||||||
|
"""Once a user has completed the capture form, re-submitting it
|
||||||
|
(the endpoint is idempotent for pending users) must not re-fan a
|
||||||
|
fresh notification to every admin — that would carpet-bomb the
|
||||||
|
inbox on every typo correction."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
provision_user_row(user_id=20, login="adminzero", role="admin")
|
||||||
|
_provision_pending_user(client, "carpet@example.com")
|
||||||
|
|
||||||
|
body = {
|
||||||
|
"first_name": "Carpet",
|
||||||
|
"last_name": "Bomb",
|
||||||
|
"beta_request_reason": "first draft",
|
||||||
|
}
|
||||||
|
r1 = client.post("/api/auth/me/beta-request", json=body)
|
||||||
|
assert r1.status_code == 200
|
||||||
|
|
||||||
|
# Re-submit with edited reason — endpoint accepts (idempotent
|
||||||
|
# update), but the admin inbox stays at one row.
|
||||||
|
body2 = dict(body, beta_request_reason="cleaner final draft")
|
||||||
|
r2 = client.post("/api/auth/me/beta-request", json=body2)
|
||||||
|
assert r2.status_code == 200
|
||||||
|
|
||||||
|
rows = db.conn().execute(
|
||||||
|
"SELECT COUNT(*) AS n FROM notifications WHERE event_kind = 'new_beta_request'"
|
||||||
|
).fetchone()
|
||||||
|
assert rows["n"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_beta_request_notification_is_admin_actionable_category(app_with_fake_gitea):
|
||||||
|
"""The §15.4 category mapping must route `new_beta_request` to the
|
||||||
|
admin-actionable bucket so the email gate consults
|
||||||
|
`email_admin_actionable` (and skips for non-admin recipients).
|
||||||
|
"""
|
||||||
|
from app import email as email_mod
|
||||||
|
|
||||||
|
assert email_mod.category_for("new_beta_request", "structural") == "admin-actionable"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# /api/admin/users — listing carries the v0.9.0 columns
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_users_listing_carries_permission_columns(app_with_fake_gitea):
|
||||||
|
"""The Users tab consumes this shape — confirm every required
|
||||||
|
column is on the response."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
# Seed an admin and a pending user with all the v0.8.0 columns
|
||||||
|
# populated. Direct-DB insert avoids the OTC dance (which would
|
||||||
|
# overwrite the cookie); the test above proves the capture
|
||||||
|
# pathway end-to-end and this one just exercises the listing
|
||||||
|
# surface's shape.
|
||||||
|
provision_user_row(user_id=30, login="ben", role="owner")
|
||||||
|
db.conn().execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO users (id, gitea_id, gitea_login, email,
|
||||||
|
display_name, avatar_url, role,
|
||||||
|
permission_state, first_name, last_name,
|
||||||
|
beta_request_reason)
|
||||||
|
VALUES (31, NULL, NULL, 'pendinguser@example.com',
|
||||||
|
'pendinguser', '', 'contributor',
|
||||||
|
'pending', 'Penn', 'Ding', 'I want in.')
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
sign_in_as(
|
||||||
|
client, user_id=30, gitea_login="ben",
|
||||||
|
display_name="Ben", role="owner",
|
||||||
|
)
|
||||||
|
|
||||||
|
r = client.get("/api/admin/users")
|
||||||
|
assert r.status_code == 200
|
||||||
|
items = r.json()["items"]
|
||||||
|
assert isinstance(items, list)
|
||||||
|
pending = next(
|
||||||
|
(i for i in items if i["email"] == "pendinguser@example.com"), None,
|
||||||
|
)
|
||||||
|
assert pending is not None
|
||||||
|
assert pending["permission_state"] == "pending"
|
||||||
|
assert pending["first_name"] == "Penn"
|
||||||
|
assert pending["last_name"] == "Ding"
|
||||||
|
assert pending["beta_request_reason"] == "I want in."
|
||||||
|
assert pending["permission_decided_at"] is None
|
||||||
|
assert pending["permission_decided_by_login"] is None
|
||||||
|
# Pending bucket is listed first (sort order).
|
||||||
|
assert items[0]["permission_state"] == "pending"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# /api/admin/users/<id>/permission — the flip endpoint
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_permission_flip_grant_promotes_pending_to_granted(app_with_fake_gitea):
|
||||||
|
"""The end-to-end gesture: a fresh OTC user lands pending, an admin
|
||||||
|
flips them to granted via the endpoint, the row reflects the new
|
||||||
|
state + decided_by/at, and a `permission_events` audit row lands."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
# Pending user.
|
||||||
|
pending_id = _provision_pending_user(client, "flip@example.com")
|
||||||
|
|
||||||
|
# Admin acting on them.
|
||||||
|
provision_user_row(user_id=40, login="adminflipper", role="admin")
|
||||||
|
sign_in_as(
|
||||||
|
client, user_id=40, gitea_login="adminflipper",
|
||||||
|
display_name="Admin Flipper", role="admin",
|
||||||
|
)
|
||||||
|
|
||||||
|
r = client.post(
|
||||||
|
f"/api/admin/users/{pending_id}/permission",
|
||||||
|
json={"state": "granted"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
body = r.json()
|
||||||
|
assert body["permission_state"] == "granted"
|
||||||
|
assert body["changed"] is True
|
||||||
|
|
||||||
|
# Row reflects the new state + decision stamp.
|
||||||
|
row = db.conn().execute(
|
||||||
|
"SELECT permission_state, permission_decided_by, permission_decided_at "
|
||||||
|
"FROM users WHERE id = ?",
|
||||||
|
(pending_id,),
|
||||||
|
).fetchone()
|
||||||
|
assert row["permission_state"] == "granted"
|
||||||
|
assert row["permission_decided_by"] == 40
|
||||||
|
assert row["permission_decided_at"] is not None
|
||||||
|
|
||||||
|
# Audit row landed in permission_events.
|
||||||
|
events = db.conn().execute(
|
||||||
|
"""
|
||||||
|
SELECT actor_user_id, subject_user_id, event_kind
|
||||||
|
FROM permission_events
|
||||||
|
WHERE event_kind = 'permission_granted'
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
assert len(events) == 1
|
||||||
|
assert events[0]["actor_user_id"] == 40
|
||||||
|
assert events[0]["subject_user_id"] == pending_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_permission_flip_revoke_promotes_granted_to_revoked(app_with_fake_gitea):
|
||||||
|
"""Revoke is the symmetric gesture. Used when an account earned a
|
||||||
|
grant then later lost it (§6.1 / `revoked` state)."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
provision_user_row(user_id=50, login="goner", role="contributor")
|
||||||
|
# Default permission_state is 'granted' via the column default.
|
||||||
|
provision_user_row(user_id=51, login="adminrevoker", role="admin")
|
||||||
|
sign_in_as(
|
||||||
|
client, user_id=51, gitea_login="adminrevoker",
|
||||||
|
display_name="Admin Revoker", role="admin",
|
||||||
|
)
|
||||||
|
|
||||||
|
r = client.post(
|
||||||
|
"/api/admin/users/50/permission",
|
||||||
|
json={"state": "revoked"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
row = db.conn().execute(
|
||||||
|
"SELECT permission_state FROM users WHERE id = 50"
|
||||||
|
).fetchone()
|
||||||
|
assert row["permission_state"] == "revoked"
|
||||||
|
|
||||||
|
events = db.conn().execute(
|
||||||
|
"SELECT event_kind FROM permission_events "
|
||||||
|
"WHERE event_kind = 'permission_revoked' AND subject_user_id = 50"
|
||||||
|
).fetchall()
|
||||||
|
assert len(events) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_permission_flip_refuses_self(app_with_fake_gitea):
|
||||||
|
"""Symmetric to set_mute / set_role: an admin can't self-flip.
|
||||||
|
The state-change channel for one's own grant is somebody else's
|
||||||
|
hand."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
provision_user_row(user_id=60, login="selfflipper", role="admin")
|
||||||
|
sign_in_as(
|
||||||
|
client, user_id=60, gitea_login="selfflipper",
|
||||||
|
display_name="Self Flipper", role="admin",
|
||||||
|
)
|
||||||
|
r = client.post(
|
||||||
|
"/api/admin/users/60/permission",
|
||||||
|
json={"state": "revoked"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_permission_flip_refuses_non_admin(app_with_fake_gitea):
|
||||||
|
"""The endpoint is admin-only (§17 admin/* requires require_admin).
|
||||||
|
A contributor caller is refused 403; an anonymous caller 401."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
provision_user_row(user_id=70, login="target", role="contributor")
|
||||||
|
provision_user_row(user_id=71, login="contrib", role="contributor")
|
||||||
|
sign_in_as(
|
||||||
|
client, user_id=71, gitea_login="contrib",
|
||||||
|
display_name="Contrib", role="contributor",
|
||||||
|
)
|
||||||
|
r = client.post(
|
||||||
|
"/api/admin/users/70/permission",
|
||||||
|
json={"state": "granted"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 403
|
||||||
|
|
||||||
|
client.cookies.clear()
|
||||||
|
r = client.post(
|
||||||
|
"/api/admin/users/70/permission",
|
||||||
|
json={"state": "granted"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_permission_flip_refuses_invalid_state(app_with_fake_gitea):
|
||||||
|
"""Pydantic regex pattern refuses anything outside the three
|
||||||
|
canonical states with 422."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
provision_user_row(user_id=80, login="targetx", role="contributor")
|
||||||
|
provision_user_row(user_id=81, login="adminx", role="admin")
|
||||||
|
sign_in_as(
|
||||||
|
client, user_id=81, gitea_login="adminx",
|
||||||
|
display_name="Admin X", role="admin",
|
||||||
|
)
|
||||||
|
r = client.post(
|
||||||
|
"/api/admin/users/80/permission",
|
||||||
|
json={"state": "banished"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_permission_flip_no_op_when_state_already_matches(app_with_fake_gitea):
|
||||||
|
"""An admin flipping a granted user to granted gets 200 with
|
||||||
|
`changed: false` — no audit row, no decided_at update."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
provision_user_row(user_id=90, login="alreadygranted", role="contributor")
|
||||||
|
provision_user_row(user_id=91, login="adminN", role="admin")
|
||||||
|
sign_in_as(
|
||||||
|
client, user_id=91, gitea_login="adminN",
|
||||||
|
display_name="Admin N", role="admin",
|
||||||
|
)
|
||||||
|
|
||||||
|
before_events = db.conn().execute(
|
||||||
|
"SELECT COUNT(*) AS n FROM permission_events"
|
||||||
|
).fetchone()["n"]
|
||||||
|
|
||||||
|
r = client.post(
|
||||||
|
"/api/admin/users/90/permission",
|
||||||
|
json={"state": "granted"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["changed"] is False
|
||||||
|
|
||||||
|
after_events = db.conn().execute(
|
||||||
|
"SELECT COUNT(*) AS n FROM permission_events"
|
||||||
|
).fetchone()["n"]
|
||||||
|
assert after_events == before_events
|
||||||
@@ -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"] == []
|
||||||
@@ -22,6 +22,7 @@ import pytest
|
|||||||
from test_propose_vertical import ( # noqa: F401
|
from test_propose_vertical import ( # noqa: F401
|
||||||
FakeGitea,
|
FakeGitea,
|
||||||
app_with_fake_gitea,
|
app_with_fake_gitea,
|
||||||
|
grant_rfc_collaborator,
|
||||||
provision_user_row,
|
provision_user_row,
|
||||||
sign_in_as,
|
sign_in_as,
|
||||||
tmp_env,
|
tmp_env,
|
||||||
@@ -131,6 +132,11 @@ def test_full_user_lifecycle_propose_through_hygiene(app_with_fake_gitea):
|
|||||||
assert d["repo"] == "wiggleverse/rfc-0001-ohm"
|
assert d["repo"] == "wiggleverse/rfc-0001-ohm"
|
||||||
|
|
||||||
# --- 8. Alice opens a PR on the now-active RFC's per-RFC repo. ---
|
# --- 8. Alice opens a PR on the now-active RFC's per-RFC repo. ---
|
||||||
|
# v0.16.0 (item #12): ben is the RFC owner now; alice needs a
|
||||||
|
# per-RFC contributor invitation to cut a branch. In the
|
||||||
|
# production flow, ben would invite her via /invitations and
|
||||||
|
# she'd accept; we shortcut to the same end-state.
|
||||||
|
grant_rfc_collaborator(user_id=2, rfc_slug="ohm", role_in_rfc="contributor")
|
||||||
sign_in_as(client, user_id=2, gitea_login="alice",
|
sign_in_as(client, user_id=2, gitea_login="alice",
|
||||||
display_name="Alice", role="contributor", email="alice@test")
|
display_name="Alice", role="contributor", email="alice@test")
|
||||||
r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={})
|
r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={})
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import pytest
|
|||||||
from test_propose_vertical import ( # noqa: F401
|
from test_propose_vertical import ( # noqa: F401
|
||||||
FakeGitea,
|
FakeGitea,
|
||||||
app_with_fake_gitea,
|
app_with_fake_gitea,
|
||||||
|
grant_rfc_collaborator,
|
||||||
provision_user_row,
|
provision_user_row,
|
||||||
sign_in_as,
|
sign_in_as,
|
||||||
tmp_env,
|
tmp_env,
|
||||||
@@ -248,6 +249,9 @@ def test_graduate_refuses_when_body_edit_pr_open(app_with_fake_gitea):
|
|||||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||||
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
||||||
pitch=PITCH, owners=["ben"])
|
pitch=PITCH, owners=["ben"])
|
||||||
|
# v0.16.0 (item #12): ben is the RFC owner; alice needs a per-RFC
|
||||||
|
# contributor invitation to cut an edit branch on the super-draft.
|
||||||
|
grant_rfc_collaborator(user_id=2, rfc_slug="ohm", role_in_rfc="contributor")
|
||||||
sign_in_as(client, user_id=2, gitea_login="alice",
|
sign_in_as(client, user_id=2, gitea_login="alice",
|
||||||
display_name="Alice", role="contributor")
|
display_name="Alice", role="contributor")
|
||||||
|
|
||||||
@@ -497,6 +501,8 @@ def test_pre_graduation_history_surfaces_edit_branch_threads(app_with_fake_gitea
|
|||||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||||
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
||||||
pitch=PITCH, owners=["ben"])
|
pitch=PITCH, owners=["ben"])
|
||||||
|
# v0.16.0 (item #12): alice needs per-RFC contributor access.
|
||||||
|
grant_rfc_collaborator(user_id=2, rfc_slug="ohm", role_in_rfc="contributor")
|
||||||
|
|
||||||
# Alice cuts an edit branch and starts chatting on it.
|
# Alice cuts an edit branch and starts chatting on it.
|
||||||
sign_in_as(client, user_id=2, gitea_login="alice",
|
sign_in_as(client, user_id=2, gitea_login="alice",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import pytest
|
|||||||
from test_propose_vertical import ( # noqa: F401
|
from test_propose_vertical import ( # noqa: F401
|
||||||
FakeGitea,
|
FakeGitea,
|
||||||
app_with_fake_gitea,
|
app_with_fake_gitea,
|
||||||
|
grant_rfc_collaborator,
|
||||||
provision_user_row,
|
provision_user_row,
|
||||||
sign_in_as,
|
sign_in_as,
|
||||||
tmp_env,
|
tmp_env,
|
||||||
@@ -140,6 +141,9 @@ def test_get_pr_returns_three_column_payload(app_with_fake_gitea):
|
|||||||
provision_user_row(user_id=3, login="bob", role="contributor")
|
provision_user_row(user_id=3, login="bob", role="contributor")
|
||||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||||
# Bob is the non-arbiter contributor — alice is seeded as an RFC owner.
|
# Bob is the non-arbiter contributor — alice is seeded as an RFC owner.
|
||||||
|
# v0.16.0 (item #12): bob needs an accepted per-RFC contributor
|
||||||
|
# invitation to cut branches and open PRs on alice's RFC.
|
||||||
|
grant_rfc_collaborator(user_id=3, rfc_slug="ohm", role_in_rfc="contributor")
|
||||||
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob", role="contributor")
|
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob", role="contributor")
|
||||||
branch, _ = _cut_branch_and_accept_change(
|
branch, _ = _cut_branch_and_accept_change(
|
||||||
client, fake, slug="ohm",
|
client, fake, slug="ohm",
|
||||||
@@ -292,6 +296,9 @@ def test_merge_by_arbiter_advances_main_and_marks_pr_merged(app_with_fake_gitea)
|
|||||||
provision_user_row(user_id=1, login="ben", role="owner")
|
provision_user_row(user_id=1, login="ben", role="owner")
|
||||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||||
# Bob is neither owner nor arbiter — the non-merge baseline.
|
# Bob is neither owner nor arbiter — the non-merge baseline.
|
||||||
|
# v0.16.0 (item #12): bob still needs an accepted contributor
|
||||||
|
# invitation to cut the branch + open the PR.
|
||||||
|
grant_rfc_collaborator(user_id=3, rfc_slug="ohm", role_in_rfc="contributor")
|
||||||
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob", role="contributor")
|
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob", role="contributor")
|
||||||
branch, _ = _cut_branch_and_accept_change(
|
branch, _ = _cut_branch_and_accept_change(
|
||||||
client, fake, slug="ohm",
|
client, fake, slug="ohm",
|
||||||
@@ -364,6 +371,10 @@ def test_resolution_branch_replays_clean_and_supersedes_on_merge(app_with_fake_g
|
|||||||
provision_user_row(user_id=3, login="bob", role="contributor")
|
provision_user_row(user_id=3, login="bob", role="contributor")
|
||||||
provision_user_row(user_id=1, login="ben", role="owner")
|
provision_user_row(user_id=1, login="ben", role="owner")
|
||||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||||
|
# v0.16.0 (item #12): bob (a non-owner contributor) needs an
|
||||||
|
# accepted per-RFC invitation to cut a branch on alice's RFC.
|
||||||
|
# Alice is the seeded RFC owner so she doesn't need one.
|
||||||
|
grant_rfc_collaborator(user_id=3, rfc_slug="ohm", role_in_rfc="contributor")
|
||||||
|
|
||||||
# Alice cuts a branch and accepts a change on it.
|
# Alice cuts a branch and accepts a change on it.
|
||||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||||
|
|||||||
@@ -395,6 +395,28 @@ def provision_user_row(*, user_id: int, login: str, role: str) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def grant_rfc_collaborator(*, user_id: int, rfc_slug: str, role_in_rfc: str = "contributor") -> None:
|
||||||
|
"""v0.16.0 / item #12 test seam: directly insert an accepted-
|
||||||
|
invitation collaborator row so a non-owner contributor can pass
|
||||||
|
the per-RFC write gate without going through the email round-trip.
|
||||||
|
|
||||||
|
Equivalent in effect to the invitation→accept dance the production
|
||||||
|
code drives; lets v0.5.0/v0.6.0/v0.8.0 era tests preserve their
|
||||||
|
"alice owns OHM, bob contributes" shape without rewriting the
|
||||||
|
setup. The invitation_id is left NULL — collaborators minted via
|
||||||
|
a direct admin gesture (a §19.2 candidate) carry the same shape.
|
||||||
|
"""
|
||||||
|
from app import db
|
||||||
|
db.conn().execute(
|
||||||
|
"""
|
||||||
|
INSERT OR REPLACE INTO rfc_collaborators
|
||||||
|
(rfc_slug, user_id, role_in_rfc, invitation_id)
|
||||||
|
VALUES (?, ?, ?, NULL)
|
||||||
|
""",
|
||||||
|
(rfc_slug, user_id, role_in_rfc),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Fixtures
|
# Fixtures
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,658 @@
|
|||||||
|
"""End-to-end integration tests for v0.16.0's owner-only invite for
|
||||||
|
per-RFC PR or PR-less discussion (roadmap item #12, §6 / §10).
|
||||||
|
|
||||||
|
The release lands a per-RFC membership layer:
|
||||||
|
|
||||||
|
* `rfc_invitations` — issued by the RFC's owner, addressed to an
|
||||||
|
email, granting one of two roles ('contributor' or 'discussant').
|
||||||
|
* `rfc_collaborators` — the accepted-invitation substrate; the
|
||||||
|
table the per-RFC write gate consults.
|
||||||
|
|
||||||
|
The tests prove:
|
||||||
|
|
||||||
|
* Only the RFC's owner (or a platform admin/owner) can invite —
|
||||||
|
a platform-granted but non-owner user gets 403.
|
||||||
|
* Creating an invitation lands a row, mints a token, and queues
|
||||||
|
an envelope on the SMTP buffer.
|
||||||
|
* Re-inviting the same (email, role) on the same RFC returns 409.
|
||||||
|
* The accept endpoint requires the accepting user's email to match
|
||||||
|
the invitee_email (case-insensitive).
|
||||||
|
* Acceptance lands a rfc_collaborators row and flips the
|
||||||
|
invitation to 'accepted'.
|
||||||
|
* Re-accepting the same invitation is idempotent (200, changed=false).
|
||||||
|
* An expired invitation refuses 409 even if the row's column status
|
||||||
|
is still 'pending'.
|
||||||
|
* A revoked invitation refuses 409.
|
||||||
|
* The owner's listing carries pending + accepted in one response.
|
||||||
|
* The per-RFC discussion-write gate refuses a non-invited
|
||||||
|
platform-granted user 403 (was previously 200 before v0.16.0).
|
||||||
|
* The same gate admits a user who holds an accepted 'discussant'
|
||||||
|
invitation.
|
||||||
|
* The same gate admits a user who holds an accepted 'contributor'
|
||||||
|
invitation (contributor strictly includes discussion).
|
||||||
|
* The platform admin/owner is admitted regardless of per-RFC
|
||||||
|
membership (the platform-level capability path).
|
||||||
|
* The /api/admin/users listing carries `rfc_invitations` per-user
|
||||||
|
after an acceptance — the §17 admin surface hook.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# Reuse fixtures and helpers from the propose / RFC-view harnesses.
|
||||||
|
from test_propose_vertical import ( # noqa: F401 — fixtures land via import
|
||||||
|
FakeGitea,
|
||||||
|
app_with_fake_gitea,
|
||||||
|
provision_user_row,
|
||||||
|
sign_in_as,
|
||||||
|
tmp_env,
|
||||||
|
)
|
||||||
|
from test_rfc_view_vertical import seed_active_rfc, SEED_BODY
|
||||||
|
|
||||||
|
|
||||||
|
def _reset_outbound():
|
||||||
|
from app import email as email_mod
|
||||||
|
email_mod.reset_sent_envelopes()
|
||||||
|
|
||||||
|
|
||||||
|
def _invitation_envelopes(to_address: str | None = None) -> list[dict]:
|
||||||
|
"""Pluck v0.16.0 invitation envelopes out of the shared _SENT buffer.
|
||||||
|
Same access pattern as the OTC tests use for `kind='otc'`."""
|
||||||
|
from app import email as email_mod
|
||||||
|
out = []
|
||||||
|
for env in email_mod.sent_envelopes():
|
||||||
|
if env.get("kind") != "rfc_invitation":
|
||||||
|
continue
|
||||||
|
if to_address is not None and env["to"] != to_address:
|
||||||
|
continue
|
||||||
|
out.append(env)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Create / list / revoke (owner-side)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_owner_can_invite_creates_row_and_sends_email(app_with_fake_gitea):
|
||||||
|
"""The end-to-end create gesture: RFC owner posts an invitation,
|
||||||
|
a row lands, the token comes back in the response, and an
|
||||||
|
`rfc_invitation`-kind envelope hits the SMTP buffer."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_reset_outbound()
|
||||||
|
# The frontmatter owner of the seeded RFC is "alice" (per
|
||||||
|
# seed_active_rfc's default), so we sign in as that user.
|
||||||
|
provision_user_row(user_id=1, login="alice", role="contributor")
|
||||||
|
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||||
|
sign_in_as(
|
||||||
|
client, user_id=1, gitea_login="alice",
|
||||||
|
display_name="Alice", role="contributor",
|
||||||
|
)
|
||||||
|
|
||||||
|
r = client.post(
|
||||||
|
"/api/rfcs/ohm/invitations",
|
||||||
|
json={"invitee_email": "newperson@example.com", "role_in_rfc": "contributor"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
body = r.json()
|
||||||
|
assert body["rfc_slug"] == "ohm"
|
||||||
|
assert body["invitee_email"] == "newperson@example.com"
|
||||||
|
assert body["role_in_rfc"] == "contributor"
|
||||||
|
assert body["status"] == "pending"
|
||||||
|
assert body["token"] and len(body["token"]) > 16
|
||||||
|
|
||||||
|
# Row landed.
|
||||||
|
row = db.conn().execute(
|
||||||
|
"SELECT * FROM rfc_invitations WHERE id = ?", (body["id"],),
|
||||||
|
).fetchone()
|
||||||
|
assert row["rfc_slug"] == "ohm"
|
||||||
|
assert row["invitee_email"] == "newperson@example.com"
|
||||||
|
assert row["inviter_user_id"] == 1
|
||||||
|
assert row["status"] == "pending"
|
||||||
|
|
||||||
|
# Email envelope went out.
|
||||||
|
envs = _invitation_envelopes("newperson@example.com")
|
||||||
|
assert len(envs) == 1
|
||||||
|
assert "OHM" in envs[0]["subject"]
|
||||||
|
assert body["token"] in envs[0]["body"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_owner_cannot_invite(app_with_fake_gitea):
|
||||||
|
"""A platform-granted user who isn't in the RFC's frontmatter
|
||||||
|
owners list cannot invite — 403. Distinct from the
|
||||||
|
require_contributor gate (which would be 401 for anonymous)."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
# alice is the RFC owner per the seed; bob is a regular
|
||||||
|
# platform-granted contributor with no per-RFC role.
|
||||||
|
provision_user_row(user_id=1, login="alice", role="contributor")
|
||||||
|
provision_user_row(user_id=2, login="bob", role="contributor")
|
||||||
|
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||||
|
sign_in_as(
|
||||||
|
client, user_id=2, gitea_login="bob",
|
||||||
|
display_name="Bob", role="contributor",
|
||||||
|
)
|
||||||
|
r = client.post(
|
||||||
|
"/api/rfcs/ohm/invitations",
|
||||||
|
json={"invitee_email": "ignored@example.com", "role_in_rfc": "discussant"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_platform_admin_can_invite_to_any_rfc(app_with_fake_gitea):
|
||||||
|
"""Per §6.1 the platform admin/owner role carries the maximal
|
||||||
|
per-RFC capability, so admins can invite on any RFC even if
|
||||||
|
they're not in its owners list."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_reset_outbound()
|
||||||
|
provision_user_row(user_id=1, login="alice", role="contributor")
|
||||||
|
provision_user_row(user_id=99, login="adminzero", role="admin")
|
||||||
|
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||||
|
sign_in_as(
|
||||||
|
client, user_id=99, gitea_login="adminzero",
|
||||||
|
display_name="Admin Zero", role="admin",
|
||||||
|
)
|
||||||
|
r = client.post(
|
||||||
|
"/api/rfcs/ohm/invitations",
|
||||||
|
json={"invitee_email": "another@example.com", "role_in_rfc": "discussant"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_anonymous_cannot_invite(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||||
|
r = client.post(
|
||||||
|
"/api/rfcs/ohm/invitations",
|
||||||
|
json={"invitee_email": "x@example.com", "role_in_rfc": "discussant"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_re_invite_same_email_and_role_returns_409(app_with_fake_gitea):
|
||||||
|
"""Refuse a duplicate pending invitation for the same (email, role)
|
||||||
|
on the same RFC. A different role on the same email is allowed
|
||||||
|
(the owner may want to upgrade discussant → contributor)."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_reset_outbound()
|
||||||
|
provision_user_row(user_id=1, login="alice", role="contributor")
|
||||||
|
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||||
|
sign_in_as(
|
||||||
|
client, user_id=1, gitea_login="alice",
|
||||||
|
display_name="Alice", role="contributor",
|
||||||
|
)
|
||||||
|
|
||||||
|
r1 = client.post(
|
||||||
|
"/api/rfcs/ohm/invitations",
|
||||||
|
json={"invitee_email": "dup@example.com", "role_in_rfc": "discussant"},
|
||||||
|
)
|
||||||
|
assert r1.status_code == 200
|
||||||
|
r2 = client.post(
|
||||||
|
"/api/rfcs/ohm/invitations",
|
||||||
|
json={"invitee_email": "dup@example.com", "role_in_rfc": "discussant"},
|
||||||
|
)
|
||||||
|
assert r2.status_code == 409
|
||||||
|
|
||||||
|
# Same email, different role is allowed.
|
||||||
|
r3 = client.post(
|
||||||
|
"/api/rfcs/ohm/invitations",
|
||||||
|
json={"invitee_email": "dup@example.com", "role_in_rfc": "contributor"},
|
||||||
|
)
|
||||||
|
assert r3.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_owner_can_list_invitations(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_reset_outbound()
|
||||||
|
provision_user_row(user_id=1, login="alice", role="contributor")
|
||||||
|
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||||
|
sign_in_as(
|
||||||
|
client, user_id=1, gitea_login="alice",
|
||||||
|
display_name="Alice", role="contributor",
|
||||||
|
)
|
||||||
|
|
||||||
|
client.post("/api/rfcs/ohm/invitations",
|
||||||
|
json={"invitee_email": "a@example.com", "role_in_rfc": "discussant"})
|
||||||
|
client.post("/api/rfcs/ohm/invitations",
|
||||||
|
json={"invitee_email": "b@example.com", "role_in_rfc": "contributor"})
|
||||||
|
|
||||||
|
r = client.get("/api/rfcs/ohm/invitations")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
items = r.json()["items"]
|
||||||
|
emails = sorted(i["invitee_email"] for i in items)
|
||||||
|
assert emails == ["a@example.com", "b@example.com"]
|
||||||
|
assert all(i["status"] == "pending" for i in items)
|
||||||
|
# The inviter is named.
|
||||||
|
assert all(i["inviter_login"] == "alice" for i in items)
|
||||||
|
|
||||||
|
|
||||||
|
def test_revoke_pending_invitation_works_already_accepted_refuses(app_with_fake_gitea):
|
||||||
|
"""Revoke flips a pending invitation to 'revoked'. An already-
|
||||||
|
accepted invitation refuses 409 — accepted membership is removed
|
||||||
|
via a different (future) surface; the v0.16.0 revoke only lifts
|
||||||
|
the pending link."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_reset_outbound()
|
||||||
|
provision_user_row(user_id=1, login="alice", role="contributor")
|
||||||
|
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||||
|
sign_in_as(
|
||||||
|
client, user_id=1, gitea_login="alice",
|
||||||
|
display_name="Alice", role="contributor",
|
||||||
|
)
|
||||||
|
|
||||||
|
r = client.post(
|
||||||
|
"/api/rfcs/ohm/invitations",
|
||||||
|
json={"invitee_email": "revokee@example.com", "role_in_rfc": "discussant"},
|
||||||
|
)
|
||||||
|
invitation_id = r.json()["id"]
|
||||||
|
|
||||||
|
r = client.post(f"/api/rfcs/ohm/invitations/{invitation_id}/revoke")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["status"] == "revoked"
|
||||||
|
|
||||||
|
# Re-revoke refuses 409.
|
||||||
|
r2 = client.post(f"/api/rfcs/ohm/invitations/{invitation_id}/revoke")
|
||||||
|
assert r2.status_code == 409
|
||||||
|
|
||||||
|
row = db.conn().execute(
|
||||||
|
"SELECT status FROM rfc_invitations WHERE id = ?", (invitation_id,),
|
||||||
|
).fetchone()
|
||||||
|
assert row["status"] == "revoked"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Accept (invitee-side)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_accept_invitation_lands_collaborator_row(app_with_fake_gitea):
|
||||||
|
"""The end-to-end accept gesture: the invitee signs in, posts the
|
||||||
|
token, and an rfc_collaborators row lands at the issued role."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_reset_outbound()
|
||||||
|
provision_user_row(user_id=1, login="alice", role="contributor")
|
||||||
|
# provision_user_row sets the email to "<login>@test", so the
|
||||||
|
# invitee row we'll create needs the same email shape.
|
||||||
|
provision_user_row(user_id=2, login="newbie", role="contributor")
|
||||||
|
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||||
|
|
||||||
|
# alice (owner) invites newbie@test.
|
||||||
|
sign_in_as(
|
||||||
|
client, user_id=1, gitea_login="alice",
|
||||||
|
display_name="Alice", role="contributor",
|
||||||
|
)
|
||||||
|
r = client.post(
|
||||||
|
"/api/rfcs/ohm/invitations",
|
||||||
|
json={"invitee_email": "newbie@test", "role_in_rfc": "contributor"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
token = r.json()["token"]
|
||||||
|
|
||||||
|
# Switch to newbie, accept.
|
||||||
|
sign_in_as(
|
||||||
|
client, user_id=2, gitea_login="newbie",
|
||||||
|
display_name="Newbie", role="contributor",
|
||||||
|
email="newbie@test",
|
||||||
|
)
|
||||||
|
r = client.post("/api/invitations/accept", json={"token": token})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
body = r.json()
|
||||||
|
assert body["ok"] is True
|
||||||
|
assert body["changed"] is True
|
||||||
|
assert body["rfc_slug"] == "ohm"
|
||||||
|
assert body["role_in_rfc"] == "contributor"
|
||||||
|
|
||||||
|
# Collaborator row landed; invitation flipped.
|
||||||
|
collab = db.conn().execute(
|
||||||
|
"SELECT role_in_rfc FROM rfc_collaborators WHERE rfc_slug = 'ohm' AND user_id = 2",
|
||||||
|
).fetchone()
|
||||||
|
assert collab is not None
|
||||||
|
assert collab["role_in_rfc"] == "contributor"
|
||||||
|
|
||||||
|
inv = db.conn().execute(
|
||||||
|
"SELECT status, accepted_by_user_id FROM rfc_invitations WHERE token = ?",
|
||||||
|
(token,),
|
||||||
|
).fetchone()
|
||||||
|
assert inv["status"] == "accepted"
|
||||||
|
assert inv["accepted_by_user_id"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_accept_refuses_when_email_does_not_match(app_with_fake_gitea):
|
||||||
|
"""The accepting user's email must match the invitation's
|
||||||
|
invitee_email (case-insensitive)."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_reset_outbound()
|
||||||
|
provision_user_row(user_id=1, login="alice", role="contributor")
|
||||||
|
provision_user_row(user_id=2, login="mallory", role="contributor")
|
||||||
|
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||||
|
|
||||||
|
sign_in_as(client, user_id=1, gitea_login="alice",
|
||||||
|
display_name="Alice", role="contributor")
|
||||||
|
r = client.post(
|
||||||
|
"/api/rfcs/ohm/invitations",
|
||||||
|
json={"invitee_email": "intended@example.com", "role_in_rfc": "discussant"},
|
||||||
|
)
|
||||||
|
token = r.json()["token"]
|
||||||
|
|
||||||
|
# mallory's email is "mallory@test", not "intended@example.com".
|
||||||
|
sign_in_as(client, user_id=2, gitea_login="mallory",
|
||||||
|
display_name="Mallory", role="contributor",
|
||||||
|
email="mallory@test")
|
||||||
|
r = client.post("/api/invitations/accept", json={"token": token})
|
||||||
|
assert r.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_accept_refuses_revoked_invitation(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_reset_outbound()
|
||||||
|
provision_user_row(user_id=1, login="alice", role="contributor")
|
||||||
|
provision_user_row(user_id=2, login="newbie", role="contributor")
|
||||||
|
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||||
|
|
||||||
|
sign_in_as(client, user_id=1, gitea_login="alice",
|
||||||
|
display_name="Alice", role="contributor")
|
||||||
|
r = client.post(
|
||||||
|
"/api/rfcs/ohm/invitations",
|
||||||
|
json={"invitee_email": "newbie@test", "role_in_rfc": "discussant"},
|
||||||
|
)
|
||||||
|
invitation_id = r.json()["id"]
|
||||||
|
token = r.json()["token"]
|
||||||
|
|
||||||
|
client.post(f"/api/rfcs/ohm/invitations/{invitation_id}/revoke")
|
||||||
|
|
||||||
|
sign_in_as(client, user_id=2, gitea_login="newbie",
|
||||||
|
display_name="Newbie", role="contributor",
|
||||||
|
email="newbie@test")
|
||||||
|
r = client.post("/api/invitations/accept", json={"token": token})
|
||||||
|
assert r.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
def test_accept_refuses_expired_invitation(app_with_fake_gitea):
|
||||||
|
"""An invitation past its `expires_at` is refused 409 even if
|
||||||
|
the row's column status is still 'pending'. We backdate the
|
||||||
|
expires_at directly to model the elapsed-window state."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_reset_outbound()
|
||||||
|
provision_user_row(user_id=1, login="alice", role="contributor")
|
||||||
|
provision_user_row(user_id=2, login="newbie", role="contributor")
|
||||||
|
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||||
|
|
||||||
|
sign_in_as(client, user_id=1, gitea_login="alice",
|
||||||
|
display_name="Alice", role="contributor")
|
||||||
|
r = client.post(
|
||||||
|
"/api/rfcs/ohm/invitations",
|
||||||
|
json={"invitee_email": "newbie@test", "role_in_rfc": "discussant"},
|
||||||
|
)
|
||||||
|
token = r.json()["token"]
|
||||||
|
invitation_id = r.json()["id"]
|
||||||
|
|
||||||
|
# Backdate.
|
||||||
|
db.conn().execute(
|
||||||
|
"UPDATE rfc_invitations SET expires_at = datetime('now', '-1 day') WHERE id = ?",
|
||||||
|
(invitation_id,),
|
||||||
|
)
|
||||||
|
|
||||||
|
sign_in_as(client, user_id=2, gitea_login="newbie",
|
||||||
|
display_name="Newbie", role="contributor",
|
||||||
|
email="newbie@test")
|
||||||
|
r = client.post("/api/invitations/accept", json={"token": token})
|
||||||
|
assert r.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
def test_accept_is_idempotent_on_re_accept(app_with_fake_gitea):
|
||||||
|
"""Re-accepting the same already-accepted invitation reads as a
|
||||||
|
200 no-op with `changed=false`. The collaborator row is unchanged."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_reset_outbound()
|
||||||
|
provision_user_row(user_id=1, login="alice", role="contributor")
|
||||||
|
provision_user_row(user_id=2, login="newbie", role="contributor")
|
||||||
|
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||||
|
|
||||||
|
sign_in_as(client, user_id=1, gitea_login="alice",
|
||||||
|
display_name="Alice", role="contributor")
|
||||||
|
r = client.post(
|
||||||
|
"/api/rfcs/ohm/invitations",
|
||||||
|
json={"invitee_email": "newbie@test", "role_in_rfc": "discussant"},
|
||||||
|
)
|
||||||
|
token = r.json()["token"]
|
||||||
|
|
||||||
|
sign_in_as(client, user_id=2, gitea_login="newbie",
|
||||||
|
display_name="Newbie", role="contributor",
|
||||||
|
email="newbie@test")
|
||||||
|
r1 = client.post("/api/invitations/accept", json={"token": token})
|
||||||
|
assert r1.status_code == 200
|
||||||
|
assert r1.json()["changed"] is True
|
||||||
|
|
||||||
|
r2 = client.post("/api/invitations/accept", json={"token": token})
|
||||||
|
assert r2.status_code == 200
|
||||||
|
assert r2.json()["changed"] is False
|
||||||
|
|
||||||
|
# Still exactly one collaborator row.
|
||||||
|
rows = db.conn().execute(
|
||||||
|
"SELECT COUNT(*) AS n FROM rfc_collaborators WHERE rfc_slug = 'ohm' AND user_id = 2"
|
||||||
|
).fetchone()
|
||||||
|
assert rows["n"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Discussion-write gate enforcement
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_invited_user_cannot_post_to_discussion(app_with_fake_gitea):
|
||||||
|
"""v0.16.0 narrows the discussion-write gate: a platform-granted
|
||||||
|
user with no per-RFC role gets 403 when posting to the
|
||||||
|
discussion. (v0.6.0 left the gate at require_contributor only;
|
||||||
|
item #12 layers can_discuss_rfc on top.)"""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
provision_user_row(user_id=1, login="alice", role="contributor")
|
||||||
|
provision_user_row(user_id=2, login="bob", role="contributor")
|
||||||
|
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||||
|
|
||||||
|
# bob is platform-granted but not in OHM's owners list and has
|
||||||
|
# no invitation. The thread-create surface refuses 403.
|
||||||
|
sign_in_as(client, user_id=2, gitea_login="bob",
|
||||||
|
display_name="Bob", role="contributor")
|
||||||
|
r = client.post(
|
||||||
|
"/api/rfcs/ohm/discussion/threads",
|
||||||
|
json={"label": "Question", "message": "Should I be allowed?"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_invited_discussant_can_post_to_discussion(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_reset_outbound()
|
||||||
|
provision_user_row(user_id=1, login="alice", role="contributor")
|
||||||
|
provision_user_row(user_id=2, login="newbie", role="contributor")
|
||||||
|
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||||
|
|
||||||
|
# alice invites newbie as a discussant.
|
||||||
|
sign_in_as(client, user_id=1, gitea_login="alice",
|
||||||
|
display_name="Alice", role="contributor")
|
||||||
|
r = client.post(
|
||||||
|
"/api/rfcs/ohm/invitations",
|
||||||
|
json={"invitee_email": "newbie@test", "role_in_rfc": "discussant"},
|
||||||
|
)
|
||||||
|
token = r.json()["token"]
|
||||||
|
|
||||||
|
# newbie accepts.
|
||||||
|
sign_in_as(client, user_id=2, gitea_login="newbie",
|
||||||
|
display_name="Newbie", role="contributor",
|
||||||
|
email="newbie@test")
|
||||||
|
client.post("/api/invitations/accept", json={"token": token})
|
||||||
|
|
||||||
|
# newbie can now post to the discussion.
|
||||||
|
r = client.post(
|
||||||
|
"/api/rfcs/ohm/discussion/threads",
|
||||||
|
json={"label": "Question", "message": "Now I can speak."},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_contributor_role_includes_discussion(app_with_fake_gitea):
|
||||||
|
"""A 'contributor' per-RFC role strictly includes discussion
|
||||||
|
permission — accepting a contributor invitation admits the user
|
||||||
|
to the discussion endpoint too."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_reset_outbound()
|
||||||
|
provision_user_row(user_id=1, login="alice", role="contributor")
|
||||||
|
provision_user_row(user_id=2, login="newbie", role="contributor")
|
||||||
|
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||||
|
|
||||||
|
sign_in_as(client, user_id=1, gitea_login="alice",
|
||||||
|
display_name="Alice", role="contributor")
|
||||||
|
r = client.post(
|
||||||
|
"/api/rfcs/ohm/invitations",
|
||||||
|
json={"invitee_email": "newbie@test", "role_in_rfc": "contributor"},
|
||||||
|
)
|
||||||
|
token = r.json()["token"]
|
||||||
|
|
||||||
|
sign_in_as(client, user_id=2, gitea_login="newbie",
|
||||||
|
display_name="Newbie", role="contributor",
|
||||||
|
email="newbie@test")
|
||||||
|
client.post("/api/invitations/accept", json={"token": token})
|
||||||
|
|
||||||
|
r = client.post(
|
||||||
|
"/api/rfcs/ohm/discussion/threads",
|
||||||
|
json={"label": "Q", "message": "Hello."},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_platform_admin_can_post_to_discussion_without_invitation(app_with_fake_gitea):
|
||||||
|
"""Per §6.1 / item #12's permission shape: platform admins/owners
|
||||||
|
can write to any RFC's discussion regardless of per-RFC
|
||||||
|
membership."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
provision_user_row(user_id=1, login="alice", role="contributor")
|
||||||
|
provision_user_row(user_id=99, login="adminzero", role="admin")
|
||||||
|
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||||
|
|
||||||
|
sign_in_as(client, user_id=99, gitea_login="adminzero",
|
||||||
|
display_name="Admin Zero", role="admin")
|
||||||
|
r = client.post(
|
||||||
|
"/api/rfcs/ohm/discussion/threads",
|
||||||
|
json={"label": "Admin chime", "message": "Drive-by from admin."},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_rfc_owner_can_post_to_discussion(app_with_fake_gitea):
|
||||||
|
"""The frontmatter RFC owner is admitted by virtue of being on
|
||||||
|
the owners list — they don't need to invite themselves."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
provision_user_row(user_id=1, login="alice", role="contributor")
|
||||||
|
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||||
|
sign_in_as(client, user_id=1, gitea_login="alice",
|
||||||
|
display_name="Alice", role="contributor")
|
||||||
|
r = client.post(
|
||||||
|
"/api/rfcs/ohm/discussion/threads",
|
||||||
|
json={"label": "Owner thought", "message": "Kicking off the conversation."},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Admin-page hook (additive on /api/admin/users)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_users_listing_surfaces_per_rfc_invitations(app_with_fake_gitea):
|
||||||
|
"""v0.16.0 hook into the v0.9.0 admin user-management surface:
|
||||||
|
each user row carries an `rfc_invitations` array listing the
|
||||||
|
per-RFC roles they hold. Empty array for users without any."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_reset_outbound()
|
||||||
|
provision_user_row(user_id=1, login="alice", role="contributor")
|
||||||
|
provision_user_row(user_id=2, login="newbie", role="contributor")
|
||||||
|
provision_user_row(user_id=99, login="adminzero", role="admin")
|
||||||
|
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||||
|
|
||||||
|
# alice invites newbie; newbie accepts.
|
||||||
|
sign_in_as(client, user_id=1, gitea_login="alice",
|
||||||
|
display_name="Alice", role="contributor")
|
||||||
|
r = client.post(
|
||||||
|
"/api/rfcs/ohm/invitations",
|
||||||
|
json={"invitee_email": "newbie@test", "role_in_rfc": "contributor"},
|
||||||
|
)
|
||||||
|
token = r.json()["token"]
|
||||||
|
|
||||||
|
sign_in_as(client, user_id=2, gitea_login="newbie",
|
||||||
|
display_name="Newbie", role="contributor",
|
||||||
|
email="newbie@test")
|
||||||
|
client.post("/api/invitations/accept", json={"token": token})
|
||||||
|
|
||||||
|
# Admin lists.
|
||||||
|
sign_in_as(client, user_id=99, gitea_login="adminzero",
|
||||||
|
display_name="Admin Zero", role="admin")
|
||||||
|
r = client.get("/api/admin/users")
|
||||||
|
assert r.status_code == 200
|
||||||
|
items = r.json()["items"]
|
||||||
|
newbie_row = next(i for i in items if i["gitea_login"] == "newbie")
|
||||||
|
assert isinstance(newbie_row["rfc_invitations"], list)
|
||||||
|
assert len(newbie_row["rfc_invitations"]) == 1
|
||||||
|
invite = newbie_row["rfc_invitations"][0]
|
||||||
|
assert invite["rfc_slug"] == "ohm"
|
||||||
|
assert invite["role_in_rfc"] == "contributor"
|
||||||
|
assert invite["inviter_login"] == "alice"
|
||||||
|
|
||||||
|
# Users with no invitations carry an empty array, not null.
|
||||||
|
alice_row = next(i for i in items if i["gitea_login"] == "alice")
|
||||||
|
assert alice_row["rfc_invitations"] == []
|
||||||
@@ -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") == []
|
||||||
@@ -49,3 +49,39 @@ VITE_PRIVACY_POLICY_URL=
|
|||||||
# Examples:
|
# Examples:
|
||||||
# VITE_COOKIES_POLICY_URL=https://wiggleverse.org/cookies
|
# VITE_COOKIES_POLICY_URL=https://wiggleverse.org/cookies
|
||||||
VITE_COOKIES_POLICY_URL=
|
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=
|
||||||
|
|||||||
Generated
+529
-10
@@ -1,13 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "rfc-app-frontend",
|
"name": "rfc-app-frontend",
|
||||||
"version": "0.10.0",
|
"version": "0.15.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "rfc-app-frontend",
|
"name": "rfc-app-frontend",
|
||||||
"version": "0.10.0",
|
"version": "0.15.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@amplitude/unified": "^1.1.9",
|
||||||
"@codemirror/commands": "^6.10.3",
|
"@codemirror/commands": "^6.10.3",
|
||||||
"@codemirror/lang-markdown": "^6.5.0",
|
"@codemirror/lang-markdown": "^6.5.0",
|
||||||
"@codemirror/language": "^6.12.3",
|
"@codemirror/language": "^6.12.3",
|
||||||
@@ -30,6 +31,360 @@
|
|||||||
"vite": "^8.0.12"
|
"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": {
|
"node_modules/@antfu/install-pkg": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz",
|
||||||
@@ -264,6 +619,12 @@
|
|||||||
"import-meta-resolve": "^4.2.0"
|
"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": {
|
"node_modules/@lezer/common": {
|
||||||
"version": "1.5.2",
|
"version": "1.5.2",
|
||||||
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
|
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
|
||||||
@@ -657,6 +1018,49 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@tiptap/core": {
|
||||||
"version": "3.23.6",
|
"version": "3.23.6",
|
||||||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.23.6.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.23.6.tgz",
|
||||||
@@ -1109,6 +1513,12 @@
|
|||||||
"tslib": "^2.4.0"
|
"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": {
|
"node_modules/@types/d3": {
|
||||||
"version": "7.4.3",
|
"version": "7.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
|
||||||
@@ -1362,6 +1772,12 @@
|
|||||||
"@types/d3-selection": "*"
|
"@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": {
|
"node_modules/@types/geojson": {
|
||||||
"version": "7946.0.16",
|
"version": "7946.0.16",
|
||||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||||
@@ -1399,6 +1815,12 @@
|
|||||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@upsetjs/venn.js": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@upsetjs/venn.js/-/venn.js-2.0.0.tgz",
|
"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": {
|
"node_modules/commander": {
|
||||||
"version": "7.2.0",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
|
||||||
@@ -2021,6 +2478,12 @@
|
|||||||
"benchmarks"
|
"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": {
|
"node_modules/fast-equals": {
|
||||||
"version": "5.4.0",
|
"version": "5.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
|
"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": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
@@ -2081,6 +2550,18 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/import-meta-resolve": {
|
||||||
"version": "4.2.0",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz",
|
||||||
@@ -2100,6 +2581,12 @@
|
|||||||
"node": ">=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": {
|
"node_modules/katex": {
|
||||||
"version": "0.16.47",
|
"version": "0.16.47",
|
||||||
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz",
|
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz",
|
||||||
@@ -2421,6 +2908,15 @@
|
|||||||
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
|
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/marked": {
|
||||||
"version": "18.0.4",
|
"version": "18.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.4.tgz",
|
||||||
@@ -2474,11 +2970,16 @@
|
|||||||
"node": ">= 20"
|
"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": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.12",
|
"version": "3.3.12",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||||
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -2515,14 +3016,12 @@
|
|||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
@@ -2551,7 +3050,6 @@
|
|||||||
"version": "8.5.15",
|
"version": "8.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||||
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -2828,6 +3326,12 @@
|
|||||||
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
|
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
|
||||||
"license": "BSD-3-Clause"
|
"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": {
|
"node_modules/safer-buffer": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
@@ -2850,7 +3354,6 @@
|
|||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||||
"dev": true,
|
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -2907,9 +3410,13 @@
|
|||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"dev": true,
|
"license": "0BSD"
|
||||||
"license": "0BSD",
|
},
|
||||||
"optional": true
|
"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": {
|
"node_modules/use-sync-external-store": {
|
||||||
"version": "1.6.0",
|
"version": "1.6.0",
|
||||||
@@ -3016,6 +3523,18 @@
|
|||||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||||
"license": "MIT"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "rfc-app-frontend",
|
"name": "rfc-app-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.14.0",
|
"version": "0.16.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@amplitude/unified": "^1.1.9",
|
||||||
"@codemirror/commands": "^6.10.3",
|
"@codemirror/commands": "^6.10.3",
|
||||||
"@codemirror/lang-markdown": "^6.5.0",
|
"@codemirror/lang-markdown": "^6.5.0",
|
||||||
"@codemirror/language": "^6.12.3",
|
"@codemirror/language": "^6.12.3",
|
||||||
|
|||||||
@@ -455,6 +455,65 @@
|
|||||||
.otc-fallback a:hover { color: #1a1a1a; text-decoration: underline; }
|
.otc-fallback a:hover { color: #1a1a1a; text-decoration: underline; }
|
||||||
.otc-fallback-sep { color: #ccc; }
|
.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 page (post-OAuth-rejection) --- */
|
||||||
|
|
||||||
.beta-pending {
|
.beta-pending {
|
||||||
@@ -1889,6 +1948,54 @@
|
|||||||
display: inline-flex; align-items: center; gap: 6px;
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
font-size: 13px; cursor: pointer;
|
font-size: 13px; cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* v0.9.0 — admin user-management surface (roadmap item #7). */
|
||||||
|
.admin-filter-chips {
|
||||||
|
display: flex; gap: 6px; margin-bottom: 16px; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.admin-chip {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
background: #fff; border: 1px solid #d1d5db; border-radius: 999px;
|
||||||
|
padding: 4px 12px; font-size: 12px; color: #374151; cursor: pointer;
|
||||||
|
}
|
||||||
|
.admin-chip:hover { background: #f9fafb; }
|
||||||
|
.admin-chip.active {
|
||||||
|
background: #111; color: #fff; border-color: #111;
|
||||||
|
}
|
||||||
|
.admin-chip-count {
|
||||||
|
font-size: 11px; opacity: 0.7;
|
||||||
|
}
|
||||||
|
.admin-users-table td { vertical-align: top; padding-top: 10px; padding-bottom: 10px; }
|
||||||
|
.permission-cell { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.permission-actions { display: flex; gap: 6px; }
|
||||||
|
.permission-badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 11px; font-weight: 600;
|
||||||
|
padding: 2px 8px; border-radius: 999px;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.04em;
|
||||||
|
width: max-content;
|
||||||
|
}
|
||||||
|
.permission-badge-pending {
|
||||||
|
background: #fef3c7; color: #92400e;
|
||||||
|
}
|
||||||
|
.permission-badge-granted {
|
||||||
|
background: #dcfce7; color: #166534;
|
||||||
|
}
|
||||||
|
.permission-badge-revoked {
|
||||||
|
background: #fee2e2; color: #991b1b;
|
||||||
|
}
|
||||||
|
.permission-decided { font-size: 11px; }
|
||||||
|
.user-row-reason td {
|
||||||
|
background: #fffbeb; border-top: none !important;
|
||||||
|
padding: 0 16px 12px !important;
|
||||||
|
}
|
||||||
|
.user-reason-block {
|
||||||
|
border-left: 3px solid #f59e0b;
|
||||||
|
padding: 8px 12px; font-size: 13px;
|
||||||
|
background: #fffbeb;
|
||||||
|
}
|
||||||
|
.user-reason-block strong { display: block; margin-bottom: 4px; color: #92400e; }
|
||||||
|
.user-reason-block p { margin: 0; white-space: pre-wrap; color: #374151; }
|
||||||
.grad-queue { list-style: none; padding: 0; margin: 8px 0 24px; }
|
.grad-queue { list-style: none; padding: 0; margin: 8px 0 24px; }
|
||||||
.grad-queue li { padding: 8px 0; border-bottom: 1px solid #f3f4f6; }
|
.grad-queue li { padding: 8px 0; border-bottom: 1px solid #f3f4f6; }
|
||||||
.grad-queue-link { color: #111; text-decoration: none; font-size: 14px; }
|
.grad-queue-link { color: #111; text-decoration: none; font-size: 14px; }
|
||||||
|
|||||||
+74
-3
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { Routes, Route, Link, useNavigate } from 'react-router-dom'
|
import { Routes, Route, Link, useLocation, useNavigate } from 'react-router-dom'
|
||||||
import { getMe, subscribeToNotifications } from './api'
|
import { getMe, subscribeToNotifications } from './api'
|
||||||
|
import { anonymize, EVENTS, identify, track } from './lib/analytics'
|
||||||
import Catalog from './components/Catalog.jsx'
|
import Catalog from './components/Catalog.jsx'
|
||||||
import Inbox from './components/Inbox.jsx'
|
import Inbox from './components/Inbox.jsx'
|
||||||
import RFCView from './components/RFCView.jsx'
|
import RFCView from './components/RFCView.jsx'
|
||||||
@@ -14,6 +15,7 @@ import Philosophy from './components/Philosophy.jsx'
|
|||||||
import Docs from './components/Docs.jsx'
|
import Docs from './components/Docs.jsx'
|
||||||
import NotificationSettings from './components/NotificationSettings.jsx'
|
import NotificationSettings from './components/NotificationSettings.jsx'
|
||||||
import Admin from './components/Admin.jsx'
|
import Admin from './components/Admin.jsx'
|
||||||
|
import AcceptInvitation from './components/AcceptInvitation.jsx'
|
||||||
import ToastHost, { showToast } from './components/ToastHost.jsx'
|
import ToastHost, { showToast } from './components/ToastHost.jsx'
|
||||||
import CookieConsentBanner from './components/CookieConsentBanner.jsx'
|
import CookieConsentBanner from './components/CookieConsentBanner.jsx'
|
||||||
import Privacy from './pages/Privacy.jsx'
|
import Privacy from './pages/Privacy.jsx'
|
||||||
@@ -34,6 +36,56 @@ export default function App() {
|
|||||||
// event that bumps this.
|
// event that bumps this.
|
||||||
const [consentReopenTick, setConsentReopenTick] = useState(0)
|
const [consentReopenTick, setConsentReopenTick] = useState(0)
|
||||||
const navigate = useNavigate()
|
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 + #21 Part C — bind the authenticated user id AND
|
||||||
|
// durable user properties 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.
|
||||||
|
//
|
||||||
|
// Property bag passed to identify (set vs setOnce per #21 Part C):
|
||||||
|
// set: role, permission_state, passcode_set, device_trusted
|
||||||
|
// (these can change mid-account-life — refresh each sign-in)
|
||||||
|
// setOnce: first_sign_in_at, account_created_at
|
||||||
|
// (immutable user-history markers — set on the first
|
||||||
|
// sign-in that observes them, never overwritten)
|
||||||
|
//
|
||||||
|
// PII discipline: NO email, NO display_name, NO gitea_login passed
|
||||||
|
// through — Amplitude only sees opaque ids + enums + timestamps +
|
||||||
|
// booleans.
|
||||||
|
const lastUserIdRef = useRef(null)
|
||||||
|
useEffect(() => {
|
||||||
|
const uid = me?.authenticated ? me.user?.id : null
|
||||||
|
const viewer = me?.authenticated ? me.user : null
|
||||||
|
if (uid != null && lastUserIdRef.current !== uid) {
|
||||||
|
lastUserIdRef.current = uid
|
||||||
|
const props = {}
|
||||||
|
if (viewer?.role != null) props.role = viewer.role
|
||||||
|
if (viewer?.permission_state != null) props.permission_state = viewer.permission_state
|
||||||
|
if (viewer?.passcode_set != null) props.passcode_set = !!viewer.passcode_set
|
||||||
|
if (viewer?.device_trusted != null) props.device_trusted = !!viewer.device_trusted
|
||||||
|
if (viewer?.first_sign_in_at) props.first_sign_in_at = ['__setOnce__', viewer.first_sign_in_at]
|
||||||
|
if (viewer?.created_at) props.account_created_at = ['__setOnce__', viewer.created_at]
|
||||||
|
identify({ user_id: String(uid), properties: props })
|
||||||
|
} 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, me?.user?.role, me?.user?.permission_state, me?.user?.passcode_set, me?.user?.device_trusted])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = () => setConsentReopenTick(t => t + 1)
|
const handler = () => setConsentReopenTick(t => t + 1)
|
||||||
@@ -141,7 +193,20 @@ export default function App() {
|
|||||||
<>
|
<>
|
||||||
<span className="user-name">{viewer.display_name}</span>
|
<span className="user-name">{viewer.display_name}</span>
|
||||||
<span className={`user-role-badge role-${viewer.role}`}>{viewer.role}</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">
|
<Link className="btn-signin-header" to="/login" title="Private beta — only invited emails can sign in">
|
||||||
@@ -156,6 +221,12 @@ export default function App() {
|
|||||||
<Route path="/welcome" element={<Landing />} />
|
<Route path="/welcome" element={<Landing />} />
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/beta-pending" element={<BetaPending viewer={viewer} />} />
|
<Route path="/beta-pending" element={<BetaPending viewer={viewer} />} />
|
||||||
|
{/* v0.16.0 (item #12): per-RFC invitation acceptance landing.
|
||||||
|
Anonymous viewers see a sign-in prompt; signed-in users
|
||||||
|
see the preview + accept gesture. */}
|
||||||
|
<Route path="/invitations/accept" element={
|
||||||
|
<PolicyShell><AcceptInvitation viewer={viewer} /></PolicyShell>
|
||||||
|
} />
|
||||||
<Route path="/philosophy" element={<PhilosophyWithSidebar viewer={viewer} />} />
|
<Route path="/philosophy" element={<PhilosophyWithSidebar viewer={viewer} />} />
|
||||||
<Route path="/docs" element={<DocsWithSidebar viewer={viewer} />} />
|
<Route path="/docs" element={<DocsWithSidebar viewer={viewer} />} />
|
||||||
{/* §14.5 / §14.6: cookie-consent companions to /philosophy.
|
{/* §14.5 / §14.6: cookie-consent companions to /philosophy.
|
||||||
|
|||||||
+103
-6
@@ -31,20 +31,33 @@ export async function getMe() {
|
|||||||
// migration — the new UI just no longer points at it primarily. These
|
// migration — the new UI just no longer points at it primarily. These
|
||||||
// two helpers drive the Login.jsx surface.
|
// 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', {
|
const res = await fetch('/auth/otc/request', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ email }),
|
body: JSON.stringify(body),
|
||||||
})
|
})
|
||||||
return jsonOrThrow(res)
|
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', {
|
const res = await fetch('/auth/otc/verify', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ email, code }),
|
body: JSON.stringify({ email, code, trust_device: !!trustDevice }),
|
||||||
})
|
})
|
||||||
return jsonOrThrow(res)
|
return jsonOrThrow(res)
|
||||||
}
|
}
|
||||||
@@ -82,15 +95,44 @@ export async function checkPasscode(email) {
|
|||||||
return jsonOrThrow(res)
|
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', {
|
const res = await fetch('/auth/passcode/verify', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ email, passcode }),
|
body: JSON.stringify({ email, passcode, trust_device: !!trustDevice }),
|
||||||
})
|
})
|
||||||
return jsonOrThrow(res)
|
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) {
|
export async function setPasscode(passcode) {
|
||||||
// Requires an active session — the server returns 401 if not signed
|
// Requires an active session — the server returns 401 if not signed
|
||||||
// in. The signed-in user is the implicit subject; the body carries
|
// in. The signed-in user is the implicit subject; the body carries
|
||||||
@@ -280,6 +322,48 @@ export async function resolveThread(slug, branch, threadId) {
|
|||||||
return jsonOrThrow(res)
|
return jsonOrThrow(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── v0.16.0: owner-only invite for per-RFC PR or PR-less discussion ──────
|
||||||
|
//
|
||||||
|
// roadmap item #12 / §6 / §10. The RFC's owner invites specific emails
|
||||||
|
// to one of two per-RFC roles ('contributor' or 'discussant'); the
|
||||||
|
// invitee accepts via the email-encoded token after signing in. The
|
||||||
|
// platform-level grant remains the admin's decision (per item #6 /
|
||||||
|
// v0.8.0) — these endpoints control per-RFC membership only.
|
||||||
|
|
||||||
|
export async function listRFCInvitations(slug) {
|
||||||
|
return jsonOrThrow(await fetch(`/api/rfcs/${slug}/invitations`))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createRFCInvitation(slug, { inviteeEmail, roleInRFC }) {
|
||||||
|
const res = await fetch(`/api/rfcs/${slug}/invitations`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ invitee_email: inviteeEmail, role_in_rfc: roleInRFC }),
|
||||||
|
})
|
||||||
|
return jsonOrThrow(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokeRFCInvitation(slug, invitationId) {
|
||||||
|
const res = await fetch(`/api/rfcs/${slug}/invitations/${invitationId}/revoke`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
return jsonOrThrow(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function previewInvitation(token) {
|
||||||
|
const params = new URLSearchParams({ token })
|
||||||
|
return jsonOrThrow(await fetch(`/api/invitations/accept?${params}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function acceptInvitation(token) {
|
||||||
|
const res = await fetch('/api/invitations/accept', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ token }),
|
||||||
|
})
|
||||||
|
return jsonOrThrow(res)
|
||||||
|
}
|
||||||
|
|
||||||
// ── v0.5.0: PR-less per-RFC discussion (§5 / §10) ────────────────────────
|
// ── v0.5.0: PR-less per-RFC discussion (§5 / §10) ────────────────────────
|
||||||
//
|
//
|
||||||
// The substrate is `threads.branch_name IS NULL` — the same threads
|
// The substrate is `threads.branch_name IS NULL` — the same threads
|
||||||
@@ -661,6 +745,19 @@ export async function setUserMute(userId, muted) {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v0.9.0 — roadmap item #7. Flip a user's permission_state between
|
||||||
|
// 'pending', 'granted', and 'revoked'. The Users tab on the admin
|
||||||
|
// page wires Grant / Revoke buttons against this endpoint; the
|
||||||
|
// returned `changed` flag is false when the requested state already
|
||||||
|
// matched the row.
|
||||||
|
export async function setUserPermission(userId, state) {
|
||||||
|
return jsonOrThrow(await fetch(`/api/admin/users/${userId}/permission`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ state }),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
export async function listAuditLog({ actionKind, actorUserId, rfcSlug, beforeId, limit } = {}) {
|
export async function listAuditLog({ actionKind, actorUserId, rfcSlug, beforeId, limit } = {}) {
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
if (actionKind) params.set('action_kind', actionKind)
|
if (actionKind) params.set('action_kind', actionKind)
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
// AcceptInvitation.jsx — v0.16.0 / roadmap item #12.
|
||||||
|
//
|
||||||
|
// The /invitations/accept?token=... landing page the invitation email
|
||||||
|
// links to. The page:
|
||||||
|
//
|
||||||
|
// 1. Reads `?token=...` from the URL.
|
||||||
|
// 2. Calls GET /api/invitations/accept?token=... to preview what the
|
||||||
|
// invitation grants (RFC title, role-in-RFC, expiry, whether the
|
||||||
|
// currently-signed-in user's email matches the invitee's).
|
||||||
|
// 3. Renders a confirmation surface — name the RFC, name the role,
|
||||||
|
// and either show "Accept" (when the email matches and the
|
||||||
|
// invitation is still pending) or a refusal message (expired,
|
||||||
|
// revoked, email mismatch).
|
||||||
|
// 4. On accept, POST /api/invitations/accept lands the
|
||||||
|
// rfc_collaborators row and the page redirects to the RFC's view.
|
||||||
|
//
|
||||||
|
// For an anonymous viewer who lands here without signing in, the
|
||||||
|
// preview call 401s and the page tells them to sign in. After
|
||||||
|
// signing in (via the existing OTC/passcode surface at /login) they
|
||||||
|
// can return to the same URL — the token is stable.
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
|
import { acceptInvitation, previewInvitation } from '../api'
|
||||||
|
import { EVENTS, identify, track } from '../lib/analytics'
|
||||||
|
|
||||||
|
export default function AcceptInvitation({ viewer }) {
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const token = searchParams.get('token') || ''
|
||||||
|
|
||||||
|
const [preview, setPreview] = useState(null)
|
||||||
|
const [previewError, setPreviewError] = useState(null)
|
||||||
|
const [accepting, setAccepting] = useState(false)
|
||||||
|
const [acceptError, setAcceptError] = useState(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) {
|
||||||
|
setPreviewError('No invitation token in the URL.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!viewer) {
|
||||||
|
// Not signed in — the preview endpoint will 401. We surface a
|
||||||
|
// sign-in prompt without making the request.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
previewInvitation(token)
|
||||||
|
.then(setPreview)
|
||||||
|
.catch(err => setPreviewError(err.message || 'Could not load invitation.'))
|
||||||
|
}, [token, viewer])
|
||||||
|
|
||||||
|
async function handleAccept() {
|
||||||
|
setAccepting(true)
|
||||||
|
setAcceptError(null)
|
||||||
|
try {
|
||||||
|
const result = await acceptInvitation(token)
|
||||||
|
// v0.16.0 + #21 Part C — re-identify with per-RFC invite
|
||||||
|
// properties on accept, BEFORE the track event fires, so the
|
||||||
|
// Amplitude user record carries the invite context from the
|
||||||
|
// moment of acceptance. setOnce on invited_at preserves the
|
||||||
|
// first-accepted timestamp if the same user accepts multiple
|
||||||
|
// RFC invitations.
|
||||||
|
if (viewer?.id != null) {
|
||||||
|
identify({
|
||||||
|
user_id: String(viewer.id),
|
||||||
|
properties: {
|
||||||
|
invited_at: ['__setOnce__', new Date().toISOString()],
|
||||||
|
last_invited_to_rfc: result.rfc_slug,
|
||||||
|
last_invite_role_in_rfc: result.role_in_rfc || preview?.role_in_rfc,
|
||||||
|
claim_method: 'rfc-invite',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
track(EVENTS.INVITATION_ACCEPTED, {
|
||||||
|
rfc_slug: result.rfc_slug,
|
||||||
|
role_in_rfc: result.role_in_rfc || preview?.role_in_rfc,
|
||||||
|
})
|
||||||
|
navigate(`/rfc/${result.rfc_slug}`)
|
||||||
|
} catch (err) {
|
||||||
|
setAcceptError(err.message || 'Could not accept invitation.')
|
||||||
|
} finally {
|
||||||
|
setAccepting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return (
|
||||||
|
<div className="accept-invitation">
|
||||||
|
<h1>Invitation link is malformed</h1>
|
||||||
|
<p>No <code>token</code> parameter was found. Ask the person who
|
||||||
|
invited you to re-send the link.</p>
|
||||||
|
<p><Link to="/">Return to the catalog</Link></p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!viewer) {
|
||||||
|
return (
|
||||||
|
<div className="accept-invitation">
|
||||||
|
<h1>Sign in to accept your invitation</h1>
|
||||||
|
<p>
|
||||||
|
You've been invited to collaborate on an RFC. Sign in first so we
|
||||||
|
can attach the membership to your account, then return to this
|
||||||
|
link.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<Link to="/login" className="btn-primary">Sign in</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previewError) {
|
||||||
|
return (
|
||||||
|
<div className="accept-invitation">
|
||||||
|
<h1>Invitation unavailable</h1>
|
||||||
|
<p>{previewError}</p>
|
||||||
|
<p><Link to="/">Return to the catalog</Link></p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!preview) {
|
||||||
|
return <div className="accept-invitation">Loading invitation…</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rfc_title, rfc_slug, role_in_rfc, status, invitee_email, email_matches_you } = preview
|
||||||
|
|
||||||
|
if (status === 'revoked') {
|
||||||
|
return (
|
||||||
|
<div className="accept-invitation">
|
||||||
|
<h1>Invitation revoked</h1>
|
||||||
|
<p>
|
||||||
|
The owner of <strong>{rfc_title}</strong> revoked this invitation.
|
||||||
|
Ask them to re-issue it if you should still have access.
|
||||||
|
</p>
|
||||||
|
<p><Link to={`/rfc/${rfc_slug}`}>Read the RFC anyway</Link></p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (status === 'expired') {
|
||||||
|
return (
|
||||||
|
<div className="accept-invitation">
|
||||||
|
<h1>Invitation expired</h1>
|
||||||
|
<p>
|
||||||
|
This invitation to <strong>{rfc_title}</strong> has expired. Ask
|
||||||
|
the RFC's owner to issue a fresh one.
|
||||||
|
</p>
|
||||||
|
<p><Link to={`/rfc/${rfc_slug}`}>Read the RFC anyway</Link></p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (status === 'accepted') {
|
||||||
|
return (
|
||||||
|
<div className="accept-invitation">
|
||||||
|
<h1>Already accepted</h1>
|
||||||
|
<p>
|
||||||
|
You've already accepted this invitation. You can{' '}
|
||||||
|
<Link to={`/rfc/${rfc_slug}`}>open {rfc_title}</Link> now.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!email_matches_you) {
|
||||||
|
return (
|
||||||
|
<div className="accept-invitation">
|
||||||
|
<h1>This invitation is for a different account</h1>
|
||||||
|
<p>
|
||||||
|
This invitation was sent to <strong>{invitee_email}</strong>. You're
|
||||||
|
currently signed in as <strong>{viewer.email || viewer.gitea_login}</strong>.
|
||||||
|
Sign out and sign back in with the invited address to accept.
|
||||||
|
</p>
|
||||||
|
<p><a className="btn-link" href="/auth/logout">Sign out</a></p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="accept-invitation">
|
||||||
|
<h1>Join {rfc_title}</h1>
|
||||||
|
<p>
|
||||||
|
You've been invited to <strong>{rfc_title}</strong> as a{' '}
|
||||||
|
<strong>{role_in_rfc}</strong>.
|
||||||
|
</p>
|
||||||
|
<p style={{ color: '#666' }}>
|
||||||
|
{role_in_rfc === 'contributor'
|
||||||
|
? 'Contributors can open PRs against this RFC and join its discussion.'
|
||||||
|
: 'Discussants can post in this RFC\'s discussion.'}
|
||||||
|
</p>
|
||||||
|
{acceptError && <div className="error-banner">{acceptError}</div>}
|
||||||
|
<p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-primary"
|
||||||
|
onClick={handleAccept}
|
||||||
|
disabled={accepting}
|
||||||
|
>
|
||||||
|
{accepting ? 'Accepting…' : `Accept and open ${rfc_title}`}
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<Link to={`/rfc/${rfc_slug}`}>or just read the RFC without accepting</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
listAdminUsers,
|
listAdminUsers,
|
||||||
setUserRole,
|
setUserRole,
|
||||||
setUserMute,
|
setUserMute,
|
||||||
|
setUserPermission,
|
||||||
listAuditLog,
|
listAuditLog,
|
||||||
listPermissionEvents,
|
listPermissionEvents,
|
||||||
listGraduationQueue,
|
listGraduationQueue,
|
||||||
@@ -23,6 +24,7 @@ import {
|
|||||||
addAllowlistEmail,
|
addAllowlistEmail,
|
||||||
removeAllowlistEmail,
|
removeAllowlistEmail,
|
||||||
} from '../api.js'
|
} from '../api.js'
|
||||||
|
import { EVENTS, track } from '../lib/analytics.js'
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ path: 'users', label: 'Users' },
|
{ path: 'users', label: 'Users' },
|
||||||
@@ -68,12 +70,26 @@ export default function Admin({ viewer }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Users + role + write-mute (§6.1 / §6.2) ────────────────────────────────
|
// ── Users + role + write-mute + permission grant/revoke (§6.1 / §6.2) ──────
|
||||||
|
//
|
||||||
|
// v0.9.0 (roadmap item #7) lands the user-management surface. The table
|
||||||
|
// shows every user with their permission_state, sign-up reason (when
|
||||||
|
// pending), role, write-mute, and Grant / Revoke controls. State filter
|
||||||
|
// chips above the table narrow to one bucket — the "Pending" chip is the
|
||||||
|
// admin's daily inbox shape.
|
||||||
|
|
||||||
|
const STATE_CHIPS = [
|
||||||
|
{ value: 'all', label: 'All' },
|
||||||
|
{ value: 'pending', label: 'Pending' },
|
||||||
|
{ value: 'granted', label: 'Granted' },
|
||||||
|
{ value: 'revoked', label: 'Revoked' },
|
||||||
|
]
|
||||||
|
|
||||||
function UsersTab() {
|
function UsersTab() {
|
||||||
const [users, setUsers] = useState(null)
|
const [users, setUsers] = useState(null)
|
||||||
const [busy, setBusy] = useState({})
|
const [busy, setBusy] = useState({})
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
|
const [stateFilter, setStateFilter] = useState('all')
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -113,68 +129,199 @@ function UsersTab() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function flipPermission(userId, state) {
|
||||||
|
setBusy(b => ({ ...b, [userId]: true }))
|
||||||
|
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) {
|
||||||
|
setError(e.message)
|
||||||
|
} finally {
|
||||||
|
setBusy(b => ({ ...b, [userId]: false }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const counts = useMemo(() => {
|
||||||
|
const c = { all: 0, pending: 0, granted: 0, revoked: 0 }
|
||||||
|
if (users) {
|
||||||
|
c.all = users.length
|
||||||
|
for (const u of users) {
|
||||||
|
const s = u.permission_state || 'granted'
|
||||||
|
if (s in c) c[s] += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}, [users])
|
||||||
|
|
||||||
if (users == null) return <p className="muted">Loading users…</p>
|
if (users == null) return <p className="muted">Loading users…</p>
|
||||||
|
|
||||||
|
const filtered = stateFilter === 'all'
|
||||||
|
? users
|
||||||
|
: users.filter(u => (u.permission_state || 'granted') === stateFilter)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="admin-tab">
|
<div className="admin-tab">
|
||||||
<header className="admin-tab-header">
|
<header className="admin-tab-header">
|
||||||
<h2>Users</h2>
|
<h2>Users</h2>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
Role changes write to <code>permission_events</code>. The §6.2
|
The pending bucket is the beta-access review queue (§6.1 /
|
||||||
write-mute applies to contributors only — promote to admin to
|
v0.8.0). Grant or revoke writes to <code>permission_events</code>
|
||||||
remove a user's ability to write without silencing them.
|
and stamps <code>permission_decided_by</code> +{' '}
|
||||||
|
<code>permission_decided_at</code>. Role and write-mute controls
|
||||||
|
retain their v0.7.0 semantics — promote to admin to remove a
|
||||||
|
user's ability to write without silencing them.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
{error && <p className="settings-note warning">{error}</p>}
|
{error && <p className="settings-note warning">{error}</p>}
|
||||||
<table className="admin-table">
|
|
||||||
<thead>
|
<div className="admin-filter-chips">
|
||||||
<tr>
|
{STATE_CHIPS.map(chip => (
|
||||||
<th>User</th>
|
<button
|
||||||
<th>Role</th>
|
key={chip.value}
|
||||||
<th>Write-muted</th>
|
type="button"
|
||||||
<th>Last seen</th>
|
className={`admin-chip${stateFilter === chip.value ? ' active' : ''}`}
|
||||||
</tr>
|
onClick={() => setStateFilter(chip.value)}
|
||||||
</thead>
|
>
|
||||||
<tbody>
|
{chip.label} <span className="admin-chip-count">{counts[chip.value] ?? 0}</span>
|
||||||
{users.map(u => (
|
</button>
|
||||||
<tr key={u.id}>
|
))}
|
||||||
<td>
|
</div>
|
||||||
<div className="user-cell">
|
|
||||||
<span className="user-handle">@{u.gitea_login}</span>
|
{filtered.length === 0 ? (
|
||||||
<span className="muted">{u.display_name}</span>
|
<p className="muted">No users in this bucket.</p>
|
||||||
</div>
|
) : (
|
||||||
</td>
|
<table className="admin-table admin-users-table">
|
||||||
<td>
|
<thead>
|
||||||
<select
|
<tr>
|
||||||
value={u.role}
|
<th>User</th>
|
||||||
onChange={e => changeRole(u.id, e.target.value)}
|
<th>State</th>
|
||||||
disabled={!!busy[u.id]}
|
<th>Role</th>
|
||||||
>
|
<th>Write-muted</th>
|
||||||
<option value="contributor">Contributor</option>
|
<th>Signed up</th>
|
||||||
<option value="admin">Admin</option>
|
<th>Last seen</th>
|
||||||
<option value="owner">Owner</option>
|
|
||||||
</select>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{u.role === 'contributor' ? (
|
|
||||||
<label className="mute-toggle">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={!!u.muted}
|
|
||||||
onChange={e => toggleMute(u.id, e.target.checked)}
|
|
||||||
disabled={!!busy[u.id]}
|
|
||||||
/>
|
|
||||||
{u.muted ? 'Muted' : 'Active'}
|
|
||||||
</label>
|
|
||||||
) : (
|
|
||||||
<span className="muted">N/A</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="muted">{u.last_seen_at}</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
</thead>
|
||||||
</tbody>
|
<tbody>
|
||||||
</table>
|
{filtered.map(u => (
|
||||||
|
<UserRow
|
||||||
|
key={u.id}
|
||||||
|
user={u}
|
||||||
|
busy={!!busy[u.id]}
|
||||||
|
onChangeRole={role => changeRole(u.id, role)}
|
||||||
|
onToggleMute={muted => toggleMute(u.id, muted)}
|
||||||
|
onFlipPermission={state => flipPermission(u.id, state)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserRow({ user: u, busy, onChangeRole, onToggleMute, onFlipPermission }) {
|
||||||
|
const state = u.permission_state || 'granted'
|
||||||
|
const fullName = [u.first_name, u.last_name].filter(Boolean).join(' ').trim()
|
||||||
|
const handle = u.gitea_login ? `@${u.gitea_login}` : (u.email || u.display_name)
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div className="user-cell">
|
||||||
|
<span className="user-handle">{handle}</span>
|
||||||
|
<span className="muted">
|
||||||
|
{fullName || u.display_name}
|
||||||
|
{u.email ? ` · ${u.email}` : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<PermissionCell user={u} busy={busy} onFlipPermission={onFlipPermission} />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<select
|
||||||
|
value={u.role}
|
||||||
|
onChange={e => onChangeRole(e.target.value)}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
<option value="contributor">Contributor</option>
|
||||||
|
<option value="admin">Admin</option>
|
||||||
|
<option value="owner">Owner</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{u.role === 'contributor' ? (
|
||||||
|
<label className="mute-toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!u.muted}
|
||||||
|
onChange={e => onToggleMute(e.target.checked)}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
{u.muted ? 'Muted' : 'Active'}
|
||||||
|
</label>
|
||||||
|
) : (
|
||||||
|
<span className="muted">N/A</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="muted">{u.created_at || '—'}</td>
|
||||||
|
<td className="muted">{u.last_seen_at || '—'}</td>
|
||||||
|
</tr>
|
||||||
|
{state === 'pending' && u.beta_request_reason ? (
|
||||||
|
<tr className="user-row-reason">
|
||||||
|
<td colSpan={6}>
|
||||||
|
<div className="user-reason-block">
|
||||||
|
<strong>Why they want access:</strong>
|
||||||
|
<p>{u.beta_request_reason}</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PermissionCell({ user: u, busy, onFlipPermission }) {
|
||||||
|
const state = u.permission_state || 'granted'
|
||||||
|
const decidedSuffix = u.permission_decided_at
|
||||||
|
? ` · by ${u.permission_decided_by_login ? '@' + u.permission_decided_by_login : '—'} at ${u.permission_decided_at}`
|
||||||
|
: ''
|
||||||
|
return (
|
||||||
|
<div className="permission-cell">
|
||||||
|
<span className={`permission-badge permission-badge-${state}`}>{state}</span>
|
||||||
|
<div className="permission-actions">
|
||||||
|
{state !== 'granted' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-link-quiet"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => onFlipPermission('granted')}
|
||||||
|
>Grant</button>
|
||||||
|
)}
|
||||||
|
{state === 'granted' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-link-quiet"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(`Revoke access for ${u.display_name || u.email}?`)) {
|
||||||
|
onFlipPermission('revoked')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>Revoke</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{decidedSuffix && (
|
||||||
|
<div className="permission-decided muted">{decidedSuffix.replace(/^ · /, '')}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,8 +27,11 @@ export default function BetaPending({ viewer }) {
|
|||||||
{isPending ? (
|
{isPending ? (
|
||||||
<>
|
<>
|
||||||
<p>
|
<p>
|
||||||
Thanks for telling us a bit about yourself. An admin will
|
Thanks for telling us a bit about yourself. The deployment's
|
||||||
review your request and get back to you as soon as we can.
|
admins are notified by email as soon as a request lands;
|
||||||
|
we don't commit to a fixed SLA — turnaround depends on
|
||||||
|
operator availability — and the deployment operator is
|
||||||
|
the right person to ask if a wait runs long.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
While you wait, the catalog on the left lists every super-draft
|
While you wait, the catalog on the left lists every super-draft
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
// InvitationsModal.jsx — v0.16.0 / roadmap item #12.
|
||||||
|
//
|
||||||
|
// The RFC owner's surface for issuing per-RFC invitations and watching
|
||||||
|
// who has accepted. Opens from the RFC view's header strip when the
|
||||||
|
// viewer is the RFC's owner (or a platform admin/owner). Non-owner
|
||||||
|
// viewers never see the trigger.
|
||||||
|
//
|
||||||
|
// The modal shows two stacked sections:
|
||||||
|
//
|
||||||
|
// 1. "Invite someone" — email input + role picker
|
||||||
|
// (contributor | discussant) + Send. The send goes through the
|
||||||
|
// backend's POST /api/rfcs/<slug>/invitations, which both writes
|
||||||
|
// the row and dispatches the email to the invitee. Success
|
||||||
|
// refreshes the list below and clears the input.
|
||||||
|
//
|
||||||
|
// 2. "Existing invitations" — every invitation (pending +
|
||||||
|
// accepted + revoked + expired) on this RFC, with revoke
|
||||||
|
// buttons on the pending ones. The status of each row is the
|
||||||
|
// effective status (the backend recomputes expired-from-pending
|
||||||
|
// at read time so an unattended cron isn't required).
|
||||||
|
//
|
||||||
|
// No custom-message field — that belongs to item #16's platform-
|
||||||
|
// level surface, not here. No bulk-invite — one email at a time
|
||||||
|
// keeps the gesture deliberate.
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import {
|
||||||
|
createRFCInvitation,
|
||||||
|
listRFCInvitations,
|
||||||
|
revokeRFCInvitation,
|
||||||
|
} from '../api'
|
||||||
|
import { EVENTS, track } from '../lib/analytics'
|
||||||
|
|
||||||
|
const ROLE_OPTIONS = [
|
||||||
|
{ value: 'contributor', label: 'Contributor — can open PRs and join discussion' },
|
||||||
|
{ value: 'discussant', label: 'Discussant — can join discussion only' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function InvitationsModal({ slug, rfcTitle, onClose }) {
|
||||||
|
const [invitations, setInvitations] = useState(null)
|
||||||
|
const [loadError, setLoadError] = useState(null)
|
||||||
|
const [inviteeEmail, setInviteeEmail] = useState('')
|
||||||
|
const [roleInRFC, setRoleInRFC] = useState('contributor')
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [submitError, setSubmitError] = useState(null)
|
||||||
|
const [submitSuccess, setSubmitSuccess] = useState(null)
|
||||||
|
const [revokingId, setRevokingId] = useState(null)
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
setLoadError(null)
|
||||||
|
try {
|
||||||
|
const r = await listRFCInvitations(slug)
|
||||||
|
setInvitations(r.items || [])
|
||||||
|
} catch (e) {
|
||||||
|
setLoadError(e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { refresh() /* eslint-disable-line react-hooks/exhaustive-deps */ }, [slug])
|
||||||
|
|
||||||
|
async function handleSend(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
const email = inviteeEmail.trim()
|
||||||
|
if (!email) return
|
||||||
|
setSubmitting(true)
|
||||||
|
setSubmitError(null)
|
||||||
|
setSubmitSuccess(null)
|
||||||
|
try {
|
||||||
|
await createRFCInvitation(slug, { inviteeEmail: email, roleInRFC })
|
||||||
|
// v0.16.0 + #21 Part C — Amplitude wiring. No PII (the email
|
||||||
|
// is the inviter's input, not the invitee's identity in our
|
||||||
|
// analytics; we record the rfc_slug + role_in_rfc so a future
|
||||||
|
// invite→accept correlation has both halves).
|
||||||
|
track(EVENTS.INVITATION_SENT, { rfc_slug: slug, role_in_rfc: roleInRFC })
|
||||||
|
setSubmitSuccess(`Invitation sent to ${email}.`)
|
||||||
|
setInviteeEmail('')
|
||||||
|
await refresh()
|
||||||
|
} catch (err) {
|
||||||
|
setSubmitError(err.message || 'Failed to send invitation.')
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRevoke(invitationId) {
|
||||||
|
setRevokingId(invitationId)
|
||||||
|
try {
|
||||||
|
await revokeRFCInvitation(slug, invitationId)
|
||||||
|
await refresh()
|
||||||
|
} catch (err) {
|
||||||
|
setSubmitError(err.message || 'Failed to revoke invitation.')
|
||||||
|
} finally {
|
||||||
|
setRevokingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||||
|
<div className="modal" style={{ maxWidth: 640 }}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<h2>Invitations — {rfcTitle || slug}</h2>
|
||||||
|
<button className="modal-close" onClick={onClose}>×</button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
<p style={{ marginTop: 0, color: '#666' }}>
|
||||||
|
Invite people by email to contribute PRs against this RFC or to
|
||||||
|
join its discussion. Anyone with the link can read this RFC;
|
||||||
|
this surface controls who can <em>write</em>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleSend} className="invitations-form" style={{ marginTop: 16 }}>
|
||||||
|
<label htmlFor="invitee-email">Invitee email</label>
|
||||||
|
<input
|
||||||
|
id="invitee-email"
|
||||||
|
type="email"
|
||||||
|
value={inviteeEmail}
|
||||||
|
onChange={e => setInviteeEmail(e.target.value)}
|
||||||
|
placeholder="someone@example.com"
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<label htmlFor="invitee-role" style={{ marginTop: 10 }}>Role on this RFC</label>
|
||||||
|
<select
|
||||||
|
id="invitee-role"
|
||||||
|
value={roleInRFC}
|
||||||
|
onChange={e => setRoleInRFC(e.target.value)}
|
||||||
|
>
|
||||||
|
{ROLE_OPTIONS.map(opt => (
|
||||||
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<div style={{ marginTop: 12, display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
|
<button type="submit" className="btn-primary" disabled={submitting}>
|
||||||
|
{submitting ? 'Sending…' : 'Send invitation'}
|
||||||
|
</button>
|
||||||
|
{submitError && <span style={{ color: '#c33' }}>{submitError}</span>}
|
||||||
|
{submitSuccess && <span style={{ color: '#383' }}>{submitSuccess}</span>}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<hr style={{ margin: '20px 0' }} />
|
||||||
|
|
||||||
|
<h3 style={{ margin: '0 0 8px' }}>Existing invitations</h3>
|
||||||
|
{loadError && <div className="error-banner">{loadError}</div>}
|
||||||
|
{invitations === null && <div>Loading…</div>}
|
||||||
|
{invitations !== null && invitations.length === 0 && (
|
||||||
|
<div style={{ color: '#666' }}>No invitations have been sent yet.</div>
|
||||||
|
)}
|
||||||
|
{invitations !== null && invitations.length > 0 && (
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ textAlign: 'left', padding: 4 }}>Email</th>
|
||||||
|
<th style={{ textAlign: 'left', padding: 4 }}>Role</th>
|
||||||
|
<th style={{ textAlign: 'left', padding: 4 }}>Status</th>
|
||||||
|
<th style={{ textAlign: 'left', padding: 4 }}>Sent</th>
|
||||||
|
<th style={{ padding: 4 }}></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{invitations.map(inv => (
|
||||||
|
<tr key={inv.id} style={{ borderTop: '1px solid #eee' }}>
|
||||||
|
<td style={{ padding: 4 }}>{inv.invitee_email}</td>
|
||||||
|
<td style={{ padding: 4 }}>{inv.role_in_rfc}</td>
|
||||||
|
<td style={{ padding: 4 }}>
|
||||||
|
<span className={`invitation-status status-${inv.status}`}>
|
||||||
|
{inv.status}
|
||||||
|
</span>
|
||||||
|
{inv.status === 'accepted' && inv.accepted_by_display && (
|
||||||
|
<span style={{ color: '#666', marginLeft: 6 }}>
|
||||||
|
by {inv.accepted_by_display}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: 4, color: '#666' }}>
|
||||||
|
{inv.created_at?.slice(0, 10) || ''}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: 4, textAlign: 'right' }}>
|
||||||
|
{inv.status === 'pending' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-link"
|
||||||
|
onClick={() => handleRevoke(inv.id)}
|
||||||
|
disabled={revokingId === inv.id}
|
||||||
|
>
|
||||||
|
{revokingId === inv.id ? 'Revoking…' : 'Revoke'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button type="button" className="btn-link" onClick={onClose}>Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,7 +1,17 @@
|
|||||||
// Login.jsx — the composed sign-in surface (§6.2) after the v0.10.0
|
// Login.jsx — the composed sign-in surface (§6.2) after the v0.12.0
|
||||||
// (passcodes, roadmap item #8) rebase onto v0.8.0 (beta-access-request
|
// (CloudFlare Turnstile gate on OTC dispatch, roadmap item #10) /
|
||||||
// capture, §6.1 / §14.1, roadmap item #6). v0.7.0 (roadmap item #5)
|
// v0.10.0 (passcodes, roadmap item #8) rebase onto v0.8.0
|
||||||
// established the email + OTC scaffolding both releases extended.
|
// (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
|
// Four-to-six-step flow (most users see three; the longest path is
|
||||||
// pending-user with no passcode, who never sees the passcode steps):
|
// pending-user with no passcode, who never sees the passcode steps):
|
||||||
@@ -72,7 +82,10 @@ import {
|
|||||||
checkPasscode,
|
checkPasscode,
|
||||||
verifyPasscode,
|
verifyPasscode,
|
||||||
setPasscode as apiSetPasscode,
|
setPasscode as apiSetPasscode,
|
||||||
|
startDeviceTrust,
|
||||||
} from '../api'
|
} from '../api'
|
||||||
|
import TurnstileWidget, { turnstileEnabled } from './TurnstileWidget'
|
||||||
|
import { EVENTS, track } from '../lib/analytics'
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
// Steps: 'email' → 'passcode' or 'code' → (on the OTC path, after
|
// Steps: 'email' → 'passcode' or 'code' → (on the OTC path, after
|
||||||
@@ -84,12 +97,31 @@ export default function Login() {
|
|||||||
const [code, setCode] = useState('')
|
const [code, setCode] = useState('')
|
||||||
const [passcode, setPasscode] = useState('')
|
const [passcode, setPasscode] = useState('')
|
||||||
const [newPasscode, setNewPasscode] = 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.
|
// v0.8.0 — capture-profile fields.
|
||||||
const [firstName, setFirstName] = useState('')
|
const [firstName, setFirstName] = useState('')
|
||||||
const [lastName, setLastName] = useState('')
|
const [lastName, setLastName] = useState('')
|
||||||
const [reason, setReason] = useState('')
|
const [reason, setReason] = useState('')
|
||||||
const [status, setStatus] = useState('')
|
const [status, setStatus] = useState('')
|
||||||
const [busy, setBusy] = useState(false)
|
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 emailRef = useRef(null)
|
||||||
const codeRef = useRef(null)
|
const codeRef = useRef(null)
|
||||||
const passcodeRef = useRef(null)
|
const passcodeRef = useRef(null)
|
||||||
@@ -105,6 +137,33 @@ export default function Login() {
|
|||||||
else if (step === 'set-passcode') newPasscodeRef.current?.focus()
|
else if (step === 'set-passcode') newPasscodeRef.current?.focus()
|
||||||
}, [step])
|
}, [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) {
|
async function submitEmail(e) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!email.trim() || !email.includes('@')) {
|
if (!email.trim() || !email.includes('@')) {
|
||||||
@@ -119,13 +178,22 @@ export default function Login() {
|
|||||||
setStep('passcode')
|
setStep('passcode')
|
||||||
setStatus('')
|
setStatus('')
|
||||||
} else {
|
} 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')
|
setStep('code')
|
||||||
setStatus('Check your inbox — a six-digit code is on the way.')
|
setStatus('Check your inbox — a six-digit code is on the way.')
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} 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) {
|
if (err.status === 429) {
|
||||||
setStatus('Slow down — wait a minute before requesting another code.')
|
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 {
|
} else {
|
||||||
setStatus(err.message || 'Could not start sign-in. Try again.')
|
setStatus(err.message || 'Could not start sign-in. Try again.')
|
||||||
}
|
}
|
||||||
@@ -143,7 +211,11 @@ export default function Login() {
|
|||||||
setBusy(true)
|
setBusy(true)
|
||||||
setStatus('')
|
setStatus('')
|
||||||
try {
|
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
|
// Reload so App.jsx's getMe() picks up the fresh session. A
|
||||||
// returning passcode user is by definition already past the
|
// returning passcode user is by definition already past the
|
||||||
// §6.1 capture step (they couldn't have set a passcode while
|
// §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.
|
// fresh code in the user's inbox immediately.
|
||||||
setPasscode('')
|
setPasscode('')
|
||||||
try {
|
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')
|
setStep('code')
|
||||||
setStatus(
|
setStatus(
|
||||||
'Too many failed attempts. We sent a one-time code to your email — use it to sign in.',
|
'Too many failed attempts. We sent a one-time code to your email — use it to sign in.',
|
||||||
)
|
)
|
||||||
} catch (e2) {
|
} catch (e2) {
|
||||||
|
setTurnstileToken(null)
|
||||||
if (e2.status === 429) {
|
if (e2.status === 429) {
|
||||||
setStep('code')
|
setStep('code')
|
||||||
setStatus(
|
setStatus(
|
||||||
'Too many failed attempts. Wait a minute, then request a one-time code to sign in.',
|
'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 {
|
} else {
|
||||||
setStatus(
|
setStatus(
|
||||||
'Too many failed attempts. Use the "Use a code instead" link to sign in via email.',
|
'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)
|
setBusy(true)
|
||||||
setStatus('')
|
setStatus('')
|
||||||
try {
|
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
|
// OTC verified — the server has signed in the user. Fetch the
|
||||||
// canonical /api/auth/me to decide where to land:
|
// canonical /api/auth/me to decide where to land:
|
||||||
// * needs_profile → §6.1 capture (then /beta-pending).
|
// * needs_profile → §6.1 capture (then /beta-pending).
|
||||||
@@ -246,6 +335,11 @@ export default function Login() {
|
|||||||
last_name: ln,
|
last_name: ln,
|
||||||
beta_request_reason: why,
|
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
|
// Hard-load so App.jsx re-fetches /api/auth/me and picks up
|
||||||
// the captured fields. The user stays permission_state='pending'
|
// the captured fields. The user stays permission_state='pending'
|
||||||
// until an admin grants access — the next thing they should
|
// until an admin grants access — the next thing they should
|
||||||
@@ -314,17 +408,27 @@ export default function Login() {
|
|||||||
|
|
||||||
async function fallbackToOtc() {
|
async function fallbackToOtc() {
|
||||||
// Manual "Use a code instead" from the passcode step. Same shape
|
// 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)
|
setBusy(true)
|
||||||
setStatus('')
|
setStatus('')
|
||||||
try {
|
try {
|
||||||
await requestOtc(email.trim())
|
await requestOtc(email.trim(), { turnstileToken })
|
||||||
|
setTurnstileToken(null)
|
||||||
setPasscode('')
|
setPasscode('')
|
||||||
setStep('code')
|
setStep('code')
|
||||||
setStatus('Check your inbox — a six-digit code is on the way.')
|
setStatus('Check your inbox — a six-digit code is on the way.')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
setTurnstileToken(null)
|
||||||
if (err.status === 429) {
|
if (err.status === 429) {
|
||||||
setStatus('Slow down — wait a minute before requesting another code.')
|
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 {
|
} else {
|
||||||
setStatus(err.message || 'Could not request a code. Try again.')
|
setStatus(err.message || 'Could not request a code. Try again.')
|
||||||
}
|
}
|
||||||
@@ -357,7 +461,18 @@ export default function Login() {
|
|||||||
required
|
required
|
||||||
disabled={busy}
|
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'}
|
{busy ? 'Checking…' : 'Continue'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -378,6 +493,30 @@ export default function Login() {
|
|||||||
required
|
required
|
||||||
disabled={busy}
|
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">
|
<div className="otc-actions">
|
||||||
<button type="submit" disabled={busy || !passcode.trim()}>
|
<button type="submit" disabled={busy || !passcode.trim()}>
|
||||||
{busy ? 'Signing in…' : 'Sign in'}
|
{busy ? 'Signing in…' : 'Sign in'}
|
||||||
@@ -386,7 +525,7 @@ export default function Login() {
|
|||||||
type="button"
|
type="button"
|
||||||
className="btn-link-quiet"
|
className="btn-link-quiet"
|
||||||
onClick={fallbackToOtc}
|
onClick={fallbackToOtc}
|
||||||
disabled={busy}
|
disabled={busy || !turnstileReady}
|
||||||
>
|
>
|
||||||
Use a code instead
|
Use a code instead
|
||||||
</button>
|
</button>
|
||||||
@@ -420,6 +559,17 @@ export default function Login() {
|
|||||||
required
|
required
|
||||||
disabled={busy}
|
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">
|
<div className="otc-actions">
|
||||||
<button type="submit" disabled={busy || code.length !== 6}>
|
<button type="submit" disabled={busy || code.length !== 6}>
|
||||||
{busy ? 'Signing in…' : 'Sign in'}
|
{busy ? 'Signing in…' : 'Sign in'}
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ import {
|
|||||||
getMe,
|
getMe,
|
||||||
setPasscode,
|
setPasscode,
|
||||||
clearPasscode,
|
clearPasscode,
|
||||||
|
listMyDevices,
|
||||||
|
revokeMyDevice,
|
||||||
|
revokeAllMyDevices,
|
||||||
} from '../api.js'
|
} from '../api.js'
|
||||||
import { getConsent, onConsentChange, hydrateFromServer } from '../lib/consent.js'
|
import { getConsent, onConsentChange, hydrateFromServer } from '../lib/consent.js'
|
||||||
|
|
||||||
@@ -54,11 +57,126 @@ export default function NotificationSettings({ viewer }) {
|
|||||||
<WatchesSection />
|
<WatchesSection />
|
||||||
<MutesSection viewer={viewer} />
|
<MutesSection viewer={viewer} />
|
||||||
<SignInSection />
|
<SignInSection />
|
||||||
|
<DevicesSection />
|
||||||
<PrivacyCookiesSection />
|
<PrivacyCookiesSection />
|
||||||
</div>
|
</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 ──────────
|
// ── §6.2 sign-in (v0.10.0 / roadmap item #8): passcode management ──────────
|
||||||
|
|
||||||
function SignInSection() {
|
function SignInSection() {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { draftPRText, openPR } from '../api'
|
import { draftPRText, openPR } from '../api'
|
||||||
|
import { EVENTS, track } from '../lib/analytics'
|
||||||
|
|
||||||
export default function PRModal({ slug, branch, branchIsPrivate, onClose, onOpened }) {
|
export default function PRModal({ slug, branch, branchIsPrivate, onClose, onOpened }) {
|
||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState('')
|
||||||
@@ -39,6 +40,9 @@ export default function PRModal({ slug, branch, branchIsPrivate, onClose, onOpen
|
|||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const { pr_number } = await openPR(slug, branch, { title: title.trim(), description: description.trim() })
|
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)
|
onOpened?.(pr_number)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e.message)
|
setError(e.message)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
startResolutionBranch,
|
startResolutionBranch,
|
||||||
withdrawPR,
|
withdrawPR,
|
||||||
} from '../api'
|
} from '../api'
|
||||||
|
import { EVENTS, track } from '../lib/analytics'
|
||||||
|
|
||||||
export default function PRView({ viewer }) {
|
export default function PRView({ viewer }) {
|
||||||
const { slug, prNumber: prNumberParam } = useParams()
|
const { slug, prNumber: prNumberParam } = useParams()
|
||||||
@@ -135,6 +136,10 @@ export default function PRView({ viewer }) {
|
|||||||
anchorPayload: reviewDraft?.anchorPayload || {},
|
anchorPayload: reviewDraft?.anchorPayload || {},
|
||||||
quote: reviewDraft?.quote || null,
|
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('')
|
setReviewText('')
|
||||||
setReviewDraft(null)
|
setReviewDraft(null)
|
||||||
await refresh()
|
await refresh()
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { proposeRFC } from '../api'
|
import { proposeRFC } from '../api'
|
||||||
|
import { EVENTS, track } from '../lib/analytics'
|
||||||
|
|
||||||
function slugify(title) {
|
function slugify(title) {
|
||||||
return title
|
return title
|
||||||
@@ -52,6 +53,10 @@ export default function ProposeModal({ viewer, onClose, onSubmitted }) {
|
|||||||
pitch: pitch.trim(),
|
pitch: pitch.trim(),
|
||||||
tags,
|
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)
|
onSubmitted?.(result)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message || 'Submission failed.')
|
setError(err.message || 'Submission failed.')
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
postDiscussionMessage,
|
postDiscussionMessage,
|
||||||
resolveDiscussionThread,
|
resolveDiscussionThread,
|
||||||
} from '../api'
|
} from '../api'
|
||||||
|
import { EVENTS, track } from '../lib/analytics'
|
||||||
|
|
||||||
export default function RFCDiscussionPanel({ slug, viewer }) {
|
export default function RFCDiscussionPanel({ slug, viewer }) {
|
||||||
const [threads, setThreads] = useState([])
|
const [threads, setThreads] = useState([])
|
||||||
@@ -100,6 +101,10 @@ export default function RFCDiscussionPanel({ slug, viewer }) {
|
|||||||
void message_id
|
void message_id
|
||||||
}
|
}
|
||||||
setComposer('')
|
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) {
|
} catch (err) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -43,7 +43,9 @@ import RFCDiscussionPanel from './RFCDiscussionPanel.jsx'
|
|||||||
import ChangePanel, { diffWords } from './ChangePanel.jsx'
|
import ChangePanel, { diffWords } from './ChangePanel.jsx'
|
||||||
import PRModal from './PRModal.jsx'
|
import PRModal from './PRModal.jsx'
|
||||||
import GraduateDialog from './GraduateDialog.jsx'
|
import GraduateDialog from './GraduateDialog.jsx'
|
||||||
|
import InvitationsModal from './InvitationsModal.jsx'
|
||||||
import { claimOwnership } from '../api'
|
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_IDLE_MS = 5 * 60 * 1000 // §8.6 idle window; exact value is impl detail.
|
||||||
const MANUAL_DEBOUNCE_MS = 800
|
const MANUAL_DEBOUNCE_MS = 800
|
||||||
@@ -121,7 +123,15 @@ export default function RFCView({ viewer }) {
|
|||||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
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)
|
listModels(slug)
|
||||||
.then(({ models, default: def }) => {
|
.then(({ models, default: def }) => {
|
||||||
setModels(models || [])
|
setModels(models || [])
|
||||||
@@ -139,6 +149,11 @@ export default function RFCView({ viewer }) {
|
|||||||
const [showMetadataPane, setShowMetadataPane] = useState(false)
|
const [showMetadataPane, setShowMetadataPane] = useState(false)
|
||||||
const [showGraduateDialog, setShowGraduateDialog] = useState(false)
|
const [showGraduateDialog, setShowGraduateDialog] = useState(false)
|
||||||
const [claimError, setClaimError] = useState(null)
|
const [claimError, setClaimError] = useState(null)
|
||||||
|
// v0.16.0 (item #12): the per-RFC invitations modal. Visible only to
|
||||||
|
// RFC owners (frontmatter) and platform admin/owner — the backend
|
||||||
|
// gates the underlying endpoints regardless, so a leaked toggle
|
||||||
|
// can't actually leak anything.
|
||||||
|
const [showInvitationsModal, setShowInvitationsModal] = useState(false)
|
||||||
|
|
||||||
// Load main view + branch view whenever slug/branch changes.
|
// Load main view + branch view whenever slug/branch changes.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -624,6 +639,20 @@ export default function RFCView({ viewer }) {
|
|||||||
Graduate to RFC repo
|
Graduate to RFC repo
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{/* v0.16.0 (item #12): owner-only invitations affordance.
|
||||||
|
Shown when the viewer is named in the RFC's frontmatter
|
||||||
|
`owners` list or holds a platform admin/owner role.
|
||||||
|
Available on both super-drafts and active RFCs. */}
|
||||||
|
{viewer && (viewer.role === 'owner' || viewer.role === 'admin' || (entry?.owners || []).includes(viewer.gitea_login)) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-link"
|
||||||
|
onClick={() => setShowInvitationsModal(true)}
|
||||||
|
title="Invite collaborators to this RFC"
|
||||||
|
>
|
||||||
|
Invitations
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{claimError && (
|
{claimError && (
|
||||||
@@ -856,6 +885,14 @@ export default function RFCView({ viewer }) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showInvitationsModal && (
|
||||||
|
<InvitationsModal
|
||||||
|
slug={slug}
|
||||||
|
rfcTitle={entry?.title}
|
||||||
|
onClose={() => setShowInvitationsModal(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{showMetadataPane && (
|
{showMetadataPane && (
|
||||||
<MetadataPaneModal
|
<MetadataPaneModal
|
||||||
slug={slug}
|
slug={slug}
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
}
|
||||||
@@ -0,0 +1,384 @@
|
|||||||
|
// 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',
|
||||||
|
// v0.16.0 / item #12 — per-RFC owner invites.
|
||||||
|
INVITATION_SENT: 'Invitation Sent',
|
||||||
|
INVITATION_ACCEPTED: 'Invitation Accepted',
|
||||||
|
// v0.17.0 / item #16 — admin-create user + invite email.
|
||||||
|
USER_INVITED: 'User Invited',
|
||||||
|
INVITE_CLAIMED: 'Invite Claimed',
|
||||||
|
})
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
let _pendingProperties = null // identify({ properties }) or
|
||||||
|
// setUserProperties() before init.
|
||||||
|
const _queue = [] // {kind: 'track'|'identify'|'anonymize'|
|
||||||
|
// 'setUserProperties', ...}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply a {key: value} property bag as an Amplitude Identify event.
|
||||||
|
// Used by both `identify({ properties })` and `setUserProperties`.
|
||||||
|
function applyProperties(props) {
|
||||||
|
if (!_initialized || !_amplitude || !props) return
|
||||||
|
try {
|
||||||
|
const id = new _amplitude.Identify()
|
||||||
|
for (const [k, v] of Object.entries(props)) {
|
||||||
|
if (v === undefined || v === null) continue
|
||||||
|
if (Array.isArray(v) && v.length === 2 && v[0] === '__setOnce__') {
|
||||||
|
id.setOnce(k, v[1])
|
||||||
|
} else {
|
||||||
|
id.set(k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_amplitude.identify(id)
|
||||||
|
} catch (_) {
|
||||||
|
// SDK errors are non-fatal; analytics is best-effort.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drain the queue. Called once init resolves.
|
||||||
|
function drainQueue() {
|
||||||
|
if (!_initialized || !_amplitude) return
|
||||||
|
if (_pendingUserId != null) {
|
||||||
|
try { _amplitude.setUserId(_pendingUserId) } catch (_) {}
|
||||||
|
_pendingUserId = null
|
||||||
|
}
|
||||||
|
if (_pendingProperties != null) {
|
||||||
|
applyProperties(_pendingProperties)
|
||||||
|
_pendingProperties = 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)
|
||||||
|
if (item.properties != null) applyProperties(item.properties)
|
||||||
|
} else if (item.kind === 'setUserProperties') {
|
||||||
|
applyProperties(item.properties)
|
||||||
|
} 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 and optional durable properties.
|
||||||
|
* Pass `{ user_id: '<opaque-id>', properties?: { role, first_sign_in_at, … } }`.
|
||||||
|
* DO NOT pass email, display name, or other PII as user_id or in
|
||||||
|
* properties. Idempotent — subsequent calls with the same id are
|
||||||
|
* cheap; properties are merged into the Amplitude user record.
|
||||||
|
*
|
||||||
|
* To mark a property as setOnce (immutable after first write),
|
||||||
|
* pass `properties: { first_sign_in_at: ['__setOnce__', '2026-05-28T…'] }`.
|
||||||
|
* Bare values use Amplitude's `.set()` (mutable).
|
||||||
|
*
|
||||||
|
* Pattern (per #21 Part C):
|
||||||
|
* - On sign-in success in App.jsx: identify with viewer.id + the
|
||||||
|
* durable property bag (role, permission_state, first_sign_in_at
|
||||||
|
* setOnce, passcode_set, device_trusted_count, account_created_at
|
||||||
|
* setOnce).
|
||||||
|
* - On invite-claim success in InviteClaim.jsx / AcceptInvitation.jsx:
|
||||||
|
* identify with the new viewer.id + invitation-derived properties
|
||||||
|
* (invited_by_admin_id, invited_at setOnce, initial_role, claim_method)
|
||||||
|
* BEFORE firing any track() — so the Amplitude user record is
|
||||||
|
* created with the OHM user_id from the first event, not as an
|
||||||
|
* anonymous device that retroactively links. */
|
||||||
|
export function identify({ user_id, properties } = {}) {
|
||||||
|
if (!API_KEY) { warnNoKey(); return }
|
||||||
|
if (user_id == null && properties == null) return
|
||||||
|
bootstrap()
|
||||||
|
if (!consentGranted()) {
|
||||||
|
// Hold for when consent lands; identify-on-sign-in is a common
|
||||||
|
// race with the consent banner choice.
|
||||||
|
if (user_id != null) _pendingUserId = user_id
|
||||||
|
if (properties != null) {
|
||||||
|
_pendingProperties = { ..._pendingProperties, ...properties }
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (_initialized && _amplitude) {
|
||||||
|
try {
|
||||||
|
if (user_id != null) _amplitude.setUserId(user_id)
|
||||||
|
if (properties != null) applyProperties(properties)
|
||||||
|
} catch (_) {}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (user_id != null) _pendingUserId = user_id
|
||||||
|
if (properties != null) {
|
||||||
|
_pendingProperties = { ..._pendingProperties, ...properties }
|
||||||
|
}
|
||||||
|
_queue.push({ kind: 'identify', user_id, properties })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update durable user properties on the current Amplitude user
|
||||||
|
* record mid-session — for state changes that shouldn't wait for the
|
||||||
|
* next sign-in to surface (role grant/revoke, passcode set, device
|
||||||
|
* trusted, etc.). Same property shape as `identify({ properties })`.
|
||||||
|
* setOnce values use the `['__setOnce__', value]` sentinel pattern.
|
||||||
|
* Has no effect if no identify has happened yet — set the user_id
|
||||||
|
* via `identify()` first.
|
||||||
|
*
|
||||||
|
* Per #21 Part C: call this from any surface where the user's
|
||||||
|
* Amplitude-relevant state changes mid-session, so the dashboard
|
||||||
|
* stays current. */
|
||||||
|
export function setUserProperties(properties) {
|
||||||
|
if (!API_KEY) { warnNoKey(); return }
|
||||||
|
if (properties == null) return
|
||||||
|
bootstrap()
|
||||||
|
if (!consentGranted()) {
|
||||||
|
_pendingProperties = { ..._pendingProperties, ...properties }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (_initialized && _amplitude) {
|
||||||
|
applyProperties(properties)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_pendingProperties = { ..._pendingProperties, ...properties }
|
||||||
|
_queue.push({ kind: 'setUserProperties', properties })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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.
|
||||||
|
*
|
||||||
|
* Per #21 Part C: clears both the user_id binding AND the pending
|
||||||
|
* property cache, so a subsequent sign-in as a different user
|
||||||
|
* starts with a fully fresh slate (no carry-over properties from
|
||||||
|
* the previous user). */
|
||||||
|
export function anonymize() {
|
||||||
|
if (!API_KEY) { warnNoKey(); return }
|
||||||
|
_pendingUserId = null
|
||||||
|
_pendingProperties = 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
|
||||||
|
_pendingProperties = null
|
||||||
|
_queue.length = 0
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user