Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 41b0c6af99 | |||
| b3f1b15f65 | |||
| 6fb68a95c7 | |||
| 7872b921ed | |||
| de28272914 | |||
| 55beba5c0a | |||
| ca8ba69acb | |||
| 8aa65014b4 | |||
| f8e797ab09 | |||
| 21743a08b1 | |||
| c92730a737 |
+1471
-4
File diff suppressed because it is too large
Load Diff
@@ -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`.
|
||||||
@@ -253,13 +253,18 @@ and exact columns are illustrative; the implementing session can adjust.
|
|||||||
- `threads` — every conversation in the system, whether scoped to an RFC's
|
- `threads` — every conversation in the system, whether scoped to an RFC's
|
||||||
main view, a branch, or a span within a branch's document. Columns:
|
main view, a branch, or a span within a branch's document. Columns:
|
||||||
`id`, `rfc_slug`, `branch_name` (nullable — null means scoped to the
|
`id`, `rfc_slug`, `branch_name` (nullable — null means scoped to the
|
||||||
RFC's main view), `anchor_kind` (`whole-doc` | `range` | `paragraph`),
|
RFC's main view, the PR-less per-RFC discussion surface per §10's
|
||||||
|
closing note; non-null means scoped to a branch's work, including
|
||||||
|
PR-comment threads), `anchor_kind` (`whole-doc` | `range` | `paragraph`),
|
||||||
`anchor_payload` (JSON: serialized ProseMirror range or paragraph id),
|
`anchor_payload` (JSON: serialized ProseMirror range or paragraph id),
|
||||||
`thread_kind` (`chat` | `flag` | `review` — `review` is the diff-anchored
|
`thread_kind` (`chat` | `flag` | `review` — `review` is the diff-anchored
|
||||||
PR-review thread defined in §10.4), `label` (short human-authored summary;
|
PR-review thread defined in §10.4), `label` (short human-authored summary;
|
||||||
for flags this is the entire content), `state` (`open` | `resolved` |
|
for flags this is the entire content), `state` (`open` | `resolved` |
|
||||||
`stale`), `created_by`, `created_at`, `resolved_at`, `resolved_by`.
|
`stale`), `created_by`, `created_at`, `resolved_at`, `resolved_by`.
|
||||||
Visibility is derived from the underlying branch (§11.1).
|
Visibility is derived from the underlying branch (§11.1); for the
|
||||||
|
null-branch PR-less discussion surface, visibility follows the RFC
|
||||||
|
(anonymous read open per the §14 / v0.3.0 contract, write requires
|
||||||
|
contributor per §6.1, tightened toward anon-write-refused in v0.6.0).
|
||||||
- `thread_messages` — the actual chat content for `chat`-kind threads.
|
- `thread_messages` — the actual chat content for `chat`-kind threads.
|
||||||
Columns: `id`, `thread_id`, `role` (`user` | `assistant` | `system`),
|
Columns: `id`, `thread_id`, `role` (`user` | `assistant` | `system`),
|
||||||
`author_user_id` (nullable; null for assistant), `model_id` (nullable;
|
`author_user_id` (nullable; null for assistant), `model_id` (nullable;
|
||||||
@@ -327,6 +332,23 @@ and exact columns are illustrative; the implementing session can adjust.
|
|||||||
- `actions` — append-only audit log for every state transition, every
|
- `actions` — append-only audit log for every state transition, every
|
||||||
graduation, every grant change. Includes the acting user, the bot
|
graduation, every grant change. Includes the acting user, the bot
|
||||||
commit hash if any, and the on-behalf-of trailer applied.
|
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
|
**Super-draft scoping.** For rows in `threads` and `changes` where the
|
||||||
entry referenced by `rfc_slug` is in state `super-draft`, `branch_name`
|
entry referenced by `rfc_slug` is in state `super-draft`, `branch_name`
|
||||||
@@ -344,17 +366,110 @@ merge with no data movement.
|
|||||||
|
|
||||||
Authorization is owned by the app. Gitea sees only the bot account.
|
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 (4–20 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
|
### 6.1 Four roles, each a strict superset of the one below
|
||||||
|
|
||||||
1. **Anonymous.** Can read public RFCs (the meta repo's main branch,
|
1. **Anonymous.** Can read public RFCs (the meta repo's main branch,
|
||||||
every RFC repo's main branch), read any branch whose `read_public`
|
every RFC repo's main branch), read any branch whose `read_public`
|
||||||
is true, read any PR. Cannot chat, propose, create branches, or
|
is true, read any PR. Cannot chat, propose, create branches, or
|
||||||
open PRs.
|
open PRs. v0.6.0 (roadmap item #4) closed the audit: every
|
||||||
2. **Contributor.** Default role for any authenticated account.
|
write-shaped endpoint surveyed in §17 enforces an explicit
|
||||||
Everything anonymous can do, plus: propose new RFCs (open a PR
|
`auth.require_contributor` (or stricter) gate before doing any
|
||||||
against the meta repo), create branches on any RFC repo, open PRs
|
state-changing work; anonymous writes refuse 401. The explicit
|
||||||
from branches they have contribute access to, chat on anything
|
audit covers propose, branch create, branch threads, PR-less
|
||||||
they can read, claim ownership of unclaimed super-drafts.
|
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
|
3. **Admin.** Everything contributor can do, plus: act on any RFC
|
||||||
(merge PRs on behalf of arbiters, graduate super-drafts, set
|
(merge PRs on behalf of arbiters, graduate super-drafts, set
|
||||||
branch visibility on anyone's behalf, downgrade or restore
|
branch visibility on anyone's behalf, downgrade or restore
|
||||||
@@ -372,6 +487,14 @@ subject to the standard 30/90 hygiene rules (§12). Restoring is the
|
|||||||
reverse action. Every mute and restore is logged in
|
reverse action. Every mute and restore is logged in
|
||||||
`permission_events`.
|
`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
|
This write-mute is structurally distinct from the two notification
|
||||||
mutes introduced in §15.8 — the per-RFC notification mute (the
|
mutes introduced in §15.8 — the per-RFC notification mute (the
|
||||||
`muted` state on the `watches` row, §15.6) and the per-user
|
`muted` state on the `watches` row, §15.6) and the per-user
|
||||||
@@ -385,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
|
self-DND'd contributor's own gestures continue to fire signals to
|
||||||
others normally.
|
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
|
### 6.3 Per-RFC delegated authority
|
||||||
|
|
||||||
An RFC's `owners:` and `arbiters:` (from the meta-repo entry's
|
An RFC's `owners:` and `arbiters:` (from the meta-repo entry's
|
||||||
@@ -1639,6 +1794,44 @@ framework's evidence unit; admitting plumbing commits — "fix merge
|
|||||||
conflict with main" — into that timeline would dilute the signal each
|
conflict with main" — into that timeline would dilute the signal each
|
||||||
commit is meant to carry.
|
commit is meant to carry.
|
||||||
|
|
||||||
|
### 10.10 PR-less discussion vs. contribution
|
||||||
|
|
||||||
|
PR comments and branch chat (§10.4, §8.4) are PR-scoped: they live on
|
||||||
|
the `threads` rows whose `branch_name` names a branch (the PR's head
|
||||||
|
or, pre-PR, a feature branch). They are the right surface for *this
|
||||||
|
specific proposed change*. They are not the right surface for "what
|
||||||
|
about this part of the RFC overall?" or "have we considered…?" — a
|
||||||
|
question that doesn't yet warrant cutting a branch and that would
|
||||||
|
distort a PR's review timeline if it landed there.
|
||||||
|
|
||||||
|
The RFC view carries a **discussion surface** distinct from PR
|
||||||
|
comments. Its substrate is `threads` rows whose `branch_name IS NULL`
|
||||||
|
(§5) — the same conversation table used by branch chat, with the
|
||||||
|
nullable column doing the segregating. Posting a discussion thread or
|
||||||
|
message does not open a PR; the §1 chokepoint is unaffected because
|
||||||
|
chat messages never produced Git writes. Contribution — proposing
|
||||||
|
edits the document will land — still requires opening a PR via the
|
||||||
|
§10.1 affordance.
|
||||||
|
|
||||||
|
The distinction in one line: **discussion is what the RFC is for;
|
||||||
|
contribution is how the RFC changes.** Either is honest; conflating
|
||||||
|
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 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
|
||||||
|
identically whether the chat lives on a branch or on the RFC's
|
||||||
|
discussion surface. A distinct `open_rfc_discussion_thread` event
|
||||||
|
kind is a §19.2 candidate if evidence demands the split.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 11. Branches and PRs: visibility, contribute, lifecycle
|
## 11. Branches and PRs: visibility, contribute, lifecycle
|
||||||
@@ -1895,14 +2088,55 @@ and its public face.
|
|||||||
The app's root URL, accessed by an unauthenticated visitor, renders a
|
The app's root URL, accessed by an unauthenticated visitor, renders a
|
||||||
landing page consisting of the title, the subtitle, and the short-form
|
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
|
deck from the top of `PHILOSOPHY.md` (see §2). Beneath the deck, a
|
||||||
single primary action: "Sign in with Gitea." Beneath that, a secondary
|
single primary action: "Sign in" → the email + one-time-code surface
|
||||||
link: "Read the full philosophy" → `/philosophy`.
|
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
|
This is the front door. It sets expectation before the user encounters
|
||||||
the mechanics, so the mechanics (super-drafts, graduation, public
|
the mechanics, so the mechanics (super-drafts, graduation, public
|
||||||
arguments, AI participation in chat) read as load-bearing rather than
|
arguments, AI participation in chat) read as load-bearing rather than
|
||||||
novel.
|
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
|
### 14.2 The `/philosophy` route
|
||||||
|
|
||||||
Authenticated and anonymous visitors alike can reach `/philosophy`,
|
Authenticated and anonymous visitors alike can reach `/philosophy`,
|
||||||
@@ -1934,6 +2168,92 @@ The visual design of the landing page and the `/philosophy` route —
|
|||||||
typography, layout, illustrations if any — is deferred. The structural
|
typography, layout, illustrations if any — is deferred. The structural
|
||||||
decisions above are the binding part.
|
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
|
## 15. Notifications
|
||||||
@@ -2007,8 +2327,14 @@ signal taxonomy this section commits to. The starting set:
|
|||||||
`graduation_complete`, `graduation_rolled_back`, `rfc_withdrawn`,
|
`graduation_complete`, `graduation_rolled_back`, `rfc_withdrawn`,
|
||||||
`rfc_reopened`, `claim_opened`, `claim_merged`,
|
`rfc_reopened`, `claim_opened`, `claim_merged`,
|
||||||
`permission_change_affecting_me`, `app_wide_mute_set`,
|
`permission_change_affecting_me`, `app_wide_mute_set`,
|
||||||
`app_wide_mute_lifted`, `digest_emitted`. The enum is extensible; the
|
`app_wide_mute_lifted`, `new_beta_request`, `digest_emitted`.
|
||||||
build session adjusts as new gestures are wired in.
|
The enum is extensible; the build session adjusts as new gestures
|
||||||
|
are wired in. The `new_beta_request` event (v0.9.0, roadmap item
|
||||||
|
#7) is framework-scoped rather than RFC-scoped — the row's
|
||||||
|
`rfc_slug` is NULL and the deep-link points `/admin/users`
|
||||||
|
instead of `/rfc/<slug>` — but otherwise rides the standard
|
||||||
|
fan-out chokepoint with category `admin-actionable` so the §15.4
|
||||||
|
email gate only reaches owners/admins.
|
||||||
|
|
||||||
### 15.2 The inbox
|
### 15.2 The inbox
|
||||||
|
|
||||||
@@ -2450,6 +2776,112 @@ The follow-up session will refine this. A minimal starting set:
|
|||||||
returned `version` matches the tag the operator just deployed,
|
returned `version` matches the tag the operator just deployed,
|
||||||
catching the failure mode where a restart did not pick up the
|
catching the failure mode where a restart did not pick up the
|
||||||
new code.
|
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` (4–20 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,
|
- `GET /api/rfcs` — list entries with state, id, title, slug, repo,
|
||||||
owners, last_active_at, has_open_prs, starred-by-me. Supports
|
owners, last_active_at, has_open_prs, starred-by-me. Supports
|
||||||
search, sort, filter chips, and the `unclaimed` predicate.
|
search, sort, filter chips, and the `unclaimed` predicate.
|
||||||
@@ -2505,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
|
trailing `rollback` step's events if any earlier step fails. The
|
||||||
Graduate dialog opens this stream on confirm and renders the step
|
Graduate dialog opens this stream on confirm and renders the step
|
||||||
stack from the events. The stream closes on success or on
|
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
|
- `GET /api/rfcs/<slug>/blocking-prs` — list open meta-repo PRs
|
||||||
against `rfcs/<slug>.md` per §13.2's precondition popover. Returns
|
against `rfcs/<slug>.md` per §13.2's precondition popover. Returns
|
||||||
PR number, title, author, last-activity timestamp, and the
|
PR number, title, author, last-activity timestamp, and the
|
||||||
@@ -2552,6 +2990,26 @@ The follow-up session will refine this. A minimal starting set:
|
|||||||
- `POST /api/rfcs/<slug>/branches/<branch>/threads/<thread_id>/resolve`
|
- `POST /api/rfcs/<slug>/branches/<branch>/threads/<thread_id>/resolve`
|
||||||
— resolve a thread per §8.12; permission per the rules in that
|
— resolve a thread per §8.12; permission per the rules in that
|
||||||
section.
|
section.
|
||||||
|
- `GET /api/rfcs/<slug>/discussion/threads` — list threads on the
|
||||||
|
RFC's PR-less discussion surface per §10.10 (rows where
|
||||||
|
`threads.branch_name IS NULL`). Anonymous-readable per the v0.3.0
|
||||||
|
anonymous-read contract; the default whole-doc chat thread is
|
||||||
|
materialized lazily on first read, mirroring the §8.12 branch-chat
|
||||||
|
default.
|
||||||
|
- `POST /api/rfcs/<slug>/discussion/threads` — open a discussion
|
||||||
|
thread per §10.10. Body: optional `label` (short summary), optional
|
||||||
|
first `message`. Writes require contributor role; anonymous viewers
|
||||||
|
receive 401. Thread is created with `anchor_kind='whole-doc'`,
|
||||||
|
`thread_kind='chat'`, `branch_name=NULL`.
|
||||||
|
- `GET /api/rfcs/<slug>/discussion/threads/<thread_id>/messages` —
|
||||||
|
read messages on a discussion thread. Anonymous-readable.
|
||||||
|
- `POST /api/rfcs/<slug>/discussion/threads/<thread_id>/messages` —
|
||||||
|
post a message into a discussion thread per §10.10. Body: `text`,
|
||||||
|
optional `quote`. Writes require contributor role.
|
||||||
|
- `POST /api/rfcs/<slug>/discussion/threads/<thread_id>/resolve` —
|
||||||
|
resolve a discussion thread per §10.10; permission collapses to the
|
||||||
|
thread creator, any RFC owner / arbiter per §6.3, and any app
|
||||||
|
admin / owner per §6.1.
|
||||||
- `POST /api/rfcs/<slug>/branches/<branch>/open-pr` — open a PR per
|
- `POST /api/rfcs/<slug>/branches/<branch>/open-pr` — open a PR per
|
||||||
§10.1; body carries the AI-drafted (and possibly edited) title and
|
§10.1; body carries the AI-drafted (and possibly edited) title and
|
||||||
description.
|
description.
|
||||||
@@ -2566,8 +3024,15 @@ The follow-up session will refine this. A minimal starting set:
|
|||||||
- `POST /api/rfcs/<slug>/prs/<pr_number>/withdraw` — withdraw per §10.8.
|
- `POST /api/rfcs/<slug>/prs/<pr_number>/withdraw` — withdraw per §10.8.
|
||||||
- `POST /api/rfcs/<slug>/prs/<pr_number>/resolution-branch` — cut a
|
- `POST /api/rfcs/<slug>/prs/<pr_number>/resolution-branch` — cut a
|
||||||
fresh resolution branch and replay per §10.9.
|
fresh resolution branch and replay per §10.9.
|
||||||
- `GET /api/admin/users` — list users with role and write-mute state,
|
- `GET /api/admin/users` — list users for the §6 / Slice 7 admin
|
||||||
for the §6 / Slice 7 admin surface.
|
surface. v0.9.0 (roadmap item #7) widened the payload to carry
|
||||||
|
`permission_state`, `first_name`, `last_name`, `beta_request_reason`,
|
||||||
|
`created_at`, `permission_decided_at`, and the joined
|
||||||
|
`permission_decided_by_login` / `permission_decided_by_display`
|
||||||
|
for the user-management page. Sort order surfaces `pending` rows
|
||||||
|
first (the daily admin queue), then `granted`, then `revoked`;
|
||||||
|
within a bucket, owners precede admins precede contributors,
|
||||||
|
with recency as the tiebreaker.
|
||||||
- `POST /api/admin/users/<id>/role` — set role. Only owners may grant
|
- `POST /api/admin/users/<id>/role` — set role. Only owners may grant
|
||||||
or revoke `owner`; admins may flip contributor ↔ admin freely. An
|
or revoke `owner`; admins may flip contributor ↔ admin freely. An
|
||||||
owner-self-demotion is refused on this endpoint; owner succession
|
owner-self-demotion is refused on this endpoint; owner succession
|
||||||
@@ -2576,6 +3041,16 @@ The follow-up session will refine this. A minimal starting set:
|
|||||||
write-mute (not the §15.8 notification mutes). Refused on owners
|
write-mute (not the §15.8 notification mutes). Refused on owners
|
||||||
and admins — for them, the role-change channel is the right
|
and admins — for them, the role-change channel is the right
|
||||||
refusal. Writes a `permission_events` row.
|
refusal. Writes a `permission_events` row.
|
||||||
|
- `POST /api/admin/users/<id>/permission` — v0.9.0 (roadmap item #7).
|
||||||
|
Flip `permission_state` between `pending`, `granted`, and `revoked`.
|
||||||
|
Stamps `permission_decided_by` + `permission_decided_at` on the
|
||||||
|
row and writes a `permission_events` row with event_kind in
|
||||||
|
`{permission_granted, permission_revoked, permission_repended}`.
|
||||||
|
Refuses with 422 if the admin tries to flip their own row
|
||||||
|
(symmetric to the `set_mute` / `set_role` self-action refusals).
|
||||||
|
v0.8.0 shipped the column shape with no admin UI — operators ran
|
||||||
|
a manual `UPDATE users` to grant access; v0.9.0 retires the
|
||||||
|
manual gesture.
|
||||||
- `GET /api/admin/audit` — paged read of the `actions` log with
|
- `GET /api/admin/audit` — paged read of the `actions` log with
|
||||||
filters `action_kind`, `actor_user_id`, `rfc_slug`, plus `before_id`
|
filters `action_kind`, `actor_user_id`, `rfc_slug`, plus `before_id`
|
||||||
for the page boundary. Returns the joined actor login/display so
|
for the page boundary. Returns the joined actor login/display so
|
||||||
@@ -2657,6 +3132,16 @@ The follow-up session will refine this. A minimal starting set:
|
|||||||
short confirmation page.
|
short confirmation page.
|
||||||
- `POST /api/webhooks/email-bounce` — bounce and complaint receiver
|
- `POST /api/webhooks/email-bounce` — bounce and complaint receiver
|
||||||
per §15.4; sets the recipient's global email opt-out.
|
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
|
Plus all the chat / streaming / model-picker endpoints, scoped to
|
||||||
per-RFC and per-branch threads.
|
per-RFC and per-branch threads.
|
||||||
@@ -3287,6 +3772,55 @@ binding.
|
|||||||
("operators MAY configure their monitoring to probe `/api/health`;
|
("operators MAY configure their monitoring to probe `/api/health`;
|
||||||
the endpoint is unauthenticated by design"). §17 now lists the
|
the endpoint is unauthenticated by design"). §17 now lists the
|
||||||
endpoint in its illustrative table.*
|
endpoint in its illustrative table.*
|
||||||
|
- **PR-less discussion: range and paragraph anchors.** v0.5.0 lands
|
||||||
|
the structural discussion surface (§10.10) but constrains every
|
||||||
|
PR-less thread to `anchor_kind='whole-doc'` — the data model permits
|
||||||
|
`range` and `paragraph` anchors (§5) but the UI work to surface a
|
||||||
|
passage-anchored thread on a non-branch view is the deferred half.
|
||||||
|
The natural follow-on is a margin-icon affordance on the main view
|
||||||
|
matching §8.12's branch-side surface, with the anchor stored on the
|
||||||
|
null-branch thread. Earns its session when discussion volume warrants
|
||||||
|
the precision; v0.5.0's flat surface is sufficient for most "have we
|
||||||
|
considered…?" gestures. Touches §10.10 and §8.12.
|
||||||
|
- **PR-less discussion: distinct notification event_kinds.** v0.5.0
|
||||||
|
routes per-RFC discussion messages through the existing
|
||||||
|
`chat_message_in_participated_thread` and `chat_reply_to_my_message`
|
||||||
|
event kinds with `branch_name=null` on the fan-out row. The inbox
|
||||||
|
prose reads identically whether the chat lives on a branch or on the
|
||||||
|
RFC's discussion surface, which is honest signal: the conversation
|
||||||
|
shape is the same; only the scope differs. A future session may
|
||||||
|
introduce `open_rfc_discussion_thread` / `post_rfc_discussion_message`
|
||||||
|
if evidence shows contributors want to filter discussion-vs-branch
|
||||||
|
chat distinctly in the §15.2 inbox. Touches §15.1 (the event_kind
|
||||||
|
enum), §15.2 (the inbox filter chips), and §10.10.
|
||||||
|
- **PR-less discussion: chat-seen cursor.** §15.7 commits the
|
||||||
|
`branch_chat_seen` cursor for branch-scoped chat. The PR-less
|
||||||
|
discussion surface has no equivalent cursor in v0.5.0; the inbox
|
||||||
|
reconciler's keying on `(rfc_slug, branch_name)` does match a
|
||||||
|
null-branch advance, but no write path advances it. A natural
|
||||||
|
follow-on is a sibling table — `rfc_discussion_seen` or a
|
||||||
|
null-branch row on `branch_chat_seen` — that the discussion panel
|
||||||
|
advances on read, closing the §15.7 reconciliation loop for
|
||||||
|
discussion-surface notifications. Defer-able until inbox volume on
|
||||||
|
the new surface shows it matters.
|
||||||
|
- **PR-less discussion: AI participation.** v0.5.0's discussion
|
||||||
|
surface is human-only — no AI participant invocation, no `<change>`
|
||||||
|
block parsing, no per-thread model picker. The branch chat (§8.12)
|
||||||
|
retains the §18 AI surface. The natural follow-on is wiring the AI
|
||||||
|
participant into discussion threads (the model picker, the
|
||||||
|
`Ask Claude` button on a selection tooltip) without enabling
|
||||||
|
document edits — a discussion-only AI turn produces only chat
|
||||||
|
content, no `changes` row, no commit. Contribution still requires a
|
||||||
|
PR; the AI's discussion-side help is just better prompts. Touches
|
||||||
|
§10.10, §8.12, and §18.
|
||||||
|
- **PR-less discussion: anonymous read polish.** v0.5.0 inherits the
|
||||||
|
v0.3.0 anonymous-read contract — anyone can read; only signed-in
|
||||||
|
contributors can write. The composer affordance for anonymous
|
||||||
|
viewers ("Sign in to comment.") matches the existing read-only-bar
|
||||||
|
treatment but the surface has not yet been audited for the v0.6.0
|
||||||
|
hardening that tightens write gates app-wide. The §19.2 "public
|
||||||
|
face of discuss mode" entry overlaps; this entry is its discussion-
|
||||||
|
surface variant.
|
||||||
- **Deployment-supplied subject framing.** The framework was built
|
- **Deployment-supplied subject framing.** The framework was built
|
||||||
with one deployment in mind (OHM, standardizing natural-language
|
with one deployment in mind (OHM, standardizing natural-language
|
||||||
vocabulary), but the substrate generalizes to any domain that
|
vocabulary), but the substrate generalizes to any domain that
|
||||||
@@ -3340,6 +3874,240 @@ the new §15 (Notifications, in full), and §17 (the notification
|
|||||||
endpoints — list, mark-read, stream, watch mutation, preferences,
|
endpoints — list, mark-read, stream, watch mutation, preferences,
|
||||||
quiet-hours, per-user mute, unsubscribe, bounce webhook).
|
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
|
### 19.3 Working agreement for the queue
|
||||||
|
|
||||||
Pre-build sessions ran on the queue agreement from prior versions
|
Pre-build sessions ran on the queue agreement from prior versions
|
||||||
|
|||||||
@@ -81,3 +81,32 @@ WEBHOOK_EMAIL_BOUNCE_SECRET=
|
|||||||
# Production default is hourly; tests override to seconds via the same
|
# Production default is hourly; tests override to seconds via the same
|
||||||
# env var.
|
# env var.
|
||||||
HYGIENE_TICK_SECONDS=3600
|
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
|
||||||
|
|||||||
@@ -20,15 +20,19 @@ from pydantic import BaseModel, Field
|
|||||||
from . import (
|
from . import (
|
||||||
api_admin,
|
api_admin,
|
||||||
api_branches,
|
api_branches,
|
||||||
|
api_discussion,
|
||||||
api_graduation,
|
api_graduation,
|
||||||
api_notifications,
|
api_notifications,
|
||||||
api_prs,
|
api_prs,
|
||||||
auth,
|
auth,
|
||||||
db,
|
db,
|
||||||
|
device_trust as device_trust_mod,
|
||||||
|
docs as docs_mod,
|
||||||
entry as entry_mod,
|
entry as entry_mod,
|
||||||
cache,
|
cache,
|
||||||
funder,
|
funder,
|
||||||
health,
|
health,
|
||||||
|
notify,
|
||||||
philosophy,
|
philosophy,
|
||||||
providers as providers_mod,
|
providers as providers_mod,
|
||||||
)
|
)
|
||||||
@@ -54,6 +58,17 @@ class FunderCredentialBody(BaseModel):
|
|||||||
api_key: str = Field(min_length=1, max_length=2048)
|
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(
|
def make_router(
|
||||||
config: Config,
|
config: Config,
|
||||||
gitea: Gitea,
|
gitea: Gitea,
|
||||||
@@ -81,6 +96,12 @@ def make_router(
|
|||||||
# the §15.8 mute typeahead) and the §6/§17 admin surfaces
|
# the §15.8 mute typeahead) and the §6/§17 admin surfaces
|
||||||
# (role, write-mute, audit-log, graduation-readiness queue).
|
# (role, write-mute, audit-log, graduation-readiness queue).
|
||||||
router.include_router(api_admin.make_router(config))
|
router.include_router(api_admin.make_router(config))
|
||||||
|
# v0.5.0: §5 / §7 / §10 — PR-less per-RFC discussion endpoints.
|
||||||
|
# The substrate is the existing threads/thread_messages tables;
|
||||||
|
# rows whose branch_name IS NULL scope to the RFC's main view.
|
||||||
|
# Contribution still requires a PR (api_prs above); this surface
|
||||||
|
# is for discussion that does not yet warrant a branch.
|
||||||
|
router.include_router(api_discussion.make_router())
|
||||||
|
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
# §17: /api/health — unauthenticated post-flight probe.
|
# §17: /api/health — unauthenticated post-flight probe.
|
||||||
@@ -104,6 +125,17 @@ def make_router(
|
|||||||
payload = philosophy.load()
|
payload = philosophy.load()
|
||||||
return {"body": payload["body"]}
|
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.
|
# Auth surface — reads role from our users table per §6.
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
@@ -113,6 +145,29 @@ def make_router(
|
|||||||
user = auth.current_user(request)
|
user = auth.current_user(request)
|
||||||
if user is None:
|
if user is None:
|
||||||
return {"authenticated": False, "user": 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 {
|
return {
|
||||||
"authenticated": True,
|
"authenticated": True,
|
||||||
"user": {
|
"user": {
|
||||||
@@ -122,9 +177,144 @@ def make_router(
|
|||||||
"email": user.email,
|
"email": user.email,
|
||||||
"avatar_url": user.avatar_url,
|
"avatar_url": user.avatar_url,
|
||||||
"role": user.role,
|
"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
|
# §7: the catalog
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
|
|||||||
+382
-5
@@ -11,6 +11,8 @@ The endpoints in this module:
|
|||||||
- `GET /api/admin/users` — list users with role + mute
|
- `GET /api/admin/users` — list users with role + mute
|
||||||
- `POST /api/admin/users/<id>/role` — set role per §6.1
|
- `POST /api/admin/users/<id>/role` — set role per §6.1
|
||||||
- `POST /api/admin/users/<id>/mute` — set the §6.2 write-mute
|
- `POST /api/admin/users/<id>/mute` — set the §6.2 write-mute
|
||||||
|
- `POST /api/admin/users` — v0.17.0: create user + invite
|
||||||
|
- `GET /api/admin/users/invites` — v0.17.0: pending invites
|
||||||
- `GET /api/admin/audit` — paged `actions` log
|
- `GET /api/admin/audit` — paged `actions` log
|
||||||
- `GET /api/admin/permission-events` — paged `permission_events` log
|
- `GET /api/admin/permission-events` — paged `permission_events` log
|
||||||
- `GET /api/admin/graduation-queue` — super-drafts ready to graduate
|
- `GET /api/admin/graduation-queue` — super-drafts ready to graduate
|
||||||
@@ -33,8 +35,9 @@ from typing import Any
|
|||||||
from fastapi import APIRouter, HTTPException, Query, Request
|
from fastapi import APIRouter, HTTPException, Query, Request
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from . import auth, db
|
from . import auth, db, email_invite, invites
|
||||||
from .config import Config
|
from .config import Config
|
||||||
|
from .email import EmailConfig
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -50,11 +53,47 @@ class MuteBody(BaseModel):
|
|||||||
muted: bool
|
muted: bool
|
||||||
|
|
||||||
|
|
||||||
|
class PermissionStateBody(BaseModel):
|
||||||
|
# v0.9.0: the admin flip from the user-management page (roadmap
|
||||||
|
# item #7). `pending` is not surfaceable from the admin UI —
|
||||||
|
# only the OTC verify path lands a row in `pending` — but we
|
||||||
|
# accept it in the pattern in case a future restore-to-queue
|
||||||
|
# gesture wants to re-pend a granted user; today the UI only
|
||||||
|
# exposes `granted` and `revoked`.
|
||||||
|
state: str = Field(pattern="^(pending|granted|revoked)$")
|
||||||
|
|
||||||
|
|
||||||
class AllowlistAddBody(BaseModel):
|
class AllowlistAddBody(BaseModel):
|
||||||
email: str = Field(min_length=3, max_length=320)
|
email: str = Field(min_length=3, max_length=320)
|
||||||
note: str | None = Field(default=None, max_length=200)
|
note: str | None = Field(default=None, max_length=200)
|
||||||
|
|
||||||
|
|
||||||
|
class CreateUserInviteBody(BaseModel):
|
||||||
|
"""v0.17.0 / roadmap item #16 — admin-create user + invite email.
|
||||||
|
|
||||||
|
The admin types these fields on the "Create user + invite" modal on
|
||||||
|
`/admin/users`. The email + role are required; first/last name and
|
||||||
|
the optional custom message round out the body.
|
||||||
|
|
||||||
|
Bounds mirror the rest of the codebase:
|
||||||
|
* `email`: 320 chars — RFC 5321 envelope limit, same as
|
||||||
|
`OtcRequestBody` / `BetaRequestBody` / `AllowlistAddBody`.
|
||||||
|
* `first_name` / `last_name`: 120 chars — same as the v0.8.0
|
||||||
|
`BetaRequestBody` capture form.
|
||||||
|
* `role`: pydantic regex pinned to the §6.1 set so an unknown
|
||||||
|
role fails at the body bound (422) instead of landing as a
|
||||||
|
CHECK constraint violation in the migration.
|
||||||
|
* `custom_message`: 500 chars — the brief calls this out as
|
||||||
|
the max. The frontend modal shows a "remaining chars"
|
||||||
|
counter to match.
|
||||||
|
"""
|
||||||
|
email: str = Field(min_length=3, max_length=320)
|
||||||
|
first_name: str = Field(default="", max_length=120)
|
||||||
|
last_name: str = Field(default="", max_length=120)
|
||||||
|
role: str = Field(pattern="^(owner|admin|contributor)$")
|
||||||
|
custom_message: str = Field(default="", max_length=500)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Router
|
# Router
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -68,15 +107,69 @@ def make_router(config: Config) -> APIRouter:
|
|||||||
|
|
||||||
@router.get("/api/admin/users")
|
@router.get("/api/admin/users")
|
||||||
async def list_users(request: Request) -> dict[str, Any]:
|
async def list_users(request: Request) -> dict[str, Any]:
|
||||||
|
"""v0.9.0: the user-management surface (roadmap item #7).
|
||||||
|
|
||||||
|
The listing carries every column the admin queue needs to triage
|
||||||
|
pending beta-access requests alongside the existing role/mute
|
||||||
|
affordances. Sort order surfaces pending requests first (so the
|
||||||
|
admin lands on the inbox shape), then granted, then revoked;
|
||||||
|
within a state, ownership/role and recency are the tiebreakers
|
||||||
|
so the legacy ordering (owner first, then admin, then by name)
|
||||||
|
is preserved inside the granted bucket.
|
||||||
|
|
||||||
|
`permission_decided_by_login` joins the deciding admin row so
|
||||||
|
the UI can render "granted by @ben" without a second round-trip.
|
||||||
|
"""
|
||||||
auth.require_admin(request)
|
auth.require_admin(request)
|
||||||
rows = db.conn().execute(
|
rows = db.conn().execute(
|
||||||
"""
|
"""
|
||||||
SELECT id, gitea_login, display_name, email, role, muted,
|
SELECT u.id, u.gitea_login, u.display_name, u.email, u.role, u.muted,
|
||||||
created_at, last_seen_at
|
u.created_at, u.last_seen_at,
|
||||||
FROM users
|
u.permission_state, u.first_name, u.last_name,
|
||||||
ORDER BY role = 'owner' DESC, role = 'admin' DESC, display_name COLLATE NOCASE
|
u.beta_request_reason,
|
||||||
|
u.permission_decided_by, u.permission_decided_at,
|
||||||
|
d.gitea_login AS decided_by_login,
|
||||||
|
d.display_name AS decided_by_display
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN users d ON d.id = u.permission_decided_by
|
||||||
|
ORDER BY
|
||||||
|
CASE u.permission_state
|
||||||
|
WHEN 'pending' THEN 0
|
||||||
|
WHEN 'granted' THEN 1
|
||||||
|
WHEN 'revoked' THEN 2
|
||||||
|
ELSE 3
|
||||||
|
END,
|
||||||
|
u.role = 'owner' DESC, u.role = 'admin' DESC,
|
||||||
|
COALESCE(u.last_seen_at, u.created_at) DESC,
|
||||||
|
u.display_name COLLATE NOCASE
|
||||||
"""
|
"""
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
# v0.17.0 / roadmap item #16: a user row whose `last_seen_at`
|
||||||
|
# is NULL is one of two things — a brand-new row that was just
|
||||||
|
# provisioned (rare, and the v0.7.0 OTC verify path stamps
|
||||||
|
# last_seen_at on the same call that creates the row), or an
|
||||||
|
# admin-created invite-pending row (v0.17.0 — created by
|
||||||
|
# `POST /api/admin/users`). We surface a `pending_invite_id`
|
||||||
|
# field by joining through `user_invite_tokens` so the
|
||||||
|
# Users tab can render a "(pending invite)" badge alongside
|
||||||
|
# the role/state controls. Filters to invites that are
|
||||||
|
# neither expired nor claimed — once the invitee clicks
|
||||||
|
# through, the badge clears (and `last_seen_at` populates).
|
||||||
|
pending_invite_rows = db.conn().execute(
|
||||||
|
"""
|
||||||
|
SELECT invited_user_id, id AS invite_id, expires_at
|
||||||
|
FROM user_invite_tokens
|
||||||
|
WHERE claimed_at IS NULL
|
||||||
|
AND datetime(expires_at) > datetime('now')
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
pending_invites = {
|
||||||
|
r["invited_user_id"]: {
|
||||||
|
"invite_id": r["invite_id"],
|
||||||
|
"expires_at": r["expires_at"],
|
||||||
|
}
|
||||||
|
for r in pending_invite_rows
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
"items": [
|
"items": [
|
||||||
{
|
{
|
||||||
@@ -88,6 +181,222 @@ def make_router(config: Config) -> APIRouter:
|
|||||||
"muted": bool(r["muted"]),
|
"muted": bool(r["muted"]),
|
||||||
"created_at": r["created_at"],
|
"created_at": r["created_at"],
|
||||||
"last_seen_at": r["last_seen_at"],
|
"last_seen_at": r["last_seen_at"],
|
||||||
|
"permission_state": r["permission_state"] or "granted",
|
||||||
|
"first_name": r["first_name"] or "",
|
||||||
|
"last_name": r["last_name"] or "",
|
||||||
|
"beta_request_reason": r["beta_request_reason"] or "",
|
||||||
|
"permission_decided_at": r["permission_decided_at"],
|
||||||
|
"permission_decided_by_login": r["decided_by_login"],
|
||||||
|
"permission_decided_by_display": r["decided_by_display"],
|
||||||
|
# v0.17.0: present iff the row is invited-but-not-
|
||||||
|
# claimed-yet. The frontend renders a "(pending
|
||||||
|
# invite)" badge when this is non-null.
|
||||||
|
"pending_invite": pending_invites.get(r["id"]),
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
# ----- Create user + invite (v0.17.0 / roadmap item #16) -----
|
||||||
|
|
||||||
|
@router.post("/api/admin/users")
|
||||||
|
async def create_user_with_invite(
|
||||||
|
body: CreateUserInviteBody, request: Request,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Provision a fresh `users` row with a pre-assigned role + send
|
||||||
|
an invite email carrying a claim link.
|
||||||
|
|
||||||
|
Refusals:
|
||||||
|
* `422` — the admin tries to invite their own email (no
|
||||||
|
self-invite; symmetric to `set_permission`'s self-flip
|
||||||
|
refusal and `set_role`'s self-downgrade refusal). Use
|
||||||
|
the existing role-change channel for self-edits.
|
||||||
|
* `422` — the admin tries to grant `owner` without being
|
||||||
|
owner themselves. §6.1: owner-zero is the only owner
|
||||||
|
bootstrap path; new owners come from a sitting owner's
|
||||||
|
hand. A 422 here matches the message shape; a 403 would
|
||||||
|
also be defensible, but staying with 422 keeps the
|
||||||
|
"your input is bad" framing.
|
||||||
|
* `409` — the email already maps to a `users` row. The
|
||||||
|
admin should use the existing role / grant gestures on
|
||||||
|
the existing user, not create a duplicate.
|
||||||
|
* `422` — pydantic-level: malformed email, role outside
|
||||||
|
the §6.1 set, custom_message over 500 chars.
|
||||||
|
|
||||||
|
On success:
|
||||||
|
1. The invitee `users` row lands with the chosen role and
|
||||||
|
`permission_state='granted'` (admin's hand is the grant)
|
||||||
|
and `last_seen_at IS NULL` (the "(pending invite)"
|
||||||
|
discriminator the listing surface joins through).
|
||||||
|
2. The `user_invite_tokens` row lands with the bcrypt-
|
||||||
|
hashed opaque token; the raw token rides only in the
|
||||||
|
email link.
|
||||||
|
3. The invite email dispatches with subject "You're
|
||||||
|
invited to <app> by <admin>" and the custom message
|
||||||
|
embedded in a clearly-delimited block if present.
|
||||||
|
4. A `permission_events` row records the admin-create
|
||||||
|
gesture so the §6.5 / `permissions` admin tab carries
|
||||||
|
the audit trail alongside the existing grant/revoke
|
||||||
|
flips.
|
||||||
|
"""
|
||||||
|
viewer = auth.require_admin(request)
|
||||||
|
email_clean = body.email.strip().lower()
|
||||||
|
if "@" not in email_clean or len(email_clean.split("@")[-1]) < 2:
|
||||||
|
raise HTTPException(422, "Email looks malformed")
|
||||||
|
|
||||||
|
# Self-invite refusal. Compare the admin's own email
|
||||||
|
# case-insensitively against the invite target.
|
||||||
|
viewer_row = db.conn().execute(
|
||||||
|
"SELECT email FROM users WHERE id = ?", (viewer.user_id,)
|
||||||
|
).fetchone()
|
||||||
|
viewer_email = (viewer_row["email"] or "").strip().lower() if viewer_row else ""
|
||||||
|
if viewer_email and viewer_email == email_clean:
|
||||||
|
raise HTTPException(
|
||||||
|
422,
|
||||||
|
"You cannot invite yourself — use the role-change channel "
|
||||||
|
"if you need to edit your own row",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Owner-grant refusal: §6.1 says only a sitting owner can mint
|
||||||
|
# a new owner. An admin trying to invite-as-owner is refused
|
||||||
|
# at 422; the admin should ask the owner to issue the invite,
|
||||||
|
# or invite as `admin` and let the owner promote later.
|
||||||
|
if body.role == "owner" and viewer.role != "owner":
|
||||||
|
raise HTTPException(
|
||||||
|
422,
|
||||||
|
"Only an owner can invite a new owner — invite as admin and "
|
||||||
|
"ask the owner to promote, or have the owner issue this invite",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Duplicate-email refusal. A pre-existing row (regardless of
|
||||||
|
# permission_state) means the admin should use the existing
|
||||||
|
# role / grant gestures, not create a parallel user.
|
||||||
|
existing = db.conn().execute(
|
||||||
|
"SELECT id FROM users WHERE email = ? COLLATE NOCASE LIMIT 1",
|
||||||
|
(email_clean,),
|
||||||
|
).fetchone()
|
||||||
|
if existing is not None:
|
||||||
|
raise HTTPException(409, "A user with this email already exists")
|
||||||
|
|
||||||
|
# Create the invitee row + token row + send the email.
|
||||||
|
outcome = invites.create_invite(
|
||||||
|
email=email_clean,
|
||||||
|
first_name=body.first_name,
|
||||||
|
last_name=body.last_name,
|
||||||
|
role=body.role,
|
||||||
|
custom_message=body.custom_message,
|
||||||
|
created_by_admin_id=viewer.user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Audit row in permission_events so the admin Permissions tab
|
||||||
|
# carries the gesture. The before-state is "n/a" (the row
|
||||||
|
# did not exist); the after-state is the granted role. We
|
||||||
|
# use a new `event_kind='user_invited'` so the existing
|
||||||
|
# grant/revoke kinds stay scoped to their flip surface.
|
||||||
|
db.conn().execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO permission_events
|
||||||
|
(actor_user_id, subject_user_id, event_kind, details)
|
||||||
|
VALUES (?, ?, 'user_invited', ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
viewer.user_id,
|
||||||
|
outcome.invited_user_id,
|
||||||
|
json.dumps({
|
||||||
|
"email": email_clean,
|
||||||
|
"role": body.role,
|
||||||
|
"invite_id": outcome.invite_id,
|
||||||
|
"custom_message_chars": len(body.custom_message or ""),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build the claim URL using the same APP_URL the email module
|
||||||
|
# reads. The token rides as a query-string param to the
|
||||||
|
# frontend route `/invites/claim?token=…`; the frontend POSTs
|
||||||
|
# it back to `/api/invites/claim` which consumes the row.
|
||||||
|
cfg = EmailConfig.from_env()
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
claim_url = f"{cfg.app_url}/invites/claim?{urlencode({'token': outcome.raw_token})}"
|
||||||
|
|
||||||
|
# Fetch the inviter display so the email body can render
|
||||||
|
# "Ben Stull (ben@example.com) has invited you to …". We
|
||||||
|
# read off the row fresh rather than trusting the session
|
||||||
|
# cookie's cached display_name.
|
||||||
|
inviter_row = db.conn().execute(
|
||||||
|
"SELECT display_name, email FROM users WHERE id = ?",
|
||||||
|
(viewer.user_id,),
|
||||||
|
).fetchone()
|
||||||
|
inviter_display = (
|
||||||
|
(inviter_row["display_name"] if inviter_row else "") or viewer.display_name or "An admin"
|
||||||
|
)
|
||||||
|
inviter_email_for_body = (inviter_row["email"] if inviter_row else "") or viewer.email or ""
|
||||||
|
|
||||||
|
email_invite.send_invite_email(
|
||||||
|
to_address=email_clean,
|
||||||
|
claim_url=claim_url,
|
||||||
|
inviter_display=inviter_display,
|
||||||
|
inviter_email=inviter_email_for_body,
|
||||||
|
custom_message=body.custom_message,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"invite_id": outcome.invite_id,
|
||||||
|
"invited_user_id": outcome.invited_user_id,
|
||||||
|
"email": email_clean,
|
||||||
|
"role": body.role,
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.get("/api/admin/users/invites")
|
||||||
|
async def list_user_invites(request: Request) -> dict[str, Any]:
|
||||||
|
"""List active (not claimed, not expired) admin-issued invites.
|
||||||
|
|
||||||
|
Powers the admin's "I sent these but they haven't been claimed
|
||||||
|
yet" view. The frontend uses this alongside `list_users` —
|
||||||
|
the user-listing's `pending_invite` field carries the per-row
|
||||||
|
flag; this endpoint carries the full invite shape for a
|
||||||
|
dedicated drill-in surface.
|
||||||
|
"""
|
||||||
|
auth.require_admin(request)
|
||||||
|
rows = invites.list_pending_invites()
|
||||||
|
# Join through to the admin display names so the surface can
|
||||||
|
# render "invited by @ben" without a second client call.
|
||||||
|
admin_ids = {r.created_by_admin_id for r in rows}
|
||||||
|
admin_lookup: dict[int, dict[str, str]] = {}
|
||||||
|
if admin_ids:
|
||||||
|
placeholders = ",".join("?" * len(admin_ids))
|
||||||
|
admin_rows = db.conn().execute(
|
||||||
|
f"SELECT id, gitea_login, display_name FROM users "
|
||||||
|
f"WHERE id IN ({placeholders})",
|
||||||
|
tuple(admin_ids),
|
||||||
|
).fetchall()
|
||||||
|
admin_lookup = {
|
||||||
|
ar["id"]: {
|
||||||
|
"gitea_login": ar["gitea_login"] or "",
|
||||||
|
"display_name": ar["display_name"] or "",
|
||||||
|
}
|
||||||
|
for ar in admin_rows
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": r.id,
|
||||||
|
"email": r.email,
|
||||||
|
"role": r.role,
|
||||||
|
"first_name": r.first_name,
|
||||||
|
"last_name": r.last_name,
|
||||||
|
"custom_message": r.custom_message,
|
||||||
|
"created_at": r.created_at,
|
||||||
|
"expires_at": r.expires_at,
|
||||||
|
"invited_user_id": r.invited_user_id,
|
||||||
|
"created_by_admin_id": r.created_by_admin_id,
|
||||||
|
"created_by_login": admin_lookup.get(
|
||||||
|
r.created_by_admin_id, {}
|
||||||
|
).get("gitea_login", ""),
|
||||||
|
"created_by_display": admin_lookup.get(
|
||||||
|
r.created_by_admin_id, {}
|
||||||
|
).get("display_name", ""),
|
||||||
}
|
}
|
||||||
for r in rows
|
for r in rows
|
||||||
]
|
]
|
||||||
@@ -136,6 +445,74 @@ def make_router(config: Config) -> APIRouter:
|
|||||||
)
|
)
|
||||||
return {"ok": True, "role": body.role, "changed": True}
|
return {"ok": True, "role": body.role, "changed": True}
|
||||||
|
|
||||||
|
# ----- Permission state (§6.1, v0.9.0 roadmap item #7) -----
|
||||||
|
|
||||||
|
@router.post("/api/admin/users/{user_id}/permission")
|
||||||
|
async def set_permission(user_id: int, body: PermissionStateBody, request: Request) -> dict[str, Any]:
|
||||||
|
"""Flip a user's `permission_state` between pending/granted/revoked.
|
||||||
|
|
||||||
|
v0.8.0 wired the column shape but shipped no admin UI for it —
|
||||||
|
the grant gesture was a manual `UPDATE users` against the DB.
|
||||||
|
v0.9.0 (roadmap item #7) lands the admin user-management page;
|
||||||
|
this endpoint is its single write surface.
|
||||||
|
|
||||||
|
Audit shape: every flip writes a `permission_events` row with
|
||||||
|
event_kind in {'permission_granted', 'permission_revoked',
|
||||||
|
'permission_repended'} so §6.5's log carries the change. The
|
||||||
|
`permission_decided_by` / `permission_decided_at` columns on
|
||||||
|
the user row are co-stamped so the user listing can render
|
||||||
|
"granted by @ben at <date>" without a second join through
|
||||||
|
the audit table.
|
||||||
|
|
||||||
|
Refuses with 422 if the admin tries to flip their own row
|
||||||
|
(no self-grant / self-revoke; symmetric to set_mute's
|
||||||
|
self-mute refusal and set_role's self-downgrade refusal).
|
||||||
|
"""
|
||||||
|
viewer = auth.require_admin(request)
|
||||||
|
target = db.conn().execute(
|
||||||
|
"SELECT id, role, permission_state FROM users WHERE id = ?",
|
||||||
|
(user_id,),
|
||||||
|
).fetchone()
|
||||||
|
if target is None:
|
||||||
|
raise HTTPException(404, "User not found")
|
||||||
|
if target["id"] == viewer.user_id:
|
||||||
|
raise HTTPException(422, "You cannot change your own permission state")
|
||||||
|
|
||||||
|
before = target["permission_state"] or "granted"
|
||||||
|
after = body.state
|
||||||
|
if before == after:
|
||||||
|
return {"ok": True, "permission_state": after, "changed": False}
|
||||||
|
|
||||||
|
db.conn().execute(
|
||||||
|
"""
|
||||||
|
UPDATE users
|
||||||
|
SET permission_state = ?,
|
||||||
|
permission_decided_by = ?,
|
||||||
|
permission_decided_at = datetime('now')
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(after, viewer.user_id, user_id),
|
||||||
|
)
|
||||||
|
event_kind = {
|
||||||
|
"granted": "permission_granted",
|
||||||
|
"revoked": "permission_revoked",
|
||||||
|
"pending": "permission_repended",
|
||||||
|
}[after]
|
||||||
|
db.conn().execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO permission_events
|
||||||
|
(actor_user_id, subject_user_id, event_kind, details)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
viewer.user_id,
|
||||||
|
user_id,
|
||||||
|
event_kind,
|
||||||
|
json.dumps({"before": before, "after": after}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return {"ok": True, "permission_state": after, "changed": True}
|
||||||
|
|
||||||
# ----- Write-mute (§6.2) -----
|
# ----- Write-mute (§6.2) -----
|
||||||
|
|
||||||
@router.post("/api/admin/users/{user_id}/mute")
|
@router.post("/api/admin/users/{user_id}/mute")
|
||||||
|
|||||||
@@ -0,0 +1,330 @@
|
|||||||
|
"""§5 / §7 / §10 — PR-less per-RFC discussion endpoints (v0.5.0).
|
||||||
|
|
||||||
|
This module surfaces the discussion-without-PR shape committed by the
|
||||||
|
roadmap's item #3. The substrate is the existing `threads` /
|
||||||
|
`thread_messages` pair from §5: rows whose `branch_name` is NULL are
|
||||||
|
scoped to the RFC's main view (the schema comment on the column says
|
||||||
|
exactly this; until now no write path produced such rows). This module
|
||||||
|
is the read+write surface for those rows.
|
||||||
|
|
||||||
|
Contribution still requires a PR: the §10 PR flow is unchanged, the
|
||||||
|
branch-scoped chat in `api_branches.py` is unchanged, and accept /
|
||||||
|
decline of AI `<change>` blocks still lives on a branch. What this
|
||||||
|
module adds is the "discuss freely about the RFC, no branch yet" surface
|
||||||
|
— a place to drop a question, a flag-style observation, or a multi-turn
|
||||||
|
conversation that does not yet warrant cutting a branch.
|
||||||
|
|
||||||
|
Auth shape mirrors the v0.3.0 anonymous-read contract: reads are open,
|
||||||
|
writes require `auth.require_contributor`. Item #4 ("anon discuss/
|
||||||
|
contribute off-limits") tightens the read gate in v0.6.0; v0.5.0's
|
||||||
|
write gate already holds the line.
|
||||||
|
|
||||||
|
Notification routing reuses the existing `fan_out_chat_message` path
|
||||||
|
with `branch_name=None`; the `notifications.branch_name` column is
|
||||||
|
nullable, and the inbox row prose ("@alice posted a chat message on
|
||||||
|
<RFC title>") renders identically whether the chat lives on a branch
|
||||||
|
or on the RFC's discussion surface. The existing
|
||||||
|
`chat_message_in_participated_thread` / `chat_reply_to_my_message`
|
||||||
|
event kinds carry both shapes; introducing a parallel
|
||||||
|
`open_rfc_discussion_thread` / `post_rfc_discussion_message` enum pair
|
||||||
|
would split routing without adding signal. The §15 §19.2 candidate
|
||||||
|
"distinct event_kinds for PR-less discussion" notes the option for a
|
||||||
|
future session if evidence demands the split.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from . import auth, chat as chat_layer, db
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Request bodies
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class DiscussionThreadCreateBody(BaseModel):
|
||||||
|
"""A discussion thread is a `thread_kind='chat'`, `anchor_kind='whole-doc'`,
|
||||||
|
`branch_name=NULL` row. Anchored-range / per-paragraph threads on the
|
||||||
|
RFC discussion surface are a §19.2 candidate — the schema supports
|
||||||
|
them; the UI work to surface a range-anchor on a non-branch view is
|
||||||
|
the deferred part. v0.5.0 keeps the shape narrow."""
|
||||||
|
label: str | None = Field(default=None, max_length=400)
|
||||||
|
message: str | None = Field(default=None, max_length=20_000)
|
||||||
|
|
||||||
|
|
||||||
|
class DiscussionMessageBody(BaseModel):
|
||||||
|
text: str = Field(min_length=1, max_length=20_000)
|
||||||
|
quote: str | None = Field(default=None, max_length=2000)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Router
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def make_router() -> APIRouter:
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------
|
||||||
|
# GET /api/rfcs/<slug>/discussion/threads
|
||||||
|
# Lists every PR-less thread on the RFC. The default whole-doc thread
|
||||||
|
# is materialized lazily on first list (mirroring the §8.12 branch-
|
||||||
|
# chat default-thread treatment) so the UI always has a target for
|
||||||
|
# the compose-message affordance.
|
||||||
|
# -------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/api/rfcs/{slug}/discussion/threads")
|
||||||
|
async def list_discussion_threads(slug: str, request: Request) -> dict[str, Any]:
|
||||||
|
viewer = auth.current_user(request)
|
||||||
|
_require_rfc_readable(slug)
|
||||||
|
# Ensure the default whole-doc discussion thread exists. We mint
|
||||||
|
# it on first read regardless of viewer (anonymous viewers can
|
||||||
|
# trigger the creation — the row's `created_by` is null in that
|
||||||
|
# case, mirroring `_ensure_branch_chat_thread`).
|
||||||
|
_ensure_discussion_thread(slug, viewer)
|
||||||
|
rows = db.conn().execute(
|
||||||
|
"""
|
||||||
|
SELECT id, anchor_kind, anchor_payload, thread_kind, label, state,
|
||||||
|
created_by, created_at, resolved_at, resolved_by
|
||||||
|
FROM threads
|
||||||
|
WHERE rfc_slug = ? AND branch_name IS NULL
|
||||||
|
ORDER BY id
|
||||||
|
""",
|
||||||
|
(slug,),
|
||||||
|
).fetchall()
|
||||||
|
return {"items": [_serialize_thread(r) for r in rows]}
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------
|
||||||
|
# POST /api/rfcs/<slug>/discussion/threads
|
||||||
|
# Open a fresh discussion thread. Writes require require_contributor
|
||||||
|
# — anonymous viewers can read but cannot open a thread, per item
|
||||||
|
# #4's hardening anticipated in v0.6.0 (we already enforce it here
|
||||||
|
# to avoid the open window).
|
||||||
|
# -------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.post("/api/rfcs/{slug}/discussion/threads")
|
||||||
|
async def create_discussion_thread(
|
||||||
|
slug: str, body: DiscussionThreadCreateBody, request: Request
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
viewer = auth.require_contributor(request)
|
||||||
|
_require_rfc_readable(slug)
|
||||||
|
cur = db.conn().execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO threads
|
||||||
|
(rfc_slug, branch_name, anchor_kind, anchor_payload,
|
||||||
|
thread_kind, label, created_by)
|
||||||
|
VALUES (?, NULL, 'whole-doc', NULL, 'chat', ?, ?)
|
||||||
|
""",
|
||||||
|
(slug, body.label, viewer.user_id),
|
||||||
|
)
|
||||||
|
thread_id = cur.lastrowid
|
||||||
|
message_id = None
|
||||||
|
if body.message:
|
||||||
|
message_id = chat_layer.append_user_message(
|
||||||
|
thread_id=thread_id,
|
||||||
|
author_user_id=viewer.user_id,
|
||||||
|
text=body.message,
|
||||||
|
quote=None,
|
||||||
|
)
|
||||||
|
return {"thread_id": thread_id, "message_id": message_id}
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------
|
||||||
|
# GET /api/rfcs/<slug>/discussion/threads/<thread_id>/messages
|
||||||
|
# -------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/api/rfcs/{slug}/discussion/threads/{thread_id}/messages")
|
||||||
|
async def get_discussion_thread_messages(
|
||||||
|
slug: str, thread_id: int, request: Request
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
_viewer = auth.current_user(request)
|
||||||
|
_require_rfc_readable(slug)
|
||||||
|
thread = _require_discussion_thread(slug, thread_id)
|
||||||
|
rows = db.conn().execute(
|
||||||
|
"""
|
||||||
|
SELECT m.id, m.role, m.author_user_id,
|
||||||
|
u.gitea_login AS author_login,
|
||||||
|
u.display_name AS author_display,
|
||||||
|
m.model_id, m.text, m.quote, m.created_at
|
||||||
|
FROM thread_messages m
|
||||||
|
LEFT JOIN users u ON u.id = m.author_user_id
|
||||||
|
WHERE m.thread_id = ?
|
||||||
|
ORDER BY m.id
|
||||||
|
""",
|
||||||
|
(thread_id,),
|
||||||
|
).fetchall()
|
||||||
|
return {
|
||||||
|
"thread": _serialize_thread(thread),
|
||||||
|
"messages": [_serialize_message(r) for r in rows],
|
||||||
|
}
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------
|
||||||
|
# POST /api/rfcs/<slug>/discussion/threads/<thread_id>/messages
|
||||||
|
# -------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.post("/api/rfcs/{slug}/discussion/threads/{thread_id}/messages")
|
||||||
|
async def post_discussion_message(
|
||||||
|
slug: str, thread_id: int, body: DiscussionMessageBody, request: Request
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
viewer = auth.require_contributor(request)
|
||||||
|
_require_rfc_readable(slug)
|
||||||
|
_require_discussion_thread(slug, thread_id)
|
||||||
|
message_id = chat_layer.append_user_message(
|
||||||
|
thread_id=thread_id,
|
||||||
|
author_user_id=viewer.user_id,
|
||||||
|
text=body.text,
|
||||||
|
quote=body.quote,
|
||||||
|
)
|
||||||
|
return {"ok": True, "message_id": message_id}
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------
|
||||||
|
# POST /api/rfcs/<slug>/discussion/threads/<thread_id>/resolve
|
||||||
|
# -------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.post("/api/rfcs/{slug}/discussion/threads/{thread_id}/resolve")
|
||||||
|
async def resolve_discussion_thread(
|
||||||
|
slug: str, thread_id: int, request: Request
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
viewer = auth.require_contributor(request)
|
||||||
|
rfc = _require_rfc_readable(slug)
|
||||||
|
thread = _require_discussion_thread(slug, thread_id)
|
||||||
|
if not _can_resolve(rfc, thread, viewer):
|
||||||
|
raise HTTPException(
|
||||||
|
403,
|
||||||
|
"Only the thread creator, an RFC owner/arbiter, or an app admin/owner may resolve",
|
||||||
|
)
|
||||||
|
db.conn().execute(
|
||||||
|
"""
|
||||||
|
UPDATE threads
|
||||||
|
SET state = 'resolved',
|
||||||
|
resolved_by = ?,
|
||||||
|
resolved_at = datetime('now')
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(viewer.user_id, thread_id),
|
||||||
|
)
|
||||||
|
return {"ok": True, "thread_id": thread_id}
|
||||||
|
|
||||||
|
return router
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _require_rfc_readable(slug: str):
|
||||||
|
"""Per the v0.3.0 anonymous-read contract: any cached RFC is readable
|
||||||
|
by anyone. Withdrawn entries refuse reads of every shape — same rule
|
||||||
|
`_require_rfc_with_repo` in `api_branches.py` follows."""
|
||||||
|
row = db.conn().execute(
|
||||||
|
"SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(404, "RFC not found")
|
||||||
|
if row["state"] == "withdrawn":
|
||||||
|
raise HTTPException(409, "RFC is withdrawn")
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def _require_discussion_thread(slug: str, thread_id: int):
|
||||||
|
"""A discussion thread is one whose (rfc_slug, branch_name) = (slug,
|
||||||
|
NULL). Refuse cleanly if the thread id resolves to a branch-scoped
|
||||||
|
thread instead — that lookup belongs on the branch endpoints."""
|
||||||
|
row = db.conn().execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM threads
|
||||||
|
WHERE id = ? AND rfc_slug = ? AND branch_name IS NULL
|
||||||
|
""",
|
||||||
|
(thread_id, slug),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, "Discussion thread not found")
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_discussion_thread(slug: str, viewer) -> int:
|
||||||
|
"""Per the §8.12 lazy-create pattern, materialize a default whole-doc
|
||||||
|
chat thread on the RFC's discussion surface on first read. Created_by
|
||||||
|
is null when an anonymous viewer triggers creation — the thread is
|
||||||
|
structurally owned by the RFC, not by whoever opened the view."""
|
||||||
|
row = db.conn().execute(
|
||||||
|
"""
|
||||||
|
SELECT id FROM threads
|
||||||
|
WHERE rfc_slug = ? AND branch_name IS NULL
|
||||||
|
AND anchor_kind = 'whole-doc' AND thread_kind = 'chat'
|
||||||
|
ORDER BY id LIMIT 1
|
||||||
|
""",
|
||||||
|
(slug,),
|
||||||
|
).fetchone()
|
||||||
|
if row:
|
||||||
|
return row["id"]
|
||||||
|
cur = db.conn().execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO threads
|
||||||
|
(rfc_slug, branch_name, anchor_kind, thread_kind, label, created_by)
|
||||||
|
VALUES (?, NULL, 'whole-doc', 'chat', NULL, ?)
|
||||||
|
""",
|
||||||
|
(slug, viewer.user_id if viewer else None),
|
||||||
|
)
|
||||||
|
return cur.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def _can_resolve(rfc, thread, viewer) -> bool:
|
||||||
|
if viewer is None:
|
||||||
|
return False
|
||||||
|
if viewer.role in ("owner", "admin"):
|
||||||
|
return True
|
||||||
|
owners = json.loads(rfc["owners_json"] or "[]")
|
||||||
|
arbiters = json.loads(rfc["arbiters_json"] or "[]")
|
||||||
|
if viewer.gitea_login in owners or viewer.gitea_login in arbiters:
|
||||||
|
return True
|
||||||
|
if thread["created_by"] == viewer.user_id:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Serializers — mirror api_branches.py's shape
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_thread(row) -> dict[str, Any]:
|
||||||
|
payload = row["anchor_payload"]
|
||||||
|
try:
|
||||||
|
anchor = json.loads(payload) if payload else None
|
||||||
|
except Exception:
|
||||||
|
anchor = None
|
||||||
|
return {
|
||||||
|
"id": row["id"],
|
||||||
|
"anchor_kind": row["anchor_kind"],
|
||||||
|
"anchor_payload": anchor,
|
||||||
|
"thread_kind": row["thread_kind"],
|
||||||
|
"label": row["label"],
|
||||||
|
"state": row["state"],
|
||||||
|
"created_by": row["created_by"],
|
||||||
|
"created_at": row["created_at"],
|
||||||
|
"resolved_at": row["resolved_at"] if "resolved_at" in row.keys() else None,
|
||||||
|
"resolved_by": row["resolved_by"] if "resolved_by" in row.keys() else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_message(row) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": row["id"],
|
||||||
|
"role": row["role"],
|
||||||
|
"author_user_id": row["author_user_id"],
|
||||||
|
"author_login": row["author_login"],
|
||||||
|
"author_display": row["author_display"],
|
||||||
|
"model_id": row["model_id"],
|
||||||
|
"text": row["text"],
|
||||||
|
"quote": row["quote"],
|
||||||
|
"created_at": row["created_at"],
|
||||||
|
}
|
||||||
@@ -520,7 +520,16 @@ def make_router(
|
|||||||
|
|
||||||
@router.get("/api/rfcs/{slug}/graduate/progress")
|
@router.get("/api/rfcs/{slug}/graduate/progress")
|
||||||
async def graduate_progress(slug: str, request: Request):
|
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)
|
state = _get_active(slug)
|
||||||
if state is None:
|
if state is None:
|
||||||
raise HTTPException(404, "No graduation in flight for this slug")
|
raise HTTPException(404, "No graduation in flight for this slug")
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ The endpoints in this module are:
|
|||||||
- `POST /api/users/me/quiet-hours` — set / clear
|
- `POST /api/users/me/quiet-hours` — set / clear
|
||||||
- `POST /api/users/<id>/notification-mute` — §15.8
|
- `POST /api/users/<id>/notification-mute` — §15.8
|
||||||
- `DELETE /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
|
- `GET /api/email/unsubscribe` — §15.4 one-click
|
||||||
- `POST /api/webhooks/email-bounce` — §15.4 receiver
|
- `POST /api/webhooks/email-bounce` — §15.4 receiver
|
||||||
|
|
||||||
@@ -73,6 +75,15 @@ class BounceBody(BaseModel):
|
|||||||
kind: str = Field(default="hard") # 'hard' or 'complaint'
|
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
|
# Router
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -362,6 +373,74 @@ def make_router(config: Config) -> APIRouter:
|
|||||||
)
|
)
|
||||||
return {"ok": True}
|
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 -----
|
# ----- Email: one-click unsubscribe + bounce webhook -----
|
||||||
|
|
||||||
@router.get("/api/email/unsubscribe")
|
@router.get("/api/email/unsubscribe")
|
||||||
|
|||||||
+68
-6
@@ -30,6 +30,12 @@ class SessionUser:
|
|||||||
email: str
|
email: str
|
||||||
avatar_url: str
|
avatar_url: str
|
||||||
role: 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:
|
def as_actor(self) -> Actor:
|
||||||
return Actor(
|
return Actor(
|
||||||
@@ -90,6 +96,13 @@ def allowlist_is_active() -> bool:
|
|||||||
def is_allowed_sign_in(profile: dict[str, Any]) -> bool:
|
def is_allowed_sign_in(profile: dict[str, Any]) -> bool:
|
||||||
"""Decide whether a freshly-completed OAuth profile may sign in.
|
"""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:
|
Three accept paths:
|
||||||
1. The allowlist is empty (gate off).
|
1. The allowlist is empty (gate off).
|
||||||
2. The Gitea profile's email is in `allowed_emails` (case-insensitive).
|
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()
|
existing = c.execute("SELECT * FROM users WHERE gitea_id = ?", (gitea_id,)).fetchone()
|
||||||
if existing is None:
|
if existing is None:
|
||||||
role = "owner" if config.owner_gitea_login and login == config.owner_gitea_login else "contributor"
|
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(
|
cur = c.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO users (gitea_id, gitea_login, email, display_name, avatar_url, role)
|
INSERT INTO users (gitea_id, gitea_login, email, display_name, avatar_url, role, permission_state)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, 'granted')
|
||||||
""",
|
""",
|
||||||
(gitea_id, login, email, display, avatar, role),
|
(gitea_id, login, email, display, avatar, role),
|
||||||
)
|
)
|
||||||
user_id = cur.lastrowid
|
user_id = cur.lastrowid
|
||||||
|
permission_state = "granted"
|
||||||
else:
|
else:
|
||||||
user_id = existing["id"]
|
user_id = existing["id"]
|
||||||
role = existing["role"]
|
role = existing["role"]
|
||||||
|
permission_state = existing["permission_state"] or "granted"
|
||||||
c.execute(
|
c.execute(
|
||||||
"""
|
"""
|
||||||
UPDATE users
|
UPDATE users
|
||||||
@@ -160,6 +183,7 @@ def provision_user(config: Config, profile: dict[str, Any]) -> SessionUser:
|
|||||||
email=email,
|
email=email,
|
||||||
avatar_url=avatar,
|
avatar_url=avatar,
|
||||||
role=role,
|
role=role,
|
||||||
|
permission_state=permission_state,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -178,6 +202,12 @@ def store_session(request: Request, user: SessionUser) -> None:
|
|||||||
"email": user.email,
|
"email": user.email,
|
||||||
"avatar_url": user.avatar_url,
|
"avatar_url": user.avatar_url,
|
||||||
"role": user.role,
|
"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
|
# Re-read the role from the database every request so role changes
|
||||||
# take effect on the next API call without forcing a logout.
|
# take effect on the next API call without forcing a logout.
|
||||||
row = db.conn().execute(
|
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"],),
|
(raw["user_id"],),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if row is None:
|
if row is None:
|
||||||
return 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(
|
return SessionUser(
|
||||||
user_id=row["id"],
|
user_id=row["id"],
|
||||||
gitea_id=row["gitea_id"],
|
gitea_id=row["gitea_id"] or 0,
|
||||||
gitea_login=row["gitea_login"],
|
gitea_login=row["gitea_login"] or "",
|
||||||
display_name=row["display_name"],
|
display_name=row["display_name"],
|
||||||
email=row["email"] or "",
|
email=row["email"] or "",
|
||||||
avatar_url=row["avatar_url"] or "",
|
avatar_url=row["avatar_url"] or "",
|
||||||
role=row["role"],
|
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:
|
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)
|
user = require_user(request)
|
||||||
row = db.conn().execute("SELECT muted FROM users WHERE id = ?", (user.user_id,)).fetchone()
|
row = db.conn().execute("SELECT muted FROM users WHERE id = ?", (user.user_id,)).fetchone()
|
||||||
if row and row["muted"]:
|
if row and row["muted"]:
|
||||||
raise HTTPException(status_code=403, detail="Your account is 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
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+6
-1
@@ -168,10 +168,15 @@ def _fan_out_chat(thread_id: int, author_user_id: int, message_id: int) -> None:
|
|||||||
).fetchone()
|
).fetchone()
|
||||||
if pr_row:
|
if pr_row:
|
||||||
pr_number = pr_row["pr_number"]
|
pr_number = pr_row["pr_number"]
|
||||||
|
# v0.5.0 (§5 / §10 — PR-less discussion): a thread with
|
||||||
|
# branch_name IS NULL is scoped to the RFC's main view. Pass None
|
||||||
|
# through to the notify chokepoint so the notifications row keeps
|
||||||
|
# `branch_name` null — coercing it to "main" would misroute the
|
||||||
|
# §15.7 chat-seen reconciler (which keys on branch_name).
|
||||||
notify.fan_out_chat_message(
|
notify.fan_out_chat_message(
|
||||||
actor_user_id=author_user_id,
|
actor_user_id=author_user_id,
|
||||||
rfc_slug=row["rfc_slug"],
|
rfc_slug=row["rfc_slug"],
|
||||||
branch_name=row["branch_name"] or "main",
|
branch_name=row["branch_name"],
|
||||||
thread_id=thread_id,
|
thread_id=thread_id,
|
||||||
message_id=message_id,
|
message_id=message_id,
|
||||||
is_review_thread=(row["thread_kind"] == "review"),
|
is_review_thread=(row["thread_kind"] == "review"),
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -139,6 +139,10 @@ _EVENT_TO_CATEGORY: dict[str, str] = {
|
|||||||
"graduation_complete": "personal-direct",
|
"graduation_complete": "personal-direct",
|
||||||
"super_draft_graduation_ready": "admin-actionable",
|
"super_draft_graduation_ready": "admin-actionable",
|
||||||
"claim_opened": "structural",
|
"claim_opened": "structural",
|
||||||
|
# v0.9.0: roadmap item #7. A fresh beta-access request lands as
|
||||||
|
# an admin-actionable signal so it consults `email_admin_actionable`
|
||||||
|
# and reaches owners/admins only.
|
||||||
|
"new_beta_request": "admin-actionable",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -285,6 +289,13 @@ def _deep_link(payload: dict, cfg: EmailConfig) -> str:
|
|||||||
slug = payload.get("rfc_slug")
|
slug = payload.get("rfc_slug")
|
||||||
pr = payload.get("pr_number")
|
pr = payload.get("pr_number")
|
||||||
branch = payload.get("branch_name")
|
branch = payload.get("branch_name")
|
||||||
|
event_kind = payload.get("event_kind")
|
||||||
|
# v0.9.0: framework-scoped admin signals link to the admin
|
||||||
|
# surface, not /rfc/... The `new_beta_request` event is the
|
||||||
|
# canonical example; future framework-scoped admin events
|
||||||
|
# may reuse the same branch.
|
||||||
|
if event_kind == "new_beta_request":
|
||||||
|
return f"{cfg.app_url}/admin/users"
|
||||||
if slug and pr:
|
if slug and pr:
|
||||||
return f"{cfg.app_url}/rfc/{slug}/pr/{pr}"
|
return f"{cfg.app_url}/rfc/{slug}/pr/{pr}"
|
||||||
if slug and branch:
|
if slug and branch:
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
"""Outbound admin-invite email — a thin wrapper over the existing SMTP layer.
|
||||||
|
|
||||||
|
v0.17.0 / roadmap item #16: when an admin uses `POST /api/admin/users` to
|
||||||
|
create-with-invite, this module composes and sends the invite envelope.
|
||||||
|
|
||||||
|
Structurally distinct from:
|
||||||
|
|
||||||
|
* `email_otc.py` (v0.7.0) — that one carries a credential the user
|
||||||
|
just requested; this one carries a credential the admin is sending
|
||||||
|
unsolicited.
|
||||||
|
* `email.py` (§15.4 notification mailer) — that one is inbox-driven,
|
||||||
|
bundled, with category opt-outs; this one is a single transactional
|
||||||
|
outbound to a person who does not yet have an inbox.
|
||||||
|
* v0.9.0's `new_beta_request` admin notification — that one is
|
||||||
|
invitee-to-admin (an existing pending user asking to be let in);
|
||||||
|
this one is admin-to-invitee (an admin reaching out to seed access).
|
||||||
|
|
||||||
|
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 admin endpoint returns 200 on the
|
||||||
|
create-row half regardless of send outcome — a transient SMTP
|
||||||
|
failure should not roll back the invite (an admin can re-send via a
|
||||||
|
future "resend invite" gesture, deferred to a follow-up release).
|
||||||
|
"""
|
||||||
|
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_invite_email(
|
||||||
|
*,
|
||||||
|
to_address: str,
|
||||||
|
claim_url: str,
|
||||||
|
inviter_display: str,
|
||||||
|
inviter_email: str,
|
||||||
|
custom_message: str = "",
|
||||||
|
) -> bool:
|
||||||
|
"""Compose and send the admin-invite 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 body names the inviting admin, embeds the optional custom
|
||||||
|
message in a clearly delimited block if present, and ships the
|
||||||
|
claim link. The subject names the inviter so the recipient can
|
||||||
|
recognize the sender at a glance in their inbox preview.
|
||||||
|
"""
|
||||||
|
cfg = EmailConfig.from_env()
|
||||||
|
subject = _subject(inviter_display, cfg)
|
||||||
|
body = _body(claim_url, inviter_display, inviter_email, custom_message, cfg)
|
||||||
|
envelope = {
|
||||||
|
"to": to_address,
|
||||||
|
"from": formataddr((cfg.from_name, cfg.from_address)),
|
||||||
|
"subject": subject,
|
||||||
|
"body": body,
|
||||||
|
"kind": "invite",
|
||||||
|
}
|
||||||
|
_SENT.append(envelope)
|
||||||
|
|
||||||
|
if not cfg.enabled:
|
||||||
|
log.info("invite email disabled (EMAIL_ENABLED=0): to=%s", to_address)
|
||||||
|
return True
|
||||||
|
if not cfg.smtp_host:
|
||||||
|
# Dev fallback: surface the claim URL at INFO so the operator can
|
||||||
|
# complete a claim flow without an SMTP relay. In production
|
||||||
|
# SMTP_HOST is always set per OHM's overlay.
|
||||||
|
log.info("invite email (stdout fallback): to=%s claim_url=%s", to_address, claim_url)
|
||||||
|
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("invite email send failed: to=%s", to_address)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _subject(inviter_display: str, cfg: EmailConfig) -> str:
|
||||||
|
"""e.g. "You're invited to Wiggleverse by Ben Stull"."""
|
||||||
|
inviter = inviter_display or "an admin"
|
||||||
|
return f"You're invited to {cfg.from_name} by {inviter}"
|
||||||
|
|
||||||
|
|
||||||
|
def _body(
|
||||||
|
claim_url: str,
|
||||||
|
inviter_display: str,
|
||||||
|
inviter_email: str,
|
||||||
|
custom_message: str,
|
||||||
|
cfg: EmailConfig,
|
||||||
|
) -> str:
|
||||||
|
inviter = inviter_display or "An admin"
|
||||||
|
inviter_suffix = f" ({inviter_email})" if inviter_email else ""
|
||||||
|
message_block = ""
|
||||||
|
if custom_message.strip():
|
||||||
|
# Indent the custom message so it reads as a clearly-delimited
|
||||||
|
# quote rather than running together with the framework's
|
||||||
|
# framing text. Per-line indent keeps multi-line messages
|
||||||
|
# visually grouped in plain-text mail clients.
|
||||||
|
indented = "\n".join(f" {line}" for line in custom_message.strip().splitlines())
|
||||||
|
message_block = f"\nA personal note from {inviter}:\n\n{indented}\n"
|
||||||
|
|
||||||
|
return (
|
||||||
|
f"{inviter}{inviter_suffix} has invited you to {cfg.from_name}.\n"
|
||||||
|
f"{message_block}\n"
|
||||||
|
f"Click the link below to claim your account and sign in.\n"
|
||||||
|
f"This link is single-use and expires in 7 days.\n\n"
|
||||||
|
f" {claim_url}\n\n"
|
||||||
|
f"If you weren't expecting this invitation, you can ignore this\n"
|
||||||
|
f"email — no account becomes active until you click the link.\n\n"
|
||||||
|
f"---\n"
|
||||||
|
f"{cfg.from_name} · {cfg.app_url}\n"
|
||||||
|
)
|
||||||
@@ -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"
|
||||||
|
)
|
||||||
@@ -0,0 +1,425 @@
|
|||||||
|
"""§6.1 / v0.17.0: admin-create user with role + invite email (roadmap item #16).
|
||||||
|
|
||||||
|
Distinguishes from the v0.8.0 self-serve beta-access flow:
|
||||||
|
|
||||||
|
* **Self-serve (v0.8.0)** — anyone with an email can request OTC sign-in;
|
||||||
|
a fresh `users` row lands in `permission_state='pending'`; an admin
|
||||||
|
grants or revokes via the v0.9.0 user-management page.
|
||||||
|
|
||||||
|
* **Admin-create (v0.17.0)** — an admin types first/last/email/role
|
||||||
|
*before* the invitee has signed in. The framework provisions the
|
||||||
|
`users` row with the chosen role and `permission_state='granted'`
|
||||||
|
(the admin's hand is the grant) and `last_seen_at IS NULL` as the
|
||||||
|
"invited but not yet arrived" discriminator. An invite-token row
|
||||||
|
lands in `user_invite_tokens`; the admin's chosen `custom_message`
|
||||||
|
(if any) rides in the email body alongside the claim link.
|
||||||
|
|
||||||
|
* **Claim flow** — the invitee clicks the link, which lands them at
|
||||||
|
`/invites/claim?token=…`. The page POSTs `/api/invites/claim` with
|
||||||
|
the token. The framework verifies the token (not expired, not
|
||||||
|
claimed, hash matches), marks the row claimed, signs the user in,
|
||||||
|
and returns a payload telling the frontend whether to route to
|
||||||
|
passcode-set (if v0.10.0 passcode flow is in play and the user has
|
||||||
|
no passcode yet) or to `/`. **No OTC roundtrip** — clicking the
|
||||||
|
unique token in the email is itself proof of email control, per
|
||||||
|
the roadmap. This is the intentional UX shortcut for first
|
||||||
|
sign-in; subsequent sign-ins use the standard OTC / passcode
|
||||||
|
paths.
|
||||||
|
|
||||||
|
The shape:
|
||||||
|
|
||||||
|
* `create_invite(...)` — provision the invitee `users` row + the
|
||||||
|
`user_invite_tokens` row, return the raw token for the admin
|
||||||
|
endpoint to put in the outbound email link.
|
||||||
|
* `claim(raw_token)` — validate the token, mark it claimed, return
|
||||||
|
the `SessionUser` the endpoint signs in. Distinguishes the failure
|
||||||
|
modes (`expired`, `claimed`, `unknown`, `invalid`) so the endpoint
|
||||||
|
can map them to HTTP 410 vs HTTP 404 cleanly.
|
||||||
|
* `list_pending_invites()` — return active invites for the admin
|
||||||
|
listing surface. Filters out claimed + expired rows so the surface
|
||||||
|
only shows live invites.
|
||||||
|
|
||||||
|
Token shape: opaque DB token (256 bits of CSPRNG entropy via
|
||||||
|
`secrets.token_urlsafe(32)`), bcrypt-hashed at rest. Opaque chosen
|
||||||
|
over JWT because revocation is then a single SQL UPDATE — a JWT
|
||||||
|
would be stateless but harder to invalidate, and admin-issued
|
||||||
|
invites are exactly the kind of thing an admin should be able to
|
||||||
|
yank back. The raw token only ever lives in the outbound email link
|
||||||
|
and the inbound claim body; server-side storage is the hash.
|
||||||
|
|
||||||
|
TTL: hard-coded to 7 days via `INVITE_TOKEN_TTL_DAYS`. Env-var
|
||||||
|
configurability is a §19.2 candidate — the constant is exposed
|
||||||
|
here as a single point of edit if a deployment wants to override.
|
||||||
|
|
||||||
|
The 500-char ceiling on `custom_message` is enforced at the
|
||||||
|
Pydantic body level in `api_admin.py`; this module trusts what
|
||||||
|
the endpoint hands it.
|
||||||
|
"""
|
||||||
|
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 — intentionally hard-coded in v0.17.0 (§19.2 candidate to env-ify).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
INVITE_TOKEN_TTL_DAYS = 7
|
||||||
|
# 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 outbound email link.
|
||||||
|
TOKEN_BYTES = 32
|
||||||
|
# Free-text ceiling for the admin's optional custom message. Matched
|
||||||
|
# at the Pydantic body bound in `api_admin.py`; mentioned here so the
|
||||||
|
# bound is documented in one place.
|
||||||
|
CUSTOM_MESSAGE_MAX_LENGTH = 500
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Token + hash helpers (mirror device_trust.py shape)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Create
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CreateOutcome:
|
||||||
|
"""The shape returned from `create_invite`.
|
||||||
|
|
||||||
|
`raw_token` is what the admin endpoint puts in the outbound email
|
||||||
|
link; it never appears in storage. `invite_id` is the surrogate
|
||||||
|
key for the admin's "invites I've sent" listing. `invited_user_id`
|
||||||
|
is the freshly-provisioned `users` row id so the admin surface can
|
||||||
|
join through to the user-management page.
|
||||||
|
"""
|
||||||
|
raw_token: str
|
||||||
|
invite_id: int
|
||||||
|
invited_user_id: int
|
||||||
|
|
||||||
|
|
||||||
|
def create_invite(
|
||||||
|
*,
|
||||||
|
email: str,
|
||||||
|
first_name: str,
|
||||||
|
last_name: str,
|
||||||
|
role: str,
|
||||||
|
custom_message: str,
|
||||||
|
created_by_admin_id: int,
|
||||||
|
) -> CreateOutcome:
|
||||||
|
"""Provision the invitee `users` row + the `user_invite_tokens` row.
|
||||||
|
|
||||||
|
Caller (`api_admin.py`) is responsible for the admin-only auth check,
|
||||||
|
the self-email refusal (422), and the duplicate-email refusal (409).
|
||||||
|
This function trusts what it's handed and writes both rows
|
||||||
|
transactionally — the v0.10.0 `passcode.py` / v0.11.0 `device_trust.py`
|
||||||
|
helpers follow the same separation-of-concerns pattern.
|
||||||
|
|
||||||
|
The invitee `users` row is provisioned with:
|
||||||
|
* `permission_state='granted'` — the admin's hand is the grant;
|
||||||
|
the v0.8.0 self-serve `pending` queue is for the other path.
|
||||||
|
* `last_seen_at = NULL` — the discriminator for "invited but
|
||||||
|
not yet arrived" per the §16 / roadmap design. Every sign-in
|
||||||
|
path stamps `last_seen_at` to now, so a NULL value means the
|
||||||
|
invited user has not clicked through yet.
|
||||||
|
* `gitea_id = NULL`, `gitea_login = NULL` — same as a v0.7.0
|
||||||
|
OTC-provisioned user; the OAuth identity is grandfathered if
|
||||||
|
the user ever lands through that path.
|
||||||
|
* `display_name` defaults to "<first> <last>" (or local-part of
|
||||||
|
email if both are empty) so the user-management page reads a
|
||||||
|
sensible label before the user has signed in.
|
||||||
|
* `first_name` / `last_name` / `beta_request_reason` — the
|
||||||
|
first two from the admin's typed values; reason stays blank
|
||||||
|
(this user did not self-request access).
|
||||||
|
"""
|
||||||
|
email_clean = email.strip()
|
||||||
|
first_clean = (first_name or "").strip()
|
||||||
|
last_clean = (last_name or "").strip()
|
||||||
|
display = " ".join(p for p in (first_clean, last_clean) if p).strip()
|
||||||
|
if not display:
|
||||||
|
display = email_clean.split("@", 1)[0] or email_clean
|
||||||
|
|
||||||
|
# 1. Provision the invitee users row. The grant is the admin's
|
||||||
|
# hand; no permission_events row is necessary for the grant itself
|
||||||
|
# (we are not transitioning from pending → granted, we are landing
|
||||||
|
# a fresh row directly into granted).
|
||||||
|
#
|
||||||
|
# Note on the "pending invite" discriminator: the brief floated
|
||||||
|
# `first_sign_in_at NULL` / `last_seen_at NULL` as the marker the
|
||||||
|
# admin user-management page reads off the row to render the
|
||||||
|
# "(pending invite)" badge. The schema didn't cooperate — the
|
||||||
|
# existing `users.last_seen_at` column is NOT NULL with a
|
||||||
|
# `datetime('now')` default (see `migrations/001_users_and_audit.sql`),
|
||||||
|
# and there is no `first_sign_in_at` column. Rather than introduce
|
||||||
|
# a schema migration to add one (the brief explicitly said "likely
|
||||||
|
# no `users` table changes"), the discriminator is the existence of
|
||||||
|
# an active row in `user_invite_tokens` joined on `invited_user_id`.
|
||||||
|
# The admin listing's `pending_invite` field joins through that
|
||||||
|
# table; the claim flow stamps `claimed_at` on the invite row,
|
||||||
|
# which clears the badge naturally. This shape keeps the
|
||||||
|
# discriminator scoped to the v0.17.0 surface and avoids
|
||||||
|
# double-tracking against an existing column.
|
||||||
|
cur = db.conn().execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO users (
|
||||||
|
gitea_id, gitea_login, email, display_name, avatar_url,
|
||||||
|
role, permission_state, first_name, last_name
|
||||||
|
)
|
||||||
|
VALUES (NULL, NULL, ?, ?, '', ?, 'granted', ?, ?)
|
||||||
|
""",
|
||||||
|
(email_clean, display, role, first_clean, last_clean),
|
||||||
|
)
|
||||||
|
invited_user_id = cur.lastrowid
|
||||||
|
|
||||||
|
# 2. Mint the token, hash it, write the invite row.
|
||||||
|
raw = _new_token()
|
||||||
|
h = _hash(raw)
|
||||||
|
cur = db.conn().execute(
|
||||||
|
f"""
|
||||||
|
INSERT INTO user_invite_tokens (
|
||||||
|
email, role, first_name, last_name, custom_message,
|
||||||
|
token_hash, expires_at, created_by_admin_id, invited_user_id
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, datetime('now', '+{INVITE_TOKEN_TTL_DAYS} days'), ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
email_clean,
|
||||||
|
role,
|
||||||
|
first_clean,
|
||||||
|
last_clean,
|
||||||
|
(custom_message or "").strip(),
|
||||||
|
h,
|
||||||
|
created_by_admin_id,
|
||||||
|
invited_user_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
invite_id = cur.lastrowid
|
||||||
|
return CreateOutcome(
|
||||||
|
raw_token=raw,
|
||||||
|
invite_id=invite_id,
|
||||||
|
invited_user_id=invited_user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Claim
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ClaimOutcome:
|
||||||
|
"""The result of `claim`.
|
||||||
|
|
||||||
|
`user` is populated only on success. `reason` distinguishes the
|
||||||
|
failure modes so the endpoint can return distinct HTTP statuses
|
||||||
|
(HTTP 410 for expired/claimed — the token is dead; HTTP 400 for
|
||||||
|
unknown/invalid — the request shape is wrong).
|
||||||
|
"""
|
||||||
|
ok: bool
|
||||||
|
user: SessionUser | None
|
||||||
|
reason: str # 'ok' | 'invalid' | 'unknown' | 'expired' | 'claimed'
|
||||||
|
invite_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def claim(raw_token: str) -> ClaimOutcome:
|
||||||
|
"""Validate the presented token and consume it.
|
||||||
|
|
||||||
|
Walks the active invite rows looking for a bcrypt hash match.
|
||||||
|
Mirrors `device_trust.lookup`: bcrypt's per-row salt means we
|
||||||
|
cannot SELECT by hash, but the set is small (a deployment's
|
||||||
|
outstanding invites at any moment) and bcrypt is cheap on the
|
||||||
|
order of milliseconds.
|
||||||
|
|
||||||
|
On a hit:
|
||||||
|
* Mark the row claimed (stamp `claimed_at = now`,
|
||||||
|
`claimed_by_user_id = invited_user_id` — the admin's
|
||||||
|
pre-provisioned row is the claimant).
|
||||||
|
* Stamp `last_seen_at = now` on the user row so the v0.9.0
|
||||||
|
admin user-management page no longer shows "(pending invite)".
|
||||||
|
* Return a populated `SessionUser` for the endpoint to sign in.
|
||||||
|
|
||||||
|
On a miss:
|
||||||
|
* `unknown` — no row matched. The token may have been forged or
|
||||||
|
the invite was admin-revoked.
|
||||||
|
* `expired` — row matched but `expires_at` is in the past.
|
||||||
|
* `claimed` — row matched but `claimed_at` is non-NULL. The
|
||||||
|
token was already consumed; the user must contact the admin
|
||||||
|
for a fresh invite.
|
||||||
|
* `invalid` — the token string itself was empty or unparseable.
|
||||||
|
"""
|
||||||
|
raw = (raw_token or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return ClaimOutcome(ok=False, user=None, reason="invalid")
|
||||||
|
|
||||||
|
rows = db.conn().execute(
|
||||||
|
"""
|
||||||
|
SELECT id, token_hash, expires_at, claimed_at, invited_user_id, role
|
||||||
|
FROM user_invite_tokens
|
||||||
|
ORDER BY id DESC
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
matched = None
|
||||||
|
for row in rows:
|
||||||
|
if _check(raw, row["token_hash"]):
|
||||||
|
matched = row
|
||||||
|
break
|
||||||
|
|
||||||
|
if matched is None:
|
||||||
|
return ClaimOutcome(ok=False, user=None, reason="unknown")
|
||||||
|
|
||||||
|
if matched["claimed_at"] is not None:
|
||||||
|
return ClaimOutcome(
|
||||||
|
ok=False, user=None, reason="claimed", invite_id=matched["id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
expired = db.conn().execute(
|
||||||
|
"SELECT datetime(?) < datetime('now') AS expired",
|
||||||
|
(matched["expires_at"],),
|
||||||
|
).fetchone()["expired"]
|
||||||
|
if expired:
|
||||||
|
return ClaimOutcome(
|
||||||
|
ok=False, user=None, reason="expired", invite_id=matched["id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Consume the row before signing in so a parallel claim of the same
|
||||||
|
# token cannot double-sign-in. (Mirrors `otc.verify_code`'s consume-
|
||||||
|
# before-provision shape.)
|
||||||
|
db.conn().execute(
|
||||||
|
"""
|
||||||
|
UPDATE user_invite_tokens
|
||||||
|
SET claimed_at = datetime('now'),
|
||||||
|
claimed_by_user_id = invited_user_id
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(matched["id"],),
|
||||||
|
)
|
||||||
|
# Stamp last_seen_at on the user row so the user's activity stamp
|
||||||
|
# is current after the claim (mirroring otc.verify_code's
|
||||||
|
# last-seen update on the provision path). The "(pending invite)"
|
||||||
|
# badge's clear is driven by the invite row's `claimed_at`
|
||||||
|
# transition above; this update is for the general user-listing's
|
||||||
|
# recency ordering.
|
||||||
|
db.conn().execute(
|
||||||
|
"UPDATE users SET last_seen_at = datetime('now') WHERE id = ?",
|
||||||
|
(matched["invited_user_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["invited_user_id"],),
|
||||||
|
).fetchone()
|
||||||
|
if user_row is None:
|
||||||
|
# The invitee user row was deleted between create_invite and
|
||||||
|
# claim (shouldn't happen under the FK ON DELETE CASCADE — the
|
||||||
|
# cascade would drop the invite row too — be defensive anyway).
|
||||||
|
return ClaimOutcome(
|
||||||
|
ok=False, user=None, reason="unknown", invite_id=matched["id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
return ClaimOutcome(
|
||||||
|
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",
|
||||||
|
invite_id=matched["id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# List pending invites — for the admin's review surface
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PendingInviteRow:
|
||||||
|
"""The shape the `GET /api/admin/users/invites` endpoint returns.
|
||||||
|
|
||||||
|
Note the absence of `token_hash` — the hash is structurally private,
|
||||||
|
and the surface has no use for it. The raw token is also not on
|
||||||
|
the listing; it lives only in the email link.
|
||||||
|
"""
|
||||||
|
id: int
|
||||||
|
email: str
|
||||||
|
role: str
|
||||||
|
first_name: str
|
||||||
|
last_name: str
|
||||||
|
custom_message: str
|
||||||
|
created_at: str
|
||||||
|
expires_at: str
|
||||||
|
created_by_admin_id: int
|
||||||
|
invited_user_id: int
|
||||||
|
|
||||||
|
|
||||||
|
def list_pending_invites() -> list[PendingInviteRow]:
|
||||||
|
"""Active invites (not claimed, not expired), freshest first.
|
||||||
|
|
||||||
|
The admin's "I sent these but they haven't been claimed yet" view.
|
||||||
|
Filters mirror the `device_trust.list_for_user` shape: the surface
|
||||||
|
only shows live records the framework would actually accept on a
|
||||||
|
presented token.
|
||||||
|
"""
|
||||||
|
rows = db.conn().execute(
|
||||||
|
"""
|
||||||
|
SELECT id, email, role, first_name, last_name, custom_message,
|
||||||
|
created_at, expires_at, created_by_admin_id, invited_user_id
|
||||||
|
FROM user_invite_tokens
|
||||||
|
WHERE claimed_at IS NULL
|
||||||
|
AND datetime(expires_at) > datetime('now')
|
||||||
|
ORDER BY created_at DESC, id DESC
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
return [
|
||||||
|
PendingInviteRow(
|
||||||
|
id=row["id"],
|
||||||
|
email=row["email"],
|
||||||
|
role=row["role"],
|
||||||
|
first_name=row["first_name"] or "",
|
||||||
|
last_name=row["last_name"] or "",
|
||||||
|
custom_message=row["custom_message"] or "",
|
||||||
|
created_at=row["created_at"],
|
||||||
|
expires_at=row["expires_at"],
|
||||||
|
created_by_admin_id=row["created_by_admin_id"],
|
||||||
|
invited_user_id=row["invited_user_id"],
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
+430
-3
@@ -10,11 +10,27 @@ import logging
|
|||||||
import secrets
|
import secrets
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import APIRouter, FastAPI, HTTPException, Request
|
from fastapi import APIRouter, FastAPI, HTTPException, Request, Response
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import JSONResponse, RedirectResponse
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
from starlette.middleware.sessions import SessionMiddleware
|
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,
|
||||||
|
invites as invites_mod,
|
||||||
|
otc,
|
||||||
|
passcode as passcode_mod,
|
||||||
|
providers as providers_mod,
|
||||||
|
turnstile,
|
||||||
|
webhooks,
|
||||||
|
)
|
||||||
from .bot import Bot
|
from .bot import Bot
|
||||||
from .config import load_config
|
from .config import load_config
|
||||||
from .gitea import Gitea
|
from .gitea import Gitea
|
||||||
@@ -23,6 +39,59 @@ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name
|
|||||||
log = logging.getLogger("rfc_app")
|
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
|
||||||
|
|
||||||
|
|
||||||
|
class InviteClaimBody(BaseModel):
|
||||||
|
"""v0.17.0 / roadmap item #16 — claim an admin-issued invite token.
|
||||||
|
|
||||||
|
The frontend `/invites/claim?token=…` page reads the token from
|
||||||
|
the URL and POSTs it here. The body bound matches the
|
||||||
|
`secrets.token_urlsafe(32)` output shape (~43 URL-safe chars);
|
||||||
|
the upper bound stays generous in case `TOKEN_BYTES` is ever
|
||||||
|
raised. The token-shape is opaque to this layer — `invites.claim`
|
||||||
|
bcrypt-checks it against the active candidate set.
|
||||||
|
"""
|
||||||
|
token: str = Field(min_length=1, max_length=512)
|
||||||
|
# v0.11.0-style opt-in: the claim flow's "trust this device" gesture
|
||||||
|
# is bundled here so the invitee can land trusted on first sign-in
|
||||||
|
# without an extra roundtrip. Defaults to false so the gesture is
|
||||||
|
# explicit (the frontend modal renders a checkbox alongside the
|
||||||
|
# claim CTA).
|
||||||
|
trust_device: bool = False
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
config = load_config()
|
config = load_config()
|
||||||
@@ -86,6 +155,48 @@ def create_app() -> FastAPI:
|
|||||||
app = create_app()
|
app = create_app()
|
||||||
|
|
||||||
|
|
||||||
|
def _set_device_trust_cookie(response: Response, raw_token: str) -> None:
|
||||||
|
"""Attach the v0.11.0 device-trust cookie to the response.
|
||||||
|
|
||||||
|
HttpOnly + Secure + SameSite=Lax + 30-day Max-Age + Path=/. The
|
||||||
|
cookie value is the raw token; server-side storage is the hash.
|
||||||
|
The cookie is "essential" per the v0.13.0 cookie-consent contract
|
||||||
|
(it is part of authentication), so we set it regardless of the
|
||||||
|
user's analytics / other-cookies choice.
|
||||||
|
|
||||||
|
Secure=True means the cookie is only ever sent over HTTPS. The
|
||||||
|
SessionMiddleware in `create_app` keeps `https_only=False` for
|
||||||
|
dev parity, but the device-trust cookie holds a 30-day credential
|
||||||
|
and must not travel cleartext — production deployments serve over
|
||||||
|
HTTPS, so Secure on the device-trust cookie is non-negotiable.
|
||||||
|
"""
|
||||||
|
response.set_cookie(
|
||||||
|
key=device_trust_mod.COOKIE_NAME,
|
||||||
|
value=raw_token,
|
||||||
|
max_age=device_trust_mod.COOKIE_MAX_AGE_SECONDS,
|
||||||
|
path="/",
|
||||||
|
secure=True,
|
||||||
|
httponly=True,
|
||||||
|
samesite="lax",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_device_trust_cookie(response: Response) -> None:
|
||||||
|
"""Delete the device-trust cookie on the response.
|
||||||
|
|
||||||
|
Used when the framework detects a presented cookie that is
|
||||||
|
expired, revoked, or otherwise stale — the next request from
|
||||||
|
this device will not carry a dead token.
|
||||||
|
"""
|
||||||
|
response.delete_cookie(
|
||||||
|
key=device_trust_mod.COOKIE_NAME,
|
||||||
|
path="/",
|
||||||
|
secure=True,
|
||||||
|
httponly=True,
|
||||||
|
samesite="lax",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _oauth_router(config) -> APIRouter:
|
def _oauth_router(config) -> APIRouter:
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -122,4 +233,320 @@ def _oauth_router(config) -> APIRouter:
|
|||||||
request.session.clear()
|
request.session.clear()
|
||||||
return RedirectResponse("/")
|
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.17.0: admin-create user + invite claim (§6.1, roadmap item #16).
|
||||||
|
#
|
||||||
|
# The admin-create surface lives at POST /api/admin/users (see
|
||||||
|
# api_admin.py); this endpoint is the corresponding claim path the
|
||||||
|
# invitee hits when they click the link in their invite email.
|
||||||
|
# The frontend route `/invites/claim?token=…` reads the token from
|
||||||
|
# the URL and POSTs it here.
|
||||||
|
#
|
||||||
|
# The claim itself is the first-sign-in for the invitee: clicking
|
||||||
|
# the unique token in the email is proof of email control per the
|
||||||
|
# roadmap, so this endpoint skips the OTC step entirely on first
|
||||||
|
# sign-in. The session cookie lands; the response tells the
|
||||||
|
# frontend whether to route to passcode-set (if v0.10.0 passcode
|
||||||
|
# flow is in play and the user has not yet set a passcode) or to
|
||||||
|
# home.
|
||||||
|
#
|
||||||
|
# The endpoint is anonymous-reachable: the entire point is to
|
||||||
|
# establish the session, so we do not gate it on `require_user`.
|
||||||
|
# The trust-device opt-in mirrors the v0.11.0 OTC/passcode verify
|
||||||
|
# contract (the body's `trust_device` flag, when true, mints a
|
||||||
|
# fresh device-trust row on the same response so the invitee
|
||||||
|
# lands trusted on their first device).
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.post("/api/invites/claim")
|
||||||
|
async def invites_claim(body: InviteClaimBody, request: Request, response: Response):
|
||||||
|
result = invites_mod.claim(body.token)
|
||||||
|
if result.reason == "expired":
|
||||||
|
# The token's TTL window passed without a claim. HTTP 410
|
||||||
|
# (Gone) so the frontend can render a "this invite has
|
||||||
|
# expired — please contact the admin for a fresh one"
|
||||||
|
# message distinct from the generic invalid-token shape.
|
||||||
|
raise HTTPException(410, "This invite has expired")
|
||||||
|
if result.reason == "claimed":
|
||||||
|
# The token was already consumed. HTTP 410 for the same
|
||||||
|
# reason — the row is dead either way.
|
||||||
|
raise HTTPException(410, "This invite has already been claimed")
|
||||||
|
if not result.ok or result.user is None:
|
||||||
|
# 'unknown' / 'invalid' — the token does not match any
|
||||||
|
# active invite row. HTTP 400 so it reads distinct from
|
||||||
|
# the dead-token shape above.
|
||||||
|
raise HTTPException(400, "Invalid invite token")
|
||||||
|
|
||||||
|
# Establish the session. From here on the invitee is signed
|
||||||
|
# in as the pre-provisioned user row carrying their
|
||||||
|
# pre-assigned role.
|
||||||
|
auth.store_session(request, result.user)
|
||||||
|
|
||||||
|
# v0.11.0 — opt-in device trust on the claim response. Same
|
||||||
|
# contract as OTC/passcode verify: when the body's flag is
|
||||||
|
# true, the server mints a fresh device-trust row and sets
|
||||||
|
# the long-lived cookie, so the invitee skips the email step
|
||||||
|
# on subsequent visits to the same browser.
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Has the user already set a passcode? (Could only happen via
|
||||||
|
# an admin pre-population path that doesn't exist yet, but
|
||||||
|
# the response shape mirrors `/api/auth/me` so the frontend
|
||||||
|
# can read it without a second call.) If `needs_passcode` is
|
||||||
|
# true and v0.10.0 passcode flow is in play, the frontend
|
||||||
|
# routes to /settings/notifications#sign-in to set a passcode
|
||||||
|
# immediately; otherwise it routes to /.
|
||||||
|
row = db.conn().execute(
|
||||||
|
"SELECT passcode_hash FROM users WHERE id = ?",
|
||||||
|
(result.user.user_id,),
|
||||||
|
).fetchone()
|
||||||
|
has_passcode = bool(row and row["passcode_hash"])
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
# Roadmap §16: the claim flow skips OTC entirely; the
|
||||||
|
# natural next step is passcode-set (so the invitee can
|
||||||
|
# sign back in without needing an email roundtrip on their
|
||||||
|
# second visit). The frontend uses this hint to decide
|
||||||
|
# whether to route to the passcode-set screen or to home.
|
||||||
|
"needs_passcode": not has_passcode,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# v0.11.0: trust device for 30 days (§6.2, roadmap item #9).
|
||||||
|
#
|
||||||
|
# The /auth/device-trust/start endpoint resolves a presented
|
||||||
|
# `rfc_device_trust` cookie. If it matches a non-expired,
|
||||||
|
# non-revoked row, the session is re-established and the client
|
||||||
|
# is told to skip OTC/passcode entry. A stale cookie (expired or
|
||||||
|
# revoked) is cleared on the response. A miss is structurally
|
||||||
|
# silent — the client falls back to the email step.
|
||||||
|
#
|
||||||
|
# The endpoint is anonymous-reachable: a returning visitor with
|
||||||
|
# the cookie hits this before the email step. We do not gate it
|
||||||
|
# on a session because the entire point is to establish one.
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.post("/auth/device-trust/start")
|
||||||
|
async def device_trust_start(request: Request):
|
||||||
|
"""Sign in via a presented device-trust cookie.
|
||||||
|
|
||||||
|
On a hit, re-establishes the session in the cookie store and
|
||||||
|
returns a user payload shaped like /auth/otc/verify (minus
|
||||||
|
`needs_profile`, which a returning device-trust user is
|
||||||
|
structurally past — they signed in at least once before).
|
||||||
|
On a miss, returns 401 + clears the stale cookie. An
|
||||||
|
'unknown' miss (cookie present but no row matches) also
|
||||||
|
clears, since the token is dead to the server either way.
|
||||||
|
|
||||||
|
Note on response construction: we return a `JSONResponse`
|
||||||
|
directly rather than raising `HTTPException` on the miss
|
||||||
|
path because FastAPI's exception handler builds a new
|
||||||
|
response from scratch and would drop any `set_cookie` /
|
||||||
|
`delete_cookie` calls. The hand-built `JSONResponse` lets
|
||||||
|
us attach the cookie-clear header alongside the 401.
|
||||||
|
"""
|
||||||
|
raw = request.cookies.get(device_trust_mod.COOKIE_NAME, "")
|
||||||
|
if not raw:
|
||||||
|
return JSONResponse({"detail": "No device trust"}, status_code=401)
|
||||||
|
outcome = device_trust_mod.lookup(raw)
|
||||||
|
if not outcome.ok or outcome.user is None:
|
||||||
|
# Clear the stale cookie so subsequent requests don't
|
||||||
|
# keep replaying a dead token. We surface 401 in all
|
||||||
|
# cases so a probing client can't tell "your row was
|
||||||
|
# revoked" from "this token never existed".
|
||||||
|
response = JSONResponse({"detail": "Device trust invalid"}, status_code=401)
|
||||||
|
_clear_device_trust_cookie(response)
|
||||||
|
return response
|
||||||
|
auth.store_session(request, outcome.user)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"user": {
|
||||||
|
"id": outcome.user.user_id,
|
||||||
|
"display_name": outcome.user.display_name,
|
||||||
|
"email": outcome.user.email,
|
||||||
|
"role": outcome.user.role,
|
||||||
|
"permission_state": outcome.user.permission_state,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
return router
|
return router
|
||||||
|
|||||||
+81
-1
@@ -64,6 +64,7 @@ log = logging.getLogger(__name__)
|
|||||||
CATEGORY_PERSONAL = "personal-direct"
|
CATEGORY_PERSONAL = "personal-direct"
|
||||||
CATEGORY_STRUCTURAL = "structural"
|
CATEGORY_STRUCTURAL = "structural"
|
||||||
CATEGORY_CHURN = "churn"
|
CATEGORY_CHURN = "churn"
|
||||||
|
CATEGORY_ADMIN_ACTIONABLE = "admin-actionable"
|
||||||
|
|
||||||
# Action kinds whose actor's first interaction with a slug triggers
|
# Action kinds whose actor's first interaction with a slug triggers
|
||||||
# auto-watch per §15.6. The substantive-gesture list in the spec is
|
# auto-watch per §15.6. The substantive-gesture list in the spec is
|
||||||
@@ -208,11 +209,72 @@ def fan_out_from_action(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def fan_out_new_beta_request(
|
||||||
|
*,
|
||||||
|
requester_user_id: int,
|
||||||
|
) -> None:
|
||||||
|
"""v0.9.0 (roadmap item #7): announce a fresh beta-access request to
|
||||||
|
every admin/owner.
|
||||||
|
|
||||||
|
Called from `POST /api/auth/me/beta-request` after the row's
|
||||||
|
first/last/why fields are populated. Fan-out shape mirrors the §15
|
||||||
|
chokepoint contract: one row per recipient, written via `_emit_one`
|
||||||
|
so the SSE broadcast + email dispatch run through the same surface
|
||||||
|
every other notification uses. The event has no rfc_slug (it is
|
||||||
|
framework-scoped, not RFC-scoped); the deep-link payload points
|
||||||
|
`/admin/users` instead of `/rfc/<slug>`.
|
||||||
|
|
||||||
|
Actor is the requester per §15.9 (the underlying user, never the
|
||||||
|
bot). Category is `admin-actionable` so the §15.4 email gate
|
||||||
|
consults `email_admin_actionable` (owners/admins-only by
|
||||||
|
construction) and the digest exclusion rules treat it identically
|
||||||
|
to other admin-actionable signals (graduation_ready et al).
|
||||||
|
|
||||||
|
Recipients are owners + admins minus the requester themselves
|
||||||
|
(a self-promotion shouldn't reach the requester's own inbox). The
|
||||||
|
requester is never in the role set in practice — the endpoint
|
||||||
|
refuses 'granted'/'revoked' callers and a fresh OTC user lands
|
||||||
|
`contributor`+`pending` — but we filter regardless so the call
|
||||||
|
is robust to future changes in the auth gate.
|
||||||
|
"""
|
||||||
|
requester = db.conn().execute(
|
||||||
|
"SELECT first_name, last_name, email, display_name FROM users WHERE id = ?",
|
||||||
|
(requester_user_id,),
|
||||||
|
).fetchone()
|
||||||
|
if requester is None:
|
||||||
|
return
|
||||||
|
first = (requester["first_name"] or "").strip()
|
||||||
|
last = (requester["last_name"] or "").strip()
|
||||||
|
email = requester["email"] or ""
|
||||||
|
display = requester["display_name"] or email or "a new user"
|
||||||
|
full_name = (f"{first} {last}").strip() or display
|
||||||
|
details = {
|
||||||
|
"requester_user_id": requester_user_id,
|
||||||
|
"requester_first_name": first,
|
||||||
|
"requester_last_name": last,
|
||||||
|
"requester_email": email,
|
||||||
|
"requester_display": full_name,
|
||||||
|
}
|
||||||
|
for recipient_id in _admin_user_ids():
|
||||||
|
if recipient_id == requester_user_id:
|
||||||
|
continue
|
||||||
|
_emit_one(
|
||||||
|
recipient_user_id=recipient_id,
|
||||||
|
event_kind="new_beta_request",
|
||||||
|
category=CATEGORY_ADMIN_ACTIONABLE,
|
||||||
|
actor_user_id=requester_user_id,
|
||||||
|
rfc_slug=None,
|
||||||
|
branch_name=None,
|
||||||
|
pr_number=None,
|
||||||
|
details=details,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def fan_out_chat_message(
|
def fan_out_chat_message(
|
||||||
*,
|
*,
|
||||||
actor_user_id: int,
|
actor_user_id: int,
|
||||||
rfc_slug: str,
|
rfc_slug: str,
|
||||||
branch_name: str,
|
branch_name: str | None,
|
||||||
thread_id: int,
|
thread_id: int,
|
||||||
message_id: int,
|
message_id: int,
|
||||||
is_review_thread: bool = False,
|
is_review_thread: bool = False,
|
||||||
@@ -227,6 +289,14 @@ def fan_out_chat_message(
|
|||||||
(state='watching', i.e. full stream) get a churn-class
|
(state='watching', i.e. full stream) get a churn-class
|
||||||
`chat_message_in_participated_thread`. The two are union'd so a user
|
`chat_message_in_participated_thread`. The two are union'd so a user
|
||||||
who is both gets only the personal-direct row.
|
who is both gets only the personal-direct row.
|
||||||
|
|
||||||
|
v0.5.0: `branch_name` may be None — that is the PR-less per-RFC
|
||||||
|
discussion shape (`threads.branch_name IS NULL`, §5). The
|
||||||
|
notifications row carries the null through; the inbox prose renders
|
||||||
|
identically whether the chat lives on a branch or on the RFC's
|
||||||
|
discussion surface, and the §15.7 reconciler keys on
|
||||||
|
(rfc_slug, branch_name) so a null branch correctly matches the
|
||||||
|
PR-less discussion's eventual chat-seen-equivalent advance.
|
||||||
"""
|
"""
|
||||||
_bump_auto_watch(actor_user_id, rfc_slug)
|
_bump_auto_watch(actor_user_id, rfc_slug)
|
||||||
|
|
||||||
@@ -699,6 +769,16 @@ def render_summary(event_kind: str, actor_display: str | None, rfc_title: str |
|
|||||||
return f"{actor} began graduating {title}."
|
return f"{actor} began graduating {title}."
|
||||||
if event_kind == "pr_conflict_with_main":
|
if event_kind == "pr_conflict_with_main":
|
||||||
return f"{actor} started a resolution branch on {title}."
|
return f"{actor} started a resolution branch on {title}."
|
||||||
|
if event_kind == "new_beta_request":
|
||||||
|
# v0.9.0: framework-scoped, not RFC-scoped. The actor (the
|
||||||
|
# requester) and the captured full name + email read as
|
||||||
|
# one self-contained sentence; the inbox row and the email
|
||||||
|
# body share this text per §15.4.
|
||||||
|
full_name = extras.get("requester_display") or actor
|
||||||
|
email_addr = extras.get("requester_email") or ""
|
||||||
|
if email_addr:
|
||||||
|
return f"New beta-access request from {full_name} ({email_addr})."
|
||||||
|
return f"New beta-access request from {full_name}."
|
||||||
return f"{event_kind} on {title}"
|
return f"{event_kind} on {title}"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,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",
|
||||||
|
)
|
||||||
@@ -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")
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
"""§6.2 / v0.12.0 / roadmap item #10: CloudFlare Turnstile siteverify.
|
||||||
|
|
||||||
|
The OTC request endpoint (`/auth/otc/request`) is the abuse hot path
|
||||||
|
of the auth surface since v0.7.0 — the per-email cooldown stops the
|
||||||
|
trivial back-to-back loop, but it does not stop a distributed scraper
|
||||||
|
that fans out across a large invitee list to harvest the "this email
|
||||||
|
is admitted vs. this email is not" signal indirectly (timing
|
||||||
|
differences, SMTP bounce-rate observation). v0.12.0 gates the request
|
||||||
|
endpoint behind a one-step browser-side Turnstile challenge before the
|
||||||
|
bcrypt hash + SMTP send.
|
||||||
|
|
||||||
|
Stateless: no DB writes, no schema change. The siteverify call to
|
||||||
|
CloudFlare lives entirely in this module; the endpoint handler in
|
||||||
|
`main.py` thin-wraps `verify_token`.
|
||||||
|
|
||||||
|
Tunables (read at call time so tests can monkeypatch):
|
||||||
|
|
||||||
|
* `CLOUDFLARE_TURNSTILE_SECRET` — the operator-provisioned secret
|
||||||
|
key from the Turnstile dashboard. Lives in GCP Secret Manager in
|
||||||
|
production; absent in tests (which monkeypatch the siteverify
|
||||||
|
transport). When unset, the behavior depends on `TURNSTILE_REQUIRED`:
|
||||||
|
- `TURNSTILE_REQUIRED=true` → fail closed (`misconfigured`).
|
||||||
|
- `TURNSTILE_REQUIRED=false` (default) → skip verification entirely
|
||||||
|
and admit the request. This is the dev/test path and the
|
||||||
|
"operator hasn't wired the secret yet" path; production
|
||||||
|
deployments **should** set `TURNSTILE_REQUIRED=true` once the
|
||||||
|
secret is in place so a regression in the secret wiring fails
|
||||||
|
loudly instead of silently disabling abuse defense.
|
||||||
|
|
||||||
|
* `TURNSTILE_REQUIRED` — `true` / `false` (default `false`).
|
||||||
|
When `false` and the secret is absent, the gate is open. When
|
||||||
|
`true` and the secret is absent, the endpoint refuses with a
|
||||||
|
misconfigured-auth shape rather than silently letting requests
|
||||||
|
through.
|
||||||
|
|
||||||
|
* `TURNSTILE_SITEVERIFY_URL` — points at the real CloudFlare
|
||||||
|
endpoint by default. Override in tests to redirect at a mock
|
||||||
|
URL when `httpx.MockTransport` isn't ergonomic for the case.
|
||||||
|
|
||||||
|
The siteverify contract is documented at
|
||||||
|
https://developers.cloudflare.com/turnstile/get-started/server-side-validation/.
|
||||||
|
We POST `secret` + `response` (and optionally `remoteip`) as form
|
||||||
|
fields and read back `{"success": true|false, ...}`. Any network /
|
||||||
|
parse failure on the siteverify call is treated as a verification
|
||||||
|
failure (`network`) — the abuse path is to skip the challenge, so
|
||||||
|
"can't reach CloudFlare" defaults to "refuse the request" when
|
||||||
|
`TURNSTILE_REQUIRED=true`, and "admit" when `TURNSTILE_REQUIRED=false`.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
SITEVERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
|
||||||
|
|
||||||
|
|
||||||
|
def _secret() -> str:
|
||||||
|
return os.environ.get("CLOUDFLARE_TURNSTILE_SECRET", "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _required() -> bool:
|
||||||
|
raw = os.environ.get("TURNSTILE_REQUIRED", "").strip().lower()
|
||||||
|
return raw in ("1", "true", "yes", "on")
|
||||||
|
|
||||||
|
|
||||||
|
def _siteverify_url() -> str:
|
||||||
|
return os.environ.get("TURNSTILE_SITEVERIFY_URL", "").strip() or SITEVERIFY_URL
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class VerifyOutcome:
|
||||||
|
"""Result of a Turnstile siteverify call.
|
||||||
|
|
||||||
|
`ok`: the request **may proceed**. True both for "siteverify said
|
||||||
|
success" and for "no secret configured AND not required" (the
|
||||||
|
dev/test soft-fail path).
|
||||||
|
|
||||||
|
`reason`: one of
|
||||||
|
* 'ok' — siteverify returned success.
|
||||||
|
* 'skipped' — no secret configured, TURNSTILE_REQUIRED=false.
|
||||||
|
The gate is open; the endpoint admits the request.
|
||||||
|
* 'misconfigured' — TURNSTILE_REQUIRED=true but no secret in env.
|
||||||
|
The endpoint fails closed with 500.
|
||||||
|
* 'missing-token' — the client did not send a token at all and
|
||||||
|
verification is required.
|
||||||
|
* 'failed' — siteverify returned success=false. The
|
||||||
|
endpoint refuses with 400.
|
||||||
|
* 'network' — siteverify call raised. Treated as a failure
|
||||||
|
under TURNSTILE_REQUIRED=true.
|
||||||
|
"""
|
||||||
|
ok: bool
|
||||||
|
reason: str
|
||||||
|
|
||||||
|
|
||||||
|
def verify_token(token: str | None, *, client_ip: str | None = None) -> VerifyOutcome:
|
||||||
|
"""Validate a Turnstile token against CloudFlare's siteverify endpoint.
|
||||||
|
|
||||||
|
Returns a VerifyOutcome describing whether the calling endpoint
|
||||||
|
should proceed. The endpoint maps `ok=False` to an HTTP status per
|
||||||
|
the `reason`:
|
||||||
|
|
||||||
|
* 'misconfigured' → 500 "auth misconfigured"
|
||||||
|
* 'missing-token' / 'failed' / 'network' → 400 "verification failed"
|
||||||
|
|
||||||
|
Tests monkeypatch `httpx.post` (or set `TURNSTILE_SITEVERIFY_URL`
|
||||||
|
+ a MockTransport client) to avoid touching the real CloudFlare
|
||||||
|
endpoint. No real keys are ever embedded in tests.
|
||||||
|
"""
|
||||||
|
secret = _secret()
|
||||||
|
required = _required()
|
||||||
|
|
||||||
|
if not secret:
|
||||||
|
if required:
|
||||||
|
log.warning("Turnstile required but CLOUDFLARE_TURNSTILE_SECRET is unset; failing closed")
|
||||||
|
return VerifyOutcome(ok=False, reason="misconfigured")
|
||||||
|
# Dev/test/soft-fail path: no secret, not required → gate is open.
|
||||||
|
return VerifyOutcome(ok=True, reason="skipped")
|
||||||
|
|
||||||
|
if not token or not token.strip():
|
||||||
|
# Secret is set, so verification is in force. A missing token
|
||||||
|
# is a hard refuse — the frontend should have rendered the
|
||||||
|
# widget and collected one.
|
||||||
|
return VerifyOutcome(ok=False, reason="missing-token")
|
||||||
|
|
||||||
|
data = {"secret": secret, "response": token.strip()}
|
||||||
|
if client_ip:
|
||||||
|
data["remoteip"] = client_ip
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = httpx.post(_siteverify_url(), data=data, timeout=10.0)
|
||||||
|
payload = response.json()
|
||||||
|
except Exception as exc: # network, JSON parse, etc.
|
||||||
|
log.warning("Turnstile siteverify call failed: %s", exc)
|
||||||
|
return VerifyOutcome(ok=False, reason="network")
|
||||||
|
|
||||||
|
if payload.get("success") is True:
|
||||||
|
return VerifyOutcome(ok=True, reason="ok")
|
||||||
|
|
||||||
|
# `error-codes` is a list of strings on failure; we log the codes
|
||||||
|
# for the operator without surfacing them to the client.
|
||||||
|
log.info("Turnstile siteverify rejected token: %s", payload.get("error-codes"))
|
||||||
|
return VerifyOutcome(ok=False, reason="failed")
|
||||||
@@ -0,0 +1,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 != '';
|
||||||
@@ -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
|
||||||
|
);
|
||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
-- §6.2 / v0.11.0: trust device for 30 days (roadmap item #9).
|
||||||
|
--
|
||||||
|
-- After a successful OTC or passcode sign-in, the user can check
|
||||||
|
-- "trust this device for 30 days." The framework then issues a
|
||||||
|
-- server-issued opaque device-trust token, stores its hash on this
|
||||||
|
-- table, and sets a long-lived HttpOnly + Secure + SameSite=Lax
|
||||||
|
-- cookie carrying the raw token. On a subsequent visit, the cookie is
|
||||||
|
-- presented at `/auth/device-trust/start`; if the server can match the
|
||||||
|
-- hash to a non-expired non-revoked row, the user is signed in without
|
||||||
|
-- another OTC / passcode round-trip.
|
||||||
|
--
|
||||||
|
-- v0.11.0 introduces no new env vars. The 30-day window is hard-coded
|
||||||
|
-- in `backend/app/device_trust.py`; raising or lowering it (or making
|
||||||
|
-- it user-selectable) is a §19.2 candidate, alongside the cross-device
|
||||||
|
-- session-revocation surface this table will eventually share with the
|
||||||
|
-- v0.10.0 passcode-lockout shape (see SPEC §19.2 / SESSIONS-AND-DEVICES).
|
||||||
|
--
|
||||||
|
-- Storage shape:
|
||||||
|
--
|
||||||
|
-- * `id` — surrogate key. Lets the revoke-device UI address a single
|
||||||
|
-- row by id without leaking the token shape.
|
||||||
|
-- * `user_id` — FK into users(id) with cascade on delete. A deleted
|
||||||
|
-- user automatically loses every trusted device.
|
||||||
|
-- * `device_token_hash` — bcrypt hash of the random opaque token
|
||||||
|
-- issued at trust-time. The raw token only ever lives in the
|
||||||
|
-- outbound `Set-Cookie` header and the inbound `Cookie` header;
|
||||||
|
-- server-side storage is the hash, so a DB compromise does not
|
||||||
|
-- hand attackers a stash of valid device tokens.
|
||||||
|
-- * `created_at` — when the row was issued.
|
||||||
|
-- * `expires_at` — `created_at + 30 days`. A row past this timestamp
|
||||||
|
-- is dead; the lookup path refuses it without further checks.
|
||||||
|
-- * `user_agent` — the User-Agent header captured at issuance.
|
||||||
|
-- Stored verbatim (truncated to 1024 chars at the application
|
||||||
|
-- layer) so the revoke-device UI can show a rough device label.
|
||||||
|
-- Not used for any auth decision — purely a hint to the user
|
||||||
|
-- reviewing their device list.
|
||||||
|
-- * `last_seen_at` — refreshed every time the row authenticates a
|
||||||
|
-- request. Lets the revoke-device UI surface "last used 3 days
|
||||||
|
-- ago" so the user can tell which row corresponds to which
|
||||||
|
-- device.
|
||||||
|
-- * `revoked_at` — NULL means active; non-NULL stamps when the user
|
||||||
|
-- (or admin) revoked the row. Lookups treat any non-NULL value
|
||||||
|
-- as "this row is dead" without consulting the expiry; the
|
||||||
|
-- revoke gesture is intentionally one-way (a revoked device must
|
||||||
|
-- re-trust to come back online).
|
||||||
|
--
|
||||||
|
-- Indexing: a unique index on `device_token_hash` so collisions are
|
||||||
|
-- detectable at insert time (the token space is 256 bits of CSPRNG
|
||||||
|
-- entropy, so a collision is structurally impossible, but the
|
||||||
|
-- declaration documents the invariant). A separate index on
|
||||||
|
-- `(user_id, revoked_at)` so the revoke-device UI's list query is
|
||||||
|
-- a covering walk.
|
||||||
|
--
|
||||||
|
-- The bcrypt dependency reused here was added in v0.7.0 for OTC and
|
||||||
|
-- extended in v0.10.0 for passcodes; v0.11.0 needs no new dep.
|
||||||
|
--
|
||||||
|
-- The cookie shape: `rfc_device_trust` carries the raw token,
|
||||||
|
-- HttpOnly, Secure, SameSite=Lax, Max-Age=2592000 (30 days). It is
|
||||||
|
-- "essential" per the v0.13.0 cookie-consent banner (it is part of
|
||||||
|
-- authentication, not analytics), so it is set regardless of the
|
||||||
|
-- user's analytics / other-cookies choices.
|
||||||
|
|
||||||
|
CREATE TABLE device_trust (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
device_token_hash TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
user_agent TEXT NOT NULL DEFAULT '',
|
||||||
|
last_seen_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
revoked_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX idx_device_trust_token_hash ON device_trust (device_token_hash);
|
||||||
|
CREATE INDEX idx_device_trust_user ON device_trust (user_id, revoked_at);
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
-- §6.1 / v0.17.0: admin-create user with role + invite email (roadmap item #16).
|
||||||
|
--
|
||||||
|
-- Distinguishes from the v0.8.0 / v0.9.0 self-serve beta-access shape:
|
||||||
|
-- here an *admin* creates a `users` row *before* the invited person has
|
||||||
|
-- ever signed in, assigns them a role at creation time, and sends them
|
||||||
|
-- an invite email carrying a claim link. The invitee clicks the link,
|
||||||
|
-- the claim flow consumes the token (which is itself proof of email
|
||||||
|
-- control), the row is marked claimed, and the user is signed in
|
||||||
|
-- inheriting the pre-set role.
|
||||||
|
--
|
||||||
|
-- Migration slot 019 is allocated to this release. Slot 018 is reserved
|
||||||
|
-- for the parallel #12 release (per-RFC invitation, owner-only) shipping
|
||||||
|
-- in the same wave; the two features live in distinct tables
|
||||||
|
-- (`user_invite_tokens` here vs. `rfc_invitations` there) so they
|
||||||
|
-- coexist cleanly. Slot 016 was reserved+skipped by Session K during
|
||||||
|
-- v0.9.0 integration; slot 017 is the v0.11.0 device-trust table.
|
||||||
|
--
|
||||||
|
-- Open-question decisions settled in this release (see CHANGELOG):
|
||||||
|
-- * No `users` table changes — the brief floated `first_sign_in_at`
|
||||||
|
-- / `last_seen_at IS NULL` as the "(pending invite)" discriminator,
|
||||||
|
-- but the existing `users.last_seen_at` is NOT NULL with a
|
||||||
|
-- `datetime('now')` default (migrations/001) and there is no
|
||||||
|
-- `first_sign_in_at` column. Rather than land a schema migration to
|
||||||
|
-- introduce one, the discriminator is the existence of an active
|
||||||
|
-- (not-claimed, not-expired) row in `user_invite_tokens` joined on
|
||||||
|
-- `invited_user_id`. The admin user-listing carries a
|
||||||
|
-- `pending_invite` field populated via that join; on claim, the
|
||||||
|
-- invite row's `claimed_at` populates and the badge clears.
|
||||||
|
-- No new `permission_state` value is introduced either.
|
||||||
|
-- * The token is opaque (random URL-safe string, bcrypt-hashed at
|
||||||
|
-- rest), not a JWT, so admin revocation by row UPDATE works
|
||||||
|
-- without distributing a key-rotation gesture.
|
||||||
|
-- * The TTL is a constant (`INVITE_TOKEN_TTL_DAYS = 7` in
|
||||||
|
-- `backend/app/invites.py`); env-var configurability is a follow-up.
|
||||||
|
-- * Immediate-send (no admin-review-then-send queue) ships in this
|
||||||
|
-- release; admin-preview is a future enhancement.
|
||||||
|
-- * Bulk-invite (CSV paste) is deferred to a follow-up release;
|
||||||
|
-- v0.17.0 is one-at-a-time.
|
||||||
|
--
|
||||||
|
-- Storage shape:
|
||||||
|
--
|
||||||
|
-- * `id` — surrogate key. Lets the admin "pending invites" listing
|
||||||
|
-- address a row without leaking the token shape.
|
||||||
|
-- * `email` — the address the invite was sent to (case-insensitive
|
||||||
|
-- match at claim time, persisted verbatim for the audit trail).
|
||||||
|
-- * `role` — the role the invitee inherits on first sign-in. Pinned
|
||||||
|
-- via CHECK to the same set the §6.1 role flip accepts
|
||||||
|
-- (`owner` / `admin` / `contributor`) so a future role-set drift
|
||||||
|
-- fails loudly at insert rather than provisioning a ghost role.
|
||||||
|
-- * `first_name` / `last_name` — captured at create time so the
|
||||||
|
-- invitee skips the v0.8.0 capture-form step on first sign-in.
|
||||||
|
-- * `custom_message` — optional free-text from the admin (max 500
|
||||||
|
-- chars enforced at the API layer); embedded verbatim in the
|
||||||
|
-- email body if present.
|
||||||
|
-- * `token_hash` — bcrypt hash of the random opaque token. The
|
||||||
|
-- raw token only ever lives in the outbound email link and the
|
||||||
|
-- inbound claim body; server-side storage is the hash.
|
||||||
|
-- * `expires_at` — `created_at + 7 days` (default at the app layer
|
||||||
|
-- via `INVITE_TOKEN_TTL_DAYS`). A row past this stamp is dead;
|
||||||
|
-- the claim path refuses with HTTP 410.
|
||||||
|
-- * `created_at` — when the admin issued the invite.
|
||||||
|
-- * `created_by_admin_id` — FK into users(id) for the admin who
|
||||||
|
-- created the invite (no cascade; if the admin's row is deleted
|
||||||
|
-- the invite history stays so the audit trail survives).
|
||||||
|
-- * `claimed_at` — non-NULL once the invitee successfully claims.
|
||||||
|
-- A second claim attempt against an already-claimed row returns
|
||||||
|
-- HTTP 410.
|
||||||
|
-- * `claimed_by_user_id` — FK into users(id) for the user row
|
||||||
|
-- that consumed the token. In the common case this equals the
|
||||||
|
-- freshly-provisioned row that was created at invite time; the
|
||||||
|
-- FK lets the admin's "claimed" list join through.
|
||||||
|
-- * `invited_user_id` — FK into users(id) for the pre-provisioned
|
||||||
|
-- row. Created at invite time with `last_seen_at IS NULL` so the
|
||||||
|
-- v0.9.0 admin user-management page can render a "(pending
|
||||||
|
-- invite)" badge alongside existing users.
|
||||||
|
--
|
||||||
|
-- Indexing:
|
||||||
|
-- * Unique index on `token_hash` documents the no-collision
|
||||||
|
-- invariant (256 bits of CSPRNG entropy; collision is
|
||||||
|
-- structurally impossible, the unique constraint catches a
|
||||||
|
-- bug at insert time).
|
||||||
|
-- * Index on `(email, claimed_at)` so the "is this email already
|
||||||
|
-- invited?" pre-check the admin endpoint runs is a covering walk.
|
||||||
|
-- * Index on `(created_by_admin_id, created_at DESC)` for the
|
||||||
|
-- admin's "invites I've sent" listing.
|
||||||
|
|
||||||
|
CREATE TABLE user_invite_tokens (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'contributor')),
|
||||||
|
first_name TEXT NOT NULL DEFAULT '',
|
||||||
|
last_name TEXT NOT NULL DEFAULT '',
|
||||||
|
custom_message TEXT NOT NULL DEFAULT '',
|
||||||
|
token_hash TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
created_by_admin_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
claimed_at TEXT,
|
||||||
|
claimed_by_user_id INTEGER REFERENCES users(id),
|
||||||
|
invited_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX idx_user_invite_tokens_hash ON user_invite_tokens (token_hash);
|
||||||
|
CREATE INDEX idx_user_invite_tokens_email ON user_invite_tokens (email, claimed_at);
|
||||||
|
CREATE INDEX idx_user_invite_tokens_admin ON user_invite_tokens (created_by_admin_id, created_at DESC);
|
||||||
@@ -8,3 +8,4 @@ anthropic>=0.39
|
|||||||
google-generativeai>=0.8
|
google-generativeai>=0.8
|
||||||
openai>=1.50
|
openai>=1.50
|
||||||
PyYAML>=6.0
|
PyYAML>=6.0
|
||||||
|
bcrypt>=4.2
|
||||||
|
|||||||
@@ -0,0 +1,649 @@
|
|||||||
|
"""End-to-end integration tests for v0.17.0's admin-create user +
|
||||||
|
invite-email + claim-flow vertical (roadmap item #16, §6.1).
|
||||||
|
|
||||||
|
The release lands three halves of the same surface:
|
||||||
|
|
||||||
|
* **Admin-create user** at `POST /api/admin/users`. The admin types
|
||||||
|
email, first/last name, role, and an optional custom message. The
|
||||||
|
framework provisions the invitee `users` row (granted, with the
|
||||||
|
chosen role) and writes a `user_invite_tokens` row carrying the
|
||||||
|
bcrypt-hashed opaque token. The "pending invite" discriminator is
|
||||||
|
the active `user_invite_tokens` row joined on `invited_user_id`,
|
||||||
|
not a NULL column on `users` (the existing `last_seen_at` column
|
||||||
|
is NOT NULL). An invite email dispatches via the existing SMTP
|
||||||
|
relay.
|
||||||
|
|
||||||
|
* **Pending-invite admin listing** at `GET /api/admin/users/invites`.
|
||||||
|
Lists active (not claimed, not expired) invites for the admin's
|
||||||
|
"I sent these but they haven't been claimed yet" view.
|
||||||
|
|
||||||
|
* **Claim** at `POST /api/invites/claim`. The invitee POSTs the token
|
||||||
|
they got via email; the framework verifies, marks the row claimed,
|
||||||
|
signs them in (skipping OTC on first sign-in per the roadmap), and
|
||||||
|
returns a `needs_passcode` hint for the frontend to route to the
|
||||||
|
passcode-set screen.
|
||||||
|
|
||||||
|
The tests prove:
|
||||||
|
|
||||||
|
* The happy path: admin creates → invite row + email envelope land →
|
||||||
|
invitee claims with the token → session is established.
|
||||||
|
* Non-admin caller is refused 403.
|
||||||
|
* Self-invite is refused 422.
|
||||||
|
* Duplicate email is refused 409.
|
||||||
|
* Owner-grant by non-owner is refused 422.
|
||||||
|
* Malformed role is refused 422 (pydantic regex).
|
||||||
|
* Custom message over 500 chars is refused 422 (pydantic max_length).
|
||||||
|
* Claim with valid token: signs in + marks row claimed.
|
||||||
|
* Claim with expired token: HTTP 410.
|
||||||
|
* Claim with already-claimed token: HTTP 410.
|
||||||
|
* Claim with unknown token: HTTP 400.
|
||||||
|
* The admin-create gesture writes a `permission_events` row with
|
||||||
|
event_kind='user_invited'.
|
||||||
|
* The user listing surfaces the `pending_invite` field for invited-
|
||||||
|
but-not-yet-claimed users, and clears it after claim.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
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_invite_envelopes(to_address: str | None = None) -> list[dict]:
|
||||||
|
"""Pull the invite-kind envelopes off the shared notifier buffer.
|
||||||
|
|
||||||
|
Mirrors the OTC code-extraction helper in
|
||||||
|
test_admin_users_vertical.py — invite emails land in the same
|
||||||
|
`_SENT` buffer with `kind='invite'`.
|
||||||
|
"""
|
||||||
|
from app import email as email_mod
|
||||||
|
out = []
|
||||||
|
for env in email_mod.sent_envelopes():
|
||||||
|
if env.get("kind") != "invite":
|
||||||
|
continue
|
||||||
|
if to_address is not None and env["to"] != to_address:
|
||||||
|
continue
|
||||||
|
out.append(env)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_claim_url(envelope: dict) -> str:
|
||||||
|
"""Pull the claim URL out of the invite email body."""
|
||||||
|
for line in envelope["body"].splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if line.startswith("http") and "/invites/claim" in line:
|
||||||
|
return line
|
||||||
|
raise AssertionError(f"no claim URL in envelope body: {envelope['body']!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_claim_token(envelope: dict) -> str:
|
||||||
|
"""Pull the `token` query-string param out of the claim URL."""
|
||||||
|
from urllib.parse import urlparse, parse_qs
|
||||||
|
url = _extract_claim_url(envelope)
|
||||||
|
qs = parse_qs(urlparse(url).query)
|
||||||
|
return qs["token"][0]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Admin create + invite — happy path
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_create_user_invite_happy_path(app_with_fake_gitea):
|
||||||
|
"""Admin creates → user row + invite-token row + email envelope all
|
||||||
|
land; the response carries the created ids and the inviter is the
|
||||||
|
admin who issued the gesture."""
|
||||||
|
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=100, login="adminzero", role="admin")
|
||||||
|
sign_in_as(
|
||||||
|
client, user_id=100, gitea_login="adminzero",
|
||||||
|
display_name="Admin Zero", role="admin",
|
||||||
|
email="adminzero@test",
|
||||||
|
)
|
||||||
|
_reset_outbound()
|
||||||
|
|
||||||
|
r = client.post(
|
||||||
|
"/api/admin/users",
|
||||||
|
json={
|
||||||
|
"email": "invitee@example.com",
|
||||||
|
"first_name": "Inv",
|
||||||
|
"last_name": "Tee",
|
||||||
|
"role": "contributor",
|
||||||
|
"custom_message": "We chatted at the conference — welcome!",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
body = r.json()
|
||||||
|
assert body["ok"] is True
|
||||||
|
assert body["email"] == "invitee@example.com"
|
||||||
|
assert body["role"] == "contributor"
|
||||||
|
assert body["invite_id"] > 0
|
||||||
|
assert body["invited_user_id"] > 0
|
||||||
|
|
||||||
|
# User row exists with the chosen role + granted. The "pending
|
||||||
|
# invite" discriminator is the active `user_invite_tokens` row,
|
||||||
|
# not a NULL column on `users` — see the invites.create_invite
|
||||||
|
# docstring for the reasoning.
|
||||||
|
row = db.conn().execute(
|
||||||
|
"SELECT role, permission_state, first_name, last_name "
|
||||||
|
"FROM users WHERE email = ? COLLATE NOCASE",
|
||||||
|
("invitee@example.com",),
|
||||||
|
).fetchone()
|
||||||
|
assert row is not None
|
||||||
|
assert row["role"] == "contributor"
|
||||||
|
assert row["permission_state"] == "granted"
|
||||||
|
assert row["first_name"] == "Inv"
|
||||||
|
assert row["last_name"] == "Tee"
|
||||||
|
|
||||||
|
# Invite-token row exists with the matching ids and the custom
|
||||||
|
# message persisted verbatim.
|
||||||
|
invite = db.conn().execute(
|
||||||
|
"SELECT email, role, custom_message, created_by_admin_id, "
|
||||||
|
"invited_user_id, claimed_at FROM user_invite_tokens WHERE id = ?",
|
||||||
|
(body["invite_id"],),
|
||||||
|
).fetchone()
|
||||||
|
assert invite is not None
|
||||||
|
assert invite["email"] == "invitee@example.com"
|
||||||
|
assert invite["role"] == "contributor"
|
||||||
|
assert invite["custom_message"] == "We chatted at the conference — welcome!"
|
||||||
|
assert invite["created_by_admin_id"] == 100
|
||||||
|
assert invite["invited_user_id"] == body["invited_user_id"]
|
||||||
|
assert invite["claimed_at"] is None
|
||||||
|
|
||||||
|
# Email envelope landed with the invite kind and embeds the
|
||||||
|
# custom message + claim URL. The inviter display name comes
|
||||||
|
# off the DB row (which provision_user_row sets to
|
||||||
|
# login.capitalize()), not the sign_in_as cookie payload.
|
||||||
|
envelopes = _outbound_invite_envelopes(to_address="invitee@example.com")
|
||||||
|
assert len(envelopes) == 1
|
||||||
|
env = envelopes[0]
|
||||||
|
assert "Adminzero" in env["subject"] or "Adminzero" in env["body"]
|
||||||
|
assert "We chatted at the conference — welcome!" in env["body"]
|
||||||
|
# Claim URL is well-formed.
|
||||||
|
url = _extract_claim_url(env)
|
||||||
|
assert "/invites/claim?token=" in url
|
||||||
|
|
||||||
|
# `permission_events` row landed with event_kind='user_invited'.
|
||||||
|
ev = db.conn().execute(
|
||||||
|
"SELECT actor_user_id, subject_user_id, event_kind, details "
|
||||||
|
"FROM permission_events WHERE event_kind = 'user_invited'"
|
||||||
|
).fetchall()
|
||||||
|
assert len(ev) == 1
|
||||||
|
assert ev[0]["actor_user_id"] == 100
|
||||||
|
assert ev[0]["subject_user_id"] == body["invited_user_id"]
|
||||||
|
details = json.loads(ev[0]["details"])
|
||||||
|
assert details["email"] == "invitee@example.com"
|
||||||
|
assert details["role"] == "contributor"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Refusals on the admin-create endpoint
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_create_user_invite_refuses_non_admin(app_with_fake_gitea):
|
||||||
|
"""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=110, login="contrib", role="contributor")
|
||||||
|
sign_in_as(
|
||||||
|
client, user_id=110, gitea_login="contrib",
|
||||||
|
display_name="Contrib", role="contributor",
|
||||||
|
)
|
||||||
|
r = client.post(
|
||||||
|
"/api/admin/users",
|
||||||
|
json={"email": "x@y.com", "role": "contributor"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 403, r.text
|
||||||
|
|
||||||
|
client.cookies.clear()
|
||||||
|
r = client.post(
|
||||||
|
"/api/admin/users",
|
||||||
|
json={"email": "x@y.com", "role": "contributor"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_create_user_invite_refuses_self_email(app_with_fake_gitea):
|
||||||
|
"""An admin trying to invite their own email is refused 422 —
|
||||||
|
self-invite is the wrong channel; the role-change endpoint exists
|
||||||
|
for self-edits."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
provision_user_row(user_id=120, login="adm", role="admin")
|
||||||
|
# Manually set the admin's email since provision_user_row's
|
||||||
|
# fixture uses login@test; this is what we'll try to self-invite.
|
||||||
|
from app import db
|
||||||
|
db.conn().execute(
|
||||||
|
"UPDATE users SET email = ? WHERE id = ?",
|
||||||
|
("selfinviter@example.com", 120),
|
||||||
|
)
|
||||||
|
sign_in_as(
|
||||||
|
client, user_id=120, gitea_login="adm",
|
||||||
|
display_name="Adm", role="admin",
|
||||||
|
email="selfinviter@example.com",
|
||||||
|
)
|
||||||
|
r = client.post(
|
||||||
|
"/api/admin/users",
|
||||||
|
json={
|
||||||
|
"email": "selfinviter@example.com",
|
||||||
|
"role": "contributor",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 422, r.text
|
||||||
|
assert "yourself" in r.json()["detail"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_create_user_invite_refuses_duplicate_email(app_with_fake_gitea):
|
||||||
|
"""An admin trying to invite an email that already maps to a users
|
||||||
|
row is refused 409 — the existing role / grant gestures are the
|
||||||
|
right surface for an existing user."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
provision_user_row(user_id=130, login="adminD", role="admin")
|
||||||
|
provision_user_row(user_id=131, login="existingone", role="contributor")
|
||||||
|
sign_in_as(
|
||||||
|
client, user_id=130, gitea_login="adminD",
|
||||||
|
display_name="Admin D", role="admin",
|
||||||
|
)
|
||||||
|
# provision_user_row sets email to <login>@test, so:
|
||||||
|
r = client.post(
|
||||||
|
"/api/admin/users",
|
||||||
|
json={
|
||||||
|
"email": "existingone@test",
|
||||||
|
"role": "contributor",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 409, r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_create_user_invite_owner_grant_refused_for_non_owner(app_with_fake_gitea):
|
||||||
|
"""An admin (not owner) trying to invite a fresh user as `owner` is
|
||||||
|
refused 422 — §6.1's owner-zero is the only bootstrap path."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
provision_user_row(user_id=140, login="adminNoOwner", role="admin")
|
||||||
|
sign_in_as(
|
||||||
|
client, user_id=140, gitea_login="adminNoOwner",
|
||||||
|
display_name="Admin", role="admin",
|
||||||
|
)
|
||||||
|
r = client.post(
|
||||||
|
"/api/admin/users",
|
||||||
|
json={
|
||||||
|
"email": "wouldbeowner@example.com",
|
||||||
|
"role": "owner",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 422, r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_create_user_invite_owner_can_invite_as_owner(app_with_fake_gitea):
|
||||||
|
"""A sitting owner can invite a fresh user as `owner` — the §6.1
|
||||||
|
role-grant channel. Sanity check that the owner-grant path itself
|
||||||
|
works, paired with the refusal above."""
|
||||||
|
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=150, login="ownerzero", role="owner")
|
||||||
|
sign_in_as(
|
||||||
|
client, user_id=150, gitea_login="ownerzero",
|
||||||
|
display_name="Owner Zero", role="owner",
|
||||||
|
)
|
||||||
|
r = client.post(
|
||||||
|
"/api/admin/users",
|
||||||
|
json={
|
||||||
|
"email": "newowner@example.com",
|
||||||
|
"role": "owner",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
row = db.conn().execute(
|
||||||
|
"SELECT role FROM users WHERE email = ? COLLATE NOCASE",
|
||||||
|
("newowner@example.com",),
|
||||||
|
).fetchone()
|
||||||
|
assert row["role"] == "owner"
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_create_user_invite_refuses_malformed_role(app_with_fake_gitea):
|
||||||
|
"""The pydantic regex refuses any role outside the §6.1 set."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
provision_user_row(user_id=160, login="adminR", role="admin")
|
||||||
|
sign_in_as(
|
||||||
|
client, user_id=160, gitea_login="adminR",
|
||||||
|
display_name="Admin R", role="admin",
|
||||||
|
)
|
||||||
|
r = client.post(
|
||||||
|
"/api/admin/users",
|
||||||
|
json={
|
||||||
|
"email": "ok@example.com",
|
||||||
|
"role": "superuser",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_create_user_invite_refuses_long_custom_message(app_with_fake_gitea):
|
||||||
|
"""Custom message over the 500-char ceiling is refused 422."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
provision_user_row(user_id=170, login="adminM", role="admin")
|
||||||
|
sign_in_as(
|
||||||
|
client, user_id=170, gitea_login="adminM",
|
||||||
|
display_name="Admin M", role="admin",
|
||||||
|
)
|
||||||
|
r = client.post(
|
||||||
|
"/api/admin/users",
|
||||||
|
json={
|
||||||
|
"email": "ok@example.com",
|
||||||
|
"role": "contributor",
|
||||||
|
"custom_message": "x" * 501,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Claim flow
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_claim_with_valid_token_signs_in_and_marks_claimed(app_with_fake_gitea):
|
||||||
|
"""End-to-end: admin creates → invitee posts the token to
|
||||||
|
/api/invites/claim → session lands + row marked claimed +
|
||||||
|
last_seen_at stamps on the user row (the pending-invite
|
||||||
|
discriminator clears)."""
|
||||||
|
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=200, login="adminC", role="admin")
|
||||||
|
sign_in_as(
|
||||||
|
client, user_id=200, gitea_login="adminC",
|
||||||
|
display_name="Admin C", role="admin",
|
||||||
|
)
|
||||||
|
_reset_outbound()
|
||||||
|
|
||||||
|
r = client.post(
|
||||||
|
"/api/admin/users",
|
||||||
|
json={
|
||||||
|
"email": "claimant@example.com",
|
||||||
|
"first_name": "Clai",
|
||||||
|
"last_name": "Mant",
|
||||||
|
"role": "contributor",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
invite_id = r.json()["invite_id"]
|
||||||
|
invited_user_id = r.json()["invited_user_id"]
|
||||||
|
|
||||||
|
env = _outbound_invite_envelopes("claimant@example.com")[0]
|
||||||
|
token = _extract_claim_token(env)
|
||||||
|
|
||||||
|
# The invitee's request is anonymous (they have no session
|
||||||
|
# yet). We clear the admin's session cookie to simulate this.
|
||||||
|
client.cookies.clear()
|
||||||
|
|
||||||
|
r = client.post(
|
||||||
|
"/api/invites/claim",
|
||||||
|
json={"token": token},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
body = r.json()
|
||||||
|
assert body["ok"] is True
|
||||||
|
assert body["user"]["id"] == invited_user_id
|
||||||
|
assert body["user"]["role"] == "contributor"
|
||||||
|
assert body["user"]["permission_state"] == "granted"
|
||||||
|
# The user has no passcode set yet → frontend should route to
|
||||||
|
# passcode-set per the roadmap.
|
||||||
|
assert body["needs_passcode"] is True
|
||||||
|
|
||||||
|
# Row marked claimed; last_seen_at populated.
|
||||||
|
invite = db.conn().execute(
|
||||||
|
"SELECT claimed_at, claimed_by_user_id FROM user_invite_tokens "
|
||||||
|
"WHERE id = ?",
|
||||||
|
(invite_id,),
|
||||||
|
).fetchone()
|
||||||
|
assert invite["claimed_at"] is not None
|
||||||
|
assert invite["claimed_by_user_id"] == invited_user_id
|
||||||
|
|
||||||
|
user_row = db.conn().execute(
|
||||||
|
"SELECT last_seen_at FROM users WHERE id = ?",
|
||||||
|
(invited_user_id,),
|
||||||
|
).fetchone()
|
||||||
|
assert user_row["last_seen_at"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_claim_with_expired_token_returns_410(app_with_fake_gitea):
|
||||||
|
"""A token whose `expires_at` has passed surfaces as HTTP 410."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app import db, invites
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
provision_user_row(user_id=210, login="adminE", role="admin")
|
||||||
|
sign_in_as(
|
||||||
|
client, user_id=210, gitea_login="adminE",
|
||||||
|
display_name="Admin E", role="admin",
|
||||||
|
)
|
||||||
|
_reset_outbound()
|
||||||
|
|
||||||
|
# Create the invite, then back-date the expires_at to the past.
|
||||||
|
r = client.post(
|
||||||
|
"/api/admin/users",
|
||||||
|
json={
|
||||||
|
"email": "expired@example.com",
|
||||||
|
"role": "contributor",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
invite_id = r.json()["invite_id"]
|
||||||
|
db.conn().execute(
|
||||||
|
"UPDATE user_invite_tokens SET expires_at = datetime('now', '-1 day') "
|
||||||
|
"WHERE id = ?",
|
||||||
|
(invite_id,),
|
||||||
|
)
|
||||||
|
env = _outbound_invite_envelopes("expired@example.com")[0]
|
||||||
|
token = _extract_claim_token(env)
|
||||||
|
|
||||||
|
client.cookies.clear()
|
||||||
|
r = client.post("/api/invites/claim", json={"token": token})
|
||||||
|
assert r.status_code == 410, r.text
|
||||||
|
assert "expired" in r.json()["detail"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_claim_with_already_claimed_token_returns_410(app_with_fake_gitea):
|
||||||
|
"""Re-claiming an already-consumed token surfaces as HTTP 410."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
provision_user_row(user_id=220, login="adminA", role="admin")
|
||||||
|
sign_in_as(
|
||||||
|
client, user_id=220, gitea_login="adminA",
|
||||||
|
display_name="Admin A", role="admin",
|
||||||
|
)
|
||||||
|
_reset_outbound()
|
||||||
|
|
||||||
|
r = client.post(
|
||||||
|
"/api/admin/users",
|
||||||
|
json={
|
||||||
|
"email": "twice@example.com",
|
||||||
|
"role": "contributor",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
env = _outbound_invite_envelopes("twice@example.com")[0]
|
||||||
|
token = _extract_claim_token(env)
|
||||||
|
|
||||||
|
client.cookies.clear()
|
||||||
|
# First claim succeeds.
|
||||||
|
r = client.post("/api/invites/claim", json={"token": token})
|
||||||
|
assert r.status_code == 200
|
||||||
|
# Second claim, with the same token, refuses with 410.
|
||||||
|
client.cookies.clear()
|
||||||
|
r = client.post("/api/invites/claim", json={"token": token})
|
||||||
|
assert r.status_code == 410, r.text
|
||||||
|
assert "already" in r.json()["detail"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_claim_with_unknown_token_returns_400(app_with_fake_gitea):
|
||||||
|
"""A token that doesn't match any active invite is HTTP 400."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
# No invite ever created; the token is whatever the attacker
|
||||||
|
# types in. The endpoint should refuse without disclosing
|
||||||
|
# whether the token "looked" right.
|
||||||
|
r = client.post(
|
||||||
|
"/api/invites/claim",
|
||||||
|
json={"token": "totally-made-up-token-string-that-is-not-real"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 400, r.text
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Pending-invite admin listing
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_pending_invites_listing_shows_active_invites_only(app_with_fake_gitea):
|
||||||
|
"""The `GET /api/admin/users/invites` listing filters to active
|
||||||
|
invites — claimed and expired rows do not surface here (the admin
|
||||||
|
user-listing carries the per-row pending-invite badge for the
|
||||||
|
living rows; once claimed, the badge clears)."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
provision_user_row(user_id=300, login="adminL", role="admin")
|
||||||
|
sign_in_as(
|
||||||
|
client, user_id=300, gitea_login="adminL",
|
||||||
|
display_name="Admin L", role="admin",
|
||||||
|
)
|
||||||
|
_reset_outbound()
|
||||||
|
|
||||||
|
# Create three invites: one stays pending, one we'll claim, one
|
||||||
|
# we'll back-date to expired.
|
||||||
|
for email in ("alive@ex.co", "claimed@ex.co", "expired@ex.co"):
|
||||||
|
r = client.post(
|
||||||
|
"/api/admin/users",
|
||||||
|
json={"email": email, "role": "contributor"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
# Claim the middle one.
|
||||||
|
env = _outbound_invite_envelopes("claimed@ex.co")[0]
|
||||||
|
token_claim = _extract_claim_token(env)
|
||||||
|
|
||||||
|
# Expire the third one.
|
||||||
|
from app import db
|
||||||
|
db.conn().execute(
|
||||||
|
"UPDATE user_invite_tokens SET expires_at = datetime('now', '-1 day') "
|
||||||
|
"WHERE email = 'expired@ex.co'"
|
||||||
|
)
|
||||||
|
|
||||||
|
# The admin's session is still on the cookie. Claim works
|
||||||
|
# anonymously; we clear and restore.
|
||||||
|
admin_cookie = client.cookies.get("rfc_session")
|
||||||
|
client.cookies.clear()
|
||||||
|
r = client.post("/api/invites/claim", json={"token": token_claim})
|
||||||
|
assert r.status_code == 200
|
||||||
|
client.cookies.set("rfc_session", admin_cookie)
|
||||||
|
|
||||||
|
r = client.get("/api/admin/users/invites")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
items = r.json()["items"]
|
||||||
|
emails = sorted(i["email"] for i in items)
|
||||||
|
assert emails == ["alive@ex.co"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_pending_invite_badge_clears_after_claim(app_with_fake_gitea):
|
||||||
|
"""The `/api/admin/users` listing surfaces `pending_invite` while
|
||||||
|
the invite is unclaimed; after the invitee claims, the row's
|
||||||
|
pending_invite is null."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
provision_user_row(user_id=310, login="adminB", role="admin")
|
||||||
|
sign_in_as(
|
||||||
|
client, user_id=310, gitea_login="adminB",
|
||||||
|
display_name="Admin B", role="admin",
|
||||||
|
)
|
||||||
|
_reset_outbound()
|
||||||
|
|
||||||
|
r = client.post(
|
||||||
|
"/api/admin/users",
|
||||||
|
json={"email": "badgey@ex.co", "role": "contributor"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
invited_id = r.json()["invited_user_id"]
|
||||||
|
|
||||||
|
# Before claim — pending_invite is populated.
|
||||||
|
r = client.get("/api/admin/users")
|
||||||
|
assert r.status_code == 200
|
||||||
|
row = next(u for u in r.json()["items"] if u["id"] == invited_id)
|
||||||
|
assert row["pending_invite"] is not None
|
||||||
|
assert row["pending_invite"]["invite_id"] > 0
|
||||||
|
|
||||||
|
# Claim.
|
||||||
|
env = _outbound_invite_envelopes("badgey@ex.co")[0]
|
||||||
|
token = _extract_claim_token(env)
|
||||||
|
admin_cookie = client.cookies.get("rfc_session")
|
||||||
|
client.cookies.clear()
|
||||||
|
r = client.post("/api/invites/claim", json={"token": token})
|
||||||
|
assert r.status_code == 200
|
||||||
|
client.cookies.set("rfc_session", admin_cookie)
|
||||||
|
|
||||||
|
# After claim — pending_invite is null.
|
||||||
|
r = client.get("/api/admin/users")
|
||||||
|
assert r.status_code == 200
|
||||||
|
row = next(u for u in r.json()["items"] if u["id"] == invited_id)
|
||||||
|
assert row["pending_invite"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_pending_invites_listing_admin_only(app_with_fake_gitea):
|
||||||
|
"""The listing requires admin/owner; contributor gets 403."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
provision_user_row(user_id=320, login="contribL", role="contributor")
|
||||||
|
sign_in_as(
|
||||||
|
client, user_id=320, gitea_login="contribL",
|
||||||
|
display_name="Contrib L", role="contributor",
|
||||||
|
)
|
||||||
|
r = client.get("/api/admin/users/invites")
|
||||||
|
assert r.status_code == 403
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"] == []
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
"""End-to-end integration tests for the v0.5.0 PR-less discussion
|
||||||
|
surface — roadmap item #3, "discussion without PR; contribution requires
|
||||||
|
PR."
|
||||||
|
|
||||||
|
The vertical: an active RFC exists; the discussion endpoints under
|
||||||
|
`/api/rfcs/<slug>/discussion/...` open threads with
|
||||||
|
`threads.branch_name IS NULL`, post messages into them, and surface
|
||||||
|
them on subsequent reads. Branch-scoped threads (the §8.12 surface)
|
||||||
|
remain segregated. Anonymous viewers can read; only signed-in
|
||||||
|
contributors can write.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# Reuse the harness from Slice 1 / Slice 2.
|
||||||
|
from test_propose_vertical import ( # noqa: F401 — fixtures land via import
|
||||||
|
FakeGitea,
|
||||||
|
app_with_fake_gitea,
|
||||||
|
provision_user_row,
|
||||||
|
sign_in_as,
|
||||||
|
tmp_env,
|
||||||
|
)
|
||||||
|
from test_rfc_view_vertical import seed_active_rfc, SEED_BODY
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_and_post_to_pr_less_discussion_thread(app_with_fake_gitea):
|
||||||
|
"""The vertical: signed-in contributor opens a thread on the RFC's
|
||||||
|
discussion surface, posts a message, and the thread + message
|
||||||
|
surface on subsequent reads with branch_name IS NULL."""
|
||||||
|
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=1, login="alice", role="contributor")
|
||||||
|
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||||
|
sign_in_as(client, user_id=1, gitea_login="alice", display_name="Alice", role="contributor")
|
||||||
|
|
||||||
|
# Listing materializes the default whole-doc thread.
|
||||||
|
r = client.get("/api/rfcs/ohm/discussion/threads")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
items = r.json()["items"]
|
||||||
|
assert len(items) == 1
|
||||||
|
default_thread_id = items[0]["id"]
|
||||||
|
assert items[0]["anchor_kind"] == "whole-doc"
|
||||||
|
assert items[0]["thread_kind"] == "chat"
|
||||||
|
|
||||||
|
# Open an additional discussion thread with a first message.
|
||||||
|
r = client.post(
|
||||||
|
"/api/rfcs/ohm/discussion/threads",
|
||||||
|
json={"label": "Question about §3", "message": "Is consent baked into the trait model?"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
payload = r.json()
|
||||||
|
thread_id = payload["thread_id"]
|
||||||
|
message_id = payload["message_id"]
|
||||||
|
assert thread_id is not None and message_id is not None
|
||||||
|
|
||||||
|
# Confirm the row carries branch_name IS NULL (the PR-less shape).
|
||||||
|
row = db.conn().execute(
|
||||||
|
"SELECT rfc_slug, branch_name, thread_kind, anchor_kind, created_by FROM threads WHERE id = ?",
|
||||||
|
(thread_id,),
|
||||||
|
).fetchone()
|
||||||
|
assert row["rfc_slug"] == "ohm"
|
||||||
|
assert row["branch_name"] is None
|
||||||
|
assert row["thread_kind"] == "chat"
|
||||||
|
assert row["anchor_kind"] == "whole-doc"
|
||||||
|
assert row["created_by"] == 1
|
||||||
|
|
||||||
|
# The thread surfaces on the list endpoint alongside the default.
|
||||||
|
r = client.get("/api/rfcs/ohm/discussion/threads")
|
||||||
|
ids = [t["id"] for t in r.json()["items"]]
|
||||||
|
assert default_thread_id in ids
|
||||||
|
assert thread_id in ids
|
||||||
|
|
||||||
|
# Posting a reply on the new thread persists and returns the id.
|
||||||
|
r = client.post(
|
||||||
|
f"/api/rfcs/ohm/discussion/threads/{thread_id}/messages",
|
||||||
|
json={"text": "Following up — see §3.2."},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
reply_id = r.json()["message_id"]
|
||||||
|
|
||||||
|
# The messages read endpoint returns both messages in order.
|
||||||
|
r = client.get(f"/api/rfcs/ohm/discussion/threads/{thread_id}/messages")
|
||||||
|
assert r.status_code == 200
|
||||||
|
messages = r.json()["messages"]
|
||||||
|
assert [m["id"] for m in messages] == [message_id, reply_id]
|
||||||
|
assert messages[0]["author_login"] == "alice"
|
||||||
|
assert messages[0]["text"].startswith("Is consent")
|
||||||
|
|
||||||
|
|
||||||
|
def test_anonymous_can_read_but_cannot_post_discussion(app_with_fake_gitea):
|
||||||
|
"""Per the v0.3.0 anonymous-read contract: reads on the discussion
|
||||||
|
surface are open; write attempts return 401. v0.6.0 (item #4) will
|
||||||
|
tighten the read gate — v0.5.0 holds the write line so there is no
|
||||||
|
open window between releases."""
|
||||||
|
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")
|
||||||
|
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||||
|
|
||||||
|
# Seed the discussion thread + first message as Alice.
|
||||||
|
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||||
|
r = client.post(
|
||||||
|
"/api/rfcs/ohm/discussion/threads",
|
||||||
|
json={"message": "First."},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
thread_id = r.json()["thread_id"]
|
||||||
|
|
||||||
|
# Drop the session — viewer is anonymous now.
|
||||||
|
client.cookies.clear()
|
||||||
|
|
||||||
|
# Reads are open.
|
||||||
|
r = client.get("/api/rfcs/ohm/discussion/threads")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert any(t["id"] == thread_id for t in r.json()["items"])
|
||||||
|
|
||||||
|
r = client.get(f"/api/rfcs/ohm/discussion/threads/{thread_id}/messages")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert len(r.json()["messages"]) >= 1
|
||||||
|
|
||||||
|
# Writes refuse 401.
|
||||||
|
r = client.post(
|
||||||
|
"/api/rfcs/ohm/discussion/threads",
|
||||||
|
json={"message": "Drive-by."},
|
||||||
|
)
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
r = client.post(
|
||||||
|
f"/api/rfcs/ohm/discussion/threads/{thread_id}/messages",
|
||||||
|
json={"text": "Drive-by reply."},
|
||||||
|
)
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_discussion_threads_and_branch_threads_are_segregated(app_with_fake_gitea):
|
||||||
|
"""A branch-scoped thread (the §8.12 surface, branch_name='main' or a
|
||||||
|
feature branch) MUST NOT surface on the discussion endpoint, which
|
||||||
|
is keyed on branch_name IS NULL. The two surfaces share a table; the
|
||||||
|
null-filter is what segregates them."""
|
||||||
|
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=3, login="alice", role="contributor")
|
||||||
|
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||||
|
sign_in_as(client, user_id=3, gitea_login="alice", display_name="Alice", role="contributor")
|
||||||
|
|
||||||
|
# Manually materialize a branch-scoped thread on a feature branch.
|
||||||
|
db.conn().execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO threads
|
||||||
|
(rfc_slug, branch_name, anchor_kind, thread_kind, label, created_by)
|
||||||
|
VALUES ('ohm', 'alice-draft-aa00', 'whole-doc', 'chat', NULL, 3)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
# And one on the discussion surface.
|
||||||
|
r = client.post(
|
||||||
|
"/api/rfcs/ohm/discussion/threads",
|
||||||
|
json={"message": "Discussion-surface message."},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
discussion_thread_id = r.json()["thread_id"]
|
||||||
|
|
||||||
|
# The discussion list contains the null-branch thread (plus the
|
||||||
|
# default whole-doc) and excludes the feature-branch thread.
|
||||||
|
r = client.get("/api/rfcs/ohm/discussion/threads")
|
||||||
|
assert r.status_code == 200
|
||||||
|
ids = [t["id"] for t in r.json()["items"]]
|
||||||
|
assert discussion_thread_id in ids
|
||||||
|
# Feature-branch thread MUST NOT surface.
|
||||||
|
branch_thread_row = db.conn().execute(
|
||||||
|
"SELECT id FROM threads WHERE branch_name = 'alice-draft-aa00'"
|
||||||
|
).fetchone()
|
||||||
|
assert branch_thread_row is not None
|
||||||
|
assert branch_thread_row["id"] not in ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_discussion_thread_resolve_permissions(app_with_fake_gitea):
|
||||||
|
"""A thread's creator can resolve it; an unrelated contributor cannot;
|
||||||
|
an admin / owner / RFC-owner can. Mirrors §8.12's resolution rule for
|
||||||
|
branch-scoped threads."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
provision_user_row(user_id=4, login="alice", role="contributor")
|
||||||
|
provision_user_row(user_id=5, login="bob", role="contributor")
|
||||||
|
provision_user_row(user_id=6, login="ben", role="owner")
|
||||||
|
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||||
|
|
||||||
|
sign_in_as(client, user_id=4, gitea_login="alice", display_name="Alice", role="contributor")
|
||||||
|
r = client.post(
|
||||||
|
"/api/rfcs/ohm/discussion/threads",
|
||||||
|
json={"label": "Alice's thread", "message": "..."},
|
||||||
|
)
|
||||||
|
thread_id = r.json()["thread_id"]
|
||||||
|
|
||||||
|
# Unrelated contributor refused.
|
||||||
|
sign_in_as(client, user_id=5, gitea_login="bob", display_name="Bob", role="contributor")
|
||||||
|
r = client.post(f"/api/rfcs/ohm/discussion/threads/{thread_id}/resolve")
|
||||||
|
assert r.status_code == 403
|
||||||
|
|
||||||
|
# Creator allowed.
|
||||||
|
sign_in_as(client, user_id=4, gitea_login="alice", display_name="Alice", role="contributor")
|
||||||
|
r = client.post(f"/api/rfcs/ohm/discussion/threads/{thread_id}/resolve")
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
# Open another thread, resolve it as the owner.
|
||||||
|
r = client.post(
|
||||||
|
"/api/rfcs/ohm/discussion/threads",
|
||||||
|
json={"label": "Another thread", "message": "..."},
|
||||||
|
)
|
||||||
|
thread_id2 = r.json()["thread_id"]
|
||||||
|
sign_in_as(client, user_id=6, gitea_login="ben", display_name="Ben", role="owner")
|
||||||
|
r = client.post(f"/api/rfcs/ohm/discussion/threads/{thread_id2}/resolve")
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_discussion_404_on_unknown_rfc(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
r = client.get("/api/rfcs/nonexistent/discussion/threads")
|
||||||
|
assert r.status_code == 404
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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") == []
|
||||||
@@ -24,3 +24,41 @@ VITE_APP_NAME=
|
|||||||
# VITE_BETA_CONTACT=ben@wiggleverse.org
|
# VITE_BETA_CONTACT=ben@wiggleverse.org
|
||||||
# VITE_BETA_CONTACT=DM @ben on Matrix
|
# VITE_BETA_CONTACT=DM @ben on Matrix
|
||||||
VITE_BETA_CONTACT=
|
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=
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "rfc-app-frontend",
|
"name": "rfc-app-frontend",
|
||||||
"version": "0.2.1",
|
"version": "0.12.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "rfc-app-frontend",
|
"name": "rfc-app-frontend",
|
||||||
"version": "0.2.1",
|
"version": "0.12.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/commands": "^6.10.3",
|
"@codemirror/commands": "^6.10.3",
|
||||||
"@codemirror/lang-markdown": "^6.5.0",
|
"@codemirror/lang-markdown": "^6.5.0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "rfc-app-frontend",
|
"name": "rfc-app-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.4.0",
|
"version": "0.17.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -352,6 +352,168 @@
|
|||||||
}
|
}
|
||||||
.landing .secondary-link:hover { color: #1a1a1a; text-decoration: underline; }
|
.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 page (post-OAuth-rejection) --- */
|
||||||
|
|
||||||
.beta-pending {
|
.beta-pending {
|
||||||
@@ -383,6 +545,22 @@
|
|||||||
.btn-link-quiet { color: #666; text-decoration: none; font-size: 13px; }
|
.btn-link-quiet { color: #666; text-decoration: none; font-size: 13px; }
|
||||||
.btn-link-quiet:hover { color: #1a1a1a; text-decoration: underline; }
|
.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 ─────────────────────────────────── */
|
/* ── §8 RFC view: three-column shape ─────────────────────────────────── */
|
||||||
|
|
||||||
.main-pane {
|
.main-pane {
|
||||||
@@ -1770,9 +1948,248 @@
|
|||||||
display: inline-flex; align-items: center; gap: 6px;
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
font-size: 13px; cursor: pointer;
|
font-size: 13px; cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* v0.9.0 — admin user-management surface (roadmap item #7). */
|
||||||
|
.admin-filter-chips {
|
||||||
|
display: flex; gap: 6px; margin-bottom: 16px; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.admin-chip {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
background: #fff; border: 1px solid #d1d5db; border-radius: 999px;
|
||||||
|
padding: 4px 12px; font-size: 12px; color: #374151; cursor: pointer;
|
||||||
|
}
|
||||||
|
.admin-chip:hover { background: #f9fafb; }
|
||||||
|
.admin-chip.active {
|
||||||
|
background: #111; color: #fff; border-color: #111;
|
||||||
|
}
|
||||||
|
.admin-chip-count {
|
||||||
|
font-size: 11px; opacity: 0.7;
|
||||||
|
}
|
||||||
|
.admin-users-table td { vertical-align: top; padding-top: 10px; padding-bottom: 10px; }
|
||||||
|
.permission-cell { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.permission-actions { display: flex; gap: 6px; }
|
||||||
|
.permission-badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 11px; font-weight: 600;
|
||||||
|
padding: 2px 8px; border-radius: 999px;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.04em;
|
||||||
|
width: max-content;
|
||||||
|
}
|
||||||
|
.permission-badge-pending {
|
||||||
|
background: #fef3c7; color: #92400e;
|
||||||
|
}
|
||||||
|
.permission-badge-granted {
|
||||||
|
background: #dcfce7; color: #166534;
|
||||||
|
}
|
||||||
|
.permission-badge-revoked {
|
||||||
|
background: #fee2e2; color: #991b1b;
|
||||||
|
}
|
||||||
|
.permission-decided { font-size: 11px; }
|
||||||
|
.user-row-reason td {
|
||||||
|
background: #fffbeb; border-top: none !important;
|
||||||
|
padding: 0 16px 12px !important;
|
||||||
|
}
|
||||||
|
.user-reason-block {
|
||||||
|
border-left: 3px solid #f59e0b;
|
||||||
|
padding: 8px 12px; font-size: 13px;
|
||||||
|
background: #fffbeb;
|
||||||
|
}
|
||||||
|
.user-reason-block strong { display: block; margin-bottom: 4px; color: #92400e; }
|
||||||
|
.user-reason-block p { margin: 0; white-space: pre-wrap; color: #374151; }
|
||||||
.grad-queue { list-style: none; padding: 0; margin: 8px 0 24px; }
|
.grad-queue { list-style: none; padding: 0; margin: 8px 0 24px; }
|
||||||
.grad-queue li { padding: 8px 0; border-bottom: 1px solid #f3f4f6; }
|
.grad-queue li { padding: 8px 0; border-bottom: 1px solid #f3f4f6; }
|
||||||
.grad-queue-link { color: #111; text-decoration: none; font-size: 14px; }
|
.grad-queue-link { color: #111; text-decoration: none; font-size: 14px; }
|
||||||
.grad-queue-link:hover strong { text-decoration: underline; }
|
.grad-queue-link:hover strong { text-decoration: underline; }
|
||||||
.muted { color: #6b7280; }
|
.muted { color: #6b7280; }
|
||||||
.error { color: #b91c1c; }
|
.error { color: #b91c1c; }
|
||||||
|
|
||||||
|
/* v0.5.0 — PR-less per-RFC discussion panel (RFCDiscussionPanel.jsx).
|
||||||
|
Visual neighbor of .chat-panel but distinct: discussion lives on the
|
||||||
|
RFC, branch chat lives on the branch. Same flex column shape so it
|
||||||
|
slots cleanly into the existing .right-panel container.
|
||||||
|
*/
|
||||||
|
.discussion-panel {
|
||||||
|
flex: 1; display: flex; flex-direction: column;
|
||||||
|
overflow: hidden; min-height: 0;
|
||||||
|
}
|
||||||
|
.discussion-header {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid #f0f0ee;
|
||||||
|
background: #fafafa;
|
||||||
|
display: flex; flex-direction: column; gap: 4px;
|
||||||
|
}
|
||||||
|
.discussion-header-title { font-size: 12px; color: #555; font-weight: 600; }
|
||||||
|
.discussion-header-meta { font-size: 11px; color: #888; }
|
||||||
|
.discussion-thread-tabs {
|
||||||
|
display: flex; gap: 4px; flex-wrap: wrap;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-bottom: 1px solid #f0f0ee;
|
||||||
|
background: #fcfcfb;
|
||||||
|
}
|
||||||
|
.discussion-thread-tab {
|
||||||
|
background: #fff; border: 1px solid #e5e5e0; cursor: pointer;
|
||||||
|
font-size: 11px; color: #555;
|
||||||
|
padding: 3px 8px; border-radius: 999px;
|
||||||
|
}
|
||||||
|
.discussion-thread-tab.active {
|
||||||
|
background: #eef2ff; border-color: #5b5bd6; color: #3737a0;
|
||||||
|
}
|
||||||
|
.discussion-thread-tab.resolved { opacity: 0.6; }
|
||||||
|
.discussion-messages {
|
||||||
|
flex: 1; overflow-y: auto;
|
||||||
|
padding: 14px;
|
||||||
|
display: flex; flex-direction: column; gap: 10px;
|
||||||
|
}
|
||||||
|
.discussion-empty {
|
||||||
|
flex: 1; display: flex; align-items: center; justify-content: center;
|
||||||
|
text-align: center; padding: 24px;
|
||||||
|
}
|
||||||
|
.discussion-empty p {
|
||||||
|
font-size: 13px; color: #999; line-height: 1.6; max-width: 280px;
|
||||||
|
}
|
||||||
|
.discussion-error {
|
||||||
|
background: #fee; border: 1px solid #fcc; color: #b91c1c;
|
||||||
|
padding: 8px 10px; border-radius: 4px; font-size: 12px;
|
||||||
|
}
|
||||||
|
.discussion-message { display: flex; flex-direction: column; gap: 3px; }
|
||||||
|
.discussion-message-meta {
|
||||||
|
display: flex; gap: 8px; font-size: 11px; color: #888;
|
||||||
|
}
|
||||||
|
.discussion-message-author { color: #5b5bd6; font-weight: 500; }
|
||||||
|
.discussion-message-quote {
|
||||||
|
font-size: 11px; color: #666; font-style: italic;
|
||||||
|
border-left: 2px solid #ddd; padding-left: 8px; margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.discussion-message-body {
|
||||||
|
font-size: 13px; color: #222; line-height: 1.5;
|
||||||
|
white-space: pre-wrap; word-wrap: break-word;
|
||||||
|
background: #f7f7f5; padding: 8px 10px; border-radius: 6px;
|
||||||
|
}
|
||||||
|
.discussion-message.system .discussion-system-bubble {
|
||||||
|
font-size: 12px; color: #888; font-style: italic;
|
||||||
|
text-align: center; padding: 4px 0;
|
||||||
|
}
|
||||||
|
.discussion-composer {
|
||||||
|
border-top: 1px solid #f0f0ee;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: #fafafa;
|
||||||
|
display: flex; flex-direction: column; gap: 6px;
|
||||||
|
}
|
||||||
|
.discussion-composer-textarea {
|
||||||
|
width: 100%; resize: vertical; min-height: 60px;
|
||||||
|
font-family: inherit; font-size: 13px;
|
||||||
|
border: 1px solid #ddd; border-radius: 4px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
}
|
||||||
|
.discussion-composer-textarea:focus {
|
||||||
|
outline: none; border-color: #5b5bd6;
|
||||||
|
}
|
||||||
|
.discussion-composer-actions {
|
||||||
|
display: flex; gap: 8px; align-items: center; justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|||||||
+77
-7
@@ -8,11 +8,17 @@ import PRView from './components/PRView.jsx'
|
|||||||
import ProposalView from './components/ProposalView.jsx'
|
import ProposalView from './components/ProposalView.jsx'
|
||||||
import ProposeModal from './components/ProposeModal.jsx'
|
import ProposeModal from './components/ProposeModal.jsx'
|
||||||
import Landing from './components/Landing.jsx'
|
import Landing from './components/Landing.jsx'
|
||||||
|
import Login from './components/Login.jsx'
|
||||||
import BetaPending from './components/BetaPending.jsx'
|
import BetaPending from './components/BetaPending.jsx'
|
||||||
import Philosophy from './components/Philosophy.jsx'
|
import Philosophy from './components/Philosophy.jsx'
|
||||||
|
import Docs from './components/Docs.jsx'
|
||||||
import NotificationSettings from './components/NotificationSettings.jsx'
|
import NotificationSettings from './components/NotificationSettings.jsx'
|
||||||
import Admin from './components/Admin.jsx'
|
import Admin from './components/Admin.jsx'
|
||||||
|
import InviteClaim from './components/InviteClaim.jsx'
|
||||||
import ToastHost, { showToast } from './components/ToastHost.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'
|
import './App.css'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
@@ -23,8 +29,19 @@ export default function App() {
|
|||||||
const [inboxOpen, setInboxOpen] = useState(false)
|
const [inboxOpen, setInboxOpen] = useState(false)
|
||||||
const [unreadCount, setUnreadCount] = useState(0)
|
const [unreadCount, setUnreadCount] = useState(0)
|
||||||
const [inboxTick, setInboxTick] = 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 navigate = useNavigate()
|
||||||
|
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
getMe()
|
getMe()
|
||||||
.then(setMe)
|
.then(setMe)
|
||||||
@@ -72,11 +89,15 @@ export default function App() {
|
|||||||
// The deployment is in private beta: anonymous visitors get the full
|
// The deployment is in private beta: anonymous visitors get the full
|
||||||
// app in read-only mode (viewer = null is passed through to every
|
// app in read-only mode (viewer = null is passed through to every
|
||||||
// component), and write affordances are hidden at the component
|
// component), and write affordances are hidden at the component
|
||||||
// level. /beta-pending is the post-OAuth-rejection page reachable by
|
// level. v0.8.0 (§6.1 / item #6): authenticated users with
|
||||||
// anyone. The original §14.1 Landing surface is retained for the
|
// `permission_state='pending'` also pass through as `viewer` with
|
||||||
// `/welcome` URL only, in case a deployment wants to link to it.
|
// 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 viewer = me?.authenticated ? me.user : null
|
||||||
const isAdmin = viewer && (viewer.role === 'owner' || viewer.role === 'admin')
|
const isAdmin = viewer && (viewer.role === 'owner' || viewer.role === 'admin')
|
||||||
|
const isPending = viewer && viewer.permission_state === 'pending'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
@@ -92,6 +113,9 @@ export default function App() {
|
|||||||
<Link to="/philosophy" className="header-about" title="Why this exists (§14)">
|
<Link to="/philosophy" className="header-about" title="Why this exists (§14)">
|
||||||
About
|
About
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link to="/docs" className="header-about" title="User guide">
|
||||||
|
Docs
|
||||||
|
</Link>
|
||||||
{viewer && (
|
{viewer && (
|
||||||
<Link to="/settings/notifications" className="header-settings" title="Notification settings (§15)">
|
<Link to="/settings/notifications" className="header-settings" title="Notification settings (§15)">
|
||||||
Settings
|
Settings
|
||||||
@@ -121,17 +145,28 @@ export default function App() {
|
|||||||
<a className="btn-link" href="/auth/logout">Sign out</a>
|
<a className="btn-link" href="/auth/logout">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>
|
Sign in <span className="beta-chip">Beta</span>
|
||||||
</a>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
{isPending && <PendingAccessBanner />}
|
||||||
<div className="app-body">
|
<div className="app-body">
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/welcome" element={<Landing />} />
|
<Route path="/welcome" element={<Landing />} />
|
||||||
<Route path="/beta-pending" element={<BetaPending />} />
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route path="/beta-pending" element={<BetaPending viewer={viewer} />} />
|
||||||
|
{/* v0.17.0 — roadmap item #16. The claim landing page for
|
||||||
|
admin-issued invites. Anonymous-reachable; the call
|
||||||
|
itself establishes the session on success. */}
|
||||||
|
<Route path="/invites/claim" element={<InviteClaim />} />
|
||||||
<Route path="/philosophy" element={<PhilosophyWithSidebar 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 && (
|
{viewer && (
|
||||||
<Route path="/settings/notifications" element={<NotificationSettingsWithSidebar viewer={viewer} />} />
|
<Route path="/settings/notifications" element={<NotificationSettingsWithSidebar viewer={viewer} />} />
|
||||||
)}
|
)}
|
||||||
@@ -172,10 +207,18 @@ export default function App() {
|
|||||||
<Inbox onClose={() => setInboxOpen(false)} lastChangeTick={inboxTick} />
|
<Inbox onClose={() => setInboxOpen(false)} lastChangeTick={inboxTick} />
|
||||||
)}
|
)}
|
||||||
<ToastHost />
|
<ToastHost />
|
||||||
|
<CookieConsentBanner viewer={viewer} forceOpen={consentReopenTick} />
|
||||||
</div>
|
</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 }) {
|
function PhilosophyWithSidebar({ viewer }) {
|
||||||
// The chrome surfaces (§14.2 philosophy, §15 settings, §6/§17 admin)
|
// The chrome surfaces (§14.2 philosophy, §15 settings, §6/§17 admin)
|
||||||
// all use the full app body — no catalog left pane, no propose modal.
|
// all use the full app body — no catalog left pane, no propose modal.
|
||||||
@@ -188,6 +231,14 @@ function PhilosophyWithSidebar({ viewer }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DocsWithSidebar({ viewer }) {
|
||||||
|
return (
|
||||||
|
<main className="chrome-pane">
|
||||||
|
<Docs authenticated={!!viewer} />
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function NotificationSettingsWithSidebar({ viewer }) {
|
function NotificationSettingsWithSidebar({ viewer }) {
|
||||||
return (
|
return (
|
||||||
<main className="chrome-pane">
|
<main className="chrome-pane">
|
||||||
@@ -204,7 +255,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 }) {
|
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) {
|
if (!viewer) {
|
||||||
return (
|
return (
|
||||||
<div className="welcome">
|
<div className="welcome">
|
||||||
@@ -216,7 +286,7 @@ function Welcome({ viewer }) {
|
|||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Discussion and contribution are in private <strong>Beta</strong> —
|
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.
|
been invited.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
@@ -25,6 +25,131 @@ export async function getMe() {
|
|||||||
return jsonOrThrow(res)
|
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() {
|
export async function listRFCs() {
|
||||||
return jsonOrThrow(await fetch('/api/rfcs'))
|
return jsonOrThrow(await fetch('/api/rfcs'))
|
||||||
}
|
}
|
||||||
@@ -197,6 +322,52 @@ export async function resolveThread(slug, branch, threadId) {
|
|||||||
return jsonOrThrow(res)
|
return jsonOrThrow(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── v0.5.0: PR-less per-RFC discussion (§5 / §10) ────────────────────────
|
||||||
|
//
|
||||||
|
// The substrate is `threads.branch_name IS NULL` — the same threads
|
||||||
|
// table the branch chat uses, with a null branch the schema already
|
||||||
|
// supported. Contribution still requires a PR (api_prs / openPR), so
|
||||||
|
// these endpoints are read+write for discussion only.
|
||||||
|
|
||||||
|
export async function listDiscussionThreads(slug) {
|
||||||
|
return jsonOrThrow(await fetch(`/api/rfcs/${slug}/discussion/threads`))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createDiscussionThread(slug, { label = null, message = null } = {}) {
|
||||||
|
const res = await fetch(`/api/rfcs/${slug}/discussion/threads`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ label, message }),
|
||||||
|
})
|
||||||
|
return jsonOrThrow(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDiscussionThreadMessages(slug, threadId) {
|
||||||
|
return jsonOrThrow(await fetch(
|
||||||
|
`/api/rfcs/${slug}/discussion/threads/${threadId}/messages`,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postDiscussionMessage(slug, threadId, { text, quote = null }) {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/rfcs/${slug}/discussion/threads/${threadId}/messages`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ text, quote }),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return jsonOrThrow(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveDiscussionThread(slug, threadId) {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/rfcs/${slug}/discussion/threads/${threadId}/resolve`,
|
||||||
|
{ method: 'POST' },
|
||||||
|
)
|
||||||
|
return jsonOrThrow(res)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Slice 4: super-draft body editing (§9.5) ─────────────────────────────
|
// ── Slice 4: super-draft body editing (§9.5) ─────────────────────────────
|
||||||
|
|
||||||
export async function startEditBranch(slug, body = {}) {
|
export async function startEditBranch(slug, body = {}) {
|
||||||
@@ -462,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) {
|
export async function muteUser(userId) {
|
||||||
return jsonOrThrow(await fetch(`/api/users/${userId}/notification-mute`, { method: 'POST' }))
|
return jsonOrThrow(await fetch(`/api/users/${userId}/notification-mute`, { method: 'POST' }))
|
||||||
}
|
}
|
||||||
@@ -486,6 +674,10 @@ export async function getPhilosophy() {
|
|||||||
return jsonOrThrow(await fetch('/api/philosophy'))
|
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
|
// Slice 7: admin neighborhood (§17 admin/* + user search for the §15.8 mute
|
||||||
// typeahead).
|
// typeahead).
|
||||||
@@ -511,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 } = {}) {
|
export async function listAuditLog({ actionKind, actorUserId, rfcSlug, beforeId, limit } = {}) {
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
if (actionKind) params.set('action_kind', actionKind)
|
if (actionKind) params.set('action_kind', actionKind)
|
||||||
@@ -552,6 +757,53 @@ export async function removeAllowlistEmail(email) {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v0.17.0 — roadmap item #16. Admin-create user + invite email with
|
||||||
|
// optional custom message. The frontend modal on /admin/users wires
|
||||||
|
// these two helpers; the claim helper drives the /invites/claim page
|
||||||
|
// that the invitee lands on when they click the email link.
|
||||||
|
//
|
||||||
|
// `createUserInvite` returns `{ ok, invite_id, invited_user_id, email,
|
||||||
|
// role }`. The 409 path (duplicate email) and 422 path (self-invite,
|
||||||
|
// owner-grant-by-non-owner, malformed input) surface as thrown errors
|
||||||
|
// via `jsonOrThrow` so the modal can render the server's message.
|
||||||
|
//
|
||||||
|
// `listUserInvites` returns the active-invites list for the admin's
|
||||||
|
// "I sent these but they haven't been claimed yet" view. Active means
|
||||||
|
// not claimed and not expired; once the invitee clicks through, the
|
||||||
|
// row clears here and the user-listing's `pending_invite` badge
|
||||||
|
// vanishes alongside.
|
||||||
|
|
||||||
|
export async function createUserInvite({ email, first_name, last_name, role, custom_message }) {
|
||||||
|
return jsonOrThrow(await fetch('/api/admin/users', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
email,
|
||||||
|
first_name: first_name || '',
|
||||||
|
last_name: last_name || '',
|
||||||
|
role,
|
||||||
|
custom_message: custom_message || '',
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listUserInvites() {
|
||||||
|
return jsonOrThrow(await fetch('/api/admin/users/invites'))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claim an admin-issued invite token. Anonymous endpoint — the invitee
|
||||||
|
// is not yet signed in; this call establishes the session on success.
|
||||||
|
// `trustDevice` mirrors the v0.11.0 OTC/passcode opt-in: when true,
|
||||||
|
// the server mints a fresh device-trust row + sets the long-lived
|
||||||
|
// cookie so the invitee skips OTC on their next visit.
|
||||||
|
export async function claimInvite(token, { trustDevice = false } = {}) {
|
||||||
|
return jsonOrThrow(await fetch('/api/invites/claim', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ token, trust_device: !!trustDevice }),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
export async function searchUsers(q) {
|
export async function searchUsers(q) {
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
if (q) params.set('q', q)
|
if (q) params.set('q', q)
|
||||||
|
|||||||
@@ -16,14 +16,22 @@ import {
|
|||||||
listAdminUsers,
|
listAdminUsers,
|
||||||
setUserRole,
|
setUserRole,
|
||||||
setUserMute,
|
setUserMute,
|
||||||
|
setUserPermission,
|
||||||
listAuditLog,
|
listAuditLog,
|
||||||
listPermissionEvents,
|
listPermissionEvents,
|
||||||
listGraduationQueue,
|
listGraduationQueue,
|
||||||
listAllowlist,
|
listAllowlist,
|
||||||
addAllowlistEmail,
|
addAllowlistEmail,
|
||||||
removeAllowlistEmail,
|
removeAllowlistEmail,
|
||||||
|
createUserInvite,
|
||||||
} from '../api.js'
|
} from '../api.js'
|
||||||
|
|
||||||
|
// v0.17.0 — roadmap item #16. The max length the backend enforces
|
||||||
|
// (Pydantic body bound + `invites.CUSTOM_MESSAGE_MAX_LENGTH`); kept
|
||||||
|
// here so the modal's "remaining chars" counter stays in lockstep
|
||||||
|
// with the server-side bound.
|
||||||
|
const CUSTOM_MESSAGE_MAX_LENGTH = 500
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ path: 'users', label: 'Users' },
|
{ path: 'users', label: 'Users' },
|
||||||
{ path: 'allowlist', label: 'Allowlist' },
|
{ path: 'allowlist', label: 'Allowlist' },
|
||||||
@@ -68,12 +76,30 @@ export default function Admin({ viewer }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Users + role + write-mute (§6.1 / §6.2) ────────────────────────────────
|
// ── Users + role + write-mute + permission grant/revoke (§6.1 / §6.2) ──────
|
||||||
|
//
|
||||||
|
// v0.9.0 (roadmap item #7) lands the user-management surface. The table
|
||||||
|
// shows every user with their permission_state, sign-up reason (when
|
||||||
|
// pending), role, write-mute, and Grant / Revoke controls. State filter
|
||||||
|
// chips above the table narrow to one bucket — the "Pending" chip is the
|
||||||
|
// admin's daily inbox shape.
|
||||||
|
|
||||||
|
const STATE_CHIPS = [
|
||||||
|
{ value: 'all', label: 'All' },
|
||||||
|
{ value: 'pending', label: 'Pending' },
|
||||||
|
{ value: 'granted', label: 'Granted' },
|
||||||
|
{ value: 'revoked', label: 'Revoked' },
|
||||||
|
]
|
||||||
|
|
||||||
function UsersTab() {
|
function UsersTab() {
|
||||||
const [users, setUsers] = useState(null)
|
const [users, setUsers] = useState(null)
|
||||||
const [busy, setBusy] = useState({})
|
const [busy, setBusy] = useState({})
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
|
const [stateFilter, setStateFilter] = useState('all')
|
||||||
|
// v0.17.0 — roadmap item #16. The "Create user + invite" modal's
|
||||||
|
// open/closed state. The modal is local to UsersTab (it only opens
|
||||||
|
// from the header button) and refreshes the listing on success.
|
||||||
|
const [inviteModalOpen, setInviteModalOpen] = useState(false)
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -113,68 +139,383 @@ function UsersTab() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function flipPermission(userId, state) {
|
||||||
|
setBusy(b => ({ ...b, [userId]: true }))
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await setUserPermission(userId, state)
|
||||||
|
// Refresh the full row so permission_decided_{at,by_*} update too.
|
||||||
|
await refresh()
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message)
|
||||||
|
} finally {
|
||||||
|
setBusy(b => ({ ...b, [userId]: false }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const counts = useMemo(() => {
|
||||||
|
const c = { all: 0, pending: 0, granted: 0, revoked: 0 }
|
||||||
|
if (users) {
|
||||||
|
c.all = users.length
|
||||||
|
for (const u of users) {
|
||||||
|
const s = u.permission_state || 'granted'
|
||||||
|
if (s in c) c[s] += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}, [users])
|
||||||
|
|
||||||
if (users == null) return <p className="muted">Loading users…</p>
|
if (users == null) return <p className="muted">Loading users…</p>
|
||||||
|
|
||||||
|
const filtered = stateFilter === 'all'
|
||||||
|
? users
|
||||||
|
: users.filter(u => (u.permission_state || 'granted') === stateFilter)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="admin-tab">
|
<div className="admin-tab">
|
||||||
<header className="admin-tab-header">
|
<header className="admin-tab-header">
|
||||||
<h2>Users</h2>
|
<h2>Users</h2>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
Role changes write to <code>permission_events</code>. The §6.2
|
The pending bucket is the beta-access review queue (§6.1 /
|
||||||
write-mute applies to contributors only — promote to admin to
|
v0.8.0). Grant or revoke writes to <code>permission_events</code>
|
||||||
remove a user's ability to write without silencing them.
|
and stamps <code>permission_decided_by</code> +{' '}
|
||||||
|
<code>permission_decided_at</code>. Role and write-mute controls
|
||||||
|
retain their v0.7.0 semantics — promote to admin to remove a
|
||||||
|
user's ability to write without silencing them.
|
||||||
</p>
|
</p>
|
||||||
|
{/* v0.17.0 — roadmap item #16. The "Create user + invite"
|
||||||
|
affordance opens a modal that provisions a fresh users row
|
||||||
|
with the chosen role and sends an invite email with a
|
||||||
|
single-use claim link. */}
|
||||||
|
<div className="admin-tab-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-primary"
|
||||||
|
onClick={() => setInviteModalOpen(true)}
|
||||||
|
>Create user + invite</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
{error && <p className="settings-note warning">{error}</p>}
|
{error && <p className="settings-note warning">{error}</p>}
|
||||||
<table className="admin-table">
|
{inviteModalOpen && (
|
||||||
<thead>
|
<CreateUserInviteModal
|
||||||
<tr>
|
onClose={() => setInviteModalOpen(false)}
|
||||||
<th>User</th>
|
onSuccess={async () => {
|
||||||
<th>Role</th>
|
setInviteModalOpen(false)
|
||||||
<th>Write-muted</th>
|
await refresh()
|
||||||
<th>Last seen</th>
|
}}
|
||||||
</tr>
|
/>
|
||||||
</thead>
|
)}
|
||||||
<tbody>
|
|
||||||
{users.map(u => (
|
<div className="admin-filter-chips">
|
||||||
<tr key={u.id}>
|
{STATE_CHIPS.map(chip => (
|
||||||
<td>
|
<button
|
||||||
<div className="user-cell">
|
key={chip.value}
|
||||||
<span className="user-handle">@{u.gitea_login}</span>
|
type="button"
|
||||||
<span className="muted">{u.display_name}</span>
|
className={`admin-chip${stateFilter === chip.value ? ' active' : ''}`}
|
||||||
</div>
|
onClick={() => setStateFilter(chip.value)}
|
||||||
</td>
|
>
|
||||||
<td>
|
{chip.label} <span className="admin-chip-count">{counts[chip.value] ?? 0}</span>
|
||||||
<select
|
</button>
|
||||||
value={u.role}
|
))}
|
||||||
onChange={e => changeRole(u.id, e.target.value)}
|
</div>
|
||||||
disabled={!!busy[u.id]}
|
|
||||||
>
|
{filtered.length === 0 ? (
|
||||||
<option value="contributor">Contributor</option>
|
<p className="muted">No users in this bucket.</p>
|
||||||
<option value="admin">Admin</option>
|
) : (
|
||||||
<option value="owner">Owner</option>
|
<table className="admin-table admin-users-table">
|
||||||
</select>
|
<thead>
|
||||||
</td>
|
<tr>
|
||||||
<td>
|
<th>User</th>
|
||||||
{u.role === 'contributor' ? (
|
<th>State</th>
|
||||||
<label className="mute-toggle">
|
<th>Role</th>
|
||||||
<input
|
<th>Write-muted</th>
|
||||||
type="checkbox"
|
<th>Signed up</th>
|
||||||
checked={!!u.muted}
|
<th>Last seen</th>
|
||||||
onChange={e => toggleMute(u.id, e.target.checked)}
|
|
||||||
disabled={!!busy[u.id]}
|
|
||||||
/>
|
|
||||||
{u.muted ? 'Muted' : 'Active'}
|
|
||||||
</label>
|
|
||||||
) : (
|
|
||||||
<span className="muted">N/A</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="muted">{u.last_seen_at}</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
</thead>
|
||||||
</tbody>
|
<tbody>
|
||||||
</table>
|
{filtered.map(u => (
|
||||||
|
<UserRow
|
||||||
|
key={u.id}
|
||||||
|
user={u}
|
||||||
|
busy={!!busy[u.id]}
|
||||||
|
onChangeRole={role => changeRole(u.id, role)}
|
||||||
|
onToggleMute={muted => toggleMute(u.id, muted)}
|
||||||
|
onFlipPermission={state => flipPermission(u.id, state)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserRow({ user: u, busy, onChangeRole, onToggleMute, onFlipPermission }) {
|
||||||
|
const state = u.permission_state || 'granted'
|
||||||
|
const fullName = [u.first_name, u.last_name].filter(Boolean).join(' ').trim()
|
||||||
|
const handle = u.gitea_login ? `@${u.gitea_login}` : (u.email || u.display_name)
|
||||||
|
// v0.17.0 — roadmap item #16. The user's row may also be the
|
||||||
|
// "(pending invite)" shape: admin-created via POST /api/admin/users,
|
||||||
|
// not yet claimed via /api/invites/claim. The backend's user-listing
|
||||||
|
// surfaces this via `pending_invite` (object with invite_id +
|
||||||
|
// expires_at) or null. The badge sits inline next to the handle so
|
||||||
|
// the admin sees at a glance which rows are real users vs. unclaimed
|
||||||
|
// invites.
|
||||||
|
const pendingInvite = u.pending_invite
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div className="user-cell">
|
||||||
|
<span className="user-handle">{handle}</span>
|
||||||
|
{pendingInvite && (
|
||||||
|
<span
|
||||||
|
className="invite-badge"
|
||||||
|
title={`Admin-created invite; expires ${pendingInvite.expires_at}`}
|
||||||
|
>(pending invite)</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Create user + invite modal (v0.17.0 / roadmap item #16) ────────────────
|
||||||
|
//
|
||||||
|
// The "Create user + invite" affordance on the Users tab opens this
|
||||||
|
// modal. Admin types email, first name, last name, role, and (optionally)
|
||||||
|
// a custom message to embed in the invite email. On submit, calls
|
||||||
|
// `POST /api/admin/users` which provisions the row + sends the email.
|
||||||
|
// The 409 path (duplicate email) and 422 path (self-invite, owner-
|
||||||
|
// grant-by-non-owner, malformed input) surface the server's message
|
||||||
|
// inline; the success path closes the modal and refreshes the listing.
|
||||||
|
//
|
||||||
|
// The modal lives in this file rather than a separate component
|
||||||
|
// because it has one caller (UsersTab), reuses the existing modal
|
||||||
|
// stylesheet from /admin's chrome, and shares the
|
||||||
|
// CUSTOM_MESSAGE_MAX_LENGTH constant defined at the top of the file.
|
||||||
|
|
||||||
|
function CreateUserInviteModal({ onClose, onSuccess }) {
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [firstName, setFirstName] = useState('')
|
||||||
|
const [lastName, setLastName] = useState('')
|
||||||
|
const [role, setRole] = useState('contributor')
|
||||||
|
const [customMessage, setCustomMessage] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const [success, setSuccess] = useState(null)
|
||||||
|
|
||||||
|
const remaining = CUSTOM_MESSAGE_MAX_LENGTH - customMessage.length
|
||||||
|
|
||||||
|
async function handleSubmit(event) {
|
||||||
|
event.preventDefault()
|
||||||
|
const trimmedEmail = email.trim()
|
||||||
|
if (!trimmedEmail) {
|
||||||
|
setError('Email is required')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setBusy(true)
|
||||||
|
setError(null)
|
||||||
|
setSuccess(null)
|
||||||
|
try {
|
||||||
|
const result = await createUserInvite({
|
||||||
|
email: trimmedEmail,
|
||||||
|
first_name: firstName.trim(),
|
||||||
|
last_name: lastName.trim(),
|
||||||
|
role,
|
||||||
|
custom_message: customMessage,
|
||||||
|
})
|
||||||
|
setSuccess(`Invite sent to ${result.email} (${result.role}).`)
|
||||||
|
// Brief delay so the admin sees the success state, then close
|
||||||
|
// and let the parent refresh the listing.
|
||||||
|
setTimeout(() => { onSuccess?.() }, 600)
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message || 'Unable to send invite')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop" onClick={onClose}>
|
||||||
|
<div className="modal-panel" onClick={e => e.stopPropagation()}>
|
||||||
|
<header className="modal-header">
|
||||||
|
<h3>Create user + invite</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-link-quiet"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={busy}
|
||||||
|
aria-label="Close"
|
||||||
|
>×</button>
|
||||||
|
</header>
|
||||||
|
<p className="muted">
|
||||||
|
Provisions a fresh user row with the chosen role and sends an
|
||||||
|
invite email carrying a single-use claim link. The link
|
||||||
|
expires in 7 days. The invitee clicks through to claim
|
||||||
|
their account — no OTC roundtrip is required on first sign-in.
|
||||||
|
</p>
|
||||||
|
<form onSubmit={handleSubmit} className="create-user-invite-form">
|
||||||
|
<label>
|
||||||
|
<span>Email</span>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={e => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
disabled={busy}
|
||||||
|
autoFocus
|
||||||
|
maxLength={320}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="form-row">
|
||||||
|
<label>
|
||||||
|
<span>First name</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={firstName}
|
||||||
|
onChange={e => setFirstName(e.target.value)}
|
||||||
|
disabled={busy}
|
||||||
|
maxLength={120}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Last name</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={lastName}
|
||||||
|
onChange={e => setLastName(e.target.value)}
|
||||||
|
disabled={busy}
|
||||||
|
maxLength={120}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
<span>Role</span>
|
||||||
|
<select
|
||||||
|
value={role}
|
||||||
|
onChange={e => setRole(e.target.value)}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
<option value="contributor">Contributor</option>
|
||||||
|
<option value="admin">Admin</option>
|
||||||
|
<option value="owner">Owner (owner-only)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>
|
||||||
|
Custom message (optional){' '}
|
||||||
|
<span className={`muted${remaining < 0 ? ' warning' : ''}`}>
|
||||||
|
{remaining} chars left
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<textarea
|
||||||
|
value={customMessage}
|
||||||
|
onChange={e => setCustomMessage(e.target.value)}
|
||||||
|
disabled={busy}
|
||||||
|
rows={4}
|
||||||
|
maxLength={CUSTOM_MESSAGE_MAX_LENGTH}
|
||||||
|
placeholder="Optional — embedded in the invite email."
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{error && <p className="settings-note warning">{error}</p>}
|
||||||
|
{success && <p className="settings-note success">{success}</p>}
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button type="button" onClick={onClose} disabled={busy}>Cancel</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn-primary"
|
||||||
|
disabled={busy || !email.trim() || remaining < 0}
|
||||||
|
>
|
||||||
|
{busy ? 'Sending…' : 'Send invite'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`
|
// v0.3.0 introduced this surface as the post-OAuth-rejection page (a
|
||||||
// table has any rows), the OAuth callback redirects unrecognised users
|
// user whose email wasn't on the `allowed_emails` table bounced here).
|
||||||
// here instead of provisioning them. The framework cannot know the
|
// v0.8.0 (roadmap item #6) repurposes it as the post-OTC pending-grant
|
||||||
// deployment operator's preferred contact channel — so the deployment
|
// page: any authenticated user whose `permission_state='pending'` lands
|
||||||
// supplies one via VITE_BETA_CONTACT (an email, URL, or short
|
// here on root visits, after a fresh-OTC profile capture, or via the
|
||||||
// instruction). If unset, we render a generic ask-the-operator line.
|
// 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'
|
import { Link } from 'react-router-dom'
|
||||||
|
|
||||||
export default function BetaPending() {
|
export default function BetaPending({ viewer }) {
|
||||||
const contact = import.meta.env.VITE_BETA_CONTACT || ''
|
const contact = import.meta.env.VITE_BETA_CONTACT || ''
|
||||||
|
const isPending = viewer?.permission_state === 'pending'
|
||||||
return (
|
return (
|
||||||
<div className="beta-pending">
|
<div className="beta-pending">
|
||||||
<div className="beta-pending-inner">
|
<div className="beta-pending-inner">
|
||||||
<h1>{import.meta.env.VITE_APP_NAME} is in private Beta.</h1>
|
<h1>
|
||||||
<p>
|
{isPending
|
||||||
Discussion and contribution are gated to invited emails for now.
|
? 'Your request is in review.'
|
||||||
Reading is open — every super-draft, every active RFC, and every
|
: `${import.meta.env.VITE_APP_NAME} is in private Beta.`}
|
||||||
public conversation is visible without signing in.
|
</h1>
|
||||||
</p>
|
{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 ? (
|
{contact ? (
|
||||||
<p className="beta-pending-contact">
|
<p className="beta-pending-contact">
|
||||||
To request access, contact <strong>{contact}</strong> with the
|
Questions? Contact <strong>{contact}</strong>.
|
||||||
email address you'd like to sign in with.
|
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="beta-pending-contact">
|
<p className="beta-pending-contact">
|
||||||
To request access, contact the deployment operator with the email
|
Questions? Contact the deployment operator.
|
||||||
address you'd like to sign in with.
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="beta-pending-actions">
|
<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>
|
<Link className="btn-link-quiet" to="/philosophy">Read the philosophy →</Link>
|
||||||
</div>
|
</div>
|
||||||
</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 & 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 → Privacy & 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> · </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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
// v0.17.0 — roadmap item #16. The claim flow's landing page.
|
||||||
|
//
|
||||||
|
// The admin's invite email carries a link to /invites/claim?token=…;
|
||||||
|
// the invitee clicks through and lands here. The page reads the
|
||||||
|
// token from the URL, posts it to /api/invites/claim, and on success
|
||||||
|
// routes either to the passcode-set screen (if v0.10.0 passcode flow
|
||||||
|
// is in play and the user has no passcode yet) or to home.
|
||||||
|
//
|
||||||
|
// Anonymous-reachable: the entire point of the call is to establish
|
||||||
|
// the session; we do not pre-check authentication.
|
||||||
|
//
|
||||||
|
// Failure modes the backend distinguishes:
|
||||||
|
// * 410 — token is expired or already claimed (the row is dead).
|
||||||
|
// * 400 — token doesn't match any active invite (forged, revoked,
|
||||||
|
// or wiped).
|
||||||
|
//
|
||||||
|
// We surface both as the same "this invite link isn't valid" shape
|
||||||
|
// for the invitee — the detail message from the server reads
|
||||||
|
// distinctively enough that the admin can debug from logs, and the
|
||||||
|
// invitee just needs to know they should contact the admin for a
|
||||||
|
// fresh link.
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useLocation, useNavigate } from 'react-router-dom'
|
||||||
|
import { claimInvite } from '../api.js'
|
||||||
|
|
||||||
|
export default function InviteClaim() {
|
||||||
|
const location = useLocation()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [status, setStatus] = useState('working') // 'working' | 'ok' | 'failed'
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const [trustDevice, setTrustDevice] = useState(false)
|
||||||
|
const [submitted, setSubmitted] = useState(false)
|
||||||
|
const [user, setUser] = useState(null)
|
||||||
|
const [needsPasscode, setNeedsPasscode] = useState(false)
|
||||||
|
|
||||||
|
const params = new URLSearchParams(location.search)
|
||||||
|
const token = params.get('token') || ''
|
||||||
|
|
||||||
|
async function performClaim() {
|
||||||
|
if (!token) {
|
||||||
|
setStatus('failed')
|
||||||
|
setError('No invite token in the URL.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSubmitted(true)
|
||||||
|
setStatus('working')
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const result = await claimInvite(token, { trustDevice })
|
||||||
|
setUser(result.user)
|
||||||
|
setNeedsPasscode(!!result.needs_passcode)
|
||||||
|
setStatus('ok')
|
||||||
|
} catch (e) {
|
||||||
|
setStatus('failed')
|
||||||
|
setError(e.message || 'Unable to claim invite')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-flight: if the URL has no token at all, fail fast so the
|
||||||
|
// invitee sees the missing-token shape immediately rather than
|
||||||
|
// an empty form.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) {
|
||||||
|
setStatus('failed')
|
||||||
|
setError('This claim link is missing its token.')
|
||||||
|
}
|
||||||
|
}, [token])
|
||||||
|
|
||||||
|
// On a successful claim, route the user onward. The brief calls
|
||||||
|
// this out: route to passcode-set if v0.10.0 passcode flow is in
|
||||||
|
// play and the user has no passcode yet; otherwise route to home.
|
||||||
|
useEffect(() => {
|
||||||
|
if (status !== 'ok') return
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
if (needsPasscode) {
|
||||||
|
navigate('/settings/notifications#sign-in', { replace: true })
|
||||||
|
} else {
|
||||||
|
navigate('/', { replace: true })
|
||||||
|
}
|
||||||
|
}, 1200)
|
||||||
|
return () => clearTimeout(timeout)
|
||||||
|
}, [status, needsPasscode, navigate])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="invite-claim-page">
|
||||||
|
<div className="invite-claim-panel">
|
||||||
|
<h1>Claim your account</h1>
|
||||||
|
{!submitted && status === 'working' && token && (
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
You've been invited to this deployment. Click the button below
|
||||||
|
to claim your account and sign in. This link is single-use and
|
||||||
|
expires 7 days after it was sent.
|
||||||
|
</p>
|
||||||
|
<label className="claim-trust-toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={trustDevice}
|
||||||
|
onChange={e => setTrustDevice(e.target.checked)}
|
||||||
|
/>
|
||||||
|
{' '}Trust this device for 30 days (skip the email step on
|
||||||
|
your next visit from this browser).
|
||||||
|
</label>
|
||||||
|
<div className="claim-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-primary"
|
||||||
|
onClick={performClaim}
|
||||||
|
>Claim my account</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{submitted && status === 'working' && (
|
||||||
|
<p>Claiming…</p>
|
||||||
|
)}
|
||||||
|
{status === 'ok' && (
|
||||||
|
<>
|
||||||
|
<p className="settings-note success">
|
||||||
|
Welcome{user?.display_name ? `, ${user.display_name}` : ''}!
|
||||||
|
You're signed in.
|
||||||
|
</p>
|
||||||
|
<p className="muted">
|
||||||
|
{needsPasscode
|
||||||
|
? 'Redirecting you to set a passcode so you can sign in without an email roundtrip next time…'
|
||||||
|
: 'Redirecting you to the home page…'}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{status === 'failed' && (
|
||||||
|
<>
|
||||||
|
<p className="settings-note warning">
|
||||||
|
{error || "This invite link isn't valid."}
|
||||||
|
</p>
|
||||||
|
<p className="muted">
|
||||||
|
If you believe this is a mistake, contact the admin who
|
||||||
|
sent you the invite — they can issue a fresh link.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -28,7 +28,7 @@ export default function Landing() {
|
|||||||
first RFC defining <em>human</em>. Build the dictionary first.
|
first RFC defining <em>human</em>. Build the dictionary first.
|
||||||
</p>
|
</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>
|
<Link className="secondary-link" to="/philosophy">Read the full philosophy →</Link>
|
||||||
|
|
||||||
<ul className="landing-deck">
|
<ul className="landing-deck">
|
||||||
|
|||||||
@@ -0,0 +1,694 @@
|
|||||||
|
// 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 (4–20 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'
|
||||||
|
|
||||||
|
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) 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 })
|
||||||
|
// 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 })
|
||||||
|
// 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,
|
||||||
|
})
|
||||||
|
// 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 (4–20 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,
|
unmuteUser,
|
||||||
muteUser,
|
muteUser,
|
||||||
searchUsers,
|
searchUsers,
|
||||||
|
getCookieConsent,
|
||||||
|
getMe,
|
||||||
|
setPasscode,
|
||||||
|
clearPasscode,
|
||||||
|
listMyDevices,
|
||||||
|
revokeMyDevice,
|
||||||
|
revokeAllMyDevices,
|
||||||
} from '../api.js'
|
} 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.'
|
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 />
|
<QuietHoursSection />
|
||||||
<WatchesSection />
|
<WatchesSection />
|
||||||
<MutesSection viewer={viewer} />
|
<MutesSection viewer={viewer} />
|
||||||
|
<SignInSection />
|
||||||
|
<DevicesSection />
|
||||||
|
<PrivacyCookiesSection />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── v0.11.0: trusted devices (§6.2, roadmap item #9) ──────────────────────
|
||||||
|
//
|
||||||
|
// Lists the user's active device-trust rows and lets them revoke any
|
||||||
|
// or all. A revoke marks the row dead server-side; the matching
|
||||||
|
// device's next visit will be refused and the cookie cleared. The
|
||||||
|
// surface intentionally does not single out the row whose cookie the
|
||||||
|
// current request carries — every row reads identically, so the user
|
||||||
|
// can revoke "this device" alongside any other from a single page.
|
||||||
|
|
||||||
|
function DevicesSection() {
|
||||||
|
const [devices, setDevices] = useState(null)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
try {
|
||||||
|
const { items } = await listMyDevices()
|
||||||
|
setDevices(items || [])
|
||||||
|
setError(null)
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message || 'Could not load trusted devices.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onRevoke(deviceId) {
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
await revokeMyDevice(deviceId)
|
||||||
|
await refresh()
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message || 'Could not revoke device.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onRevokeAll() {
|
||||||
|
if (!confirm('Revoke trust on every device, including this one? You will be asked to sign in via email next time.')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
await revokeAllMyDevices()
|
||||||
|
await refresh()
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message || 'Could not revoke devices.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionShell
|
||||||
|
title="Trusted devices"
|
||||||
|
subtitle="Devices where you've checked “Trust this device for 30 days.” Sign-in is automatic on these devices until the trust expires or you revoke it."
|
||||||
|
>
|
||||||
|
{devices === null && <p className="settings-note">Loading…</p>}
|
||||||
|
{devices !== null && devices.length === 0 && (
|
||||||
|
<p className="device-empty">
|
||||||
|
No trusted devices. Sign in and check “Trust this device for 30 days”
|
||||||
|
to add the device you're on now.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{devices !== null && devices.length > 0 && (
|
||||||
|
<>
|
||||||
|
<ul className="device-list">
|
||||||
|
{devices.map(d => (
|
||||||
|
<li key={d.id} className="device-list-item">
|
||||||
|
<div className="device-meta">
|
||||||
|
<div className="device-ua">{d.user_agent || 'Unknown device'}</div>
|
||||||
|
<div className="device-stamps">
|
||||||
|
Trusted {formatStamp(d.created_at)} · last seen {formatStamp(d.last_seen_at)} · expires {formatStamp(d.expires_at)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRevoke(d.id)}
|
||||||
|
disabled={busy}
|
||||||
|
title="Revoke trust on this device"
|
||||||
|
>
|
||||||
|
Revoke
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="device-revoke-all"
|
||||||
|
onClick={onRevokeAll}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
Revoke all devices
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{error && <p className="settings-note warning">{error}</p>}
|
||||||
|
</SectionShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatStamp(stamp) {
|
||||||
|
// The server emits SQLite `datetime('now')` strings (UTC, no
|
||||||
|
// timezone marker). Parse defensively; fall back to the raw stamp
|
||||||
|
// if Date can't make sense of it.
|
||||||
|
if (!stamp) return '—'
|
||||||
|
const d = new Date(stamp.replace(' ', 'T') + 'Z')
|
||||||
|
if (Number.isNaN(d.getTime())) return stamp
|
||||||
|
return d.toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── §6.2 sign-in (v0.10.0 / roadmap item #8): passcode management ──────────
|
||||||
|
|
||||||
|
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="4–20 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 ───────────────────────────────────────────
|
// ── §15.4 email category toggles ───────────────────────────────────────────
|
||||||
|
|
||||||
function EmailPreferencesSection() {
|
function EmailPreferencesSection() {
|
||||||
|
|||||||
@@ -0,0 +1,290 @@
|
|||||||
|
// RFCDiscussionPanel.jsx — v0.5.0's PR-less per-RFC discussion surface.
|
||||||
|
//
|
||||||
|
// Roadmap item #3: an RFC's main view now has a discussion surface
|
||||||
|
// distinct from PR comments and from branch chat. The substrate is the
|
||||||
|
// existing threads/thread_messages tables — rows with
|
||||||
|
// `threads.branch_name IS NULL` scope to "the RFC, no branch yet."
|
||||||
|
//
|
||||||
|
// Reused as the right-column panel on `branchParam === 'main'`. Branch
|
||||||
|
// chat (ChatPanel.jsx) keeps its existing role for branch-scoped work,
|
||||||
|
// including PRs. Contribution remains gated behind opening a PR —
|
||||||
|
// nothing here writes to the document.
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import {
|
||||||
|
createDiscussionThread,
|
||||||
|
getDiscussionThreadMessages,
|
||||||
|
listDiscussionThreads,
|
||||||
|
postDiscussionMessage,
|
||||||
|
resolveDiscussionThread,
|
||||||
|
} from '../api'
|
||||||
|
|
||||||
|
export default function RFCDiscussionPanel({ slug, viewer }) {
|
||||||
|
const [threads, setThreads] = useState([])
|
||||||
|
const [messagesByThread, setMessagesByThread] = useState({})
|
||||||
|
const [composer, setComposer] = useState('')
|
||||||
|
const [activeThreadId, setActiveThreadId] = useState(null)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const [sending, setSending] = useState(false)
|
||||||
|
const bottomRef = useRef(null)
|
||||||
|
|
||||||
|
// Pull threads + messages on mount / slug change.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!slug) return
|
||||||
|
let cancelled = false
|
||||||
|
setError(null)
|
||||||
|
setThreads([])
|
||||||
|
setMessagesByThread({})
|
||||||
|
setActiveThreadId(null)
|
||||||
|
listDiscussionThreads(slug)
|
||||||
|
.then(async ({ items }) => {
|
||||||
|
if (cancelled) return
|
||||||
|
setThreads(items || [])
|
||||||
|
// Pre-load messages for each thread. The list is small (per-RFC,
|
||||||
|
// not per-branch) so a fan-out fetch is fine; §19.2 candidate
|
||||||
|
// for paging if a hot RFC accumulates lots of threads.
|
||||||
|
const collected = {}
|
||||||
|
for (const t of items || []) {
|
||||||
|
try {
|
||||||
|
const { messages } = await getDiscussionThreadMessages(slug, t.id)
|
||||||
|
collected[t.id] = messages
|
||||||
|
} catch {
|
||||||
|
collected[t.id] = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!cancelled) {
|
||||||
|
setMessagesByThread(collected)
|
||||||
|
// Default the active thread to the system's lazy whole-doc
|
||||||
|
// default (the first row with anchor_kind='whole-doc' and
|
||||||
|
// no label) so the composer wires to a real id immediately.
|
||||||
|
const dflt = (items || []).find(
|
||||||
|
t => t.anchor_kind === 'whole-doc' && !t.label,
|
||||||
|
)
|
||||||
|
setActiveThreadId(dflt?.id || items?.[0]?.id || null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => { if (!cancelled) setError(err.message) })
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [slug])
|
||||||
|
|
||||||
|
// Scroll to bottom when messages land in the active thread.
|
||||||
|
useEffect(() => {
|
||||||
|
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||||
|
}, [activeThreadId, messagesByThread[activeThreadId]?.length])
|
||||||
|
|
||||||
|
const handleSend = useCallback(async () => {
|
||||||
|
if (!viewer) { window.location.href = '/auth/login'; return }
|
||||||
|
const text = composer.trim()
|
||||||
|
if (!text || sending) return
|
||||||
|
setSending(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
// If no thread yet, mint one with the message as its first turn.
|
||||||
|
if (!activeThreadId) {
|
||||||
|
const { thread_id, message_id } = await createDiscussionThread(slug, { message: text })
|
||||||
|
// Re-pull authoritative state — the default whole-doc thread
|
||||||
|
// existed pre-this call (the GET creates it lazily), so we
|
||||||
|
// either get the existing default's id back from the new
|
||||||
|
// thread's row or the prior default; either way the list call
|
||||||
|
// is the source of truth.
|
||||||
|
const { items } = await listDiscussionThreads(slug)
|
||||||
|
setThreads(items || [])
|
||||||
|
const { messages } = await getDiscussionThreadMessages(slug, thread_id)
|
||||||
|
setMessagesByThread(prev => ({ ...prev, [thread_id]: messages }))
|
||||||
|
setActiveThreadId(thread_id)
|
||||||
|
void message_id
|
||||||
|
} else {
|
||||||
|
const { message_id } = await postDiscussionMessage(slug, activeThreadId, { text })
|
||||||
|
const { messages } = await getDiscussionThreadMessages(slug, activeThreadId)
|
||||||
|
setMessagesByThread(prev => ({ ...prev, [activeThreadId]: messages }))
|
||||||
|
void message_id
|
||||||
|
}
|
||||||
|
setComposer('')
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setSending(false)
|
||||||
|
}
|
||||||
|
}, [composer, sending, viewer, slug, activeThreadId])
|
||||||
|
|
||||||
|
const handleNewThread = useCallback(async () => {
|
||||||
|
if (!viewer) { window.location.href = '/auth/login'; return }
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const { thread_id } = await createDiscussionThread(slug, { label: null, message: null })
|
||||||
|
const { items } = await listDiscussionThreads(slug)
|
||||||
|
setThreads(items || [])
|
||||||
|
setActiveThreadId(thread_id)
|
||||||
|
setMessagesByThread(prev => ({ ...prev, [thread_id]: [] }))
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
}
|
||||||
|
}, [viewer, slug])
|
||||||
|
|
||||||
|
const handleResolve = useCallback(async (threadId) => {
|
||||||
|
if (!viewer) return
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await resolveDiscussionThread(slug, threadId)
|
||||||
|
const { items } = await listDiscussionThreads(slug)
|
||||||
|
setThreads(items || [])
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
}
|
||||||
|
}, [viewer, slug])
|
||||||
|
|
||||||
|
const onKeyDown = useCallback((e) => {
|
||||||
|
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||||
|
e.preventDefault()
|
||||||
|
handleSend()
|
||||||
|
}
|
||||||
|
}, [handleSend])
|
||||||
|
|
||||||
|
const activeThread = threads.find(t => t.id === activeThreadId) || null
|
||||||
|
const activeMessages = messagesByThread[activeThreadId] || []
|
||||||
|
const openThreads = threads.filter(t => t.state === 'open')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="discussion-panel">
|
||||||
|
<div className="discussion-header">
|
||||||
|
<span className="discussion-header-title">
|
||||||
|
Discussion <span className="beta-chip">Beta</span>
|
||||||
|
</span>
|
||||||
|
<span className="discussion-header-meta">
|
||||||
|
{openThreads.length} open thread{openThreads.length === 1 ? '' : 's'}
|
||||||
|
{' · '}contribution requires a PR
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{threads.length > 1 && (
|
||||||
|
<div className="discussion-thread-tabs">
|
||||||
|
{threads.map(t => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
type="button"
|
||||||
|
className={`discussion-thread-tab ${t.id === activeThreadId ? 'active' : ''} ${t.state === 'resolved' ? 'resolved' : ''}`}
|
||||||
|
onClick={() => setActiveThreadId(t.id)}
|
||||||
|
title={t.label || (t.id === activeThreadId ? 'Current thread' : 'Open thread')}
|
||||||
|
>
|
||||||
|
{t.label || (t.anchor_kind === 'whole-doc' && !t.label ? 'General' : `Thread ${t.id}`)}
|
||||||
|
{t.state === 'resolved' && ' ✓'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="discussion-messages">
|
||||||
|
{error && <div className="discussion-error">{error}</div>}
|
||||||
|
{activeMessages.length === 0 && !error && (
|
||||||
|
<div className="discussion-empty">
|
||||||
|
<p>
|
||||||
|
{viewer
|
||||||
|
? 'No discussion yet. Be the first to comment — discussion lives here without opening a PR. To propose an edit, use Start Contributing above.'
|
||||||
|
: 'No discussion yet. Sign in to comment. Discussion lives here without opening a PR; proposed edits still flow through PRs.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{activeMessages.map(msg => (
|
||||||
|
<DiscussionMessage key={msg.id} message={msg} />
|
||||||
|
))}
|
||||||
|
<div ref={bottomRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="discussion-composer">
|
||||||
|
{viewer ? (
|
||||||
|
<>
|
||||||
|
<textarea
|
||||||
|
className="discussion-composer-textarea"
|
||||||
|
value={composer}
|
||||||
|
onChange={e => setComposer(e.target.value)}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
placeholder={
|
||||||
|
activeThread?.label
|
||||||
|
? `Reply in "${activeThread.label}" — Cmd/Ctrl+Enter to send`
|
||||||
|
: 'Discuss this RFC — Cmd/Ctrl+Enter to send'
|
||||||
|
}
|
||||||
|
disabled={sending}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
<div className="discussion-composer-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-secondary"
|
||||||
|
onClick={handleNewThread}
|
||||||
|
disabled={sending}
|
||||||
|
title="Open a fresh discussion thread on this RFC"
|
||||||
|
>
|
||||||
|
New thread
|
||||||
|
</button>
|
||||||
|
{activeThread
|
||||||
|
&& activeThread.state === 'open'
|
||||||
|
&& (activeThread.created_by === viewer.user_id
|
||||||
|
|| viewer.role === 'owner'
|
||||||
|
|| viewer.role === 'admin') && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-link"
|
||||||
|
onClick={() => handleResolve(activeThread.id)}
|
||||||
|
disabled={sending}
|
||||||
|
title="Mark this discussion thread resolved"
|
||||||
|
>
|
||||||
|
Resolve
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-primary"
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={sending || !composer.trim()}
|
||||||
|
>
|
||||||
|
{sending ? 'Sending…' : 'Send'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="discussion-readonly">
|
||||||
|
Read-only — <a href="/auth/login">sign in</a> to join the discussion.
|
||||||
|
Discussion is in private <strong>Beta</strong>.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DiscussionMessage({ message }) {
|
||||||
|
const isSystem = message.role === 'system'
|
||||||
|
if (isSystem) {
|
||||||
|
return (
|
||||||
|
<div className="discussion-message system">
|
||||||
|
<div className="discussion-system-bubble">{message.text}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className={`discussion-message ${message.role}`}>
|
||||||
|
<div className="discussion-message-meta">
|
||||||
|
<span className="discussion-message-author">
|
||||||
|
@{message.author_login || '—'}
|
||||||
|
</span>
|
||||||
|
<span className="discussion-message-time">
|
||||||
|
{formatTimestamp(message.created_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{message.quote && (
|
||||||
|
<div className="discussion-message-quote">"{message.quote}"</div>
|
||||||
|
)}
|
||||||
|
<div className="discussion-message-body">{message.text}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(ts) {
|
||||||
|
if (!ts) return ''
|
||||||
|
try {
|
||||||
|
const d = new Date(ts + (ts.endsWith('Z') ? '' : 'Z'))
|
||||||
|
return d.toLocaleString()
|
||||||
|
} catch {
|
||||||
|
return ts
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,7 @@ import MarkdownPreview from './MarkdownPreview.jsx'
|
|||||||
import SelectionTooltip from './SelectionTooltip.jsx'
|
import SelectionTooltip from './SelectionTooltip.jsx'
|
||||||
import PromptBar from './PromptBar.jsx'
|
import PromptBar from './PromptBar.jsx'
|
||||||
import ChatPanel from './ChatPanel.jsx'
|
import ChatPanel from './ChatPanel.jsx'
|
||||||
|
import RFCDiscussionPanel from './RFCDiscussionPanel.jsx'
|
||||||
import ChangePanel, { diffWords } from './ChangePanel.jsx'
|
import ChangePanel, { diffWords } from './ChangePanel.jsx'
|
||||||
import PRModal from './PRModal.jsx'
|
import PRModal from './PRModal.jsx'
|
||||||
import GraduateDialog from './GraduateDialog.jsx'
|
import GraduateDialog from './GraduateDialog.jsx'
|
||||||
@@ -759,17 +760,25 @@ export default function RFCView({ viewer }) {
|
|||||||
data-open={drawerOpen ? 'true' : 'false'}
|
data-open={drawerOpen ? 'true' : 'false'}
|
||||||
/>
|
/>
|
||||||
<div className={`right-panel${drawerOpen ? ' drawer-open' : ''}`} role="complementary">
|
<div className={`right-panel${drawerOpen ? ' drawer-open' : ''}`} role="complementary">
|
||||||
<ChatPanel
|
{/* v0.5.0 — on main, the right panel is the PR-less discussion
|
||||||
messages={messages}
|
* surface (threads.branch_name IS NULL). Branches keep their
|
||||||
threads={branchView.threads || []}
|
* existing branch-chat panel; contribution still requires
|
||||||
changes={changes}
|
* opening a PR from a branch via the Open PR affordance above. */}
|
||||||
branchName={branchParam}
|
{branchParam === 'main' ? (
|
||||||
isStreaming={isStreaming}
|
<RFCDiscussionPanel slug={slug} viewer={viewer} />
|
||||||
contributionMode={mode === 'contribute'}
|
) : (
|
||||||
onStartContribution={handleStartContributing}
|
<ChatPanel
|
||||||
onScrollToChange={setFocusedChangeId}
|
messages={messages}
|
||||||
onResolveThread={handleResolveThread}
|
threads={branchView.threads || []}
|
||||||
/>
|
changes={changes}
|
||||||
|
branchName={branchParam}
|
||||||
|
isStreaming={isStreaming}
|
||||||
|
contributionMode={mode === 'contribute'}
|
||||||
|
onStartContribution={handleStartContributing}
|
||||||
|
onScrollToChange={setFocusedChangeId}
|
||||||
|
onResolveThread={handleResolveThread}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{mode === 'contribute' && (changes.length > 0 || manualPending) && (
|
{mode === 'contribute' && (changes.length > 0 || manualPending) && (
|
||||||
<ChangePanel
|
<ChangePanel
|
||||||
changes={changes}
|
changes={changes}
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
// TurnstileWidget.jsx — v0.12.0 / roadmap item #10.
|
||||||
|
//
|
||||||
|
// Renders the CloudFlare Turnstile JS widget on the email-entry step of
|
||||||
|
// `/login`. Reads the site key from `import.meta.env.VITE_TURNSTILE_SITE_KEY`
|
||||||
|
// (Vite convention — VITE_* prefix is build-time embedded). When the
|
||||||
|
// site key is unset/empty, this component renders nothing and reports
|
||||||
|
// a `null` token through `onToken` so the parent form can still submit.
|
||||||
|
// The backend's `TURNSTILE_REQUIRED` policy decides what happens to a
|
||||||
|
// request that arrives without a token; the frontend is intentionally
|
||||||
|
// not in that loop. See `backend/app/turnstile.py` for the matrix.
|
||||||
|
//
|
||||||
|
// The CloudFlare script is loaded once per page on first widget mount.
|
||||||
|
// Subsequent mounts (e.g. user goes back to email-entry after a failed
|
||||||
|
// OTC request) reuse the script tag and re-render the widget on the
|
||||||
|
// fresh container `div`. Unmounting removes the widget instance via
|
||||||
|
// `turnstile.remove(widgetId)` so a remount produces a new challenge
|
||||||
|
// rather than reusing a stale, already-consumed token.
|
||||||
|
//
|
||||||
|
// Turnstile contract:
|
||||||
|
// * `data-callback` fires with the token string on a successful
|
||||||
|
// challenge; the token is single-use and expires after ~5 minutes.
|
||||||
|
// * `data-error-callback` fires on a failed challenge (network,
|
||||||
|
// blocked, etc.); we surface a `null` token so the parent shows
|
||||||
|
// a retry hint.
|
||||||
|
// * `data-expired-callback` fires when the token times out before
|
||||||
|
// submission; we also drop to `null` and re-render so the user
|
||||||
|
// gets a fresh challenge on retry.
|
||||||
|
//
|
||||||
|
// We do **not** import the CloudFlare script at build time; loading it
|
||||||
|
// dynamically here keeps the bundle clean of an external request the
|
||||||
|
// page may not need (anonymous viewers reading RFCs never see Login).
|
||||||
|
|
||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
const TURNSTILE_SCRIPT_URL = 'https://challenges.cloudflare.com/turnstile/v0/api.js'
|
||||||
|
const SITE_KEY = import.meta.env.VITE_TURNSTILE_SITE_KEY || ''
|
||||||
|
|
||||||
|
// Promise-keyed: only one script tag, only one resolution chain.
|
||||||
|
let scriptLoadPromise = null
|
||||||
|
|
||||||
|
function loadTurnstileScript() {
|
||||||
|
if (typeof window === 'undefined') return Promise.resolve(null)
|
||||||
|
if (window.turnstile) return Promise.resolve(window.turnstile)
|
||||||
|
if (scriptLoadPromise) return scriptLoadPromise
|
||||||
|
|
||||||
|
scriptLoadPromise = new Promise((resolve, reject) => {
|
||||||
|
const existing = document.querySelector(`script[src="${TURNSTILE_SCRIPT_URL}"]`)
|
||||||
|
if (existing) {
|
||||||
|
existing.addEventListener('load', () => resolve(window.turnstile))
|
||||||
|
existing.addEventListener('error', reject)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const script = document.createElement('script')
|
||||||
|
script.src = TURNSTILE_SCRIPT_URL
|
||||||
|
script.async = true
|
||||||
|
script.defer = true
|
||||||
|
script.addEventListener('load', () => resolve(window.turnstile))
|
||||||
|
script.addEventListener('error', reject)
|
||||||
|
document.head.appendChild(script)
|
||||||
|
})
|
||||||
|
return scriptLoadPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
export function turnstileEnabled() {
|
||||||
|
return !!SITE_KEY
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TurnstileWidget({ onToken, theme = 'auto' }) {
|
||||||
|
const containerRef = useRef(null)
|
||||||
|
const widgetIdRef = useRef(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!SITE_KEY) {
|
||||||
|
// No site key configured → surface a null token immediately so
|
||||||
|
// the parent form's submit-disabled gate doesn't lock up
|
||||||
|
// waiting on a challenge that will never arrive. The backend
|
||||||
|
// decides whether a tokenless request is admitted.
|
||||||
|
onToken?.(null)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
loadTurnstileScript()
|
||||||
|
.then(turnstile => {
|
||||||
|
if (cancelled || !turnstile || !containerRef.current) return
|
||||||
|
widgetIdRef.current = turnstile.render(containerRef.current, {
|
||||||
|
sitekey: SITE_KEY,
|
||||||
|
theme,
|
||||||
|
callback: token => onToken?.(token),
|
||||||
|
'error-callback': () => onToken?.(null),
|
||||||
|
'expired-callback': () => onToken?.(null),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Script load failure — surface null so the parent can decide
|
||||||
|
// what to do (today: still let submit through; the backend
|
||||||
|
// policy decides admission).
|
||||||
|
if (!cancelled) onToken?.(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
if (widgetIdRef.current && window.turnstile) {
|
||||||
|
try {
|
||||||
|
window.turnstile.remove(widgetIdRef.current)
|
||||||
|
} catch (_) {
|
||||||
|
// Already gone or never registered — nothing to clean up.
|
||||||
|
}
|
||||||
|
widgetIdRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// We intentionally do not list `onToken` in the dependency array;
|
||||||
|
// a parent re-rendering with a fresh closure should not tear down
|
||||||
|
// and rebuild the widget (which would consume a fresh challenge).
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (!SITE_KEY) return null
|
||||||
|
return <div ref={containerRef} className="turnstile-widget" />
|
||||||
|
}
|
||||||
@@ -0,0 +1,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()
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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 → Privacy & 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 → Privacy & 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user