Compare commits

..

12 Commits

Author SHA1 Message Date
Ben Stull 72f8457933 Release 0.15.0: Amplitude Analytics + Session Replay (with #21 Part C identity lifecycle)
Wave 5 / Track A. Roadmap item #13. Folds in #21 Part C user-identity-
lifecycle work inline rather than as a follow-up release (operator
ask: "implement Amplitude best practices from the very get-go").

Wraps @amplitude/unified with:
  - consent-gated lazy init (v0.13.0 cookie banner; SDK never loads
    without explicit analytics opt-in)
  - amplitude.initAll(KEY, { analytics: { autocapture: true },
    sessionReplay: { sampleRate: 1 } }) — vendor-recommended shape
  - identify({ user_id, properties? }) with set vs setOnce semantics
  - setUserProperties(properties) for mid-session state changes
  - anonymize() clears both user_id binding AND property cache

Wired into App.jsx (identify on me.user with role / permission_state /
passcode_set / device_trusted as set; first_sign_in_at / account_created_at
as setOnce; Page Viewed on route change; Sign-out fires User Signed
Out + anonymize), Login.jsx (User Signed In + Beta Access Requested),
and the per-feature surfaces (ProposeModal, PRModal, RFCView,
RFCDiscussionPanel, PRView, Admin).

Binding: VITE_AMPLITUDE_API_KEY via `flotilla overlay set`
(bundle-embedded public key like VITE_TURNSTILE_SITE_KEY — not
secret). Mid-Session-L correction: original dispatch brief specified
secret-set; vendor guidance + bundle visibility settled it as overlay.

PII discipline: no email, no display_name, no gitea_login, no free-text
fields ever sent. Properties are opaque ids + enums + timestamps +
booleans only.

Subagent ξ shipped the wrapper structure + nine-event taxonomy on
feature/v0.15.0-amplitude (0fd8c52 + 6cfbf69 post-correction).
Driver-side integration extended the wrapper with setUserProperties
+ identify properties for the #21 Part C identity-lifecycle work.
Frontend build verified green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 05:04:10 -07:00
Ben Stull b3f1b15f65 Release 0.12.0: CloudFlare Turnstile on OTC email-entry 2026-05-28 03:52:45 -07:00
Ben Stull 6fb68a95c7 Release 0.11.0: trust device for 30 days 2026-05-28 03:46:24 -07:00
Ben Stull 7872b921ed Release 0.9.0: admin user-management page + new-request notifications 2026-05-28 03:39:25 -07:00
Ben Stull de28272914 Release 0.14.0: public /docs user guide (DOCS.md served at /docs)
Ships DOCS.md and the /docs route — a plain-prose translation of
SPEC.md for users, distinct from the binding spec. Originating need
was the admin-vs-owner role distinction on /admin/users (§6.1);
response is a single guide that covers the framework's user-facing
surfaces end-to-end (roles, proposing, branches, PRs, graduation,
notifications). Mirrors /philosophy: markdown file at repo root +
sibling backend loader + MarkdownPreview render. No schema migration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 03:08:28 -07:00
Ben Stull 55beba5c0a Release 0.10.0: user-set passcodes (OTC stays as fallback)
After a successful OTC sign-in, a contributor can set a passcode
(4-20 characters, bcrypt-hashed) and use email + passcode for
subsequent sign-ins. OTC remains the structural fallback: five
consecutive failed verifies lock the passcode path for 15 minutes
(HTTP 423), and a forgotten passcode is recovered by requesting a
fresh code. Migration 015_passcode.sql adds four nullable columns
to the users table; existing rows pass through as OTC-only and can
opt into a passcode from a new Sign-in tab in /settings/notifications.
The /login surface is extended to a five-step flow (email → either
passcode or OTC code → optional post-OTC passcode offer → optional
set-passcode). SPEC corrections per §19.3 rule 2: §6 names the
three auth paths, §14.1 documents the stepped login flow, §17 lists
the four new /auth/passcode/* endpoints, §19.2 surfaces four new
candidates and refreshes the cross-refs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 02:39:27 -07:00
Ben Stull ca8ba69acb Release 0.8.0: open beta-access request flow (first/last/why)
Replaces the v0.3.0 / v0.7.0 allowed_emails admission gate with an
admin-grant flow (roadmap item #6, SPEC §6.1 / §6.2 / §14.1 / §17).
Any valid email can sign in via OTC; a fresh user lands in
permission_state='pending' with a captured first/last/why profile,
and an admin grant flips them to 'granted' before write endpoints
accept them. Grandfathered users pass through the migration with
the column default 'granted' so existing contributors are unaffected.
The allowed_emails table stays in the schema as a fast-path bypass
pending v0.9.0's admin user-management page (item #7).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 02:25:59 -07:00
Ben Stull 8aa65014b4 Release 0.13.0: cookie/privacy consent banner + policy pages (instrumentation prep)
Roadmap item #11. Ships the non-modal bottom-of-page cookie consent
banner, the default /privacy and /cookies policy pages, the
`cookie_consent` table + two §17 endpoints for server-side persistence,
the localStorage fallback for anonymous viewers, the /settings
"Privacy & cookies" tab for revisiting the choice, and the
`frontend/src/lib/consent.js` helper that roadmap item #13's analytics
SDK (v0.15.0) will gate against. No analytics SDK ships in this release
— the consent infrastructure goes in first so the gate is already in
place. Adds SPEC §14.5 / §14.6, lists two new endpoints in §17, names
the new table in §5, and surfaces four §19.2 candidates (content-repo
file vs env-var policy, GPC / DNT headers, i18n, item-#13 dependency).
Two new optional env vars (`VITE_PRIVACY_POLICY_URL`,
`VITE_COOKIES_POLICY_URL`) — defaults render the framework's stub
pages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 01:08:23 -07:00
Ben Stull f8e797ab09 Release 0.7.0: email/OTC sign-in (Gitea OAuth retained as fallback)
Replaces the Gitea OAuth gesture as the primary human-auth path
(roadmap item #5, SPEC §6.2). Users sign in by entering their email,
receiving a six-digit code via the existing SMTP layer, and entering
the code on a two-step /login surface. The Gitea OAuth callback
remains functional during migration — the new UI links to it as a
fallback for users with active OAuth sessions or older invite paths
— and is scheduled for removal in a future release once OTC adoption
is universal. Existing users are linked by email on first OTC sign-
in (gitea_id preserved); new users are provisioned with NULL
gitea_id and rely on email as the identity key. The migration
introduces backend/migrations/012_otc.sql (otc_codes table + users
schema rebuild for nullable gitea_id and a partial unique index on
email), two new endpoints (POST /auth/otc/request, POST /auth/otc/verify),
bcrypt as a new backend dependency for code hashing, and 11 new
tests in test_otc_vertical.py covering the happy path, expired and
consumed and wrong codes, the per-email rate limit, the allowlist
gate, the OAuth-era link path, fresh provisioning, and prior-code
invalidation on re-request. No new secrets are required — the
existing SECRET_KEY signs sessions and bcrypt's per-row salt covers
the code hashes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 01:03:18 -07:00
Ben Stull 21743a08b1 Release 0.6.0: anonymous-write gates audit + hardening
Sweep-the-edges hardening release (roadmap item #4). Audits every
write-shaped backend endpoint to confirm each one enforces an explicit
auth.require_contributor (or stricter) gate before doing state-changing
work; adds a regression test net (test_anon_offlimits_vertical.py, 12
tests, 66 assertions) so future endpoints can't quietly ship without a
gate. The only behaviour change: GET /api/rfcs/<slug>/graduate/progress
now requires auth.require_user since the step detail (repo name, PR
number, rollback steps) isn't part of the v0.3.0 anonymous-read contract.
No schema, no env, no dependency changes; operator action is rebuild +
restart per CHANGELOG §0.6.0 upgrade steps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 00:47:49 -07:00
Ben Stull c92730a737 Release 0.5.0: PR-less per-RFC discussion (contribution still requires PR)
Roadmap item #3. An RFC's main view now carries a discussion surface
distinct from PR comments and from branch chat. The substrate is the
existing threads/thread_messages tables — rows with branch_name IS NULL
scope to the RFC's main view; the schema already permitted that shape,
v0.5.0 is the first build to write it. Five new endpoints under
/api/rfcs/<slug>/discussion/..., a new RFCDiscussionPanel right-column
component used when branchParam === main, SPEC §10.10 settling
discussion-vs-contribution, and §17 listing the new routes. Notification
routing reuses the existing chat_message_in_participated_thread /
chat_reply_to_my_message event kinds with branch_name=null on the
fan-out row; a distinct event_kind is a §19.2 candidate. Anonymous
viewers can read; writes require contributor — v0.6.0's item #4 will
harden adjacent gates. No schema migration; minor bump, no operator
action required beyond rebuild and restart.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 23:04:52 -07:00
Ben Stull 0f8b318afa Release 0.4.0: auto-set RFC owner = proposer
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 22:53:09 -07:00
57 changed files with 12121 additions and 139 deletions
+1420 -4
View File
File diff suppressed because it is too large Load Diff
+605
View File
@@ -0,0 +1,605 @@
# Using the RFC app
This is the user-facing guide to the Wiggleverse RFC framework — how to
read what's here, propose a new RFC, contribute to one that already
exists, and understand who is allowed to do what.
This guide describes the framework. Individual deployments brand and
configure themselves independently — the name in the header and the
corpus the RFCs are about belong to the deployment, not to this
document.
For the *why* of the framework, read the [philosophy](/philosophy).
For the binding technical contract, see `SPEC.md` in the repository.
---
## Reading without signing in
You can read the catalog and every public RFC without an account.
Anonymous visitors can:
- Browse the catalog of super-drafts and active RFCs.
- Open any RFC and read its canonical body.
- Read any public branch — its diff and its chat thread.
- Read any pull request — its diff, its conversation, its review
comments.
- Read the discussion that has accumulated on an RFC's main view.
Reading is open by design. The framework's claim is that the *argument
behind a definition* is the evidence that the definition was earned,
and an argument that disappears behind a sign-in wall stops carrying
that evidence.
What you cannot do without an account: chat, propose a new RFC,
create a branch, open a PR, drop a flag, or post on a discussion
thread. Every write affordance is replaced with a sign-in prompt.
---
## Signing in
While the framework is in private beta, only invited email addresses
can complete sign-in. If your email is on the allowlist, the
"Sign in" button in the header completes the flow and lands you on
the catalog with full read and write access. If your email is not on
the allowlist, you'll be sent to a short "pending" page explaining
the gate.
Once you have an account, you're a **contributor** by default — the
role that grants every write affordance the app exposes, scoped by
the per-RFC and per-branch rules described below.
---
## Proposing a new RFC
A new RFC begins as a proposal. The "+ Propose new RFC" button at
the bottom of the catalog opens a small modal that collects four
things:
- **Title.** The word, concept, or topic this RFC would define.
- **Slug.** A kebab-cased identifier derived from the title. It is
the entry's stable handle from this moment until it graduates;
collisions with existing entries or open proposals are caught
inline.
- **Pitch.** One or two paragraphs answering *why this RFC is
needed*. This becomes the body of the entry.
- **Tags.** Optional. The AI suggests tags from the pitch; you can
accept, dismiss, or type your own.
Submitting the modal does one concrete thing: it opens a pull
request against the framework's meta repository, adding one new
file under `rfcs/`. There is no other Git artifact and no other
side-effect. You are returned to the **pending-idea view** for the
new proposal.
A pending idea is publicly readable but not yet a super-draft. The
catalog surfaces it in a "Pending ideas" disclosure at the bottom
of the list. A conversation can accumulate on the pending-idea view
before it is admitted — contributors can argue, in public, about
whether the entry belongs in the catalog at all.
Three outcomes are possible:
- **Merge.** An admin or owner merges the proposal PR. The entry
becomes a super-draft and graduates from the "Pending ideas"
section into the main catalog. Any conversation that accumulated
on the pending-idea view migrates with it.
- **Decline.** An admin or owner declines, attaching a written
comment. You see the comment on your next visit, along with a
one-click affordance to revise and re-propose.
- **Withdraw.** You can withdraw your own proposal at any time. The
entry will not appear in any default view; the conversation that
accumulated stays attached to the closed PR as historical record.
You are automatically the first owner of any RFC you propose. The
claim flow described under [Roles & permissions](#roles--permissions)
is for *other* contributors to add themselves as owners later, not
for the proposer.
---
## What a super-draft is
A super-draft is an entry that has been admitted to the catalog but
does not yet have its own dedicated repository. Most of the
argument that shapes a definition happens here. The framework
assumes — and the philosophy explicitly invites — that many
super-drafts will not survive the argument, and that is fine. The
entries that do survive earn their place in the catalog by being
defensible in public.
Opening a super-draft from the catalog gives you the same surface
an active RFC uses:
- The canonical body in the centre, read-only by default.
- A chat thread on the right where the public conversation lives.
- A breadcrumb dropdown listing any in-flight edit branches and
any open body-edit PRs against this entry.
- A "Start Contributing" affordance that cuts a fresh edit branch
and lands you in contribute mode.
Edits to a super-draft body propagate through pull requests against
the meta repository — there is no dedicated RFC repository yet.
---
## What an active RFC is
An active RFC is an entry that has been **graduated**. It has its
own dedicated repository, an integer `RFC-NNNN` identifier, and a
canonical body file (`RFC.md`) inside that repository. The catalog
distinguishes super-drafts and active RFCs at a glance.
Opening an active RFC gives you:
- `main` — the canonical body, always read-only. Changes to `main`
arrive exclusively through pull requests.
- A breadcrumb listing every open branch and pull request on this
RFC.
- A per-branch chat thread on the right. Each branch has its own
conversation, including `main` itself.
- A "Start Contributing" affordance: on `main` it cuts a new branch
and lands you on it in contribute mode; on any other branch you
already have push access to, it flips that branch into
contribute mode.
---
## Discussion vs contribution
The framework draws an explicit distinction between two surfaces
that other tools tend to conflate:
- **Discussion** is what the RFC is *for*. The chat thread on an
RFC's main view is the place for "what about this part?" or
"have we considered…?" questions that don't yet warrant proposing
a specific edit. Posting on a discussion thread does not create
any Git artifact; the conversation lives in the app database.
- **Contribution** is how an RFC *changes*. Editing the canonical
body requires opening a branch and, eventually, a pull request.
The pull request is the place a specific proposed change is
reviewed and merged.
Reading both surfaces is open to anonymous visitors. Posting on
either requires a contributor account.
---
## Working on a branch
Contribute mode flips one branch into edit-enabled. The centre
column splits: a markdown source pane on the left, a live-rendered
preview on the right. Fenced `mermaid` blocks render as diagrams in
the preview.
Two kinds of edits accumulate on a branch:
- **AI-proposed changes.** You ask the AI a question or request a
revision in the branch's chat. When the AI proposes a concrete
edit, that edit appears as a *change card* in a panel below the
chat — not yet applied to the document. You can **accept**,
**decline**, or **edit before accepting**. Accepting produces
one commit on the branch with the original text, the proposed
text, and the AI's reason recorded in the commit body.
- **Manual edits.** Typing directly into the source pane buffers
locally and flushes as a single commit on an idle window, a
branch switch, or an explicit "Save now" button. Manual edits
also appear as change cards in the same panel — same evidence
shape, different author.
Every accepted change is one commit. The framework does not
support squash-merges or fixup-style cleanups: the per-change
commit granularity is the framework's evidence unit, and
collapsing it would erase what was earned.
### Discuss mode vs contribute mode
A branch defaults to discuss mode — read-only, with chat enabled.
AI proposals still appear in chat, but they are *buffered* rather
than applied; a single CTA invites you to flip the branch into
contribute mode if you want to act on them. The toggle is an
*intent* affordance, not a permission one. If you don't have push
access to the branch, the toggle is disabled with a sign-in or
request-access path.
`main` is special: contribute mode is never available there. The
"Start Contributing" button on `main` always cuts a new branch.
### Flags
Anywhere you can read, you can drop a flag. A flag is the
lightweight "I'm pointing at this, it's a problem" gesture — a
single short declarative statement anchored to a passage. Creating
a flag requires a contributor account but does not require push
access to the branch: any signed-in contributor who can read a
passage can point at it and say it's wrong.
Flags don't block PR merges by design — making them a merge gate
would re-create the failure mode where contributors hastily "resolve"
threads to unblock a button. Flags are prominent on PR headers but
non-blocking.
### Branch visibility
A new branch is publicly readable by default. The branch creator
can flip a branch to private, in which case only the creator, any
explicit grantees, and the RFC's per-RFC owners and arbiters can
read it. Owners and arbiters can flip it back.
**Opening a PR makes the branch fully public.** If your branch is
currently private, the "Open PR" affordance asks you to confirm
this before submitting. There is no concept of a private PR — the
framework's evidence claim depends on the argument being readable.
### Who can push to a branch
Every branch has one of three contribute modes:
- **`just-me`** (default) — only the branch creator can push.
- **`specific`** — only the branch creator and explicitly granted
contributors can push.
- **`any-contributor`** — any signed-in contributor can push.
The branch creator and the RFC's per-RFC owners and arbiters can
change this setting at any time.
### Branch hygiene
A branch with no associated PR auto-closes after 30 days of
inactivity. A closed branch is deleted from the Git host 60 days
later. Closed branches remain in the catalog under a "show closed"
filter — closing is a state, not a censorship event. The chat
attached to a closed or deleted branch is preserved as historical
record.
Owners and arbiters can *pin* a branch to disable the auto-close
timer if the work is paused but legitimately ongoing.
---
## Opening and reviewing a pull request
A pull request is the deliberate "ready for review" gesture for
work that has accumulated on a branch. The "Open PR" affordance is
available on any branch with at least one commit ahead of `main`.
The PR creation modal collects two AI-drafted fields, both editable
before submit:
- **Title.** A one-line description of the change, in spec voice.
- **Description.** Two to four sentences pulling from the branch
chat, written for an arbiter.
There is no reviewer picker. The RFC's arbiters are the implicit
reviewer set.
### The PR review page
The review page shows the diff, the branch's compressed chat
(messages that produced accepted changes are expanded, the rest is
behind a "Show full conversation" toggle), and the review-comment
surface inline below the chat.
Review comments are not a separate concept from chat — they live in
the same thread, anchored to a range in the diff. The framework's
claim is that the disagreement an arbiter raises about a proposed
change is the same *kind* of thing as the disagreement that
produced the proposed change in the first place, and the two should
share a surface.
Each PR records a per-user seen-cursor. New diff hunks and new
conversation messages since your last visit render with a subtle
accent. The cursor advances on view; you do not have to mark
anything as read.
### Merging a PR
Per-RFC owners and arbiters can merge; app-wide admins and owners
also retain this capability. The merge produces a no-fast-forward
commit on `main`, preserving every per-acceptance commit as an
individually reachable node in `main`'s history.
Merge is hard-blocked **only** by Git-level conflicts with `main`.
Open review threads, pending change-cards, unresolved chat threads,
and open flags do not block merge by design.
### Conflicts with main
A conflict surfaces on the PR page as a read-only banner. A "Start
resolution branch" affordance cuts a fresh branch off `main`'s
current tip, replays the work into it (asking the AI to resolve
unambiguous conflicts, surfacing the rest for you), and opens a new
PR. The original PR auto-closes when the resolution PR merges.
Fixup commits on the existing branch are not supported. Per-change
commit granularity is the framework's evidence unit; admitting
"fix merge conflict with main" commits would dilute it.
---
## Graduation: super-draft → active RFC
Graduation is the moment a super-draft becomes a canonical entry
in the catalog. It is initiated by an app-wide admin, an app-wide
owner, or one of the RFC's per-RFC owners or arbiters from the
super-draft's page.
Two preconditions block the action:
- **The super-draft must have at least one owner.** The proposer
is automatically the first owner; if they have stepped away, any
contributor can use the "Claim ownership" affordance to add
themselves.
- **No open body-edit PRs against the super-draft's entry.** An
open body-edit PR would attempt to re-introduce a body to a
frontmatter-only entry after graduation runs. Merge or withdraw
them first.
When the dialog confirms, the framework runs a transactional
sequence: create a fresh Git repository for the RFC, seed it with
the super-draft's body as `RFC.md`, update the meta-repo entry to
`state: active` with the integer ID and the new repository's URL,
auto-merge that update. If any step fails partway, the sequence
rolls back — the half-created repository is deleted and the
unmerged update is abandoned. The dialog shows each step in flight
and tells you exactly what happened.
The chat thread on the super-draft moves to the new repository's
`main` chat at graduation. Edit-branch chats from the super-draft
phase stay attached to their original branches on the meta repo
and surface from the new RFC view under a "Pre-graduation history"
section.
Graduation is not reversible. The path forward from an active RFC
is withdrawal, not back to super-draft.
---
## Withdrawing and reopening
An active RFC or a super-draft can be withdrawn by the proposer
(for a super-draft they proposed) or by an admin or owner. A
withdrawn entry stays in the catalog as a historical record but is
hidden from default views. The entry is filterable back in.
An admin or owner can reopen a withdrawn entry back into the
super-draft state. The history is preserved across the transition.
---
## AI in the chat
The chat on every RFC, super-draft, branch, and PR has an AI
participant by default. The framework treats the AI as one voice
among many in a public argument — not an oracle, and not a
co-author whose name lands on commits.
You invoke the AI by writing into the chat composer and submitting.
Each message can pick a model from the picker (the option list is
configurable per RFC). The AI responds in the chat; when its
response includes a concrete change to the document, that change
appears as a card you can accept, decline, or edit.
When you accept an AI's proposed change, the commit's
`On-behalf-of:` trailer names *you*, not the AI. The AI's authorship
survives only as evidence — the original proposal in the commit body
and the message that produced it in the chat record. The framework
is explicit about this: AI participation produces evidence; it does
not produce authorship.
Two configuration knobs scope AI participation per RFC:
- **Which models are available.** The meta-repo entry's frontmatter
carries an optional `models:` list. Absent means the RFC inherits
whatever models the deployment is provisioned to run. An empty
list (`models: []`) opts the RFC out of AI entirely — every AI
surface is absent rather than disabled-but-present.
- **Whose credentials pay.** By default the deployment operator's
API credentials cover AI calls on every RFC. A `funder:`
frontmatter field can name a single contributor whose registered
credentials pay for AI calls on this RFC instead. The named
contributor must explicitly consent from their settings page;
either side can revoke at any time.
Per-RFC AI configuration is edited through the meta-repo PR flow
that governs the rest of the entry's frontmatter — by the RFC's
per-RFC owners and arbiters, or by app-wide admins or owners.
---
## Notifications
The framework's public-async work model produces signals that
shouldn't all reach you the same way. Five surfaces compose:
- **In-app inbox.** The durable triage surface. One mental space
across every RFC you have any relationship to, with per-RFC and
per-category filters. Reachable from the inbox icon in the
header.
- **Badges.** Ambient pull-ins. A single integer beside the inbox
icon (count of unread notifications). A small binary dot on
individual catalog rows for watched RFCs with unseen activity.
No per-row counts and no per-section counts.
- **Toasts.** Transient mid-session signals. Used only for your own
actions completing, and for events arriving on the view you're
currently looking at.
- **Email.** The single channel that escapes the app. Opt-in per
category, conservative defaults. One-click unsubscribe per
category.
- **Digest.** Aggregation for activity on watched RFCs you haven't
triaged through any other channel.
### Watch states
Every RFC has one of three implicit relationship states for you:
- **Watching.** You receive structural signals for the RFC.
- **Following.** You receive only churn-grade signals (new
commits, new chat messages on threads you didn't participate
in). This is a lighter relationship than watching.
- **Muted.** You receive no signals for the RFC. The mute is
per-RFC and self-imposed; it does not affect what others see
or what reaches you on *other* RFCs.
Watch states transition automatically based on your participation,
with explicit overrides available from each RFC's header and from
the notification settings page.
### Email categories
Four categories with distinct defaults:
- **Personal-direct events** — default on. Signals where you are
the named subject. The contract is that when your name is on the
action, the framework reaches out of band.
- **Watched-RFC structural events** — default off. PR opened on a
watched RFC, PR merged, graduation, withdrawal. Inbox and badges
carry these by default; the email toggle is opt-in.
- **Watched-RFC churn** — permanently off, by design. Per-commit
and per-message email is intentionally not offered. The digest
aggregates this activity weekly.
- **Admin-actionable events** — default on for admins and owners,
unused for contributors.
### Quiet hours
You can set a daily window during which email notifications are
held. Messages held during the window are released at window end —
bundled into a single "Activity while you were away" email if a
threshold accumulated, otherwise sent individually.
---
## Roles & permissions
Authorization in this framework is owned by the app itself, not by
the Git host. The Git host sees only a single bot account — every
commit, every PR, every merge passes through it on a user's behalf
— and the *app* decides which users are authorized to ask the bot
to do which things.
### The four app-wide roles
Each role is a strict superset of the one below it.
1. **Anonymous.** Anyone who has not signed in. Can read public
RFCs, public branches, and public PRs; cannot chat, propose,
create branches, or open PRs.
2. **Contributor.** The default role for any authenticated
account. Adds everything anonymous can do, plus: propose new
RFCs, create branches on any RFC repository, open PRs from
branches they have push access to, post on chat anywhere they
can read, claim ownership of unclaimed super-drafts.
3. **Admin.** Adds the ability to act on any RFC, anywhere in the
framework. Concretely: merge any PR on any RFC, graduate any
super-draft, set branch visibility on anyone's behalf, withdraw
or reopen any entry, write-mute or restore any contributor,
grant or revoke the **admin** role.
4. **Owner.** Adds two capabilities admin does not have: grant or
revoke the **owner** role itself, and disable an account
entirely. The framework names a single "owner zero" at
bootstrap.
The practical difference between admin and owner is narrow but
load-bearing: admin is the operational tier — it does the day-to-
day moderation and stewardship work; owner is the tier that
controls the admin tier. Disabling an account and creating other
owners are owner-only because they affect the framework's chain of
authority itself.
The app refuses to let the last owner demote themselves silently —
losing the last owner would leave nobody able to grant the role
back. Role changes are recorded in an append-only `permission_events`
log; an admin's own admin/users page shows the log of who promoted,
demoted, or muted whom.
### Per-RFC delegated authority
The four roles above are framework-wide. Within an individual RFC,
the meta-repo entry's frontmatter names two additional groups:
- **`owners:`** — contributors elevated for this RFC. They can
grant push access on any branch in the RFC, merge any PR on the
RFC, change branch visibility, and withdraw the RFC.
- **`arbiters:`** — contributors with merge authority for this RFC.
Functionally similar to per-RFC owners for merge decisions; the
distinction matters in some configuration paths.
Per-RFC owners and arbiters are **not** app-wide admins. Their
elevated powers are scoped strictly to the RFC named in the
frontmatter. This is what lets the framework distribute work
without putting one person on the hook for every action.
The proposer of an RFC is automatically the first per-RFC owner.
Additional per-RFC owners are added through a "Claim ownership"
PR against the meta repository; app-wide admins or owners merge
it.
### Per-branch contribute grants
Within an RFC, the branch creator and the RFC's per-RFC owners
and arbiters can grant push access to specific contributors on a
specific branch — `specific` contribute mode, described under
"Working on a branch."
### The write-mute
An app-wide admin or owner can **mute** a contributor. A muted
account retains read access and keeps its existing branches, but
cannot create new branches, open new PRs, propose new RFCs, or
post chat. This is a moderation tool, distinct from removing the
account; restoring is the reverse gesture.
The write-mute applies only to contributors. Promoting a user to
admin or owner is the way to remove a user's write-restriction in
the structural sense; the write-mute is for *retaining* an account
while removing its ability to act.
Every mute and every restore is recorded in `permission_events`.
### Three different "mutes"
The word "mute" appears in three structurally distinct places.
They share a word and nothing else.
- **Write-mute.** Admin-imposed. Removes a contributor's ability
to post or push. Described above.
- **Per-RFC notification mute.** Self-imposed. Sets your watch
state on a specific RFC to *muted* — you stop receiving signals
for that RFC, in inbox, badges, and email. Does not affect what
others see.
- **Per-user notification mute.** Self-imposed. Suppresses
notifications produced by a specific other user, anywhere in
the framework. Notification-volume only — it does not affect
what you can read.
A write-muted contributor continues to receive notifications
normally, so they can triage what they can't act on, and so a
restore lands cleanly.
### Audit trail
Every gesture that changes app state — role changes, mutes,
graduations, withdrawals, grant changes — is recorded in
append-only logs the app maintains. Git commit history is for
code archaeology; the app's audit log is the accountability
record. An admin's page surfaces both `permission_events` (the
role/mute log) and `actions` (the state-transition log) for
review.
---
## Where to learn more
- The framework's *why* lives in [the philosophy
document](/philosophy).
- The binding technical contract — section numbers (`§n.n`)
referenced throughout this guide — is in `SPEC.md` in the
framework's source repository.
- Deployment operators have their own recipe in
`docs/DEPLOYMENTS.md`.
+707 -24
View File
@@ -332,6 +332,23 @@ and exact columns are illustrative; the implementing session can adjust.
- `actions` — append-only audit log for every state transition, every
graduation, every grant change. Includes the acting user, the bot
commit hash if any, and the on-behalf-of trailer applied.
- `cookie_consent` — per-user record of the §14.5 cookie consent
choice. One row per user. Columns: `user_id` (PK, FK users), three
flags (`essential`, `analytics`, `other_cookies`), and
`recorded_at`. `essential` is permanently 1; `recorded_at` is set
on first write and updated on every change. Absence of a row means
"no choice yet" — the banner shows. Anonymous viewers persist their
choice in `localStorage` only, with no corresponding row here.
- `device_trust` — per-row record of the §6.2 device-trust gesture
(v0.11.0, roadmap item #9). One row per `(user, trusted device)`
pair; a user with three trusted devices has three rows. Columns:
`id`, `user_id` (FK users, ON DELETE CASCADE), `device_token_hash`
(bcrypt at rest, with a unique index documenting the no-collision
invariant of the 256-bit CSPRNG token space), `created_at`,
`expires_at` (`created_at + 30 days`), `user_agent` (verbatim,
application-layer-truncated to 1024 chars), `last_seen_at`
(refreshed on every successful lookup), `revoked_at` (NULL means
active). The raw token never lives in this table — only the hash.
**Super-draft scoping.** For rows in `threads` and `changes` where the
entry referenced by `rfc_slug` is in state `super-draft`, `branch_name`
@@ -349,17 +366,110 @@ merge with no data movement.
Authorization is owned by the app. Gitea sees only the bot account.
Authentication has three paths, in the order a visitor encounters
them:
1. **Email + one-time code (OTC).** The v0.7.0 primary path: a
visitor enters their email address, receives a six-digit code via
SMTP, and exchanges the code for a session. Used by every visitor
on first sign-in, and as the fallback for the other two paths.
2. **Email + passcode (with OTC fallback).** Added in v0.10.0
(roadmap item #8). After a successful OTC sign-in, the visitor
may set a user-chosen passcode (420 characters, bcrypt-hashed at
rest) and use email + passcode on subsequent sign-ins. Five
consecutive failed verifies lock the passcode path for 15 minutes
(HTTP 423); during the lockout the user falls back to OTC. The
OTC path is unaffected by the passcode lockout, so a forgotten
passcode is recovered by requesting a fresh OTC — there is no
separate "forgot passcode" flow. The user can remove the passcode
at any time from the §6.2 sign-in settings tab, returning to
OTC-only.
3. **Device trust (cookie-only, 30 days).** Added in v0.11.0
(roadmap item #9). After a successful OTC or passcode sign-in,
the visitor may check "trust this device for 30 days." The
framework then mints a server-issued opaque token, hashes it
(bcrypt) into the `device_trust` table, and sets a long-lived
HttpOnly + Secure + SameSite=Lax cookie carrying the raw token.
On a subsequent visit, `POST /auth/device-trust/start` resolves
the cookie and re-establishes the session without an OTC /
passcode roundtrip. The user can list and revoke their trusted
devices from the `/settings/notifications` "Trusted devices"
section; a revoked or expired cookie is cleared on the next
request. The cookie is "essential" per §14.5 — it is part of
authentication, not analytics, and is set regardless of the
user's analytics / other-cookies choice. The raw token only
ever lives in the outbound `Set-Cookie` header and the inbound
`Cookie` header; server-side storage is the hash, with
constant-time comparison on lookup.
4. **Gitea OAuth fallback (migration only).** The v0.1 OAuth
callback remains functional during the v0.7.0 window, with a
small "Sign in with Gitea (fallback)" link on `/login` so users
with active OAuth sessions or older invite paths still have a
way in. Scheduled for removal in a future release per §19.2.
`users.gitea_id` is preserved on existing rows so a grandfathered
user signing in via any of the three paths resolves to the same row.
`users.email` is the identity key for everything provisioned after
v0.7.0; `users.gitea_id` is the grandfathering linker (nullable,
partial-unique). The Gitea bot user + token are still required for
server-side git operations (repo reads, PR creation); only the
operator-facing sign-in surface moved.
Admission, as of v0.8.0, is by admin grant. v0.7.0 carried the
v0.3.0 `allowed_emails` table forward as the admission gate at the
OTC request surface — emails not on the list got a silent drop.
v0.8.0 (roadmap item #6) reverses that: any valid email receives an
OTC, the fresh `users` row lands in `permission_state='pending'`,
and an admin grant flips the column to `'granted'` before write
endpoints accept the user. The capture-fields step (first name,
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
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
(roadmap item #7) shipped the user-management surface that
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
1. **Anonymous.** Can read public RFCs (the meta repo's main branch,
every RFC repo's main branch), read any branch whose `read_public`
is true, read any PR. Cannot chat, propose, create branches, or
open PRs.
2. **Contributor.** Default role for any authenticated account.
Everything anonymous can do, plus: propose new RFCs (open a PR
against the meta repo), create branches on any RFC repo, open PRs
from branches they have contribute access to, chat on anything
they can read, claim ownership of unclaimed super-drafts.
open PRs. v0.6.0 (roadmap item #4) closed the audit: every
write-shaped endpoint surveyed in §17 enforces an explicit
`auth.require_contributor` (or stricter) gate before doing any
state-changing work; anonymous writes refuse 401. The explicit
audit covers propose, branch create, branch threads, PR-less
discussion threads + messages, PR open / merge / withdraw,
funder credentials + consent, admin allowlist add, graduation
kickoff + claim. Anonymous reads on every catalog and RFC-body
surface remain open per the v0.3.0 contract.
2. **Contributor.** Default role for any authenticated account. A
first OTC sign-in by a previously unknown email provisions a row
at this role; v0.8.0 replaced the v0.3.0 / v0.7.0 allowlist gate
with an admin-grant flow (roadmap item #6, see opening of §6).
The contributor capabilities below — propose, branch, PR, chat,
claim — are gated by `users.permission_state='granted'` as well
as by the role. A pending contributor (the post-OTC waiting
state) has the same read access as anonymous and zero write
capability until an admin grants. Everything anonymous can do,
plus: propose new RFCs (open a PR against the meta repo), create
branches on any RFC repo, open PRs from branches they have
contribute access to, chat on anything they can read, claim
ownership of unclaimed super-drafts.
3. **Admin.** Everything contributor can do, plus: act on any RFC
(merge PRs on behalf of arbiters, graduate super-drafts, set
branch visibility on anyone's behalf, downgrade or restore
@@ -377,6 +487,14 @@ subject to the standard 30/90 hygiene rules (§12). Restoring is the
reverse action. Every mute and restore is logged in
`permission_events`.
The write-mute is keyed on `users.id` and is auth-path-agnostic: a
contributor muted under the v0.1 OAuth-era flow stays muted after
the v0.7.0 email/OTC migration, since the same row is reused via the
email-match linker. Identity in this section means the `users.id`
column; the v0.7.0 identity-key shift (`gitea_id``email`) is
about which column carries the unique constraint for new
provisioning, not about which column the permission gates read.
This write-mute is structurally distinct from the two notification
mutes introduced in §15.8 — the per-RFC notification mute (the
`muted` state on the `watches` row, §15.6) and the per-user
@@ -390,6 +508,38 @@ triage what they can't act on, and the restore lands cleanly); a
self-DND'd contributor's own gestures continue to fire signals to
others normally.
v0.8.0 adds a fourth structurally-distinct field on the same row:
`users.permission_state` (the admission gate the v0.8.0 release
ships, see opening of §6 and §6.1). The four — role, muted,
permission_state, the notification mutes — are orthogonal and the
gate semantics compose:
* `role` answers "what scope of action is this user authorized to
perform if they're admitted at all?" (anonymous / contributor /
admin / owner).
* `muted` answers "is this contributor write-restricted by an
admin gesture against their existing grant?" (a sanctions
primitive — owner/admin imposed).
* `permission_state` answers "is this user admitted to the beta
at all?" (the v0.8.0 admin-grant gate — 'pending' / 'granted' /
'revoked'). The default for grandfathered rows at migration time
is `'granted'`; OTC freshly provisions `'pending'`.
* The notification mutes answer "does this user want to receive
signals about a particular RFC or from a particular other
user?" (self-imposed preference).
The four never gate each other. A pending user with `role=owner`
(impossible by construction in v0.8.0 — fresh OTC always provisions
role=contributor — but the orthogonality holds at the column level)
would still refuse write endpoints because the admission gate
runs first; a granted contributor whose row is also muted refuses
writes via the mute gate; a granted contributor with notification
mutes set still passes the contributor gate and writes normally.
v0.6.0's anon-write audit (item #4) is the structural floor for all
four — every write-shaped endpoint funnels through
`auth.require_contributor`, which checks all three of {authenticated,
not muted, permission_state='granted'} in order.
### 6.3 Per-RFC delegated authority
An RFC's `owners:` and `arbiters:` (from the meta-repo entry's
@@ -1125,12 +1275,20 @@ The modal collects four fields:
type their own.
No proposer name or email — the logged-in identity is canonical and
need not be retyped. No proposed-owner or working-group fields —
ownership flows through the post-merge claim flow (§13.1), and
need not be retyped. No proposer-owner field either: the proposer is
implicitly the first owner of their own RFC, set automatically at
submit-time from the session user (see §9.2); the post-merge claim
flow (§13.1) remains for *other* contributors to add themselves as
owners on an RFC they didn't propose. No working-group field —
arbiters are admin work, not the proposer's call. AI's drafting role
is intentionally narrow: tag suggestions only. The proposer is
making a specific claim about a specific word, and having AI propose
the claim for them would undercut the gesture.
the claim for them would undercut the gesture. (§19.3 rule-2
correction: 0.4.0 narrowed this paragraph; the prior version asserted
"no proposed-owner field" without qualification, but running code
revealed that forcing a separate claim-flow gesture for the
proposer's own RFC was UX friction with no benefit — the proposer
already self-identified by proposing.)
Primary action: **"Open proposal PR"** — naming the actual Git
artifact produced, consistent with §10.1's *Open PR* and §13's
@@ -1146,7 +1304,13 @@ is populated from the modal and session:
- `state: super-draft`, `id: null`, `repo: null`,
`graduated_at: null`, `graduated_by: null` — fixed at creation.
- `proposed_by: <session email>`, `proposed_at: <today>` — auto.
- `owners: []` — empty; the claim flow (§13.1) fills this.
- `owners: [<proposer.gitea_login>]` — the proposer is the first
owner at propose time, set automatically from the session user.
The §13.1 claim flow remains available for *additional* owners on
the same entry. (§19.3 rule-2 correction: 0.4.0 changed this from
`owners: []`; the prior shape required a separate claim-flow
gesture for the proposer's own RFC, which running code surfaced
as needless UX friction.)
- `arbiters: []` — empty; arbiters are admin work, not the
proposer's call.
@@ -1655,8 +1819,12 @@ them was the failure mode of generic-PR-comments-as-only-conversation.
Reads on the discussion surface follow §14 / the v0.3.0 anonymous-read
contract: anyone can see the conversation. Writes require contributor
role per §6.1 (v0.5.0 implements the gate; v0.6.0 — item #4 — hardens
adjacent surfaces to match). The notification routing reuses the
role per §6.1: v0.5.0 implemented the gate on the three discussion
write paths (POST threads, POST messages, POST resolve); v0.6.0 (item
#4) audited the adjacent surfaces and added the matching test net
(`test_anon_offlimits_vertical.py`) so a regression on any write
endpoint is caught immediately. The gates use `auth.require_contributor`
as the canonical helper. The notification routing reuses the
existing `chat_message_in_participated_thread` /
`chat_reply_to_my_message` event kinds with `branch_name=null` on the
fan-out row; the §15.7 reconciler and §15 inbox prose render
@@ -1757,11 +1925,20 @@ PR do not block graduation; they remain on the meta repo subject to
### 13.1 Claim ownership (prerequisite)
If a super-draft has no owner, any signed-in contributor can click
"Claim ownership," which opens a PR against the meta repo adding their
username to the `owners:` field of the entry. Owners and admins can
merge. (A self-merge window for un-acted claims is not enabled in v1;
configurable later if needed.) Multiple claims simply append.
The proposer is already the first owner of any super-draft they
proposed (per §9.2; auto-set at propose time). The claim flow exists
for *additional* contributors to become owners on an existing super-
draft — typically a draft whose proposer has stepped away, or a draft
graduating with a working group. Any signed-in contributor can click
"Claim ownership," which opens a PR against the meta repo adding
their username to the `owners:` field of the entry. Owners and admins
can merge. (A self-merge window for un-acted claims is not enabled in
v1; configurable later if needed.) Multiple claims simply append.
(§19.3 rule-2 correction: 0.4.0 narrowed this section's framing; the
prior version implied owners always started empty and the claim flow
was a hard prerequisite to graduation, but with the proposer now
auto-set as first owner, the claim flow is a graduation-time
broadening rather than a precondition for the proposer's own RFC.)
### 13.2 The Graduate dialog
@@ -1911,14 +2088,55 @@ and its public face.
The app's root URL, accessed by an unauthenticated visitor, renders a
landing page consisting of the title, the subtitle, and the short-form
deck from the top of `PHILOSOPHY.md` (see §2). Beneath the deck, a
single primary action: "Sign in with Gitea." Beneath that, a secondary
link: "Read the full philosophy" → `/philosophy`.
single primary action: "Sign in" → the email + one-time-code surface
at `/login` (per §6.2). Beneath that, a secondary link: "Read the
full philosophy" → `/philosophy`. The v0.1 landing said "Sign in
with Gitea"; v0.7.0's email/OTC surface replaced that as the primary
gesture, with a small "Sign in with Gitea (fallback)" link surviving
on `/login` itself for the migration window.
`/login` itself is a stepped surface, driven by which auth path the
viewer is currently on (§6):
1. **Email step.** The viewer enters their email. The frontend
consults `GET /auth/passcode/check?email=…` to learn whether
this email has a passcode set. The check endpoint is
account-enumeration-safe — it returns `has_passcode: false` for
both "unknown email" and "known email without passcode", so a
probing client cannot distinguish the two from the response.
2. **Either the passcode step or the OTC code step.** If the email
has a passcode set, the viewer is asked for it (v0.10.0).
Otherwise an OTC is dispatched and the viewer is asked for the
six-digit code from their email (v0.7.0).
3. **Optional post-OTC passcode-offer step.** After a successful
OTC verify on an account with no passcode set, the surface
asks "Set a passcode for faster sign-in next time?" — the user
can dismiss the offer or set one inline. The skip-for-now path
redirects straight to `/`.
The passcode step carries a "Use a code instead" link that
re-dispatches an OTC and switches to the code step — the same path
the lockout response (HTTP 423) takes automatically after five
consecutive failed passcode verifies.
This is the front door. It sets expectation before the user encounters
the mechanics, so the mechanics (super-drafts, graduation, public
arguments, AI participation in chat) read as load-bearing rather than
novel.
v0.8.0 (roadmap item #6) added a third sign-in step the surface
runs conditionally — on the first OTC sign-in by a previously
unknown email, the verify response carries `needs_profile=true`
and the surface prompts for first name, last name, and a free-text
"why I should be included in the beta" before bouncing the user to
`/beta-pending`. The page displays a "your request is in review"
message keyed on `users.permission_state='pending'` (repurposed
from the v0.3.0 post-OAuth-rejection surface). Anonymous viewers
and pending viewers see the same read surfaces; only the write
affordances differ. A persistent thin "Your beta access is in
review" banner shows on every page (other than `/beta-pending`
itself) until an admin grants access.
### 14.2 The `/philosophy` route
Authenticated and anonymous visitors alike can reach `/philosophy`,
@@ -1950,6 +2168,92 @@ The visual design of the landing page and the `/philosophy` route —
typography, layout, illustrations if any — is deferred. The structural
decisions above are the binding part.
### 14.5 Cookie / privacy consent (v0.13.0)
The framework ships a non-modal cookie consent banner reachable by
every viewer — authenticated and anonymous alike. The banner appears
at the bottom of the viewport on first load and stays visible until
the user makes a choice, after which it hides and the choice is
persisted. The `/settings/notifications` page carries a "Privacy &
cookies" tab that surfaces the current choice and re-opens the banner
on demand.
The choice has three categories, presented as a single-select:
- **Essential only** — the framework's strictly-necessary cookies
(sign-in session, signed payloads, the consent-choice record
itself). Always on; the user cannot switch this off because the
app cannot function without it.
- **Essential + analytics** — adds the optional analytics layer
gated by this choice. As of v0.13.0 no analytics SDK ships in the
framework; roadmap item #13 (v0.15.0) lands one behind this gate.
Off by default — the user has to opt in.
- **Essential + analytics + other** — adds third-party embeds or
social widgets a deployment may configure. The framework ships no
such cookies by default; this category exists so deployments that
add them have a categorized opt-in to wire them behind.
Storage shape:
- **Anonymous viewer** — choice persists in `localStorage` only
(`rfc-app.cookie-consent.v1`). The same browser carries the choice
forward; a different browser, or cleared storage, re-prompts.
- **Authenticated viewer** — choice persists in the `cookie_consent`
row keyed by `user_id`. On sign-in, the server row (if present)
overrides the local snapshot; if the server has no row, the local
choice is uploaded.
The `essential` flag is permanently true at the API surface. The
endpoint accepts it for symmetry but never persists a false value.
A deployment that wants strictly-necessary cookies to be optional
must change the framework contract, not flip a flag.
The framework exports a small JavaScript helper (`frontend/src/lib/
consent.js`) for downstream surfaces:
- `getConsent()` — current snapshot.
- `hasChosen()` — true once the user has made a choice.
- `onConsentChange(cb)` — subscribe to updates.
- `setConsent({analytics, other})` — record a new choice locally
(the banner / settings surface handles server persistence on top).
Roadmap item #13's analytics SDK (v0.15.0) will read from this helper:
read consent, then conditionally `import()` the SDK module. The gate
is wired before the SDK lands so the contract is already in place.
### 14.6 Privacy and cookies policy pages
The framework ships two policy routes:
- `/privacy` — a minimal default privacy policy that describes the
framework's stance (what is stored, why, how to revoke consent,
how to reach the deployment operator). The page is reachable by
anonymous and authenticated viewers alike.
- `/cookies` — the framework's cookies policy, listing exactly which
cookies the framework sets, by category. Self-documenting: a future
framework release that adds or removes a cookie updates this page
as part of the change.
Each page links to the other and to the §14.5 banner. The consent
banner links to both.
Deployments override the policy content via two optional build-time
env vars documented in `frontend/.env.example`:
- `VITE_PRIVACY_POLICY_URL` — an http(s) URL the `/privacy` page
links to as the "full deployment policy". The framework's stub
always renders above the link so the framework-level contract is
always visible; the link layers deployment-specific content on
top.
- `VITE_COOKIES_POLICY_URL` — same shape for `/cookies`.
Both are optional. Unset is the supported default; the stub pages are
sufficient for a default-config deployment that has nothing
deployment-specific to add. The framework chose the env-var path over
a content-repo file because it composes with the existing build-time
config layer; the content-repo-file alternative is the §19.2
candidate.
---
## 15. Notifications
@@ -2023,8 +2327,14 @@ signal taxonomy this section commits to. The starting set:
`graduation_complete`, `graduation_rolled_back`, `rfc_withdrawn`,
`rfc_reopened`, `claim_opened`, `claim_merged`,
`permission_change_affecting_me`, `app_wide_mute_set`,
`app_wide_mute_lifted`, `digest_emitted`. The enum is extensible; the
build session adjusts as new gestures are wired in.
`app_wide_mute_lifted`, `new_beta_request`, `digest_emitted`.
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
@@ -2466,6 +2776,112 @@ The follow-up session will refine this. A minimal starting set:
returned `version` matches the tag the operator just deployed,
catching the failure mode where a restart did not pick up the
new code.
- `POST /auth/otc/request` — unauthenticated. Body carries `email`.
Generates a six-digit code, stores its bcrypt hash with an expiry
(`OTC_TTL_MINUTES`, default 10), and dispatches a plain-text email
via the SMTP layer. Returns HTTP 200 (`{ok:true}`) uniformly.
Returns HTTP 429 when the per-email cooldown
(`OTC_REQUEST_COOLDOWN_SECONDS`, default 60) blocks back-to-back
requests — the loud-failure shape for the abuse path. A re-request
invalidates the prior unused code for the same email so only one
code is outstanding at a time. v0.7.0 also dropped requests
silently if the email wasn't on the `allowed_emails` list (the
v0.3.0 admission gate); v0.8.0 (item #6) removed that check —
admission moved to `permission_state` on the freshly-provisioned
`users` row, asserted at the contributor gate. v0.12.0 (item #10)
gates this endpoint behind a CloudFlare Turnstile siteverify call:
the body carries an optional `turnstile_token` field, the server
POSTs `secret` + `response` to `challenges.cloudflare.com/turnstile/
v0/siteverify` before the bcrypt hash + SMTP send, and a failed
challenge refuses with HTTP 400 spending no rate budget. Two env
vars drive the policy: `CLOUDFLARE_TURNSTILE_SECRET` (Secret
Manager) and `TURNSTILE_REQUIRED` (overlay, default `false`). When
the secret is unset and `TURNSTILE_REQUIRED=false`, the gate is
open (the dev / pre-rollout path); when the secret is unset and
`TURNSTILE_REQUIRED=true`, the endpoint refuses with HTTP 500
"auth misconfigured" so a future config drift fails loudly
instead of silently disabling abuse defense.
- `POST /auth/otc/verify` — unauthenticated. Body carries `email` and
`code`. Validates the bcrypt hash against the most-recent unconsumed
non-expired row for the email, marks the row consumed, provisions
or links the `users` row by email (per §6.2's migration path —
match by `users.email` case-insensitive, otherwise insert a fresh
contributor row with `gitea_id = NULL` and
`permission_state='pending'`), and stores the session cookie.
Returns HTTP 200 on success; the response body carries
`{ok, user, needs_profile}` where `needs_profile=true` iff the
user is `permission_state='pending'` AND the row has no
first_name / last_name / beta_request_reason yet (a fresh OTC
sign-in). The `needs_profile` flag drives the Login.jsx surface's
step-3 capture form. HTTP 400 on any failure (expired, consumed,
wrong, unknown). The failure modes collapse to a single generic
message so a probing client cannot distinguish "you got the
wrong code" from "we don't know this email" — the operator logs
carry the distinction.
- `POST /api/auth/me/beta-request` — authenticated. Body carries
`first_name`, `last_name`, `beta_request_reason` (all required;
bounded at 120 / 120 / 4000 chars). Writes the fields to the
signed-in user's row and leaves `permission_state='pending'`.
Idempotent for the same already-pending user (a re-submit
updates the row so the admin sees the latest text). Refuses
with HTTP 409 if the user is already `'granted'` or `'revoked'`.
v0.8.0 — the first-OTC profile-capture endpoint (roadmap item
#6). v0.9.0's admin user-management page consumes this column
set to render the request queue.
- `GET /auth/passcode/check` — unauthenticated. Query param `email`.
Returns `{has_passcode: boolean}`. The frontend's `/login` surface
calls this after the email step to decide whether to render a
passcode input or fall back to OTC. The response carries only the
boolean; lockout state, the bcrypt hash, and the `passcode_set_at`
stamp are not leaked. An unknown email and a known-without-passcode
email both return `false`, so the endpoint is enumeration-safe.
- `POST /auth/passcode/set` — authenticated (any role). Body carries
`passcode` (420 characters). bcrypt-hashes the passcode, writes
`users.passcode_hash` + `users.passcode_set_at`, clears the failure
counter and any active lockout. Refuses obvious patterns (a small
denylist: `0000`, `1234`, `aaaa`, `password`, etc.) and length
violations with HTTP 422. Replaces any prior passcode. v0.10.0.
- `DELETE /auth/passcode` — authenticated. Clears
`users.passcode_hash` and `users.passcode_set_at`, returning the
user to OTC-only on next sign-in. v0.10.0.
- `POST /auth/passcode/verify` — unauthenticated. Body carries
`email` and `passcode`. Locates the user, checks the lockout
window, and compares via bcrypt. On success: clears the failure
counter, refreshes `last_seen_at`, stores the session cookie,
returns HTTP 200 with the minimal user payload. On failure:
increments `passcode_failed_attempts`. After five consecutive
failures, stamps `passcode_locked_until = now + 15 minutes` and
returns HTTP 423 with a `locked_until` field; subsequent attempts
inside the window are refused with the same shape. After the
window expires, the next attempt clears the counter and proceeds
normally. The OTC path (§17 above) is unaffected by the passcode
lockout — a locked-out user can still request and verify a fresh
OTC. The wrong-passcode and unknown-email failure modes both
return HTTP 400 with a generic message; the no-passcode-set
failure also collapses to 400 so the response does not enumerate
account state. v0.10.0.
- `POST /auth/device-trust/start` — unauthenticated. Reads the
`rfc_device_trust` cookie (set previously by an OTC or passcode
verify with `trust_device: true`). On a non-expired, non-revoked
match, re-establishes the session and returns HTTP 200 with the
minimal user payload. On a miss (no cookie, expired, revoked, or
unknown), returns HTTP 401 and clears the stale cookie via the
response's `Set-Cookie` header. The failure modes collapse to
one shape so a probing client cannot enumerate "your row was
revoked" vs. "this token never existed". v0.11.0.
- `GET /api/auth/me/devices` — authenticated. Returns the active
(`revoked_at IS NULL` AND `expires_at > now`) device-trust rows
for the signed-in user: `id`, `created_at`, `expires_at`,
`last_seen_at`, `user_agent`. The bcrypt hash is structurally
private and is never surfaced. v0.11.0.
- `DELETE /api/auth/me/devices/{id}` — authenticated. Stamps
`revoked_at` on the row with id `{id}` belonging to the
signed-in user. The user-id scope is enforced in SQL so a
hostile client cannot revoke another user's row by guessing
ids; a row that does not match returns HTTP 404. v0.11.0.
- `DELETE /api/auth/me/devices` — authenticated. Revokes every
active row for the signed-in user; returns the count revoked.
v0.11.0.
- `GET /api/rfcs` — list entries with state, id, title, slug, repo,
owners, last_active_at, has_open_prs, starred-by-me. Supports
search, sort, filter chips, and the `unclaimed` predicate.
@@ -2521,7 +2937,13 @@ The follow-up session will refine this. A minimal starting set:
trailing `rollback` step's events if any earlier step fails. The
Graduate dialog opens this stream on confirm and renders the step
stack from the events. The stream closes on success or on
rollback completion.
rollback completion. Requires `auth.require_user` per v0.6.0
(item #4): the step detail (repo name, PR number, rollback steps)
is operator-visible state and isn't part of the v0.3.0
anonymous-read contract for catalog and RFC bodies. The floor is
`require_user` (not `require_contributor`) so a write-muted
operator can still observe a graduation they kicked off before
being muted.
- `GET /api/rfcs/<slug>/blocking-prs` — list open meta-repo PRs
against `rfcs/<slug>.md` per §13.2's precondition popover. Returns
PR number, title, author, last-activity timestamp, and the
@@ -2602,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>/resolution-branch` — cut a
fresh resolution branch and replay per §10.9.
- `GET /api/admin/users` — list users with role and write-mute state,
for the §6 / Slice 7 admin surface.
- `GET /api/admin/users` — list users for the §6 / Slice 7 admin
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
or revoke `owner`; admins may flip contributor ↔ admin freely. An
owner-self-demotion is refused on this endpoint; owner succession
@@ -2612,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
and admins — for them, the role-change channel is the right
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
filters `action_kind`, `actor_user_id`, `rfc_slug`, plus `before_id`
for the page boundary. Returns the joined actor login/display so
@@ -2693,6 +3132,16 @@ The follow-up session will refine this. A minimal starting set:
short confirmation page.
- `POST /api/webhooks/email-bounce` — bounce and complaint receiver
per §15.4; sets the recipient's global email opt-out.
- `GET /api/users/me/cookie-consent` — read the signed-in user's
cookie consent record per §14.5. Returns `{essential, analytics,
other, recorded_at}`. `recorded_at: null` means "no choice yet"
and the banner should be shown; the framework treats absence of a
row as equivalent to that. `essential` is permanently true.
- `PUT /api/users/me/cookie-consent` — write the signed-in user's
cookie consent record per §14.5. Body: `{essential, analytics,
other}`. The `essential` flag is accepted for symmetry but always
persisted as true. Upserts (a single row per user) and stamps
`recorded_at` to now.
Plus all the chat / streaming / model-picker endpoints, scoped to
per-RFC and per-branch threads.
@@ -3425,6 +3874,240 @@ the new §15 (Notifications, in full), and §17 (the notification
endpoints — list, mark-read, stream, watch mutation, preferences,
quiet-hours, per-user mute, unsubscribe, bounce webhook).
First-OTC profile capture (formerly a v0.7.0 candidate) is settled
and folded into §6.1 (the contributor role now requires
`permission_state='granted'`), §6.2 (the orthogonality of
permission_state vs role / muted / notification-mutes), §14.1
(the landing page's v0.8.0 first-OTC capture step), and §17
(the `POST /api/auth/me/beta-request` endpoint and the verify
endpoint's new `needs_profile` flag). The structural decision
landed as: capture is a third step on the `/login` surface
gated by `verify_response.needs_profile=true`; pending users
land on `/beta-pending` after submitting and see a thin banner
on every other page until an admin grants. v0.8.0 (roadmap item
#6) shipped the work.
Candidates surfaced during v0.8.0 (open beta-access request flow,
§6.1 / §14.1, item #6):
- **Admin user-management page** (`/admin/users`). *Shipped in
v0.9.0 (roadmap item #7).* The listing surfaces every user with
permission_state, profile fields, sign-up reason, and a Grant /
Revoke control set; the `POST /api/admin/users/<id>/permission`
endpoint flips the column and writes a `permission_events` row.
v0.9.0 left the `/admin/allowlist` sub-tab in place rather than
merging (see allowlist deprecation below). The grant/revoke
notify-the-user surface is deferred (see the new candidate
below).
- **Allowlist deprecation.** *Decision deferred past v0.9.0.*
v0.9.0 considered merging `/admin/allowlist` into the new
`/admin/users` page but kept the surface as a sibling sub-tab:
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 fast-path-bypass role
the allowlist has carried since v0.8.0 stays intact; the
cutover to retire the table outright is a later session.
Decision points unchanged from v0.8.0: drop the table outright
(a schema migration) or leave it as a non-functional surface
and remove only the UI (a frontend-only change); how to handle
existing `allowed_emails` rows at the cutover (probably: walk
them into the pending queue with `permission_state='granted'`
for any matching `users` row, leave unmatched rows as a no-op
since v0.8.0 doesn't consult them anymore). Earns its session
once the v0.9.0 admin queue has run long enough to confirm the
allowlist's bypass role is no longer pulling weight.
- **Admin notification on new beta request.** *Shipped in v0.9.0
(roadmap item #7).* The `POST /api/auth/me/beta-request`
handler now calls `notify.fan_out_new_beta_request`, which
fans a `new_beta_request` event (category `admin-actionable`,
rfc_slug NULL) out to every owner / admin. The §15 chokepoint
handles the SSE broadcast and the §15.4 email dispatch; the
email reaches only recipients whose `email_admin_actionable`
toggle is on (the default for owners + admins).
- **Grant / revoke notification to the user.** *Surfaced by
v0.9.0.* The new flip endpoint stamps `permission_decided_by` +
writes a `permission_events` row but does not yet signal the
affected user that their state changed. A future release could
fire a `personal-direct` notification (event_kind
`permission_change_affecting_me`, already in the §15.1 enum) so
a granted user sees "Your beta-access request was approved" in
their inbox and email, and a revoked user sees a parallel
refusal notice. Decision points: does revocation include a
reason field (probably yes — symmetric with §9.3's decline
comment); does grant carry a welcome message (probably no —
the existing welcome surfaces are sufficient); does the
notification escape the §15.8 mute path (probably yes — it's
a personal-direct admission state change). Earns its session
as a follow-up to the v0.9.0 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.*
v0.7.0 keeps `/auth/callback` functional and links to it as a
"Sign in with Gitea (fallback)" affordance on the new `/login`
surface, so users with active OAuth sessions or older invite
paths still have a way in during the migration window. A later
release retires the route entirely. Decision points: how do we
know "every active user has signed in via OTC at least once"
(probably: a `users.otc_first_signed_in_at` timestamp added in
v0.7.x and a query that confirms 100% population), how do we
handle users who never come back (probably: silently leave them
with stale rows; OAuth callback returning 404 is a sufficient
message), and whether the `/auth/login` and `/auth/callback`
routes get a tombstone redirect to `/login` or just 404. Earns
its session once the OTC adoption curve flattens.
- **Device trust (30-day skip).** *Settled in v0.11.0 (roadmap
item #9). The shape: a distinct `rfc_device_trust` cookie
(HttpOnly + Secure + SameSite=Lax + 30-day Max-Age) carrying a
server-issued opaque token, keyed against a `device_trust` table
whose rows store the bcrypt hash. `POST /auth/device-trust/start`
resolves a presented cookie at next visit. A
`/settings/notifications` "Trusted devices" section lists active
rows with per-row + bulk revoke. The trust outlives a sign-out
(sign-out clears the session cookie, not the device-trust
cookie) and is not affected by passcode set/change/clear — the
next two items below carry the remaining open questions.*
- **Cross-device session revocation surface.** v0.11.0's
`/settings/notifications → Trusted devices` revokes the
long-lived device-trust grants. What it does NOT revoke is an
active session cookie sitting in another browser, or the
v0.10.0 passcode-failure-counter shape, or a stale
password-equivalent that some future release ships. The natural
next step is a single "active sessions and devices" surface
that lists everything currently authenticating as this user —
device-trust rows + active session cookies (if/when the
framework moves to server-side sessions) + future credential
shapes — and lets the user kill any of them with one gesture.
Earns its session when a second cross-cutting concern lands
(the most likely first trigger: future Yubikey / WebAuthn
support, which surfaces another credential to revoke).
- **Password-equivalent change invalidates device trust.** v0.11.0
intentionally leaves device-trust rows live across a passcode
set / change / clear. The argument is structural: the user has
the v0.10.0 lockout, the v0.11.0 per-device revoke list, and a
fresh sign-in path via OTC, so the cookie is not a high-value
bypass relative to the keys-to-the-account a passcode change
signals. The argument against is the conventional "changing a
password should kill every active session" expectation users
bring from other systems. This earns its own session once the
evidence is in: either a security-review finding that says
"this is the wrong default," or user feedback that says "I
expected my old laptop to sign out when I changed my passcode."
- **Device-trust window tunables via env.** v0.11.0 hard-codes
the 30-day window in `backend/app/device_trust.py`
(`TRUST_DURATION_DAYS = 30`). Surfacing it as an env var
(`DEVICE_TRUST_DURATION_DAYS`?) is small and obvious; deferring
follows the same pattern as the v0.10.0 passcode-lockout
hard-coding — name the tunable when a deployment wants it
different rather than shipping a knob that has no operator
asking for it.
- **Cloudflare Turnstile on `/auth/otc/request`.** *Settled in
v0.12.0 (roadmap item #10).* The OTC request endpoint is now
gated behind a Turnstile siteverify call: the frontend renders
the official widget on the `/login` email-entry step (and on the
passcode step for the "Use a code instead" fallback dispatch),
the captured token rides in the request body as
`turnstile_token`, and the backend POSTs `secret` + `response`
to `challenges.cloudflare.com/turnstile/v0/siteverify` before
the bcrypt hash + SMTP send. The widget renders only on the
email-entry / passcode-fallback dispatch points — the OTC
verify step is bottlenecked on email delivery and protected by
the five-minute TTL + single-use row consume, so a second
challenge there would double the rate budget against the same
abuse path without measurably more protection (revisit if bots
adapt to the email-entry challenge specifically). Configured
via `CLOUDFLARE_TURNSTILE_SECRET` (Secret Manager) and
`VITE_TURNSTILE_SITE_KEY` (frontend build-time overlay); a
third var `TURNSTILE_REQUIRED` (default `false`) lets the
operator flip from "soft-fail when secret unset" (the dev /
pre-rollout shape) to "fail-closed when secret unset" (HTTP 500
"auth misconfigured", the production-locked shape). Tests mock
the siteverify HTTP call at the `httpx.post` boundary in
`app.turnstile`. The hCaptcha / reCAPTCHA alternatives noted in
the v0.7.0 surfacing are still viable substitutes for a future
deployment that wants them but the framework's tested path is
Turnstile.
Candidates surfaced during v0.10.0 (user-set passcodes, §6.2 /
roadmap item #8):
- **Passcode policy tunables via env.** v0.10.0 hard-codes the
lockout shape (5 consecutive failures → 15-minute lockout) and
the min/max passcode length (4 / 20) in
`backend/app/passcode.py`. The denylist of obvious patterns is
also hard-coded. A deployment that wants tighter or looser rules
has to fork the constants. Two env vars
(`PASSCODE_LOCKOUT_AFTER_ATTEMPTS`,
`PASSCODE_LOCKOUT_DURATION_MINUTES`) would let operators
reshape the lockout without forking; a third
(`PASSCODE_MIN_LENGTH`) would cover the length floor. Earns its
session if a deployment surfaces evidence that the v1 defaults
bite.
- **Per-IP rate-limiting on `/auth/passcode/verify`.** v0.10.0's
lockout is per-account: 5 failures against the same email lock
that account for 15 minutes. A distributed attacker that knows
many emails can fan out across them without ever tripping any
one account's lockout. Adding a per-IP throttle (e.g., 30
passcode-verify attempts / minute / IP, returning HTTP 429) is
the natural pairing. Defer-able — the per-account lockout is
the v1 shape that closes the loud-loop case; the per-IP
distributed case waits on evidence. Touches §6.2 and §17.
- **Passcode-change "still know your old passcode" challenge.**
v0.10.0 lets a signed-in user replace their passcode from
`/settings/notifications` without re-entering the old one — the
session is sufficient. A future hardening pass may require the
old passcode (or a fresh OTC verify) before accepting the
change, to mitigate session-hijack scenarios where the attacker
rotates the passcode to lock the legitimate owner out. The same
question applies to the clear gesture. Earns its session if
session-hijack becomes a real threat surface.
- **Passkey / WebAuthn.** A much heavier next step than passcodes:
hardware-backed device credentials that resist phishing. The
v0.10.0 passcode shape is a stopgap for the "I'd rather not
type a code every time" ergonomic problem; passkeys are the
long-term answer. Out of scope for the current roadmap —
earns a dedicated session if/when the deployment grows enough
that the phishing surface justifies the integration cost.
Candidates surfaced during v0.13.0 (cookie / privacy consent, §14.5
and §14.6):
- **Policy content via content-repo file vs env var.** v0.13.0
shipped the deployment-policy-override path as two env vars
(`VITE_PRIVACY_POLICY_URL`, `VITE_COOKIES_POLICY_URL`) that the
framework's stub pages link out to. The alternative — accepting
a markdown file path the framework renders inline, parallel to
`PHILOSOPHY_PATH` per §14.2 — was deferred. The two compose:
a deployment could carry both an inline file (rendered above
the fold) and an external link (rendered below). Earns its own
topic when a real deployment ships a policy long enough that
the link-out shape bites and renders the link unread.
- **Global Privacy Control / Do-Not-Track headers.** v0.13.0
scoped the consent surface to the in-app banner and did not
honor browser-side GPC or DNT signals. The framework's stance
is that the in-app banner is the authoritative gesture — a
user who clears their consent in the banner has expressed
intent, and the GPC header is a coarser signal layered on top.
Earns its own topic if a regulatory regime emerges that treats
GPC as the legally-binding gesture, in which case the framework
would honor GPC as an automatic "essential only" choice unless
the user explicitly broadened it in-app.
- **Multi-language consent text.** The banner ships English-only.
i18n of the framework's user-facing strings is a broader topic
than the consent banner; carrying the work in that future topic
rather than as a per-surface translation pass.
- **Analytics SDK gating against `consent.js`.** Roadmap item #13
(target v0.15.0) lands the analytics SDK behind
`lib/consent.js`'s `getConsent().analytics` gate. The framework
contract is already in place; the SDK integration is the work
the item ships. Listed here so the dependency is documented.
### 19.3 Working agreement for the queue
Pre-build sessions ran on the queue agreement from prior versions
+1 -1
View File
@@ -1 +1 @@
0.5.0
0.15.0
+29
View File
@@ -81,3 +81,32 @@ WEBHOOK_EMAIL_BOUNCE_SECRET=
# Production default is hourly; tests override to seconds via the same
# env var.
HYGIENE_TICK_SECONDS=3600
# --- v0.7.0: email + one-time-code sign-in (§6.2) ---
# How long a one-time code stays valid after issuance. Re-requesting
# invalidates the prior code immediately regardless of TTL.
OTC_TTL_MINUTES=10
# Per-email cooldown between successive /auth/otc/request calls. The
# endpoint returns HTTP 429 when the cooldown blocks a request (the
# loud-failure shape so the abuse path is visible). Set to 0 to
# disable the cooldown — useful for tests but never in production.
OTC_REQUEST_COOLDOWN_SECONDS=60
# --- v0.12.0: CloudFlare Turnstile gate on OTC dispatch (§6.2, item #10) ---
# Provision a Turnstile site at dash.cloudflare.com → Turnstile → Add
# site. The site key (public) goes in `frontend/.env` as
# VITE_TURNSTILE_SITE_KEY. The secret key (private) goes here and is
# what the backend POSTs to /siteverify alongside the user's response
# token. Leave both unset for dev/test paths; the gate stays open when
# the secret is absent AND TURNSTILE_REQUIRED=false (the default).
CLOUDFLARE_TURNSTILE_SECRET=
# When `true`, /auth/otc/request fails closed (HTTP 500 "auth
# misconfigured") if CLOUDFLARE_TURNSTILE_SECRET is unset. When `false`
# (the default), a missing secret skips verification — useful in dev
# and during the pre-rollout window when the operator hasn't wired
# the secret yet. Flip to `true` once the secret is wired so a future
# config drift surfaces as a loud 500 rather than a silent abuse-
# defense disablement.
TURNSTILE_REQUIRED=false
+188 -1
View File
@@ -26,10 +26,13 @@ from . import (
api_prs,
auth,
db,
device_trust as device_trust_mod,
docs as docs_mod,
entry as entry_mod,
cache,
funder,
health,
notify,
philosophy,
providers as providers_mod,
)
@@ -55,6 +58,17 @@ class FunderCredentialBody(BaseModel):
api_key: str = Field(min_length=1, max_length=2048)
class BetaRequestBody(BaseModel):
# v0.8.0 — captured on the first OTC sign-in. All three fields are
# required so the admin queue has a coherent triage shape.
# The bounds match the v0.7.0 OTC body (320 chars for email-ish
# headers; 4000 for the free-text reason — the same upper bound
# DeclineBody uses elsewhere in this file).
first_name: str = Field(min_length=1, max_length=120)
last_name: str = Field(min_length=1, max_length=120)
beta_request_reason: str = Field(min_length=1, max_length=4000)
def make_router(
config: Config,
gitea: Gitea,
@@ -111,6 +125,17 @@ def make_router(
payload = philosophy.load()
return {"body": payload["body"]}
# ---------------------------------------------------------------
# /api/docs — DOCS.md served verbatim. Sibling of /api/philosophy:
# no auth gate, same disk-first load + cache shape, same intent —
# public read surface for a markdown file checked into the repo.
# ---------------------------------------------------------------
@router.get("/api/docs")
async def get_docs() -> dict[str, Any]:
payload = docs_mod.load()
return {"body": payload["body"]}
# ---------------------------------------------------------------
# Auth surface — reads role from our users table per §6.
# ---------------------------------------------------------------
@@ -120,6 +145,29 @@ def make_router(
user = auth.current_user(request)
if user is None:
return {"authenticated": False, "user": None}
# v0.8.0 + v0.10.0: single round-trip for everything the
# frontend gates UI off of — beta-access state + passcode state.
row = db.conn().execute(
"SELECT first_name, last_name, beta_request_reason, "
"passcode_hash, passcode_set_at "
"FROM users WHERE id = ?",
(user.user_id,),
).fetchone()
first_name = (row["first_name"] if row else None) or ""
last_name = (row["last_name"] if row else None) or ""
beta_request_reason = (row["beta_request_reason"] if row else None) or ""
# "Needs profile" iff the user is pending AND hasn't yet
# filed their beta-request capture. Granted users never see
# the capture prompt; pending users who already filed see
# the /beta-pending page without the capture form.
needs_profile = (
user.permission_state == "pending"
and not first_name
and not last_name
and not beta_request_reason
)
has_passcode = bool(row and row["passcode_hash"])
passcode_set_at = row["passcode_set_at"] if (row and has_passcode) else None
return {
"authenticated": True,
"user": {
@@ -129,9 +177,144 @@ def make_router(
"email": user.email,
"avatar_url": user.avatar_url,
"role": user.role,
"permission_state": user.permission_state,
"first_name": first_name,
"last_name": last_name,
"beta_request_reason": beta_request_reason,
"needs_profile": needs_profile,
"has_passcode": has_passcode,
"passcode_set_at": passcode_set_at,
},
}
# ---------------------------------------------------------------
# v0.8.0: /api/auth/me/beta-request — first-OTC profile capture
# (roadmap item #6). Lands first name, last name, and the free-
# text "why I should be included in the beta" on the signed-in
# user's row. Idempotent for the same already-pending user;
# refuses to overwrite a row that's already granted (so a
# bored already-granted user can't accidentally re-submit the
# form and clobber the admin's audit trail). Uses
# `require_user` rather than `require_contributor` because
# `require_contributor` already enforces `permission_state =
# 'granted'` and would refuse a pending user; the whole point
# of this endpoint is to register the request _from_ a pending
# user.
# ---------------------------------------------------------------
@router.post("/api/auth/me/beta-request")
async def submit_beta_request(body: BetaRequestBody, request: Request) -> dict[str, Any]:
user = auth.require_user(request)
row = db.conn().execute(
"SELECT permission_state, first_name, last_name, beta_request_reason FROM users WHERE id = ?",
(user.user_id,),
).fetchone()
if row is None:
# Defensive — the session pointed at a deleted row.
raise HTTPException(404, "User not found")
# Granted users have no business filing a beta request.
# 'revoked' likewise — the request flow is for fresh users
# only. Both shapes refuse with 409 (conflict) so the client
# can distinguish "you already have access" from
# "your access was revoked".
if row["permission_state"] == "granted":
raise HTTPException(409, "Your account is already granted access")
if row["permission_state"] == "revoked":
raise HTTPException(409, "Your account's access has been revoked")
# Re-submission from a pending user updates the row — the
# admin sees the latest text rather than a stale draft.
# The state stays 'pending'; only an admin can flip it.
db.conn().execute(
"""
UPDATE users
SET first_name = ?,
last_name = ?,
beta_request_reason = ?
WHERE id = ?
""",
(
body.first_name.strip(),
body.last_name.strip(),
body.beta_request_reason.strip(),
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}
# ---------------------------------------------------------------
# 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
# ---------------------------------------------------------------
@@ -305,7 +488,11 @@ def make_router(
proposed_at=entry_mod.today(),
graduated_at=None,
graduated_by=None,
owners=[],
# §9.2: the proposer is the implicit first owner at propose time.
# The §13.1 claim flow exists for *other* contributors to become
# owners on an RFC they didn't propose; the proposer never needs
# to claim their own RFC.
owners=[user.gitea_login],
arbiters=[],
tags=[t.strip() for t in payload.tags if t.strip()],
body=payload.pitch.strip() + "\n",
+117 -4
View File
@@ -50,6 +50,16 @@ class MuteBody(BaseModel):
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):
email: str = Field(min_length=3, max_length=320)
note: str | None = Field(default=None, max_length=200)
@@ -68,13 +78,41 @@ def make_router(config: Config) -> APIRouter:
@router.get("/api/admin/users")
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.
"""
auth.require_admin(request)
rows = db.conn().execute(
"""
SELECT id, gitea_login, display_name, email, role, muted,
created_at, last_seen_at
FROM users
ORDER BY role = 'owner' DESC, role = 'admin' DESC, display_name COLLATE NOCASE
SELECT u.id, u.gitea_login, u.display_name, u.email, u.role, u.muted,
u.created_at, u.last_seen_at,
u.permission_state, u.first_name, u.last_name,
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()
return {
@@ -88,6 +126,13 @@ def make_router(config: Config) -> APIRouter:
"muted": bool(r["muted"]),
"created_at": r["created_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"],
}
for r in rows
]
@@ -136,6 +181,74 @@ def make_router(config: Config) -> APIRouter:
)
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) -----
@router.post("/api/admin/users/{user_id}/mute")
+10 -1
View File
@@ -520,7 +520,16 @@ def make_router(
@router.get("/api/rfcs/{slug}/graduate/progress")
async def graduate_progress(slug: str, request: Request):
del request
# v0.6.0 (item #4): the progress SSE surfaces admin-internal step
# detail (repo name, PR number, rollback steps) that isn't part of
# the v0.3.0 anonymous-read contract for catalog/RFC bodies. The
# corresponding POST /graduate is gated to RFC owners/arbiters and
# app admins/owners via `_can_graduate`; the read SSE shares that
# operator-visible surface, so it requires at least an
# authenticated viewer. We keep the floor at require_user (not
# require_contributor) so a write-muted operator can still observe
# the progress of a graduation they kicked off before being muted.
auth.require_user(request)
state = _get_active(slug)
if state is None:
raise HTTPException(404, "No graduation in flight for this slug")
+79
View File
@@ -14,6 +14,8 @@ The endpoints in this module are:
- `POST /api/users/me/quiet-hours` set / clear
- `POST /api/users/<id>/notification-mute` §15.8
- `DELETE /api/users/<id>/notification-mute` §15.8
- `GET /api/users/me/cookie-consent` §14.5
- `PUT /api/users/me/cookie-consent` §14.5
- `GET /api/email/unsubscribe` §15.4 one-click
- `POST /api/webhooks/email-bounce` §15.4 receiver
@@ -73,6 +75,15 @@ class BounceBody(BaseModel):
kind: str = Field(default="hard") # 'hard' or 'complaint'
class CookieConsentBody(BaseModel):
# `essential` is always true at the surface; we accept it for symmetry
# but never persist a false value (the framework's strictly-necessary
# cookies are not user-optional per SPEC §14.5).
essential: bool = True
analytics: bool = False
other: bool = False
# ---------------------------------------------------------------------------
# Router
# ---------------------------------------------------------------------------
@@ -362,6 +373,74 @@ def make_router(config: Config) -> APIRouter:
)
return {"ok": True}
# ----- Cookie consent (v0.13.0 / roadmap item #11; SPEC §14.5) -----
#
# The shape is intentionally small: three flags + a recorded-at stamp.
# The banner's local-vs-server precedence rule lives in the frontend
# (`consent.js`): on sign-in, the server row (if any) overrides local;
# otherwise local is uploaded.
@router.get("/api/users/me/cookie-consent")
async def get_cookie_consent(request: Request) -> dict[str, Any]:
viewer = auth.require_user(request)
row = db.conn().execute(
"""
SELECT essential, analytics, other_cookies, recorded_at
FROM cookie_consent WHERE user_id = ?
""",
(viewer.user_id,),
).fetchone()
if row is None:
return {
"essential": True,
"analytics": False,
"other": False,
"recorded_at": None,
}
return {
"essential": bool(row["essential"]),
"analytics": bool(row["analytics"]),
"other": bool(row["other_cookies"]),
"recorded_at": row["recorded_at"],
}
@router.put("/api/users/me/cookie-consent")
async def set_cookie_consent(body: CookieConsentBody, request: Request) -> dict[str, Any]:
viewer = auth.require_user(request)
# `essential` is the framework's strictly-necessary set; the
# surface accepts the flag for symmetry but never persists a
# false value. SPEC §14.5: a deployment that wants to make
# session-cookie storage optional must change the framework
# contract, not flip a flag here.
db.conn().execute(
"""
INSERT INTO cookie_consent
(user_id, essential, analytics, other_cookies, recorded_at)
VALUES (?, 1, ?, ?, datetime('now'))
ON CONFLICT(user_id) DO UPDATE SET
essential = 1,
analytics = excluded.analytics,
other_cookies = excluded.other_cookies,
recorded_at = excluded.recorded_at
""",
(
viewer.user_id,
1 if body.analytics else 0,
1 if body.other else 0,
),
)
row = db.conn().execute(
"SELECT recorded_at FROM cookie_consent WHERE user_id = ?",
(viewer.user_id,),
).fetchone()
return {
"ok": True,
"essential": True,
"analytics": bool(body.analytics),
"other": bool(body.other),
"recorded_at": row["recorded_at"] if row else None,
}
# ----- Email: one-click unsubscribe + bounce webhook -----
@router.get("/api/email/unsubscribe")
+68 -6
View File
@@ -30,6 +30,12 @@ class SessionUser:
email: str
avatar_url: str
role: str
# v0.8.0 / §6.1 — admission gate. Three states: 'pending' (waiting
# for an admin grant), 'granted' (active contributor), 'revoked'
# (was granted, later removed). Existing rows at migration time
# default to 'granted' so grandfathered users are unaffected; OTC
# provisions fresh users with 'pending' (see `app/otc.py`).
permission_state: str = "granted"
def as_actor(self) -> Actor:
return Actor(
@@ -90,6 +96,13 @@ def allowlist_is_active() -> bool:
def is_allowed_sign_in(profile: dict[str, Any]) -> bool:
"""Decide whether a freshly-completed OAuth profile may sign in.
v0.8.0 (item #6) replaces the allowlist gate with an admin-grant
flow at the OTC `/request` surface, but the Gitea OAuth callback
in `main.py` still consults this helper so the fallback path
keeps the v0.3.0 admission shape during the OAuth migration
window. The eventual removal of the OAuth callback (§19.2)
retires this function alongside it.
Three accept paths:
1. The allowlist is empty (gate off).
2. The Gitea profile's email is in `allowed_emails` (case-insensitive).
@@ -132,17 +145,27 @@ def provision_user(config: Config, profile: dict[str, Any]) -> SessionUser:
existing = c.execute("SELECT * FROM users WHERE gitea_id = ?", (gitea_id,)).fetchone()
if existing is None:
role = "owner" if config.owner_gitea_login and login == config.owner_gitea_login else "contributor"
# v0.8.0: a fresh OAuth-provisioned user is also subject to
# the admin-grant flow. The OAuth fallback only fires for
# users who pass `is_allowed_sign_in` (so they're already on
# the legacy allowlist or are grandfathered by gitea_id);
# 'granted' is the right default here since the allowlist
# check is itself the admin gesture. A future release that
# retires the OAuth callback (§19.2) collapses both paths
# under the same gate.
cur = c.execute(
"""
INSERT INTO users (gitea_id, gitea_login, email, display_name, avatar_url, role)
VALUES (?, ?, ?, ?, ?, ?)
INSERT INTO users (gitea_id, gitea_login, email, display_name, avatar_url, role, permission_state)
VALUES (?, ?, ?, ?, ?, ?, 'granted')
""",
(gitea_id, login, email, display, avatar, role),
)
user_id = cur.lastrowid
permission_state = "granted"
else:
user_id = existing["id"]
role = existing["role"]
permission_state = existing["permission_state"] or "granted"
c.execute(
"""
UPDATE users
@@ -160,6 +183,7 @@ def provision_user(config: Config, profile: dict[str, Any]) -> SessionUser:
email=email,
avatar_url=avatar,
role=role,
permission_state=permission_state,
)
@@ -178,6 +202,12 @@ def store_session(request: Request, user: SessionUser) -> None:
"email": user.email,
"avatar_url": user.avatar_url,
"role": user.role,
# v0.8.0: persist the admission state on the cookie payload so
# the post-cookie audit doesn't second-guess the row. The DB
# is re-read on every `current_user` call regardless (so an
# admin grant takes effect on the next request); this field
# is purely structural redundancy for the cookie shape.
"permission_state": user.permission_state,
}
@@ -188,19 +218,31 @@ def current_user(request: Request) -> SessionUser | None:
# Re-read the role from the database every request so role changes
# take effect on the next API call without forcing a logout.
row = db.conn().execute(
"SELECT id, gitea_id, gitea_login, email, display_name, avatar_url, role FROM users WHERE id = ?",
"SELECT id, gitea_id, gitea_login, email, display_name, avatar_url, role, permission_state FROM users WHERE id = ?",
(raw["user_id"],),
).fetchone()
if row is None:
return None
# v0.7.0: OTC-provisioned users have NULL gitea_id / gitea_login.
# Coerce nulls to the SessionUser's typed defaults so downstream
# code (Actor, _on_behalf_trailer) reads a stable shape regardless
# of which sign-in path the row came from. The DB remains the
# source of truth for "is this an OAuth-linked user" (gitea_id IS
# NOT NULL); the in-memory SessionUser is the per-request handle.
# v0.8.0: permission_state comes off the row directly. A NULL
# column value (shouldn't happen under the migration's
# NOT NULL DEFAULT, but be defensive) reads as 'granted' so the
# gate fails open for grandfathered surfaces rather than locking
# everyone out on a malformed row.
return SessionUser(
user_id=row["id"],
gitea_id=row["gitea_id"],
gitea_login=row["gitea_login"],
gitea_id=row["gitea_id"] or 0,
gitea_login=row["gitea_login"] or "",
display_name=row["display_name"],
email=row["email"] or "",
avatar_url=row["avatar_url"] or "",
role=row["role"],
permission_state=row["permission_state"] or "granted",
)
@@ -212,11 +254,31 @@ def require_user(request: Request) -> SessionUser:
def require_contributor(request: Request) -> SessionUser:
"""§6.1: authenticated, not write-muted."""
"""§6.1: authenticated, not write-muted, and granted by an admin.
v0.8.0 (item #6) widens this gate. A fresh OTC sign-in lands in
`permission_state='pending'`; the user can read everything an
anonymous viewer can read, but every write-shaped endpoint that
funnels through this dependency now refuses with 403 until an
admin grants them. The `pending` blast radius is the same as
anonymous (item #4 / v0.6.0 already audited the anon-write
refusal at every write site), so this widening is structurally
a relabel the same surfaces that already refused 401 to
anonymous now also refuse 403 to pending.
"""
user = require_user(request)
row = db.conn().execute("SELECT muted FROM users WHERE id = ?", (user.user_id,)).fetchone()
if row and row["muted"]:
raise HTTPException(status_code=403, detail="Your account is muted")
if user.permission_state != "granted":
# 'pending' is the post-OTC waiting state; 'revoked' is the
# admin-undid-the-grant state. Both refuse with the same 403
# shape; the client distinguishes via `/api/auth/me` which
# carries `permission_state` in the response.
raise HTTPException(
status_code=403,
detail="Your beta access request is in review",
)
return user
+351
View File
@@ -0,0 +1,351 @@
"""§6.2 / v0.11.0: trust device for 30 days (roadmap item #9).
After a successful OTC or passcode sign-in, a contributor may check
"trust this device for 30 days." The framework then issues a
server-issued opaque token, hashes it (bcrypt) for storage in the
`device_trust` table, and sets a long-lived cookie carrying the raw
token. On a subsequent visit, the cookie is presented at
`/auth/device-trust/start`; if a non-expired, non-revoked row matches,
the session is re-established without another OTC / passcode round
trip.
The shape:
* `issue(user_id, user_agent)` mint a fresh CSPRNG token, hash it,
insert a row, and return the raw token + row id so the endpoint
can set the cookie. The 30-day expiry is the only knob; the
`revoked_at` column stays NULL.
* `lookup(raw_token)` walk the user's active rows (the unique
index keys on the hash, so we read a small candidate set), check
the bcrypt hash in constant time, drop any row whose `expires_at`
has passed or whose `revoked_at` is non-NULL, and return the
matched row or None. On a hit, refresh `last_seen_at`.
* `list_for_user(user_id)` return the active rows for the
/settings/devices surface. Revoked + expired rows are filtered out
so the surface only shows live trust grants.
* `revoke(user_id, row_id)` stamp `revoked_at` on the row. The
next lookup refuses the cookie token (the row is dead).
* `revoke_all(user_id)` bulk-revoke every active row for the user.
The /settings/devices surface's "revoke all" button calls this.
Cookie shape: `rfc_device_trust`. HttpOnly, Secure, SameSite=Lax,
Max-Age=2592000 (30 days), Path=/. The cookie value is the raw token;
server-side storage is the hash. The cookie is "essential" per the
v0.13.0 cookie-consent banner (it is part of authentication, not
analytics), so the framework sets it regardless of analytics /
other-cookies choices.
Constant-time comparison: bcrypt's `checkpw` is already constant-time
over the hash bytes. We walk the candidate set linearly with `_check`
which delegates to `bcrypt.checkpw`; no early-exit shortcut leaks
which row was the match.
The raw token never appears in a log line or an exception message;
the helpers carry the token only as a parameter and forget it after
hashing.
The cookie sits orthogonal to the §6.1 `permission_state` gate: a
revoked or pending user with a valid device-trust cookie still
re-establishes their session (the cookie identifies the user, not
their admission state), and the existing `require_contributor` /
`require_admin` dependencies in `auth.py` continue to refuse the
unrelated write surfaces.
"""
from __future__ import annotations
import logging
import secrets
from dataclasses import dataclass
import bcrypt
from . import db
from .auth import SessionUser
log = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Tunables — hard-coded in v0.11.0 (§19.2 candidate to env-ify later).
# ---------------------------------------------------------------------------
TRUST_DURATION_DAYS = 30
COOKIE_NAME = "rfc_device_trust"
COOKIE_MAX_AGE_SECONDS = TRUST_DURATION_DAYS * 24 * 60 * 60
# 256 bits of CSPRNG entropy. `secrets.token_urlsafe(32)` yields ~43
# URL-safe characters; the bcrypt hash is what's stored, so the raw
# token only ever lives in the cookie.
TOKEN_BYTES = 32
# User-Agent header values seen in the wild can be unbounded; clamp
# to a reasonable ceiling so a hostile UA doesn't bloat the row.
USER_AGENT_MAX_LENGTH = 1024
# ---------------------------------------------------------------------------
# Issue
# ---------------------------------------------------------------------------
@dataclass
class IssueOutcome:
"""The shape returned from `issue`.
`raw_token` is the cookie value to send to the client; it never
appears in storage. `row_id` is the surrogate key for the
/settings/devices UI to address the row by id.
"""
raw_token: str
row_id: int
def _new_token() -> str:
return secrets.token_urlsafe(TOKEN_BYTES)
def _hash(token: str) -> str:
return bcrypt.hashpw(token.encode("utf-8"), bcrypt.gensalt()).decode("ascii")
def _check(token: str, token_hash: str) -> bool:
try:
return bcrypt.checkpw(token.encode("utf-8"), token_hash.encode("ascii"))
except (ValueError, TypeError):
return False
def _trim_user_agent(ua: str) -> str:
ua = (ua or "").strip()
if len(ua) > USER_AGENT_MAX_LENGTH:
return ua[:USER_AGENT_MAX_LENGTH]
return ua
def issue(user_id: int, user_agent: str) -> IssueOutcome:
"""Mint a fresh device-trust token + row for `user_id`.
The row's expiry is set 30 days in the future. The hash, not the
raw token, lands in the database. The caller (the endpoint) sets
the cookie with the raw token returned here.
"""
raw = _new_token()
h = _hash(raw)
ua = _trim_user_agent(user_agent)
cur = db.conn().execute(
f"""
INSERT INTO device_trust (user_id, device_token_hash, expires_at, user_agent)
VALUES (?, ?, datetime('now', '+{TRUST_DURATION_DAYS} days'), ?)
""",
(user_id, h, ua),
)
row_id = cur.lastrowid
return IssueOutcome(raw_token=raw, row_id=row_id)
# ---------------------------------------------------------------------------
# Lookup
# ---------------------------------------------------------------------------
@dataclass
class LookupOutcome:
"""The result of `lookup`.
`user` is populated only on a hit. `reason` distinguishes the
failure modes so the endpoint can decide whether to clear the
cookie ('expired', 'revoked', 'unknown') or just refuse ('invalid').
"""
ok: bool
user: SessionUser | None
reason: str # 'ok' | 'invalid' | 'unknown' | 'expired' | 'revoked'
row_id: int | None = None
def lookup(raw_token: str) -> LookupOutcome:
"""Resolve a presented cookie token to a user.
A hit refreshes `last_seen_at` on the matched row. A miss returns
a reason so the endpoint can clear the stale cookie if the row
was revoked or expired (vs. simply unknown, which probably means
the cookie was forged or the row was wiped by a /settings/devices
revoke from another browser).
"""
raw = (raw_token or "").strip()
if not raw:
return LookupOutcome(ok=False, user=None, reason="invalid")
# The unique index on `device_token_hash` would let us SELECT by
# hash if bcrypt were a stable hash, but bcrypt incorporates a
# per-row salt — equal tokens produce different hashes. We walk
# the candidate set instead. In practice the set is small (a
# human has a handful of trusted devices) and bcrypt is cheap on
# the order of milliseconds; the walk is bounded by the user's
# active device count.
#
# We don't pre-filter by `revoked_at IS NULL` here so that a
# token presented for a recently-revoked row produces a
# 'revoked' outcome (the endpoint surfaces a different shape).
# Same for expired: we let the walk hit and classify after.
rows = db.conn().execute(
"""
SELECT id, user_id, device_token_hash, expires_at, revoked_at
FROM device_trust
ORDER BY id DESC
""",
).fetchall()
matched = None
for row in rows:
if _check(raw, row["device_token_hash"]):
matched = row
break
if matched is None:
return LookupOutcome(ok=False, user=None, reason="unknown")
if matched["revoked_at"] is not None:
return LookupOutcome(ok=False, user=None, reason="revoked", row_id=matched["id"])
expired = db.conn().execute(
"SELECT datetime(?) < datetime('now') AS expired",
(matched["expires_at"],),
).fetchone()["expired"]
if expired:
return LookupOutcome(ok=False, user=None, reason="expired", row_id=matched["id"])
# Refresh last-seen so the /settings/devices surface can show the
# user when each device was last active. This is the only write
# the lookup path does on the hot read.
db.conn().execute(
"UPDATE device_trust SET last_seen_at = datetime('now') WHERE id = ?",
(matched["id"],),
)
user_row = db.conn().execute(
"""
SELECT id, gitea_id, gitea_login, email, display_name, avatar_url, role, permission_state
FROM users
WHERE id = ?
""",
(matched["user_id"],),
).fetchone()
if user_row is None:
# The user row was deleted but the device_trust row hadn't
# cascaded yet (shouldn't happen under the FK ON DELETE
# CASCADE — be defensive anyway). Treat as 'unknown' so the
# endpoint clears the cookie.
return LookupOutcome(ok=False, user=None, reason="unknown", row_id=matched["id"])
# Also stamp last_seen_at on the user row so the user's overall
# activity stamp keeps pace with cookie-only sign-ins.
db.conn().execute(
"UPDATE users SET last_seen_at = datetime('now') WHERE id = ?",
(matched["user_id"],),
)
return LookupOutcome(
ok=True,
user=SessionUser(
user_id=user_row["id"],
gitea_id=user_row["gitea_id"] or 0,
gitea_login=user_row["gitea_login"] or "",
display_name=user_row["display_name"],
email=user_row["email"] or "",
avatar_url=user_row["avatar_url"] or "",
role=user_row["role"],
permission_state=user_row["permission_state"] or "granted",
),
reason="ok",
row_id=matched["id"],
)
# ---------------------------------------------------------------------------
# List / revoke (for the /settings/devices surface)
# ---------------------------------------------------------------------------
@dataclass
class DeviceRow:
"""The shape the /settings/devices endpoint returns.
Note the absence of `device_token_hash` the hash is structurally
private, and the surface has no use for it.
"""
id: int
created_at: str
expires_at: str
last_seen_at: str
user_agent: str
def list_for_user(user_id: int) -> list[DeviceRow]:
"""Active device-trust rows for the user, freshest first.
Filters out revoked rows and rows whose expiry has passed; the
surface only shows live trust grants. A user wondering "which
devices are signed in" gets the answer that matches what the
framework would actually accept on a presented cookie.
"""
rows = db.conn().execute(
"""
SELECT id, created_at, expires_at, last_seen_at, user_agent
FROM device_trust
WHERE user_id = ?
AND revoked_at IS NULL
AND datetime(expires_at) > datetime('now')
ORDER BY last_seen_at DESC, id DESC
""",
(user_id,),
).fetchall()
return [
DeviceRow(
id=row["id"],
created_at=row["created_at"],
expires_at=row["expires_at"],
last_seen_at=row["last_seen_at"],
user_agent=row["user_agent"] or "",
)
for row in rows
]
def revoke(user_id: int, row_id: int) -> bool:
"""Revoke a single device-trust row for the given user.
Returns True iff a row was matched (still active, belongs to the
user). The user-id scope is enforced in SQL so a hostile client
cannot revoke another user's row by guessing ids.
"""
cur = db.conn().execute(
"""
UPDATE device_trust
SET revoked_at = datetime('now')
WHERE id = ?
AND user_id = ?
AND revoked_at IS NULL
""",
(row_id, user_id),
)
return cur.rowcount > 0
def revoke_all(user_id: int) -> int:
"""Revoke every active device-trust row for the user. Returns the
count of rows touched.
The /settings/devices "revoke all" button calls this. The user's
current request stays authenticated via its session cookie; the
device-trust cookie on the current device is also revoked, but
the session middleware's `rfc_session` cookie keeps the request
flow alive until the user signs out or the session cookie
expires.
"""
cur = db.conn().execute(
"""
UPDATE device_trust
SET revoked_at = datetime('now')
WHERE user_id = ?
AND revoked_at IS NULL
""",
(user_id,),
)
return cur.rowcount
+61
View File
@@ -0,0 +1,61 @@
"""User-facing docs source.
Mirrors `philosophy.py` shape. Serves `DOCS.md` from the repo root
the framework's plain-prose user guide to roles, contribution flow,
and notification surfaces, distinct from the binding `SPEC.md`. Read
from disk on first call and cached in-process; the periodic
reconciler can call `refresh()` to pick up out-of-band edits.
`DOCS_PATH` overrides the default location if a deployment hosts the
file elsewhere (a meta-repo working-tree clone, a sync target, etc.).
"""
from __future__ import annotations
import logging
import os
import threading
from pathlib import Path
log = logging.getLogger(__name__)
_DEFAULT_PATH = Path(__file__).resolve().parents[2] / "DOCS.md"
_lock = threading.Lock()
_cache: dict | None = None
def _resolved_path() -> Path:
override = os.environ.get("DOCS_PATH", "").strip()
if override:
return Path(override).expanduser().resolve()
return _DEFAULT_PATH
def load(force: bool = False) -> dict:
"""Return the cached `{body, path, mtime}` payload, reading from disk
on first call or when `force=True`.
"""
global _cache
with _lock:
if _cache is not None and not force:
return _cache
path = _resolved_path()
try:
text = path.read_text(encoding="utf-8")
mtime = path.stat().st_mtime
except FileNotFoundError:
log.warning("DOCS.md not found at %s — serving placeholder", path)
text = (
"# DOCS.md not found\n\n"
"The deployment is missing its user guide. Set "
"DOCS_PATH or place DOCS.md at the project root."
)
mtime = 0.0
_cache = {"body": text, "path": str(path), "mtime": mtime}
return _cache
def refresh() -> dict:
"""Force-reread from disk. Returns the new payload."""
return load(force=True)
+11
View File
@@ -139,6 +139,10 @@ _EVENT_TO_CATEGORY: dict[str, str] = {
"graduation_complete": "personal-direct",
"super_draft_graduation_ready": "admin-actionable",
"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")
pr = payload.get("pr_number")
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:
return f"{cfg.app_url}/rfc/{slug}/pr/{pr}"
if slug and branch:
+96
View File
@@ -0,0 +1,96 @@
"""Outbound OTC email — a thin wrapper over the existing SMTP layer.
The §15.4 notification mailer in `email.py` is purpose-built for
inbox-driven mail (unsubscribe footers, quiet-hours holds, bundling).
OTC mail is structurally different: it carries a credential, has no
inbox row behind it, and ignores user-preferences (a contributor
who's opted out of every notification still needs to receive the
code they explicitly requested).
So this module reuses `EmailConfig.from_env()` for the SMTP plumbing
and the From identity, but writes its own envelope. In dev (no
SMTP_HOST set), the envelope is logged at INFO level and pushed to
the same `_SENT` buffer the notification mailer uses, so the
integration tests can assert on the outbound shape without standing
up an SMTP server.
The send is synchronous. The `/auth/otc/request` endpoint always
returns 202 regardless of send outcome the user-facing surface
doesn't know whether the SMTP relay was reachable, since revealing
that would let an attacker probe for valid emails on a tight loop.
"""
from __future__ import annotations
import logging
import smtplib
from email.message import EmailMessage
from email.utils import formataddr
from .email import EmailConfig, _SENT
log = logging.getLogger(__name__)
def send_otc_email(to_address: str, code: str) -> bool:
"""Compose and send the one-time-code email. Returns True on the
happy path; False on SMTP failure. The notifier-side buffer
`_SENT` is appended either way so tests can assert on content.
The subject and body intentionally avoid branding strings that
belong to a deployment only `EMAIL_FROM_NAME` (operator-supplied
via env) lands in the From line. The body names the code, the
TTL, and a single instruction line. No tracking pixel, no
deep-link query, no embedded JS plain text only."""
cfg = EmailConfig.from_env()
subject = f"Your sign-in code for {cfg.from_name}"
body = _body(code, cfg)
envelope = {
"to": to_address,
"from": formataddr((cfg.from_name, cfg.from_address)),
"subject": subject,
"body": body,
"kind": "otc",
}
_SENT.append(envelope)
if not cfg.enabled:
log.info("otc email disabled (EMAIL_ENABLED=0): to=%s", to_address)
return True
if not cfg.smtp_host:
# Dev fallback: surface the code at INFO so the operator can
# complete a sign-in flow without an SMTP relay. In production
# SMTP_HOST is always set per OHM's overlay.
log.info("otc email (stdout fallback): to=%s code=%s", to_address, code)
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("otc email send failed: to=%s", to_address)
return False
def _body(code: str, cfg: EmailConfig) -> str:
return (
f"Your sign-in code is:\n\n"
f" {code}\n\n"
f"Enter this code in the sign-in screen to finish signing in.\n"
f"The code expires in 10 minutes. If you did not request this,\n"
f"you can safely ignore this email — no account was created.\n\n"
f"---\n"
f"{cfg.from_name} · {cfg.app_url}\n"
)
+321 -3
View File
@@ -10,11 +10,26 @@ import logging
import secrets
from contextlib import asynccontextmanager
from fastapi import APIRouter, FastAPI, HTTPException, Request
from fastapi.responses import RedirectResponse
from fastapi import APIRouter, FastAPI, HTTPException, Request, Response
from fastapi.responses import JSONResponse, RedirectResponse
from pydantic import BaseModel, Field
from starlette.middleware.sessions import SessionMiddleware
from . import api as api_routes, auth, cache, db, digest, hygiene, providers as providers_mod, webhooks
from . import (
api as api_routes,
auth,
cache,
db,
device_trust as device_trust_mod,
digest,
email_otc,
hygiene,
otc,
passcode as passcode_mod,
providers as providers_mod,
turnstile,
webhooks,
)
from .bot import Bot
from .config import load_config
from .gitea import Gitea
@@ -23,6 +38,40 @@ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name
log = logging.getLogger("rfc_app")
class OtcRequestBody(BaseModel):
email: str = Field(min_length=3, max_length=320)
# v0.12.0 / roadmap item #10: CloudFlare Turnstile token from the
# frontend widget. Optional in the body so a deployment that has
# not yet wired the Turnstile site key (or a dev environment with
# the widget intentionally skipped) still routes through the same
# endpoint; the backend turnstile.verify_token call decides whether
# to admit the request based on `TURNSTILE_REQUIRED` + presence of
# the secret.
turnstile_token: str | None = Field(default=None, max_length=4096)
class OtcVerifyBody(BaseModel):
email: str = Field(min_length=3, max_length=320)
code: str = Field(min_length=1, max_length=16)
# v0.11.0 — "trust this device for 30 days" checkbox on the Login.jsx
# OTC step. When true and verify succeeds, the server issues a fresh
# device-trust row and sets the `rfc_device_trust` cookie on the
# response. Defaults to false so existing clients that don't send
# the flag continue to behave the way they did pre-v0.11.0.
trust_device: bool = False
class PasscodeSetBody(BaseModel):
passcode: str = Field(min_length=1, max_length=64)
class PasscodeVerifyBody(BaseModel):
email: str = Field(min_length=3, max_length=320)
passcode: str = Field(min_length=1, max_length=64)
# v0.11.0 — same trust-device opt-in as the OTC verify body.
trust_device: bool = False
@asynccontextmanager
async def lifespan(app: FastAPI):
config = load_config()
@@ -86,6 +135,48 @@ def create_app() -> FastAPI:
app = create_app()
def _set_device_trust_cookie(response: Response, raw_token: str) -> None:
"""Attach the v0.11.0 device-trust cookie to the response.
HttpOnly + Secure + SameSite=Lax + 30-day Max-Age + Path=/. The
cookie value is the raw token; server-side storage is the hash.
The cookie is "essential" per the v0.13.0 cookie-consent contract
(it is part of authentication), so we set it regardless of the
user's analytics / other-cookies choice.
Secure=True means the cookie is only ever sent over HTTPS. The
SessionMiddleware in `create_app` keeps `https_only=False` for
dev parity, but the device-trust cookie holds a 30-day credential
and must not travel cleartext production deployments serve over
HTTPS, so Secure on the device-trust cookie is non-negotiable.
"""
response.set_cookie(
key=device_trust_mod.COOKIE_NAME,
value=raw_token,
max_age=device_trust_mod.COOKIE_MAX_AGE_SECONDS,
path="/",
secure=True,
httponly=True,
samesite="lax",
)
def _clear_device_trust_cookie(response: Response) -> None:
"""Delete the device-trust cookie on the response.
Used when the framework detects a presented cookie that is
expired, revoked, or otherwise stale the next request from
this device will not carry a dead token.
"""
response.delete_cookie(
key=device_trust_mod.COOKIE_NAME,
path="/",
secure=True,
httponly=True,
samesite="lax",
)
def _oauth_router(config) -> APIRouter:
router = APIRouter()
@@ -122,4 +213,231 @@ def _oauth_router(config) -> APIRouter:
request.session.clear()
return RedirectResponse("/")
# ---------------------------------------------------------------
# v0.7.0: email + one-time-code sign-in (§6.2).
#
# Replaces the OAuth gesture as the primary human-auth path. The
# /auth/callback handler above remains functional as a fallback;
# the new UI no longer surfaces it. A future release retires the
# OAuth path entirely once every active user has signed in at
# least once via OTC.
# ---------------------------------------------------------------
@router.post("/auth/otc/request")
async def otc_request(body: OtcRequestBody, request: Request):
# v0.12.0 / roadmap item #10: gate the request on a successful
# Turnstile siteverify before the bcrypt hash + SMTP send. The
# check runs first so a failed challenge spends no rate budget
# and produces no envelope. When the operator has not wired the
# secret AND TURNSTILE_REQUIRED=false (the default), the gate
# opens — see `backend/app/turnstile.py` for the full matrix.
client_ip = request.client.host if request.client else None
ts = turnstile.verify_token(body.turnstile_token, client_ip=client_ip)
if not ts.ok:
if ts.reason == "misconfigured":
# TURNSTILE_REQUIRED=true but the secret is unset. This
# is an operator/config problem, not a client problem;
# surface as 500 so the operator notices in their logs
# rather than blaming the user's browser.
raise HTTPException(500, "auth misconfigured")
# missing-token / failed / network → uniform 400 so the
# response does not enumerate which leg of the challenge
# broke. The reason is in the server logs.
raise HTTPException(400, "verification failed")
outcome = otc.request_code(body.email)
if outcome.reason == "cooldown":
# Loud failure per the rate-limit primitive — the abuse
# surface should be visible to clients hammering /request.
raise HTTPException(429, "Wait before requesting another code")
if outcome.sent and outcome.code is not None:
email_otc.send_otc_email(body.email.strip(), outcome.code)
# 202 regardless of allowlist/invalid — don't leak which
# emails are recognized.
return {"ok": True}
@router.post("/auth/otc/verify")
async def otc_verify(body: OtcVerifyBody, request: Request, response: Response):
result = otc.verify_code(body.email, body.code)
if not result.ok or result.user is None:
raise HTTPException(400, "Invalid or expired code")
auth.store_session(request, result.user)
# v0.8.0: surface `needs_profile` so the Login.jsx surface can
# decide whether to advance to the first/last/why capture step
# or jump straight to "/". `needs_profile=true` iff the user
# is `permission_state='pending'` AND the row has no profile
# fields yet — a fresh OTC user. Grandfathered users
# (`permission_state='granted'`) and pending users who already
# captured their fields both read as false.
row = db.conn().execute(
"SELECT first_name, last_name, beta_request_reason FROM users WHERE id = ?",
(result.user.user_id,),
).fetchone()
first_name = (row["first_name"] if row else None) or ""
last_name = (row["last_name"] if row else None) or ""
beta_request_reason = (row["beta_request_reason"] if row else None) or ""
needs_profile = (
result.user.permission_state == "pending"
and not first_name
and not last_name
and not beta_request_reason
)
# v0.11.0 — opt-in device trust. The checkbox lives on the
# Login.jsx OTC step; when true, the server mints a fresh
# device-trust row and sets the long-lived cookie. The cookie
# is "essential" per the v0.13.0 consent contract (it is part
# of authentication, not analytics) so it lands regardless of
# the user's analytics / other-cookies choice. We capture the
# User-Agent at issuance so the /settings/devices surface can
# render a rough device label.
if body.trust_device:
ua = request.headers.get("user-agent", "")
outcome = device_trust_mod.issue(result.user.user_id, ua)
_set_device_trust_cookie(response, outcome.raw_token)
return {
"ok": True,
"user": {
"id": result.user.user_id,
"display_name": result.user.display_name,
"email": result.user.email,
"role": result.user.role,
"permission_state": result.user.permission_state,
},
"needs_profile": needs_profile,
}
# ---------------------------------------------------------------
# v0.10.0: user-set passcodes after OTC (§6.2, roadmap item #8).
#
# After a successful OTC sign-in, a contributor may set a passcode
# and use email + passcode for subsequent sign-ins. OTC remains the
# forgot-passcode fallback — a verify failure beyond 5 consecutive
# attempts locks the passcode path for 15 minutes; the OTC path is
# unaffected by the lockout.
# ---------------------------------------------------------------
@router.get("/auth/passcode/check")
async def passcode_check(email: str = ""):
"""Does this email have a passcode set? Anonymous endpoint —
the Login.jsx flow calls this after the user types their email
to decide whether to render a passcode input or fall back to
OTC. We surface only the boolean; lockout state, the hash, and
the set-at stamp are not leaked here."""
status = passcode_mod.passcode_status(email)
return {"has_passcode": status.has_passcode}
@router.post("/auth/passcode/set")
async def passcode_set(body: PasscodeSetBody, request: Request):
"""Set or replace the signed-in user's passcode. Requires an
active session (OTC- or passcode-authenticated)."""
user = auth.require_user(request)
try:
passcode_mod.set_passcode(user.user_id, body.passcode)
except passcode_mod.PasscodeValidationError as e:
raise HTTPException(422, str(e))
return {"ok": True}
@router.delete("/auth/passcode")
async def passcode_delete(request: Request):
"""Remove the signed-in user's passcode. The user is back to
OTC-only on next sign-in."""
user = auth.require_user(request)
passcode_mod.clear_passcode(user.user_id)
return {"ok": True}
@router.post("/auth/passcode/verify")
async def passcode_verify(body: PasscodeVerifyBody, request: Request, response: Response):
"""Sign in with email + passcode. Returns the standard session
payload on success; HTTP 423 with `locked_until` when the
account is in the lockout window; HTTP 400 for every other
failure (the wrong-vs-unknown distinction is intentionally
collapsed so a probing client cannot enumerate emails).
v0.11.0: the body's `trust_device` flag, if true, mints a
fresh device-trust row and sets the long-lived cookie. Same
opt-in contract as `/auth/otc/verify`."""
result = passcode_mod.verify_passcode(body.email, body.passcode)
if result.reason == "locked":
raise HTTPException(
423,
{
"detail": "Too many failed attempts; sign in with a one-time code instead",
"locked_until": result.locked_until,
},
)
if not result.ok or result.user is None:
raise HTTPException(400, "Invalid passcode")
auth.store_session(request, result.user)
if body.trust_device:
ua = request.headers.get("user-agent", "")
outcome = device_trust_mod.issue(result.user.user_id, ua)
_set_device_trust_cookie(response, outcome.raw_token)
return {
"ok": True,
"user": {
"id": result.user.user_id,
"display_name": result.user.display_name,
"email": result.user.email,
"role": result.user.role,
},
}
# ---------------------------------------------------------------
# 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
+72
View File
@@ -64,6 +64,7 @@ log = logging.getLogger(__name__)
CATEGORY_PERSONAL = "personal-direct"
CATEGORY_STRUCTURAL = "structural"
CATEGORY_CHURN = "churn"
CATEGORY_ADMIN_ACTIONABLE = "admin-actionable"
# Action kinds whose actor's first interaction with a slug triggers
# 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(
*,
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}."
if event_kind == "pr_conflict_with_main":
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}"
+336
View File
@@ -0,0 +1,336 @@
"""§6.2 / v0.7.0 / v0.8.0: email + one-time-code sign-in.
Replaces the Gitea OAuth gesture as the primary human-auth path. The
Gitea bot user + token are still needed for server-side git
operations (repo reads, PR creation); only the operator-facing
sign-in surface moves through this module.
The shape:
* `request_code(email)` generates a 6-digit decimal code,
hashes it (bcrypt), stores the hash + expiry in `otc_codes`,
and dispatches a plain-text email via `email_otc.send`. It
invalidates any prior unused codes for the same email so a
re-request keeps the surface to one outstanding code per
address. The TTL comes from `OTC_TTL_MINUTES` (default 10).
A per-email cooldown (`OTC_REQUEST_COOLDOWN_SECONDS`, default
60) refuses back-to-back requests inside the window.
* `verify_code(email, code)` walks the most recent unconsumed
non-expired row for the email, checks the bcrypt hash, marks
the row consumed, and returns the linked or freshly-provisioned
user row.
* `provision_or_link_user(email)` is the migration path: if a
`users` row already carries `email` (case-insensitive), it is
reused `gitea_id` is left alone so a grandfathered OAuth-era
user keeps the linker intact. Otherwise a fresh contributor
row is provisioned with `gitea_id = NULL`, `gitea_login = NULL`,
and `permission_state = 'pending'` (v0.8.0 see below).
The endpoints in `main.py` thin-wrap this module.
v0.8.0 (roadmap item #6) replaces the v0.3.0 `allowed_emails` gate at
the request surface. The request handler used to silently drop OTC
requests for emails not on the allowlist; now any valid email
receives a code. The admission gate moves to `permission_state` on
the freshly-provisioned `users` row: a fresh user lands in 'pending'
and waits for an admin grant before write endpoints accept them.
Read surfaces stay open (the same blast radius v0.6.0 / item #4
already audited for anonymous viewers).
The `allowed_emails` table itself stays in the schema as a
fast-path bypass the admin UI from v0.3.0 continues to manage it,
and a future release (v0.9.0's admin user-management page) collapses
the two admission surfaces into one. The OTC request path no
longer consults the table.
"""
from __future__ import annotations
import logging
import os
import secrets
from dataclasses import dataclass
import bcrypt
from . import db
from .auth import SessionUser
log = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Tunables — env-driven with defaults so v0.7.0 needs no new secrets.
# ---------------------------------------------------------------------------
def _ttl_minutes() -> int:
raw = os.environ.get("OTC_TTL_MINUTES", "").strip()
if not raw:
return 10
try:
return max(1, int(raw))
except ValueError:
return 10
def _cooldown_seconds() -> int:
raw = os.environ.get("OTC_REQUEST_COOLDOWN_SECONDS", "").strip()
if not raw:
return 60
try:
return max(0, int(raw))
except ValueError:
return 60
# ---------------------------------------------------------------------------
# Code generation + hashing
# ---------------------------------------------------------------------------
def _new_code() -> str:
"""Six decimal digits. `secrets.randbelow` is CSPRNG-backed so the
code resists guessing even at the small (10^6) keyspace. The TTL
+ rate-limit are what carry the security weight the entropy of a
six-digit code by itself is intentionally human-readable."""
return f"{secrets.randbelow(1_000_000):06d}"
def _hash_code(code: str) -> str:
"""bcrypt over the code bytes. The hash is stored at rest; the code
itself only travels in the outbound email and the inbound verify
body."""
return bcrypt.hashpw(code.encode("utf-8"), bcrypt.gensalt()).decode("ascii")
def _check_code(code: str, code_hash: str) -> bool:
try:
return bcrypt.checkpw(code.encode("utf-8"), code_hash.encode("ascii"))
except (ValueError, TypeError):
return False
# ---------------------------------------------------------------------------
# Request path
#
# v0.8.0: the allowlist gate from v0.7.0 / v0.3.0 is removed here. Any
# valid email receives a code; the admission gate moved to
# `permission_state` on the freshly-provisioned `users` row (see
# `provision_or_link_user`). The `allowed_emails` table stays in the
# schema (admin UI from v0.3.0 still manages it); v0.9.0's admin
# user-management page will collapse the two surfaces.
# ---------------------------------------------------------------------------
@dataclass
class RequestOutcome:
"""The outcome of a `request_code` call.
`code` is None whenever no code was generated the cooldown
window blocked the request or the email was syntactically
invalid. The caller (the API endpoint) does not surface the
invalid-email shape to the user; it returns 202 either way.
The cooldown shape surfaces as a loud 429 per the v0.7.0
contract.
"""
sent: bool
code: str | None
reason: str # 'sent' | 'cooldown' | 'invalid'
def request_code(email: str) -> RequestOutcome:
email = (email or "").strip()
if not email or "@" not in email:
return RequestOutcome(sent=False, code=None, reason="invalid")
# Cooldown: refuse if a code was issued for this email in the last
# COOLDOWN_SECONDS. We surface it as a distinct outcome so the
# endpoint can return 429 — the spec calls this out as a "loud
# failure" so the abuse path is visible rather than swallowed.
cooldown = _cooldown_seconds()
if cooldown > 0:
row = db.conn().execute(
f"""
SELECT 1 FROM otc_codes
WHERE email = ?
AND datetime(created_at, '+{cooldown} seconds') > datetime('now')
LIMIT 1
""",
(email,),
).fetchone()
if row is not None:
return RequestOutcome(sent=False, code=None, reason="cooldown")
# Invalidate prior unused codes for this email. A re-request is
# always for the most recent code; older codes are dead.
db.conn().execute(
"""
UPDATE otc_codes
SET consumed_at = datetime('now')
WHERE email = ?
AND consumed_at IS NULL
""",
(email,),
)
code = _new_code()
code_hash = _hash_code(code)
ttl = _ttl_minutes()
db.conn().execute(
f"""
INSERT INTO otc_codes (email, code_hash, expires_at)
VALUES (?, ?, datetime('now', '+{ttl} minutes'))
""",
(email, code_hash),
)
return RequestOutcome(sent=True, code=code, reason="sent")
# ---------------------------------------------------------------------------
# Verify path
# ---------------------------------------------------------------------------
@dataclass
class VerifyOutcome:
"""Result of a `verify_code` call.
`user` is populated only on success. `reason` distinguishes the
failure modes the UI can render 'expired', 'consumed', 'wrong',
'unknown' (no outstanding code at all). The endpoint maps the
failure modes to a single 400 with a generic message; the reason
is logged for the operator.
"""
ok: bool
user: SessionUser | None
reason: str
def verify_code(email: str, code: str) -> VerifyOutcome:
email = (email or "").strip()
code = (code or "").strip()
if not email or not code:
return VerifyOutcome(ok=False, user=None, reason="invalid")
rows = db.conn().execute(
"""
SELECT id, code_hash, expires_at, consumed_at
FROM otc_codes
WHERE email = ?
ORDER BY id DESC
LIMIT 5
""",
(email,),
).fetchall()
if not rows:
return VerifyOutcome(ok=False, user=None, reason="unknown")
# Walk the recent rows so a user who pasted an older code still
# gets a sensible error — without this, the most-recent-row check
# would mask "you entered yesterday's code" as "wrong code".
matched = None
for row in rows:
if _check_code(code, row["code_hash"]):
matched = row
break
if matched is None:
return VerifyOutcome(ok=False, user=None, reason="wrong")
if matched["consumed_at"] is not None:
return VerifyOutcome(ok=False, user=None, reason="consumed")
expired = db.conn().execute(
"SELECT datetime(?) < datetime('now') AS expired",
(matched["expires_at"],),
).fetchone()["expired"]
if expired:
return VerifyOutcome(ok=False, user=None, reason="expired")
# Stamp consumed before provisioning so a parallel verify of the
# same row can't double-sign-in.
db.conn().execute(
"UPDATE otc_codes SET consumed_at = datetime('now') WHERE id = ?",
(matched["id"],),
)
user = provision_or_link_user(email)
return VerifyOutcome(ok=True, user=user, reason="ok")
# ---------------------------------------------------------------------------
# Provisioning — the migration path from OAuth identity to email identity.
# ---------------------------------------------------------------------------
def provision_or_link_user(email: str) -> SessionUser:
"""Link the OTC sign-in to a `users` row.
Match order:
1. An existing row whose email equals (case-insensitive) the
requested email the OAuth-era user is grandfathered in via
this path. `gitea_id` is preserved so a future OAuth round
trip still resolves the same row. `permission_state` is
read off the row as-is grandfathered users come through
migration with 'granted' (the column default), so their
contributor capabilities are unaffected.
2. Otherwise: a fresh contributor row with `gitea_id = NULL`,
`gitea_login = NULL`, and `permission_state = 'pending'`
(v0.8.0). The display name defaults to the local part of
the email (everything before the `@`); a separate
`POST /auth/me/beta-request` call lands first name / last
name / "why I want access" on the same row.
The §6.1 owner-zero bootstrap still applies: if the email matches
the configured `OWNER_GITEA_LOGIN`-derived owner identity, the row
is provisioned with role='owner'. v0.7.0 keeps that field as the
Gitea login (so existing deployments don't break); a future
release may add a parallel `OWNER_EMAIL` env if the OAuth route is
dropped entirely.
"""
email = email.strip()
existing = db.conn().execute(
"SELECT * FROM users WHERE email = ? COLLATE NOCASE",
(email,),
).fetchone()
if existing is not None:
db.conn().execute(
"UPDATE users SET last_seen_at = datetime('now') WHERE id = ?",
(existing["id"],),
)
return SessionUser(
user_id=existing["id"],
gitea_id=existing["gitea_id"] or 0,
gitea_login=existing["gitea_login"] or "",
display_name=existing["display_name"],
email=existing["email"] or email,
avatar_url=existing["avatar_url"] or "",
role=existing["role"],
permission_state=existing["permission_state"] or "granted",
)
display = email.split("@", 1)[0] or email
# v0.8.0: 'pending' is the explicit insert value; the migration
# default of 'granted' is what passes grandfathered users
# through. A fresh OTC user lands in 'pending' regardless of
# what the migration default says, so the gate engages reliably
# even if a future migration changes the default.
cur = db.conn().execute(
"""
INSERT INTO users (gitea_id, gitea_login, email, display_name, avatar_url, role, permission_state)
VALUES (NULL, NULL, ?, ?, '', 'contributor', 'pending')
""",
(email, display),
)
user_id = cur.lastrowid
return SessionUser(
user_id=user_id,
gitea_id=0,
gitea_login="",
display_name=display,
email=email,
avatar_url="",
role="contributor",
permission_state="pending",
)
+367
View File
@@ -0,0 +1,367 @@
"""§6.2 / v0.10.0: user-set passcodes after OTC (roadmap item #8).
After a successful OTC sign-in, a contributor may set a passcode and
use email + passcode for subsequent sign-ins. OTC remains the fallback
a forgotten passcode is recovered by requesting a fresh OTC.
This module is the state machine behind the four `/auth/passcode/*`
endpoints (`set`, `clear`, `verify`, `check`). The endpoints in
`main.py` thin-wrap these helpers in the same shape the OTC module
uses (see `otc.py`).
Shape:
* `set_passcode(user_id, passcode)` bcrypt-hash the passcode and
write it to `users.passcode_hash` + `users.passcode_set_at`.
Validation (length, denylist) happens here, not at the endpoint,
so the rule lives in one place. Replaces any prior passcode.
* `clear_passcode(user_id)` null out `passcode_hash` and
`passcode_set_at`. The user is back to OTC-only.
* `verify_passcode(email, passcode)` locate the user by email,
check lockout, compare via bcrypt, manage the failure counter,
and return a populated `SessionUser` on success.
* `passcode_status(email)` does this email have a passcode set?
Used by the `/auth/passcode/check` endpoint that the Login.jsx
flow consults after the user types their email.
Lockout is a v1 shape: 5 consecutive failures sets
`passcode_locked_until` to `now + 15 minutes`, after which a verify
attempt that lands inside the window returns HTTP 423. The OTC path
is unaffected by the lockout a user can request and verify a fresh
OTC to sign in while their passcode is locked out, and `verify_code`
in `otc.py` does not consult these columns.
The lockout window and the failure threshold are hard-coded here.
Tuning them via env vars (or moving to per-IP rate-limiting) is a
§19.2 candidate; see SPEC §19.2.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
import bcrypt
from . import db
from .auth import SessionUser
log = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Tunables — intentionally hard-coded in v0.10.0 (see module docstring).
# ---------------------------------------------------------------------------
LOCKOUT_AFTER_FAILED_ATTEMPTS = 5
LOCKOUT_DURATION_MINUTES = 15
PASSCODE_MIN_LENGTH = 4
PASSCODE_MAX_LENGTH = 20
# A small denylist of patterns we never want a passcode to be. The
# rule is "no obvious patterns"; the list is deliberately small —
# every entry here is a verbatim string match. A heavier check
# (sequential digits, single-character runs of length >= N, etc.)
# is a §19.2 candidate.
PASSCODE_DENYLIST: frozenset[str] = frozenset(
{
"0000",
"1111",
"2222",
"3333",
"4444",
"5555",
"6666",
"7777",
"8888",
"9999",
"1234",
"12345",
"123456",
"1234567",
"12345678",
"123456789",
"1234567890",
"0123",
"01234",
"012345",
"0123456",
"01234567",
"012345678",
"0123456789",
"abcd",
"abcde",
"abcdef",
"qwer",
"qwerty",
"asdf",
"asdfg",
"asdfgh",
"aaaa",
"bbbb",
"cccc",
"password",
"letmein",
}
)
# ---------------------------------------------------------------------------
# Validation
# ---------------------------------------------------------------------------
class PasscodeValidationError(Exception):
"""The proposed passcode failed validation. The endpoint surface
maps this to HTTP 422 with the message intact."""
def _validate(passcode: str) -> str:
"""Return the normalized passcode (stripped) or raise.
Rules:
* 4-20 characters after stripping leading/trailing whitespace.
* Not on the small denylist of obvious patterns.
No character-class restriction beyond that the spec says
"numeric PIN or short alphanumeric"; we don't refuse other
characters because the entropy isn't load-bearing (the per-account
lockout is what carries the security weight, mirroring the OTC
shape from v0.7.0).
"""
pc = (passcode or "").strip()
if not pc:
raise PasscodeValidationError("Passcode is required")
if len(pc) < PASSCODE_MIN_LENGTH:
raise PasscodeValidationError(
f"Passcode must be at least {PASSCODE_MIN_LENGTH} characters"
)
if len(pc) > PASSCODE_MAX_LENGTH:
raise PasscodeValidationError(
f"Passcode must be at most {PASSCODE_MAX_LENGTH} characters"
)
if pc.lower() in PASSCODE_DENYLIST:
raise PasscodeValidationError("Passcode is too common; pick something less obvious")
return pc
# ---------------------------------------------------------------------------
# Hashing
# ---------------------------------------------------------------------------
def _hash(passcode: str) -> str:
return bcrypt.hashpw(passcode.encode("utf-8"), bcrypt.gensalt()).decode("ascii")
def _check(passcode: str, passcode_hash: str) -> bool:
try:
return bcrypt.checkpw(passcode.encode("utf-8"), passcode_hash.encode("ascii"))
except (ValueError, TypeError):
return False
# ---------------------------------------------------------------------------
# Set / clear
# ---------------------------------------------------------------------------
def set_passcode(user_id: int, passcode: str) -> None:
"""Hash and store the passcode. Replaces any prior passcode on the
same row; clears the failure counter and lockout (a user setting a
fresh passcode is implicitly re-authenticating their account)."""
pc = _validate(passcode)
h = _hash(pc)
db.conn().execute(
"""
UPDATE users
SET passcode_hash = ?,
passcode_set_at = datetime('now'),
passcode_failed_attempts = 0,
passcode_locked_until = NULL
WHERE id = ?
""",
(h, user_id),
)
def clear_passcode(user_id: int) -> None:
"""Remove the passcode. The user is back to OTC-only on next sign-in."""
db.conn().execute(
"""
UPDATE users
SET passcode_hash = NULL,
passcode_set_at = NULL,
passcode_failed_attempts = 0,
passcode_locked_until = NULL
WHERE id = ?
""",
(user_id,),
)
# ---------------------------------------------------------------------------
# Check (status surface for the Login.jsx flow)
# ---------------------------------------------------------------------------
@dataclass
class PasscodeStatus:
"""The shape `/auth/passcode/check` returns.
`has_passcode` is the only signal the frontend needs to decide
whether to show a passcode input or an OTC request step. We do
not leak the hash, the set-at timestamp, or the lockout state
a probing client that wants to know "is this account locked
out" can attempt a verify and read the 423.
"""
has_passcode: bool
def passcode_status(email: str) -> PasscodeStatus:
email = (email or "").strip()
if not email or "@" not in email:
return PasscodeStatus(has_passcode=False)
row = db.conn().execute(
"SELECT passcode_hash FROM users WHERE email = ? COLLATE NOCASE",
(email,),
).fetchone()
if row is None:
return PasscodeStatus(has_passcode=False)
return PasscodeStatus(has_passcode=bool(row["passcode_hash"]))
# ---------------------------------------------------------------------------
# Verify
# ---------------------------------------------------------------------------
@dataclass
class VerifyOutcome:
"""Result of a `verify_passcode` call.
`reason` distinguishes the failure modes the endpoint surfaces as
distinct HTTP shapes:
* 'ok' populated `user`, HTTP 200.
* 'unknown' no user with this email, HTTP 400 (generic).
* 'no_passcode' user exists but never set a passcode, HTTP 400
(the frontend should fall back to OTC).
* 'locked' user is currently in the lockout window, HTTP
423. `locked_until` carries the ISO-8601 stamp for the client.
* 'wrong' passcode didn't match. HTTP 400. If the failure
crossed the lockout threshold the row is now locked; the
endpoint surfaces this as a fresh `locked` response on the
next attempt rather than collapsing the two states here.
"""
ok: bool
user: SessionUser | None
reason: str
locked_until: str | None = None
def verify_passcode(email: str, passcode: str) -> VerifyOutcome:
email = (email or "").strip()
passcode = (passcode or "").strip()
if not email or not passcode:
return VerifyOutcome(ok=False, user=None, reason="unknown")
row = db.conn().execute(
"""
SELECT id, gitea_id, gitea_login, email, display_name, avatar_url, role,
passcode_hash, passcode_failed_attempts, passcode_locked_until
FROM users
WHERE email = ? COLLATE NOCASE
""",
(email,),
).fetchone()
if row is None:
return VerifyOutcome(ok=False, user=None, reason="unknown")
if not row["passcode_hash"]:
return VerifyOutcome(ok=False, user=None, reason="no_passcode")
# Lockout check: if `passcode_locked_until` is populated and in the
# future, the verify is refused without touching the hash. Once the
# window has elapsed we let the verify proceed; the failed-attempts
# counter is also reset so the user gets a fresh 5-attempt budget.
locked_until = row["passcode_locked_until"]
if locked_until:
still_locked = db.conn().execute(
"SELECT datetime(?) > datetime('now') AS still_locked",
(locked_until,),
).fetchone()["still_locked"]
if still_locked:
return VerifyOutcome(
ok=False,
user=None,
reason="locked",
locked_until=locked_until,
)
# Lockout expired — clear the counter so the next failure starts
# from zero, and continue with the verify.
db.conn().execute(
"""
UPDATE users
SET passcode_failed_attempts = 0,
passcode_locked_until = NULL
WHERE id = ?
""",
(row["id"],),
)
if _check(passcode, row["passcode_hash"]):
# Success: clear the counter (a single success wipes the
# accumulated failures — the threshold tracks *consecutive*
# failures).
db.conn().execute(
"""
UPDATE users
SET passcode_failed_attempts = 0,
passcode_locked_until = NULL,
last_seen_at = datetime('now')
WHERE id = ?
""",
(row["id"],),
)
return VerifyOutcome(
ok=True,
user=SessionUser(
user_id=row["id"],
gitea_id=row["gitea_id"] or 0,
gitea_login=row["gitea_login"] or "",
display_name=row["display_name"],
email=row["email"] or email,
avatar_url=row["avatar_url"] or "",
role=row["role"],
),
reason="ok",
)
# Failure: increment the counter. If this push crosses the
# threshold, stamp the lockout. The next verify attempt against
# the same row returns 423 with the `locked_until` stamp.
next_count = (row["passcode_failed_attempts"] or 0) + 1
if next_count >= LOCKOUT_AFTER_FAILED_ATTEMPTS:
db.conn().execute(
f"""
UPDATE users
SET passcode_failed_attempts = ?,
passcode_locked_until = datetime('now', '+{LOCKOUT_DURATION_MINUTES} minutes')
WHERE id = ?
""",
(next_count, row["id"]),
)
new_locked_until = db.conn().execute(
"SELECT passcode_locked_until FROM users WHERE id = ?",
(row["id"],),
).fetchone()["passcode_locked_until"]
return VerifyOutcome(
ok=False,
user=None,
reason="locked",
locked_until=new_locked_until,
)
db.conn().execute(
"UPDATE users SET passcode_failed_attempts = ? WHERE id = ?",
(next_count, row["id"]),
)
return VerifyOutcome(ok=False, user=None, reason="wrong")
+148
View File
@@ -0,0 +1,148 @@
"""§6.2 / v0.12.0 / roadmap item #10: CloudFlare Turnstile siteverify.
The OTC request endpoint (`/auth/otc/request`) is the abuse hot path
of the auth surface since v0.7.0 the per-email cooldown stops the
trivial back-to-back loop, but it does not stop a distributed scraper
that fans out across a large invitee list to harvest the "this email
is admitted vs. this email is not" signal indirectly (timing
differences, SMTP bounce-rate observation). v0.12.0 gates the request
endpoint behind a one-step browser-side Turnstile challenge before the
bcrypt hash + SMTP send.
Stateless: no DB writes, no schema change. The siteverify call to
CloudFlare lives entirely in this module; the endpoint handler in
`main.py` thin-wraps `verify_token`.
Tunables (read at call time so tests can monkeypatch):
* `CLOUDFLARE_TURNSTILE_SECRET` the operator-provisioned secret
key from the Turnstile dashboard. Lives in GCP Secret Manager in
production; absent in tests (which monkeypatch the siteverify
transport). When unset, the behavior depends on `TURNSTILE_REQUIRED`:
- `TURNSTILE_REQUIRED=true` fail closed (`misconfigured`).
- `TURNSTILE_REQUIRED=false` (default) skip verification entirely
and admit the request. This is the dev/test path and the
"operator hasn't wired the secret yet" path; production
deployments **should** set `TURNSTILE_REQUIRED=true` once the
secret is in place so a regression in the secret wiring fails
loudly instead of silently disabling abuse defense.
* `TURNSTILE_REQUIRED` `true` / `false` (default `false`).
When `false` and the secret is absent, the gate is open. When
`true` and the secret is absent, the endpoint refuses with a
misconfigured-auth shape rather than silently letting requests
through.
* `TURNSTILE_SITEVERIFY_URL` points at the real CloudFlare
endpoint by default. Override in tests to redirect at a mock
URL when `httpx.MockTransport` isn't ergonomic for the case.
The siteverify contract is documented at
https://developers.cloudflare.com/turnstile/get-started/server-side-validation/.
We POST `secret` + `response` (and optionally `remoteip`) as form
fields and read back `{"success": true|false, ...}`. Any network /
parse failure on the siteverify call is treated as a verification
failure (`network`) the abuse path is to skip the challenge, so
"can't reach CloudFlare" defaults to "refuse the request" when
`TURNSTILE_REQUIRED=true`, and "admit" when `TURNSTILE_REQUIRED=false`.
"""
from __future__ import annotations
import logging
import os
from dataclasses import dataclass
import httpx
log = logging.getLogger(__name__)
SITEVERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
def _secret() -> str:
return os.environ.get("CLOUDFLARE_TURNSTILE_SECRET", "").strip()
def _required() -> bool:
raw = os.environ.get("TURNSTILE_REQUIRED", "").strip().lower()
return raw in ("1", "true", "yes", "on")
def _siteverify_url() -> str:
return os.environ.get("TURNSTILE_SITEVERIFY_URL", "").strip() or SITEVERIFY_URL
@dataclass
class VerifyOutcome:
"""Result of a Turnstile siteverify call.
`ok`: the request **may proceed**. True both for "siteverify said
success" and for "no secret configured AND not required" (the
dev/test soft-fail path).
`reason`: one of
* 'ok' siteverify returned success.
* 'skipped' no secret configured, TURNSTILE_REQUIRED=false.
The gate is open; the endpoint admits the request.
* 'misconfigured' TURNSTILE_REQUIRED=true but no secret in env.
The endpoint fails closed with 500.
* 'missing-token' the client did not send a token at all and
verification is required.
* 'failed' siteverify returned success=false. The
endpoint refuses with 400.
* 'network' siteverify call raised. Treated as a failure
under TURNSTILE_REQUIRED=true.
"""
ok: bool
reason: str
def verify_token(token: str | None, *, client_ip: str | None = None) -> VerifyOutcome:
"""Validate a Turnstile token against CloudFlare's siteverify endpoint.
Returns a VerifyOutcome describing whether the calling endpoint
should proceed. The endpoint maps `ok=False` to an HTTP status per
the `reason`:
* 'misconfigured' 500 "auth misconfigured"
* 'missing-token' / 'failed' / 'network' 400 "verification failed"
Tests monkeypatch `httpx.post` (or set `TURNSTILE_SITEVERIFY_URL`
+ a MockTransport client) to avoid touching the real CloudFlare
endpoint. No real keys are ever embedded in tests.
"""
secret = _secret()
required = _required()
if not secret:
if required:
log.warning("Turnstile required but CLOUDFLARE_TURNSTILE_SECRET is unset; failing closed")
return VerifyOutcome(ok=False, reason="misconfigured")
# Dev/test/soft-fail path: no secret, not required → gate is open.
return VerifyOutcome(ok=True, reason="skipped")
if not token or not token.strip():
# Secret is set, so verification is in force. A missing token
# is a hard refuse — the frontend should have rendered the
# widget and collected one.
return VerifyOutcome(ok=False, reason="missing-token")
data = {"secret": secret, "response": token.strip()}
if client_ip:
data["remoteip"] = client_ip
try:
response = httpx.post(_siteverify_url(), data=data, timeout=10.0)
payload = response.json()
except Exception as exc: # network, JSON parse, etc.
log.warning("Turnstile siteverify call failed: %s", exc)
return VerifyOutcome(ok=False, reason="network")
if payload.get("success") is True:
return VerifyOutcome(ok=True, reason="ok")
# `error-codes` is a list of strings on failure; we log the codes
# for the operator without surfacing them to the client.
log.info("Turnstile siteverify rejected token: %s", payload.get("error-codes"))
return VerifyOutcome(ok=False, reason="failed")
+105
View File
@@ -0,0 +1,105 @@
-- §6.2 / v0.7.0: email + one-time-code sign-in.
--
-- Replaces the Gitea OAuth gesture as the primary human-auth path.
-- The Gitea bot user + token are still needed for server-side git
-- operations (repo reads, PR creation); only the operator-facing
-- sign-in surface moves. The /auth/callback OAuth route remains
-- functional during migration as a fallback, scheduled for removal
-- in a future release once every active user has signed in via OTC
-- at least once.
--
-- A row in `otc_codes` represents an outstanding 6-digit code that
-- was emailed to `email`. Codes are stored hashed (bcrypt) rather
-- than plaintext, so a database compromise does not expose the
-- in-flight code. TTL is enforced by `expires_at`. Each `verify`
-- success stamps `consumed_at` and refuses every later attempt
-- against the same row.
--
-- The §6.2 identity model under v0.7.0:
--
-- * `users.email` is the primary identity key for new sign-ins.
-- * `users.gitea_id` stays populated for users grandfathered in
-- via the OAuth-era flow; new users have `gitea_id = NULL`.
-- The unique-constraint on `gitea_id` is relaxed (in v0.5.0 it
-- was `INTEGER UNIQUE NOT NULL`) to permit the NULL.
-- * `users.email` becomes a (case-insensitive) unique key. An
-- existing OAuth user whose Gitea profile carried an email is
-- linked on first OTC sign-in; if no row matches, a fresh
-- contributor row is provisioned.
--
-- New env vars (v0.7.0):
-- * `OTC_TTL_MINUTES` (default 10): how long a code stays valid.
-- * `OTC_REQUEST_COOLDOWN_SECONDS` (default 60): per-email rate
-- limit between successive `/auth/otc/request` calls.
CREATE TABLE otc_codes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL COLLATE NOCASE,
code_hash TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT NOT NULL,
consumed_at TEXT
);
CREATE INDEX idx_otc_codes_email ON otc_codes (email, consumed_at, expires_at);
-- Relax `users.gitea_id` from `INTEGER UNIQUE NOT NULL` to a nullable
-- column with a partial unique index that ignores nulls. SQLite does
-- not support ALTER COLUMN, so we rebuild the table.
--
-- A few defensive notes:
-- * Every foreign key into `users(id)` continues to resolve — `id`
-- is the same INTEGER PRIMARY KEY in the rebuilt table.
-- * `email` is now declared NOCASE so a `WHERE email = ?` match
-- is case-insensitive without changing every read site. The
-- prior column accepted any text; existing rows pass through
-- unchanged.
-- * `gitea_login` likewise relaxes from NOT NULL to nullable, so
-- users provisioned by OTC alone don't carry a synthetic login.
CREATE TABLE users_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
gitea_id INTEGER,
gitea_login TEXT,
email TEXT COLLATE NOCASE,
display_name TEXT NOT NULL,
avatar_url TEXT,
role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'contributor')),
muted INTEGER NOT NULL DEFAULT 0,
email_personal_direct INTEGER NOT NULL DEFAULT 1,
email_watched_structural INTEGER NOT NULL DEFAULT 0,
email_admin_actionable INTEGER NOT NULL DEFAULT 1,
email_opt_out_all INTEGER NOT NULL DEFAULT 0,
digest_cadence TEXT NOT NULL DEFAULT 'weekly' CHECK (digest_cadence IN ('off', 'weekly', 'daily')),
notification_quiet_hours_start TEXT,
notification_quiet_hours_end TEXT,
notification_quiet_hours_timezone TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_seen_at TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT INTO users_new (
id, gitea_id, gitea_login, email, display_name, avatar_url, role,
muted, email_personal_direct, email_watched_structural,
email_admin_actionable, email_opt_out_all, digest_cadence,
notification_quiet_hours_start, notification_quiet_hours_end,
notification_quiet_hours_timezone, created_at, last_seen_at
)
SELECT
id, gitea_id, gitea_login, email, display_name, avatar_url, role,
muted, email_personal_direct, email_watched_structural,
email_admin_actionable, email_opt_out_all, digest_cadence,
notification_quiet_hours_start, notification_quiet_hours_end,
notification_quiet_hours_timezone, created_at, last_seen_at
FROM users;
DROP TABLE users;
ALTER TABLE users_new RENAME TO users;
CREATE INDEX idx_users_role ON users (role);
-- Partial unique indexes so NULLs are permitted but populated values
-- collide. Gitea linkage stays unique per gitea_id; OTC-era identity
-- is keyed on email (case-insensitive via NOCASE on the column).
CREATE UNIQUE INDEX idx_users_gitea_id ON users (gitea_id) WHERE gitea_id IS NOT NULL;
CREATE UNIQUE INDEX idx_users_gitea_login ON users (gitea_login) WHERE gitea_login IS NOT NULL;
CREATE UNIQUE INDEX idx_users_email ON users (email) WHERE email IS NOT NULL AND email != '';
+35
View File
@@ -0,0 +1,35 @@
-- v0.13.0 / roadmap item #11 — cookie consent.
--
-- The framework now ships a non-modal cookie consent banner per the
-- privacy-and-cookies UX (SPEC §14.5 / §14.6). Authenticated viewers
-- get their choice persisted server-side so it survives sign-out /
-- sign-in across devices; anonymous viewers persist their choice in
-- localStorage only.
--
-- Shape: a single row per user, three flags, plus a recorded-at stamp.
-- The flags are:
-- - essential: the framework's strictly-necessary cookies (session,
-- itsdangerous-signed payloads, CSRF if any). Permanently
-- true at the API surface — included in the row for
-- symmetry with the analytics / other flags rather than
-- because the user can switch it off.
-- - analytics: reserved for the §13 analytics SDK gating that lands
-- in v0.15.0. Off by default; opt-in via the banner.
-- - other: everything else (third-party embeds, social widgets).
-- Off by default; opt-in via the banner.
--
-- A NULL recorded_at means "no choice yet" — the banner should re-prompt
-- the next time the user signs in on a fresh device. Once recorded_at is
-- set, the banner is hidden until the user re-opens it from the
-- /settings/notifications "Privacy & cookies" tab.
--
-- The row is created lazily on first PUT. Absence of a row is equivalent
-- to NULL recorded_at — the banner shows.
CREATE TABLE cookie_consent (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
essential INTEGER NOT NULL DEFAULT 1 CHECK (essential IN (0, 1)),
analytics INTEGER NOT NULL DEFAULT 0 CHECK (analytics IN (0, 1)),
other_cookies INTEGER NOT NULL DEFAULT 0 CHECK (other_cookies IN (0, 1)),
recorded_at TEXT
);
+76
View File
@@ -0,0 +1,76 @@
-- §6.1 / §6.2 / §14.1 / v0.8.0: open beta-access request flow (roadmap item #6).
--
-- This release replaces v0.3.0's `allowed_emails` allowlist as the
-- admission control. Anyone with a valid email can sign in via the
-- v0.7.0 OTC flow; a fresh user lands in `permission_state='pending'`
-- until an admin grants access. The first-OTC flow captures three
-- profile fields (first name, last name, free-text "why I should be
-- included in the beta") that the admin sees when triaging the
-- request queue. The `allowed_emails` table stays in the schema as a
-- fast-path bypass — populated rows are still readable by the
-- existing admin UI; the OTC `/request` handler no longer consults
-- it. v0.9.0's admin user-management page will replace the
-- allowlist UI entirely.
--
-- Schema additions:
--
-- * `permission_state` — three-state CHECK: 'pending' | 'granted' |
-- 'revoked'. Default 'granted' so every row at migration time
-- passes through unaffected; only newly provisioned OTC users
-- land in 'pending' (the OTC verify path sets the column
-- explicitly on a fresh row, per `app/otc.py`). 'revoked' is the
-- admin gesture for an account that earned a grant then later
-- lost it; v0.8.0 doesn't surface a revoke UI, but the schema
-- slot is here so v0.9.0's admin user-management page can flip
-- the column without another migration.
--
-- * `first_name`, `last_name` — nullable TEXT. Captured on the
-- first OTC sign-in via `POST /auth/me/beta-request`. Existing
-- rows (OAuth-era users, OTC users provisioned in v0.7.0) carry
-- NULL through the migration; the admin queue treats an
-- unpopulated capture as "auto-grandfathered" since the row's
-- `permission_state` is already 'granted'.
--
-- * `beta_request_reason` — nullable TEXT. The free-text "why I
-- should be included" from the capture form. Bounded to ~4000
-- chars at the endpoint layer (no DB-level constraint —
-- SQLite's TEXT is unbounded).
--
-- * `permission_decided_by` — nullable INTEGER. The `users.id` of
-- the admin who flipped `permission_state` from 'pending' to
-- 'granted' (or 'granted' to 'revoked'). NULL for grandfathered
-- rows (they were never decided — they passed through at
-- migration). ON DELETE SET NULL because losing the admin row
-- should not cascade-delete the user whose access they granted.
--
-- * `permission_decided_at` — nullable TEXT timestamp (ISO 8601,
-- same shape as the existing `created_at` / `last_seen_at`).
-- Co-populated with `permission_decided_by` on each decision.
--
-- Grandfathered-row invariant:
--
-- Every row that exists at migration time has
-- `permission_state='granted'` and `permission_decided_by=NULL`
-- (the column default + NULL preservation). v0.8.0's auth gate
-- reads `permission_state='granted'` as the admission check, so
-- no existing user is locked out by the upgrade. v0.7.0's OTC
-- path is patched in the same release to set
-- `permission_state='pending'` explicitly on a fresh row, so the
-- gate engages only for users provisioned after the upgrade.
ALTER TABLE users ADD COLUMN permission_state TEXT NOT NULL DEFAULT 'granted'
CHECK (permission_state IN ('pending', 'granted', 'revoked'));
ALTER TABLE users ADD COLUMN first_name TEXT;
ALTER TABLE users ADD COLUMN last_name TEXT;
ALTER TABLE users ADD COLUMN beta_request_reason TEXT;
ALTER TABLE users ADD COLUMN permission_decided_by INTEGER
REFERENCES users(id) ON DELETE SET NULL;
ALTER TABLE users ADD COLUMN permission_decided_at TEXT;
-- Index for the v0.9.0 admin queue: list pending requests ordered by
-- when the user's row was created (the implicit "request received at"
-- timestamp, since v0.8.0 sets pending at the same moment as the row
-- itself is inserted via the OTC verify path).
CREATE INDEX idx_users_permission_state ON users (permission_state);
+52
View File
@@ -0,0 +1,52 @@
-- §6.2 / v0.10.0: user-set passcodes after OTC (roadmap item #8).
--
-- After a successful OTC sign-in, a contributor may set a passcode
-- (numeric PIN or short alphanumeric). Subsequent sign-ins on the same
-- account can use email + passcode instead of email + OTC. OTC remains
-- the structural fallback — a forgotten passcode is recovered by
-- requesting a fresh OTC and signing in via that path. Per-account
-- lockout after 5 consecutive verify failures redirects the user to
-- the OTC path for 15 minutes; the OTC path itself is unaffected by
-- the passcode lockout (a locked-out user can still receive a fresh
-- code and sign in).
--
-- The columns are additive to the `users` table from `012_otc.sql`.
-- v0.8.0's `permission_state` column (roadmap item #6) lands in the
-- driver's integration order ahead of this migration; we do not touch
-- that column here. v0.7.0's nullable-`gitea_id`/`gitea_login` shape
-- is preserved verbatim.
--
-- Storage shape:
--
-- * `passcode_hash` (nullable) — bcrypt hash of the passcode.
-- NULL means "no passcode set"; the user is OTC-only.
-- * `passcode_set_at` (nullable) — timestamp of the most recent
-- `passcode/set` call. Updated when a passcode is set or
-- replaced; cleared when the passcode is removed.
-- * `passcode_failed_attempts` — count of consecutive failed
-- verify attempts since the last successful verify (or since
-- the lockout cleared). Resets to 0 on success and on lockout
-- expiry. Defaults to 0 so existing rows post-migration are
-- not implicitly half-locked.
-- * `passcode_locked_until` (nullable) — if populated and the
-- timestamp is in the future, passcode verify is refused with
-- HTTP 423. Cleared on successful verify after the window
-- expires, or by the operator via direct DB intervention if
-- ever needed (no admin endpoint surfaces this in v1).
--
-- v0.10.0 introduces no new env vars. The lockout window (5 attempts,
-- 15 minutes) is hard-coded in `backend/app/passcode.py`; raising or
-- lowering it is a future-§19.2 candidate. Passcode hashing reuses
-- the bcrypt dependency added in v0.7.0 for OTC; no new secret is
-- required (the existing `SECRET_KEY` continues to sign sessions).
--
-- Note on SQLite: ALTER TABLE ... ADD COLUMN is supported, so this
-- migration does not need the rebuild dance that `012_otc.sql`
-- required. The runner wraps each file in a single BEGIN/COMMIT
-- block — see `backend/app/db.py` — so either every ADD COLUMN
-- here lands or none do.
ALTER TABLE users ADD COLUMN passcode_hash TEXT;
ALTER TABLE users ADD COLUMN passcode_set_at TEXT;
ALTER TABLE users ADD COLUMN passcode_failed_attempts INTEGER NOT NULL DEFAULT 0;
ALTER TABLE users ADD COLUMN passcode_locked_until TEXT;
+75
View File
@@ -0,0 +1,75 @@
-- §6.2 / v0.11.0: trust device for 30 days (roadmap item #9).
--
-- After a successful OTC or passcode sign-in, the user can check
-- "trust this device for 30 days." The framework then issues a
-- server-issued opaque device-trust token, stores its hash on this
-- table, and sets a long-lived HttpOnly + Secure + SameSite=Lax
-- cookie carrying the raw token. On a subsequent visit, the cookie is
-- presented at `/auth/device-trust/start`; if the server can match the
-- hash to a non-expired non-revoked row, the user is signed in without
-- another OTC / passcode round-trip.
--
-- v0.11.0 introduces no new env vars. The 30-day window is hard-coded
-- in `backend/app/device_trust.py`; raising or lowering it (or making
-- it user-selectable) is a §19.2 candidate, alongside the cross-device
-- session-revocation surface this table will eventually share with the
-- v0.10.0 passcode-lockout shape (see SPEC §19.2 / SESSIONS-AND-DEVICES).
--
-- Storage shape:
--
-- * `id` — surrogate key. Lets the revoke-device UI address a single
-- row by id without leaking the token shape.
-- * `user_id` — FK into users(id) with cascade on delete. A deleted
-- user automatically loses every trusted device.
-- * `device_token_hash` — bcrypt hash of the random opaque token
-- issued at trust-time. The raw token only ever lives in the
-- outbound `Set-Cookie` header and the inbound `Cookie` header;
-- server-side storage is the hash, so a DB compromise does not
-- hand attackers a stash of valid device tokens.
-- * `created_at` — when the row was issued.
-- * `expires_at` — `created_at + 30 days`. A row past this timestamp
-- is dead; the lookup path refuses it without further checks.
-- * `user_agent` — the User-Agent header captured at issuance.
-- Stored verbatim (truncated to 1024 chars at the application
-- layer) so the revoke-device UI can show a rough device label.
-- Not used for any auth decision — purely a hint to the user
-- reviewing their device list.
-- * `last_seen_at` — refreshed every time the row authenticates a
-- request. Lets the revoke-device UI surface "last used 3 days
-- ago" so the user can tell which row corresponds to which
-- device.
-- * `revoked_at` — NULL means active; non-NULL stamps when the user
-- (or admin) revoked the row. Lookups treat any non-NULL value
-- as "this row is dead" without consulting the expiry; the
-- revoke gesture is intentionally one-way (a revoked device must
-- re-trust to come back online).
--
-- Indexing: a unique index on `device_token_hash` so collisions are
-- detectable at insert time (the token space is 256 bits of CSPRNG
-- entropy, so a collision is structurally impossible, but the
-- declaration documents the invariant). A separate index on
-- `(user_id, revoked_at)` so the revoke-device UI's list query is
-- a covering walk.
--
-- The bcrypt dependency reused here was added in v0.7.0 for OTC and
-- extended in v0.10.0 for passcodes; v0.11.0 needs no new dep.
--
-- The cookie shape: `rfc_device_trust` carries the raw token,
-- HttpOnly, Secure, SameSite=Lax, Max-Age=2592000 (30 days). It is
-- "essential" per the v0.13.0 cookie-consent banner (it is part of
-- authentication, not analytics), so it is set regardless of the
-- user's analytics / other-cookies choices.
CREATE TABLE device_trust (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
device_token_hash TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT NOT NULL,
user_agent TEXT NOT NULL DEFAULT '',
last_seen_at TEXT NOT NULL DEFAULT (datetime('now')),
revoked_at TEXT
);
CREATE UNIQUE INDEX idx_device_trust_token_hash ON device_trust (device_token_hash);
CREATE INDEX idx_device_trust_user ON device_trust (user_id, revoked_at);
+1
View File
@@ -8,3 +8,4 @@ anthropic>=0.39
google-generativeai>=0.8
openai>=1.50
PyYAML>=6.0
bcrypt>=4.2
+425
View File
@@ -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,476 @@
"""v0.6.0 (roadmap item #4) — "anon discuss + contribute off-limits"
vertical.
A sweep-the-edges hardening release. The v0.3.0 release hid the write
affordances from anonymous viewers; v0.5.0 added the PR-less discussion
surface with its own write gate. v0.6.0 audits both: every write-shaped
endpoint refuses anonymous callers with 401 (or 403 when the role check
runs after the auth check), and every anonymous-read surface stays
reachable.
This test is the regression net for the audit. It walks each module's
representative write endpoint as an anonymous client and asserts the
401/403, then walks the same surfaces' representative read endpoints
as anonymous and asserts the 200. The intent is breadth over depth:
one assertion per write endpoint family is enough to catch a
regression where someone strips the `auth.require_contributor` line.
Endpoints covered (one or two from each module):
- api.py: propose, decline (admin), withdraw,
funder credentials POST/DELETE, funder consent
POST/DELETE
- api_branches.py: promote-to-branch, start-edit-branch, metadata,
manual-flush, visibility, grants POST/DELETE,
threads POST, thread messages POST, resolve,
chat-seen, change accept/decline/reask
- api_prs.py: pr-draft, open-pr, seen, review, merge, withdraw,
description, resolution-branch
- api_discussion.py: thread create, message post, resolve
- api_admin.py: role POST, mute POST, allowlist POST/DELETE
- api_notifications.py: prefs POST, watch POST, mark-read POST,
quiet-hours POST, user-mute POST/DELETE
- api_graduation.py: graduate POST, claim POST, progress GET
The §15.7 reads (`/api/notifications`, `/api/watches`,
`/api/users/me/*`) are per-user surfaces they require an
authenticated viewer by definition; an anonymous 401 on those reads is
shape-correct, not a regression. The test does not assert reads on
those.
"""
from __future__ import annotations
import pytest
# Reuse the fixture / session / fake-Gitea harness from Slice 1.
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_BODY, seed_active_rfc
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
def test_anonymous_can_read_every_public_surface(app_with_fake_gitea):
"""Per §14 / the v0.3.0 anonymous-read contract: the catalog, the
RFC view, the PR-less discussion surface, the philosophy page, and
the health probe must remain reachable for unauthenticated viewers.
This is the read side of the item #4 contract — the read surfaces
must NOT regress to require auth as the write gates tighten.
"""
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)
# No session cookie — viewer is anonymous.
client.cookies.clear()
# The five read surfaces an anonymous viewer must reach.
assert client.get("/api/health").status_code == 200
assert client.get("/api/philosophy").status_code == 200
assert client.get("/api/auth/me").status_code == 200
assert client.get("/api/rfcs").status_code == 200
assert client.get("/api/rfcs/ohm").status_code == 200
assert client.get("/api/rfcs/ohm/main").status_code == 200
assert client.get("/api/rfcs/ohm/discussion/threads").status_code == 200
assert client.get("/api/proposals").status_code == 200
def test_anonymous_propose_refused(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
client.cookies.clear()
r = client.post(
"/api/rfcs/propose",
json={"title": "X", "slug": "x", "pitch": "p", "tags": []},
)
assert r.status_code == 401
def test_anonymous_proposal_admin_paths_refused(app_with_fake_gitea):
"""The admin-gated proposal actions — merge, decline — must refuse
anonymous callers with 401 (the auth check runs before the role
check; both refusals are correct, but 401 is the structural signal
"no session at all")."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
client.cookies.clear()
# PR number doesn't need to exist — the gate runs first.
assert client.post("/api/proposals/1/merge").status_code == 401
assert (
client.post("/api/proposals/1/decline", json={"comment": "no"}).status_code
== 401
)
assert client.post("/api/proposals/1/withdraw").status_code == 401
def test_anonymous_branch_writes_refused_on_active_rfc(app_with_fake_gitea):
"""Branch-scoped writes on an active RFC: promote-to-branch,
manual-flush, visibility, grants, threads create, message post,
resolve, chat-seen, change accept/decline/reask. All must 401 for
anonymous callers."""
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)
client.cookies.clear()
# Branch-scoped writes — slug + branch values are placeholders;
# the auth gate runs before any state lookup.
slug = "ohm"
branch = "feature-x"
assert (
client.post(
f"/api/rfcs/{slug}/branches/main/promote-to-branch",
json={},
).status_code == 401
)
assert (
client.post(
f"/api/rfcs/{slug}/branches/{branch}/manual-flush",
json={"new_content": "hi", "paragraph_count": 1},
).status_code == 401
)
assert (
client.post(
f"/api/rfcs/{slug}/branches/{branch}/visibility",
json={"read_public": False},
).status_code == 401
)
assert (
client.post(
f"/api/rfcs/{slug}/branches/{branch}/grants",
json={"grantee_gitea_login": "alice"},
).status_code == 401
)
assert (
client.delete(
f"/api/rfcs/{slug}/branches/{branch}/grants/alice",
).status_code == 401
)
assert (
client.post(
f"/api/rfcs/{slug}/branches/{branch}/threads",
json={"thread_kind": "chat", "anchor_kind": "whole-doc"},
).status_code == 401
)
assert (
client.post(
f"/api/rfcs/{slug}/branches/{branch}/threads/1/messages",
json={"text": "hi"},
).status_code == 401
)
assert (
client.post(
f"/api/rfcs/{slug}/branches/{branch}/threads/1/resolve",
).status_code == 401
)
assert (
client.post(
f"/api/rfcs/{slug}/branches/{branch}/chat-seen",
json={"last_seen_message_id": 1},
).status_code == 401
)
assert (
client.post(
f"/api/rfcs/{slug}/branches/{branch}/changes/1/accept",
json={"proposed": "x"},
).status_code == 401
)
assert (
client.post(
f"/api/rfcs/{slug}/branches/{branch}/changes/1/decline",
).status_code == 401
)
assert (
client.post(
f"/api/rfcs/{slug}/branches/{branch}/changes/1/reask",
).status_code == 401
)
# Chat stream — POST shaped, same auth gate.
assert (
client.post(
f"/api/rfcs/{slug}/branches/{branch}/threads/1/chat",
json={"text": "hi"},
).status_code == 401
)
def test_anonymous_super_draft_writes_refused(app_with_fake_gitea):
"""Super-draft-scoped writes: start-edit-branch and metadata. The
PR open / merge paths share the gate via api_prs.py see the
PR-flow test below for those."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
client.cookies.clear()
assert (
client.post(
"/api/rfcs/anything/start-edit-branch", json={}
).status_code == 401
)
assert (
client.post(
"/api/rfcs/anything/metadata", json={"title": "x"}
).status_code == 401
)
def test_anonymous_pr_flow_writes_refused(app_with_fake_gitea):
"""All §10 PR-flow writes — open, merge, withdraw, description,
review, seen, pr-draft, resolution-branch must 401 for anonymous."""
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)
client.cookies.clear()
slug, branch, pr = "ohm", "feature-x", 1
assert (
client.post(
f"/api/rfcs/{slug}/branches/{branch}/pr-draft"
).status_code == 401
)
assert (
client.post(
f"/api/rfcs/{slug}/branches/{branch}/open-pr",
json={"title": "t", "description": "d"},
).status_code == 401
)
assert (
client.post(
f"/api/rfcs/{slug}/prs/{pr}/seen",
json={"last_seen_message_id": 1},
).status_code == 401
)
assert (
client.post(
f"/api/rfcs/{slug}/prs/{pr}/review",
json={"text": "x", "anchor_payload": {}},
).status_code == 401
)
assert (
client.post(
f"/api/rfcs/{slug}/prs/{pr}/merge"
).status_code == 401
)
assert (
client.post(
f"/api/rfcs/{slug}/prs/{pr}/withdraw"
).status_code == 401
)
assert (
client.post(
f"/api/rfcs/{slug}/prs/{pr}/description",
json={"title": "t", "description": "d"},
).status_code == 401
)
assert (
client.post(
f"/api/rfcs/{slug}/prs/{pr}/resolution-branch"
).status_code == 401
)
def test_anonymous_discussion_writes_refused(app_with_fake_gitea):
"""The v0.5.0 PR-less discussion surface — write gates must hold.
This duplicates the assertion in `test_discussion_vertical.py` and
keeps it here too as the canonical home for the item #4 audit."""
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)
client.cookies.clear()
assert (
client.post(
"/api/rfcs/ohm/discussion/threads",
json={"message": "drive-by"},
).status_code == 401
)
assert (
client.post(
"/api/rfcs/ohm/discussion/threads/1/messages",
json={"text": "drive-by"},
).status_code == 401
)
assert (
client.post(
"/api/rfcs/ohm/discussion/threads/1/resolve"
).status_code == 401
)
def test_anonymous_admin_writes_refused(app_with_fake_gitea):
"""Admin surfaces — role, mute, allowlist — refuse anonymous.
The auth check runs before the require_admin role check, so the
response is 401."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
client.cookies.clear()
assert (
client.post(
"/api/admin/users/1/role", json={"role": "admin"}
).status_code == 401
)
assert (
client.post(
"/api/admin/users/1/mute", json={"muted": True}
).status_code == 401
)
assert (
client.post(
"/api/admin/allowlist", json={"email": "x@y.z"}
).status_code == 401
)
assert (
client.delete("/api/admin/allowlist/x@y.z").status_code == 401
)
# Admin reads also gated.
assert client.get("/api/admin/users").status_code == 401
assert client.get("/api/admin/audit").status_code == 401
assert client.get("/api/admin/permission-events").status_code == 401
assert client.get("/api/admin/graduation-queue").status_code == 401
assert client.get("/api/admin/allowlist").status_code == 401
def test_anonymous_notification_writes_refused(app_with_fake_gitea):
"""Notification preference / watch / mark-read / user-mute writes —
all per-user surfaces, all require an authenticated viewer."""
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)
client.cookies.clear()
assert (
client.post(
"/api/users/me/notification-preferences",
json={"email_personal_direct": False},
).status_code == 401
)
assert (
client.post(
"/api/users/me/quiet-hours",
json={"start": None, "end": None, "timezone": None},
).status_code == 401
)
assert (
client.post("/api/rfcs/ohm/watch", json={"state": "watching"}).status_code
== 401
)
assert client.post("/api/notifications/1/read").status_code == 401
assert (
client.post("/api/notifications/read", json={}).status_code == 401
)
assert client.post("/api/users/1/notification-mute").status_code == 401
assert client.delete("/api/users/1/notification-mute").status_code == 401
def test_anonymous_funder_writes_refused(app_with_fake_gitea):
"""§6.7 funder credential + consent writes — registering a key,
consenting to fund all refuse anonymous callers."""
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)
client.cookies.clear()
assert (
client.post(
"/api/users/me/funder/credentials",
json={"provider": "anthropic", "api_key": "sk-test"},
).status_code == 401
)
assert (
client.delete(
"/api/users/me/funder/credentials/anthropic"
).status_code == 401
)
assert (
client.post("/api/rfcs/ohm/funder/consent").status_code == 401
)
assert (
client.delete("/api/rfcs/ohm/funder/consent").status_code == 401
)
def test_anonymous_graduation_writes_refused(app_with_fake_gitea):
"""§13 graduation: the POST kickoff and POST claim both refuse
anonymous. The progress SSE was gated to require_user in v0.6.0
(item #4) since it surfaces admin-internal step detail."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
client.cookies.clear()
assert (
client.post(
"/api/rfcs/anything/graduate",
json={
"rfc_id": "RFC-0001",
"repo_name": "rfc-0001-x",
"owners": ["alice"],
},
).status_code == 401
)
assert client.post("/api/rfcs/anything/claim").status_code == 401
# v0.6.0 tightening: progress SSE now requires require_user.
# No graduation is in flight, but the auth check runs first.
assert (
client.get("/api/rfcs/anything/graduate/progress").status_code == 401
)
def test_anonymous_can_read_published_pr_view(app_with_fake_gitea):
"""The PR review page is §11.3 universal-public — once a PR is
open, anonymous viewers can read it. This guards against a
regression where the read endpoint accidentally grows an auth
gate."""
from fastapi.testclient import TestClient
from app import db
app, fake = app_with_fake_gitea
with TestClient(app) as client:
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
# Seed an open PR row directly — the cache shape is enough for
# the read endpoint; the live Gitea fetch falls back gracefully.
db.conn().execute(
"""
INSERT INTO cached_prs
(rfc_slug, pr_kind, repo, pr_number, title, description, state,
opened_by, opened_at, head_branch, base_branch, head_sha)
VALUES ('ohm', 'rfc_branch', 'wiggleverse/rfc-0001-ohm', 7, 't', 'd',
'open', 'alice', datetime('now'), 'feature-x', 'main', 'sha7')
"""
)
client.cookies.clear()
# Anonymous read on an open PR: should be 200. The endpoint may
# surface a partial response (the FakeGitea won't have the head
# branch's RFC.md, so branch_body falls back to empty) but the
# auth gate must let the read through.
r = client.get("/api/rfcs/ohm/prs/7")
assert r.status_code == 200
body = r.json()
assert body["capabilities"]["is_anonymous"] is True
assert body["capabilities"]["can_merge"] is False
assert body["capabilities"]["can_post_review"] is False
+390
View File
@@ -0,0 +1,390 @@
"""End-to-end integration tests for v0.8.0's open beta-access request
flow (§6.1 / §14.1, roadmap item #6).
The release replaces v0.3.0's `allowed_emails` allowlist as the
admission gate. Any valid email can sign in via the v0.7.0 OTC flow;
a fresh user lands in `permission_state='pending'` until an admin
grants access. The first-OTC flow captures first name, last name,
and a free-text "why I should be included in the beta" via a new
`POST /api/auth/me/beta-request` endpoint.
The tests prove:
* A fresh OTC user lands `permission_state='pending'` with empty
profile fields, and the verify-response carries `needs_profile=true`.
* `POST /api/auth/me/beta-request` populates the three fields and
leaves the row in `pending`.
* A pending user is refused write endpoints (representative
samples: propose RFC, post discussion thread). The refusal is
403 (not 401 they're authenticated, just not granted).
* An admin-grant flow promotes pending granted. v0.8.0 doesn't
ship an admin UI for this (deferred to item #7 / v0.9.0), so
the test flips the column directly via DB and asserts that
`require_contributor` now admits the user.
* A grandfathered user (existing row pre-migration, default
`permission_state='granted'`) is unaffected write endpoints
accept them.
* The `/auth/otc/request` endpoint accepts any email the
v0.7.0 allowlist gate is gone from this path. The `allowed_emails`
table stays in the schema; the admin UI from v0.3.0 continues to
manage it for the fast-path bypass deployments may use.
"""
from __future__ import annotations
import pytest
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]:
"""Pluck the code line from every OTC envelope in the test buffer."""
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
# ---------------------------------------------------------------------------
# Fresh OTC sign-in lands pending with empty fields
# ---------------------------------------------------------------------------
def test_fresh_otc_user_lands_pending_with_empty_profile(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
# Request + verify the OTC.
r = client.post("/auth/otc/request", json={"email": "newcomer@example.com"})
assert r.status_code == 200, r.text
code = _outbound_otc_codes("newcomer@example.com")[-1]
r = client.post("/auth/otc/verify", json={"email": "newcomer@example.com", "code": code})
assert r.status_code == 200, r.text
body = r.json()
# The verify response carries the new fields v0.8.0 added.
assert body["needs_profile"] is True
assert body["user"]["permission_state"] == "pending"
# The row reflects the same: pending state, no profile yet.
row = db.conn().execute(
"SELECT permission_state, first_name, last_name, beta_request_reason FROM users WHERE email = ? COLLATE NOCASE",
("newcomer@example.com",),
).fetchone()
assert row is not None
assert row["permission_state"] == "pending"
assert row["first_name"] is None
assert row["last_name"] is None
assert row["beta_request_reason"] is None
# /api/auth/me surfaces the same shape.
me = client.get("/api/auth/me").json()
assert me["authenticated"] is True
assert me["user"]["permission_state"] == "pending"
assert me["user"]["needs_profile"] is True
assert me["user"]["first_name"] == ""
assert me["user"]["last_name"] == ""
assert me["user"]["beta_request_reason"] == ""
# ---------------------------------------------------------------------------
# beta-request endpoint captures the fields and leaves state pending
# ---------------------------------------------------------------------------
def test_beta_request_populates_fields_keeps_state_pending(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
# Sign in the fresh user via the full OTC flow.
client.post("/auth/otc/request", json={"email": "alice@example.com"})
code = _outbound_otc_codes("alice@example.com")[-1]
client.post("/auth/otc/verify", json={"email": "alice@example.com", "code": code})
# Submit the capture form.
r = client.post(
"/api/auth/me/beta-request",
json={
"first_name": "Alice",
"last_name": "Liddell",
"beta_request_reason": "I want to help write the RFCs.",
},
)
assert r.status_code == 200, r.text
# The row reflects the captured fields; state stays pending.
row = db.conn().execute(
"SELECT permission_state, first_name, last_name, beta_request_reason FROM users WHERE email = ? COLLATE NOCASE",
("alice@example.com",),
).fetchone()
assert row["permission_state"] == "pending"
assert row["first_name"] == "Alice"
assert row["last_name"] == "Liddell"
assert row["beta_request_reason"] == "I want to help write the RFCs."
# /api/auth/me now reports needs_profile=false (fields are set).
me = client.get("/api/auth/me").json()
assert me["user"]["permission_state"] == "pending"
assert me["user"]["needs_profile"] is False
assert me["user"]["first_name"] == "Alice"
def test_beta_request_refuses_anonymous(app_with_fake_gitea):
"""The endpoint requires authentication — an anonymous caller can't
file a request without first signing in via OTC."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
client.cookies.clear()
r = client.post(
"/api/auth/me/beta-request",
json={"first_name": "A", "last_name": "B", "beta_request_reason": "Hi"},
)
assert r.status_code == 401
def test_beta_request_refuses_granted_user(app_with_fake_gitea):
"""A grandfathered (already granted) user has no business filing a
beta request. The endpoint refuses with 409 so the client can
distinguish the failure from "we don't know you" (401)."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="grandfathered", role="contributor")
sign_in_as(
client,
user_id=1,
gitea_login="grandfathered",
display_name="Grandfathered",
role="contributor",
)
r = client.post(
"/api/auth/me/beta-request",
json={"first_name": "G", "last_name": "F", "beta_request_reason": "x"},
)
assert r.status_code == 409
# ---------------------------------------------------------------------------
# Pending user is refused write endpoints; admin grant promotes them
# ---------------------------------------------------------------------------
def test_pending_user_is_refused_write_endpoints(app_with_fake_gitea):
"""A pending user can read everything anonymous can read, but every
write-shaped endpoint refuses with 403. The refusal shape mirrors
the v0.6.0 / item #4 audit's anon-401 — both are "no contributor
capability"; pending is the authenticated-but-ungranted variant.
Representative samples: propose RFC, post discussion thread.
"""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
# Sign in via fresh OTC — lands pending.
client.post("/auth/otc/request", json={"email": "pending@example.com"})
code = _outbound_otc_codes("pending@example.com")[-1]
client.post("/auth/otc/verify", json={"email": "pending@example.com", "code": code})
# Reads work — every anonymous surface stays reachable.
assert client.get("/api/health").status_code == 200
assert client.get("/api/rfcs").status_code == 200
assert client.get("/api/philosophy").status_code == 200
# Propose — write-shaped, refused with 403.
r = client.post(
"/api/rfcs/propose",
json={"title": "T", "slug": "t", "pitch": "p", "tags": []},
)
assert r.status_code == 403
# The error body mentions the review state so a UI surface can
# render the right message — but the test asserts only on the
# status code (the body shape is the FastAPI default detail).
def test_admin_grant_promotes_pending_to_granted(app_with_fake_gitea):
"""v0.8.0 doesn't ship an admin UI for this — it's deferred to
item #7 / v0.9.0. For this release, an admin gesture is an
`UPDATE users SET permission_state='granted' WHERE email=?`. The
test flips the column directly via DB and asserts the
`require_contributor` gate now admits the user.
"""
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
# Sign in a fresh OTC user — lands pending.
client.post("/auth/otc/request", json={"email": "promoted@example.com"})
code = _outbound_otc_codes("promoted@example.com")[-1]
client.post("/auth/otc/verify", json={"email": "promoted@example.com", "code": code})
# Before the grant: propose refused with 403.
r = client.post(
"/api/rfcs/propose",
json={"title": "T", "slug": "t-pre", "pitch": "p", "tags": []},
)
assert r.status_code == 403
# The admin gesture (v0.8.0 shape — direct UPDATE; v0.9.0 will
# ship a UI). The test stamps `permission_decided_by` and
# `permission_decided_at` as the v0.9.0 admin UI will, so the
# column population exercises the schema slot. user_id=99 is
# a placeholder admin row — provision it so the FK resolves.
provision_user_row(user_id=99, login="adminuser", role="admin")
db.conn().execute(
"""
UPDATE users
SET permission_state = 'granted',
permission_decided_by = 99,
permission_decided_at = datetime('now')
WHERE email = ?
""",
("promoted@example.com",),
)
# The next request reads the fresh column from the DB. The
# propose endpoint reaches the route body now (it then refuses
# for a different reason — the slug 't-prop' will fail
# the slug-format check or hit a mock-gitea path — but the
# status code is _not_ 403/401, which is the v0.8.0 assertion).
r = client.post(
"/api/rfcs/propose",
json={"title": "Title", "slug": "tprop", "pitch": "Pitch text.", "tags": []},
)
assert r.status_code != 403, r.text
assert r.status_code != 401, r.text
def test_grandfathered_user_is_unaffected_by_migration(app_with_fake_gitea):
"""An existing `users` row at migration time has
`permission_state='granted'` via the column default. The
grandfathered user passes write endpoints without filing a
beta request and without the admin UI. v0.6.0 (anon-write
audit) is the v0.6.0 contract; v0.8.0 widens the gate but
does not break this case.
"""
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=5, login="oldhand", role="contributor")
# provision_user_row uses INSERT OR REPLACE INTO users with
# the column list it knows; permission_state is not in that
# list, so it picks up the column default ('granted') on
# insert. Confirm directly.
row = db.conn().execute(
"SELECT permission_state FROM users WHERE id = 5"
).fetchone()
assert row["permission_state"] == "granted"
sign_in_as(
client,
user_id=5,
gitea_login="oldhand",
display_name="Old Hand",
role="contributor",
)
# Propose is write-shaped; the call should not refuse on
# the permission_state gate. (Subsequent failure modes —
# e.g. mock-gitea wiring — are not the v0.8.0 concern; this
# test asserts on the gate, not the propose body's success.)
r = client.post(
"/api/rfcs/propose",
json={"title": "Title", "slug": "gf-slug", "pitch": "Pitch.", "tags": []},
)
assert r.status_code != 403, r.text
assert r.status_code != 401, r.text
# ---------------------------------------------------------------------------
# /auth/otc/request accepts any email — the v0.7.0 allowlist gate is gone
# ---------------------------------------------------------------------------
def test_otc_request_accepts_any_email_regardless_of_allowlist(app_with_fake_gitea):
"""v0.7.0 silently dropped OTC requests for emails not on the
`allowed_emails` table. v0.8.0 reverses this: the request
endpoint sends a code to any valid email; admission gates at
`permission_state` post-verify instead. The `allowed_emails`
table stays in the schema as a fast-path bypass for
deployments that want to pre-mark known-good emails (the v0.9.0
admin user-management page will collapse the two surfaces).
"""
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
# Populate the allowlist with one specific email so the v0.7.0
# gate would have engaged. v0.8.0 ignores it for the request
# path.
db.conn().execute("INSERT INTO allowed_emails (email) VALUES (?)", ("known@example.com",))
# An email NOT on the allowlist still gets a code under v0.8.0.
r = client.post("/auth/otc/request", json={"email": "stranger@example.com"})
assert r.status_code == 200
codes = _outbound_otc_codes("stranger@example.com")
assert len(codes) == 1, "OTC code must be sent regardless of allowlist state"
# The row is there and the user can complete sign-in (and will
# land in 'pending' per the other tests).
row = db.conn().execute(
"SELECT 1 FROM otc_codes WHERE email = ?",
("stranger@example.com",),
).fetchone()
assert row is not None
def test_allowlist_table_still_present_in_schema(app_with_fake_gitea):
"""The schema migration leaves the `allowed_emails` table in
place the admin UI from v0.3.0 still manages it for the
fast-path bypass deployments may use. This is a regression net
for "did the v0.8.0 cleanup accidentally drop the table"."""
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app):
# The table accepts inserts (i.e. it exists) — no schema check
# gymnastics needed.
db.conn().execute("INSERT INTO allowed_emails (email) VALUES (?)", ("kept@example.com",))
row = db.conn().execute(
"SELECT email FROM allowed_emails WHERE email = ?",
("kept@example.com",),
).fetchone()
assert row is not None
@@ -0,0 +1,205 @@
"""End-to-end tests for v0.13.0 / roadmap item #11 — cookie / privacy consent.
Covers the §17 endpoints (`GET` / `PUT /api/users/me/cookie-consent`) and
the §14.5 storage contract:
* GET on a fresh user returns no-choice-yet (recorded_at is None,
essential=True, analytics=False, other=False).
* PUT writes a row, stamps recorded_at, and the choice survives.
* PUT with `analytics=true, other=false` round-trips faithfully.
* `essential` is permanently true at the API surface a PUT that
requests essential=false is still persisted with essential=true.
* The endpoint requires authentication (401 for anon).
* A second PUT updates the existing row in place (single row per
user, recorded_at re-stamps).
* Choice persists across sign-out / sign-in.
"""
from __future__ import annotations
import pytest
from test_propose_vertical import ( # noqa: F401
FakeGitea,
app_with_fake_gitea,
provision_user_row,
sign_in_as,
tmp_env,
)
def test_get_cookie_consent_fresh_user_has_no_choice(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
r = client.get("/api/users/me/cookie-consent")
assert r.status_code == 200, r.text
body = r.json()
assert body["essential"] is True
assert body["analytics"] is False
assert body["other"] is False
assert body["recorded_at"] is None
def test_put_cookie_consent_records_choice(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
r = client.put(
"/api/users/me/cookie-consent",
json={"essential": True, "analytics": True, "other": False},
)
assert r.status_code == 200, r.text
body = r.json()
assert body["ok"] is True
assert body["essential"] is True
assert body["analytics"] is True
assert body["other"] is False
assert body["recorded_at"] is not None
# Round-trip the read endpoint.
r = client.get("/api/users/me/cookie-consent")
body = r.json()
assert body["essential"] is True
assert body["analytics"] is True
assert body["other"] is False
assert body["recorded_at"] is not None
def test_put_cookie_consent_forces_essential_true(app_with_fake_gitea):
"""§14.5: `essential` is permanently true at the API surface. A
request that sets it to false is accepted (for symmetry with the
other two flags) but persisted as true.
"""
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=2, login="alice", role="contributor")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
r = client.put(
"/api/users/me/cookie-consent",
json={"essential": False, "analytics": False, "other": False},
)
assert r.status_code == 200, r.text
assert r.json()["essential"] is True
# Confirm at the schema layer too — the persisted row has essential=1.
row = db.conn().execute(
"SELECT essential FROM cookie_consent WHERE user_id = ?",
(2,),
).fetchone()
assert row["essential"] == 1
def test_cookie_consent_requires_auth(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
r = client.get("/api/users/me/cookie-consent")
assert r.status_code == 401, r.text
r = client.put(
"/api/users/me/cookie-consent",
json={"essential": True, "analytics": True, "other": True},
)
assert r.status_code == 401, r.text
def test_put_cookie_consent_upserts_in_place(app_with_fake_gitea):
"""A second PUT updates the existing row rather than inserting a new
one. Verifies the §14.5 single-row-per-user shape.
"""
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=2, login="alice", role="contributor")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
client.put(
"/api/users/me/cookie-consent",
json={"essential": True, "analytics": True, "other": False},
)
client.put(
"/api/users/me/cookie-consent",
json={"essential": True, "analytics": False, "other": True},
)
rows = db.conn().execute(
"SELECT analytics, other_cookies FROM cookie_consent WHERE user_id = ?",
(2,),
).fetchall()
assert len(rows) == 1
assert rows[0]["analytics"] == 0
assert rows[0]["other_cookies"] == 1
def test_cookie_consent_persists_across_sign_out_in(app_with_fake_gitea):
"""§14.5 precedence: the server row survives sign-out / sign-in.
"""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
client.put(
"/api/users/me/cookie-consent",
json={"essential": True, "analytics": True, "other": True},
)
# Simulate sign-out by clearing the session cookie.
client.cookies.clear()
# Anonymous viewer cannot read.
r = client.get("/api/users/me/cookie-consent")
assert r.status_code == 401
# Sign back in as Alice. The server row is still there.
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
r = client.get("/api/users/me/cookie-consent")
body = r.json()
assert body["analytics"] is True
assert body["other"] is True
assert body["recorded_at"] is not None
def test_two_users_have_independent_rows(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
provision_user_row(user_id=3, login="bob", role="contributor")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
client.put(
"/api/users/me/cookie-consent",
json={"essential": True, "analytics": True, "other": False},
)
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob", role="contributor")
client.put(
"/api/users/me/cookie-consent",
json={"essential": True, "analytics": False, "other": False},
)
# Each user reads their own row.
r = client.get("/api/users/me/cookie-consent").json()
assert r["analytics"] is False # Bob's
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
r = client.get("/api/users/me/cookie-consent").json()
assert r["analytics"] is True # Alice's
+494
View File
@@ -0,0 +1,494 @@
"""End-to-end integration tests for the v0.11.0 trust-device vertical
(§6.2, roadmap item #9).
After a successful OTC or passcode sign-in with `trust_device=true`
on the body, the server mints a fresh `device_trust` row and sets the
`rfc_device_trust` cookie. On a subsequent visit, the cookie carries
a session re-established by `POST /auth/device-trust/start`. The
tests below prove:
* `trust_device=false` (default, including omitted) on OTC verify
does NOT set the device-trust cookie and does NOT insert a row.
* `trust_device=true` on OTC verify DOES set the cookie (HttpOnly +
Secure + SameSite=Lax + 30-day Max-Age) and DOES insert a row.
The row's hash is NOT the raw token; only the hash lives in the
database.
* Same shape for passcode verify.
* On a returning visit with the cookie, `POST /auth/device-trust/start`
re-establishes the session `GET /api/auth/me` reads the right
user without an OTC roundtrip.
* `last_seen_at` refreshes on a successful lookup.
* `POST /auth/device-trust/start` with no cookie returns 401.
* `POST /auth/device-trust/start` with a forged / unknown cookie
returns 401 + clears the cookie.
* A revoked row refuses the cookie (401) and clears it.
* An expired row refuses the cookie (401) and clears it.
* `GET /api/auth/me/devices` lists the user's active rows.
* `DELETE /api/auth/me/devices/{id}` revokes a single row.
* `DELETE /api/auth/me/devices/{id}` for another user's row reads 404.
* `DELETE /api/auth/me/devices` revokes every active row.
* Constant-time path: bcrypt.checkpw guards lookup; the raw token
is never written to logs or to the DB.
The fakes from `test_propose_vertical` give us a working app harness.
The OTC envelope buffer from `test_otc_vertical` is reused for the
OTC roundtrips this suite needs.
"""
from __future__ import annotations
import pytest
from test_propose_vertical import ( # noqa: F401
FakeGitea,
app_with_fake_gitea,
provision_user_row,
tmp_env,
)
# ---------------------------------------------------------------------------
# Helpers — mirror the OTC suite's outbound-buffer helpers.
# ---------------------------------------------------------------------------
COOKIE_NAME = "rfc_device_trust"
# The device-trust cookie is set with Secure=True, which httpx (the
# TestClient's underlying transport) will only return on an https
# scheme. We use a `base_url="https://testserver"` so the cookie
# roundtrips faithfully — that mirrors how production deployments
# serve the framework (per the v0.11.0 upgrade-step requiring HTTPS).
HTTPS_BASE = "https://testserver"
def _reset_outbound():
from app import email as email_mod
email_mod.reset_sent_envelopes()
def _outbound_otc_codes(to_address: str | None = None) -> list[str]:
from app import email as email_mod
out = []
for env in email_mod.sent_envelopes():
if env.get("kind") != "otc":
continue
if to_address is not None and env["to"] != to_address:
continue
for line in env["body"].splitlines():
tok = line.strip()
if tok.isdigit() and len(tok) == 6:
out.append(tok)
break
return out
def _sign_in_via_otc(client, email: str, *, trust_device: bool = False) -> None:
r = client.post("/auth/otc/request", json={"email": email})
assert r.status_code == 200, r.text
code = _outbound_otc_codes(email)[-1]
body = {"email": email, "code": code, "trust_device": trust_device}
r = client.post("/auth/otc/verify", json=body)
assert r.status_code == 200, r.text
def _device_rows_for_email(email: str) -> list[dict]:
from app import db
rows = db.conn().execute(
"""
SELECT dt.*
FROM device_trust dt
JOIN users u ON u.id = dt.user_id
WHERE u.email = ? COLLATE NOCASE
ORDER BY dt.id
""",
(email,),
).fetchall()
return [dict(r) for r in rows]
# ---------------------------------------------------------------------------
# trust_device flag controls cookie issuance
# ---------------------------------------------------------------------------
def test_otc_verify_without_trust_device_does_not_issue_cookie(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app, base_url=HTTPS_BASE) as client:
_reset_outbound()
_sign_in_via_otc(client, "alice@example.com", trust_device=False)
# No cookie set on the response.
assert COOKIE_NAME not in {c.name for c in client.cookies.jar}
# No row inserted.
assert _device_rows_for_email("alice@example.com") == []
def test_otc_verify_with_trust_device_issues_cookie_and_row(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app, base_url=HTTPS_BASE) as client:
_reset_outbound()
r = client.post("/auth/otc/request", json={"email": "alice@example.com"})
assert r.status_code == 200, r.text
code = _outbound_otc_codes("alice@example.com")[-1]
r = client.post(
"/auth/otc/verify",
json={"email": "alice@example.com", "code": code, "trust_device": True},
headers={"User-Agent": "Mozilla/5.0 (TestBrowser)"},
)
assert r.status_code == 200, r.text
# Cookie present on the response.
set_cookie = r.headers.get("set-cookie", "")
assert COOKIE_NAME in set_cookie
# Cookie attribute set asserts the spec'd shape. Starlette emits
# the attribute names case-insensitively (`samesite=lax`,
# `httponly`); we normalize when asserting.
lower = set_cookie.lower()
assert "httponly" in lower
assert "secure" in lower
assert "samesite=lax" in lower
assert "max-age=" in lower
# Row inserted; hash is not the raw token.
rows = _device_rows_for_email("alice@example.com")
assert len(rows) == 1
row = rows[0]
assert row["revoked_at"] is None
assert row["user_agent"] == "Mozilla/5.0 (TestBrowser)"
cookie_token = client.cookies.get(COOKIE_NAME)
assert cookie_token
assert cookie_token != row["device_token_hash"]
# bcrypt hash shape (starts with $2)
assert row["device_token_hash"].startswith("$2")
def test_passcode_verify_with_trust_device_issues_cookie(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app, base_url=HTTPS_BASE) as client:
_reset_outbound()
_sign_in_via_otc(client, "alice@example.com")
# Set a passcode.
r = client.post("/auth/passcode/set", json={"passcode": "secret123"})
assert r.status_code == 200, r.text
# Sign out so the passcode verify path is the active sign-in.
client.cookies.clear()
# Passcode verify with trust_device=true issues a row.
r = client.post(
"/auth/passcode/verify",
json={"email": "alice@example.com", "passcode": "secret123", "trust_device": True},
headers={"User-Agent": "Test/Phone"},
)
assert r.status_code == 200, r.text
set_cookie = r.headers.get("set-cookie", "")
assert COOKIE_NAME in set_cookie
rows = _device_rows_for_email("alice@example.com")
assert len(rows) == 1
assert rows[0]["user_agent"] == "Test/Phone"
# ---------------------------------------------------------------------------
# /auth/device-trust/start
# ---------------------------------------------------------------------------
def test_device_trust_start_with_no_cookie_returns_401(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app, base_url=HTTPS_BASE) as client:
r = client.post("/auth/device-trust/start")
assert r.status_code == 401
def test_device_trust_start_with_valid_cookie_establishes_session(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app, base_url=HTTPS_BASE) as client:
_reset_outbound()
# Trust the device.
r = client.post("/auth/otc/request", json={"email": "alice@example.com"})
assert r.status_code == 200, r.text
code = _outbound_otc_codes("alice@example.com")[-1]
r = client.post(
"/auth/otc/verify",
json={"email": "alice@example.com", "code": code, "trust_device": True},
)
assert r.status_code == 200, r.text
trust_cookie = client.cookies.get(COOKIE_NAME)
assert trust_cookie
# Clear the session cookie so only the device-trust cookie is in
# play. We keep `rfc_device_trust` and drop `rfc_session`.
for cookie in list(client.cookies.jar):
if cookie.name != COOKIE_NAME:
client.cookies.jar.clear(cookie.domain, cookie.path, cookie.name)
# The session cookie is gone — /api/auth/me reads anonymous.
me = client.get("/api/auth/me").json()
assert me["authenticated"] is False
# Hit the trust-start endpoint; the cookie re-establishes the session.
r = client.post("/auth/device-trust/start")
assert r.status_code == 200, r.text
assert r.json()["user"]["email"] == "alice@example.com"
# /api/auth/me now reads authenticated.
me = client.get("/api/auth/me").json()
assert me["authenticated"] is True
assert me["user"]["email"] == "alice@example.com"
def test_device_trust_start_refreshes_last_seen_at(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app, base_url=HTTPS_BASE) as client:
_reset_outbound()
r = client.post("/auth/otc/request", json={"email": "alice@example.com"})
assert r.status_code == 200, r.text
code = _outbound_otc_codes("alice@example.com")[-1]
r = client.post(
"/auth/otc/verify",
json={"email": "alice@example.com", "code": code, "trust_device": True},
)
assert r.status_code == 200, r.text
# Force the existing row's last_seen_at into the past so we can
# assert the refresh moved it forward.
db.conn().execute(
"""
UPDATE device_trust
SET last_seen_at = datetime('now', '-7 days')
"""
)
# Hit the start endpoint.
r = client.post("/auth/device-trust/start")
assert r.status_code == 200, r.text
# last_seen_at is now recent (within the last minute).
row = db.conn().execute(
"SELECT last_seen_at, datetime('now') >= datetime(last_seen_at, '-1 minute') AS fresh FROM device_trust LIMIT 1"
).fetchone()
assert row["fresh"] == 1
def test_device_trust_start_with_revoked_row_refuses_and_clears(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app, base_url=HTTPS_BASE) as client:
_reset_outbound()
r = client.post("/auth/otc/request", json={"email": "alice@example.com"})
assert r.status_code == 200, r.text
code = _outbound_otc_codes("alice@example.com")[-1]
r = client.post(
"/auth/otc/verify",
json={"email": "alice@example.com", "code": code, "trust_device": True},
)
assert r.status_code == 200, r.text
# Revoke the row out-of-band.
db.conn().execute(
"UPDATE device_trust SET revoked_at = datetime('now')"
)
# Now the start endpoint refuses + clears the cookie.
r = client.post("/auth/device-trust/start")
assert r.status_code == 401
# The cookie is cleared via a Set-Cookie header with Max-Age=0
# (Starlette's `delete_cookie` shape).
set_cookie = r.headers.get("set-cookie", "")
assert COOKIE_NAME in set_cookie
assert "Max-Age=0" in set_cookie or 'expires=Thu, 01 Jan 1970' in set_cookie.lower().replace("expires=thu", "expires=Thu")
def test_device_trust_start_with_expired_row_refuses_and_clears(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app, base_url=HTTPS_BASE) as client:
_reset_outbound()
r = client.post("/auth/otc/request", json={"email": "alice@example.com"})
assert r.status_code == 200, r.text
code = _outbound_otc_codes("alice@example.com")[-1]
r = client.post(
"/auth/otc/verify",
json={"email": "alice@example.com", "code": code, "trust_device": True},
)
assert r.status_code == 200, r.text
# Backdate the expiry into the past.
db.conn().execute(
"UPDATE device_trust SET expires_at = datetime('now', '-1 day')"
)
r = client.post("/auth/device-trust/start")
assert r.status_code == 401
set_cookie = r.headers.get("set-cookie", "")
assert COOKIE_NAME in set_cookie
def test_device_trust_start_with_forged_cookie_refuses_and_clears(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app, base_url=HTTPS_BASE) as client:
# No real row; just paste a cookie value.
client.cookies.set(COOKIE_NAME, "definitely-not-a-real-token-value-xxx")
r = client.post("/auth/device-trust/start")
assert r.status_code == 401
set_cookie = r.headers.get("set-cookie", "")
assert COOKIE_NAME in set_cookie
# ---------------------------------------------------------------------------
# /api/auth/me/devices — list + revoke
# ---------------------------------------------------------------------------
def test_list_devices_requires_session(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app, base_url=HTTPS_BASE) as client:
r = client.get("/api/auth/me/devices")
assert r.status_code == 401
def test_list_devices_returns_active_rows_only(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app, base_url=HTTPS_BASE) as client:
_reset_outbound()
_sign_in_via_otc(client, "alice@example.com", trust_device=True)
# Add a second trusted device by re-running the verify flow.
# OTC has a per-email cooldown, so drop the cooldown rather
# than waiting it out.
db.conn().execute("UPDATE otc_codes SET consumed_at = datetime('now', '-1 hour'), created_at = datetime('now', '-1 hour')")
r = client.post("/auth/otc/request", json={"email": "alice@example.com"})
assert r.status_code == 200, r.text
code = _outbound_otc_codes("alice@example.com")[-1]
r = client.post(
"/auth/otc/verify",
json={"email": "alice@example.com", "code": code, "trust_device": True},
headers={"User-Agent": "Test/Tablet"},
)
assert r.status_code == 200, r.text
# Revoke one row directly.
db.conn().execute(
"UPDATE device_trust SET revoked_at = datetime('now') WHERE id = 1"
)
# /api/auth/me/devices returns only the un-revoked one.
r = client.get("/api/auth/me/devices")
assert r.status_code == 200, r.text
items = r.json()["items"]
assert len(items) == 1
assert items[0]["user_agent"] == "Test/Tablet"
def test_revoke_single_device_kills_the_row(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app, base_url=HTTPS_BASE) as client:
_reset_outbound()
_sign_in_via_otc(client, "alice@example.com", trust_device=True)
r = client.get("/api/auth/me/devices")
assert r.status_code == 200, r.text
items = r.json()["items"]
assert len(items) == 1
device_id = items[0]["id"]
# Revoke it.
r = client.delete(f"/api/auth/me/devices/{device_id}")
assert r.status_code == 200, r.text
# List is empty.
r = client.get("/api/auth/me/devices")
assert r.json()["items"] == []
# The row in the table has revoked_at populated.
row = db.conn().execute(
"SELECT revoked_at FROM device_trust WHERE id = ?", (device_id,)
).fetchone()
assert row["revoked_at"] is not None
def test_revoke_other_users_device_reads_404(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app, base_url=HTTPS_BASE) as client:
_reset_outbound()
# Alice trusts a device.
_sign_in_via_otc(client, "alice@example.com", trust_device=True)
alice_device_id = client.get("/api/auth/me/devices").json()["items"][0]["id"]
# Bob signs in (without a trusted device of his own).
client.cookies.clear()
db.conn().execute("UPDATE otc_codes SET consumed_at = datetime('now', '-1 hour'), created_at = datetime('now', '-1 hour')")
_sign_in_via_otc(client, "bob@example.com", trust_device=False)
# Bob tries to revoke Alice's row by id.
r = client.delete(f"/api/auth/me/devices/{alice_device_id}")
assert r.status_code == 404
# Alice's row is still active.
row = db.conn().execute(
"SELECT revoked_at FROM device_trust WHERE id = ?", (alice_device_id,)
).fetchone()
assert row["revoked_at"] is None
def test_revoke_all_devices_kills_every_active_row(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app, base_url=HTTPS_BASE) as client:
_reset_outbound()
_sign_in_via_otc(client, "alice@example.com", trust_device=True)
# Add a second device.
db.conn().execute("UPDATE otc_codes SET consumed_at = datetime('now', '-1 hour'), created_at = datetime('now', '-1 hour')")
r = client.post("/auth/otc/request", json={"email": "alice@example.com"})
assert r.status_code == 200, r.text
code = _outbound_otc_codes("alice@example.com")[-1]
r = client.post(
"/auth/otc/verify",
json={"email": "alice@example.com", "code": code, "trust_device": True},
)
assert r.status_code == 200, r.text
# Two active rows.
assert len(client.get("/api/auth/me/devices").json()["items"]) == 2
# Revoke all.
r = client.delete("/api/auth/me/devices")
assert r.status_code == 200, r.text
assert r.json()["revoked"] == 2
# List is empty.
assert client.get("/api/auth/me/devices").json()["items"] == []
+349
View File
@@ -0,0 +1,349 @@
"""End-to-end integration tests for the v0.7.0 email/OTC sign-in
vertical (§6.2).
The release replaces the Gitea OAuth gesture as the primary human
sign-in path. The tests prove:
* `/auth/otc/request` is rate-limited per-email back-to-back
requests inside `OTC_REQUEST_COOLDOWN_SECONDS` are refused with
429 (the loud-failure shape the spec calls out).
* The happy path: request code lands in the outbound buffer
verify with the code session cookie surfaces an authenticated
user via `/api/auth/me`.
* Expired codes refuse with 400.
* Already-consumed codes refuse with 400 on re-use.
* Wrong codes refuse with 400.
* Allowlist gate (v0.8.0 update): v0.7.0 silently dropped requests
for emails not on `allowed_emails`. v0.8.0 (item #6) removed
that gate from the request path; the admission gate is now
`permission_state` on the freshly-provisioned `users` row,
asserted in test_beta_access_vertical.py. The tests below
confirm v0.8.0's open-request shape for both on-list and
off-list emails.
* Migration link: an existing OAuth-era user (with a `users.email`
row) is linked by email on first OTC sign-in `gitea_id` is
preserved.
* Provisioning path: an unrecognized email creates a fresh
contributor row with `gitea_id = NULL`.
The Gitea bot user + token are still required at process construction
(every test harness sets the same `GITEA_*` env vars); the OTC flow
itself never reaches Gitea. The fakes from `test_propose_vertical`
remain in scope so the rest of the app boots cleanly.
"""
from __future__ import annotations
import pytest
from test_propose_vertical import ( # noqa: F401
FakeGitea,
app_with_fake_gitea,
provision_user_row,
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]:
"""Pluck the `code` line out of every OTC email in the test buffer.
The OTC mailer stamps `kind='otc'` on the envelope so the §15.4
notification mailer's envelopes (the unsubscribe-footer shape)
don't accidentally satisfy the assertion. Each envelope's body
carries the code on its own indented line; this helper extracts
just that token so the test reads the same way the user would
read the email.
"""
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
# ---------------------------------------------------------------------------
# Happy path
# ---------------------------------------------------------------------------
def test_otc_request_then_verify_signs_in_a_fresh_user(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
# Request: 202 + a single OTC envelope to the requested address.
r = client.post("/auth/otc/request", json={"email": "newcomer@example.com"})
assert r.status_code == 200, r.text
codes = _outbound_otc_codes("newcomer@example.com")
assert len(codes) == 1
code = codes[0]
# Verify: 200 + session cookie + me-shape now reads authenticated.
r = client.post("/auth/otc/verify", json={"email": "newcomer@example.com", "code": code})
assert r.status_code == 200, r.text
me = client.get("/api/auth/me").json()
assert me["authenticated"] is True
assert me["user"]["email"] == "newcomer@example.com"
# Fresh provisioning: no gitea linker. The display name is the
# local part of the email per §6.2.
assert me["user"]["role"] == "contributor"
assert me["user"]["display_name"] == "newcomer"
# The `users` row reflects the same: gitea_id NULL, email set.
from app import db
row = db.conn().execute(
"SELECT gitea_id, email FROM users WHERE email = ? COLLATE NOCASE",
("newcomer@example.com",),
).fetchone()
assert row is not None
assert row["gitea_id"] is None
assert row["email"] == "newcomer@example.com"
# ---------------------------------------------------------------------------
# Failure modes on verify
# ---------------------------------------------------------------------------
def test_otc_verify_refuses_wrong_code(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
client.post("/auth/otc/request", json={"email": "alice@example.com"})
r = client.post("/auth/otc/verify", json={"email": "alice@example.com", "code": "000000"})
assert r.status_code == 400
def test_otc_verify_refuses_consumed_code(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
client.post("/auth/otc/request", json={"email": "alice@example.com"})
code = _outbound_otc_codes("alice@example.com")[-1]
# First verify succeeds.
r1 = client.post("/auth/otc/verify", json={"email": "alice@example.com", "code": code})
assert r1.status_code == 200
# Drop the session cookie so the re-verify reads as fresh.
client.cookies.clear()
# Second verify with the same code is refused — `consumed_at`
# stamped on the row blocks the replay.
r2 = client.post("/auth/otc/verify", json={"email": "alice@example.com", "code": code})
assert r2.status_code == 400
def test_otc_verify_refuses_expired_code(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
client.post("/auth/otc/request", json={"email": "alice@example.com"})
code = _outbound_otc_codes("alice@example.com")[-1]
# Backdate the row's expires_at to the past. The TTL setting is
# an env var (default 10 min); rather than waiting, the test
# rewrites the row.
db.conn().execute(
"UPDATE otc_codes SET expires_at = datetime('now', '-1 minute') WHERE email = ?",
("alice@example.com",),
)
r = client.post("/auth/otc/verify", json={"email": "alice@example.com", "code": code})
assert r.status_code == 400
# ---------------------------------------------------------------------------
# Rate limiting
# ---------------------------------------------------------------------------
def test_otc_request_rate_limited_per_email(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
r1 = client.post("/auth/otc/request", json={"email": "alice@example.com"})
assert r1.status_code == 200
# Cooldown defaults to 60s; the second back-to-back call is
# refused with a loud 429.
r2 = client.post("/auth/otc/request", json={"email": "alice@example.com"})
assert r2.status_code == 429
# The buffer still has exactly one envelope — the rate-limited
# call didn't double-send.
assert len(_outbound_otc_codes("alice@example.com")) == 1
def test_otc_request_cooldown_is_per_email_not_global(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
r1 = client.post("/auth/otc/request", json={"email": "alice@example.com"})
assert r1.status_code == 200
# Different email, fresh cooldown.
r2 = client.post("/auth/otc/request", json={"email": "bob@example.com"})
assert r2.status_code == 200
# ---------------------------------------------------------------------------
# Allowlist gate — v0.8.0 update
#
# v0.7.0 gated the OTC request endpoint on the `allowed_emails` table:
# emails not on the list got a silent drop (still 202, but no code).
# v0.8.0 (roadmap item #6) reverses this: the request endpoint
# accepts any valid email and sends a code. The admission gate moves
# to `permission_state` on the freshly-provisioned `users` row,
# which the next-tier tests in test_beta_access_vertical.py cover.
# The `allowed_emails` table stays in the schema as a fast-path
# bypass for admin convenience.
# ---------------------------------------------------------------------------
def test_otc_request_admits_emails_regardless_of_allowlist_population(app_with_fake_gitea):
"""v0.8.0: the OTC request path no longer consults `allowed_emails`.
Whether the allowlist is empty or populated, every valid email
receives a code; admission gates at `permission_state` post-verify.
"""
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
# Populate the allowlist with one specific email; the v0.7.0
# gate would have engaged here.
db.conn().execute("INSERT INTO allowed_emails (email) VALUES (?)", ("invited@example.com",))
# The not-on-list email still gets a code under v0.8.0.
r = client.post("/auth/otc/request", json={"email": "stranger@example.com"})
assert r.status_code == 200
assert len(_outbound_otc_codes("stranger@example.com")) == 1
def test_otc_request_admits_allowlisted_email(app_with_fake_gitea):
"""v0.8.0: still works for emails that happen to be on the legacy
allowlist the table is no longer consulted at request time but
populated rows are admitted alongside everyone else (since the
gate is now open at the request surface)."""
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
db.conn().execute("INSERT INTO allowed_emails (email) VALUES (?)", ("invited@example.com",))
r = client.post("/auth/otc/request", json={"email": "invited@example.com"})
assert r.status_code == 200
assert len(_outbound_otc_codes("invited@example.com")) == 1
# ---------------------------------------------------------------------------
# Migration path — link by email to an OAuth-era user
# ---------------------------------------------------------------------------
def test_otc_links_to_existing_oauth_user_by_email(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
# Seed an OAuth-era row. `provision_user_row` writes
# email=<login>@test, so we sign in via OTC with the matching
# email and expect the same `users.id` to come back.
provision_user_row(user_id=42, login="legacyuser", role="contributor")
existing = db.conn().execute(
"SELECT id, gitea_id FROM users WHERE id = ?", (42,)
).fetchone()
assert existing["gitea_id"] == 42 # OAuth linker is set.
r = client.post("/auth/otc/request", json={"email": "legacyuser@test"})
assert r.status_code == 200
code = _outbound_otc_codes("legacyuser@test")[-1]
r = client.post("/auth/otc/verify", json={"email": "legacyuser@test", "code": code})
assert r.status_code == 200
# /api/auth/me reports the linked user — same id, original role.
me = client.get("/api/auth/me").json()
assert me["authenticated"] is True
assert me["user"]["id"] == 42
assert me["user"]["role"] == "contributor"
# gitea_id is preserved on the linked row — the migration path
# doesn't disturb the OAuth linker.
row = db.conn().execute(
"SELECT gitea_id FROM users WHERE id = ?", (42,)
).fetchone()
assert row["gitea_id"] == 42
def test_otc_provisions_fresh_user_when_email_matches_no_one(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
r = client.post("/auth/otc/request", json={"email": "newperson@example.com"})
assert r.status_code == 200
code = _outbound_otc_codes("newperson@example.com")[-1]
r = client.post("/auth/otc/verify", json={"email": "newperson@example.com", "code": code})
assert r.status_code == 200
# A fresh row landed with NULL gitea_id (no OAuth linker).
row = db.conn().execute(
"SELECT id, gitea_id, gitea_login, role FROM users WHERE email = ? COLLATE NOCASE",
("newperson@example.com",),
).fetchone()
assert row is not None
assert row["gitea_id"] is None
assert row["gitea_login"] is None
assert row["role"] == "contributor"
# ---------------------------------------------------------------------------
# Re-request invalidates prior code
# ---------------------------------------------------------------------------
def test_otc_re_request_invalidates_prior_unused_code(app_with_fake_gitea, monkeypatch):
from fastapi.testclient import TestClient
# Drop the cooldown so the second request lands instead of 429ing.
monkeypatch.setenv("OTC_REQUEST_COOLDOWN_SECONDS", "0")
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
client.post("/auth/otc/request", json={"email": "alice@example.com"})
first = _outbound_otc_codes("alice@example.com")[-1]
client.post("/auth/otc/request", json={"email": "alice@example.com"})
second = _outbound_otc_codes("alice@example.com")[-1]
assert first != second
# The old code is invalidated — verify with `first` now refuses.
r = client.post("/auth/otc/verify", json={"email": "alice@example.com", "code": first})
assert r.status_code == 400
# The new code still works.
r = client.post("/auth/otc/verify", json={"email": "alice@example.com", "code": second})
assert r.status_code == 200
+532
View File
@@ -0,0 +1,532 @@
"""End-to-end integration tests for the v0.10.0 user-set passcode
vertical (§6.2, roadmap item #8).
After a successful OTC sign-in the user can set a passcode and use
email + passcode for subsequent sign-ins. OTC remains the structural
fallback these tests prove:
* `/auth/passcode/set` requires an active session.
* `/auth/passcode/check` returns `has_passcode` without leaking the
hash, the set-at stamp, or the lockout state.
* Happy path: OTC sign-in set passcode sign out email +
passcode signs in (no OTC roundtrip).
* Wrong passcode increments the failure counter without locking.
* Five consecutive failures lock the passcode path (HTTP 423) and
persist `passcode_locked_until` on the user row.
* The lockout expires after `passcode_locked_until`; a verify
attempt past the window succeeds again and clears the counter.
* The OTC path is unaffected by the passcode lockout a user
whose passcode is locked can still request and verify a fresh
OTC to sign in.
* Clearing the passcode wipes the hash; subsequent verify refuses
with the no-passcode failure shape.
* Setting a new passcode replaces the prior one (and resets the
failure counter / lockout state).
* `passcode_set_at` updates on every set call.
* The validation denylist refuses obvious patterns (e.g. `0000`,
`1234`).
* Passcode length is enforced (4-20).
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.
# ---------------------------------------------------------------------------
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) -> None:
"""Run an OTC request+verify so the client carries an authenticated
session. The cooldown is irrelevant on a fresh email; we don't
need to drop it."""
r = client.post("/auth/otc/request", json={"email": email})
assert r.status_code == 200, r.text
code = _outbound_otc_codes(email)[-1]
r = client.post("/auth/otc/verify", json={"email": email, "code": code})
assert r.status_code == 200, r.text
# ---------------------------------------------------------------------------
# Set passcode — auth-required, happy path
# ---------------------------------------------------------------------------
def test_set_passcode_requires_session(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
r = client.post("/auth/passcode/set", json={"passcode": "secret123"})
assert r.status_code == 401
def test_set_passcode_after_otc_landing_persists_hash(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
_sign_in_via_otc(client, "alice@example.com")
r = client.post("/auth/passcode/set", json={"passcode": "secret123"})
assert r.status_code == 200, r.text
row = db.conn().execute(
"SELECT passcode_hash, passcode_set_at FROM users WHERE email = ? COLLATE NOCASE",
("alice@example.com",),
).fetchone()
assert row is not None
assert row["passcode_hash"] is not None
# Not the plaintext.
assert row["passcode_hash"] != "secret123"
assert row["passcode_set_at"] is not None
# ---------------------------------------------------------------------------
# Check endpoint — leak-free shape
# ---------------------------------------------------------------------------
def test_check_endpoint_returns_false_for_unknown_email(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
r = client.get("/auth/passcode/check", params={"email": "nobody@example.com"})
assert r.status_code == 200
assert r.json() == {"has_passcode": False}
def test_check_endpoint_returns_false_for_user_without_passcode(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
_sign_in_via_otc(client, "bob@example.com")
r = client.get("/auth/passcode/check", params={"email": "bob@example.com"})
assert r.status_code == 200
assert r.json() == {"has_passcode": False}
def test_check_endpoint_returns_true_after_set(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
_sign_in_via_otc(client, "carol@example.com")
client.post("/auth/passcode/set", json={"passcode": "letmein9"})
# Drop the session so the check is read in the anonymous shape.
client.cookies.clear()
r = client.get("/auth/passcode/check", params={"email": "carol@example.com"})
assert r.status_code == 200
assert r.json() == {"has_passcode": True}
# The response carries ONLY the boolean — no hash, no stamp.
assert set(r.json().keys()) == {"has_passcode"}
# ---------------------------------------------------------------------------
# Verify path — happy path
# ---------------------------------------------------------------------------
def test_verify_passcode_signs_in_user(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
_sign_in_via_otc(client, "dave@example.com")
client.post("/auth/passcode/set", json={"passcode": "secret123"})
client.cookies.clear()
r = client.post(
"/auth/passcode/verify",
json={"email": "dave@example.com", "passcode": "secret123"},
)
assert r.status_code == 200, r.text
me = client.get("/api/auth/me").json()
assert me["authenticated"] is True
assert me["user"]["email"] == "dave@example.com"
assert me["user"]["has_passcode"] is True
# ---------------------------------------------------------------------------
# Verify path — failure modes
# ---------------------------------------------------------------------------
def test_verify_passcode_wrong_increments_counter_without_locking(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
_sign_in_via_otc(client, "erin@example.com")
client.post("/auth/passcode/set", json={"passcode": "secret123"})
client.cookies.clear()
# Three bad attempts — under the lockout threshold.
for _ in range(3):
r = client.post(
"/auth/passcode/verify",
json={"email": "erin@example.com", "passcode": "wrongwrong"},
)
assert r.status_code == 400
row = db.conn().execute(
"SELECT passcode_failed_attempts, passcode_locked_until FROM users WHERE email = ?",
("erin@example.com",),
).fetchone()
assert row["passcode_failed_attempts"] == 3
assert row["passcode_locked_until"] is None
def test_verify_passcode_locks_after_five_failures(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
_sign_in_via_otc(client, "frank@example.com")
client.post("/auth/passcode/set", json={"passcode": "secret123"})
client.cookies.clear()
# Five bad attempts — the last crosses the threshold and the
# response shape flips to 423.
statuses = []
for _ in range(5):
r = client.post(
"/auth/passcode/verify",
json={"email": "frank@example.com", "passcode": "wrongwrong"},
)
statuses.append(r.status_code)
# First four are 400, the fifth (threshold-crossing) is 423.
assert statuses == [400, 400, 400, 400, 423]
row = db.conn().execute(
"SELECT passcode_failed_attempts, passcode_locked_until FROM users WHERE email = ?",
("frank@example.com",),
).fetchone()
assert row["passcode_failed_attempts"] >= 5
assert row["passcode_locked_until"] is not None
# Sixth attempt — still locked, still 423, even with the correct
# passcode (lockout overrides the verify).
r = client.post(
"/auth/passcode/verify",
json={"email": "frank@example.com", "passcode": "secret123"},
)
assert r.status_code == 423
def test_verify_passcode_lockout_expires(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
_sign_in_via_otc(client, "gina@example.com")
client.post("/auth/passcode/set", json={"passcode": "secret123"})
client.cookies.clear()
for _ in range(5):
client.post(
"/auth/passcode/verify",
json={"email": "gina@example.com", "passcode": "wrongwrong"},
)
# Backdate the lockout to the past so the next attempt clears it.
db.conn().execute(
"""
UPDATE users
SET passcode_locked_until = datetime('now', '-1 minute')
WHERE email = ?
""",
("gina@example.com",),
)
r = client.post(
"/auth/passcode/verify",
json={"email": "gina@example.com", "passcode": "secret123"},
)
assert r.status_code == 200, r.text
# Lockout cleared, counter reset.
row = db.conn().execute(
"SELECT passcode_failed_attempts, passcode_locked_until FROM users WHERE email = ?",
("gina@example.com",),
).fetchone()
assert row["passcode_failed_attempts"] == 0
assert row["passcode_locked_until"] is None
def test_otc_path_unaffected_by_passcode_lockout(app_with_fake_gitea, monkeypatch):
from fastapi.testclient import TestClient
# Drop the OTC cooldown so the second request lands without a 429.
# The cooldown is re-read from env on every `request_code` call so
# this takes effect mid-process.
monkeypatch.setenv("OTC_REQUEST_COOLDOWN_SECONDS", "0")
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
_sign_in_via_otc(client, "harvey@example.com")
client.post("/auth/passcode/set", json={"passcode": "secret123"})
client.cookies.clear()
# Lock the passcode path.
for _ in range(5):
client.post(
"/auth/passcode/verify",
json={"email": "harvey@example.com", "passcode": "wrongwrong"},
)
# The OTC path is unaffected by the passcode lockout: the user
# can still request and verify a fresh code to sign in.
r = client.post("/auth/otc/request", json={"email": "harvey@example.com"})
assert r.status_code == 200
code = _outbound_otc_codes("harvey@example.com")[-1]
r = client.post("/auth/otc/verify", json={"email": "harvey@example.com", "code": code})
assert r.status_code == 200
# The user is now signed in via OTC even though the passcode
# path is locked. The /api/auth/me payload reflects this.
me = client.get("/api/auth/me").json()
assert me["authenticated"] is True
assert me["user"]["email"] == "harvey@example.com"
# ---------------------------------------------------------------------------
# Clear + replace
# ---------------------------------------------------------------------------
def test_clear_passcode_wipes_the_hash(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
_sign_in_via_otc(client, "ivy@example.com")
client.post("/auth/passcode/set", json={"passcode": "secret123"})
r = client.delete("/auth/passcode")
assert r.status_code == 200
row = db.conn().execute(
"SELECT passcode_hash, passcode_set_at FROM users WHERE email = ?",
("ivy@example.com",),
).fetchone()
assert row["passcode_hash"] is None
assert row["passcode_set_at"] is None
# Verify against the cleared passcode refuses (no-passcode shape
# collapses to a generic 400).
client.cookies.clear()
r = client.post(
"/auth/passcode/verify",
json={"email": "ivy@example.com", "passcode": "secret123"},
)
assert r.status_code == 400
def test_setting_new_passcode_replaces_old(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
_sign_in_via_otc(client, "jane@example.com")
client.post("/auth/passcode/set", json={"passcode": "secret123"})
# Replace.
r = client.post("/auth/passcode/set", json={"passcode": "newsecret9"})
assert r.status_code == 200
client.cookies.clear()
# Old passcode refuses.
r = client.post(
"/auth/passcode/verify",
json={"email": "jane@example.com", "passcode": "secret123"},
)
assert r.status_code == 400
# New passcode signs in.
r = client.post(
"/auth/passcode/verify",
json={"email": "jane@example.com", "passcode": "newsecret9"},
)
assert r.status_code == 200
def test_setting_new_passcode_resets_lockout(app_with_fake_gitea, monkeypatch):
from fastapi.testclient import TestClient
from app import db
monkeypatch.setenv("OTC_REQUEST_COOLDOWN_SECONDS", "0")
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
_sign_in_via_otc(client, "kate@example.com")
client.post("/auth/passcode/set", json={"passcode": "secret123"})
# Lock the passcode path with bad attempts (drop session first).
client.cookies.clear()
for _ in range(5):
client.post(
"/auth/passcode/verify",
json={"email": "kate@example.com", "passcode": "wrongwrong"},
)
row = db.conn().execute(
"SELECT passcode_locked_until FROM users WHERE email = ?",
("kate@example.com",),
).fetchone()
assert row["passcode_locked_until"] is not None
# Sign back in via OTC and reset the passcode.
r = client.post("/auth/otc/request", json={"email": "kate@example.com"})
assert r.status_code == 200
code = _outbound_otc_codes("kate@example.com")[-1]
r = client.post("/auth/otc/verify", json={"email": "kate@example.com", "code": code})
assert r.status_code == 200
r = client.post("/auth/passcode/set", json={"passcode": "freshcode9"})
assert r.status_code == 200
# Lockout cleared on set.
row = db.conn().execute(
"SELECT passcode_locked_until, passcode_failed_attempts FROM users WHERE email = ?",
("kate@example.com",),
).fetchone()
assert row["passcode_locked_until"] is None
assert row["passcode_failed_attempts"] == 0
def test_passcode_set_at_updates_on_each_set(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
import time
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
_sign_in_via_otc(client, "luke@example.com")
client.post("/auth/passcode/set", json={"passcode": "secret123"})
first_stamp = db.conn().execute(
"SELECT passcode_set_at FROM users WHERE email = ?",
("luke@example.com",),
).fetchone()["passcode_set_at"]
assert first_stamp is not None
# SQLite's datetime('now') has second precision; sleep so the
# stamp visibly advances on the next set.
time.sleep(1.1)
client.post("/auth/passcode/set", json={"passcode": "newcode99"})
second_stamp = db.conn().execute(
"SELECT passcode_set_at FROM users WHERE email = ?",
("luke@example.com",),
).fetchone()["passcode_set_at"]
assert second_stamp is not None
assert second_stamp >= first_stamp
# Lexicographic compare on ISO-8601 datetime strings works for
# the SQLite shape.
assert second_stamp > first_stamp
# ---------------------------------------------------------------------------
# Validation
# ---------------------------------------------------------------------------
def test_set_passcode_refuses_too_short(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
_sign_in_via_otc(client, "mia@example.com")
r = client.post("/auth/passcode/set", json={"passcode": "abc"})
assert r.status_code == 422
def test_set_passcode_refuses_denylist_pattern(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
_sign_in_via_otc(client, "nick@example.com")
for bad in ["0000", "1234", "aaaa", "qwerty", "password"]:
r = client.post("/auth/passcode/set", json={"passcode": bad})
assert r.status_code == 422, f"expected 422 for {bad!r}, got {r.status_code}"
# ---------------------------------------------------------------------------
# Auth me payload
# ---------------------------------------------------------------------------
def test_auth_me_carries_has_passcode_flag(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
_sign_in_via_otc(client, "olga@example.com")
me = client.get("/api/auth/me").json()
assert me["user"]["has_passcode"] is False
assert me["user"]["passcode_set_at"] is None
client.post("/auth/passcode/set", json={"passcode": "secret123"})
me = client.get("/api/auth/me").json()
assert me["user"]["has_passcode"] is True
assert me["user"]["passcode_set_at"] is not None
+40
View File
@@ -496,6 +496,11 @@ def test_propose_to_super_draft_vertical(app_with_fake_gitea):
proposal = r.json()
assert proposal["entry"]["title"] == "Open Human Model"
assert proposal["entry"]["state"] == "super-draft"
# §9.2: the proposer is the implicit first owner at propose time.
# The owners field is a single-element list containing exactly the
# session user's gitea_login — no request-supplied owner field
# exists or is honored.
assert proposal["entry"]["owners"] == ["alice"]
assert proposal["affordances"]["merge"] is True
# Owner merges. The catalog picks up the new super-draft.
@@ -516,6 +521,10 @@ def test_propose_to_super_draft_vertical(app_with_fake_gitea):
view = r.json()
assert view["state"] == "super-draft"
assert "shared definition" in view["body"]
# §9.2: the auto-set proposer-owner survives the meta-repo round-trip
# — it's in the file's frontmatter on main after merge, not just
# in the pending-PR view above.
assert view["owners"] == ["alice"]
# The pending-ideas list no longer carries the merged proposal.
r = client.get("/api/proposals")
@@ -568,6 +577,37 @@ def test_anonymous_cannot_propose(app_with_fake_gitea):
assert r.status_code == 401
def test_proposer_is_auto_owner_request_payload_ignored(app_with_fake_gitea):
"""§9.2: the owners field on the new entry is always exactly
`[session.gitea_login]`. The propose endpoint never accepts an owner
from the client; a request payload that smuggles one in is ignored
by the Pydantic body model and the auto-set value lands instead.
"""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=11, login="alice", role="contributor")
sign_in_as(client, user_id=11, gitea_login="alice", display_name="Alice", role="contributor")
# Extra unknown fields like `owners` are dropped by the
# ProposeBody model; the session user is the only source of truth.
r = client.post("/api/rfcs/propose", json={
"title": "Spoof attempt",
"slug": "spoof-attempt",
"pitch": "p",
"tags": [],
"owners": ["mallory", "eve"],
"proposed_by": "mallory@test",
})
assert r.status_code == 200, r.text
pr_number = r.json()["pr_number"]
r = client.get(f"/api/proposals/{pr_number}")
assert r.status_code == 200, r.text
entry = r.json()["entry"]
assert entry["owners"] == ["alice"]
# proposed_by also comes from the session, never the body.
assert entry["proposed_by"] in ("alice@test", "alice")
def test_withdraw_by_proposer_works(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
+220
View File
@@ -0,0 +1,220 @@
"""End-to-end integration tests for the v0.12.0 CloudFlare Turnstile
gate on `/auth/otc/request` (§6.2 / roadmap item #10).
The release gates the OTC request endpoint behind a one-step
browser-side Turnstile challenge before the bcrypt hash + SMTP send.
The tests prove:
* Happy path: with the secret set, a valid token admits the request
and the OTC envelope lands.
* Failure path: with the secret set, a token siteverify rejects
refuses the request with 400 and produces no envelope.
* Missing-token: with the secret set, a request without a token
refuses with 400.
* Missing-secret-soft: with the secret unset AND
`TURNSTILE_REQUIRED=false` (the v0.12.0 default), the request
admits this is the dev / "operator hasn't wired it yet" path.
* Missing-secret-hard: with the secret unset AND
`TURNSTILE_REQUIRED=true`, the request refuses with 500
"auth misconfigured" the production fail-closed path once
the operator has flipped the policy.
The Turnstile siteverify call is mocked at the `httpx.post` boundary
inside `app.turnstile` so no real keys are needed and no real
CloudFlare call is made. The Gitea fakes from `test_propose_vertical`
remain in scope so the rest of the app boots cleanly.
"""
from __future__ import annotations
from types import SimpleNamespace
import pytest
from test_propose_vertical import ( # noqa: F401
FakeGitea,
app_with_fake_gitea,
tmp_env,
)
def _reset_outbound():
from app import email as email_mod
email_mod.reset_sent_envelopes()
def _outbound_otc_envelopes(to_address: str | None = None) -> list[dict]:
from app import email as email_mod
out = []
for env in email_mod.sent_envelopes():
if env.get("kind") != "otc":
continue
if to_address is not None and env["to"] != to_address:
continue
out.append(env)
return out
def _patch_siteverify(monkeypatch, *, success: bool, error_codes: list[str] | None = None):
"""Replace `httpx.post` inside `app.turnstile` with a stub that
returns the requested success shape. The stub does not touch the
real CloudFlare endpoint and never sees a real secret.
"""
captured = {}
def fake_post(url, *, data=None, timeout=None, **kwargs):
captured["url"] = url
captured["data"] = data
body = {"success": bool(success)}
if error_codes is not None:
body["error-codes"] = error_codes
return SimpleNamespace(json=lambda: body)
from app import turnstile as turnstile_mod
monkeypatch.setattr(turnstile_mod.httpx, "post", fake_post)
return captured
# ---------------------------------------------------------------------------
# Happy path: secret set, token valid → admit + OTC envelope lands
# ---------------------------------------------------------------------------
def test_otc_request_admits_when_turnstile_token_is_valid(app_with_fake_gitea, monkeypatch):
from fastapi.testclient import TestClient
monkeypatch.setenv("CLOUDFLARE_TURNSTILE_SECRET", "test-secret-not-real")
monkeypatch.setenv("TURNSTILE_REQUIRED", "true")
captured = _patch_siteverify(monkeypatch, success=True)
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
r = client.post(
"/auth/otc/request",
json={"email": "alice@example.com", "turnstile_token": "fake-token-abc"},
)
assert r.status_code == 200, r.text
# The siteverify call was made with the secret + the token we sent.
assert captured["data"]["secret"] == "test-secret-not-real"
assert captured["data"]["response"] == "fake-token-abc"
# And the OTC dispatch ran — exactly one envelope to the address.
envs = _outbound_otc_envelopes("alice@example.com")
assert len(envs) == 1
# ---------------------------------------------------------------------------
# Failure path: secret set, siteverify says success=false → 400 + no envelope
# ---------------------------------------------------------------------------
def test_otc_request_refuses_when_turnstile_siteverify_fails(app_with_fake_gitea, monkeypatch):
from fastapi.testclient import TestClient
monkeypatch.setenv("CLOUDFLARE_TURNSTILE_SECRET", "test-secret-not-real")
monkeypatch.setenv("TURNSTILE_REQUIRED", "true")
_patch_siteverify(monkeypatch, success=False, error_codes=["invalid-input-response"])
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
r = client.post(
"/auth/otc/request",
json={"email": "alice@example.com", "turnstile_token": "fake-bad-token"},
)
assert r.status_code == 400, r.text
# The OTC bcrypt + SMTP path did not run — no envelope was buffered.
assert _outbound_otc_envelopes("alice@example.com") == []
# ---------------------------------------------------------------------------
# Missing-token: secret set, no token → 400 + no envelope
# ---------------------------------------------------------------------------
def test_otc_request_refuses_when_turnstile_token_is_missing(app_with_fake_gitea, monkeypatch):
from fastapi.testclient import TestClient
monkeypatch.setenv("CLOUDFLARE_TURNSTILE_SECRET", "test-secret-not-real")
monkeypatch.setenv("TURNSTILE_REQUIRED", "true")
# Even though we patch httpx.post, the missing-token check fires
# before the siteverify call — so the patch is here only as a
# safety net in case the implementation regresses to making the
# network call anyway.
_patch_siteverify(monkeypatch, success=False)
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
r = client.post(
"/auth/otc/request",
json={"email": "alice@example.com"}, # no turnstile_token field at all
)
assert r.status_code == 400, r.text
assert _outbound_otc_envelopes("alice@example.com") == []
# ---------------------------------------------------------------------------
# Missing-secret-soft: no secret, TURNSTILE_REQUIRED=false (default) → admit
# ---------------------------------------------------------------------------
def test_otc_request_admits_when_secret_unset_and_not_required(app_with_fake_gitea, monkeypatch):
"""v0.12.0 default: the operator has not yet wired the Turnstile
secret and has not enabled `TURNSTILE_REQUIRED`. The gate stays
open this is the dev / test / pre-rollout path. Once the
operator confirms the secret is in place and flips
`TURNSTILE_REQUIRED=true`, missing-secret becomes fail-closed
(covered in test_otc_request_refuses_when_required_but_secret_unset).
"""
from fastapi.testclient import TestClient
monkeypatch.delenv("CLOUDFLARE_TURNSTILE_SECRET", raising=False)
monkeypatch.delenv("TURNSTILE_REQUIRED", raising=False)
# The httpx.post inside turnstile must not be called in this path —
# patch it to a sentinel that explodes if it ever runs.
from app import turnstile as turnstile_mod
def must_not_be_called(*a, **kw):
raise AssertionError("siteverify should not run when no secret is configured")
monkeypatch.setattr(turnstile_mod.httpx, "post", must_not_be_called)
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
r = client.post(
"/auth/otc/request",
json={"email": "alice@example.com"},
)
assert r.status_code == 200, r.text
# The OTC path ran end-to-end — one envelope to the address.
assert len(_outbound_otc_envelopes("alice@example.com")) == 1
# ---------------------------------------------------------------------------
# Missing-secret-hard: no secret, TURNSTILE_REQUIRED=true → 500 "misconfigured"
# ---------------------------------------------------------------------------
def test_otc_request_refuses_when_required_but_secret_unset(app_with_fake_gitea, monkeypatch):
"""Once the operator has flipped `TURNSTILE_REQUIRED=true` to lock
down production, a missing secret stops being a soft-fail and
becomes a fail-closed 500. This is the regression-detection shape
the §20.4 upgrade-steps MAY block calls out flip the flag once
the secret is wired so a future config drift fails loudly instead
of silently disabling abuse defense.
"""
from fastapi.testclient import TestClient
monkeypatch.delenv("CLOUDFLARE_TURNSTILE_SECRET", raising=False)
monkeypatch.setenv("TURNSTILE_REQUIRED", "true")
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
r = client.post(
"/auth/otc/request",
json={"email": "alice@example.com", "turnstile_token": "doesnt-matter"},
)
assert r.status_code == 500, r.text
assert _outbound_otc_envelopes("alice@example.com") == []
+61
View File
@@ -24,3 +24,64 @@ VITE_APP_NAME=
# VITE_BETA_CONTACT=ben@wiggleverse.org
# VITE_BETA_CONTACT=DM @ben on Matrix
VITE_BETA_CONTACT=
# Optional URL to the deployment's privacy policy (v0.13.0+, SPEC §14.5).
# The framework ships a minimal default privacy policy at `/privacy`
# that describes the framework's stance and lists the cookies the
# framework sets. When this var is set to an http(s) URL, the page
# renders the framework's stub above a link to the configured URL —
# deployments use this to layer their own policy content on top
# without forking the framework. Unset is OK; the stub is sufficient
# for a deployment that has nothing specific to add.
#
# Examples:
# VITE_PRIVACY_POLICY_URL=https://wiggleverse.org/privacy
VITE_PRIVACY_POLICY_URL=
# Optional URL to the deployment's cookies policy (v0.13.0+, SPEC §14.6).
# Same shape as VITE_PRIVACY_POLICY_URL. The framework's default
# `/cookies` page lists exactly which cookies the framework sets
# (rfc_session, the consent-choice localStorage entry); a deployment
# that adds its own cookies (analytics SDK once #13 lands, third-party
# embeds) points this var at a page that documents the full list.
# Unset is OK; the stub is sufficient for a default-config deployment.
#
# Examples:
# VITE_COOKIES_POLICY_URL=https://wiggleverse.org/cookies
VITE_COOKIES_POLICY_URL=
# v0.12.0 / roadmap item #10: CloudFlare Turnstile site key (public).
# Provision a Turnstile site at dash.cloudflare.com → Turnstile → Add
# site. The site key (this var) is embedded into the frontend bundle at
# build time and rendered by the Turnstile widget on the /login email-
# entry step. The secret key (private) lives in the backend env as
# CLOUDFLARE_TURNSTILE_SECRET — see backend/.env.example. Leave unset
# in dev to skip the widget; the backend's TURNSTILE_REQUIRED policy
# decides what happens to a tokenless request.
#
# Examples:
# VITE_TURNSTILE_SITE_KEY=0x4AAAAAAA...
VITE_TURNSTILE_SITE_KEY=
# v0.15.0 / roadmap item #13: Amplitude project API key (public).
# Embedded in the frontend bundle at build time and used by the
# analytics wrapper (`frontend/src/lib/analytics.js`) — which loads
# `@amplitude/unified` (Analytics + Session Replay) when the user
# has granted analytics consent (v0.13.0 cookie banner). Provision
# an Amplitude project at app.amplitude.com → Projects → New, copy
# the API key.
#
# Public by design: Amplitude browser keys are bundle-embedded
# (visible in dev tools), same nature as VITE_TURNSTILE_SITE_KEY
# (also public; the truly-secret half of that Turnstile pair is
# CLOUDFLARE_TURNSTILE_SECRET on the backend). For deployments
# behind flotilla, bind via `flotilla overlay set <deployment>
# VITE_AMPLITUDE_API_KEY=<key>` — NOT `flotilla secret set`. The
# vendor's installation wizard shows the key inline as a literal
# string in the init call, confirming the public framing. Leave
# unset in dev; the wrapper logs one console warning and no-ops
# (the app continues to work).
#
# Examples:
# VITE_AMPLITUDE_API_KEY=01234567890abcdef01234567890abcd
VITE_AMPLITUDE_API_KEY=
+529 -10
View File
@@ -1,13 +1,14 @@
{
"name": "rfc-app-frontend",
"version": "0.5.0",
"version": "0.15.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "rfc-app-frontend",
"version": "0.5.0",
"version": "0.15.0",
"dependencies": {
"@amplitude/unified": "^1.1.9",
"@codemirror/commands": "^6.10.3",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/language": "^6.12.3",
@@ -30,6 +31,360 @@
"vite": "^8.0.12"
}
},
"node_modules/@amplitude/analytics-browser": {
"version": "2.42.4",
"resolved": "https://registry.npmjs.org/@amplitude/analytics-browser/-/analytics-browser-2.42.4.tgz",
"integrity": "sha512-q1XUlaKQkLq2CFx8xsVEc+uekOwHlnDYyaMBzlQDf2vcEaPaQDb7LzJ7z4CFs4Jn9FyBGDNo4w3IYjv9L6xjGA==",
"license": "MIT",
"dependencies": {
"@amplitude/analytics-core": "2.48.2",
"@amplitude/plugin-autocapture-browser": "1.27.2",
"@amplitude/plugin-custom-enrichment-browser": "0.1.9",
"@amplitude/plugin-event-property-attribution-browser": "0.2.1",
"@amplitude/plugin-network-capture-browser": "1.10.1",
"@amplitude/plugin-page-url-enrichment-browser": "0.7.11",
"@amplitude/plugin-page-view-tracking-browser": "2.11.1",
"@amplitude/plugin-web-vitals-browser": "1.1.33",
"tslib": "^2.4.1"
}
},
"node_modules/@amplitude/analytics-client-common": {
"version": "2.4.48",
"resolved": "https://registry.npmjs.org/@amplitude/analytics-client-common/-/analytics-client-common-2.4.48.tgz",
"integrity": "sha512-jdRvu8ux3aIf74FvTDZuSFR1mutzdrIg1ebXYqpKizs9upXz1AJnHClkldSw9i4yu924AJ2wudxq6dccHWlNiA==",
"license": "MIT",
"dependencies": {
"@amplitude/analytics-connector": "^1.4.8",
"@amplitude/analytics-core": "2.48.2",
"@amplitude/analytics-types": "2.11.1",
"tslib": "^2.4.1"
}
},
"node_modules/@amplitude/analytics-connector": {
"version": "1.6.4",
"resolved": "https://registry.npmjs.org/@amplitude/analytics-connector/-/analytics-connector-1.6.4.tgz",
"integrity": "sha512-SpIv0IQMNIq6SH3UqFGiaZyGSc7PBZwRdq7lvP0pBxW8i4Ny+8zwI0pV+VMfMHQwWY3wdIbWw5WQphNjpdq1/Q==",
"license": "MIT"
},
"node_modules/@amplitude/analytics-core": {
"version": "2.48.2",
"resolved": "https://registry.npmjs.org/@amplitude/analytics-core/-/analytics-core-2.48.2.tgz",
"integrity": "sha512-r9O+hsTnTsDa1p6QdyC0KbBPXupzoWz9053RQB9XQz8078LM+5KCMbCKYOrSYniH4DH/OM2kOUEdJlwdxIl/IA==",
"license": "MIT",
"dependencies": {
"@amplitude/analytics-connector": "^1.6.4",
"@types/zen-observable": "0.8.3",
"safe-json-stringify": "1.2.0",
"tslib": "^2.4.1",
"zen-observable": "0.10.0"
}
},
"node_modules/@amplitude/analytics-types": {
"version": "2.11.1",
"resolved": "https://registry.npmjs.org/@amplitude/analytics-types/-/analytics-types-2.11.1.tgz",
"integrity": "sha512-wFEgb0t99ly2uJKm5oZ28Lti0Kh5RecR5XBkwfUpDzn84IoCIZ8GJTsMw/nThu8FZFc7xFDA4UAt76zhZKrs9A==",
"license": "MIT"
},
"node_modules/@amplitude/engagement-browser": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@amplitude/engagement-browser/-/engagement-browser-1.0.9.tgz",
"integrity": "sha512-zvPr0L5aLlOS3nG8scIkEEDMVK2y3MaMbgjYhMfYruhMpfsC/U0apov22nEc1RRrTwve2awEXruPRKf1TysqrQ==",
"license": "MIT",
"dependencies": {
"@amplitude/analytics-types": "^2.0.0"
}
},
"node_modules/@amplitude/experiment-core": {
"version": "0.13.1",
"resolved": "https://registry.npmjs.org/@amplitude/experiment-core/-/experiment-core-0.13.1.tgz",
"integrity": "sha512-ZHvR0dxTltasp8MiMcQ6qKsY20mWnODoy3oebGad6qaRR1ywpUi8IuLf5AwLTM35ZwgzEUTn9TEIWKLHpDwHMw==",
"license": "MIT",
"dependencies": {
"js-base64": "^3.7.5"
}
},
"node_modules/@amplitude/experiment-js-client": {
"version": "1.21.1",
"resolved": "https://registry.npmjs.org/@amplitude/experiment-js-client/-/experiment-js-client-1.21.1.tgz",
"integrity": "sha512-chE/4qQG/5Cgl93Wqj1NEdgOL5LkqySLlfk1EN0f+7bJa52HpkGFALA2FeCNYf31Z5CglEeKX6dUMgL7y33SIw==",
"license": "MIT",
"dependencies": {
"@amplitude/analytics-connector": "^1.6.4",
"@amplitude/experiment-core": "^0.13.1",
"@amplitude/ua-parser-js": "^0.7.31",
"base64-js": "1.5.1",
"unfetch": "4.1.0"
}
},
"node_modules/@amplitude/plugin-autocapture-browser": {
"version": "1.27.2",
"resolved": "https://registry.npmjs.org/@amplitude/plugin-autocapture-browser/-/plugin-autocapture-browser-1.27.2.tgz",
"integrity": "sha512-UTA/0IDw/f2nnK+S1XILqoI5pgUgMTEZokDS6+pC4wuYtmOS9uNAgKuyajzjW12uobybMHRpv7xLjCJ5khKGAg==",
"license": "MIT",
"dependencies": {
"@amplitude/analytics-core": "2.48.2",
"tslib": "^2.4.1"
}
},
"node_modules/@amplitude/plugin-custom-enrichment-browser": {
"version": "0.1.9",
"resolved": "https://registry.npmjs.org/@amplitude/plugin-custom-enrichment-browser/-/plugin-custom-enrichment-browser-0.1.9.tgz",
"integrity": "sha512-wemh2Tw3zgQ7sa7MUNyMGz9OR6VjTG4tlAMrLlDKbQ4tVkgNI3oAwOF7+0BA8qzgeMXX6iw+CEKaE+EC/okkuQ==",
"license": "MIT",
"dependencies": {
"@amplitude/analytics-core": "2.48.2",
"tslib": "^2.4.1"
}
},
"node_modules/@amplitude/plugin-event-property-attribution-browser": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/@amplitude/plugin-event-property-attribution-browser/-/plugin-event-property-attribution-browser-0.2.1.tgz",
"integrity": "sha512-xqBCZe0DYsKyQ1eELN2LM8adXwRE2eOi3SnvSu9SkS0GDXBYWinuPCuLqyc/3uD5hY2FLACWvakpU0tr7GDJgg==",
"license": "MIT",
"dependencies": {
"@amplitude/analytics-core": "2.48.2",
"tslib": "^2.4.1"
}
},
"node_modules/@amplitude/plugin-experiment-browser": {
"version": "1.0.0-beta.28",
"resolved": "https://registry.npmjs.org/@amplitude/plugin-experiment-browser/-/plugin-experiment-browser-1.0.0-beta.28.tgz",
"integrity": "sha512-NQz267zLi7vl2G2lx10yUrEoGOCe5K9iqcPSIjbTavGu/XGvsmqLDqBHhg+EkdEMAPwypoXnmtPEs3RMhX+1MA==",
"license": "MIT",
"dependencies": {
"@amplitude/analytics-core": "2.48.2",
"@amplitude/experiment-js-client": "^1.15.5"
}
},
"node_modules/@amplitude/plugin-network-capture-browser": {
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/@amplitude/plugin-network-capture-browser/-/plugin-network-capture-browser-1.10.1.tgz",
"integrity": "sha512-jROIAkUDPd25A/t8W5MpmsTiBat2qoJbCMoNBKKxLMNEaE8VYbheflByWLkm4enbHgWS7OveWy0i3Oc7uPCfAg==",
"license": "MIT",
"dependencies": {
"@amplitude/analytics-core": "2.48.2",
"tslib": "^2.4.1"
}
},
"node_modules/@amplitude/plugin-page-url-enrichment-browser": {
"version": "0.7.11",
"resolved": "https://registry.npmjs.org/@amplitude/plugin-page-url-enrichment-browser/-/plugin-page-url-enrichment-browser-0.7.11.tgz",
"integrity": "sha512-u9JhUP/VenJifCSbdTz2YZZiXAphs3efzd+qx1SRAIU6d1swPh0g/GVw3sTwvH+4MZtw3SwVC1OFxmz+f2QVyA==",
"license": "MIT",
"dependencies": {
"@amplitude/analytics-core": "2.48.2",
"tslib": "^2.4.1"
}
},
"node_modules/@amplitude/plugin-page-view-tracking-browser": {
"version": "2.11.1",
"resolved": "https://registry.npmjs.org/@amplitude/plugin-page-view-tracking-browser/-/plugin-page-view-tracking-browser-2.11.1.tgz",
"integrity": "sha512-tfXg6Uir6X1XuWsOOXE/EgZ9NvM7i2ktDdagydSrFN6OyVkMvqdjPKUZSSUPuHtOoomboi3WaZsTUfq1jkWP3w==",
"license": "MIT",
"dependencies": {
"@amplitude/analytics-core": "2.48.2",
"tslib": "^2.4.1"
}
},
"node_modules/@amplitude/plugin-session-replay-browser": {
"version": "1.31.0",
"resolved": "https://registry.npmjs.org/@amplitude/plugin-session-replay-browser/-/plugin-session-replay-browser-1.31.0.tgz",
"integrity": "sha512-b7kyYVEdW3EMR6cPXCfld+h8nQsuAR5o6vum8Glu+ofhFDfG4wj/mTJ0ITEaNbsJCfXniKQ3kFgTe6hTtxSFGQ==",
"license": "MIT",
"dependencies": {
"@amplitude/analytics-client-common": "2.4.48",
"@amplitude/analytics-core": "2.48.2",
"@amplitude/analytics-types": "2.11.1",
"@amplitude/rrweb-plugin-console-record": "2.0.0-alpha.40",
"@amplitude/rrweb-record": "2.0.0-alpha.40",
"@amplitude/session-replay-browser": "1.44.0",
"idb-keyval": "^6.2.1",
"tslib": "^2.4.1"
}
},
"node_modules/@amplitude/plugin-web-vitals-browser": {
"version": "1.1.33",
"resolved": "https://registry.npmjs.org/@amplitude/plugin-web-vitals-browser/-/plugin-web-vitals-browser-1.1.33.tgz",
"integrity": "sha512-33FzxMH1Lr2lhvr5DDy3xD1HHWEI4KPLQsMUXqDTldkLl/ENNeBWcsljQTTDJipmRdS32I79KJhuHRNaoXd6fg==",
"license": "MIT",
"dependencies": {
"@amplitude/analytics-core": "2.48.2",
"tslib": "^2.4.1",
"web-vitals": "5.1.0"
}
},
"node_modules/@amplitude/rrdom": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@amplitude/rrdom/-/rrdom-2.1.0.tgz",
"integrity": "sha512-2dAtxXL02usBV2CSOnScLd3WoVqWaeiGpxN8LuXJ0r/NpLJkW1k876v2tRKAz5NrxPwSdjihsMmwCIXHpJhHfA==",
"license": "MIT",
"dependencies": {
"@amplitude/rrweb-snapshot": "^2.1.0"
}
},
"node_modules/@amplitude/rrweb": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@amplitude/rrweb/-/rrweb-2.1.1.tgz",
"integrity": "sha512-6uA+5VE/VHumaXPXTTLGRogd/K9MDwd01jGteppeLzsX0PvqlDyY5aIi35yh9+q1iS6ciPBn/2NRg0lg4cFIlw==",
"license": "MIT",
"dependencies": {
"@amplitude/rrdom": "^2.1.0",
"@amplitude/rrweb-snapshot": "^2.1.0",
"@amplitude/rrweb-types": "^2.1.0",
"@amplitude/rrweb-utils": "^2.1.0",
"@types/css-font-loading-module": "0.0.7",
"@xstate/fsm": "^1.4.0",
"base64-arraybuffer": "^1.0.1",
"mitt": "^3.0.0"
}
},
"node_modules/@amplitude/rrweb-packer": {
"version": "2.0.0-alpha.40",
"resolved": "https://registry.npmjs.org/@amplitude/rrweb-packer/-/rrweb-packer-2.0.0-alpha.40.tgz",
"integrity": "sha512-Btb6b9pS1IvDMbvyYxpUdTk9NRJugSoJjRCl7R6jP/iSlPWXoveJIwHaNFAS9ZmWUEK7HhyBJ8bKGFN3giUsDg==",
"license": "MIT",
"dependencies": {
"@amplitude/rrweb-types": "^2.0.0-alpha.40",
"fflate": "^0.4.4"
}
},
"node_modules/@amplitude/rrweb-plugin-console-record": {
"version": "2.0.0-alpha.40",
"resolved": "https://registry.npmjs.org/@amplitude/rrweb-plugin-console-record/-/rrweb-plugin-console-record-2.0.0-alpha.40.tgz",
"integrity": "sha512-vtY7T/kGFl62nC1u7ZUXQvU7ulB70cZGVHPRN/SO9fzVfsY7y6rCmBfoc2jS5KmISdlgkVzMjY2r/EE2Gk9AQA==",
"license": "MIT",
"peerDependencies": {
"@amplitude/rrweb": "^2.0.0-alpha.40"
}
},
"node_modules/@amplitude/rrweb-record": {
"version": "2.0.0-alpha.40",
"resolved": "https://registry.npmjs.org/@amplitude/rrweb-record/-/rrweb-record-2.0.0-alpha.40.tgz",
"integrity": "sha512-5cJhQwzhymJWX5/XOtpWK0h2NLq9+t2YiO6ub0cdZ9F5AZizaRbsVH88int07DfX0YiXTKWbISezVuduCLqgSQ==",
"license": "MIT",
"dependencies": {
"@amplitude/rrweb": "^2.0.0-alpha.40",
"@amplitude/rrweb-types": "^2.0.0-alpha.40"
}
},
"node_modules/@amplitude/rrweb-snapshot": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@amplitude/rrweb-snapshot/-/rrweb-snapshot-2.1.0.tgz",
"integrity": "sha512-xYQvOW73ig+5M7caqilA8j0S6MHWUULLeJNK+2VVvUqv8mr4FMT2DUAQiVBGCImNlb9Gu2rLUfCScMnVxn+EDg==",
"license": "MIT",
"dependencies": {
"postcss": "^8.4.38"
}
},
"node_modules/@amplitude/rrweb-types": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@amplitude/rrweb-types/-/rrweb-types-2.1.0.tgz",
"integrity": "sha512-S73tBI/04A6HCHgnrUNeeVOvnDTEoQnNrmZGyrZncJwRlTIX+6BQSYtBFofMag8GnAy9gA+NtC0TL0CnluOWBw==",
"license": "MIT"
},
"node_modules/@amplitude/rrweb-utils": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@amplitude/rrweb-utils/-/rrweb-utils-2.1.0.tgz",
"integrity": "sha512-dTCDnSiMMHZ10utYHJ8dSd/xkjFgdF67y74PkOzAPcCKW1rLxyJYcFOA3uPL2b7cIVVmoel/5NTp5eflaUaJfQ==",
"license": "MIT"
},
"node_modules/@amplitude/session-replay-browser": {
"version": "1.44.0",
"resolved": "https://registry.npmjs.org/@amplitude/session-replay-browser/-/session-replay-browser-1.44.0.tgz",
"integrity": "sha512-8Ruep2TTDMcfVMKurSpBbVclBK/v8Lb3aSHFsYd/xOQ1E3CaKoAu39pplli28NWoUcW7unyVE7khkOa2zzn0Lw==",
"license": "MIT",
"dependencies": {
"@amplitude/analytics-client-common": "2.4.48",
"@amplitude/analytics-core": "2.48.2",
"@amplitude/analytics-types": "2.11.1",
"@amplitude/experiment-core": "0.7.2",
"@amplitude/rrweb-packer": "2.0.0-alpha.40",
"@amplitude/rrweb-plugin-console-record": "2.0.0-alpha.40",
"@amplitude/rrweb-record": "2.0.0-alpha.40",
"@amplitude/rrweb-types": "2.0.0-alpha.40",
"@amplitude/rrweb-utils": "2.0.0-alpha.40",
"@amplitude/targeting": "0.2.0",
"@rollup/plugin-replace": "^6.0.1",
"idb": "8.0.0",
"tslib": "^2.4.1"
}
},
"node_modules/@amplitude/session-replay-browser/node_modules/@amplitude/experiment-core": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/@amplitude/experiment-core/-/experiment-core-0.7.2.tgz",
"integrity": "sha512-Wc2NWvgQ+bLJLeF0A9wBSPIaw0XuqqgkPKsoNFQrmS7r5Djd56um75In05tqmVntPJZRvGKU46pAp8o5tdf4mA==",
"license": "MIT",
"dependencies": {
"js-base64": "^3.7.5"
}
},
"node_modules/@amplitude/session-replay-browser/node_modules/@amplitude/rrweb-types": {
"version": "2.0.0-alpha.40",
"resolved": "https://registry.npmjs.org/@amplitude/rrweb-types/-/rrweb-types-2.0.0-alpha.40.tgz",
"integrity": "sha512-rP7CBDkzXupxOA7ukvC+zDYLuCtsz54TuJKC4+5O72Jsz4YdokLznKZRG34P6zXozfhGU0261qckk87lLY6mKQ==",
"license": "MIT"
},
"node_modules/@amplitude/session-replay-browser/node_modules/@amplitude/rrweb-utils": {
"version": "2.0.0-alpha.40",
"resolved": "https://registry.npmjs.org/@amplitude/rrweb-utils/-/rrweb-utils-2.0.0-alpha.40.tgz",
"integrity": "sha512-i1CCt6MCjlqoeNc+1Hse5bz+ZbASaWaIJ0WdJZvnQjUCHH29Xy/QFouyOuor73RZ+UWX4s2tYSrUIdmBepXk3w==",
"license": "MIT"
},
"node_modules/@amplitude/targeting": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@amplitude/targeting/-/targeting-0.2.0.tgz",
"integrity": "sha512-/50ywTrC4hfcfJVBbh5DFbqMPPfaIOivZeb5Gb+OGM03QrA+lsUqdvtnKLNuWtceD4H6QQ2KFzPJ5aAJLyzVDA==",
"license": "MIT",
"dependencies": {
"@amplitude/analytics-client-common": ">=1 <3",
"@amplitude/analytics-core": ">=1 <3",
"@amplitude/analytics-types": ">=1 <3",
"@amplitude/experiment-core": "0.7.2",
"idb": "^8.0.0",
"tslib": "^2.4.1"
}
},
"node_modules/@amplitude/targeting/node_modules/@amplitude/experiment-core": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/@amplitude/experiment-core/-/experiment-core-0.7.2.tgz",
"integrity": "sha512-Wc2NWvgQ+bLJLeF0A9wBSPIaw0XuqqgkPKsoNFQrmS7r5Djd56um75In05tqmVntPJZRvGKU46pAp8o5tdf4mA==",
"license": "MIT",
"dependencies": {
"js-base64": "^3.7.5"
}
},
"node_modules/@amplitude/ua-parser-js": {
"version": "0.7.33",
"resolved": "https://registry.npmjs.org/@amplitude/ua-parser-js/-/ua-parser-js-0.7.33.tgz",
"integrity": "sha512-wKEtVR4vXuPT9cVEIJkYWnlF++Gx3BdLatPBM+SZ1ztVIvnhdGBZR/mn9x/PzyrMcRlZmyi6L56I2J3doVBnjA==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/ua-parser-js"
},
{
"type": "paypal",
"url": "https://paypal.me/faisalman"
}
],
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/@amplitude/unified": {
"version": "1.1.9",
"resolved": "https://registry.npmjs.org/@amplitude/unified/-/unified-1.1.9.tgz",
"integrity": "sha512-YPgQbp/vDQ92GshHs2hfUxoeRnR3rRBWCoQ6wXgFjXQ1uiJf2tP0CBZWdrCStSDuhcpo2rsCz/Ek2LGq5J6SIQ==",
"license": "MIT",
"dependencies": {
"@amplitude/analytics-browser": "2.42.4",
"@amplitude/analytics-core": "2.48.2",
"@amplitude/engagement-browser": "^1.0.3",
"@amplitude/plugin-experiment-browser": "1.0.0-beta.28",
"@amplitude/plugin-session-replay-browser": "1.31.0"
}
},
"node_modules/@antfu/install-pkg": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz",
@@ -264,6 +619,12 @@
"import-meta-resolve": "^4.2.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"license": "MIT"
},
"node_modules/@lezer/common": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
@@ -657,6 +1018,49 @@
"dev": true,
"license": "MIT"
},
"node_modules/@rollup/plugin-replace": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-6.0.3.tgz",
"integrity": "sha512-J4RZarRvQAm5IF0/LwUUg+obsm+xZhYnbMXmXROyoSE1ATJe3oXSb9L5MMppdxP2ylNSjv6zFBwKYjcKMucVfA==",
"license": "MIT",
"dependencies": {
"@rollup/pluginutils": "^5.0.1",
"magic-string": "^0.30.3"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
},
"peerDependenciesMeta": {
"rollup": {
"optional": true
}
}
},
"node_modules/@rollup/pluginutils": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz",
"integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==",
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0",
"estree-walker": "^2.0.2",
"picomatch": "^4.0.2"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
},
"peerDependenciesMeta": {
"rollup": {
"optional": true
}
}
},
"node_modules/@tiptap/core": {
"version": "3.23.6",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.23.6.tgz",
@@ -1109,6 +1513,12 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/css-font-loading-module": {
"version": "0.0.7",
"resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.7.tgz",
"integrity": "sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==",
"license": "MIT"
},
"node_modules/@types/d3": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
@@ -1362,6 +1772,12 @@
"@types/d3-selection": "*"
}
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
"license": "MIT"
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
@@ -1399,6 +1815,12 @@
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@types/zen-observable": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/@types/zen-observable/-/zen-observable-0.8.3.tgz",
"integrity": "sha512-fbF6oTd4sGGy0xjHPKAt+eS2CrxJ3+6gQ3FGcBoIJR2TLAyCkCyI8JqZNy+FeON0AhVgNJoUumVoZQjBFUqHkw==",
"license": "MIT"
},
"node_modules/@upsetjs/venn.js": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@upsetjs/venn.js/-/venn.js-2.0.0.tgz",
@@ -1435,6 +1857,41 @@
}
}
},
"node_modules/@xstate/fsm": {
"version": "1.6.5",
"resolved": "https://registry.npmjs.org/@xstate/fsm/-/fsm-1.6.5.tgz",
"integrity": "sha512-b5o1I6aLNeYlU/3CPlj/Z91ybk1gUsKT+5NAJI+2W4UjvS5KLG28K9v5UvNoFVjHV8PajVZ00RH3vnjyQO7ZAw==",
"license": "MIT"
},
"node_modules/base64-arraybuffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/commander": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
@@ -2021,6 +2478,12 @@
"benchmarks"
]
},
"node_modules/estree-walker": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"license": "MIT"
},
"node_modules/fast-equals": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
@@ -2048,6 +2511,12 @@
}
}
},
"node_modules/fflate": {
"version": "0.4.8",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.8.tgz",
"integrity": "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==",
"license": "MIT"
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -2081,6 +2550,18 @@
"node": ">=0.10.0"
}
},
"node_modules/idb": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/idb/-/idb-8.0.0.tgz",
"integrity": "sha512-l//qvlAKGmQO31Qn7xdzagVPPaHTxXx199MhrAFuVBTPqydcPYBWjkrbv4Y0ktB+GmWOiwHl237UUOrLmQxLvw==",
"license": "ISC"
},
"node_modules/idb-keyval": {
"version": "6.2.4",
"resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.4.tgz",
"integrity": "sha512-D/NzHWUmYJGXi++z67aMSrnisb9A3621CyRK5G89JyTlN13C8xf0g04DLxUKMufPem3e3L2JAXR6Z00OWy183Q==",
"license": "Apache-2.0"
},
"node_modules/import-meta-resolve": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz",
@@ -2100,6 +2581,12 @@
"node": ">=12"
}
},
"node_modules/js-base64": {
"version": "3.7.8",
"resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz",
"integrity": "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==",
"license": "BSD-3-Clause"
},
"node_modules/katex": {
"version": "0.16.47",
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz",
@@ -2421,6 +2908,15 @@
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
"license": "MIT"
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/marked": {
"version": "18.0.4",
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.4.tgz",
@@ -2474,11 +2970,16 @@
"node": ">= 20"
}
},
"node_modules/mitt": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
"dev": true,
"funding": [
{
"type": "github",
@@ -2515,14 +3016,12 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
@@ -2551,7 +3050,6 @@
"version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -2828,6 +3326,12 @@
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
"license": "BSD-3-Clause"
},
"node_modules/safe-json-stringify": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/safe-json-stringify/-/safe-json-stringify-1.2.0.tgz",
"integrity": "sha512-gH8eh2nZudPQO6TytOvbxnuhYBOvDBBLW52tz5q6X58lJcd/tkmqFR+5Z9adS8aJtURSXWThWy/xJtJwixErvg==",
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
@@ -2850,7 +3354,6 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
@@ -2907,9 +3410,13 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD",
"optional": true
"license": "0BSD"
},
"node_modules/unfetch": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/unfetch/-/unfetch-4.1.0.tgz",
"integrity": "sha512-crP/n3eAPUJxZXM9T80/yv0YhkTEx2K1D3h7D1AJM6fzsWZrxdyRuLN0JH/dkZh1LNH8LxCnBzoPFCPbb2iGpg==",
"license": "MIT"
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
@@ -3016,6 +3523,18 @@
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/web-vitals": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.1.0.tgz",
"integrity": "sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==",
"license": "Apache-2.0"
},
"node_modules/zen-observable": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/zen-observable/-/zen-observable-0.10.0.tgz",
"integrity": "sha512-iI3lT0iojZhKwT5DaFy2Ce42n3yFcLdFyOh01G7H0flMY60P8MJuVFEoJoNwXlmAyQ45GrjL6AcZmmlv8A5rbw==",
"license": "MIT"
}
}
}
+2 -1
View File
@@ -1,7 +1,7 @@
{
"name": "rfc-app-frontend",
"private": true,
"version": "0.5.0",
"version": "0.15.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -9,6 +9,7 @@
"preview": "vite preview"
},
"dependencies": {
"@amplitude/unified": "^1.1.9",
"@codemirror/commands": "^6.10.3",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/language": "^6.12.3",
+329
View File
@@ -352,6 +352,168 @@
}
.landing .secondary-link:hover { color: #1a1a1a; text-decoration: underline; }
/* --- v0.7.0: email + one-time-code sign-in (§6.2) --- */
.otc-login {
flex: 1;
display: flex; align-items: center; justify-content: center;
padding: 40px 24px;
}
.otc-login-inner {
max-width: 360px;
width: 100%;
display: flex; flex-direction: column;
gap: 14px;
}
.otc-login h1 {
font-size: 22px;
font-weight: 600;
margin: 0 0 4px;
}
.otc-login .otc-hint {
color: #555;
font-size: 14px;
line-height: 1.5;
margin: 0;
}
.otc-login input {
width: 100%;
padding: 10px 12px;
font-size: 15px;
border: 1px solid #ddd;
border-radius: 6px;
box-sizing: border-box;
}
.otc-login input:focus {
outline: none;
border-color: #1a1a1a;
}
.otc-login button[type="submit"] {
background: #1a1a1a; color: #fff;
border: none; border-radius: 6px;
padding: 10px 18px;
font-size: 14px; font-weight: 600;
cursor: pointer;
}
.otc-login button[type="submit"]:hover:not(:disabled) { background: #333; }
.otc-login button[type="submit"]:disabled { opacity: 0.5; cursor: not-allowed; }
.otc-login form {
display: flex; flex-direction: column;
gap: 10px;
}
.otc-actions {
display: flex; align-items: center; gap: 12px;
}
.otc-login .btn-link-quiet {
background: none; border: none;
color: #666; font-size: 13px;
cursor: pointer; padding: 0;
}
.otc-login .btn-link-quiet:hover { color: #1a1a1a; text-decoration: underline; }
/* v0.8.0 — labels + textarea for the first-OTC profile capture step. */
.otc-field-label {
font-size: 12px; color: #666;
margin: 8px 0 -4px;
font-weight: 600;
}
.otc-login textarea {
width: 100%;
padding: 10px 12px;
font-size: 15px;
border: 1px solid #ddd;
border-radius: 6px;
box-sizing: border-box;
font-family: inherit;
resize: vertical;
}
.otc-login textarea:focus {
outline: none;
border-color: #1a1a1a;
}
.otc-shortcut-hint {
color: #888; font-size: 12px; margin: 4px 0 0;
}
.otc-shortcut-hint kbd {
background: #f0f0ee; border: 1px solid #ddd; border-radius: 3px;
padding: 1px 5px; font-size: 11px; font-family: inherit;
}
.otc-status {
color: #555; font-size: 13px;
background: #f7f6f0;
border-left: 3px solid #cfc8a8;
padding: 8px 12px;
margin: 4px 0 0;
}
.otc-fallback {
font-size: 12px; color: #777;
margin: 16px 0 0;
display: flex; gap: 8px; align-items: center; flex-wrap: wrap;
}
.otc-fallback a, .otc-fallback .otc-fallback-link {
color: #666; text-decoration: none;
}
.otc-fallback a:hover { color: #1a1a1a; text-decoration: underline; }
.otc-fallback-sep { color: #ccc; }
/* v0.11.0 "trust this device for 30 days" checkbox on the verify
step. Sits above the action row, padded so it doesn't crowd the
passcode/code input. */
.otc-trust-device {
display: flex; align-items: center; gap: 8px;
font-size: 13px; color: #444;
margin: 8px 0 4px;
cursor: pointer;
user-select: none;
}
.otc-trust-device input[type="checkbox"] {
width: auto; margin: 0; cursor: pointer;
}
/* v0.11.0 — /settings/devices revoke-device UI. */
.device-list {
list-style: none; padding: 0; margin: 12px 0 0;
}
.device-list-item {
display: flex; align-items: center; justify-content: space-between;
gap: 12px;
border: 1px solid #eee; border-radius: 6px;
padding: 10px 12px; margin: 0 0 8px;
background: #fafafa;
}
.device-list-item .device-meta {
flex: 1; min-width: 0;
}
.device-list-item .device-ua {
font-size: 13px; color: #1a1a1a;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.device-list-item .device-stamps {
font-size: 12px; color: #777;
margin-top: 2px;
}
.device-list-item button {
font-size: 12px; padding: 4px 10px;
border: 1px solid #ccc; border-radius: 4px;
background: white; cursor: pointer;
}
.device-list-item button:hover:not(:disabled) {
background: #f5f5f5;
}
.device-revoke-all {
margin-top: 8px;
font-size: 13px; padding: 6px 12px;
border: 1px solid #cb6a6a; border-radius: 4px;
background: white; color: #cb6a6a; cursor: pointer;
}
.device-revoke-all:hover:not(:disabled) {
background: #fff5f5;
}
.device-empty {
font-size: 13px; color: #777;
background: #fafafa; border: 1px solid #eee; border-radius: 6px;
padding: 12px;
}
/* --- Beta-pending page (post-OAuth-rejection) --- */
.beta-pending {
@@ -383,6 +545,22 @@
.btn-link-quiet { color: #666; text-decoration: none; font-size: 13px; }
.btn-link-quiet:hover { color: #1a1a1a; text-decoration: underline; }
/* v0.8.0 thin "your beta access is in review" banner. Shown on every
page (other than /beta-pending itself, which carries the larger
form of the message). Sits just under the app header so it doesn't
compete with the catalog rail. */
.pending-access-banner {
background: #fff8e0;
border-bottom: 1px solid #e6dca0;
color: #4a3f00;
font-size: 13px;
padding: 8px 16px;
text-align: center;
}
.pending-access-banner a {
color: #4a3f00; text-decoration: underline;
}
/* ── §8 RFC view: three-column shape ─────────────────────────────────── */
.main-pane {
@@ -1770,6 +1948,54 @@
display: inline-flex; align-items: center; gap: 6px;
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 li { padding: 8px 0; border-bottom: 1px solid #f3f4f6; }
.grad-queue-link { color: #111; text-decoration: none; font-size: 14px; }
@@ -1864,3 +2090,106 @@
.discussion-readonly {
font-size: 12px; color: #666; padding: 4px 0;
}
/* §14.5 — cookie consent banner (v0.13.0) */
.cookie-consent-banner {
position: fixed;
left: 0; right: 0; bottom: 0;
z-index: 1000;
background: #fff;
border-top: 1px solid #d1d5db;
box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.08);
padding: 20px 24px;
}
.cookie-consent-body {
max-width: 880px; margin: 0 auto;
display: flex; flex-direction: column; gap: 12px;
}
.cookie-consent-title {
margin: 0; font-size: 16px; font-weight: 700; color: #111;
}
.cookie-consent-intro {
margin: 0; font-size: 13px; color: #4b5563; line-height: 1.5;
}
.cookie-consent-choices {
border: none; padding: 0; margin: 0;
display: flex; flex-direction: column; gap: 6px;
}
.cookie-consent-choice {
display: flex; gap: 10px; align-items: flex-start;
padding: 10px 12px; border-radius: 6px;
border: 1px solid #e5e7eb;
cursor: pointer;
}
.cookie-consent-choice.is-selected {
border-color: #111; background: #f9fafb;
}
.cookie-consent-choice input[type=radio] { margin-top: 3px; }
.cookie-consent-choice-text {
display: flex; flex-direction: column; gap: 2px;
}
.cookie-consent-choice-label {
font-size: 13px; font-weight: 600; color: #111;
}
.cookie-consent-choice-desc {
font-size: 12px; color: #6b7280; line-height: 1.5;
}
.cookie-consent-links {
margin: 0; font-size: 12px; color: #6b7280;
}
.cookie-consent-links a { color: #111; text-decoration: underline; }
.cookie-consent-error {
margin: 0; font-size: 12px; color: #b91c1c;
}
.cookie-consent-actions {
display: flex; gap: 8px; justify-content: flex-end;
}
.visually-hidden {
position: absolute; width: 1px; height: 1px;
padding: 0; margin: -1px; overflow: hidden;
clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
}
/* §14.5 / §14.6 — privacy + cookies policy pages */
.policy-page {
max-width: 720px; margin: 0 auto;
padding: 0 32px 80px;
}
.policy-header {
display: flex; align-items: center; gap: 12px;
padding: 20px 0; border-bottom: 1px solid #f3f4f6;
margin-bottom: 24px;
}
.policy-back {
background: none; border: none; cursor: pointer;
color: #6b7280; font-size: 13px; padding: 4px 8px;
}
.policy-back:hover { color: #111; }
.policy-title { font-size: 13px; font-weight: 600; color: #6b7280; }
.policy-body { line-height: 1.7; color: #111; }
.policy-body h1 { font-size: 26px; margin: 0 0 6px; font-weight: 700; }
.policy-body .policy-subtitle { color: #6b7280; margin: 0 0 24px; font-size: 14px; }
.policy-body h2 { font-size: 16px; margin: 28px 0 8px; font-weight: 600; }
.policy-body p { margin: 0 0 12px; }
.policy-body ul { margin: 0 0 16px; padding-left: 22px; }
.policy-body li { margin-bottom: 6px; }
.policy-body code {
background: #f3f4f6; padding: 1px 5px; border-radius: 3px;
font-family: ui-monospace, monospace; font-size: 12px;
}
.policy-body .policy-footnote {
margin-top: 24px; font-size: 12px; color: #6b7280;
}
.policy-table {
width: 100%; border-collapse: collapse;
font-size: 13px; margin: 8px 0 16px;
}
.policy-table th, .policy-table td {
text-align: left; padding: 8px 10px;
border-bottom: 1px solid #f3f4f6;
vertical-align: top;
}
.policy-table th {
font-size: 11px; text-transform: uppercase;
color: #6b7280; letter-spacing: 0.05em; font-weight: 600;
}
+140 -10
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import { Routes, Route, Link, useNavigate } from 'react-router-dom'
import { useEffect, useRef, useState } from 'react'
import { Routes, Route, Link, useLocation, useNavigate } from 'react-router-dom'
import { getMe, subscribeToNotifications } from './api'
import { anonymize, EVENTS, identify, track } from './lib/analytics'
import Catalog from './components/Catalog.jsx'
import Inbox from './components/Inbox.jsx'
import RFCView from './components/RFCView.jsx'
@@ -8,11 +9,16 @@ import PRView from './components/PRView.jsx'
import ProposalView from './components/ProposalView.jsx'
import ProposeModal from './components/ProposeModal.jsx'
import Landing from './components/Landing.jsx'
import Login from './components/Login.jsx'
import BetaPending from './components/BetaPending.jsx'
import Philosophy from './components/Philosophy.jsx'
import Docs from './components/Docs.jsx'
import NotificationSettings from './components/NotificationSettings.jsx'
import Admin from './components/Admin.jsx'
import ToastHost, { showToast } from './components/ToastHost.jsx'
import CookieConsentBanner from './components/CookieConsentBanner.jsx'
import Privacy from './pages/Privacy.jsx'
import Cookies from './pages/Cookies.jsx'
import './App.css'
export default function App() {
@@ -23,7 +29,68 @@ export default function App() {
const [inboxOpen, setInboxOpen] = useState(false)
const [unreadCount, setUnreadCount] = useState(0)
const [inboxTick, setInboxTick] = useState(0)
// §14.5: a tick that, when bumped, asks <CookieConsentBanner> to
// re-open even if the user has already made a choice. The settings
// "Privacy & cookies" tab dispatches a `rfc-app:cookie-consent-reopen`
// event that bumps this.
const [consentReopenTick, setConsentReopenTick] = useState(0)
const navigate = useNavigate()
const location = useLocation()
// v0.15.0 Page Viewed event taxonomy. We fire on every
// route change; the analytics wrapper itself decides whether
// anything ships out (consent + key check). The first fire is
// also covered because `location` is set on mount.
const lastPathRef = useRef(null)
useEffect(() => {
const path = location.pathname + (location.search || '')
if (lastPathRef.current === path) return
lastPathRef.current = path
track(EVENTS.PAGE_VIEWED, { path: location.pathname })
}, [location.pathname, location.search])
// v0.15.0 + #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(() => {
const handler = () => setConsentReopenTick(t => t + 1)
window.addEventListener('rfc-app:cookie-consent-reopen', handler)
return () => window.removeEventListener('rfc-app:cookie-consent-reopen', handler)
}, [])
useEffect(() => {
getMe()
@@ -72,11 +139,15 @@ export default function App() {
// The deployment is in private beta: anonymous visitors get the full
// app in read-only mode (viewer = null is passed through to every
// component), and write affordances are hidden at the component
// level. /beta-pending is the post-OAuth-rejection page reachable by
// anyone. The original §14.1 Landing surface is retained for the
// `/welcome` URL only, in case a deployment wants to link to it.
// level. v0.8.0 (§6.1 / item #6): authenticated users with
// `permission_state='pending'` also pass through as `viewer` with
// their state attached every write-gated affordance reads the
// state and treats pending the same as anonymous, while reads
// remain open. The /beta-pending page is the home root for a
// pending user.
const viewer = me?.authenticated ? me.user : null
const isAdmin = viewer && (viewer.role === 'owner' || viewer.role === 'admin')
const isPending = viewer && viewer.permission_state === 'pending'
return (
<div className="app">
@@ -92,6 +163,9 @@ export default function App() {
<Link to="/philosophy" className="header-about" title="Why this exists (§14)">
About
</Link>
<Link to="/docs" className="header-about" title="User guide">
Docs
</Link>
{viewer && (
<Link to="/settings/notifications" className="header-settings" title="Notification settings (§15)">
Settings
@@ -118,20 +192,40 @@ export default function App() {
<>
<span className="user-name">{viewer.display_name}</span>
<span className={`user-role-badge role-${viewer.role}`}>{viewer.role}</span>
<a className="btn-link" href="/auth/logout">Sign out</a>
<a
className="btn-link"
href="/auth/logout"
onClick={() => {
// v0.15.0 fire the sign-out event before the
// hard nav. The wrapper's track() is sync-enqueue;
// the underlying SDK flush is best-effort across
// navigation. anonymize() clears the user binding
// so any post-nav anonymous events on the next
// page aren't attributed to the prior user.
track(EVENTS.USER_SIGNED_OUT)
anonymize()
}}
>Sign out</a>
</>
) : (
<a className="btn-signin-header" href="/auth/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">
Sign in <span className="beta-chip">Beta</span>
</a>
</Link>
)}
</div>
</header>
{isPending && <PendingAccessBanner />}
<div className="app-body">
<Routes>
<Route path="/welcome" element={<Landing />} />
<Route path="/beta-pending" element={<BetaPending />} />
<Route path="/login" element={<Login />} />
<Route path="/beta-pending" element={<BetaPending viewer={viewer} />} />
<Route path="/philosophy" element={<PhilosophyWithSidebar viewer={viewer} />} />
<Route path="/docs" element={<DocsWithSidebar viewer={viewer} />} />
{/* §14.5 / §14.6: cookie-consent companions to /philosophy.
Available to anonymous and authenticated viewers alike. */}
<Route path="/privacy" element={<PolicyShell><Privacy /></PolicyShell>} />
<Route path="/cookies" element={<PolicyShell><Cookies /></PolicyShell>} />
{viewer && (
<Route path="/settings/notifications" element={<NotificationSettingsWithSidebar viewer={viewer} />} />
)}
@@ -159,6 +253,7 @@ export default function App() {
</div>
{proposeOpen && viewer && (
<ProposeModal
viewer={viewer}
onClose={() => setProposeOpen(false)}
onSubmitted={({ pr_number }) => {
setProposeOpen(false)
@@ -171,10 +266,18 @@ export default function App() {
<Inbox onClose={() => setInboxOpen(false)} lastChangeTick={inboxTick} />
)}
<ToastHost />
<CookieConsentBanner viewer={viewer} forceOpen={consentReopenTick} />
</div>
)
}
function PolicyShell({ children }) {
// §14.5 / §14.6 policy pages reuse the chrome-pane shape so they
// render full-width without the catalog rail. The components inside
// carry their own back affordance per Philosophy.jsx's pattern.
return <main className="chrome-pane">{children}</main>
}
function PhilosophyWithSidebar({ viewer }) {
// The chrome surfaces (§14.2 philosophy, §15 settings, §6/§17 admin)
// all use the full app body no catalog left pane, no propose modal.
@@ -187,6 +290,14 @@ function PhilosophyWithSidebar({ viewer }) {
)
}
function DocsWithSidebar({ viewer }) {
return (
<main className="chrome-pane">
<Docs authenticated={!!viewer} />
</main>
)
}
function NotificationSettingsWithSidebar({ viewer }) {
return (
<main className="chrome-pane">
@@ -203,7 +314,26 @@ function AdminWithSidebar({ viewer }) {
)
}
function PendingAccessBanner() {
// v0.8.0 thin banner shown on every page (other than /beta-pending
// itself, which carries the same message in larger form) when the
// signed-in user's `permission_state='pending'`. Sign-out works
// normally via the header affordance.
return (
<div className="pending-access-banner">
Your beta access request is in review.{' '}
<Link to="/beta-pending">Learn more </Link>
</div>
)
}
function Welcome({ viewer }) {
// v0.8.0 a pending user landing on "/" gets the same page they'd
// see at /beta-pending, inline. This is the post-OTC home root for
// a user awaiting admin grant.
if (viewer && viewer.permission_state === 'pending') {
return <BetaPending viewer={viewer} />
}
if (!viewer) {
return (
<div className="welcome">
@@ -215,7 +345,7 @@ function Welcome({ viewer }) {
</p>
<p>
Discussion and contribution are in private <strong>Beta</strong>
read freely, and <a href="/auth/login">sign in</a> if your email has
read freely, and <Link to="/login">sign in</Link> if your email has
been invited.
</p>
<p>
+159
View File
@@ -25,6 +25,131 @@ export async function getMe() {
return jsonOrThrow(res)
}
// ── v0.7.0: email + one-time-code sign-in (§6.2) ─────────────────────────
//
// The legacy /auth/login → /auth/callback OAuth flow remains during the
// migration — the new UI just no longer points at it primarily. These
// two helpers drive the Login.jsx surface.
export async function requestOtc(email, { turnstileToken } = {}) {
// v0.12.0 / roadmap item #10: when the Turnstile widget has produced
// a token, send it alongside the email so the backend can siteverify
// before the OTC dispatch. The backend treats a missing token as
// either soft-fail (no secret wired AND TURNSTILE_REQUIRED=false)
// or hard-fail (verification required) — the frontend stays
// uninvolved in the policy.
const body = { email }
if (turnstileToken) body.turnstile_token = turnstileToken
const res = await fetch('/auth/otc/request', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
return jsonOrThrow(res)
}
export async function verifyOtc(email, code, { trustDevice = false } = {}) {
// v0.11.0 — `trustDevice` is the "trust this device for 30 days"
// checkbox on the Login.jsx OTC step. When true, the server mints
// a fresh device-trust row and sets the long-lived cookie; on
// subsequent visits, the cookie skips the OTC roundtrip via
// `startDeviceTrust()`.
const res = await fetch('/auth/otc/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, code, trust_device: !!trustDevice }),
})
return jsonOrThrow(res)
}
// ── v0.8.0: open beta-access request flow (§6.1 / §14.1) ─────────────────
//
// On the first OTC sign-in, the user lands in `permission_state='pending'`
// and `/api/auth/me` reports `needs_profile=true`. The Login.jsx surface
// then prompts for first/last/why and POSTs them here. After this lands,
// the user sees the /beta-pending page until an admin grants access.
export async function submitBetaRequest({ first_name, last_name, beta_request_reason }) {
const res = await fetch('/api/auth/me/beta-request', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ first_name, last_name, beta_request_reason }),
})
return jsonOrThrow(res)
}
// ── v0.10.0: user-set passcodes after OTC (§6.2, roadmap item #8) ─────────
//
// After a successful OTC sign-in, a contributor may set a passcode and
// use email + passcode for subsequent sign-ins. OTC remains the
// forgot-passcode fallback — 5 consecutive verify failures locks the
// passcode path for 15 minutes (HTTP 423); the OTC path is unaffected.
export async function checkPasscode(email) {
// Anonymous endpoint. Returns `{has_passcode: boolean}` so the
// Login.jsx flow can decide whether to render a passcode input or
// fall back to OTC. We URL-encode the email so addresses with '+'
// round-trip cleanly.
const params = new URLSearchParams({ email })
const res = await fetch(`/auth/passcode/check?${params}`)
return jsonOrThrow(res)
}
export async function verifyPasscode(email, passcode, { trustDevice = false } = {}) {
// v0.11.0 — same trust-device opt-in as `verifyOtc`.
const res = await fetch('/auth/passcode/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, passcode, trust_device: !!trustDevice }),
})
return jsonOrThrow(res)
}
// ── v0.11.0: trust device for 30 days (§6.2, roadmap item #9) ─────────────
//
// On a returning visit with a valid device-trust cookie, `startDeviceTrust`
// re-establishes the session without an OTC / passcode roundtrip. The
// cookie is HttpOnly so the client cannot read it; the call is a pure POST
// that the browser attaches the cookie to automatically.
//
// `listMyDevices`, `revokeMyDevice`, and `revokeAllMyDevices` drive the
// /settings/devices revoke-device UI. The signed-in user is the implicit
// subject; the cookie carries the session.
export async function startDeviceTrust() {
const res = await fetch('/auth/device-trust/start', { method: 'POST' })
return jsonOrThrow(res)
}
export async function listMyDevices() {
return jsonOrThrow(await fetch('/api/auth/me/devices'))
}
export async function revokeMyDevice(deviceId) {
return jsonOrThrow(await fetch(`/api/auth/me/devices/${deviceId}`, { method: 'DELETE' }))
}
export async function revokeAllMyDevices() {
return jsonOrThrow(await fetch('/api/auth/me/devices', { method: 'DELETE' }))
}
export async function setPasscode(passcode) {
// Requires an active session — the server returns 401 if not signed
// in. The signed-in user is the implicit subject; the body carries
// only the new passcode.
const res = await fetch('/auth/passcode/set', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ passcode }),
})
return jsonOrThrow(res)
}
export async function clearPasscode() {
const res = await fetch('/auth/passcode', { method: 'DELETE' })
return jsonOrThrow(res)
}
export async function listRFCs() {
return jsonOrThrow(await fetch('/api/rfcs'))
}
@@ -508,6 +633,23 @@ export async function setQuietHours({ start, end, timezone } = {}) {
}))
}
// v0.13.0 / roadmap item #11: cookie consent (SPEC §14.5).
export async function getCookieConsent() {
return jsonOrThrow(await fetch('/api/users/me/cookie-consent'))
}
export async function setCookieConsent({ analytics, other } = {}) {
return jsonOrThrow(await fetch('/api/users/me/cookie-consent', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
essential: true,
analytics: !!analytics,
other: !!other,
}),
}))
}
export async function muteUser(userId) {
return jsonOrThrow(await fetch(`/api/users/${userId}/notification-mute`, { method: 'POST' }))
}
@@ -532,6 +674,10 @@ export async function getPhilosophy() {
return jsonOrThrow(await fetch('/api/philosophy'))
}
export async function getDocs() {
return jsonOrThrow(await fetch('/api/docs'))
}
// ---------------------------------------------------------------------------
// Slice 7: admin neighborhood (§17 admin/* + user search for the §15.8 mute
// typeahead).
@@ -557,6 +703,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 } = {}) {
const params = new URLSearchParams()
if (actionKind) params.set('action_kind', actionKind)
+199 -52
View File
@@ -16,6 +16,7 @@ import {
listAdminUsers,
setUserRole,
setUserMute,
setUserPermission,
listAuditLog,
listPermissionEvents,
listGraduationQueue,
@@ -23,6 +24,7 @@ import {
addAllowlistEmail,
removeAllowlistEmail,
} from '../api.js'
import { EVENTS, track } from '../lib/analytics.js'
const TABS = [
{ 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() {
const [users, setUsers] = useState(null)
const [busy, setBusy] = useState({})
const [error, setError] = useState(null)
const [stateFilter, setStateFilter] = useState('all')
async function refresh() {
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>
const filtered = stateFilter === 'all'
? users
: users.filter(u => (u.permission_state || 'granted') === stateFilter)
return (
<div className="admin-tab">
<header className="admin-tab-header">
<h2>Users</h2>
<p className="muted">
Role changes write to <code>permission_events</code>. The §6.2
write-mute applies to contributors only promote to admin to
remove a user's ability to write without silencing them.
The pending bucket is the beta-access review queue (§6.1 /
v0.8.0). Grant or revoke writes to <code>permission_events</code>
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>
</header>
{error && <p className="settings-note warning">{error}</p>}
<table className="admin-table">
<thead>
<tr>
<th>User</th>
<th>Role</th>
<th>Write-muted</th>
<th>Last seen</th>
</tr>
</thead>
<tbody>
{users.map(u => (
<tr key={u.id}>
<td>
<div className="user-cell">
<span className="user-handle">@{u.gitea_login}</span>
<span className="muted">{u.display_name}</span>
</div>
</td>
<td>
<select
value={u.role}
onChange={e => changeRole(u.id, e.target.value)}
disabled={!!busy[u.id]}
>
<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 => 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>
<div className="admin-filter-chips">
{STATE_CHIPS.map(chip => (
<button
key={chip.value}
type="button"
className={`admin-chip${stateFilter === chip.value ? ' active' : ''}`}
onClick={() => setStateFilter(chip.value)}
>
{chip.label} <span className="admin-chip-count">{counts[chip.value] ?? 0}</span>
</button>
))}
</div>
{filtered.length === 0 ? (
<p className="muted">No users in this bucket.</p>
) : (
<table className="admin-table admin-users-table">
<thead>
<tr>
<th>User</th>
<th>State</th>
<th>Role</th>
<th>Write-muted</th>
<th>Signed up</th>
<th>Last seen</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{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>
)
}
+43 -19
View File
@@ -1,38 +1,62 @@
// BetaPending.jsx the post-OAuth-rejection page.
// BetaPending.jsx the "your request is in review" page (§6.1 / §14.1).
//
// When a deployment is in private-beta mode (i.e. its `allowed_emails`
// table has any rows), the OAuth callback redirects unrecognised users
// here instead of provisioning them. The framework cannot know the
// deployment operator's preferred contact channel so the deployment
// supplies one via VITE_BETA_CONTACT (an email, URL, or short
// instruction). If unset, we render a generic ask-the-operator line.
// v0.3.0 introduced this surface as the post-OAuth-rejection page (a
// user whose email wasn't on the `allowed_emails` table bounced here).
// v0.8.0 (roadmap item #6) repurposes it as the post-OTC pending-grant
// page: any authenticated user whose `permission_state='pending'` lands
// here on root visits, after a fresh-OTC profile capture, or via the
// header "Your beta access is in review" affordance.
//
// The deployment supplies a contact channel via VITE_BETA_CONTACT (an
// email, URL, or short instruction). If unset, we render a generic
// ask-the-operator line.
import { Link } from 'react-router-dom'
export default function BetaPending() {
export default function BetaPending({ viewer }) {
const contact = import.meta.env.VITE_BETA_CONTACT || ''
const isPending = viewer?.permission_state === 'pending'
return (
<div className="beta-pending">
<div className="beta-pending-inner">
<h1>{import.meta.env.VITE_APP_NAME} is in private Beta.</h1>
<p>
Discussion and contribution are gated to invited emails for now.
Reading is open every super-draft, every active RFC, and every
public conversation is visible without signing in.
</p>
<h1>
{isPending
? 'Your request is in review.'
: `${import.meta.env.VITE_APP_NAME} is in private Beta.`}
</h1>
{isPending ? (
<>
<p>
Thanks for telling us a bit about yourself. The deployment's
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>
While you wait, the catalog on the left lists every super-draft
and active RFC in the framework reading is open. Discussion
and contribution unlock once your access is granted.
</p>
</>
) : (
<p>
Discussion and contribution are gated to invited contributors for
now. Reading is open every super-draft, every active RFC, and
every public conversation is visible without signing in.
</p>
)}
{contact ? (
<p className="beta-pending-contact">
To request access, contact <strong>{contact}</strong> with the
email address you'd like to sign in with.
Questions? Contact <strong>{contact}</strong>.
</p>
) : (
<p className="beta-pending-contact">
To request access, contact the deployment operator with the email
address you'd like to sign in with.
Questions? Contact the deployment operator.
</p>
)}
<div className="beta-pending-actions">
<Link className="btn-primary" to="/">Browse as a guest</Link>
<Link className="btn-primary" to="/">Browse the catalog</Link>
<Link className="btn-link-quiet" to="/philosophy">Read the philosophy </Link>
</div>
</div>
@@ -0,0 +1,164 @@
// CookieConsentBanner.jsx v0.13.0 / roadmap item #11 / SPEC §14.5.
//
// A non-modal bottom-of-page banner that asks the user once which
// categories of cookies they accept. The framework's strictly-necessary
// cookies (session, signed payloads) are always on; the user can opt in
// or out of analytics (which gates the §13 SDK landing in v0.15.0) and
// "other" (third-party embeds, social widgets if a deployment adds any).
//
// Visible until the user makes a choice. Hides itself once the choice
// is recorded. The /settings/notifications "Privacy & cookies" tab
// surfaces the current choice and re-opens the banner via `forceOpen`.
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { getConsent, setConsent, hasChosen, hydrateFromServer } from '../lib/consent.js'
import { getCookieConsent, setCookieConsent } from '../api.js'
const CATEGORIES = [
{
key: 'essential-only',
label: 'Essential only',
description: 'Just the cookies the app needs to keep you signed in and protect submissions. (Sign-in session, signed payloads.)',
flags: { analytics: false, other: false },
},
{
key: 'essential-analytics',
label: 'Essential + analytics',
description: 'Adds anonymous usage analytics so the framework can see which surfaces get used. No third-party scripts beyond the analytics SDK.',
flags: { analytics: true, other: false },
},
{
key: 'essential-analytics-other',
label: 'Essential + analytics + other',
description: 'Adds analytics plus any third-party embeds the deployment configures (e.g. social widgets). Choose this if you want the full surface.',
flags: { analytics: true, other: true },
},
]
function selectionKeyFor(consent) {
if (consent.analytics && consent.other) return 'essential-analytics-other'
if (consent.analytics && !consent.other) return 'essential-analytics'
return 'essential-only'
}
export default function CookieConsentBanner({ viewer, forceOpen, onClosed }) {
const [open, setOpen] = useState(() => forceOpen || !hasChosen())
const [choice, setChoice] = useState(() => selectionKeyFor(getConsent()))
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
// When forceOpen flips (settings "Change" affordance), re-render the
// banner and pre-select the user's current choice.
useEffect(() => {
if (forceOpen) {
setOpen(true)
setChoice(selectionKeyFor(getConsent()))
}
}, [forceOpen])
// Server-side hydrate for authenticated viewers per the v0.13.0
// precedence rule: a server row overrides local; absent server row,
// upload the local choice.
useEffect(() => {
if (!viewer?.user_id) return
let cancelled = false
getCookieConsent()
.then(record => {
if (cancelled) return
if (record.recorded_at) {
// Server is authoritative adopt + hide the banner unless
// the settings page forced it open.
hydrateFromServer(record)
setChoice(selectionKeyFor(record))
if (!forceOpen) setOpen(false)
} else if (hasChosen()) {
// Local has a choice the server doesn't know about yet push.
const local = getConsent()
setCookieConsent({ analytics: local.analytics, other: local.other })
.then(r => hydrateFromServer(r))
.catch(() => {})
}
})
.catch(() => {
// Network or auth error leave the local-only path in place.
})
return () => { cancelled = true }
}, [viewer?.user_id]) // eslint-disable-line react-hooks/exhaustive-deps
if (!open) return null
async function save() {
setSaving(true)
setError(null)
const picked = CATEGORIES.find(c => c.key === choice) || CATEGORIES[0]
try {
setConsent(picked.flags)
if (viewer?.user_id) {
// Best-effort server persistence. A failure here doesn't
// invalidate the local choice; the banner still hides because
// the user expressed their preference. The server can catch up
// on the next sign-in via the hydrate path above.
try {
const r = await setCookieConsent(picked.flags)
hydrateFromServer(r)
} catch (e) {
setError(`Saved locally; server sync failed (${e.message}).`)
}
}
setOpen(false)
onClosed?.()
} finally {
setSaving(false)
}
}
return (
<div className="cookie-consent-banner" role="region" aria-label="Cookie consent">
<div className="cookie-consent-body">
<h2 className="cookie-consent-title">Cookies &amp; privacy</h2>
<p className="cookie-consent-intro">
This site uses cookies. Essential cookies keep you signed in and
protect your submissions; analytics and other cookies are
optional. Choose what you allow you can change this any time
from <Link to="/settings/notifications">Settings &rarr; Privacy &amp; cookies</Link>.
</p>
<fieldset className="cookie-consent-choices">
<legend className="visually-hidden">Cookie categories</legend>
{CATEGORIES.map(c => (
<label key={c.key} className={`cookie-consent-choice ${choice === c.key ? 'is-selected' : ''}`}>
<input
type="radio"
name="cookie-consent-choice"
value={c.key}
checked={choice === c.key}
onChange={() => setChoice(c.key)}
disabled={saving}
/>
<span className="cookie-consent-choice-text">
<span className="cookie-consent-choice-label">{c.label}</span>
<span className="cookie-consent-choice-desc">{c.description}</span>
</span>
</label>
))}
</fieldset>
<p className="cookie-consent-links">
<Link to="/cookies">Cookies policy</Link>
<span aria-hidden> &middot; </span>
<Link to="/privacy">Privacy policy</Link>
</p>
{error && <p className="cookie-consent-error">{error}</p>}
<div className="cookie-consent-actions">
<button
type="button"
className="btn-primary"
onClick={save}
disabled={saving}
>
{saving ? 'Saving…' : 'Save choice'}
</button>
</div>
</div>
</div>
)
}
+51
View File
@@ -0,0 +1,51 @@
// `/docs` the user-facing guide.
//
// Sibling of Philosophy.jsx: same chrome, same data path, different
// source file. Renders DOCS.md verbatim with light chrome around it.
// Reachable anonymously, same as `/philosophy`, so a visitor can read
// the guide before deciding to sign in.
import { useEffect, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import MarkdownPreview from './MarkdownPreview.jsx'
import { getDocs } from '../api.js'
export default function Docs({ authenticated }) {
const [body, setBody] = useState('')
const [error, setError] = useState(null)
const [loading, setLoading] = useState(true)
const navigate = useNavigate()
useEffect(() => {
let active = true
getDocs()
.then(r => { if (active) setBody(r.body || '') })
.catch(e => { if (active) setError(e.message || String(e)) })
.finally(() => { if (active) setLoading(false) })
return () => { active = false }
}, [])
return (
<div className="philosophy-page">
<header className="philosophy-header">
<button
className="philosophy-back"
onClick={() => (history.length > 1 ? navigate(-1) : navigate('/'))}
>
Back
</button>
<span className="philosophy-title">User guide</span>
{!authenticated && (
<Link className="philosophy-signin" to="/">Home</Link>
)}
</header>
<article className="philosophy-body">
{loading && <p className="muted">Loading</p>}
{error && <p className="error">Could not load the guide: {error}</p>}
{!loading && !error && (
<MarkdownPreview content={body} />
)}
</article>
</div>
)
}
+1 -1
View File
@@ -28,7 +28,7 @@ export default function Landing() {
first RFC defining <em>human</em>. Build the dictionary first.
</p>
<a className="btn-signin" href="/auth/login">Sign in with Gitea</a>
<Link className="btn-signin" to="/login">Sign in</Link>
<Link className="secondary-link" to="/philosophy">Read the full philosophy </Link>
<ul className="landing-deck">
+714
View File
@@ -0,0 +1,714 @@
// Login.jsx the composed sign-in surface (§6.2) after the v0.12.0
// (CloudFlare Turnstile gate on OTC dispatch, roadmap item #10) /
// v0.10.0 (passcodes, roadmap item #8) rebase onto v0.8.0
// (beta-access-request capture, §6.1 / §14.1, roadmap item #6). v0.7.0
// (roadmap item #5) established the email + OTC scaffolding the later
// releases extended.
//
// v0.12.0: the email-entry step renders a Turnstile widget. The token
// it produces is sent to `/auth/otc/request` alongside the email. The
// passcode step also renders a widget for the "Use a code instead"
// fallback dispatch (same backend endpoint, same gate). When the
// `VITE_TURNSTILE_SITE_KEY` build var is unset, the widget renders
// nothing the form still submits and the backend's TURNSTILE_REQUIRED
// policy decides admission.
//
// Four-to-six-step flow (most users see three; the longest path is
// pending-user with no passcode, who never sees the passcode steps):
//
// 1. 'email' Enter email GET /auth/passcode/check.
// * has_passcode=true step 'passcode'.
// * has_passcode=false POST /auth/otc/request,
// step 'code'.
// 429 on either dispatch surfaces a "wait a
// moment" hint and keeps the user on step 1.
//
// 2a. 'passcode' Enter passcode POST /auth/passcode/verify.
// * 200 redirect to "/".
// * 423 (lockout, 5 consecutive failures)
// auto-fall back to OTC by requesting a fresh
// code and advancing to step 'code'.
// * 400 wrong passcode; user can retry or
// click "Use a code instead" to fall back
// manually.
//
// 2b. 'code' Enter the six-digit code POST /auth/otc/verify.
// On 200, fetch /api/auth/me and branch:
// * needs_profile === true 'capture-profile'
// * has_passcode === false 'offer-passcode'
// * otherwise redirect to "/".
// needs_profile WINS over has_passcode a
// pending user goes through the §6.1 capture
// flow first; setting a passcode while waiting
// for admin grant gains them nothing.
// Cmd/Ctrl+Enter on the code field is the
// keyboard shortcut.
//
// 3. 'capture-profile' (v0.8.0, §6.1) First name, last name, and "why
// I should be included in the beta" POST
// /api/auth/me/beta-request redirect to
// /beta-pending. The user's row stays
// permission_state='pending' until an admin
// grants access; they can set a passcode later
// from settings, or on a future sign-in once
// granted.
//
// 4a. 'offer-passcode' (v0.10.0) "Set a passcode for faster sign-in
// next time?" Yes 'set-passcode'. Skip "/".
//
// 4b. 'set-passcode' Pick a passcode (420 chars) POST
// /auth/passcode/set redirect to "/". A
// "Skip for now" link also redirects to "/".
//
// Server-side, /auth/otc/request returns 202 uniformly and
// /auth/passcode/check returns has_passcode=false for an unknown
// email, so this surface never distinguishes "we couldn't reach you"
// from "we don't know you" an unknown email always lands in the
// OTC path with no account-enumeration signal.
//
// The legacy Gitea OAuth callback remains at /auth/login
// /auth/callback during the v0.7.0 migration; we surface a "Sign in
// with Gitea" link as a fallback in the footer so users with active
// OAuth sessions or older invite emails still have a path. We hide
// the fallback on 'capture-profile' so a half-captured pending user
// doesn't bail out into the OAuth path mid-form.
import { useEffect, useRef, useState } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import {
requestOtc,
verifyOtc,
submitBetaRequest,
checkPasscode,
verifyPasscode,
setPasscode as apiSetPasscode,
startDeviceTrust,
} from '../api'
import TurnstileWidget, { turnstileEnabled } from './TurnstileWidget'
import { EVENTS, track } from '../lib/analytics'
export default function Login() {
// Steps: 'email' 'passcode' or 'code' (on the OTC path, after
// verify) one of: 'capture-profile' (pending user), 'offer-passcode'
// (no passcode yet), or straight to "/". 'set-passcode' is reached
// from 'offer-passcode'.
const [step, setStep] = useState('email')
const [email, setEmail] = useState('')
const [code, setCode] = useState('')
const [passcode, setPasscode] = useState('')
const [newPasscode, setNewPasscode] = useState('')
// v0.11.0 "trust this device for 30 days" checkbox, shared by the
// OTC and passcode verify steps. The flag rides on the verify POST;
// a checked box mints a device-trust row server-side and sets the
// long-lived `rfc_device_trust` cookie. Defaults off so the user
// makes an explicit choice auth credentials shouldn't persist by
// default.
const [trustDevice, setTrustDevice] = useState(false)
// v0.8.0 capture-profile fields.
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
const [reason, setReason] = useState('')
const [status, setStatus] = useState('')
const [busy, setBusy] = useState(false)
// v0.12.0: Turnstile token captured by the widget. `null` means no
// challenge solved yet (or the site key is unset, in which case the
// widget surfaces null on mount). The token is single-use; we clear
// it back to null right after we send it so a second request on the
// same form remount re-challenges. `turnstileReady` is true once the
// widget has produced a token OR the widget is not configured at
// build time (no site key) the submit button reads from it so the
// form locks up when the operator has wired Turnstile but the user
// hasn't solved the challenge yet.
const [turnstileToken, setTurnstileToken] = useState(null)
const turnstileOn = turnstileEnabled()
const turnstileReady = !turnstileOn || !!turnstileToken
const emailRef = useRef(null)
const codeRef = useRef(null)
const passcodeRef = useRef(null)
const newPasscodeRef = useRef(null)
const firstNameRef = useRef(null)
const navigate = useNavigate()
useEffect(() => {
if (step === 'email') emailRef.current?.focus()
else if (step === 'code') codeRef.current?.focus()
else if (step === 'passcode') passcodeRef.current?.focus()
else if (step === 'capture-profile') firstNameRef.current?.focus()
else if (step === 'set-passcode') newPasscodeRef.current?.focus()
}, [step])
// v0.11.0 on mount, try the device-trust cookie path. If the
// browser still carries a valid `rfc_device_trust` cookie from a
// prior "trust this device" gesture, the server re-establishes the
// session without any user input and we redirect home. The cookie
// is HttpOnly so we can't peek at it; we just call the endpoint and
// see whether it returns 200. 401 (no cookie / invalid / revoked)
// is the structural-silent case the user proceeds to the email
// step normally. We do not surface any UI about the attempt; a
// failure should be invisible.
useEffect(() => {
let cancelled = false
;(async () => {
try {
await startDeviceTrust()
if (!cancelled) {
// v0.15.0 analytics: device-trust cookie path is one of
// three sign-in methods the taxonomy distinguishes.
track(EVENTS.USER_SIGNED_IN, { method: 'trust-device' })
window.location.assign('/')
}
} catch (_) {
// No trusted device fall through to the email step.
}
})()
return () => { cancelled = true }
}, [])
async function submitEmail(e) {
e.preventDefault()
if (!email.trim() || !email.includes('@')) {
setStatus('Enter a valid email address.')
return
}
setBusy(true)
setStatus('')
try {
const { has_passcode } = await checkPasscode(email.trim())
if (has_passcode) {
setStep('passcode')
setStatus('')
} else {
await requestOtc(email.trim(), { turnstileToken })
// v0.12.0: the token is single-use; drop it so a re-request
// from the code step (via "Use a different email" back to
// email) starts with a fresh challenge.
setTurnstileToken(null)
setStep('code')
setStatus('Check your inbox — a six-digit code is on the way.')
}
} catch (err) {
// Any failure consumes the token from CloudFlare's side; clear
// so the widget re-renders a fresh challenge on retry.
setTurnstileToken(null)
if (err.status === 429) {
setStatus('Slow down — wait a minute before requesting another code.')
} else if (err.status === 400) {
setStatus("Couldn't verify you're human. Please retry the challenge.")
} else {
setStatus(err.message || 'Could not start sign-in. Try again.')
}
} finally {
setBusy(false)
}
}
async function submitPasscode(e) {
if (e) e.preventDefault()
if (!passcode.trim()) {
setStatus('Enter your passcode.')
return
}
setBusy(true)
setStatus('')
try {
await verifyPasscode(email.trim(), passcode.trim(), { trustDevice })
// v0.15.0 analytics: passcode is the second of three
// sign-in methods. trust-device gets credited separately when
// the cookie-driven path fires above.
track(EVENTS.USER_SIGNED_IN, { method: 'passcode' })
// Reload so App.jsx's getMe() picks up the fresh session. A
// returning passcode user is by definition already past the
// §6.1 capture step (they couldn't have set a passcode while
// pending), so we go straight to "/".
window.location.assign('/')
} catch (err) {
if (err.status === 423) {
// Lockout auto-fall back to OTC. The OTC request endpoint
// is independent of the passcode lockout, so this lands a
// fresh code in the user's inbox immediately.
setPasscode('')
try {
// v0.12.0: pass whatever token the widget on the passcode
// step has produced. If the operator has Turnstile required
// and the user hasn't solved the passcode-step widget, the
// backend refuses and we bounce them back to email-entry
// with a clear status (see catch below).
await requestOtc(email.trim(), { turnstileToken })
setTurnstileToken(null)
setStep('code')
setStatus(
'Too many failed attempts. We sent a one-time code to your email — use it to sign in.',
)
} catch (e2) {
setTurnstileToken(null)
if (e2.status === 429) {
setStep('code')
setStatus(
'Too many failed attempts. Wait a minute, then request a one-time code to sign in.',
)
} else if (e2.status === 400) {
setStep('email')
setStatus(
'Too many failed attempts. Solve the challenge below to receive a one-time code.',
)
} else {
setStatus(
'Too many failed attempts. Use the "Use a code instead" link to sign in via email.',
)
}
}
} else {
setStatus('Wrong passcode. Try again, or use a one-time code instead.')
}
setBusy(false)
}
}
async function submitCode(e) {
if (e) e.preventDefault()
if (!code.trim() || code.trim().length !== 6) {
setStatus('Enter the six-digit code from your email.')
return
}
setBusy(true)
setStatus('')
try {
await verifyOtc(email.trim(), code.trim(), { trustDevice })
// v0.15.0 analytics: OTC is the third sign-in method.
// We fire it here regardless of whether the user then lands
// in capture-profile or offer-passcode sign-in has happened
// server-side either way.
track(EVENTS.USER_SIGNED_IN, { method: 'otc' })
// OTC verified the server has signed in the user. Fetch the
// canonical /api/auth/me to decide where to land:
// * needs_profile §6.1 capture (then /beta-pending).
// * no passcode §6.2 offer-passcode (then /).
// * otherwise /.
// needs_profile wins over has_passcode: a pending user can't yet
// do anything that benefits from faster sign-in, so we don't
// distract them with the passcode offer mid-admission.
const meResp = await fetch('/api/auth/me', { credentials: 'include' })
let me = null
if (meResp.ok) {
try {
me = await meResp.json()
} catch (_) {
me = null
}
}
if (me?.needs_profile === true) {
setStep('capture-profile')
setStatus('')
setBusy(false)
return
}
if (me?.has_passcode === false) {
setStep('offer-passcode')
setStatus('')
setBusy(false)
return
}
// Either /me returned the granted-with-passcode shape, or the
// call failed but the session cookie is set fall through to
// a hard reload so App.jsx re-fetches and renders accordingly.
window.location.assign('/')
} catch (err) {
setStatus('That code is invalid or expired. Try again, or request a new code.')
setBusy(false)
}
}
async function submitProfile(e) {
if (e) e.preventDefault()
const fn = firstName.trim()
const ln = lastName.trim()
const why = reason.trim()
if (!fn || !ln || !why) {
setStatus('All three fields are required.')
return
}
setBusy(true)
setStatus('')
try {
await submitBetaRequest({
first_name: fn,
last_name: ln,
beta_request_reason: why,
})
// v0.15.0 analytics: a successful capture-profile submit is
// the moment a beta-access request lands. No PII in the event
// body (no name, no reason text); the count + timestamp is
// what the funnel needs.
track(EVENTS.BETA_ACCESS_REQUESTED)
// Hard-load so App.jsx re-fetches /api/auth/me and picks up
// the captured fields. The user stays permission_state='pending'
// until an admin grants access the next thing they should
// see is the "your request is in review" page.
window.location.assign('/beta-pending')
} catch (err) {
setStatus(err.message || 'Could not submit your request. Try again.')
setBusy(false)
}
}
async function submitNewPasscode(e) {
if (e) e.preventDefault()
const pc = newPasscode.trim()
if (pc.length < 4) {
setStatus('Passcode must be at least 4 characters.')
return
}
setBusy(true)
setStatus('')
try {
await apiSetPasscode(pc)
window.location.assign('/')
} catch (err) {
// 422 carries the validation message verbatim (denylist /
// length); surface it as-is so the user knows what to change.
setStatus(err.message || 'Could not set passcode. Try a different one.')
setBusy(false)
}
}
function onCodeKey(e) {
// §6.2 ergonomic: Cmd/Ctrl+Enter submits from the code field.
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
submitCode(e)
}
}
function onPasscodeKey(e) {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
submitPasscode(e)
}
}
function onReasonKey(e) {
// §6.1 ergonomic: Cmd/Ctrl+Enter submits the capture form from
// the reason textarea (the multi-line input that would otherwise
// swallow Enter as a newline).
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
submitProfile(e)
}
}
function onNewPasscodeKey(e) {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
submitNewPasscode(e)
}
}
function backToEmail() {
setStep('email')
setCode('')
setPasscode('')
setStatus('')
}
async function fallbackToOtc() {
// Manual "Use a code instead" from the passcode step. Same shape
// as the email-step OTC dispatch. v0.12.0: pass through whatever
// Turnstile token the passcode-step widget has produced (or null
// when the widget is disabled at build time).
setBusy(true)
setStatus('')
try {
await requestOtc(email.trim(), { turnstileToken })
setTurnstileToken(null)
setPasscode('')
setStep('code')
setStatus('Check your inbox — a six-digit code is on the way.')
} catch (err) {
setTurnstileToken(null)
if (err.status === 429) {
setStatus('Slow down — wait a minute before requesting another code.')
} else if (err.status === 400) {
// v0.12.0: the widget rejected or no token was sent. Bounce
// the user back to the email step so they get a fresh
// challenge alongside the email input.
setStep('email')
setStatus("Couldn't verify you're human. Please retry the challenge.")
} else {
setStatus(err.message || 'Could not request a code. Try again.')
}
} finally {
setBusy(false)
}
}
function skipPasscodeOffer() {
window.location.assign('/')
}
return (
<div className="otc-login">
<div className="otc-login-inner">
<h1>Sign in</h1>
{step === 'email' && (
<form onSubmit={submitEmail}>
<p className="otc-hint">
Enter your email. If you've set a passcode, you'll enter that
next; otherwise we'll send a one-time code.
</p>
<input
ref={emailRef}
type="email"
autoComplete="email"
value={email}
onChange={e => setEmail(e.target.value)}
placeholder="you@example.com"
required
disabled={busy}
/>
{/*
v0.12.0: CloudFlare Turnstile widget. Renders nothing
when VITE_TURNSTILE_SITE_KEY is unset (and turnstileReady
defaults to true in that case so the submit gate doesn't
lock up). On every challenge the widget calls onToken
with the fresh token; we feed it to /auth/otc/request.
*/}
<TurnstileWidget onToken={setTurnstileToken} />
<button
type="submit"
disabled={busy || !email.trim() || !turnstileReady}
>
{busy ? 'Checking…' : 'Continue'}
</button>
</form>
)}
{step === 'passcode' && (
<form onSubmit={submitPasscode}>
<p className="otc-hint">
Enter the passcode for <strong>{email}</strong>.
</p>
<input
ref={passcodeRef}
type="password"
autoComplete="current-password"
value={passcode}
onChange={e => setPasscode(e.target.value)}
onKeyDown={onPasscodeKey}
placeholder="Your passcode"
required
disabled={busy}
/>
{/* v0.11.0 trust device for 30 days. The checkbox lives
on the verify step so the user makes the trust gesture
in the same breath as signing in. Off by default; the
user opts in deliberately. */}
<label className="otc-trust-device">
<input
type="checkbox"
checked={trustDevice}
onChange={e => setTrustDevice(e.target.checked)}
disabled={busy}
/>
<span>Trust this device for 30 days</span>
</label>
{/*
v0.12.0: a second Turnstile widget for the
"Use a code instead" fallback dispatch. The passcode
verify path does not consume a Turnstile token (it has
its own 5-attempt lockout shape from v0.10.0), but if
the user falls back to OTC the same /auth/otc/request
endpoint runs and needs a token. We render the widget
on this step too so the fallback works without bouncing
back to email-entry first.
*/}
<TurnstileWidget onToken={setTurnstileToken} />
<div className="otc-actions">
<button type="submit" disabled={busy || !passcode.trim()}>
{busy ? 'Signing in…' : 'Sign in'}
</button>
<button
type="button"
className="btn-link-quiet"
onClick={fallbackToOtc}
disabled={busy || !turnstileReady}
>
Use a code instead
</button>
<button
type="button"
className="btn-link-quiet"
onClick={backToEmail}
disabled={busy}
>
Use a different email
</button>
</div>
</form>
)}
{step === 'code' && (
<form onSubmit={submitCode}>
<p className="otc-hint">
Enter the six-digit code we sent to <strong>{email}</strong>.
</p>
<input
ref={codeRef}
type="text"
inputMode="numeric"
pattern="[0-9]*"
autoComplete="one-time-code"
maxLength={6}
value={code}
onChange={e => setCode(e.target.value.replace(/\D/g, ''))}
onKeyDown={onCodeKey}
placeholder="123456"
required
disabled={busy}
/>
{/* v0.11.0 trust device for 30 days. Same shape as the
passcode step; the user opts in deliberately. */}
<label className="otc-trust-device">
<input
type="checkbox"
checked={trustDevice}
onChange={e => setTrustDevice(e.target.checked)}
disabled={busy}
/>
<span>Trust this device for 30 days</span>
</label>
<div className="otc-actions">
<button type="submit" disabled={busy || code.length !== 6}>
{busy ? 'Signing in…' : 'Sign in'}
</button>
<button
type="button"
className="btn-link-quiet"
onClick={backToEmail}
disabled={busy}
>
Use a different email
</button>
</div>
<p className="otc-shortcut-hint">
Tip: <kbd></kbd>+<kbd>Enter</kbd> (or <kbd>Ctrl</kbd>+<kbd>Enter</kbd>) to sign in.
</p>
</form>
)}
{step === 'capture-profile' && (
<form onSubmit={submitProfile}>
<p className="otc-hint">
You're signed in. {import.meta.env.VITE_APP_NAME} is in private
beta tell us a bit about yourself and an admin will review
your request.
</p>
<label className="otc-field-label">First name</label>
<input
ref={firstNameRef}
type="text"
autoComplete="given-name"
value={firstName}
onChange={e => setFirstName(e.target.value)}
required
disabled={busy}
maxLength={120}
/>
<label className="otc-field-label">Last name</label>
<input
type="text"
autoComplete="family-name"
value={lastName}
onChange={e => setLastName(e.target.value)}
required
disabled={busy}
maxLength={120}
/>
<label className="otc-field-label">
Why you'd like to be included in the beta
</label>
<textarea
value={reason}
onChange={e => setReason(e.target.value)}
onKeyDown={onReasonKey}
required
disabled={busy}
rows={5}
maxLength={4000}
placeholder="A sentence or two is plenty."
/>
<div className="otc-actions">
<button
type="submit"
disabled={busy || !firstName.trim() || !lastName.trim() || !reason.trim()}
>
{busy ? 'Submitting…' : 'Submit request'}
</button>
</div>
<p className="otc-shortcut-hint">
Tip: <kbd></kbd>+<kbd>Enter</kbd> (or <kbd>Ctrl</kbd>+<kbd>Enter</kbd>) to submit.
</p>
</form>
)}
{step === 'offer-passcode' && (
<div className="otc-offer-passcode">
<p className="otc-hint">
You're signed in. Want to set a passcode for faster sign-in
next time? You can always use a one-time code instead and
you can change or remove the passcode from your settings.
</p>
<div className="otc-actions">
<button
type="button"
onClick={() => { setStep('set-passcode'); setStatus('') }}
>
Set a passcode
</button>
<button
type="button"
className="btn-link-quiet"
onClick={skipPasscodeOffer}
>
Skip for now
</button>
</div>
</div>
)}
{step === 'set-passcode' && (
<form onSubmit={submitNewPasscode}>
<p className="otc-hint">
Pick a passcode (420 characters). You'll use it with your
email to sign in next time.
</p>
<input
ref={newPasscodeRef}
type="password"
autoComplete="new-password"
value={newPasscode}
onChange={e => setNewPasscode(e.target.value)}
onKeyDown={onNewPasscodeKey}
placeholder="New passcode"
required
disabled={busy}
minLength={4}
maxLength={20}
/>
<div className="otc-actions">
<button type="submit" disabled={busy || newPasscode.trim().length < 4}>
{busy ? 'Saving…' : 'Save passcode'}
</button>
<button
type="button"
className="btn-link-quiet"
onClick={skipPasscodeOffer}
disabled={busy}
>
Skip for now
</button>
</div>
</form>
)}
{status && <p className="otc-status">{status}</p>}
{step !== 'capture-profile' && (
<p className="otc-fallback">
<Link to="/philosophy">Read the philosophy </Link>
<span className="otc-fallback-sep">·</span>
<a href="/auth/login">Sign in with Gitea (fallback)</a>
</p>
)}
</div>
</div>
)
}
@@ -28,7 +28,15 @@ import {
unmuteUser,
muteUser,
searchUsers,
getCookieConsent,
getMe,
setPasscode,
clearPasscode,
listMyDevices,
revokeMyDevice,
revokeAllMyDevices,
} from '../api.js'
import { getConsent, onConsentChange, hydrateFromServer } from '../lib/consent.js'
const CHURN_REFUSAL = 'Per-commit and per-message email is intentionally not offered. The digest aggregates this activity weekly.'
@@ -48,10 +56,338 @@ export default function NotificationSettings({ viewer }) {
<QuietHoursSection />
<WatchesSection />
<MutesSection viewer={viewer} />
<SignInSection />
<DevicesSection />
<PrivacyCookiesSection />
</div>
)
}
// v0.11.0: trusted devices (§6.2, roadmap item #9)
//
// Lists the user's active device-trust rows and lets them revoke any
// or all. A revoke marks the row dead server-side; the matching
// device's next visit will be refused and the cookie cleared. The
// surface intentionally does not single out the row whose cookie the
// current request carries every row reads identically, so the user
// can revoke "this device" alongside any other from a single page.
function DevicesSection() {
const [devices, setDevices] = useState(null)
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
useEffect(() => {
refresh()
}, [])
async function refresh() {
try {
const { items } = await listMyDevices()
setDevices(items || [])
setError(null)
} catch (e) {
setError(e.message || 'Could not load trusted devices.')
}
}
async function onRevoke(deviceId) {
setBusy(true)
try {
await revokeMyDevice(deviceId)
await refresh()
} catch (e) {
setError(e.message || 'Could not revoke device.')
} finally {
setBusy(false)
}
}
async function onRevokeAll() {
if (!confirm('Revoke trust on every device, including this one? You will be asked to sign in via email next time.')) {
return
}
setBusy(true)
try {
await revokeAllMyDevices()
await refresh()
} catch (e) {
setError(e.message || 'Could not revoke devices.')
} finally {
setBusy(false)
}
}
return (
<SectionShell
title="Trusted devices"
subtitle="Devices where you've checked “Trust this device for 30 days.” Sign-in is automatic on these devices until the trust expires or you revoke it."
>
{devices === null && <p className="settings-note">Loading</p>}
{devices !== null && devices.length === 0 && (
<p className="device-empty">
No trusted devices. Sign in and check Trust this device for 30 days
to add the device you're on now.
</p>
)}
{devices !== null && devices.length > 0 && (
<>
<ul className="device-list">
{devices.map(d => (
<li key={d.id} className="device-list-item">
<div className="device-meta">
<div className="device-ua">{d.user_agent || 'Unknown device'}</div>
<div className="device-stamps">
Trusted {formatStamp(d.created_at)} · last seen {formatStamp(d.last_seen_at)} · expires {formatStamp(d.expires_at)}
</div>
</div>
<button
type="button"
onClick={() => onRevoke(d.id)}
disabled={busy}
title="Revoke trust on this device"
>
Revoke
</button>
</li>
))}
</ul>
<button
type="button"
className="device-revoke-all"
onClick={onRevokeAll}
disabled={busy}
>
Revoke all devices
</button>
</>
)}
{error && <p className="settings-note warning">{error}</p>}
</SectionShell>
)
}
function formatStamp(stamp) {
// The server emits SQLite `datetime('now')` strings (UTC, no
// timezone marker). Parse defensively; fall back to the raw stamp
// if Date can't make sense of it.
if (!stamp) return '—'
const d = new Date(stamp.replace(' ', 'T') + 'Z')
if (Number.isNaN(d.getTime())) return stamp
return d.toLocaleString()
}
// §6.2 sign-in (v0.10.0 / roadmap item #8): passcode management
function SignInSection() {
// Source of truth for `has_passcode` and `passcode_set_at` is the
// /api/auth/me payload (v0.10.0 added both fields). We re-read after
// every mutation so the surface reflects what just landed.
const [me, setMe] = useState(null)
const [error, setError] = useState(null)
const [mode, setMode] = useState('idle') // 'idle' | 'set' | 'change'
const [draft, setDraft] = useState('')
const [busy, setBusy] = useState(false)
const [savedNote, setSavedNote] = useState('')
useEffect(() => {
getMe()
.then(payload => setMe(payload.user || null))
.catch(e => setError(e.message))
}, [])
async function refresh() {
const payload = await getMe()
setMe(payload.user || null)
}
async function save(e) {
if (e) e.preventDefault()
const pc = draft.trim()
if (pc.length < 4) {
setError('Passcode must be at least 4 characters.')
return
}
setBusy(true)
setError(null)
setSavedNote('')
try {
await setPasscode(pc)
setDraft('')
setMode('idle')
setSavedNote('Passcode saved.')
await refresh()
} catch (err) {
// 422 carries the validation message verbatim (denylist /
// length); surface it as-is so the user knows what to change.
setError(err.message || 'Could not save passcode. Try a different one.')
} finally {
setBusy(false)
setTimeout(() => setSavedNote(''), 2000)
}
}
async function remove() {
if (!confirm('Remove your passcode? You will sign in with a one-time code next time.')) {
return
}
setBusy(true)
setError(null)
setSavedNote('')
try {
await clearPasscode()
setSavedNote('Passcode removed.')
await refresh()
} catch (err) {
setError(err.message || 'Could not remove passcode.')
} finally {
setBusy(false)
setTimeout(() => setSavedNote(''), 2000)
}
}
if (!me) return <SectionShell title="Sign-in" subtitle={error || 'Loading…'} />
const hasPasscode = !!me.has_passcode
return (
<SectionShell
title="Sign-in"
subtitle="How you sign in. A passcode lets you skip the one-time-code email; the one-time-code path is always available as a fallback (and as the recovery path if you forget your passcode)."
>
<div className="settings-row">
<span className="settings-note">
<strong>Passcode:</strong>{' '}
{hasPasscode ? 'Set.' : 'Not set — you sign in with a one-time code each time.'}
</span>
</div>
{hasPasscode && me.passcode_set_at && (
<p className="settings-note muted">Set on {me.passcode_set_at}.</p>
)}
{mode === 'idle' && (
<div className="settings-row">
{hasPasscode ? (
<>
<button
className="btn-primary"
onClick={() => { setMode('change'); setDraft(''); setError(null) }}
disabled={busy}
>
Change passcode
</button>
<button
className="btn-link-muted"
onClick={remove}
disabled={busy}
>
Remove passcode
</button>
</>
) : (
<button
className="btn-primary"
onClick={() => { setMode('set'); setDraft(''); setError(null) }}
disabled={busy}
>
Set passcode
</button>
)}
</div>
)}
{(mode === 'set' || mode === 'change') && (
<form className="settings-row" onSubmit={save}>
<label>
{mode === 'change' ? 'New passcode' : 'Passcode'}
<input
type="password"
autoComplete="new-password"
value={draft}
onChange={e => setDraft(e.target.value)}
placeholder="420 characters"
minLength={4}
maxLength={20}
required
disabled={busy}
/>
</label>
<button className="btn-primary" type="submit" disabled={busy || draft.trim().length < 4}>
{busy ? 'Saving…' : 'Save'}
</button>
<button
type="button"
className="btn-link-muted"
onClick={() => { setMode('idle'); setDraft(''); setError(null) }}
disabled={busy}
>
Cancel
</button>
</form>
)}
{savedNote && <p className="settings-note">{savedNote}</p>}
{error && <p className="settings-note warning">{error}</p>}
</SectionShell>
)
}
// §14.5 cookie / privacy consent (v0.13.0 / roadmap item #11)
function PrivacyCookiesSection() {
const [consent, setConsent] = useState(() => getConsent())
useEffect(() => {
// Pull the server-side row on mount; if it has a recorded_at the
// local snapshot is updated via hydrate.
getCookieConsent()
.then(record => { if (record.recorded_at) hydrateFromServer(record) })
.catch(() => {})
return onConsentChange(next => setConsent(next))
}, [])
function reopenBanner() {
// App.jsx listens for this event and bumps the forceOpen tick on
// <CookieConsentBanner>. The banner pre-selects the current choice
// from the snapshot, so the user can revise rather than restart.
window.dispatchEvent(new CustomEvent('rfc-app:cookie-consent-reopen'))
}
const summary = (() => {
if (!consent.recorded_at) {
return 'No choice recorded — the consent banner is being shown to you.'
}
if (consent.analytics && consent.other) {
return 'Essential + analytics + other.'
}
if (consent.analytics) {
return 'Essential + analytics.'
}
return 'Essential only.'
})()
return (
<SectionShell
title="Privacy & cookies"
subtitle="What categories of cookies you've allowed. Essential cookies are always on; analytics and other categories are opt-in."
>
<div className="settings-row">
<span className="settings-note"><strong>Current choice:</strong> {summary}</span>
</div>
{consent.recorded_at && (
<p className="settings-note muted">Recorded {consent.recorded_at}.</p>
)}
<div className="settings-row">
<button className="btn-primary" onClick={reopenBanner}>
Change
</button>
<Link to="/cookies" className="btn-link-muted">Cookies policy</Link>
<Link to="/privacy" className="btn-link-muted">Privacy policy</Link>
</div>
</SectionShell>
)
}
// §15.4 email category toggles
function EmailPreferencesSection() {
+4
View File
@@ -10,6 +10,7 @@
import { useEffect, useState } from 'react'
import { draftPRText, openPR } from '../api'
import { EVENTS, track } from '../lib/analytics'
export default function PRModal({ slug, branch, branchIsPrivate, onClose, onOpened }) {
const [title, setTitle] = useState('')
@@ -39,6 +40,9 @@ export default function PRModal({ slug, branch, branchIsPrivate, onClose, onOpen
setError(null)
try {
const { pr_number } = await openPR(slug, branch, { title: title.trim(), description: description.trim() })
// v0.15.0 analytics: fire on §10.2 PR-open success. slug
// and pr_number are the join keys; title/description stay out.
track(EVENTS.PR_OPENED, { rfc_slug: slug, pr_number })
onOpened?.(pr_number)
} catch (e) {
setError(e.message)
+5
View File
@@ -22,6 +22,7 @@ import {
startResolutionBranch,
withdrawPR,
} from '../api'
import { EVENTS, track } from '../lib/analytics'
export default function PRView({ viewer }) {
const { slug, prNumber: prNumberParam } = useParams()
@@ -135,6 +136,10 @@ export default function PRView({ viewer }) {
anchorPayload: reviewDraft?.anchorPayload || {},
quote: reviewDraft?.quote || null,
})
// v0.15.0 analytics: fire on §10.4 review-comment success.
// surface=pr distinguishes this from RFC discussion comments.
// No body text or quote material in the event.
track(EVENTS.COMMENT_POSTED, { rfc_slug: slug, pr_number: prNumber, surface: 'pr' })
setReviewText('')
setReviewDraft(null)
await refresh()
+12 -1
View File
@@ -11,6 +11,7 @@
import { useEffect, useState } from 'react'
import { proposeRFC } from '../api'
import { EVENTS, track } from '../lib/analytics'
function slugify(title) {
return title
@@ -20,7 +21,7 @@ function slugify(title) {
.replace(/^-+|-+$/g, '')
}
export default function ProposeModal({ onClose, onSubmitted }) {
export default function ProposeModal({ viewer, onClose, onSubmitted }) {
const [title, setTitle] = useState('')
const [slug, setSlug] = useState('')
const [slugEdited, setSlugEdited] = useState(false)
@@ -52,6 +53,10 @@ export default function ProposeModal({ onClose, onSubmitted }) {
pitch: pitch.trim(),
tags,
})
// v0.15.0 analytics: fire on the §9.1 propose-RFC submit.
// Slug is a stable, low-cardinality identifier (kebab-case
// ascii); title and pitch stay out of the event body.
track(EVENTS.RFC_PROPOSED, { rfc_slug: slug })
onSubmitted?.(result)
} catch (err) {
setError(err.message || 'Submission failed.')
@@ -131,6 +136,12 @@ export default function ProposeModal({ onClose, onSubmitted }) {
</div>
)}
{viewer && (
<p className="field-help" style={{ marginTop: 14, marginBottom: 0 }}>
Owner: <strong>{viewer.display_name || viewer.gitea_login}</strong> you'll be the first owner of this super-draft. Additional owners can claim later (§13.1).
</p>
)}
{error && <p className="field-error">{error}</p>}
</div>
<div className="modal-actions">
@@ -18,6 +18,7 @@ import {
postDiscussionMessage,
resolveDiscussionThread,
} from '../api'
import { EVENTS, track } from '../lib/analytics'
export default function RFCDiscussionPanel({ slug, viewer }) {
const [threads, setThreads] = useState([])
@@ -100,6 +101,10 @@ export default function RFCDiscussionPanel({ slug, viewer }) {
void message_id
}
setComposer('')
// v0.15.0 analytics: fire on a successful discussion post.
// surface=discussion distinguishes this from PR review comments
// which fire from PRView with surface=pr. No body text.
track(EVENTS.COMMENT_POSTED, { rfc_slug: slug, surface: 'discussion' })
} catch (err) {
setError(err.message)
} finally {
+10 -1
View File
@@ -44,6 +44,7 @@ import ChangePanel, { diffWords } from './ChangePanel.jsx'
import PRModal from './PRModal.jsx'
import GraduateDialog from './GraduateDialog.jsx'
import { claimOwnership } from '../api'
import { EVENTS, track } from '../lib/analytics'
const MANUAL_IDLE_MS = 5 * 60 * 1000 // §8.6 idle window; exact value is impl detail.
const MANUAL_DEBOUNCE_MS = 800
@@ -121,7 +122,15 @@ export default function RFCView({ viewer }) {
const [drawerOpen, setDrawerOpen] = useState(false)
useEffect(() => {
getRFC(slug).then(setEntry).catch(err => setError(err.message))
getRFC(slug).then(entry => {
setEntry(entry)
// v0.15.0 analytics: fire RFC Viewed once per slug load.
// We key on the slug param rather than the loaded entry so a
// re-render doesn't double-fire; the slug is the stable
// identifier. id is included for join-friendliness in the
// Amplitude dashboard.
track(EVENTS.RFC_VIEWED, { rfc_slug: slug, rfc_id: entry?.id })
}).catch(err => setError(err.message))
listModels(slug)
.then(({ models, default: def }) => {
setModels(models || [])
+120
View File
@@ -0,0 +1,120 @@
// TurnstileWidget.jsx v0.12.0 / roadmap item #10.
//
// Renders the CloudFlare Turnstile JS widget on the email-entry step of
// `/login`. Reads the site key from `import.meta.env.VITE_TURNSTILE_SITE_KEY`
// (Vite convention VITE_* prefix is build-time embedded). When the
// site key is unset/empty, this component renders nothing and reports
// a `null` token through `onToken` so the parent form can still submit.
// The backend's `TURNSTILE_REQUIRED` policy decides what happens to a
// request that arrives without a token; the frontend is intentionally
// not in that loop. See `backend/app/turnstile.py` for the matrix.
//
// The CloudFlare script is loaded once per page on first widget mount.
// Subsequent mounts (e.g. user goes back to email-entry after a failed
// OTC request) reuse the script tag and re-render the widget on the
// fresh container `div`. Unmounting removes the widget instance via
// `turnstile.remove(widgetId)` so a remount produces a new challenge
// rather than reusing a stale, already-consumed token.
//
// Turnstile contract:
// * `data-callback` fires with the token string on a successful
// challenge; the token is single-use and expires after ~5 minutes.
// * `data-error-callback` fires on a failed challenge (network,
// blocked, etc.); we surface a `null` token so the parent shows
// a retry hint.
// * `data-expired-callback` fires when the token times out before
// submission; we also drop to `null` and re-render so the user
// gets a fresh challenge on retry.
//
// We do **not** import the CloudFlare script at build time; loading it
// dynamically here keeps the bundle clean of an external request the
// page may not need (anonymous viewers reading RFCs never see Login).
import { useEffect, useRef } from 'react'
const TURNSTILE_SCRIPT_URL = 'https://challenges.cloudflare.com/turnstile/v0/api.js'
const SITE_KEY = import.meta.env.VITE_TURNSTILE_SITE_KEY || ''
// Promise-keyed: only one script tag, only one resolution chain.
let scriptLoadPromise = null
function loadTurnstileScript() {
if (typeof window === 'undefined') return Promise.resolve(null)
if (window.turnstile) return Promise.resolve(window.turnstile)
if (scriptLoadPromise) return scriptLoadPromise
scriptLoadPromise = new Promise((resolve, reject) => {
const existing = document.querySelector(`script[src="${TURNSTILE_SCRIPT_URL}"]`)
if (existing) {
existing.addEventListener('load', () => resolve(window.turnstile))
existing.addEventListener('error', reject)
return
}
const script = document.createElement('script')
script.src = TURNSTILE_SCRIPT_URL
script.async = true
script.defer = true
script.addEventListener('load', () => resolve(window.turnstile))
script.addEventListener('error', reject)
document.head.appendChild(script)
})
return scriptLoadPromise
}
export function turnstileEnabled() {
return !!SITE_KEY
}
export default function TurnstileWidget({ onToken, theme = 'auto' }) {
const containerRef = useRef(null)
const widgetIdRef = useRef(null)
useEffect(() => {
if (!SITE_KEY) {
// No site key configured surface a null token immediately so
// the parent form's submit-disabled gate doesn't lock up
// waiting on a challenge that will never arrive. The backend
// decides whether a tokenless request is admitted.
onToken?.(null)
return undefined
}
let cancelled = false
loadTurnstileScript()
.then(turnstile => {
if (cancelled || !turnstile || !containerRef.current) return
widgetIdRef.current = turnstile.render(containerRef.current, {
sitekey: SITE_KEY,
theme,
callback: token => onToken?.(token),
'error-callback': () => onToken?.(null),
'expired-callback': () => onToken?.(null),
})
})
.catch(() => {
// Script load failure surface null so the parent can decide
// what to do (today: still let submit through; the backend
// policy decides admission).
if (!cancelled) onToken?.(null)
})
return () => {
cancelled = true
if (widgetIdRef.current && window.turnstile) {
try {
window.turnstile.remove(widgetIdRef.current)
} catch (_) {
// Already gone or never registered nothing to clean up.
}
widgetIdRef.current = null
}
}
// We intentionally do not list `onToken` in the dependency array;
// a parent re-rendering with a fresh closure should not tear down
// and rebuild the widget (which would consume a fresh challenge).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
if (!SITE_KEY) return null
return <div ref={containerRef} className="turnstile-widget" />
}
+378
View File
@@ -0,0 +1,378 @@
// analytics.js — v0.15.0 / roadmap item #13.
//
// Wrapper around `@amplitude/unified` (Amplitude Analytics +
// Session Replay) that gates SDK initialization on the user's
// cookie/privacy consent (v0.13.0, `frontend/src/lib/consent.js`,
// SPEC §14.5). The wrapper presents a stable surface to the rest
// of the app:
//
// import { track, identify, anonymize } from './lib/analytics'
//
// track('RFC Viewed', { rfc_slug: 'open-human-model' })
// identify({ user_id: 'u_123' })
// anonymize() // call on sign-out
//
// At first import the wrapper:
// 1. Calls `bootstrap()` once, which reads `getConsent()` and
// subscribes to `onConsentChange()`. If consent.analytics is
// true, it lazily imports the Amplitude SDK and calls
// `amplitude.initAll(API_KEY, { analytics: { autocapture: true },
// sessionReplay: { sampleRate: 1 } })`.
// If consent.analytics is false (or undecided), the SDK is
// not loaded — no network request, no cookies, no session
// replay recording. A later consent change to `true` triggers
// init at that moment.
// 2. The wrapper queues `track()` and `identify()` calls made
// before init finishes (lazy import + consent grant), and
// drains the queue when init completes.
// 3. If the user later flips consent from granted → denied, the
// wrapper calls `amplitude.setOptOut(true)` so subsequent
// events are dropped client-side and session replay stops
// recording (the SDK is still loaded — we cannot unload a
// script — but it stops firing).
//
// Consent precedence ladder:
//
// consent.analytics === true → init + track
// consent.analytics === false → no init; or if already init,
// setOptOut(true)
// consent.recorded_at === null → treat as denied (banner is up;
// the user has not yet chosen)
//
// Session replay scope: this release ships session replay at
// `sampleRate: 1` (100% of sessions are recorded for full-DOM
// playback). That is the vendor-recommended default for new
// Amplitude deployments. The v0.13.0 consent banner's single
// "analytics" toggle gates both events and session replay together —
// a separate consent category for session-replay specifically is a
// §19.2 follow-up.
//
// API key resolution:
//
// The build-time env var `VITE_AMPLITUDE_API_KEY` carries the
// Amplitude project's API key. When it is unset/empty, the
// wrapper logs one console warning and no-ops — every public
// function becomes a deterministic no-op so dev environments
// (and deployments that intentionally don't ship analytics)
// keep working. The deploy gesture wires the key via flotilla's
// `overlay set` verb (see CHANGELOG for the operator gesture):
// Amplitude browser keys are bundle-embedded by design (visible
// to anyone with dev tools, same nature as the v0.12.0
// `VITE_TURNSTILE_SITE_KEY`), so the binding is overlay, not
// secret.
//
// PII discipline:
//
// `identify({ user_id })` SHOULD pass only the opaque server-
// side user id (the `viewer.id` integer or string). DO NOT pass
// email, display name, IP, or any other PII through the SDK.
// Event properties SHOULD likewise stay limited to ids and
// enums; free-text fields (titles, comment bodies) MUST NOT be
// sent.
//
// Event taxonomy: defined in `EVENTS` below. Callers SHOULD use
// one of these names rather than firing arbitrary strings — that
// keeps the Amplitude dashboard coherent over time.
import { getConsent, onConsentChange } from './consent.js'
const API_KEY = import.meta.env.VITE_AMPLITUDE_API_KEY || ''
// Public taxonomy. Keep this short and stable — new entries should
// land via a release, not ad-hoc. The strings match the Amplitude
// dashboard names exactly (Title Case, spaces, no punctuation).
export const EVENTS = Object.freeze({
PAGE_VIEWED: 'Page Viewed',
RFC_VIEWED: 'RFC Viewed',
USER_SIGNED_IN: 'User Signed In',
USER_SIGNED_OUT: 'User Signed Out',
RFC_PROPOSED: 'RFC Proposed',
PR_OPENED: 'PR Opened',
COMMENT_POSTED: 'Comment Posted',
BETA_ACCESS_REQUESTED: 'Beta Access Requested',
ADMIN_PERMISSION_DECISION: 'Admin Permission Decision',
})
// Internal state.
let _bootstrapped = false
let _amplitude = null // The dynamically imported SDK module.
let _initPromise = null // Pending init (lazy import + sdk.init).
let _initialized = false // True after sdk.init has resolved.
let _warnedNoKey = false
let _pendingUserId = null // identify() called before init resolves.
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
}
+154
View File
@@ -0,0 +1,154 @@
// consent.js — cookie / privacy consent state (v0.13.0, SPEC §14.5).
//
// The framework's analytics SDK gating (roadmap item #13, target v0.15.0)
// will read from this module. v0.13.0 ships the storage + the banner +
// the on-change pub/sub; no analytics SDK ships yet.
//
// Shape of a consent record:
//
// { essential: true, analytics: bool, other: bool, recorded_at: string | null }
//
// `essential` is always true at the API surface; it's included for
// symmetry. `recorded_at` is null when the user has not yet made a
// choice — the banner is shown until it's non-null.
//
// Precedence:
// - Anonymous viewer: localStorage is the only source.
// - Authenticated viewer: on sign-in, the server row (if any) overrides
// local; if the server has no row, the local choice is uploaded.
//
// The fan-out is intentionally tiny — three flags. The banner writes
// once; subscribers re-read on demand via `getConsent()` and can
// register `onConsentChange(cb)` to be notified of subsequent updates.
//
// IMPORTANT: don't import this from analytics SDKs that themselves
// set cookies on load. Read consent first, then conditionally `import()`
// the SDK module — that's the contract item #13 will follow.
const LS_KEY = 'rfc-app.cookie-consent.v1'
const DEFAULT = Object.freeze({
essential: true,
analytics: false,
other: false,
recorded_at: null,
})
const listeners = new Set()
function readLocal() {
try {
const raw = localStorage.getItem(LS_KEY)
if (!raw) return null
const parsed = JSON.parse(raw)
if (!parsed || typeof parsed !== 'object') return null
return normalize(parsed)
} catch {
return null
}
}
function writeLocal(record) {
try {
localStorage.setItem(LS_KEY, JSON.stringify(normalize(record)))
} catch {
// localStorage may be unavailable (private mode, disabled storage).
// In that case we behave as if no choice was ever made — the banner
// shows on every load. Acceptable per §14.5: the user can still
// refuse to consent on each visit.
}
}
function normalize(record) {
return {
essential: true,
analytics: !!record.analytics,
other: !!record.other,
recorded_at: record.recorded_at || null,
}
}
// In-memory snapshot. Initialised lazily on first read so the module
// import order doesn't matter; refreshed by `setConsent` and
// `hydrateFromServer`.
let _snapshot = null
function snapshot() {
if (_snapshot == null) {
_snapshot = readLocal() || { ...DEFAULT }
}
return _snapshot
}
function emit() {
for (const cb of listeners) {
try { cb(snapshot()) } catch {}
}
}
/** Read the current consent record. Always returns a normalized object;
* `recorded_at: null` means the user has not yet chosen. */
export function getConsent() {
return snapshot()
}
/** True if the user has made a choice. The banner uses this to decide
* whether to render itself on load. */
export function hasChosen() {
return snapshot().recorded_at != null
}
/** Subscribe to consent updates. Returns an unsubscribe function. */
export function onConsentChange(cb) {
listeners.add(cb)
return () => listeners.delete(cb)
}
/** Write a new choice locally and emit. Returns the new snapshot. The
* server-side persistence path is handled separately by the banner /
* settings surface via the API client; this helper is for both anon
* and authenticated callers because localStorage is the always-on
* layer (the server row is a backup that survives sign-out). */
export function setConsent({ analytics = false, other = false } = {}) {
const next = normalize({
analytics,
other,
recorded_at: new Date().toISOString(),
})
_snapshot = next
writeLocal(next)
emit()
return next
}
/** Adopt a server-side record as authoritative. Called by the banner /
* settings surface after sign-in when the server returns a non-null
* recorded_at. Updates local + memory + emits to subscribers. */
export function hydrateFromServer(record) {
if (!record || !record.recorded_at) return snapshot()
const next = normalize(record)
_snapshot = next
writeLocal(next)
emit()
return next
}
/** Reset local state used by the settings "Change" affordance to
* re-prompt the banner. Does not touch the server row; the user must
* re-confirm a choice and the banner uploads on save. */
export function clearLocal() {
try { localStorage.removeItem(LS_KEY) } catch {}
_snapshot = { ...DEFAULT }
emit()
return _snapshot
}
// Cross-tab sync: if another tab writes the key, mirror the change here.
// Wrapped in a guard so SSR / non-browser test contexts don't blow up.
if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
window.addEventListener('storage', e => {
if (e.key !== LS_KEY) return
_snapshot = readLocal() || { ...DEFAULT }
emit()
})
}
+125
View File
@@ -0,0 +1,125 @@
// Cookies.jsx v0.13.0 / roadmap item #11 / SPEC §14.6.
//
// Lists the framework's cookies, by category, with each cookie's
// purpose. Deployments override via `VITE_COOKIES_POLICY_URL` (linked
// below the framework's stub list, same shape as the privacy page).
//
// Keeping the list in source makes the framework self-documenting:
// when a future framework release adds or removes a cookie, this page
// is the change-record. Item #13's analytics SDK will add its own row
// to the analytics-category list in v0.15.0.
import { useNavigate, Link } from 'react-router-dom'
const COOKIES = [
{
name: 'rfc_session',
category: 'Essential',
purpose: "Signed session cookie that remembers who you're signed in as. itsdangerous-signed; HttpOnly; SameSite=Lax.",
lifetime: 'Session (cleared on sign-out).',
},
{
name: 'rfc-app.cookie-consent.v1',
category: 'Essential',
purpose: 'localStorage entry (not a cookie strictly, but tracked here for symmetry) that remembers your consent choice on this device. Cleared on browser data reset.',
lifetime: 'Until cleared.',
},
]
export default function Cookies() {
const navigate = useNavigate()
const deploymentUrl = (import.meta.env.VITE_COOKIES_POLICY_URL || '').trim()
const appName = import.meta.env.VITE_APP_NAME || 'this deployment'
return (
<div className="policy-page">
<header className="policy-header">
<button
className="policy-back"
onClick={() => (history.length > 1 ? navigate(-1) : navigate('/'))}
>
Back
</button>
<span className="policy-title">Cookies policy</span>
</header>
<article className="policy-body">
<h1>Cookies policy</h1>
<p className="policy-subtitle">
What {appName} stores in your browser, by category.
</p>
<h2>Categories</h2>
<ul>
<li>
<strong>Essential</strong> required for the app to keep
you signed in, protect submissions, and remember your
consent choice. Cannot be switched off (without these the
app cannot function).
</li>
<li>
<strong>Analytics</strong> optional anonymous usage
telemetry. Off by default; opt-in via the consent banner.
As of v0.13.0 no analytics SDK ships; roadmap item #13
(v0.15.0) adds one behind this gate.
</li>
<li>
<strong>Other</strong> third-party embeds, social
widgets, or anything else the deployment chooses to enable.
Off by default; opt-in via the consent banner. The
framework ships no such cookies by default.
</li>
</ul>
<h2>Current cookies set by the framework</h2>
<table className="policy-table">
<thead>
<tr>
<th>Name</th>
<th>Category</th>
<th>Purpose</th>
<th>Lifetime</th>
</tr>
</thead>
<tbody>
{COOKIES.map(c => (
<tr key={c.name}>
<td><code>{c.name}</code></td>
<td>{c.category}</td>
<td>{c.purpose}</td>
<td>{c.lifetime}</td>
</tr>
))}
</tbody>
</table>
<h2>Manage your choice</h2>
<p>
Change your consent any time from{' '}
<Link to="/settings/notifications">
Settings &rarr; Privacy &amp; cookies
</Link>. The "Change" affordance re-opens the consent banner
with your current selection pre-loaded.
</p>
{deploymentUrl ? (
<>
<h2>Deployment-specific cookies</h2>
<p>
This deployment may add additional cookies on top of the
framework's. See the full deployment policy at:
</p>
<p>
<a href={deploymentUrl} target="_blank" rel="noopener noreferrer">
{deploymentUrl}
</a>
</p>
</>
) : null}
<p className="policy-footnote">
See also the <Link to="/privacy">privacy policy</Link>.
</p>
</article>
</div>
)
}
+118
View File
@@ -0,0 +1,118 @@
// Privacy.jsx v0.13.0 / roadmap item #11 / SPEC §14.5.
//
// The framework's default privacy policy page. Reachable by anonymous
// and authenticated viewers alike at `/privacy`. The text below is a
// minimal stub that describes the framework's stance; deployments are
// expected to override it via the `VITE_PRIVACY_POLICY_URL` env var.
//
// When `VITE_PRIVACY_POLICY_URL` is set:
// - http(s) URL the page renders the framework's stub above a
// "Read the full deployment policy" link to the configured URL.
// We don't iframe-embed third-party policy hosts because their
// Content-Security-Policy frequently refuses framing; the link is
// the predictable affordance.
//
// The framework's stub is intentionally short the rules that matter
// to a user are: (1) what categories of cookies the app sets, (2) how
// to change consent, (3) where to reach the deployment operator with a
// complaint. Each deployment's content repo can carry a fuller version.
import { useNavigate, Link } from 'react-router-dom'
export default function Privacy() {
const navigate = useNavigate()
const deploymentUrl = (import.meta.env.VITE_PRIVACY_POLICY_URL || '').trim()
const appName = import.meta.env.VITE_APP_NAME || 'this deployment'
return (
<div className="policy-page">
<header className="policy-header">
<button
className="policy-back"
onClick={() => (history.length > 1 ? navigate(-1) : navigate('/'))}
>
Back
</button>
<span className="policy-title">Privacy policy</span>
</header>
<article className="policy-body">
<h1>Privacy policy</h1>
<p className="policy-subtitle">
What {appName} stores, why, and how to control it.
</p>
<h2>What we store</h2>
<p>
{appName} runs on the Wiggleverse RFC framework. The framework
stores the identity you sign in with (your Gitea login,
display name, email, and avatar URL), the proposals and edits
you author, the discussion threads you participate in, and
your notification preferences. Authoring is public by design
this is a framework for public-async RFC work, and threads,
changes, and PRs are visible to anyone who reaches the
deployment. Settings (notification toggles, quiet hours, mute
list, cookie consent) are private to your account.
</p>
<h2>Cookies</h2>
<p>
The app sets a small set of cookies. The full list is on the{' '}
<Link to="/cookies">cookies policy page</Link>. You can choose
which categories you allow from the consent banner shown on
your first visit or from <Link to="/settings/notifications">
Settings &rarr; Privacy &amp; cookies</Link> any time
afterwards.
</p>
<h2>Analytics</h2>
<p>
The framework supports an optional anonymous analytics layer
gated behind your consent choice. As of v0.13.0 no analytics
SDK ships in the framework; deployments that enable analytics
do so via a later framework version (roadmap item #13). The
consent toggle exists today so the gate is already in place
when the SDK lands.
</p>
<h2>Your data, your control</h2>
<ul>
<li>Revoke cookie consent any time from settings.</li>
<li>
Edit notification preferences including the global email
opt-out from{' '}
<Link to="/settings/notifications">notification settings</Link>.
</li>
<li>
Your authored content (proposals, threads, edits) is public
and not retractable from the meta-repo's Git history. If you
need a redaction, reach the deployment operator directly.
</li>
</ul>
{deploymentUrl ? (
<>
<h2>Deployment-specific policy</h2>
<p>
This deployment may layer additional policy on top of the
framework's defaults. Read the full deployment policy at:
</p>
<p>
<a href={deploymentUrl} target="_blank" rel="noopener noreferrer">
{deploymentUrl}
</a>
</p>
</>
) : (
<>
<h2>Deployment contact</h2>
<p>
For deployment-specific privacy questions data subject
requests, redaction requests, complaints contact the
operator of {appName}.
</p>
</>
)}
</article>
</div>
)
}