Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 213f6862d5 | |||
| 1456c8b73f | |||
| ee4925b6ac | |||
| 72f8457933 | |||
| b3f1b15f65 | |||
| 6fb68a95c7 | |||
| 7872b921ed | |||
| de28272914 | |||
| 55beba5c0a | |||
| ca8ba69acb |
+1402
-4
File diff suppressed because it is too large
Load Diff
+407
@@ -0,0 +1,407 @@
|
||||
# Contributing to rfc-app
|
||||
|
||||
`rfc-app` is the framework that hosts RFC-shaped collections of
|
||||
documents — one repo per RFC, a meta repo per collection, a web app
|
||||
that turns the Git substrate into a writeable surface. The Open
|
||||
Human Model (OHM) deployment at `ohm.wiggleverse.org` is one
|
||||
instance. The framework is intended to host more.
|
||||
|
||||
This document explains how to propose a change to the framework
|
||||
itself — a new endpoint, a schema migration, a UI affordance, a
|
||||
spec clarification. For changes to *content* hosted by a specific
|
||||
deployment (the OHM RFCs, the OHM roadmap), see that deployment's
|
||||
own contribution guide (e.g. [`ohm-rfc/CONTRIBUTING.md`](https://git.wiggleverse.org/wiggleverse/ohm-rfc/src/branch/main/CONTRIBUTING.md)).
|
||||
|
||||
---
|
||||
|
||||
## How the project actually evolves
|
||||
|
||||
rfc-app is built in the open in the literal sense: **every build
|
||||
session produces a full transcript** at
|
||||
[`wiggleverse/ohm-session-history`](https://git.wiggleverse.org/wiggleverse/ohm-session-history)
|
||||
on `git.wiggleverse.org`. The transcripts are the authoritative
|
||||
record of how the framework got from one release to the next — the
|
||||
decisions, the friction, the dead ends, the reasoning. They are not
|
||||
curated retrospectives; wrong turns stay in.
|
||||
|
||||
If you are proposing a change to rfc-app, **read at least the most
|
||||
recent session transcript before opening a PR.** The transcripts
|
||||
show what shape a feature lands in, where the spec gets touched,
|
||||
what the operator pushes back on, and how the release rides into
|
||||
deployment. A PR that matches that texture is much more likely to
|
||||
land cleanly than one shaped by the README alone.
|
||||
|
||||
Worked examples to start with:
|
||||
|
||||
- **Session E** ([transcript](https://git.wiggleverse.org/wiggleverse/ohm-session-history)) —
|
||||
a clean small release. Read this for the simplest possible release
|
||||
shape: one feature, one version bump, one upgrade-steps block, no
|
||||
surprises.
|
||||
- **Session I** — recovery from a deploy fault. Read this for how
|
||||
the project handles things going wrong mid-deploy, and for the
|
||||
honest no-curation discipline.
|
||||
- **Session K** — a multi-feature wave with one item paused on an
|
||||
operator-provided secret. Read this for the subagent dispatch
|
||||
pattern (the model the project uses to ship multiple features in
|
||||
parallel), and for the binding rule that the assistant **never**
|
||||
asks the operator to paste secret bytes into the conversation.
|
||||
- **Session L** — squash-merge integration across three parallel
|
||||
features (v0.15.0 / v0.16.0 / v0.17.0), with `#21 Part C`
|
||||
identity-lifecycle Amplitude wiring folded inline across all
|
||||
three releases. Read this for how cross-cutting concerns (analytics,
|
||||
observability) get layered into already-in-flight features
|
||||
without scope-creeping any single release.
|
||||
|
||||
The repository where transcripts live —
|
||||
[`wiggleverse/ohm-session-history`](https://git.wiggleverse.org/wiggleverse/ohm-session-history) —
|
||||
is the canonical history. The `git log` of `rfc-app` is the artifact;
|
||||
the transcripts are the story behind it.
|
||||
|
||||
---
|
||||
|
||||
## How a contribution flows
|
||||
|
||||
The framework runs on a **subagents push feature branches; operator
|
||||
tags and deploys** model. Contributors — whether human or AI agents
|
||||
running in a Claude Code subsession — open feature branches and
|
||||
submit PRs. The operator (the person running the deployment) is the
|
||||
one who merges, tags, bumps `VERSION`, runs `flotilla deploy` (or
|
||||
the equivalent for non-OHM deployments), and moves the deployment's
|
||||
`.rfc-app-version` pin. The driver session transcripts inherit
|
||||
this shape; contributors inherit it from them.
|
||||
|
||||
Concretely:
|
||||
|
||||
1. Read the most recent session transcript. Understand what just
|
||||
shipped and what is in flight.
|
||||
2. Open an Issue first if your change is exploratory, structural,
|
||||
or might overlap with in-flight work. The operator will name
|
||||
any collision.
|
||||
3. Branch from `main`. Name the branch
|
||||
`feature/<short-description>` for additive work, `fix/<short-
|
||||
description>` for bug fixes, `docs/<short-description>` for
|
||||
documentation-only work. The driver sessions use
|
||||
`feature/v<target-version>-<slug>` (e.g.
|
||||
`feature/v0.16.0-owner-invite`) — that shape is welcome but not
|
||||
required for outside contributors, since contributors do not
|
||||
pick the target version.
|
||||
4. **Do not bump `VERSION` or `frontend/package.json#version` in
|
||||
your PR.** The operator picks the target version at integration
|
||||
time; bumping ahead causes cherry-pick conflicts. The same
|
||||
applies to the `CHANGELOG.md` entry header — see below.
|
||||
5. **Do not tag releases, do not run any deploy gesture, do not
|
||||
touch any deployment's `.rfc-app-version` pin.** The operator
|
||||
alone owns those gestures. (For OHM specifically: "I'm the only
|
||||
one that gets to yolo." See the boundary section in
|
||||
`ohm-rfc/CONTRIBUTING.md`.)
|
||||
6. Push your branch and open a PR. Describe what you're proposing
|
||||
and why, in language the operator can paste into the eventual
|
||||
release commit. If the change touches `SPEC.md`, name which
|
||||
section(s) and the contract change.
|
||||
|
||||
---
|
||||
|
||||
## CHANGELOG convention: strict descending
|
||||
|
||||
`CHANGELOG.md` is ordered **newest-on-top**. The header line for
|
||||
the in-progress version goes at the top of the file; older
|
||||
releases descend below it. This is the binding convention; the
|
||||
operator hand-resolves the conflict when two parallel feature
|
||||
branches both insert at the top of the file (the squash-merge
|
||||
integration that ships parallel-feature waves keeps the strict-
|
||||
descending shape — see Session K for the cherry-pick mechanics and
|
||||
Session L for the hand-resolved-with-a-small-script variant).
|
||||
|
||||
A new entry has this shape (read the existing 0.15.0 / 0.16.0 /
|
||||
0.17.0 entries for worked examples):
|
||||
|
||||
```markdown
|
||||
## 0.X.Y — YYYY-MM-DD
|
||||
|
||||
**Minor — schema migration auto-applied; no operator action.** This
|
||||
release ships <one or two sentences naming the feature and why>.
|
||||
|
||||
### Added
|
||||
- **<New module/endpoint/component>** — what it does, where it lives,
|
||||
why it exists. Include file paths inline so a reader can click through.
|
||||
### Changed
|
||||
- **<Existing surface>** — what changed and how a deployment notices.
|
||||
### Migration
|
||||
- **`<NNN_name>.sql`** — auto-applied by `db.run_migrations()` on
|
||||
backend start. <Describe the schema delta in one sentence.>
|
||||
### Upgrade steps (from 0.(X-1).Y)
|
||||
- You **MUST** … (per RFC 2119; see SPEC.md §20.4).
|
||||
- You **MUST NOT** …
|
||||
- You **SHOULD** …
|
||||
- You **MAY** …
|
||||
```
|
||||
|
||||
The header version number is filled in by the operator at merge
|
||||
time. Your PR's CHANGELOG diff can leave the version as
|
||||
`0.X.Y — YYYY-MM-DD` (literal placeholder), or use a guessed value
|
||||
the operator overwrites; either is fine.
|
||||
|
||||
---
|
||||
|
||||
## `Upgrade steps:` blocks use RFC 2119 keywords
|
||||
|
||||
If your change requires deployments to do anything when they
|
||||
upgrade — set an env var, apply a migration, restart a process,
|
||||
flip an overlay value, accept a behavioral change — your CHANGELOG
|
||||
entry **must** include an `### Upgrade steps` block, and that
|
||||
block **must** use the [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119)
|
||||
/ [RFC 8174](https://www.rfc-editor.org/rfc/rfc8174) keywords as
|
||||
defined in `SPEC.md` §20.4:
|
||||
|
||||
- **MUST** / **SHALL** / **REQUIRED** — without this step the
|
||||
deployment will not function correctly. Skipping is a regression
|
||||
the framework does not handle.
|
||||
- **MUST NOT** / **SHALL NOT** — previously valid, now no longer
|
||||
supported.
|
||||
- **SHOULD** / **RECOMMENDED** — the framework's tested path. A
|
||||
deployment may deviate when it has a reason.
|
||||
- **SHOULD NOT** / **NOT RECOMMENDED** — discouraged without being
|
||||
forbidden.
|
||||
- **MAY** / **OPTIONAL** — an affordance you can take or skip.
|
||||
|
||||
Cross-version upgrades (jumping more than one minor) are computed by
|
||||
the operator composing each intervening release's steps in order.
|
||||
Each adjacent step must therefore be locally unambiguous — this is
|
||||
the whole reason the keyword discipline is binding. Avoid words
|
||||
like "should probably" or "might want to" inside an upgrade step;
|
||||
either the framework needs the action or it doesn't.
|
||||
|
||||
If your change touches the env contract, **also update**
|
||||
`backend/.env.example` and/or `frontend/.env.example` in the same
|
||||
PR so the contract and the documentation land together (§20.4).
|
||||
|
||||
---
|
||||
|
||||
## SPEC.md and §19.2 candidates
|
||||
|
||||
`SPEC.md` is the framework's binding spec. It is honest about open
|
||||
questions — large sections of it carry "§19.2 candidates," which
|
||||
are decisions the project has deliberately deferred rather than
|
||||
guessed at.
|
||||
|
||||
The discipline: **architectural or process deferrals get noted as
|
||||
§19.2 candidates rather than scope-creeping a release.** When you
|
||||
notice that your change opens a question larger than the change
|
||||
itself (a different DB shape, a new auth contract, a cross-cutting
|
||||
UX rethink), the right move is usually to land the narrow change
|
||||
and add a §19.2 candidate naming the larger question. The candidate
|
||||
documents what was set aside and why, so a future session can pick
|
||||
it up with context.
|
||||
|
||||
Worked examples from recent sessions:
|
||||
|
||||
- v0.11.0 (Session K) shipped device trust and surfaced three new
|
||||
§19.2 candidates: cross-device session revocation, password-
|
||||
equivalent change invalidating trust, device-trust window
|
||||
tunables via env. None of those were in the v0.11.0 scope; they
|
||||
were noted in SPEC.md §19.2 so a future session can address them
|
||||
on their own terms.
|
||||
- v0.15.0 (Session L) shipped the Amplitude wrapper and added
|
||||
candidates around session-replay-specific consent category +
|
||||
bundle-size measurement, both deferred to the future Part-A audit.
|
||||
|
||||
When you spot a deferred decision in your PR's territory, name it
|
||||
in your PR description and add it to `SPEC.md` §19.2 in the same
|
||||
diff. Do not silently expand scope to settle it.
|
||||
|
||||
---
|
||||
|
||||
## Test-coverage expectations
|
||||
|
||||
The backend has the load-bearing test suite at
|
||||
`backend/tests/`. Tests are organized as `*_vertical.py` files,
|
||||
each covering one feature end-to-end through the FastAPI app
|
||||
(provisioning fixtures, hitting the HTTP surface, asserting on the
|
||||
database state). At time of writing, the suite is ~250 tests across
|
||||
~25 files. Examples:
|
||||
|
||||
- `test_admin_create_user_invite_vertical.py` — v0.17.0's
|
||||
admin-create user + invite + claim flow, 15 tests covering happy
|
||||
path + every refusal shape + the audit-trail row.
|
||||
- `test_rfc_invitations_vertical.py` — v0.16.0's per-RFC invite +
|
||||
accept flow, 18 tests.
|
||||
- `test_device_trust_vertical.py` — v0.11.0's 30-day device trust,
|
||||
14 tests including cookie shape, hash-vs-raw-token discipline,
|
||||
expired / revoked / forged / cross-user invariants.
|
||||
|
||||
Expected coverage for a new feature:
|
||||
|
||||
- **Backend feature** — one new `test_<feature>_vertical.py` file
|
||||
that covers the happy path, every documented refusal/error code,
|
||||
and any cross-surface effect (rows the feature writes to existing
|
||||
tables, fields it adds to existing endpoints). Reuse fixtures
|
||||
from neighboring test files (e.g. `test_propose_vertical.py`'s
|
||||
`FakeGitea` is widely reused).
|
||||
- **Migration** — verify migrations are reachable from `backend/.venv`
|
||||
before pushing: `cd backend && PYTHONPATH=. .venv/bin/pytest -q`
|
||||
exercises `db.run_migrations()` through the fixture setup.
|
||||
- **Frontend feature** — there is currently no frontend test
|
||||
runner. The discipline is: keep the change ships-clean
|
||||
(`cd frontend && npm run build` succeeds), and the backend
|
||||
vertical test exercises the HTTP contract the frontend
|
||||
consumes, which is the meaningful behavioral guarantee.
|
||||
Frontend changes that ride along with a backend feature land
|
||||
with the backend test as the regression boundary.
|
||||
- **Bug fix** — add a regression test in the same vertical file
|
||||
that proves the original failure mode and verifies the fix.
|
||||
|
||||
Run the backend suite before pushing. From `backend/`:
|
||||
|
||||
```bash
|
||||
PYTHONPATH=. .venv/bin/pytest -q
|
||||
```
|
||||
|
||||
(The `PYTHONPATH=.` is a known ergonomic gap — see SPEC.md §19.2
|
||||
candidate; the suite does not pick up `app/` without it.)
|
||||
|
||||
If your PR doesn't include tests, the operator will ask for them
|
||||
before merge unless the change is genuinely test-irrelevant
|
||||
(documentation, comments, dev-only tooling).
|
||||
|
||||
---
|
||||
|
||||
## Analytics instrumentation checklist
|
||||
|
||||
> *(This section codifies `ohm-rfc/ROADMAP.md` #21 Part B's
|
||||
> CONTRIBUTING checklist. It is discipline, not a gate — but the
|
||||
> operator will push back on PRs that skip it.)*
|
||||
|
||||
If your PR adds or changes a user-facing feature, walk this
|
||||
checklist before opening the PR. The instrumentation conventions
|
||||
themselves are specified in `SPEC.md` §21 (Analytics instrumentation
|
||||
and identity); this section is the procedural reminder.
|
||||
|
||||
1. **What named event(s) does this feature need?**
|
||||
Open `frontend/src/lib/analytics.js` and look at the `EVENTS`
|
||||
constant. Does an existing event cover your feature? If not, is
|
||||
the new event in the spec's "Subject Verb" Title Case form
|
||||
(`Comment Posted`, `Invitation Sent`)? Are the prop families
|
||||
consistent with SPEC.md §21's required-prop catalog (opaque
|
||||
ids only, no PII, enums lowercased like `'otc'` not `'OTC'`)?
|
||||
|
||||
2. **Do interactive elements have stable text / ARIA labels /
|
||||
`data-amp-track-*` so autocapture is meaningful?**
|
||||
The frontend ships `autocapture: true`, which instruments
|
||||
every click and form interaction. The *value* of those events
|
||||
depends on the DOM the SDK sees: a `<button>` with stable
|
||||
visible text or an `aria-label` shows up as a meaningful
|
||||
dashboard row; an icon-only `<button>` with no label shows up
|
||||
as garbage. New components that introduce interactive elements
|
||||
should either carry meaningful labels (visible text or ARIA) or
|
||||
carry a `data-amp-track-name="<Stable Name>"` attribute. For
|
||||
repeated rows (per-RFC lists, comment lists), use a stable
|
||||
`data-amp-track-*` identifier so per-row click counts aggregate
|
||||
to the row's identity rather than to a generic label.
|
||||
|
||||
3. **Does any new form field need replay masking?**
|
||||
Session replay records at `sampleRate: 1` (100% of consented
|
||||
sessions). New form inputs that capture passwords, OTC codes,
|
||||
tokens, magic-link URLs, or other secret/credential-equivalent
|
||||
material **MUST** be masked with Amplitude's masking conventions
|
||||
(the `.amp-mask` class or the `data-amp-mask` attribute,
|
||||
whichever the wrapper integration expects in this version).
|
||||
New inputs that capture arguably-PII (email, real name, free-
|
||||
text drafts) **SHOULD** also be masked; if a deliberate
|
||||
un-masking decision is taken, document it in the PR description
|
||||
and in `SPEC.md` §21.
|
||||
|
||||
4. **Does the PR description name the instrumentation decisions?**
|
||||
A one-sentence summary in the PR description — "fires
|
||||
`Comment Posted` with `{rfc_slug, comment_id}`; no new form
|
||||
fields, no new replay-masking concerns" — is enough. If the
|
||||
decision is "we chose not to instrument this," say that too;
|
||||
the absence of an event is itself a decision the operator
|
||||
wants visible. The relevant SPEC chapter (§21) is the binding
|
||||
reference for what shapes are correct.
|
||||
|
||||
If your feature touches an identity-meaningful surface (sign-in,
|
||||
sign-out, invite-claim, role change, account state change), also
|
||||
walk the **identity lifecycle** contract in SPEC.md §21.6: every
|
||||
new claim/sign-in path **MUST** call `identify({ user_id, properties })`
|
||||
BEFORE the first `track()` event on that surface, so the Amplitude
|
||||
user record is created with the OHM user_id from the very first
|
||||
event rather than as an anonymous device that retroactively links.
|
||||
v0.16.0's `AcceptInvitation.jsx` and v0.17.0's `InviteClaim.jsx`
|
||||
are the worked examples; mirror their shape.
|
||||
|
||||
---
|
||||
|
||||
## The operator-only gestures
|
||||
|
||||
Some gestures are operator-only. Contributors do not perform them;
|
||||
PRs that perform them get rejected on principle, not on merit:
|
||||
|
||||
- **Tagging a release** (`git tag v0.X.Y` + `git push --tags`).
|
||||
- **Pushing to `main`** after merge (the operator merges; the
|
||||
framework's `main` branch tracks releases the operator has
|
||||
shipped).
|
||||
- **Bumping `VERSION` and `frontend/package.json#version` to the
|
||||
shipped value.** The operator does this at integration time so
|
||||
the version line is consistent across the release commit.
|
||||
- **Running `flotilla deploy` or any equivalent deployment gesture**
|
||||
in any deployment of rfc-app. Contributors do not deploy.
|
||||
- **Moving a deployment's `.rfc-app-version` pin.** That pin lives
|
||||
in the deployment's content repo (e.g. `ohm-rfc/.rfc-app-version`)
|
||||
and is moved by the deployment's operator. Contributors to that
|
||||
deployment do not move it; contributors to the framework
|
||||
certainly do not.
|
||||
- **Setting secrets** (anywhere — Secret Manager, env files,
|
||||
`flotilla secret set`, vendor dashboards, anything). The
|
||||
binding rule baked in mid-Session-K is: **the assistant never
|
||||
asks the operator to paste secret bytes into a conversation,
|
||||
even as one offered option**. The corollary for contributors:
|
||||
do not include secret values in PR descriptions, commit
|
||||
messages, or issue comments. Reference secrets by their binding
|
||||
name (`SMTP_PASSWORD`, `AMPLITUDE_API_KEY`) and let the
|
||||
operator handle the bytes.
|
||||
|
||||
If your change requires a new secret or env var, document the
|
||||
requirement in the CHANGELOG `### Upgrade steps` block in the
|
||||
RFC 2119 form ("operators **MUST** set `<NEW_VAR>` ...") and
|
||||
update the `*.env.example` file. The operator will run the
|
||||
secret/overlay-set gesture themselves at deploy time.
|
||||
|
||||
---
|
||||
|
||||
## When in doubt
|
||||
|
||||
- **Open an Issue first.** Especially for any change that touches
|
||||
SPEC.md, the auth/permissions model (§6), the storage shape (§4
|
||||
/ §5), or the deploy contract (§20). The operator (or a future
|
||||
driver session) will name what they want before you write code.
|
||||
- **Read the most recent session transcript.** It will tell you
|
||||
what shipped last and what's in flight.
|
||||
- **Cite SPEC.md sections in your PR description.** "Touches §15.4
|
||||
(per-category email toggles) and adds §19.2 candidate around
|
||||
per-channel mute granularity" gives the operator a map of where
|
||||
to read.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
The framework is released under the MIT License (see
|
||||
[`LICENSE`](./LICENSE)). By contributing, you agree your work
|
||||
ships under those terms.
|
||||
|
||||
---
|
||||
|
||||
## See also
|
||||
|
||||
- [`SPEC.md`](./SPEC.md) — the framework's binding spec. §19.2
|
||||
is the deferred-decisions queue; §20 is the versioning + deploy
|
||||
contract; §21 is the analytics instrumentation contract.
|
||||
- [`CHANGELOG.md`](./CHANGELOG.md) — release history in strict
|
||||
descending order. Read recent entries for the shape your PR's
|
||||
release-commit will take.
|
||||
- [`PHILOSOPHY.md`](./PHILOSOPHY.md) — what the framework is for.
|
||||
PRs whose shape conflicts with the philosophy get a longer
|
||||
conversation than PRs that fit.
|
||||
- [`wiggleverse/ohm-session-history`](https://git.wiggleverse.org/wiggleverse/ohm-session-history)
|
||||
— the authoritative record of how the project has actually
|
||||
evolved, session by session.
|
||||
@@ -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`.
|
||||
@@ -92,3 +92,21 @@ OTC_TTL_MINUTES=10
|
||||
# loud-failure shape so the abuse path is visible). Set to 0 to
|
||||
# disable the cooldown — useful for tests but never in production.
|
||||
OTC_REQUEST_COOLDOWN_SECONDS=60
|
||||
|
||||
# --- v0.12.0: CloudFlare Turnstile gate on OTC dispatch (§6.2, item #10) ---
|
||||
# Provision a Turnstile site at dash.cloudflare.com → Turnstile → Add
|
||||
# site. The site key (public) goes in `frontend/.env` as
|
||||
# VITE_TURNSTILE_SITE_KEY. The secret key (private) goes here and is
|
||||
# what the backend POSTs to /siteverify alongside the user's response
|
||||
# token. Leave both unset for dev/test paths; the gate stays open when
|
||||
# the secret is absent AND TURNSTILE_REQUIRED=false (the default).
|
||||
CLOUDFLARE_TURNSTILE_SECRET=
|
||||
|
||||
# When `true`, /auth/otc/request fails closed (HTTP 500 "auth
|
||||
# misconfigured") if CLOUDFLARE_TURNSTILE_SECRET is unset. When `false`
|
||||
# (the default), a missing secret skips verification — useful in dev
|
||||
# and during the pre-rollout window when the operator hasn't wired
|
||||
# the secret yet. Flip to `true` once the secret is wired so a future
|
||||
# config drift surfaces as a loud 500 rather than a silent abuse-
|
||||
# defense disablement.
|
||||
TURNSTILE_REQUIRED=false
|
||||
|
||||
@@ -22,14 +22,18 @@ from . import (
|
||||
api_branches,
|
||||
api_discussion,
|
||||
api_graduation,
|
||||
api_invitations,
|
||||
api_notifications,
|
||||
api_prs,
|
||||
auth,
|
||||
db,
|
||||
device_trust as device_trust_mod,
|
||||
docs as docs_mod,
|
||||
entry as entry_mod,
|
||||
cache,
|
||||
funder,
|
||||
health,
|
||||
notify,
|
||||
philosophy,
|
||||
providers as providers_mod,
|
||||
)
|
||||
@@ -55,6 +59,17 @@ class FunderCredentialBody(BaseModel):
|
||||
api_key: str = Field(min_length=1, max_length=2048)
|
||||
|
||||
|
||||
class BetaRequestBody(BaseModel):
|
||||
# v0.8.0 — captured on the first OTC sign-in. All three fields are
|
||||
# required so the admin queue has a coherent triage shape.
|
||||
# The bounds match the v0.7.0 OTC body (320 chars for email-ish
|
||||
# headers; 4000 for the free-text reason — the same upper bound
|
||||
# DeclineBody uses elsewhere in this file).
|
||||
first_name: str = Field(min_length=1, max_length=120)
|
||||
last_name: str = Field(min_length=1, max_length=120)
|
||||
beta_request_reason: str = Field(min_length=1, max_length=4000)
|
||||
|
||||
|
||||
def make_router(
|
||||
config: Config,
|
||||
gitea: Gitea,
|
||||
@@ -88,6 +103,12 @@ def make_router(
|
||||
# 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())
|
||||
# v0.16.0 (roadmap item #12): owner-only invite for per-RFC
|
||||
# contribution + discussion. The RFC's owner can invite specific
|
||||
# users by email to either open PRs or join the discussion; non-
|
||||
# invited users keep read access but cannot write (v0.6.0
|
||||
# contract extended to per-RFC scope).
|
||||
router.include_router(api_invitations.make_router())
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# §17: /api/health — unauthenticated post-flight probe.
|
||||
@@ -111,6 +132,17 @@ def make_router(
|
||||
payload = philosophy.load()
|
||||
return {"body": payload["body"]}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# /api/docs — DOCS.md served verbatim. Sibling of /api/philosophy:
|
||||
# no auth gate, same disk-first load + cache shape, same intent —
|
||||
# public read surface for a markdown file checked into the repo.
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.get("/api/docs")
|
||||
async def get_docs() -> dict[str, Any]:
|
||||
payload = docs_mod.load()
|
||||
return {"body": payload["body"]}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Auth surface — reads role from our users table per §6.
|
||||
# ---------------------------------------------------------------
|
||||
@@ -120,6 +152,29 @@ def make_router(
|
||||
user = auth.current_user(request)
|
||||
if user is None:
|
||||
return {"authenticated": False, "user": None}
|
||||
# v0.8.0 + v0.10.0: single round-trip for everything the
|
||||
# frontend gates UI off of — beta-access state + passcode state.
|
||||
row = db.conn().execute(
|
||||
"SELECT first_name, last_name, beta_request_reason, "
|
||||
"passcode_hash, passcode_set_at "
|
||||
"FROM users WHERE id = ?",
|
||||
(user.user_id,),
|
||||
).fetchone()
|
||||
first_name = (row["first_name"] if row else None) or ""
|
||||
last_name = (row["last_name"] if row else None) or ""
|
||||
beta_request_reason = (row["beta_request_reason"] if row else None) or ""
|
||||
# "Needs profile" iff the user is pending AND hasn't yet
|
||||
# filed their beta-request capture. Granted users never see
|
||||
# the capture prompt; pending users who already filed see
|
||||
# the /beta-pending page without the capture form.
|
||||
needs_profile = (
|
||||
user.permission_state == "pending"
|
||||
and not first_name
|
||||
and not last_name
|
||||
and not beta_request_reason
|
||||
)
|
||||
has_passcode = bool(row and row["passcode_hash"])
|
||||
passcode_set_at = row["passcode_set_at"] if (row and has_passcode) else None
|
||||
return {
|
||||
"authenticated": True,
|
||||
"user": {
|
||||
@@ -129,9 +184,144 @@ def make_router(
|
||||
"email": user.email,
|
||||
"avatar_url": user.avatar_url,
|
||||
"role": user.role,
|
||||
"permission_state": user.permission_state,
|
||||
"first_name": first_name,
|
||||
"last_name": last_name,
|
||||
"beta_request_reason": beta_request_reason,
|
||||
"needs_profile": needs_profile,
|
||||
"has_passcode": has_passcode,
|
||||
"passcode_set_at": passcode_set_at,
|
||||
},
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# v0.8.0: /api/auth/me/beta-request — first-OTC profile capture
|
||||
# (roadmap item #6). Lands first name, last name, and the free-
|
||||
# text "why I should be included in the beta" on the signed-in
|
||||
# user's row. Idempotent for the same already-pending user;
|
||||
# refuses to overwrite a row that's already granted (so a
|
||||
# bored already-granted user can't accidentally re-submit the
|
||||
# form and clobber the admin's audit trail). Uses
|
||||
# `require_user` rather than `require_contributor` because
|
||||
# `require_contributor` already enforces `permission_state =
|
||||
# 'granted'` and would refuse a pending user; the whole point
|
||||
# of this endpoint is to register the request _from_ a pending
|
||||
# user.
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.post("/api/auth/me/beta-request")
|
||||
async def submit_beta_request(body: BetaRequestBody, request: Request) -> dict[str, Any]:
|
||||
user = auth.require_user(request)
|
||||
row = db.conn().execute(
|
||||
"SELECT permission_state, first_name, last_name, beta_request_reason FROM users WHERE id = ?",
|
||||
(user.user_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
# Defensive — the session pointed at a deleted row.
|
||||
raise HTTPException(404, "User not found")
|
||||
# Granted users have no business filing a beta request.
|
||||
# 'revoked' likewise — the request flow is for fresh users
|
||||
# only. Both shapes refuse with 409 (conflict) so the client
|
||||
# can distinguish "you already have access" from
|
||||
# "your access was revoked".
|
||||
if row["permission_state"] == "granted":
|
||||
raise HTTPException(409, "Your account is already granted access")
|
||||
if row["permission_state"] == "revoked":
|
||||
raise HTTPException(409, "Your account's access has been revoked")
|
||||
# Re-submission from a pending user updates the row — the
|
||||
# admin sees the latest text rather than a stale draft.
|
||||
# The state stays 'pending'; only an admin can flip it.
|
||||
db.conn().execute(
|
||||
"""
|
||||
UPDATE users
|
||||
SET first_name = ?,
|
||||
last_name = ?,
|
||||
beta_request_reason = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(
|
||||
body.first_name.strip(),
|
||||
body.last_name.strip(),
|
||||
body.beta_request_reason.strip(),
|
||||
user.user_id,
|
||||
),
|
||||
)
|
||||
# v0.9.0 (roadmap item #7): notify every admin/owner of the
|
||||
# fresh request. Only the first submission is the
|
||||
# "newly-pending" gesture — re-submits from the same user
|
||||
# would otherwise carpet the admin inbox. We fire only when
|
||||
# this is the row's first time getting all three fields
|
||||
# populated (the prior row carried at least one NULL).
|
||||
prior = row # captured before the UPDATE above
|
||||
was_already_complete = bool(
|
||||
prior["first_name"] and prior["last_name"] and prior["beta_request_reason"]
|
||||
)
|
||||
if not was_already_complete:
|
||||
notify.fan_out_new_beta_request(requester_user_id=user.user_id)
|
||||
return {"ok": True}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# v0.11.0: trust device for 30 days (§6.2, roadmap item #9).
|
||||
#
|
||||
# The mint path lives on the OAuth router (issuing the cookie is
|
||||
# coupled to OTC/passcode verify). This module owns the read/revoke
|
||||
# surface the /settings/devices page calls.
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.get("/api/auth/me/devices")
|
||||
async def list_my_devices(request: Request) -> dict[str, Any]:
|
||||
"""Active device-trust rows for the signed-in user.
|
||||
|
||||
Active = not revoked, not expired. The current request's
|
||||
device (if any) is *not* singled out here — the surface
|
||||
shows the same row shape for every device so the user can
|
||||
revoke any of them without the page leaking which row
|
||||
carries the cookie they're using right now.
|
||||
"""
|
||||
user = auth.require_user(request)
|
||||
rows = device_trust_mod.list_for_user(user.user_id)
|
||||
return {
|
||||
"items": [
|
||||
{
|
||||
"id": r.id,
|
||||
"created_at": r.created_at,
|
||||
"expires_at": r.expires_at,
|
||||
"last_seen_at": r.last_seen_at,
|
||||
"user_agent": r.user_agent,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
}
|
||||
|
||||
@router.delete("/api/auth/me/devices/{device_id}")
|
||||
async def revoke_my_device(device_id: int, request: Request) -> dict[str, Any]:
|
||||
"""Revoke a single device-trust row for the signed-in user.
|
||||
|
||||
The user-id scope is enforced in SQL so a hostile client
|
||||
cannot revoke another user's row by guessing ids. A row that
|
||||
doesn't exist, doesn't belong to this user, or is already
|
||||
revoked reads as 404 — the wrong-vs-already-revoked
|
||||
distinction would only help a probing client enumerate ids.
|
||||
"""
|
||||
user = auth.require_user(request)
|
||||
ok = device_trust_mod.revoke(user.user_id, device_id)
|
||||
if not ok:
|
||||
raise HTTPException(404, "Device not found")
|
||||
return {"ok": True}
|
||||
|
||||
@router.delete("/api/auth/me/devices")
|
||||
async def revoke_all_my_devices(request: Request) -> dict[str, Any]:
|
||||
"""Revoke every active device-trust row for the signed-in user.
|
||||
|
||||
The user's current request stays authenticated via its
|
||||
session cookie; the device-trust cookie carried on the
|
||||
current device is also revoked, but `rfc_session` keeps the
|
||||
request flow alive until sign-out / expiry.
|
||||
"""
|
||||
user = auth.require_user(request)
|
||||
count = device_trust_mod.revoke_all(user.user_id)
|
||||
return {"ok": True, "revoked": count}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# §7: the catalog
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
+425
-5
@@ -11,6 +11,8 @@ The endpoints in this module:
|
||||
- `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>/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/permission-events` — paged `permission_events` log
|
||||
- `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 pydantic import BaseModel, Field
|
||||
|
||||
from . import auth, db
|
||||
from . import auth, db, email_invite, invites
|
||||
from .config import Config
|
||||
from .email import EmailConfig
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -50,11 +53,47 @@ class MuteBody(BaseModel):
|
||||
muted: bool
|
||||
|
||||
|
||||
class PermissionStateBody(BaseModel):
|
||||
# v0.9.0: the admin flip from the user-management page (roadmap
|
||||
# item #7). `pending` is not surfaceable from the admin UI —
|
||||
# only the OTC verify path lands a row in `pending` — but we
|
||||
# accept it in the pattern in case a future restore-to-queue
|
||||
# gesture wants to re-pend a granted user; today the UI only
|
||||
# exposes `granted` and `revoked`.
|
||||
state: str = Field(pattern="^(pending|granted|revoked)$")
|
||||
|
||||
|
||||
class AllowlistAddBody(BaseModel):
|
||||
email: str = Field(min_length=3, max_length=320)
|
||||
note: str | None = Field(default=None, max_length=200)
|
||||
|
||||
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -68,15 +107,110 @@ def make_router(config: Config) -> APIRouter:
|
||||
|
||||
@router.get("/api/admin/users")
|
||||
async def list_users(request: Request) -> dict[str, Any]:
|
||||
"""v0.9.0: the user-management surface (roadmap item #7).
|
||||
|
||||
The listing carries every column the admin queue needs to triage
|
||||
pending beta-access requests alongside the existing role/mute
|
||||
affordances. Sort order surfaces pending requests first (so the
|
||||
admin lands on the inbox shape), then granted, then revoked;
|
||||
within a state, ownership/role and recency are the tiebreakers
|
||||
so the legacy ordering (owner first, then admin, then by name)
|
||||
is preserved inside the granted bucket.
|
||||
|
||||
`permission_decided_by_login` joins the deciding admin row so
|
||||
the UI can render "granted by @ben" without a second round-trip.
|
||||
|
||||
v0.16.0 (roadmap item #12) additive: each user row now carries
|
||||
an `rfc_invitations` array — the per-RFC invitations the user
|
||||
has accepted. This is the "permission-grant requests from
|
||||
invited users" hook the roadmap text calls for: when a user
|
||||
accepts a per-RFC invite and they're not yet platform-granted,
|
||||
the admin sees "here because @ben invited them to <RFC> as
|
||||
<role>" alongside their pending row, informing (not deciding)
|
||||
the platform grant. The two write surfaces remain distinct —
|
||||
the RFC's owner controls per-RFC roles; the admin controls
|
||||
platform-grant state.
|
||||
"""
|
||||
auth.require_admin(request)
|
||||
rows = db.conn().execute(
|
||||
"""
|
||||
SELECT id, gitea_login, display_name, email, role, muted,
|
||||
created_at, last_seen_at
|
||||
FROM users
|
||||
ORDER BY role = 'owner' DESC, role = 'admin' DESC, display_name COLLATE NOCASE
|
||||
SELECT u.id, u.gitea_login, u.display_name, u.email, u.role, u.muted,
|
||||
u.created_at, u.last_seen_at,
|
||||
u.permission_state, u.first_name, u.last_name,
|
||||
u.beta_request_reason,
|
||||
u.permission_decided_by, u.permission_decided_at,
|
||||
d.gitea_login AS decided_by_login,
|
||||
d.display_name AS decided_by_display
|
||||
FROM users u
|
||||
LEFT JOIN users d ON d.id = u.permission_decided_by
|
||||
ORDER BY
|
||||
CASE u.permission_state
|
||||
WHEN 'pending' THEN 0
|
||||
WHEN 'granted' THEN 1
|
||||
WHEN 'revoked' THEN 2
|
||||
ELSE 3
|
||||
END,
|
||||
u.role = 'owner' DESC, u.role = 'admin' DESC,
|
||||
COALESCE(u.last_seen_at, u.created_at) DESC,
|
||||
u.display_name COLLATE NOCASE
|
||||
"""
|
||||
).fetchall()
|
||||
# v0.16.0 — per-user accepted per-RFC invitations. One query
|
||||
# over the full set, indexed bucket-by-user-id in Python so
|
||||
# the per-row attachment below is O(1). Empty array for users
|
||||
# who hold no accepted invitations.
|
||||
invitation_rows = db.conn().execute(
|
||||
"""
|
||||
SELECT c.user_id, c.rfc_slug, c.role_in_rfc, c.created_at,
|
||||
r.title AS rfc_title,
|
||||
i.id AS invitation_id, i.invitee_email,
|
||||
ui.gitea_login AS inviter_login,
|
||||
ui.display_name AS inviter_display
|
||||
FROM rfc_collaborators c
|
||||
LEFT JOIN cached_rfcs r ON r.slug = c.rfc_slug
|
||||
LEFT JOIN rfc_invitations i ON i.id = c.invitation_id
|
||||
LEFT JOIN users ui ON ui.id = i.inviter_user_id
|
||||
ORDER BY c.created_at DESC
|
||||
"""
|
||||
).fetchall()
|
||||
per_user_invites: dict[int, list[dict]] = {}
|
||||
for ir in invitation_rows:
|
||||
per_user_invites.setdefault(ir["user_id"], []).append({
|
||||
"rfc_slug": ir["rfc_slug"],
|
||||
"rfc_title": ir["rfc_title"] or ir["rfc_slug"],
|
||||
"role_in_rfc": ir["role_in_rfc"],
|
||||
"invited_at": ir["created_at"],
|
||||
"invitation_id": ir["invitation_id"],
|
||||
"invitee_email": ir["invitee_email"],
|
||||
"inviter_login": ir["inviter_login"],
|
||||
"inviter_display": ir["inviter_display"],
|
||||
})
|
||||
# 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 {
|
||||
"items": [
|
||||
{
|
||||
@@ -88,6 +222,224 @@ def make_router(config: Config) -> APIRouter:
|
||||
"muted": bool(r["muted"]),
|
||||
"created_at": r["created_at"],
|
||||
"last_seen_at": r["last_seen_at"],
|
||||
"permission_state": r["permission_state"] or "granted",
|
||||
"first_name": r["first_name"] or "",
|
||||
"last_name": r["last_name"] or "",
|
||||
"beta_request_reason": r["beta_request_reason"] or "",
|
||||
"permission_decided_at": r["permission_decided_at"],
|
||||
"permission_decided_by_login": r["decided_by_login"],
|
||||
"permission_decided_by_display": r["decided_by_display"],
|
||||
# v0.16.0 additive — never null, always an array.
|
||||
"rfc_invitations": per_user_invites.get(r["id"], []),
|
||||
# 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
|
||||
]
|
||||
@@ -136,6 +488,74 @@ def make_router(config: Config) -> APIRouter:
|
||||
)
|
||||
return {"ok": True, "role": body.role, "changed": True}
|
||||
|
||||
# ----- Permission state (§6.1, v0.9.0 roadmap item #7) -----
|
||||
|
||||
@router.post("/api/admin/users/{user_id}/permission")
|
||||
async def set_permission(user_id: int, body: PermissionStateBody, request: Request) -> dict[str, Any]:
|
||||
"""Flip a user's `permission_state` between pending/granted/revoked.
|
||||
|
||||
v0.8.0 wired the column shape but shipped no admin UI for it —
|
||||
the grant gesture was a manual `UPDATE users` against the DB.
|
||||
v0.9.0 (roadmap item #7) lands the admin user-management page;
|
||||
this endpoint is its single write surface.
|
||||
|
||||
Audit shape: every flip writes a `permission_events` row with
|
||||
event_kind in {'permission_granted', 'permission_revoked',
|
||||
'permission_repended'} so §6.5's log carries the change. The
|
||||
`permission_decided_by` / `permission_decided_at` columns on
|
||||
the user row are co-stamped so the user listing can render
|
||||
"granted by @ben at <date>" without a second join through
|
||||
the audit table.
|
||||
|
||||
Refuses with 422 if the admin tries to flip their own row
|
||||
(no self-grant / self-revoke; symmetric to set_mute's
|
||||
self-mute refusal and set_role's self-downgrade refusal).
|
||||
"""
|
||||
viewer = auth.require_admin(request)
|
||||
target = db.conn().execute(
|
||||
"SELECT id, role, permission_state FROM users WHERE id = ?",
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
if target is None:
|
||||
raise HTTPException(404, "User not found")
|
||||
if target["id"] == viewer.user_id:
|
||||
raise HTTPException(422, "You cannot change your own permission state")
|
||||
|
||||
before = target["permission_state"] or "granted"
|
||||
after = body.state
|
||||
if before == after:
|
||||
return {"ok": True, "permission_state": after, "changed": False}
|
||||
|
||||
db.conn().execute(
|
||||
"""
|
||||
UPDATE users
|
||||
SET permission_state = ?,
|
||||
permission_decided_by = ?,
|
||||
permission_decided_at = datetime('now')
|
||||
WHERE id = ?
|
||||
""",
|
||||
(after, viewer.user_id, user_id),
|
||||
)
|
||||
event_kind = {
|
||||
"granted": "permission_granted",
|
||||
"revoked": "permission_revoked",
|
||||
"pending": "permission_repended",
|
||||
}[after]
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO permission_events
|
||||
(actor_user_id, subject_user_id, event_kind, details)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
viewer.user_id,
|
||||
user_id,
|
||||
event_kind,
|
||||
json.dumps({"before": before, "after": after}),
|
||||
),
|
||||
)
|
||||
return {"ok": True, "permission_state": after, "changed": True}
|
||||
|
||||
# ----- Write-mute (§6.2) -----
|
||||
|
||||
@router.post("/api/admin/users/{user_id}/mute")
|
||||
|
||||
@@ -279,6 +279,15 @@ def make_router(
|
||||
@router.post("/api/rfcs/{slug}/branches/main/promote-to-branch")
|
||||
async def promote_to_branch(slug: str, body: PromoteToBranchBody, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
# v0.16.0 (item #12): cutting a contribute branch is the
|
||||
# PR-shaped write surface gate. A platform-granted user who is
|
||||
# not invited as a per-RFC contributor cannot start work that
|
||||
# only exists to land in a PR.
|
||||
if not auth.can_contribute_to_rfc(viewer, slug):
|
||||
raise HTTPException(
|
||||
403,
|
||||
"This RFC's owner has not invited you to contribute PRs",
|
||||
)
|
||||
rfc = _require_active_rfc(slug)
|
||||
owner, repo = _repo_for(rfc)
|
||||
new_branch = (body.branch_name or "").strip()
|
||||
@@ -331,6 +340,14 @@ def make_router(
|
||||
@router.post("/api/rfcs/{slug}/start-edit-branch")
|
||||
async def start_edit_branch(slug: str, body: StartEditBranchBody, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
# v0.16.0 (item #12): same per-RFC contribute gate as
|
||||
# promote-to-branch — kicking off a super-draft edit branch is
|
||||
# also PR-shaped work.
|
||||
if not auth.can_contribute_to_rfc(viewer, slug):
|
||||
raise HTTPException(
|
||||
403,
|
||||
"This RFC's owner has not invited you to contribute PRs",
|
||||
)
|
||||
rfc = _require_super_draft(slug)
|
||||
owner, repo = _repo_for(rfc)
|
||||
new_branch = (body.branch_name or "").strip()
|
||||
|
||||
@@ -116,6 +116,17 @@ def make_router() -> APIRouter:
|
||||
) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
_require_rfc_readable(slug)
|
||||
# v0.16.0 (roadmap item #12): the per-RFC discussion is now a
|
||||
# gated surface. The platform-level `require_contributor` above
|
||||
# ensures the user is signed in + admin-granted; this layer
|
||||
# narrows further to "is this user named for this RFC?" The
|
||||
# 403 here is structurally the v0.6.0 anon-write refusal
|
||||
# extended to non-invited platform users.
|
||||
if not auth.can_discuss_rfc(viewer, slug):
|
||||
raise HTTPException(
|
||||
403,
|
||||
"This RFC's owner has not invited you to its discussion",
|
||||
)
|
||||
cur = db.conn().execute(
|
||||
"""
|
||||
INSERT INTO threads
|
||||
@@ -175,6 +186,12 @@ def make_router() -> APIRouter:
|
||||
) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
_require_rfc_readable(slug)
|
||||
# v0.16.0 (item #12): same per-RFC gate as create_discussion_thread.
|
||||
if not auth.can_discuss_rfc(viewer, slug):
|
||||
raise HTTPException(
|
||||
403,
|
||||
"This RFC's owner has not invited you to its discussion",
|
||||
)
|
||||
_require_discussion_thread(slug, thread_id)
|
||||
message_id = chat_layer.append_user_message(
|
||||
thread_id=thread_id,
|
||||
|
||||
@@ -0,0 +1,575 @@
|
||||
"""v0.16.0 / §6 / §10 — owner-only invite for per-RFC PR or PR-less
|
||||
discussion (roadmap item #12).
|
||||
|
||||
The RFC's owner can invite a specific email to one of two per-RFC roles:
|
||||
|
||||
* `contributor` — may open PRs against this RFC AND post in its
|
||||
discussion (PR-permission strictly includes discussion-permission).
|
||||
* `discussant` — may post in this RFC's PR-less discussion only.
|
||||
|
||||
Non-invited users keep the v0.6.0 anonymous-read contract: they can
|
||||
read but cannot write/discuss the RFC. Reads are not narrowed by
|
||||
this item.
|
||||
|
||||
Endpoints:
|
||||
|
||||
* `POST /api/rfcs/{slug}/invitations` — owner: create + email
|
||||
* `GET /api/rfcs/{slug}/invitations` — owner: list pending/accepted
|
||||
* `POST /api/rfcs/{slug}/invitations/{id}/revoke` — owner: revoke
|
||||
* `GET /api/invitations/accept` — token lookup (signed-in user)
|
||||
* `POST /api/invitations/accept` — token redeem (signed-in user)
|
||||
|
||||
The accept endpoints are deliberately platform-scoped (not nested under
|
||||
the RFC slug) because the user clicking the email link only has the
|
||||
token and may not even know the slug yet. The GET shape lets the
|
||||
frontend show a confirmation page ("RFC <X> invited you to be a
|
||||
<role> — accept?") before the POST commits the membership.
|
||||
|
||||
Permission gates (composed with `require_contributor`):
|
||||
|
||||
* Issue / list / revoke: `auth.can_invite_to_rfc` — RFC owner or
|
||||
platform admin/owner.
|
||||
* Accept: any platform-granted signed-in user; the gate is the
|
||||
token, not the role. The token also constrains which email the
|
||||
accept lands under — the accepting user's email must match the
|
||||
invitation's invitee_email (case-insensitive). This prevents an
|
||||
invited-but-not-the-account-holder situation from minting a
|
||||
collaborator row under the wrong identity.
|
||||
|
||||
Email shape: a single plain-text body sent via the existing SMTP path
|
||||
(reuses `EmailConfig.from_env()` like `email_otc.py` does). No
|
||||
unsubscribe footer — the email is transactional and per-invite, not a
|
||||
recurring notification. No tracking pixel.
|
||||
|
||||
Admin-page hook: when an accept lands and the user's
|
||||
`permission_state` is still `pending`, that signals to the admin's
|
||||
`/admin/users` queue that the user is here because they accepted a
|
||||
per-RFC invitation — informing (not deciding) the admin's
|
||||
platform-grant call. v0.16.0 surfaces this via additive columns on
|
||||
the existing `GET /api/admin/users` listing (see `api_admin.py`'s
|
||||
diff in the same release) — no new endpoint, no restructure.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
import smtplib
|
||||
from email.message import EmailMessage
|
||||
from email.utils import formataddr
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from . import auth, db
|
||||
from .email import EmailConfig, _SENT
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pydantic bodies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class CreateInvitationBody(BaseModel):
|
||||
"""The owner picks an email and a role-in-RFC. No custom-message
|
||||
field — that belongs to item #16's platform-level invite surface,
|
||||
not here.
|
||||
|
||||
We validate the email with a deliberately narrow pattern rather
|
||||
than `pydantic.EmailStr` to avoid pulling in `email-validator` as
|
||||
a dependency (and v0.7.0's OTC body does the same — see
|
||||
`OTCRequestBody`'s shape). The validation here is intentionally
|
||||
permissive: a local-part, an `@`, and a domain part with no
|
||||
whitespace. Operator-side typo catching is the job of the email
|
||||
transport; the framework only guards against obviously malformed
|
||||
input."""
|
||||
invitee_email: str = Field(min_length=3, max_length=320,
|
||||
pattern=r"^[^\s@]+@[^\s@]+$")
|
||||
role_in_rfc: str = Field(pattern="^(contributor|discussant)$")
|
||||
|
||||
|
||||
class AcceptInvitationBody(BaseModel):
|
||||
token: str = Field(min_length=1, max_length=200)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# 30-day TTL matches the device-trust window the framework already
|
||||
# ships (v0.11.0). A pending invitation past this is rejected at the
|
||||
# accept endpoint regardless of the row's `status` column.
|
||||
INVITATION_TTL_DAYS = 30
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Router
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def make_router() -> APIRouter:
|
||||
router = APIRouter()
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# POST /api/rfcs/<slug>/invitations
|
||||
# The owner creates an invitation. The endpoint mints the token,
|
||||
# writes the row, and dispatches the email synchronously. A failure
|
||||
# to send the email does NOT roll back the row — the owner can
|
||||
# share the link directly out-of-band if SMTP is briefly down (the
|
||||
# `GET /api/rfcs/<slug>/invitations` response carries the token
|
||||
# for that fallback).
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.post("/api/rfcs/{slug}/invitations")
|
||||
async def create_invitation(slug: str, body: CreateInvitationBody, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
rfc = _require_rfc(slug)
|
||||
if not auth.can_invite_to_rfc(viewer, slug):
|
||||
raise HTTPException(
|
||||
403,
|
||||
"Only the RFC's owner can invite collaborators",
|
||||
)
|
||||
|
||||
invitee_email = body.invitee_email.strip()
|
||||
role_in_rfc = body.role_in_rfc
|
||||
|
||||
# Refuse re-inviting an email that already has a pending
|
||||
# invitation on this RFC at the same role. Different-role
|
||||
# re-invite is allowed (upgrade discussant → contributor)
|
||||
# — the new row supersedes the old in the UI listing's
|
||||
# natural ordering, and acceptance of either picks up the
|
||||
# corresponding role.
|
||||
existing = db.conn().execute(
|
||||
"""
|
||||
SELECT id FROM rfc_invitations
|
||||
WHERE rfc_slug = ? AND invitee_email = ? COLLATE NOCASE
|
||||
AND role_in_rfc = ? AND status = 'pending'
|
||||
LIMIT 1
|
||||
""",
|
||||
(slug, invitee_email, role_in_rfc),
|
||||
).fetchone()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
409,
|
||||
f"{invitee_email} already has a pending {role_in_rfc} invitation for this RFC",
|
||||
)
|
||||
|
||||
token = _mint_token()
|
||||
cur = db.conn().execute(
|
||||
"""
|
||||
INSERT INTO rfc_invitations
|
||||
(rfc_slug, inviter_user_id, invitee_email, role_in_rfc,
|
||||
token, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, datetime('now', ?))
|
||||
""",
|
||||
(
|
||||
slug,
|
||||
viewer.user_id,
|
||||
invitee_email,
|
||||
role_in_rfc,
|
||||
token,
|
||||
f"+{INVITATION_TTL_DAYS} days",
|
||||
),
|
||||
)
|
||||
invitation_id = cur.lastrowid
|
||||
|
||||
# Send the email — synchronous. A send failure logs and
|
||||
# returns; the row stays so the owner can recover via the
|
||||
# listing (which carries the token for an out-of-band share).
|
||||
_send_invitation_email(
|
||||
to_address=invitee_email,
|
||||
inviter_display=viewer.display_name or viewer.gitea_login or "An RFC owner",
|
||||
rfc_title=rfc["title"],
|
||||
role_in_rfc=role_in_rfc,
|
||||
token=token,
|
||||
)
|
||||
|
||||
return {
|
||||
"id": invitation_id,
|
||||
"rfc_slug": slug,
|
||||
"invitee_email": invitee_email,
|
||||
"role_in_rfc": role_in_rfc,
|
||||
"status": "pending",
|
||||
"token": token,
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# GET /api/rfcs/<slug>/invitations
|
||||
# The owner's listing of every invitation on the RFC, regardless
|
||||
# of status. Carries the token (for the resend / re-share path).
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.get("/api/rfcs/{slug}/invitations")
|
||||
async def list_invitations(slug: str, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
_require_rfc(slug)
|
||||
if not auth.can_invite_to_rfc(viewer, slug):
|
||||
raise HTTPException(
|
||||
403,
|
||||
"Only the RFC's owner can view invitations",
|
||||
)
|
||||
|
||||
rows = db.conn().execute(
|
||||
"""
|
||||
SELECT i.id, i.invitee_email, i.role_in_rfc, i.status, i.token,
|
||||
i.expires_at, i.created_at, i.accepted_at,
|
||||
i.inviter_user_id, i.accepted_by_user_id,
|
||||
u_inviter.display_name AS inviter_display,
|
||||
u_inviter.gitea_login AS inviter_login,
|
||||
u_accept.display_name AS accepted_by_display,
|
||||
u_accept.gitea_login AS accepted_by_login
|
||||
FROM rfc_invitations i
|
||||
LEFT JOIN users u_inviter ON u_inviter.id = i.inviter_user_id
|
||||
LEFT JOIN users u_accept ON u_accept.id = i.accepted_by_user_id
|
||||
WHERE i.rfc_slug = ?
|
||||
ORDER BY i.id DESC
|
||||
""",
|
||||
(slug,),
|
||||
).fetchall()
|
||||
|
||||
return {
|
||||
"items": [
|
||||
{
|
||||
"id": r["id"],
|
||||
"invitee_email": r["invitee_email"],
|
||||
"role_in_rfc": r["role_in_rfc"],
|
||||
"status": _effective_status(r),
|
||||
"token": r["token"],
|
||||
"expires_at": r["expires_at"],
|
||||
"created_at": r["created_at"],
|
||||
"accepted_at": r["accepted_at"],
|
||||
"inviter_display": r["inviter_display"],
|
||||
"inviter_login": r["inviter_login"],
|
||||
"accepted_by_display": r["accepted_by_display"],
|
||||
"accepted_by_login": r["accepted_by_login"],
|
||||
}
|
||||
for r in rows
|
||||
],
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# POST /api/rfcs/<slug>/invitations/<id>/revoke
|
||||
# Revokes a pending invitation. Already-accepted invitations
|
||||
# cannot be "revoked" from this surface — the corresponding
|
||||
# collaborator-removal surface is a §19.2 candidate; v0.16.0
|
||||
# only lifts the *pending* link.
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.post("/api/rfcs/{slug}/invitations/{invitation_id}/revoke")
|
||||
async def revoke_invitation(slug: str, invitation_id: int, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
_require_rfc(slug)
|
||||
if not auth.can_invite_to_rfc(viewer, slug):
|
||||
raise HTTPException(
|
||||
403,
|
||||
"Only the RFC's owner can revoke invitations",
|
||||
)
|
||||
|
||||
row = db.conn().execute(
|
||||
"SELECT id, status FROM rfc_invitations WHERE id = ? AND rfc_slug = ?",
|
||||
(invitation_id, slug),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, "Invitation not found")
|
||||
if row["status"] != "pending":
|
||||
raise HTTPException(
|
||||
409,
|
||||
f"Invitation is {row['status']}; only pending invitations can be revoked",
|
||||
)
|
||||
|
||||
db.conn().execute(
|
||||
"UPDATE rfc_invitations SET status = 'revoked' WHERE id = ?",
|
||||
(invitation_id,),
|
||||
)
|
||||
return {"ok": True, "id": invitation_id, "status": "revoked"}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# GET /api/invitations/accept?token=...
|
||||
# Lookup-only — returns what the invitation grants so the
|
||||
# frontend can render a confirmation page before the POST. The
|
||||
# token is required; no token, no peek.
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.get("/api/invitations/accept")
|
||||
async def preview_invitation(token: str, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_user(request)
|
||||
row = _lookup_invitation_by_token(token)
|
||||
if row is None:
|
||||
raise HTTPException(404, "Invitation not found")
|
||||
effective = _effective_status(row)
|
||||
rfc = db.conn().execute(
|
||||
"SELECT slug, title FROM cached_rfcs WHERE slug = ?", (row["rfc_slug"],),
|
||||
).fetchone()
|
||||
return {
|
||||
"rfc_slug": row["rfc_slug"],
|
||||
"rfc_title": rfc["title"] if rfc else row["rfc_slug"],
|
||||
"role_in_rfc": row["role_in_rfc"],
|
||||
"status": effective,
|
||||
"invitee_email": row["invitee_email"],
|
||||
"email_matches_you": (viewer.email or "").strip().lower()
|
||||
== row["invitee_email"].strip().lower(),
|
||||
"expires_at": row["expires_at"],
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# POST /api/invitations/accept
|
||||
# The accept gesture: token → collaborator row.
|
||||
#
|
||||
# Requires:
|
||||
# * an authenticated user (no token-only acceptance — we want
|
||||
# the per-user audit trail),
|
||||
# * a valid (pending, non-expired, non-revoked) invitation,
|
||||
# * the accepting user's email matches invitee_email
|
||||
# (case-insensitive).
|
||||
#
|
||||
# On success the row's status flips to 'accepted' and a
|
||||
# rfc_collaborators row is inserted (or upgraded if the user
|
||||
# already had a lower role). Idempotent: re-accepting the same
|
||||
# already-accepted invitation reads as a 200 no-op with
|
||||
# `changed=false`.
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.post("/api/invitations/accept")
|
||||
async def accept_invitation(body: AcceptInvitationBody, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_user(request)
|
||||
row = _lookup_invitation_by_token(body.token)
|
||||
if row is None:
|
||||
raise HTTPException(404, "Invitation not found")
|
||||
|
||||
effective = _effective_status(row)
|
||||
if effective == "revoked":
|
||||
raise HTTPException(409, "Invitation was revoked")
|
||||
if effective == "expired":
|
||||
raise HTTPException(409, "Invitation has expired")
|
||||
|
||||
# Email match — case-insensitive. Empty viewer email cannot
|
||||
# accept (an OAuth-only user with no captured email shape).
|
||||
viewer_email = (viewer.email or "").strip().lower()
|
||||
invitee_email = row["invitee_email"].strip().lower()
|
||||
if not viewer_email or viewer_email != invitee_email:
|
||||
raise HTTPException(
|
||||
403,
|
||||
"This invitation was sent to a different email; sign in with that address",
|
||||
)
|
||||
|
||||
if effective == "accepted":
|
||||
# Idempotent re-accept — surface the existing collaborator
|
||||
# row without writing anything new.
|
||||
collab = db.conn().execute(
|
||||
"SELECT role_in_rfc FROM rfc_collaborators WHERE rfc_slug = ? AND user_id = ?",
|
||||
(row["rfc_slug"], viewer.user_id),
|
||||
).fetchone()
|
||||
return {
|
||||
"ok": True,
|
||||
"changed": False,
|
||||
"rfc_slug": row["rfc_slug"],
|
||||
"role_in_rfc": collab["role_in_rfc"] if collab else row["role_in_rfc"],
|
||||
}
|
||||
|
||||
# First-time accept. Flip the invitation; upsert the
|
||||
# collaborator. We do the upsert with ON CONFLICT so a
|
||||
# user who already held a lower role gets upgraded, never
|
||||
# downgraded (the MAX-style precedence is contributor >
|
||||
# discussant; lower roles never overwrite higher).
|
||||
with db.tx() as c:
|
||||
c.execute(
|
||||
"""
|
||||
UPDATE rfc_invitations
|
||||
SET status = 'accepted',
|
||||
accepted_at = datetime('now'),
|
||||
accepted_by_user_id = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(viewer.user_id, row["id"]),
|
||||
)
|
||||
existing = c.execute(
|
||||
"SELECT role_in_rfc FROM rfc_collaborators WHERE rfc_slug = ? AND user_id = ?",
|
||||
(row["rfc_slug"], viewer.user_id),
|
||||
).fetchone()
|
||||
target_role = _max_role(
|
||||
existing["role_in_rfc"] if existing else None,
|
||||
row["role_in_rfc"],
|
||||
)
|
||||
if existing is None:
|
||||
c.execute(
|
||||
"""
|
||||
INSERT INTO rfc_collaborators
|
||||
(rfc_slug, user_id, role_in_rfc, invitation_id)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
(row["rfc_slug"], viewer.user_id, target_role, row["id"]),
|
||||
)
|
||||
elif existing["role_in_rfc"] != target_role:
|
||||
c.execute(
|
||||
"""
|
||||
UPDATE rfc_collaborators
|
||||
SET role_in_rfc = ?, invitation_id = ?
|
||||
WHERE rfc_slug = ? AND user_id = ?
|
||||
""",
|
||||
(target_role, row["id"], row["rfc_slug"], viewer.user_id),
|
||||
)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"changed": True,
|
||||
"rfc_slug": row["rfc_slug"],
|
||||
"role_in_rfc": target_role,
|
||||
}
|
||||
|
||||
return router
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _require_rfc(slug: str):
|
||||
"""The invitation surface only operates on a known, non-withdrawn
|
||||
RFC. We refuse 404 on unknown and 409 on withdrawn — mirrors the
|
||||
discussion endpoints' `_require_rfc_readable` shape."""
|
||||
row = db.conn().execute(
|
||||
"SELECT slug, title, state FROM cached_rfcs WHERE slug = ?", (slug,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, "RFC not found")
|
||||
if row["state"] == "withdrawn":
|
||||
raise HTTPException(409, "RFC is withdrawn")
|
||||
return row
|
||||
|
||||
|
||||
def _lookup_invitation_by_token(token: str):
|
||||
return db.conn().execute(
|
||||
"""
|
||||
SELECT id, rfc_slug, inviter_user_id, invitee_email, role_in_rfc,
|
||||
status, token, expires_at, created_at, accepted_at,
|
||||
accepted_by_user_id
|
||||
FROM rfc_invitations
|
||||
WHERE token = ?
|
||||
""",
|
||||
(token,),
|
||||
).fetchone()
|
||||
|
||||
|
||||
def _effective_status(row) -> str:
|
||||
"""The row's column status is the authoritative truth except for
|
||||
`expired` — that is derived from `expires_at` at read time so an
|
||||
unattended cron isn't required to flip rows. A revoked-then-
|
||||
expired row reads as `revoked` (the explicit gesture wins)."""
|
||||
column_status = row["status"]
|
||||
if column_status != "pending":
|
||||
return column_status
|
||||
# Compare via SQL so the comparison is in sqlite-time, matching the
|
||||
# `datetime('now')` insert. A simpler same-process comparison would
|
||||
# work too, but routing through the DB keeps the timezone handling
|
||||
# consistent with the inserts.
|
||||
is_past = db.conn().execute(
|
||||
"SELECT datetime(?) <= datetime('now') AS past",
|
||||
(row["expires_at"],),
|
||||
).fetchone()["past"]
|
||||
return "expired" if is_past else "pending"
|
||||
|
||||
|
||||
def _mint_token() -> str:
|
||||
"""A 256-bit URL-safe token. The token shape is opaque to the
|
||||
consumer; the email link encodes it as a query param."""
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def _max_role(existing: str | None, new: str) -> str:
|
||||
"""contributor strictly dominates discussant. A re-accept that
|
||||
would lower the role is a no-op (the existing role survives)."""
|
||||
precedence = {"discussant": 0, "contributor": 1}
|
||||
if existing is None:
|
||||
return new
|
||||
if precedence.get(new, 0) > precedence.get(existing, 0):
|
||||
return new
|
||||
return existing
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Email dispatch — transactional, no preferences honored
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _send_invitation_email(
|
||||
*,
|
||||
to_address: str,
|
||||
inviter_display: str,
|
||||
rfc_title: str,
|
||||
role_in_rfc: str,
|
||||
token: str,
|
||||
) -> bool:
|
||||
"""Compose and send the invitation email.
|
||||
|
||||
Like `email_otc.send_otc_email`, this writes its own envelope and
|
||||
reuses `EmailConfig.from_env()` for the SMTP plumbing. The
|
||||
`_SENT` buffer is appended either way so integration tests can
|
||||
assert on the outbound shape without a real SMTP server.
|
||||
|
||||
Returns True on the happy path / dev fallback; False on SMTP
|
||||
failure. The caller does not roll back the invitation row on
|
||||
failure — the owner has the token in the create response and on
|
||||
the listing surface for an out-of-band share.
|
||||
"""
|
||||
cfg = EmailConfig.from_env()
|
||||
subject = f"{inviter_display} invited you to {rfc_title} on {cfg.from_name}"
|
||||
role_label = (
|
||||
"open PRs against the RFC and join its discussion"
|
||||
if role_in_rfc == "contributor"
|
||||
else "join the RFC's discussion"
|
||||
)
|
||||
link = f"{cfg.app_url}/invitations/accept?token={token}"
|
||||
body = (
|
||||
f"{inviter_display} invited you to {rfc_title} on {cfg.from_name} as {role_in_rfc}.\n\n"
|
||||
f"This invitation lets you {role_label}.\n\n"
|
||||
f"Click to accept (you'll be asked to sign in first if you aren't already):\n\n"
|
||||
f" {link}\n\n"
|
||||
f"The invitation expires in {INVITATION_TTL_DAYS} days. If you weren't expecting\n"
|
||||
f"this, you can safely ignore the email.\n\n"
|
||||
f"---\n"
|
||||
f"{cfg.from_name} · {cfg.app_url}\n"
|
||||
)
|
||||
envelope = {
|
||||
"to": to_address,
|
||||
"from": formataddr((cfg.from_name, cfg.from_address)),
|
||||
"subject": subject,
|
||||
"body": body,
|
||||
"kind": "rfc_invitation",
|
||||
}
|
||||
_SENT.append(envelope)
|
||||
|
||||
if not cfg.enabled:
|
||||
log.info("invitation email disabled (EMAIL_ENABLED=0): to=%s", to_address)
|
||||
return True
|
||||
if not cfg.smtp_host:
|
||||
# Dev fallback — surface the link at INFO so the operator can
|
||||
# complete an accept flow without an SMTP relay.
|
||||
log.info(
|
||||
"invitation email (stdout fallback): to=%s rfc=%s role=%s link=%s",
|
||||
to_address, rfc_title, role_in_rfc, link,
|
||||
)
|
||||
return True
|
||||
|
||||
try:
|
||||
msg = EmailMessage()
|
||||
msg["From"] = envelope["from"]
|
||||
msg["To"] = to_address
|
||||
msg["Subject"] = subject
|
||||
msg.set_content(body)
|
||||
smtp = smtplib.SMTP(cfg.smtp_host, cfg.smtp_port, timeout=30)
|
||||
try:
|
||||
if cfg.smtp_starttls:
|
||||
smtp.starttls()
|
||||
if cfg.smtp_user:
|
||||
smtp.login(cfg.smtp_user, cfg.smtp_password)
|
||||
smtp.send_message(msg)
|
||||
finally:
|
||||
smtp.quit()
|
||||
return True
|
||||
except Exception:
|
||||
log.exception("invitation email send failed: to=%s", to_address)
|
||||
return False
|
||||
@@ -112,6 +112,17 @@ def make_router(
|
||||
@router.post("/api/rfcs/{slug}/branches/{branch:path}/open-pr")
|
||||
async def open_pr(slug: str, branch: str, body: OpenPRBody, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
# v0.16.0 (item #12): opening a PR is the canonical PR-shaped
|
||||
# write — the gate fires here even though the branch-cutting
|
||||
# entry points also gate, since a user with prior branch access
|
||||
# who's since had their per-RFC role revoked shouldn't be able
|
||||
# to ship the PR. The branch-creation gate is the kickoff
|
||||
# refusal; this one is the post-work refusal.
|
||||
if not auth.can_contribute_to_rfc(viewer, slug):
|
||||
raise HTTPException(
|
||||
403,
|
||||
"This RFC's owner has not invited you to contribute PRs",
|
||||
)
|
||||
rfc = _require_active_rfc(slug)
|
||||
if branch == "main":
|
||||
raise HTTPException(409, "PRs open from non-main branches")
|
||||
|
||||
+214
-4
@@ -30,6 +30,12 @@ class SessionUser:
|
||||
email: str
|
||||
avatar_url: str
|
||||
role: str
|
||||
# v0.8.0 / §6.1 — admission gate. Three states: 'pending' (waiting
|
||||
# for an admin grant), 'granted' (active contributor), 'revoked'
|
||||
# (was granted, later removed). Existing rows at migration time
|
||||
# default to 'granted' so grandfathered users are unaffected; OTC
|
||||
# provisions fresh users with 'pending' (see `app/otc.py`).
|
||||
permission_state: str = "granted"
|
||||
|
||||
def as_actor(self) -> Actor:
|
||||
return Actor(
|
||||
@@ -90,6 +96,13 @@ def allowlist_is_active() -> bool:
|
||||
def is_allowed_sign_in(profile: dict[str, Any]) -> bool:
|
||||
"""Decide whether a freshly-completed OAuth profile may sign in.
|
||||
|
||||
v0.8.0 (item #6) replaces the allowlist gate with an admin-grant
|
||||
flow at the OTC `/request` surface, but the Gitea OAuth callback
|
||||
in `main.py` still consults this helper so the fallback path
|
||||
keeps the v0.3.0 admission shape during the OAuth migration
|
||||
window. The eventual removal of the OAuth callback (§19.2)
|
||||
retires this function alongside it.
|
||||
|
||||
Three accept paths:
|
||||
1. The allowlist is empty (gate off).
|
||||
2. The Gitea profile's email is in `allowed_emails` (case-insensitive).
|
||||
@@ -132,17 +145,27 @@ def provision_user(config: Config, profile: dict[str, Any]) -> SessionUser:
|
||||
existing = c.execute("SELECT * FROM users WHERE gitea_id = ?", (gitea_id,)).fetchone()
|
||||
if existing is None:
|
||||
role = "owner" if config.owner_gitea_login and login == config.owner_gitea_login else "contributor"
|
||||
# v0.8.0: a fresh OAuth-provisioned user is also subject to
|
||||
# the admin-grant flow. The OAuth fallback only fires for
|
||||
# users who pass `is_allowed_sign_in` (so they're already on
|
||||
# the legacy allowlist or are grandfathered by gitea_id);
|
||||
# 'granted' is the right default here since the allowlist
|
||||
# check is itself the admin gesture. A future release that
|
||||
# retires the OAuth callback (§19.2) collapses both paths
|
||||
# under the same gate.
|
||||
cur = c.execute(
|
||||
"""
|
||||
INSERT INTO users (gitea_id, gitea_login, email, display_name, avatar_url, role)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO users (gitea_id, gitea_login, email, display_name, avatar_url, role, permission_state)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'granted')
|
||||
""",
|
||||
(gitea_id, login, email, display, avatar, role),
|
||||
)
|
||||
user_id = cur.lastrowid
|
||||
permission_state = "granted"
|
||||
else:
|
||||
user_id = existing["id"]
|
||||
role = existing["role"]
|
||||
permission_state = existing["permission_state"] or "granted"
|
||||
c.execute(
|
||||
"""
|
||||
UPDATE users
|
||||
@@ -160,6 +183,7 @@ def provision_user(config: Config, profile: dict[str, Any]) -> SessionUser:
|
||||
email=email,
|
||||
avatar_url=avatar,
|
||||
role=role,
|
||||
permission_state=permission_state,
|
||||
)
|
||||
|
||||
|
||||
@@ -178,6 +202,12 @@ def store_session(request: Request, user: SessionUser) -> None:
|
||||
"email": user.email,
|
||||
"avatar_url": user.avatar_url,
|
||||
"role": user.role,
|
||||
# v0.8.0: persist the admission state on the cookie payload so
|
||||
# the post-cookie audit doesn't second-guess the row. The DB
|
||||
# is re-read on every `current_user` call regardless (so an
|
||||
# admin grant takes effect on the next request); this field
|
||||
# is purely structural redundancy for the cookie shape.
|
||||
"permission_state": user.permission_state,
|
||||
}
|
||||
|
||||
|
||||
@@ -188,7 +218,7 @@ def current_user(request: Request) -> SessionUser | None:
|
||||
# Re-read the role from the database every request so role changes
|
||||
# take effect on the next API call without forcing a logout.
|
||||
row = db.conn().execute(
|
||||
"SELECT id, gitea_id, gitea_login, email, display_name, avatar_url, role FROM users WHERE id = ?",
|
||||
"SELECT id, gitea_id, gitea_login, email, display_name, avatar_url, role, permission_state FROM users WHERE id = ?",
|
||||
(raw["user_id"],),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
@@ -199,6 +229,11 @@ def current_user(request: Request) -> SessionUser | None:
|
||||
# of which sign-in path the row came from. The DB remains the
|
||||
# source of truth for "is this an OAuth-linked user" (gitea_id IS
|
||||
# NOT NULL); the in-memory SessionUser is the per-request handle.
|
||||
# v0.8.0: permission_state comes off the row directly. A NULL
|
||||
# column value (shouldn't happen under the migration's
|
||||
# NOT NULL DEFAULT, but be defensive) reads as 'granted' so the
|
||||
# gate fails open for grandfathered surfaces rather than locking
|
||||
# everyone out on a malformed row.
|
||||
return SessionUser(
|
||||
user_id=row["id"],
|
||||
gitea_id=row["gitea_id"] or 0,
|
||||
@@ -207,6 +242,7 @@ def current_user(request: Request) -> SessionUser | None:
|
||||
email=row["email"] or "",
|
||||
avatar_url=row["avatar_url"] or "",
|
||||
role=row["role"],
|
||||
permission_state=row["permission_state"] or "granted",
|
||||
)
|
||||
|
||||
|
||||
@@ -218,11 +254,31 @@ def require_user(request: Request) -> SessionUser:
|
||||
|
||||
|
||||
def require_contributor(request: Request) -> SessionUser:
|
||||
"""§6.1: authenticated, not write-muted."""
|
||||
"""§6.1: authenticated, not write-muted, and granted by an admin.
|
||||
|
||||
v0.8.0 (item #6) widens this gate. A fresh OTC sign-in lands in
|
||||
`permission_state='pending'`; the user can read everything an
|
||||
anonymous viewer can read, but every write-shaped endpoint that
|
||||
funnels through this dependency now refuses with 403 until an
|
||||
admin grants them. The `pending` blast radius is the same as
|
||||
anonymous (item #4 / v0.6.0 already audited the anon-write
|
||||
refusal at every write site), so this widening is structurally
|
||||
a relabel — the same surfaces that already refused 401 to
|
||||
anonymous now also refuse 403 to pending.
|
||||
"""
|
||||
user = require_user(request)
|
||||
row = db.conn().execute("SELECT muted FROM users WHERE id = ?", (user.user_id,)).fetchone()
|
||||
if row and row["muted"]:
|
||||
raise HTTPException(status_code=403, detail="Your account is muted")
|
||||
if user.permission_state != "granted":
|
||||
# 'pending' is the post-OTC waiting state; 'revoked' is the
|
||||
# admin-undid-the-grant state. Both refuse with the same 403
|
||||
# shape; the client distinguishes via `/api/auth/me` which
|
||||
# carries `permission_state` in the response.
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Your beta access request is in review",
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
@@ -234,5 +290,159 @@ def require_admin(request: Request) -> SessionUser:
|
||||
return user
|
||||
|
||||
|
||||
# v0.16.0 (roadmap item #12): per-RFC membership helpers.
|
||||
#
|
||||
# These don't replace `require_contributor` — they layer on top of it for
|
||||
# endpoints that an RFC's owner can selectively open up. The "discussion"
|
||||
# and "PR" write surfaces consult `is_rfc_writer(...)` / `is_rfc_discussant(...)`
|
||||
# to admit users who are either platform-privileged (admin, RFC owner)
|
||||
# OR who hold an explicit invitation-accepted per-RFC role.
|
||||
#
|
||||
# The platform gate still fires first: a user whose
|
||||
# `permission_state != 'granted'` cannot write anywhere, invitation or
|
||||
# not. v0.16.0 doesn't loosen that — a per-RFC invitation is additive
|
||||
# *within* the granted-platform-user population. (Accepting an
|
||||
# invitation as a pending user surfaces in the admin-page hook per
|
||||
# the roadmap text; the platform grant remains the admin's decision.)
|
||||
|
||||
|
||||
def _rfc_owners_set(rfc_slug: str) -> set[str]:
|
||||
"""The gitea_logins named in the RFC's frontmatter owners array.
|
||||
|
||||
Read from `cached_rfcs.owners_json`. Returns an empty set if the RFC
|
||||
isn't cached (the caller's earlier `_require_rfc_readable` will
|
||||
already have rejected that case in practice).
|
||||
"""
|
||||
import json as _json
|
||||
row = db.conn().execute(
|
||||
"SELECT owners_json FROM cached_rfcs WHERE slug = ?", (rfc_slug,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return set()
|
||||
try:
|
||||
return set(_json.loads(row["owners_json"] or "[]"))
|
||||
except Exception:
|
||||
return set()
|
||||
|
||||
|
||||
def is_rfc_owner(user: SessionUser | None, rfc_slug: str) -> bool:
|
||||
"""True iff the user is named in the RFC's frontmatter `owners`
|
||||
list. The platform-level admin/owner check is separate; per §6.1 an
|
||||
app admin/owner has all per-RFC capabilities by construction, but
|
||||
this predicate is intentionally narrow — it answers "is this
|
||||
person on the RFC's owners line?" and nothing more.
|
||||
"""
|
||||
if user is None:
|
||||
return False
|
||||
return user.gitea_login in _rfc_owners_set(rfc_slug)
|
||||
|
||||
|
||||
def is_rfc_collaborator(user: SessionUser | None, rfc_slug: str, *, role_in_rfc: str | None = None) -> bool:
|
||||
"""True iff the user has an accepted per-RFC collaborator row.
|
||||
|
||||
`role_in_rfc`:
|
||||
* None — any role qualifies (the discussion-write check uses this
|
||||
shape: contributor strictly includes discussant).
|
||||
* 'contributor' — only the contributor role qualifies (the PR-write
|
||||
check uses this shape).
|
||||
* 'discussant' — only the discussant role qualifies (not used by
|
||||
v0.16.0 endpoints; included for symmetry).
|
||||
"""
|
||||
if user is None:
|
||||
return False
|
||||
if role_in_rfc is None:
|
||||
row = db.conn().execute(
|
||||
"SELECT 1 FROM rfc_collaborators WHERE rfc_slug = ? AND user_id = ? LIMIT 1",
|
||||
(rfc_slug, user.user_id),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
row = db.conn().execute(
|
||||
"SELECT 1 FROM rfc_collaborators WHERE rfc_slug = ? AND user_id = ? AND role_in_rfc = ? LIMIT 1",
|
||||
(rfc_slug, user.user_id, role_in_rfc),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
def can_discuss_rfc(user: SessionUser | None, rfc_slug: str) -> bool:
|
||||
"""v0.16.0 — admit to PR-less discussion writes on this RFC.
|
||||
|
||||
True if ANY of:
|
||||
* platform admin/owner (the §6.1 maximal-capability path),
|
||||
* the RFC has no frontmatter owners yet (the gate is open
|
||||
until an owner exists to set it — relevant for super-drafts
|
||||
pre-§13.1 claim),
|
||||
* RFC owner (frontmatter `owners` membership),
|
||||
* accepted per-RFC collaborator at any role (contributor strictly
|
||||
includes discussant).
|
||||
|
||||
Returns False for anonymous viewers and for users whose
|
||||
`permission_state != 'granted'` — the platform-level gate must hold
|
||||
before any per-RFC layer can apply. The platform gate is also
|
||||
enforced earlier in the request via `require_contributor`; the
|
||||
helper here is defensive so callers that compose it with
|
||||
`current_user` directly still respect the gate.
|
||||
"""
|
||||
if user is None:
|
||||
return False
|
||||
if user.permission_state != "granted":
|
||||
return False
|
||||
if user.role in ("owner", "admin"):
|
||||
return True
|
||||
owners = _rfc_owners_set(rfc_slug)
|
||||
if not owners:
|
||||
# No owner to gate the invite-list — fall through to the
|
||||
# platform-granted contract. The first §13.1 claim engages
|
||||
# the gate; before that, anyone platform-granted can
|
||||
# contribute (mirrors the v0.5.0 / v0.6.0 contract).
|
||||
return True
|
||||
if user.gitea_login in owners:
|
||||
return True
|
||||
return is_rfc_collaborator(user, rfc_slug, role_in_rfc=None)
|
||||
|
||||
|
||||
def can_contribute_to_rfc(user: SessionUser | None, rfc_slug: str) -> bool:
|
||||
"""v0.16.0 — admit to PR-shaped writes on this RFC.
|
||||
|
||||
True if ANY of:
|
||||
* platform admin/owner,
|
||||
* the RFC has no frontmatter owners yet (gate open until an
|
||||
owner exists),
|
||||
* RFC owner,
|
||||
* accepted per-RFC collaborator at role 'contributor' (a
|
||||
'discussant' row is NOT sufficient — PRs are the
|
||||
higher-privilege surface).
|
||||
|
||||
Same `permission_state` and anonymous-viewer refusals as
|
||||
`can_discuss_rfc`.
|
||||
"""
|
||||
if user is None:
|
||||
return False
|
||||
if user.permission_state != "granted":
|
||||
return False
|
||||
if user.role in ("owner", "admin"):
|
||||
return True
|
||||
owners = _rfc_owners_set(rfc_slug)
|
||||
if not owners:
|
||||
# Same fall-through as can_discuss_rfc: until an owner exists,
|
||||
# the gate is open.
|
||||
return True
|
||||
if user.gitea_login in owners:
|
||||
return True
|
||||
return is_rfc_collaborator(user, rfc_slug, role_in_rfc="contributor")
|
||||
|
||||
|
||||
def can_invite_to_rfc(user: SessionUser | None, rfc_slug: str) -> bool:
|
||||
"""v0.16.0 — only RFC owners (frontmatter) and platform admin/owner
|
||||
can issue invitations. Per-RFC collaborators do not get the
|
||||
invite-others power; that stays with the RFC's owner."""
|
||||
if user is None:
|
||||
return False
|
||||
if user.permission_state != "granted":
|
||||
return False
|
||||
if user.role in ("owner", "admin"):
|
||||
return True
|
||||
return is_rfc_owner(user, rfc_slug)
|
||||
|
||||
|
||||
def new_state() -> str:
|
||||
return secrets.token_urlsafe(16)
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
"""§6.2 / v0.11.0: trust device for 30 days (roadmap item #9).
|
||||
|
||||
After a successful OTC or passcode sign-in, a contributor may check
|
||||
"trust this device for 30 days." The framework then issues a
|
||||
server-issued opaque token, hashes it (bcrypt) for storage in the
|
||||
`device_trust` table, and sets a long-lived cookie carrying the raw
|
||||
token. On a subsequent visit, the cookie is presented at
|
||||
`/auth/device-trust/start`; if a non-expired, non-revoked row matches,
|
||||
the session is re-established without another OTC / passcode round
|
||||
trip.
|
||||
|
||||
The shape:
|
||||
|
||||
* `issue(user_id, user_agent)` — mint a fresh CSPRNG token, hash it,
|
||||
insert a row, and return the raw token + row id so the endpoint
|
||||
can set the cookie. The 30-day expiry is the only knob; the
|
||||
`revoked_at` column stays NULL.
|
||||
* `lookup(raw_token)` — walk the user's active rows (the unique
|
||||
index keys on the hash, so we read a small candidate set), check
|
||||
the bcrypt hash in constant time, drop any row whose `expires_at`
|
||||
has passed or whose `revoked_at` is non-NULL, and return the
|
||||
matched row or None. On a hit, refresh `last_seen_at`.
|
||||
* `list_for_user(user_id)` — return the active rows for the
|
||||
/settings/devices surface. Revoked + expired rows are filtered out
|
||||
so the surface only shows live trust grants.
|
||||
* `revoke(user_id, row_id)` — stamp `revoked_at` on the row. The
|
||||
next lookup refuses the cookie token (the row is dead).
|
||||
* `revoke_all(user_id)` — bulk-revoke every active row for the user.
|
||||
The /settings/devices surface's "revoke all" button calls this.
|
||||
|
||||
Cookie shape: `rfc_device_trust`. HttpOnly, Secure, SameSite=Lax,
|
||||
Max-Age=2592000 (30 days), Path=/. The cookie value is the raw token;
|
||||
server-side storage is the hash. The cookie is "essential" per the
|
||||
v0.13.0 cookie-consent banner (it is part of authentication, not
|
||||
analytics), so the framework sets it regardless of analytics /
|
||||
other-cookies choices.
|
||||
|
||||
Constant-time comparison: bcrypt's `checkpw` is already constant-time
|
||||
over the hash bytes. We walk the candidate set linearly with `_check`
|
||||
which delegates to `bcrypt.checkpw`; no early-exit shortcut leaks
|
||||
which row was the match.
|
||||
|
||||
The raw token never appears in a log line or an exception message;
|
||||
the helpers carry the token only as a parameter and forget it after
|
||||
hashing.
|
||||
|
||||
The cookie sits orthogonal to the §6.1 `permission_state` gate: a
|
||||
revoked or pending user with a valid device-trust cookie still
|
||||
re-establishes their session (the cookie identifies the user, not
|
||||
their admission state), and the existing `require_contributor` /
|
||||
`require_admin` dependencies in `auth.py` continue to refuse the
|
||||
unrelated write surfaces.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
from dataclasses import dataclass
|
||||
|
||||
import bcrypt
|
||||
|
||||
from . import db
|
||||
from .auth import SessionUser
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tunables — hard-coded in v0.11.0 (§19.2 candidate to env-ify later).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
TRUST_DURATION_DAYS = 30
|
||||
COOKIE_NAME = "rfc_device_trust"
|
||||
COOKIE_MAX_AGE_SECONDS = TRUST_DURATION_DAYS * 24 * 60 * 60
|
||||
# 256 bits of CSPRNG entropy. `secrets.token_urlsafe(32)` yields ~43
|
||||
# URL-safe characters; the bcrypt hash is what's stored, so the raw
|
||||
# token only ever lives in the cookie.
|
||||
TOKEN_BYTES = 32
|
||||
# User-Agent header values seen in the wild can be unbounded; clamp
|
||||
# to a reasonable ceiling so a hostile UA doesn't bloat the row.
|
||||
USER_AGENT_MAX_LENGTH = 1024
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Issue
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class IssueOutcome:
|
||||
"""The shape returned from `issue`.
|
||||
|
||||
`raw_token` is the cookie value to send to the client; it never
|
||||
appears in storage. `row_id` is the surrogate key for the
|
||||
/settings/devices UI to address the row by id.
|
||||
"""
|
||||
raw_token: str
|
||||
row_id: int
|
||||
|
||||
|
||||
def _new_token() -> str:
|
||||
return secrets.token_urlsafe(TOKEN_BYTES)
|
||||
|
||||
|
||||
def _hash(token: str) -> str:
|
||||
return bcrypt.hashpw(token.encode("utf-8"), bcrypt.gensalt()).decode("ascii")
|
||||
|
||||
|
||||
def _check(token: str, token_hash: str) -> bool:
|
||||
try:
|
||||
return bcrypt.checkpw(token.encode("utf-8"), token_hash.encode("ascii"))
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
def _trim_user_agent(ua: str) -> str:
|
||||
ua = (ua or "").strip()
|
||||
if len(ua) > USER_AGENT_MAX_LENGTH:
|
||||
return ua[:USER_AGENT_MAX_LENGTH]
|
||||
return ua
|
||||
|
||||
|
||||
def issue(user_id: int, user_agent: str) -> IssueOutcome:
|
||||
"""Mint a fresh device-trust token + row for `user_id`.
|
||||
|
||||
The row's expiry is set 30 days in the future. The hash, not the
|
||||
raw token, lands in the database. The caller (the endpoint) sets
|
||||
the cookie with the raw token returned here.
|
||||
"""
|
||||
raw = _new_token()
|
||||
h = _hash(raw)
|
||||
ua = _trim_user_agent(user_agent)
|
||||
cur = db.conn().execute(
|
||||
f"""
|
||||
INSERT INTO device_trust (user_id, device_token_hash, expires_at, user_agent)
|
||||
VALUES (?, ?, datetime('now', '+{TRUST_DURATION_DAYS} days'), ?)
|
||||
""",
|
||||
(user_id, h, ua),
|
||||
)
|
||||
row_id = cur.lastrowid
|
||||
return IssueOutcome(raw_token=raw, row_id=row_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lookup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class LookupOutcome:
|
||||
"""The result of `lookup`.
|
||||
|
||||
`user` is populated only on a hit. `reason` distinguishes the
|
||||
failure modes so the endpoint can decide whether to clear the
|
||||
cookie ('expired', 'revoked', 'unknown') or just refuse ('invalid').
|
||||
"""
|
||||
ok: bool
|
||||
user: SessionUser | None
|
||||
reason: str # 'ok' | 'invalid' | 'unknown' | 'expired' | 'revoked'
|
||||
row_id: int | None = None
|
||||
|
||||
|
||||
def lookup(raw_token: str) -> LookupOutcome:
|
||||
"""Resolve a presented cookie token to a user.
|
||||
|
||||
A hit refreshes `last_seen_at` on the matched row. A miss returns
|
||||
a reason so the endpoint can clear the stale cookie if the row
|
||||
was revoked or expired (vs. simply unknown, which probably means
|
||||
the cookie was forged or the row was wiped by a /settings/devices
|
||||
revoke from another browser).
|
||||
"""
|
||||
raw = (raw_token or "").strip()
|
||||
if not raw:
|
||||
return LookupOutcome(ok=False, user=None, reason="invalid")
|
||||
|
||||
# The unique index on `device_token_hash` would let us SELECT by
|
||||
# hash if bcrypt were a stable hash, but bcrypt incorporates a
|
||||
# per-row salt — equal tokens produce different hashes. We walk
|
||||
# the candidate set instead. In practice the set is small (a
|
||||
# human has a handful of trusted devices) and bcrypt is cheap on
|
||||
# the order of milliseconds; the walk is bounded by the user's
|
||||
# active device count.
|
||||
#
|
||||
# We don't pre-filter by `revoked_at IS NULL` here so that a
|
||||
# token presented for a recently-revoked row produces a
|
||||
# 'revoked' outcome (the endpoint surfaces a different shape).
|
||||
# Same for expired: we let the walk hit and classify after.
|
||||
rows = db.conn().execute(
|
||||
"""
|
||||
SELECT id, user_id, device_token_hash, expires_at, revoked_at
|
||||
FROM device_trust
|
||||
ORDER BY id DESC
|
||||
""",
|
||||
).fetchall()
|
||||
|
||||
matched = None
|
||||
for row in rows:
|
||||
if _check(raw, row["device_token_hash"]):
|
||||
matched = row
|
||||
break
|
||||
|
||||
if matched is None:
|
||||
return LookupOutcome(ok=False, user=None, reason="unknown")
|
||||
|
||||
if matched["revoked_at"] is not None:
|
||||
return LookupOutcome(ok=False, user=None, reason="revoked", row_id=matched["id"])
|
||||
|
||||
expired = db.conn().execute(
|
||||
"SELECT datetime(?) < datetime('now') AS expired",
|
||||
(matched["expires_at"],),
|
||||
).fetchone()["expired"]
|
||||
if expired:
|
||||
return LookupOutcome(ok=False, user=None, reason="expired", row_id=matched["id"])
|
||||
|
||||
# Refresh last-seen so the /settings/devices surface can show the
|
||||
# user when each device was last active. This is the only write
|
||||
# the lookup path does on the hot read.
|
||||
db.conn().execute(
|
||||
"UPDATE device_trust SET last_seen_at = datetime('now') WHERE id = ?",
|
||||
(matched["id"],),
|
||||
)
|
||||
user_row = db.conn().execute(
|
||||
"""
|
||||
SELECT id, gitea_id, gitea_login, email, display_name, avatar_url, role, permission_state
|
||||
FROM users
|
||||
WHERE id = ?
|
||||
""",
|
||||
(matched["user_id"],),
|
||||
).fetchone()
|
||||
if user_row is None:
|
||||
# The user row was deleted but the device_trust row hadn't
|
||||
# cascaded yet (shouldn't happen under the FK ON DELETE
|
||||
# CASCADE — be defensive anyway). Treat as 'unknown' so the
|
||||
# endpoint clears the cookie.
|
||||
return LookupOutcome(ok=False, user=None, reason="unknown", row_id=matched["id"])
|
||||
|
||||
# Also stamp last_seen_at on the user row so the user's overall
|
||||
# activity stamp keeps pace with cookie-only sign-ins.
|
||||
db.conn().execute(
|
||||
"UPDATE users SET last_seen_at = datetime('now') WHERE id = ?",
|
||||
(matched["user_id"],),
|
||||
)
|
||||
|
||||
return LookupOutcome(
|
||||
ok=True,
|
||||
user=SessionUser(
|
||||
user_id=user_row["id"],
|
||||
gitea_id=user_row["gitea_id"] or 0,
|
||||
gitea_login=user_row["gitea_login"] or "",
|
||||
display_name=user_row["display_name"],
|
||||
email=user_row["email"] or "",
|
||||
avatar_url=user_row["avatar_url"] or "",
|
||||
role=user_row["role"],
|
||||
permission_state=user_row["permission_state"] or "granted",
|
||||
),
|
||||
reason="ok",
|
||||
row_id=matched["id"],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# List / revoke (for the /settings/devices surface)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeviceRow:
|
||||
"""The shape the /settings/devices endpoint returns.
|
||||
|
||||
Note the absence of `device_token_hash` — the hash is structurally
|
||||
private, and the surface has no use for it.
|
||||
"""
|
||||
id: int
|
||||
created_at: str
|
||||
expires_at: str
|
||||
last_seen_at: str
|
||||
user_agent: str
|
||||
|
||||
|
||||
def list_for_user(user_id: int) -> list[DeviceRow]:
|
||||
"""Active device-trust rows for the user, freshest first.
|
||||
|
||||
Filters out revoked rows and rows whose expiry has passed; the
|
||||
surface only shows live trust grants. A user wondering "which
|
||||
devices are signed in" gets the answer that matches what the
|
||||
framework would actually accept on a presented cookie.
|
||||
"""
|
||||
rows = db.conn().execute(
|
||||
"""
|
||||
SELECT id, created_at, expires_at, last_seen_at, user_agent
|
||||
FROM device_trust
|
||||
WHERE user_id = ?
|
||||
AND revoked_at IS NULL
|
||||
AND datetime(expires_at) > datetime('now')
|
||||
ORDER BY last_seen_at DESC, id DESC
|
||||
""",
|
||||
(user_id,),
|
||||
).fetchall()
|
||||
return [
|
||||
DeviceRow(
|
||||
id=row["id"],
|
||||
created_at=row["created_at"],
|
||||
expires_at=row["expires_at"],
|
||||
last_seen_at=row["last_seen_at"],
|
||||
user_agent=row["user_agent"] or "",
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def revoke(user_id: int, row_id: int) -> bool:
|
||||
"""Revoke a single device-trust row for the given user.
|
||||
|
||||
Returns True iff a row was matched (still active, belongs to the
|
||||
user). The user-id scope is enforced in SQL so a hostile client
|
||||
cannot revoke another user's row by guessing ids.
|
||||
"""
|
||||
cur = db.conn().execute(
|
||||
"""
|
||||
UPDATE device_trust
|
||||
SET revoked_at = datetime('now')
|
||||
WHERE id = ?
|
||||
AND user_id = ?
|
||||
AND revoked_at IS NULL
|
||||
""",
|
||||
(row_id, user_id),
|
||||
)
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
def revoke_all(user_id: int) -> int:
|
||||
"""Revoke every active device-trust row for the user. Returns the
|
||||
count of rows touched.
|
||||
|
||||
The /settings/devices "revoke all" button calls this. The user's
|
||||
current request stays authenticated via its session cookie; the
|
||||
device-trust cookie on the current device is also revoked, but
|
||||
the session middleware's `rfc_session` cookie keeps the request
|
||||
flow alive until the user signs out or the session cookie
|
||||
expires.
|
||||
"""
|
||||
cur = db.conn().execute(
|
||||
"""
|
||||
UPDATE device_trust
|
||||
SET revoked_at = datetime('now')
|
||||
WHERE user_id = ?
|
||||
AND revoked_at IS NULL
|
||||
""",
|
||||
(user_id,),
|
||||
)
|
||||
return cur.rowcount
|
||||
@@ -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",
|
||||
"super_draft_graduation_ready": "admin-actionable",
|
||||
"claim_opened": "structural",
|
||||
# v0.9.0: roadmap item #7. A fresh beta-access request lands as
|
||||
# an admin-actionable signal so it consults `email_admin_actionable`
|
||||
# and reaches owners/admins only.
|
||||
"new_beta_request": "admin-actionable",
|
||||
}
|
||||
|
||||
|
||||
@@ -285,6 +289,13 @@ def _deep_link(payload: dict, cfg: EmailConfig) -> str:
|
||||
slug = payload.get("rfc_slug")
|
||||
pr = payload.get("pr_number")
|
||||
branch = payload.get("branch_name")
|
||||
event_kind = payload.get("event_kind")
|
||||
# v0.9.0: framework-scoped admin signals link to the admin
|
||||
# surface, not /rfc/... The `new_beta_request` event is the
|
||||
# canonical example; future framework-scoped admin events
|
||||
# may reuse the same branch.
|
||||
if event_kind == "new_beta_request":
|
||||
return f"{cfg.app_url}/admin/users"
|
||||
if slug and pr:
|
||||
return f"{cfg.app_url}/rfc/{slug}/pr/{pr}"
|
||||
if slug and branch:
|
||||
|
||||
@@ -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,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
|
||||
]
|
||||
+371
-4
@@ -10,8 +10,8 @@ import logging
|
||||
import secrets
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import APIRouter, FastAPI, HTTPException, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi import APIRouter, FastAPI, HTTPException, Request, Response
|
||||
from fastapi.responses import JSONResponse, RedirectResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
@@ -20,11 +20,15 @@ from . import (
|
||||
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
|
||||
@@ -37,11 +41,55 @@ 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
|
||||
@@ -107,6 +155,48 @@ def create_app() -> FastAPI:
|
||||
app = create_app()
|
||||
|
||||
|
||||
def _set_device_trust_cookie(response: Response, raw_token: str) -> None:
|
||||
"""Attach the v0.11.0 device-trust cookie to the response.
|
||||
|
||||
HttpOnly + Secure + SameSite=Lax + 30-day Max-Age + Path=/. The
|
||||
cookie value is the raw token; server-side storage is the hash.
|
||||
The cookie is "essential" per the v0.13.0 cookie-consent contract
|
||||
(it is part of authentication), so we set it regardless of the
|
||||
user's analytics / other-cookies choice.
|
||||
|
||||
Secure=True means the cookie is only ever sent over HTTPS. The
|
||||
SessionMiddleware in `create_app` keeps `https_only=False` for
|
||||
dev parity, but the device-trust cookie holds a 30-day credential
|
||||
and must not travel cleartext — production deployments serve over
|
||||
HTTPS, so Secure on the device-trust cookie is non-negotiable.
|
||||
"""
|
||||
response.set_cookie(
|
||||
key=device_trust_mod.COOKIE_NAME,
|
||||
value=raw_token,
|
||||
max_age=device_trust_mod.COOKIE_MAX_AGE_SECONDS,
|
||||
path="/",
|
||||
secure=True,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
)
|
||||
|
||||
|
||||
def _clear_device_trust_cookie(response: Response) -> None:
|
||||
"""Delete the device-trust cookie on the response.
|
||||
|
||||
Used when the framework detects a presented cookie that is
|
||||
expired, revoked, or otherwise stale — the next request from
|
||||
this device will not carry a dead token.
|
||||
"""
|
||||
response.delete_cookie(
|
||||
key=device_trust_mod.COOKIE_NAME,
|
||||
path="/",
|
||||
secure=True,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
)
|
||||
|
||||
|
||||
def _oauth_router(config) -> APIRouter:
|
||||
router = APIRouter()
|
||||
|
||||
@@ -154,7 +244,27 @@ def _oauth_router(config) -> APIRouter:
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.post("/auth/otc/request")
|
||||
async def otc_request(body: OtcRequestBody):
|
||||
async def otc_request(body: OtcRequestBody, request: Request):
|
||||
# v0.12.0 / roadmap item #10: gate the request on a successful
|
||||
# Turnstile siteverify before the bcrypt hash + SMTP send. The
|
||||
# check runs first so a failed challenge spends no rate budget
|
||||
# and produces no envelope. When the operator has not wired the
|
||||
# secret AND TURNSTILE_REQUIRED=false (the default), the gate
|
||||
# opens — see `backend/app/turnstile.py` for the full matrix.
|
||||
client_ip = request.client.host if request.client else None
|
||||
ts = turnstile.verify_token(body.turnstile_token, client_ip=client_ip)
|
||||
if not ts.ok:
|
||||
if ts.reason == "misconfigured":
|
||||
# TURNSTILE_REQUIRED=true but the secret is unset. This
|
||||
# is an operator/config problem, not a client problem;
|
||||
# surface as 500 so the operator notices in their logs
|
||||
# rather than blaming the user's browser.
|
||||
raise HTTPException(500, "auth misconfigured")
|
||||
# missing-token / failed / network → uniform 400 so the
|
||||
# response does not enumerate which leg of the challenge
|
||||
# broke. The reason is in the server logs.
|
||||
raise HTTPException(400, "verification failed")
|
||||
|
||||
outcome = otc.request_code(body.email)
|
||||
if outcome.reason == "cooldown":
|
||||
# Loud failure per the rate-limit primitive — the abuse
|
||||
@@ -167,11 +277,121 @@ def _oauth_router(config) -> APIRouter:
|
||||
return {"ok": True}
|
||||
|
||||
@router.post("/auth/otc/verify")
|
||||
async def otc_verify(body: OtcVerifyBody, request: Request):
|
||||
async def otc_verify(body: OtcVerifyBody, request: Request, response: Response):
|
||||
result = otc.verify_code(body.email, body.code)
|
||||
if not result.ok or result.user is None:
|
||||
raise HTTPException(400, "Invalid or expired code")
|
||||
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": {
|
||||
@@ -182,4 +402,151 @@ def _oauth_router(config) -> APIRouter:
|
||||
},
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 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
|
||||
|
||||
@@ -64,6 +64,7 @@ log = logging.getLogger(__name__)
|
||||
CATEGORY_PERSONAL = "personal-direct"
|
||||
CATEGORY_STRUCTURAL = "structural"
|
||||
CATEGORY_CHURN = "churn"
|
||||
CATEGORY_ADMIN_ACTIONABLE = "admin-actionable"
|
||||
|
||||
# Action kinds whose actor's first interaction with a slug triggers
|
||||
# auto-watch per §15.6. The substantive-gesture list in the spec is
|
||||
@@ -208,6 +209,67 @@ def fan_out_from_action(
|
||||
)
|
||||
|
||||
|
||||
def fan_out_new_beta_request(
|
||||
*,
|
||||
requester_user_id: int,
|
||||
) -> None:
|
||||
"""v0.9.0 (roadmap item #7): announce a fresh beta-access request to
|
||||
every admin/owner.
|
||||
|
||||
Called from `POST /api/auth/me/beta-request` after the row's
|
||||
first/last/why fields are populated. Fan-out shape mirrors the §15
|
||||
chokepoint contract: one row per recipient, written via `_emit_one`
|
||||
so the SSE broadcast + email dispatch run through the same surface
|
||||
every other notification uses. The event has no rfc_slug (it is
|
||||
framework-scoped, not RFC-scoped); the deep-link payload points
|
||||
`/admin/users` instead of `/rfc/<slug>`.
|
||||
|
||||
Actor is the requester per §15.9 (the underlying user, never the
|
||||
bot). Category is `admin-actionable` so the §15.4 email gate
|
||||
consults `email_admin_actionable` (owners/admins-only by
|
||||
construction) and the digest exclusion rules treat it identically
|
||||
to other admin-actionable signals (graduation_ready et al).
|
||||
|
||||
Recipients are owners + admins minus the requester themselves
|
||||
(a self-promotion shouldn't reach the requester's own inbox). The
|
||||
requester is never in the role set in practice — the endpoint
|
||||
refuses 'granted'/'revoked' callers and a fresh OTC user lands
|
||||
`contributor`+`pending` — but we filter regardless so the call
|
||||
is robust to future changes in the auth gate.
|
||||
"""
|
||||
requester = db.conn().execute(
|
||||
"SELECT first_name, last_name, email, display_name FROM users WHERE id = ?",
|
||||
(requester_user_id,),
|
||||
).fetchone()
|
||||
if requester is None:
|
||||
return
|
||||
first = (requester["first_name"] or "").strip()
|
||||
last = (requester["last_name"] or "").strip()
|
||||
email = requester["email"] or ""
|
||||
display = requester["display_name"] or email or "a new user"
|
||||
full_name = (f"{first} {last}").strip() or display
|
||||
details = {
|
||||
"requester_user_id": requester_user_id,
|
||||
"requester_first_name": first,
|
||||
"requester_last_name": last,
|
||||
"requester_email": email,
|
||||
"requester_display": full_name,
|
||||
}
|
||||
for recipient_id in _admin_user_ids():
|
||||
if recipient_id == requester_user_id:
|
||||
continue
|
||||
_emit_one(
|
||||
recipient_user_id=recipient_id,
|
||||
event_kind="new_beta_request",
|
||||
category=CATEGORY_ADMIN_ACTIONABLE,
|
||||
actor_user_id=requester_user_id,
|
||||
rfc_slug=None,
|
||||
branch_name=None,
|
||||
pr_number=None,
|
||||
details=details,
|
||||
)
|
||||
|
||||
|
||||
def fan_out_chat_message(
|
||||
*,
|
||||
actor_user_id: int,
|
||||
@@ -707,6 +769,16 @@ def render_summary(event_kind: str, actor_display: str | None, rfc_title: str |
|
||||
return f"{actor} began graduating {title}."
|
||||
if event_kind == "pr_conflict_with_main":
|
||||
return f"{actor} started a resolution branch on {title}."
|
||||
if event_kind == "new_beta_request":
|
||||
# v0.9.0: framework-scoped, not RFC-scoped. The actor (the
|
||||
# requester) and the captured full name + email read as
|
||||
# one self-contained sentence; the inbox row and the email
|
||||
# body share this text per §15.4.
|
||||
full_name = extras.get("requester_display") or actor
|
||||
email_addr = extras.get("requester_email") or ""
|
||||
if email_addr:
|
||||
return f"New beta-access request from {full_name} ({email_addr})."
|
||||
return f"New beta-access request from {full_name}."
|
||||
return f"{event_kind} on {title}"
|
||||
|
||||
|
||||
|
||||
+52
-45
@@ -1,4 +1,4 @@
|
||||
"""§6.2 / v0.7.0: email + one-time-code sign-in.
|
||||
"""§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
|
||||
@@ -25,14 +25,25 @@ The shape:
|
||||
`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`.
|
||||
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. The allowlist gate
|
||||
from v0.3.0 is consulted at request time — if `allowed_emails` is
|
||||
populated and the requested address isn't on it, the request returns
|
||||
202 as usual but no email is sent. This intentionally does not leak
|
||||
allowlist state to the caller; the §19.2 candidate for v0.8.0
|
||||
replaces this gate with an admin-grant flow.
|
||||
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
|
||||
|
||||
@@ -44,7 +55,7 @@ from dataclasses import dataclass
|
||||
import bcrypt
|
||||
|
||||
from . import db
|
||||
from .auth import SessionUser, allowlist_is_active
|
||||
from .auth import SessionUser
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -101,25 +112,15 @@ def _check_code(code: str, code_hash: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Allowlist gate — shared with the OAuth flow.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _allowlist_admits(email: str) -> bool:
|
||||
"""The same allowlist v0.3.0 introduced for OAuth, applied to OTC
|
||||
requests. If the allowlist is populated and the email is not on it,
|
||||
we still respond 202 to the caller, but no code is sent."""
|
||||
if not allowlist_is_active():
|
||||
return True
|
||||
row = db.conn().execute(
|
||||
"SELECT 1 FROM allowed_emails WHERE email = ? LIMIT 1", (email,)
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -127,14 +128,16 @@ def _allowlist_admits(email: str) -> bool:
|
||||
class RequestOutcome:
|
||||
"""The outcome of a `request_code` call.
|
||||
|
||||
`code` is None whenever no code was generated — either because the
|
||||
allowlist denied the email or because the cooldown window blocked
|
||||
the request. The caller (the API endpoint) does not surface this
|
||||
distinction to the user; it returns 202 either way.
|
||||
`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' | 'allowlist' | 'cooldown' | 'invalid'
|
||||
reason: str # 'sent' | 'cooldown' | 'invalid'
|
||||
|
||||
|
||||
def request_code(email: str) -> RequestOutcome:
|
||||
@@ -160,13 +163,6 @@ def request_code(email: str) -> RequestOutcome:
|
||||
if row is not None:
|
||||
return RequestOutcome(sent=False, code=None, reason="cooldown")
|
||||
|
||||
# Allowlist: silently drop the send if the email isn't on the list.
|
||||
# The row is not written either — there's nothing for verify to
|
||||
# match against, so the user-facing experience is "I never got an
|
||||
# email", which is the intended shape for the private-beta gate.
|
||||
if not _allowlist_admits(email):
|
||||
return RequestOutcome(sent=False, code=None, reason="allowlist")
|
||||
|
||||
# Invalidate prior unused codes for this email. A re-request is
|
||||
# always for the most recent code; older codes are dead.
|
||||
db.conn().execute(
|
||||
@@ -275,12 +271,16 @@ def provision_or_link_user(email: str) -> SessionUser:
|
||||
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.
|
||||
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`. The display name defaults to the local
|
||||
part of the email (everything before the `@`) — users can
|
||||
rename later via the §19.2 first-OTC profile-capture flow
|
||||
that v0.8.0 introduces.
|
||||
`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
|
||||
@@ -307,13 +307,19 @@ def provision_or_link_user(email: str) -> SessionUser:
|
||||
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)
|
||||
VALUES (NULL, NULL, ?, ?, '', 'contributor')
|
||||
INSERT INTO users (gitea_id, gitea_login, email, display_name, avatar_url, role, permission_state)
|
||||
VALUES (NULL, NULL, ?, ?, '', 'contributor', 'pending')
|
||||
""",
|
||||
(email, display),
|
||||
)
|
||||
@@ -326,4 +332,5 @@ def provision_or_link_user(email: str) -> SessionUser:
|
||||
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,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,177 @@
|
||||
-- §6 / §10 / v0.16.0: owner-only invite for per-RFC contribution +
|
||||
-- discussion (roadmap item #12).
|
||||
--
|
||||
-- Distinct from a platform-level grant (`users.permission_state`,
|
||||
-- v0.8.0 / item #6). This row is per-RFC membership: the RFC's owner
|
||||
-- invites a specific email to either open PRs against that RFC
|
||||
-- (`role_in_rfc='contributor'`) or to participate in the RFC's PR-less
|
||||
-- discussion only (`role_in_rfc='discussant'`). Non-invited users keep
|
||||
-- the v0.6.0 anonymous-read contract — they can read but cannot
|
||||
-- write/discuss that specific RFC.
|
||||
--
|
||||
-- Coordinates with item #16's parallel work this wave: that item
|
||||
-- adds platform-wide invitation tokens; this one adds per-RFC
|
||||
-- collaboration rows. To avoid table-name + concept collisions the
|
||||
-- two surfaces are scoped distinctly — this migration owns slot 018
|
||||
-- and names everything `rfc_*` (RFC-scoped); #16 will use a later
|
||||
-- slot and name its tables under a different prefix (`invite_tokens`
|
||||
-- or similar) at the user/platform level.
|
||||
--
|
||||
-- Tables in this migration:
|
||||
--
|
||||
-- * `rfc_invitations` — one row per (rfc, invitee_email) invite
|
||||
-- issued by the RFC's owner. Carries the role-in-RFC the
|
||||
-- invitation grants, the opaque token the email link encodes,
|
||||
-- the lifecycle state, and the audit trail (who invited, when
|
||||
-- accepted, by which user_id if any).
|
||||
--
|
||||
-- * `rfc_collaborators` — one row per (rfc, user_id, role_in_rfc)
|
||||
-- after an invitation is accepted. This is the table the
|
||||
-- write-gate consults: "is the viewer named here for this RFC?"
|
||||
-- Separating the two means the invitation row carries the
|
||||
-- issue/accept lifecycle while the collaborator row is the
|
||||
-- compact membership-check substrate. A grant via collaborator
|
||||
-- can exist independently of a live invitation (admin-only
|
||||
-- direct insert is a §19.2 candidate; v0.16.0 only writes
|
||||
-- collaborator rows via the accept path).
|
||||
--
|
||||
-- Authorization model the application layer enforces on top of these
|
||||
-- rows (not encoded in SQL — the schema is just storage):
|
||||
--
|
||||
-- * Writes (open PR, post discussion message, open discussion
|
||||
-- thread) to an RFC require ONE of:
|
||||
-- (a) the viewer is named in this RFC's `rfc_collaborators`
|
||||
-- with the appropriate role_in_rfc, OR
|
||||
-- (b) the viewer holds a globally privileged role (admin,
|
||||
-- owner of the platform) per the existing §6 helpers, OR
|
||||
-- (c) the viewer is named in the RFC's frontmatter owners
|
||||
-- list (the §6 RFC-owner concept, which already grants
|
||||
-- the maximal per-RFC capability).
|
||||
--
|
||||
-- * Reads remain on the v0.6.0 anonymous-read contract — anyone
|
||||
-- can read any non-withdrawn RFC. Item #12 does not narrow this.
|
||||
--
|
||||
-- * Only the RFC's owner (per `cached_rfcs.owners_json`) can
|
||||
-- invite. App admins/owners also can (they have the maximal
|
||||
-- per-RFC capability by construction).
|
||||
--
|
||||
-- Storage shape — `rfc_invitations`:
|
||||
--
|
||||
-- * `id` — surrogate key; the revoke-by-id surface addresses a
|
||||
-- single row without leaking the token shape.
|
||||
--
|
||||
-- * `rfc_slug` — TEXT NOT NULL; the RFC the invitation scopes to.
|
||||
-- We FK against `cached_rfcs(slug)` so a withdrawn/deleted RFC
|
||||
-- cascades its invitations away cleanly. The §4 cache contract
|
||||
-- says cached_rfcs is rebuildable from Gitea; per the same
|
||||
-- contract, invitations are app-truth (no Git substrate), so
|
||||
-- the cascade is the right direction.
|
||||
--
|
||||
-- * `inviter_user_id` — the owner who issued the invite. ON
|
||||
-- DELETE SET NULL because losing the inviter's user row should
|
||||
-- not cascade-delete invitations they sent (the row stays as
|
||||
-- audit; the UI renders "by (deleted user)" the same way the
|
||||
-- audit log does for orphaned actors).
|
||||
--
|
||||
-- * `invitee_email` — TEXT NOT NULL; the email the invitation
|
||||
-- was sent to. Stored verbatim (case-preserved) so the email
|
||||
-- body can address the invitee in their original shape; the
|
||||
-- accept path matches case-insensitively.
|
||||
--
|
||||
-- * `role_in_rfc` — CHECK in {'contributor' | 'discussant'}.
|
||||
-- `contributor` lets the user open PRs against the RFC AND
|
||||
-- post in its discussion (PR-permission strictly includes
|
||||
-- discussion-permission); `discussant` only lets them post
|
||||
-- in discussion. Future roles (e.g., 'arbiter') would be
|
||||
-- additions; v0.16.0 ships the two.
|
||||
--
|
||||
-- * `status` — CHECK in {'pending' | 'accepted' | 'revoked' |
|
||||
-- 'expired'}. Default 'pending'. `accepted` flips on the
|
||||
-- accept endpoint; `revoked` on the owner's revoke gesture;
|
||||
-- `expired` lazily on read (the accept endpoint refuses a
|
||||
-- row whose expires_at has passed, regardless of the column
|
||||
-- value).
|
||||
--
|
||||
-- * `token` — opaque high-entropy string the email link
|
||||
-- encodes. Stored verbatim (not hashed) because the
|
||||
-- invitation token is single-use and lower-stakes than a
|
||||
-- session token: it grants per-RFC role only, and is bounded
|
||||
-- by expires_at. Hashing the token here is a §19.2 candidate
|
||||
-- if/when the threat model demands it. UNIQUE so the accept
|
||||
-- path is a single-row lookup.
|
||||
--
|
||||
-- * `expires_at` — TEXT timestamp. Set to `created_at + 30 days`
|
||||
-- at insert time by the application layer. Accept refuses past
|
||||
-- this point; the row can still be revoked or re-issued.
|
||||
--
|
||||
-- * `created_at` — when the invitation was issued.
|
||||
--
|
||||
-- * `accepted_at` — when the invitee accepted (NULL until then).
|
||||
--
|
||||
-- * `accepted_by_user_id` — the user row that accepted. NULL
|
||||
-- until acceptance. On a fresh email (no platform user yet)
|
||||
-- the accept endpoint requires the invitee to sign in first
|
||||
-- via the v0.7.0 OTC path; that path provisions the user row,
|
||||
-- after which the accept call lands the user_id here.
|
||||
--
|
||||
-- Indexing:
|
||||
--
|
||||
-- * UNIQUE on `token` so the accept lookup is a primary-key-shape
|
||||
-- hit and accidental collisions are detectable at insert time.
|
||||
-- * (rfc_slug, status) for the owner's "list pending/accepted for
|
||||
-- this RFC" surface — the most frequent query.
|
||||
-- * (invitee_email, status) for a future cross-RFC "show me my
|
||||
-- pending invites" inbox; v0.16.0 doesn't ship that surface but
|
||||
-- the index slot is cheap and aligned with the data shape.
|
||||
--
|
||||
-- Storage shape — `rfc_collaborators`:
|
||||
--
|
||||
-- * `id` — surrogate key.
|
||||
-- * `rfc_slug` — TEXT NOT NULL FK cached_rfcs(slug) ON DELETE CASCADE.
|
||||
-- * `user_id` — INTEGER NOT NULL FK users(id) ON DELETE CASCADE.
|
||||
-- A deleted user loses every per-RFC role automatically (mirrors
|
||||
-- the device_trust / passcode cascade shape).
|
||||
-- * `role_in_rfc` — same CHECK as the invitation table.
|
||||
-- * `invitation_id` — INTEGER FK rfc_invitations(id) ON DELETE
|
||||
-- SET NULL. Audit pointer to the row that minted this
|
||||
-- collaborator; NULL is allowed so a future admin-direct grant
|
||||
-- path (a §19.2 candidate) can mint a collaborator with no
|
||||
-- originating invitation. v0.16.0 always populates this.
|
||||
-- * `created_at` — when the collaborator row was minted.
|
||||
--
|
||||
-- Indexing on collaborators:
|
||||
-- * UNIQUE on (rfc_slug, user_id) — a single user can hold at most
|
||||
-- one role per RFC. Re-accepting an invitation upgrades the row
|
||||
-- (discussant → contributor) but never duplicates.
|
||||
-- * (user_id) for "what RFCs am I a collaborator on?" reads.
|
||||
|
||||
CREATE TABLE rfc_invitations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
rfc_slug TEXT NOT NULL REFERENCES cached_rfcs(slug) ON DELETE CASCADE,
|
||||
inviter_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
invitee_email TEXT NOT NULL,
|
||||
role_in_rfc TEXT NOT NULL CHECK (role_in_rfc IN ('contributor', 'discussant')),
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending', 'accepted', 'revoked', 'expired')),
|
||||
token TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
accepted_at TEXT,
|
||||
accepted_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_rfc_invitations_token ON rfc_invitations (token);
|
||||
CREATE INDEX idx_rfc_invitations_rfc_status ON rfc_invitations (rfc_slug, status);
|
||||
CREATE INDEX idx_rfc_invitations_email_status ON rfc_invitations (invitee_email, status);
|
||||
|
||||
CREATE TABLE rfc_collaborators (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
rfc_slug TEXT NOT NULL REFERENCES cached_rfcs(slug) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role_in_rfc TEXT NOT NULL CHECK (role_in_rfc IN ('contributor', 'discussant')),
|
||||
invitation_id INTEGER REFERENCES rfc_invitations(id) ON DELETE SET NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_rfc_collaborators_unique ON rfc_collaborators (rfc_slug, user_id);
|
||||
CREATE INDEX idx_rfc_collaborators_user ON rfc_collaborators (user_id);
|
||||
@@ -0,0 +1,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);
|
||||
@@ -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,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,494 @@
|
||||
"""End-to-end integration tests for the v0.11.0 trust-device vertical
|
||||
(§6.2, roadmap item #9).
|
||||
|
||||
After a successful OTC or passcode sign-in with `trust_device=true`
|
||||
on the body, the server mints a fresh `device_trust` row and sets the
|
||||
`rfc_device_trust` cookie. On a subsequent visit, the cookie carries
|
||||
a session re-established by `POST /auth/device-trust/start`. The
|
||||
tests below prove:
|
||||
|
||||
* `trust_device=false` (default, including omitted) on OTC verify
|
||||
does NOT set the device-trust cookie and does NOT insert a row.
|
||||
* `trust_device=true` on OTC verify DOES set the cookie (HttpOnly +
|
||||
Secure + SameSite=Lax + 30-day Max-Age) and DOES insert a row.
|
||||
The row's hash is NOT the raw token; only the hash lives in the
|
||||
database.
|
||||
* Same shape for passcode verify.
|
||||
* On a returning visit with the cookie, `POST /auth/device-trust/start`
|
||||
re-establishes the session — `GET /api/auth/me` reads the right
|
||||
user without an OTC roundtrip.
|
||||
* `last_seen_at` refreshes on a successful lookup.
|
||||
* `POST /auth/device-trust/start` with no cookie returns 401.
|
||||
* `POST /auth/device-trust/start` with a forged / unknown cookie
|
||||
returns 401 + clears the cookie.
|
||||
* A revoked row refuses the cookie (401) and clears it.
|
||||
* An expired row refuses the cookie (401) and clears it.
|
||||
* `GET /api/auth/me/devices` lists the user's active rows.
|
||||
* `DELETE /api/auth/me/devices/{id}` revokes a single row.
|
||||
* `DELETE /api/auth/me/devices/{id}` for another user's row reads 404.
|
||||
* `DELETE /api/auth/me/devices` revokes every active row.
|
||||
* Constant-time path: bcrypt.checkpw guards lookup; the raw token
|
||||
is never written to logs or to the DB.
|
||||
|
||||
The fakes from `test_propose_vertical` give us a working app harness.
|
||||
The OTC envelope buffer from `test_otc_vertical` is reused for the
|
||||
OTC roundtrips this suite needs.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
FakeGitea,
|
||||
app_with_fake_gitea,
|
||||
provision_user_row,
|
||||
tmp_env,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers — mirror the OTC suite's outbound-buffer helpers.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
COOKIE_NAME = "rfc_device_trust"
|
||||
|
||||
# The device-trust cookie is set with Secure=True, which httpx (the
|
||||
# TestClient's underlying transport) will only return on an https
|
||||
# scheme. We use a `base_url="https://testserver"` so the cookie
|
||||
# roundtrips faithfully — that mirrors how production deployments
|
||||
# serve the framework (per the v0.11.0 upgrade-step requiring HTTPS).
|
||||
HTTPS_BASE = "https://testserver"
|
||||
|
||||
|
||||
def _reset_outbound():
|
||||
from app import email as email_mod
|
||||
email_mod.reset_sent_envelopes()
|
||||
|
||||
|
||||
def _outbound_otc_codes(to_address: str | None = None) -> list[str]:
|
||||
from app import email as email_mod
|
||||
out = []
|
||||
for env in email_mod.sent_envelopes():
|
||||
if env.get("kind") != "otc":
|
||||
continue
|
||||
if to_address is not None and env["to"] != to_address:
|
||||
continue
|
||||
for line in env["body"].splitlines():
|
||||
tok = line.strip()
|
||||
if tok.isdigit() and len(tok) == 6:
|
||||
out.append(tok)
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def _sign_in_via_otc(client, email: str, *, trust_device: bool = False) -> None:
|
||||
r = client.post("/auth/otc/request", json={"email": email})
|
||||
assert r.status_code == 200, r.text
|
||||
code = _outbound_otc_codes(email)[-1]
|
||||
body = {"email": email, "code": code, "trust_device": trust_device}
|
||||
r = client.post("/auth/otc/verify", json=body)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
def _device_rows_for_email(email: str) -> list[dict]:
|
||||
from app import db
|
||||
rows = db.conn().execute(
|
||||
"""
|
||||
SELECT dt.*
|
||||
FROM device_trust dt
|
||||
JOIN users u ON u.id = dt.user_id
|
||||
WHERE u.email = ? COLLATE NOCASE
|
||||
ORDER BY dt.id
|
||||
""",
|
||||
(email,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# trust_device flag controls cookie issuance
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_otc_verify_without_trust_device_does_not_issue_cookie(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app, base_url=HTTPS_BASE) as client:
|
||||
_reset_outbound()
|
||||
_sign_in_via_otc(client, "alice@example.com", trust_device=False)
|
||||
# No cookie set on the response.
|
||||
assert COOKIE_NAME not in {c.name for c in client.cookies.jar}
|
||||
# No row inserted.
|
||||
assert _device_rows_for_email("alice@example.com") == []
|
||||
|
||||
|
||||
def test_otc_verify_with_trust_device_issues_cookie_and_row(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app, base_url=HTTPS_BASE) as client:
|
||||
_reset_outbound()
|
||||
r = client.post("/auth/otc/request", json={"email": "alice@example.com"})
|
||||
assert r.status_code == 200, r.text
|
||||
code = _outbound_otc_codes("alice@example.com")[-1]
|
||||
r = client.post(
|
||||
"/auth/otc/verify",
|
||||
json={"email": "alice@example.com", "code": code, "trust_device": True},
|
||||
headers={"User-Agent": "Mozilla/5.0 (TestBrowser)"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# Cookie present on the response.
|
||||
set_cookie = r.headers.get("set-cookie", "")
|
||||
assert COOKIE_NAME in set_cookie
|
||||
# Cookie attribute set asserts the spec'd shape. Starlette emits
|
||||
# the attribute names case-insensitively (`samesite=lax`,
|
||||
# `httponly`); we normalize when asserting.
|
||||
lower = set_cookie.lower()
|
||||
assert "httponly" in lower
|
||||
assert "secure" in lower
|
||||
assert "samesite=lax" in lower
|
||||
assert "max-age=" in lower
|
||||
|
||||
# Row inserted; hash is not the raw token.
|
||||
rows = _device_rows_for_email("alice@example.com")
|
||||
assert len(rows) == 1
|
||||
row = rows[0]
|
||||
assert row["revoked_at"] is None
|
||||
assert row["user_agent"] == "Mozilla/5.0 (TestBrowser)"
|
||||
cookie_token = client.cookies.get(COOKIE_NAME)
|
||||
assert cookie_token
|
||||
assert cookie_token != row["device_token_hash"]
|
||||
# bcrypt hash shape (starts with $2)
|
||||
assert row["device_token_hash"].startswith("$2")
|
||||
|
||||
|
||||
def test_passcode_verify_with_trust_device_issues_cookie(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app, base_url=HTTPS_BASE) as client:
|
||||
_reset_outbound()
|
||||
_sign_in_via_otc(client, "alice@example.com")
|
||||
|
||||
# Set a passcode.
|
||||
r = client.post("/auth/passcode/set", json={"passcode": "secret123"})
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# Sign out so the passcode verify path is the active sign-in.
|
||||
client.cookies.clear()
|
||||
|
||||
# Passcode verify with trust_device=true issues a row.
|
||||
r = client.post(
|
||||
"/auth/passcode/verify",
|
||||
json={"email": "alice@example.com", "passcode": "secret123", "trust_device": True},
|
||||
headers={"User-Agent": "Test/Phone"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
set_cookie = r.headers.get("set-cookie", "")
|
||||
assert COOKIE_NAME in set_cookie
|
||||
|
||||
rows = _device_rows_for_email("alice@example.com")
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["user_agent"] == "Test/Phone"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /auth/device-trust/start
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_device_trust_start_with_no_cookie_returns_401(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app, base_url=HTTPS_BASE) as client:
|
||||
r = client.post("/auth/device-trust/start")
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_device_trust_start_with_valid_cookie_establishes_session(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app, base_url=HTTPS_BASE) as client:
|
||||
_reset_outbound()
|
||||
# Trust the device.
|
||||
r = client.post("/auth/otc/request", json={"email": "alice@example.com"})
|
||||
assert r.status_code == 200, r.text
|
||||
code = _outbound_otc_codes("alice@example.com")[-1]
|
||||
r = client.post(
|
||||
"/auth/otc/verify",
|
||||
json={"email": "alice@example.com", "code": code, "trust_device": True},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
trust_cookie = client.cookies.get(COOKIE_NAME)
|
||||
assert trust_cookie
|
||||
|
||||
# Clear the session cookie so only the device-trust cookie is in
|
||||
# play. We keep `rfc_device_trust` and drop `rfc_session`.
|
||||
for cookie in list(client.cookies.jar):
|
||||
if cookie.name != COOKIE_NAME:
|
||||
client.cookies.jar.clear(cookie.domain, cookie.path, cookie.name)
|
||||
|
||||
# The session cookie is gone — /api/auth/me reads anonymous.
|
||||
me = client.get("/api/auth/me").json()
|
||||
assert me["authenticated"] is False
|
||||
|
||||
# Hit the trust-start endpoint; the cookie re-establishes the session.
|
||||
r = client.post("/auth/device-trust/start")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["user"]["email"] == "alice@example.com"
|
||||
|
||||
# /api/auth/me now reads authenticated.
|
||||
me = client.get("/api/auth/me").json()
|
||||
assert me["authenticated"] is True
|
||||
assert me["user"]["email"] == "alice@example.com"
|
||||
|
||||
|
||||
def test_device_trust_start_refreshes_last_seen_at(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app, base_url=HTTPS_BASE) as client:
|
||||
_reset_outbound()
|
||||
r = client.post("/auth/otc/request", json={"email": "alice@example.com"})
|
||||
assert r.status_code == 200, r.text
|
||||
code = _outbound_otc_codes("alice@example.com")[-1]
|
||||
r = client.post(
|
||||
"/auth/otc/verify",
|
||||
json={"email": "alice@example.com", "code": code, "trust_device": True},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# Force the existing row's last_seen_at into the past so we can
|
||||
# assert the refresh moved it forward.
|
||||
db.conn().execute(
|
||||
"""
|
||||
UPDATE device_trust
|
||||
SET last_seen_at = datetime('now', '-7 days')
|
||||
"""
|
||||
)
|
||||
|
||||
# Hit the start endpoint.
|
||||
r = client.post("/auth/device-trust/start")
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# last_seen_at is now recent (within the last minute).
|
||||
row = db.conn().execute(
|
||||
"SELECT last_seen_at, datetime('now') >= datetime(last_seen_at, '-1 minute') AS fresh FROM device_trust LIMIT 1"
|
||||
).fetchone()
|
||||
assert row["fresh"] == 1
|
||||
|
||||
|
||||
def test_device_trust_start_with_revoked_row_refuses_and_clears(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app, base_url=HTTPS_BASE) as client:
|
||||
_reset_outbound()
|
||||
r = client.post("/auth/otc/request", json={"email": "alice@example.com"})
|
||||
assert r.status_code == 200, r.text
|
||||
code = _outbound_otc_codes("alice@example.com")[-1]
|
||||
r = client.post(
|
||||
"/auth/otc/verify",
|
||||
json={"email": "alice@example.com", "code": code, "trust_device": True},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# Revoke the row out-of-band.
|
||||
db.conn().execute(
|
||||
"UPDATE device_trust SET revoked_at = datetime('now')"
|
||||
)
|
||||
|
||||
# Now the start endpoint refuses + clears the cookie.
|
||||
r = client.post("/auth/device-trust/start")
|
||||
assert r.status_code == 401
|
||||
# The cookie is cleared via a Set-Cookie header with Max-Age=0
|
||||
# (Starlette's `delete_cookie` shape).
|
||||
set_cookie = r.headers.get("set-cookie", "")
|
||||
assert COOKIE_NAME in set_cookie
|
||||
assert "Max-Age=0" in set_cookie or 'expires=Thu, 01 Jan 1970' in set_cookie.lower().replace("expires=thu", "expires=Thu")
|
||||
|
||||
|
||||
def test_device_trust_start_with_expired_row_refuses_and_clears(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app, base_url=HTTPS_BASE) as client:
|
||||
_reset_outbound()
|
||||
r = client.post("/auth/otc/request", json={"email": "alice@example.com"})
|
||||
assert r.status_code == 200, r.text
|
||||
code = _outbound_otc_codes("alice@example.com")[-1]
|
||||
r = client.post(
|
||||
"/auth/otc/verify",
|
||||
json={"email": "alice@example.com", "code": code, "trust_device": True},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# Backdate the expiry into the past.
|
||||
db.conn().execute(
|
||||
"UPDATE device_trust SET expires_at = datetime('now', '-1 day')"
|
||||
)
|
||||
|
||||
r = client.post("/auth/device-trust/start")
|
||||
assert r.status_code == 401
|
||||
set_cookie = r.headers.get("set-cookie", "")
|
||||
assert COOKIE_NAME in set_cookie
|
||||
|
||||
|
||||
def test_device_trust_start_with_forged_cookie_refuses_and_clears(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app, base_url=HTTPS_BASE) as client:
|
||||
# No real row; just paste a cookie value.
|
||||
client.cookies.set(COOKIE_NAME, "definitely-not-a-real-token-value-xxx")
|
||||
r = client.post("/auth/device-trust/start")
|
||||
assert r.status_code == 401
|
||||
set_cookie = r.headers.get("set-cookie", "")
|
||||
assert COOKIE_NAME in set_cookie
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /api/auth/me/devices — list + revoke
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_list_devices_requires_session(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app, base_url=HTTPS_BASE) as client:
|
||||
r = client.get("/api/auth/me/devices")
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_list_devices_returns_active_rows_only(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app, base_url=HTTPS_BASE) as client:
|
||||
_reset_outbound()
|
||||
_sign_in_via_otc(client, "alice@example.com", trust_device=True)
|
||||
|
||||
# Add a second trusted device by re-running the verify flow.
|
||||
# OTC has a per-email cooldown, so drop the cooldown rather
|
||||
# than waiting it out.
|
||||
db.conn().execute("UPDATE otc_codes SET consumed_at = datetime('now', '-1 hour'), created_at = datetime('now', '-1 hour')")
|
||||
r = client.post("/auth/otc/request", json={"email": "alice@example.com"})
|
||||
assert r.status_code == 200, r.text
|
||||
code = _outbound_otc_codes("alice@example.com")[-1]
|
||||
r = client.post(
|
||||
"/auth/otc/verify",
|
||||
json={"email": "alice@example.com", "code": code, "trust_device": True},
|
||||
headers={"User-Agent": "Test/Tablet"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# Revoke one row directly.
|
||||
db.conn().execute(
|
||||
"UPDATE device_trust SET revoked_at = datetime('now') WHERE id = 1"
|
||||
)
|
||||
|
||||
# /api/auth/me/devices returns only the un-revoked one.
|
||||
r = client.get("/api/auth/me/devices")
|
||||
assert r.status_code == 200, r.text
|
||||
items = r.json()["items"]
|
||||
assert len(items) == 1
|
||||
assert items[0]["user_agent"] == "Test/Tablet"
|
||||
|
||||
|
||||
def test_revoke_single_device_kills_the_row(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app, base_url=HTTPS_BASE) as client:
|
||||
_reset_outbound()
|
||||
_sign_in_via_otc(client, "alice@example.com", trust_device=True)
|
||||
|
||||
r = client.get("/api/auth/me/devices")
|
||||
assert r.status_code == 200, r.text
|
||||
items = r.json()["items"]
|
||||
assert len(items) == 1
|
||||
device_id = items[0]["id"]
|
||||
|
||||
# Revoke it.
|
||||
r = client.delete(f"/api/auth/me/devices/{device_id}")
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# List is empty.
|
||||
r = client.get("/api/auth/me/devices")
|
||||
assert r.json()["items"] == []
|
||||
|
||||
# The row in the table has revoked_at populated.
|
||||
row = db.conn().execute(
|
||||
"SELECT revoked_at FROM device_trust WHERE id = ?", (device_id,)
|
||||
).fetchone()
|
||||
assert row["revoked_at"] is not None
|
||||
|
||||
|
||||
def test_revoke_other_users_device_reads_404(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app, base_url=HTTPS_BASE) as client:
|
||||
_reset_outbound()
|
||||
# Alice trusts a device.
|
||||
_sign_in_via_otc(client, "alice@example.com", trust_device=True)
|
||||
alice_device_id = client.get("/api/auth/me/devices").json()["items"][0]["id"]
|
||||
|
||||
# Bob signs in (without a trusted device of his own).
|
||||
client.cookies.clear()
|
||||
db.conn().execute("UPDATE otc_codes SET consumed_at = datetime('now', '-1 hour'), created_at = datetime('now', '-1 hour')")
|
||||
_sign_in_via_otc(client, "bob@example.com", trust_device=False)
|
||||
|
||||
# Bob tries to revoke Alice's row by id.
|
||||
r = client.delete(f"/api/auth/me/devices/{alice_device_id}")
|
||||
assert r.status_code == 404
|
||||
|
||||
# Alice's row is still active.
|
||||
row = db.conn().execute(
|
||||
"SELECT revoked_at FROM device_trust WHERE id = ?", (alice_device_id,)
|
||||
).fetchone()
|
||||
assert row["revoked_at"] is None
|
||||
|
||||
|
||||
def test_revoke_all_devices_kills_every_active_row(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app, base_url=HTTPS_BASE) as client:
|
||||
_reset_outbound()
|
||||
_sign_in_via_otc(client, "alice@example.com", trust_device=True)
|
||||
|
||||
# Add a second device.
|
||||
db.conn().execute("UPDATE otc_codes SET consumed_at = datetime('now', '-1 hour'), created_at = datetime('now', '-1 hour')")
|
||||
r = client.post("/auth/otc/request", json={"email": "alice@example.com"})
|
||||
assert r.status_code == 200, r.text
|
||||
code = _outbound_otc_codes("alice@example.com")[-1]
|
||||
r = client.post(
|
||||
"/auth/otc/verify",
|
||||
json={"email": "alice@example.com", "code": code, "trust_device": True},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# Two active rows.
|
||||
assert len(client.get("/api/auth/me/devices").json()["items"]) == 2
|
||||
|
||||
# Revoke all.
|
||||
r = client.delete("/api/auth/me/devices")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["revoked"] == 2
|
||||
|
||||
# List is empty.
|
||||
assert client.get("/api/auth/me/devices").json()["items"] == []
|
||||
@@ -22,6 +22,7 @@ import pytest
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
FakeGitea,
|
||||
app_with_fake_gitea,
|
||||
grant_rfc_collaborator,
|
||||
provision_user_row,
|
||||
sign_in_as,
|
||||
tmp_env,
|
||||
@@ -131,6 +132,11 @@ def test_full_user_lifecycle_propose_through_hygiene(app_with_fake_gitea):
|
||||
assert d["repo"] == "wiggleverse/rfc-0001-ohm"
|
||||
|
||||
# --- 8. Alice opens a PR on the now-active RFC's per-RFC repo. ---
|
||||
# v0.16.0 (item #12): ben is the RFC owner now; alice needs a
|
||||
# per-RFC contributor invitation to cut a branch. In the
|
||||
# production flow, ben would invite her via /invitations and
|
||||
# she'd accept; we shortcut to the same end-state.
|
||||
grant_rfc_collaborator(user_id=2, rfc_slug="ohm", role_in_rfc="contributor")
|
||||
sign_in_as(client, user_id=2, gitea_login="alice",
|
||||
display_name="Alice", role="contributor", email="alice@test")
|
||||
r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={})
|
||||
|
||||
@@ -34,6 +34,7 @@ import pytest
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
FakeGitea,
|
||||
app_with_fake_gitea,
|
||||
grant_rfc_collaborator,
|
||||
provision_user_row,
|
||||
sign_in_as,
|
||||
tmp_env,
|
||||
@@ -248,6 +249,9 @@ def test_graduate_refuses_when_body_edit_pr_open(app_with_fake_gitea):
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
||||
pitch=PITCH, owners=["ben"])
|
||||
# v0.16.0 (item #12): ben is the RFC owner; alice needs a per-RFC
|
||||
# contributor invitation to cut an edit branch on the super-draft.
|
||||
grant_rfc_collaborator(user_id=2, rfc_slug="ohm", role_in_rfc="contributor")
|
||||
sign_in_as(client, user_id=2, gitea_login="alice",
|
||||
display_name="Alice", role="contributor")
|
||||
|
||||
@@ -497,6 +501,8 @@ def test_pre_graduation_history_surfaces_edit_branch_threads(app_with_fake_gitea
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
||||
pitch=PITCH, owners=["ben"])
|
||||
# v0.16.0 (item #12): alice needs per-RFC contributor access.
|
||||
grant_rfc_collaborator(user_id=2, rfc_slug="ohm", role_in_rfc="contributor")
|
||||
|
||||
# Alice cuts an edit branch and starts chatting on it.
|
||||
sign_in_as(client, user_id=2, gitea_login="alice",
|
||||
|
||||
@@ -13,9 +13,13 @@ sign-in path. The tests prove:
|
||||
* Expired codes refuse with 400.
|
||||
* Already-consumed codes refuse with 400 on re-use.
|
||||
* Wrong codes refuse with 400.
|
||||
* Allowlist gate: when `allowed_emails` is populated and the email
|
||||
isn't on it, the response is still 202 (no leak), but no email
|
||||
lands in the outbound buffer and verify finds no matching code.
|
||||
* 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.
|
||||
@@ -201,33 +205,45 @@ def test_otc_request_cooldown_is_per_email_not_global(app_with_fake_gitea):
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Allowlist gate
|
||||
# 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_silently_drops_when_email_not_on_allowlist(app_with_fake_gitea):
|
||||
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 so the gate turns on.
|
||||
# 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"})
|
||||
# Still 202 — the allowlist's state is not leaked to callers.
|
||||
assert r.status_code == 200
|
||||
# But no email was sent, and no row landed in otc_codes.
|
||||
assert _outbound_otc_codes("stranger@example.com") == []
|
||||
row = db.conn().execute(
|
||||
"SELECT 1 FROM otc_codes WHERE email = ?",
|
||||
("stranger@example.com",),
|
||||
).fetchone()
|
||||
assert row is None
|
||||
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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -21,6 +21,7 @@ import pytest
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
FakeGitea,
|
||||
app_with_fake_gitea,
|
||||
grant_rfc_collaborator,
|
||||
provision_user_row,
|
||||
sign_in_as,
|
||||
tmp_env,
|
||||
@@ -140,6 +141,9 @@ def test_get_pr_returns_three_column_payload(app_with_fake_gitea):
|
||||
provision_user_row(user_id=3, login="bob", role="contributor")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
# Bob is the non-arbiter contributor — alice is seeded as an RFC owner.
|
||||
# v0.16.0 (item #12): bob needs an accepted per-RFC contributor
|
||||
# invitation to cut branches and open PRs on alice's RFC.
|
||||
grant_rfc_collaborator(user_id=3, rfc_slug="ohm", role_in_rfc="contributor")
|
||||
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob", role="contributor")
|
||||
branch, _ = _cut_branch_and_accept_change(
|
||||
client, fake, slug="ohm",
|
||||
@@ -292,6 +296,9 @@ def test_merge_by_arbiter_advances_main_and_marks_pr_merged(app_with_fake_gitea)
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
# Bob is neither owner nor arbiter — the non-merge baseline.
|
||||
# v0.16.0 (item #12): bob still needs an accepted contributor
|
||||
# invitation to cut the branch + open the PR.
|
||||
grant_rfc_collaborator(user_id=3, rfc_slug="ohm", role_in_rfc="contributor")
|
||||
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob", role="contributor")
|
||||
branch, _ = _cut_branch_and_accept_change(
|
||||
client, fake, slug="ohm",
|
||||
@@ -364,6 +371,10 @@ def test_resolution_branch_replays_clean_and_supersedes_on_merge(app_with_fake_g
|
||||
provision_user_row(user_id=3, login="bob", role="contributor")
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
# v0.16.0 (item #12): bob (a non-owner contributor) needs an
|
||||
# accepted per-RFC invitation to cut a branch on alice's RFC.
|
||||
# Alice is the seeded RFC owner so she doesn't need one.
|
||||
grant_rfc_collaborator(user_id=3, rfc_slug="ohm", role_in_rfc="contributor")
|
||||
|
||||
# Alice cuts a branch and accepts a change on it.
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
|
||||
@@ -395,6 +395,28 @@ def provision_user_row(*, user_id: int, login: str, role: str) -> None:
|
||||
)
|
||||
|
||||
|
||||
def grant_rfc_collaborator(*, user_id: int, rfc_slug: str, role_in_rfc: str = "contributor") -> None:
|
||||
"""v0.16.0 / item #12 test seam: directly insert an accepted-
|
||||
invitation collaborator row so a non-owner contributor can pass
|
||||
the per-RFC write gate without going through the email round-trip.
|
||||
|
||||
Equivalent in effect to the invitation→accept dance the production
|
||||
code drives; lets v0.5.0/v0.6.0/v0.8.0 era tests preserve their
|
||||
"alice owns OHM, bob contributes" shape without rewriting the
|
||||
setup. The invitation_id is left NULL — collaborators minted via
|
||||
a direct admin gesture (a §19.2 candidate) carry the same shape.
|
||||
"""
|
||||
from app import db
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO rfc_collaborators
|
||||
(rfc_slug, user_id, role_in_rfc, invitation_id)
|
||||
VALUES (?, ?, ?, NULL)
|
||||
""",
|
||||
(rfc_slug, user_id, role_in_rfc),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,658 @@
|
||||
"""End-to-end integration tests for v0.16.0's owner-only invite for
|
||||
per-RFC PR or PR-less discussion (roadmap item #12, §6 / §10).
|
||||
|
||||
The release lands a per-RFC membership layer:
|
||||
|
||||
* `rfc_invitations` — issued by the RFC's owner, addressed to an
|
||||
email, granting one of two roles ('contributor' or 'discussant').
|
||||
* `rfc_collaborators` — the accepted-invitation substrate; the
|
||||
table the per-RFC write gate consults.
|
||||
|
||||
The tests prove:
|
||||
|
||||
* Only the RFC's owner (or a platform admin/owner) can invite —
|
||||
a platform-granted but non-owner user gets 403.
|
||||
* Creating an invitation lands a row, mints a token, and queues
|
||||
an envelope on the SMTP buffer.
|
||||
* Re-inviting the same (email, role) on the same RFC returns 409.
|
||||
* The accept endpoint requires the accepting user's email to match
|
||||
the invitee_email (case-insensitive).
|
||||
* Acceptance lands a rfc_collaborators row and flips the
|
||||
invitation to 'accepted'.
|
||||
* Re-accepting the same invitation is idempotent (200, changed=false).
|
||||
* An expired invitation refuses 409 even if the row's column status
|
||||
is still 'pending'.
|
||||
* A revoked invitation refuses 409.
|
||||
* The owner's listing carries pending + accepted in one response.
|
||||
* The per-RFC discussion-write gate refuses a non-invited
|
||||
platform-granted user 403 (was previously 200 before v0.16.0).
|
||||
* The same gate admits a user who holds an accepted 'discussant'
|
||||
invitation.
|
||||
* The same gate admits a user who holds an accepted 'contributor'
|
||||
invitation (contributor strictly includes discussion).
|
||||
* The platform admin/owner is admitted regardless of per-RFC
|
||||
membership (the platform-level capability path).
|
||||
* The /api/admin/users listing carries `rfc_invitations` per-user
|
||||
after an acceptance — the §17 admin surface hook.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
# Reuse fixtures and helpers from the propose / RFC-view harnesses.
|
||||
from test_propose_vertical import ( # noqa: F401 — fixtures land via import
|
||||
FakeGitea,
|
||||
app_with_fake_gitea,
|
||||
provision_user_row,
|
||||
sign_in_as,
|
||||
tmp_env,
|
||||
)
|
||||
from test_rfc_view_vertical import seed_active_rfc, SEED_BODY
|
||||
|
||||
|
||||
def _reset_outbound():
|
||||
from app import email as email_mod
|
||||
email_mod.reset_sent_envelopes()
|
||||
|
||||
|
||||
def _invitation_envelopes(to_address: str | None = None) -> list[dict]:
|
||||
"""Pluck v0.16.0 invitation envelopes out of the shared _SENT buffer.
|
||||
Same access pattern as the OTC tests use for `kind='otc'`."""
|
||||
from app import email as email_mod
|
||||
out = []
|
||||
for env in email_mod.sent_envelopes():
|
||||
if env.get("kind") != "rfc_invitation":
|
||||
continue
|
||||
if to_address is not None and env["to"] != to_address:
|
||||
continue
|
||||
out.append(env)
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Create / list / revoke (owner-side)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_owner_can_invite_creates_row_and_sends_email(app_with_fake_gitea):
|
||||
"""The end-to-end create gesture: RFC owner posts an invitation,
|
||||
a row lands, the token comes back in the response, and an
|
||||
`rfc_invitation`-kind envelope hits the SMTP buffer."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
# The frontmatter owner of the seeded RFC is "alice" (per
|
||||
# seed_active_rfc's default), so we sign in as that user.
|
||||
provision_user_row(user_id=1, login="alice", role="contributor")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
sign_in_as(
|
||||
client, user_id=1, gitea_login="alice",
|
||||
display_name="Alice", role="contributor",
|
||||
)
|
||||
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/invitations",
|
||||
json={"invitee_email": "newperson@example.com", "role_in_rfc": "contributor"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["rfc_slug"] == "ohm"
|
||||
assert body["invitee_email"] == "newperson@example.com"
|
||||
assert body["role_in_rfc"] == "contributor"
|
||||
assert body["status"] == "pending"
|
||||
assert body["token"] and len(body["token"]) > 16
|
||||
|
||||
# Row landed.
|
||||
row = db.conn().execute(
|
||||
"SELECT * FROM rfc_invitations WHERE id = ?", (body["id"],),
|
||||
).fetchone()
|
||||
assert row["rfc_slug"] == "ohm"
|
||||
assert row["invitee_email"] == "newperson@example.com"
|
||||
assert row["inviter_user_id"] == 1
|
||||
assert row["status"] == "pending"
|
||||
|
||||
# Email envelope went out.
|
||||
envs = _invitation_envelopes("newperson@example.com")
|
||||
assert len(envs) == 1
|
||||
assert "OHM" in envs[0]["subject"]
|
||||
assert body["token"] in envs[0]["body"]
|
||||
|
||||
|
||||
def test_non_owner_cannot_invite(app_with_fake_gitea):
|
||||
"""A platform-granted user who isn't in the RFC's frontmatter
|
||||
owners list cannot invite — 403. Distinct from the
|
||||
require_contributor gate (which would be 401 for anonymous)."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
# alice is the RFC owner per the seed; bob is a regular
|
||||
# platform-granted contributor with no per-RFC role.
|
||||
provision_user_row(user_id=1, login="alice", role="contributor")
|
||||
provision_user_row(user_id=2, login="bob", role="contributor")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
sign_in_as(
|
||||
client, user_id=2, gitea_login="bob",
|
||||
display_name="Bob", role="contributor",
|
||||
)
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/invitations",
|
||||
json={"invitee_email": "ignored@example.com", "role_in_rfc": "discussant"},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
def test_platform_admin_can_invite_to_any_rfc(app_with_fake_gitea):
|
||||
"""Per §6.1 the platform admin/owner role carries the maximal
|
||||
per-RFC capability, so admins can invite on any RFC even if
|
||||
they're not in its owners list."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
provision_user_row(user_id=1, login="alice", role="contributor")
|
||||
provision_user_row(user_id=99, login="adminzero", role="admin")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
sign_in_as(
|
||||
client, user_id=99, gitea_login="adminzero",
|
||||
display_name="Admin Zero", role="admin",
|
||||
)
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/invitations",
|
||||
json={"invitee_email": "another@example.com", "role_in_rfc": "discussant"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
def test_anonymous_cannot_invite(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/invitations",
|
||||
json={"invitee_email": "x@example.com", "role_in_rfc": "discussant"},
|
||||
)
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_re_invite_same_email_and_role_returns_409(app_with_fake_gitea):
|
||||
"""Refuse a duplicate pending invitation for the same (email, role)
|
||||
on the same RFC. A different role on the same email is allowed
|
||||
(the owner may want to upgrade discussant → contributor)."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
provision_user_row(user_id=1, login="alice", role="contributor")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
sign_in_as(
|
||||
client, user_id=1, gitea_login="alice",
|
||||
display_name="Alice", role="contributor",
|
||||
)
|
||||
|
||||
r1 = client.post(
|
||||
"/api/rfcs/ohm/invitations",
|
||||
json={"invitee_email": "dup@example.com", "role_in_rfc": "discussant"},
|
||||
)
|
||||
assert r1.status_code == 200
|
||||
r2 = client.post(
|
||||
"/api/rfcs/ohm/invitations",
|
||||
json={"invitee_email": "dup@example.com", "role_in_rfc": "discussant"},
|
||||
)
|
||||
assert r2.status_code == 409
|
||||
|
||||
# Same email, different role is allowed.
|
||||
r3 = client.post(
|
||||
"/api/rfcs/ohm/invitations",
|
||||
json={"invitee_email": "dup@example.com", "role_in_rfc": "contributor"},
|
||||
)
|
||||
assert r3.status_code == 200
|
||||
|
||||
|
||||
def test_owner_can_list_invitations(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
provision_user_row(user_id=1, login="alice", role="contributor")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
sign_in_as(
|
||||
client, user_id=1, gitea_login="alice",
|
||||
display_name="Alice", role="contributor",
|
||||
)
|
||||
|
||||
client.post("/api/rfcs/ohm/invitations",
|
||||
json={"invitee_email": "a@example.com", "role_in_rfc": "discussant"})
|
||||
client.post("/api/rfcs/ohm/invitations",
|
||||
json={"invitee_email": "b@example.com", "role_in_rfc": "contributor"})
|
||||
|
||||
r = client.get("/api/rfcs/ohm/invitations")
|
||||
assert r.status_code == 200, r.text
|
||||
items = r.json()["items"]
|
||||
emails = sorted(i["invitee_email"] for i in items)
|
||||
assert emails == ["a@example.com", "b@example.com"]
|
||||
assert all(i["status"] == "pending" for i in items)
|
||||
# The inviter is named.
|
||||
assert all(i["inviter_login"] == "alice" for i in items)
|
||||
|
||||
|
||||
def test_revoke_pending_invitation_works_already_accepted_refuses(app_with_fake_gitea):
|
||||
"""Revoke flips a pending invitation to 'revoked'. An already-
|
||||
accepted invitation refuses 409 — accepted membership is removed
|
||||
via a different (future) surface; the v0.16.0 revoke only lifts
|
||||
the pending link."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
provision_user_row(user_id=1, login="alice", role="contributor")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
sign_in_as(
|
||||
client, user_id=1, gitea_login="alice",
|
||||
display_name="Alice", role="contributor",
|
||||
)
|
||||
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/invitations",
|
||||
json={"invitee_email": "revokee@example.com", "role_in_rfc": "discussant"},
|
||||
)
|
||||
invitation_id = r.json()["id"]
|
||||
|
||||
r = client.post(f"/api/rfcs/ohm/invitations/{invitation_id}/revoke")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["status"] == "revoked"
|
||||
|
||||
# Re-revoke refuses 409.
|
||||
r2 = client.post(f"/api/rfcs/ohm/invitations/{invitation_id}/revoke")
|
||||
assert r2.status_code == 409
|
||||
|
||||
row = db.conn().execute(
|
||||
"SELECT status FROM rfc_invitations WHERE id = ?", (invitation_id,),
|
||||
).fetchone()
|
||||
assert row["status"] == "revoked"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Accept (invitee-side)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_accept_invitation_lands_collaborator_row(app_with_fake_gitea):
|
||||
"""The end-to-end accept gesture: the invitee signs in, posts the
|
||||
token, and an rfc_collaborators row lands at the issued role."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
provision_user_row(user_id=1, login="alice", role="contributor")
|
||||
# provision_user_row sets the email to "<login>@test", so the
|
||||
# invitee row we'll create needs the same email shape.
|
||||
provision_user_row(user_id=2, login="newbie", role="contributor")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
|
||||
# alice (owner) invites newbie@test.
|
||||
sign_in_as(
|
||||
client, user_id=1, gitea_login="alice",
|
||||
display_name="Alice", role="contributor",
|
||||
)
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/invitations",
|
||||
json={"invitee_email": "newbie@test", "role_in_rfc": "contributor"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
token = r.json()["token"]
|
||||
|
||||
# Switch to newbie, accept.
|
||||
sign_in_as(
|
||||
client, user_id=2, gitea_login="newbie",
|
||||
display_name="Newbie", role="contributor",
|
||||
email="newbie@test",
|
||||
)
|
||||
r = client.post("/api/invitations/accept", json={"token": token})
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["ok"] is True
|
||||
assert body["changed"] is True
|
||||
assert body["rfc_slug"] == "ohm"
|
||||
assert body["role_in_rfc"] == "contributor"
|
||||
|
||||
# Collaborator row landed; invitation flipped.
|
||||
collab = db.conn().execute(
|
||||
"SELECT role_in_rfc FROM rfc_collaborators WHERE rfc_slug = 'ohm' AND user_id = 2",
|
||||
).fetchone()
|
||||
assert collab is not None
|
||||
assert collab["role_in_rfc"] == "contributor"
|
||||
|
||||
inv = db.conn().execute(
|
||||
"SELECT status, accepted_by_user_id FROM rfc_invitations WHERE token = ?",
|
||||
(token,),
|
||||
).fetchone()
|
||||
assert inv["status"] == "accepted"
|
||||
assert inv["accepted_by_user_id"] == 2
|
||||
|
||||
|
||||
def test_accept_refuses_when_email_does_not_match(app_with_fake_gitea):
|
||||
"""The accepting user's email must match the invitation's
|
||||
invitee_email (case-insensitive)."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
provision_user_row(user_id=1, login="alice", role="contributor")
|
||||
provision_user_row(user_id=2, login="mallory", role="contributor")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
|
||||
sign_in_as(client, user_id=1, gitea_login="alice",
|
||||
display_name="Alice", role="contributor")
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/invitations",
|
||||
json={"invitee_email": "intended@example.com", "role_in_rfc": "discussant"},
|
||||
)
|
||||
token = r.json()["token"]
|
||||
|
||||
# mallory's email is "mallory@test", not "intended@example.com".
|
||||
sign_in_as(client, user_id=2, gitea_login="mallory",
|
||||
display_name="Mallory", role="contributor",
|
||||
email="mallory@test")
|
||||
r = client.post("/api/invitations/accept", json={"token": token})
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
def test_accept_refuses_revoked_invitation(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
provision_user_row(user_id=1, login="alice", role="contributor")
|
||||
provision_user_row(user_id=2, login="newbie", role="contributor")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
|
||||
sign_in_as(client, user_id=1, gitea_login="alice",
|
||||
display_name="Alice", role="contributor")
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/invitations",
|
||||
json={"invitee_email": "newbie@test", "role_in_rfc": "discussant"},
|
||||
)
|
||||
invitation_id = r.json()["id"]
|
||||
token = r.json()["token"]
|
||||
|
||||
client.post(f"/api/rfcs/ohm/invitations/{invitation_id}/revoke")
|
||||
|
||||
sign_in_as(client, user_id=2, gitea_login="newbie",
|
||||
display_name="Newbie", role="contributor",
|
||||
email="newbie@test")
|
||||
r = client.post("/api/invitations/accept", json={"token": token})
|
||||
assert r.status_code == 409
|
||||
|
||||
|
||||
def test_accept_refuses_expired_invitation(app_with_fake_gitea):
|
||||
"""An invitation past its `expires_at` is refused 409 even if
|
||||
the row's column status is still 'pending'. We backdate the
|
||||
expires_at directly to model the elapsed-window state."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
provision_user_row(user_id=1, login="alice", role="contributor")
|
||||
provision_user_row(user_id=2, login="newbie", role="contributor")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
|
||||
sign_in_as(client, user_id=1, gitea_login="alice",
|
||||
display_name="Alice", role="contributor")
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/invitations",
|
||||
json={"invitee_email": "newbie@test", "role_in_rfc": "discussant"},
|
||||
)
|
||||
token = r.json()["token"]
|
||||
invitation_id = r.json()["id"]
|
||||
|
||||
# Backdate.
|
||||
db.conn().execute(
|
||||
"UPDATE rfc_invitations SET expires_at = datetime('now', '-1 day') WHERE id = ?",
|
||||
(invitation_id,),
|
||||
)
|
||||
|
||||
sign_in_as(client, user_id=2, gitea_login="newbie",
|
||||
display_name="Newbie", role="contributor",
|
||||
email="newbie@test")
|
||||
r = client.post("/api/invitations/accept", json={"token": token})
|
||||
assert r.status_code == 409
|
||||
|
||||
|
||||
def test_accept_is_idempotent_on_re_accept(app_with_fake_gitea):
|
||||
"""Re-accepting the same already-accepted invitation reads as a
|
||||
200 no-op with `changed=false`. The collaborator row is unchanged."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
provision_user_row(user_id=1, login="alice", role="contributor")
|
||||
provision_user_row(user_id=2, login="newbie", role="contributor")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
|
||||
sign_in_as(client, user_id=1, gitea_login="alice",
|
||||
display_name="Alice", role="contributor")
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/invitations",
|
||||
json={"invitee_email": "newbie@test", "role_in_rfc": "discussant"},
|
||||
)
|
||||
token = r.json()["token"]
|
||||
|
||||
sign_in_as(client, user_id=2, gitea_login="newbie",
|
||||
display_name="Newbie", role="contributor",
|
||||
email="newbie@test")
|
||||
r1 = client.post("/api/invitations/accept", json={"token": token})
|
||||
assert r1.status_code == 200
|
||||
assert r1.json()["changed"] is True
|
||||
|
||||
r2 = client.post("/api/invitations/accept", json={"token": token})
|
||||
assert r2.status_code == 200
|
||||
assert r2.json()["changed"] is False
|
||||
|
||||
# Still exactly one collaborator row.
|
||||
rows = db.conn().execute(
|
||||
"SELECT COUNT(*) AS n FROM rfc_collaborators WHERE rfc_slug = 'ohm' AND user_id = 2"
|
||||
).fetchone()
|
||||
assert rows["n"] == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Discussion-write gate enforcement
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_non_invited_user_cannot_post_to_discussion(app_with_fake_gitea):
|
||||
"""v0.16.0 narrows the discussion-write gate: a platform-granted
|
||||
user with no per-RFC role gets 403 when posting to the
|
||||
discussion. (v0.6.0 left the gate at require_contributor only;
|
||||
item #12 layers can_discuss_rfc on top.)"""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="alice", role="contributor")
|
||||
provision_user_row(user_id=2, login="bob", role="contributor")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
|
||||
# bob is platform-granted but not in OHM's owners list and has
|
||||
# no invitation. The thread-create surface refuses 403.
|
||||
sign_in_as(client, user_id=2, gitea_login="bob",
|
||||
display_name="Bob", role="contributor")
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/discussion/threads",
|
||||
json={"label": "Question", "message": "Should I be allowed?"},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
def test_invited_discussant_can_post_to_discussion(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
provision_user_row(user_id=1, login="alice", role="contributor")
|
||||
provision_user_row(user_id=2, login="newbie", role="contributor")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
|
||||
# alice invites newbie as a discussant.
|
||||
sign_in_as(client, user_id=1, gitea_login="alice",
|
||||
display_name="Alice", role="contributor")
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/invitations",
|
||||
json={"invitee_email": "newbie@test", "role_in_rfc": "discussant"},
|
||||
)
|
||||
token = r.json()["token"]
|
||||
|
||||
# newbie accepts.
|
||||
sign_in_as(client, user_id=2, gitea_login="newbie",
|
||||
display_name="Newbie", role="contributor",
|
||||
email="newbie@test")
|
||||
client.post("/api/invitations/accept", json={"token": token})
|
||||
|
||||
# newbie can now post to the discussion.
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/discussion/threads",
|
||||
json={"label": "Question", "message": "Now I can speak."},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
def test_contributor_role_includes_discussion(app_with_fake_gitea):
|
||||
"""A 'contributor' per-RFC role strictly includes discussion
|
||||
permission — accepting a contributor invitation admits the user
|
||||
to the discussion endpoint too."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
provision_user_row(user_id=1, login="alice", role="contributor")
|
||||
provision_user_row(user_id=2, login="newbie", role="contributor")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
|
||||
sign_in_as(client, user_id=1, gitea_login="alice",
|
||||
display_name="Alice", role="contributor")
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/invitations",
|
||||
json={"invitee_email": "newbie@test", "role_in_rfc": "contributor"},
|
||||
)
|
||||
token = r.json()["token"]
|
||||
|
||||
sign_in_as(client, user_id=2, gitea_login="newbie",
|
||||
display_name="Newbie", role="contributor",
|
||||
email="newbie@test")
|
||||
client.post("/api/invitations/accept", json={"token": token})
|
||||
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/discussion/threads",
|
||||
json={"label": "Q", "message": "Hello."},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_platform_admin_can_post_to_discussion_without_invitation(app_with_fake_gitea):
|
||||
"""Per §6.1 / item #12's permission shape: platform admins/owners
|
||||
can write to any RFC's discussion regardless of per-RFC
|
||||
membership."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="alice", role="contributor")
|
||||
provision_user_row(user_id=99, login="adminzero", role="admin")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
|
||||
sign_in_as(client, user_id=99, gitea_login="adminzero",
|
||||
display_name="Admin Zero", role="admin")
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/discussion/threads",
|
||||
json={"label": "Admin chime", "message": "Drive-by from admin."},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_rfc_owner_can_post_to_discussion(app_with_fake_gitea):
|
||||
"""The frontmatter RFC owner is admitted by virtue of being on
|
||||
the owners list — they don't need to invite themselves."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="alice", role="contributor")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
sign_in_as(client, user_id=1, gitea_login="alice",
|
||||
display_name="Alice", role="contributor")
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/discussion/threads",
|
||||
json={"label": "Owner thought", "message": "Kicking off the conversation."},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Admin-page hook (additive on /api/admin/users)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_admin_users_listing_surfaces_per_rfc_invitations(app_with_fake_gitea):
|
||||
"""v0.16.0 hook into the v0.9.0 admin user-management surface:
|
||||
each user row carries an `rfc_invitations` array listing the
|
||||
per-RFC roles they hold. Empty array for users without any."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
provision_user_row(user_id=1, login="alice", role="contributor")
|
||||
provision_user_row(user_id=2, login="newbie", role="contributor")
|
||||
provision_user_row(user_id=99, login="adminzero", role="admin")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
|
||||
# alice invites newbie; newbie accepts.
|
||||
sign_in_as(client, user_id=1, gitea_login="alice",
|
||||
display_name="Alice", role="contributor")
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/invitations",
|
||||
json={"invitee_email": "newbie@test", "role_in_rfc": "contributor"},
|
||||
)
|
||||
token = r.json()["token"]
|
||||
|
||||
sign_in_as(client, user_id=2, gitea_login="newbie",
|
||||
display_name="Newbie", role="contributor",
|
||||
email="newbie@test")
|
||||
client.post("/api/invitations/accept", json={"token": token})
|
||||
|
||||
# Admin lists.
|
||||
sign_in_as(client, user_id=99, gitea_login="adminzero",
|
||||
display_name="Admin Zero", role="admin")
|
||||
r = client.get("/api/admin/users")
|
||||
assert r.status_code == 200
|
||||
items = r.json()["items"]
|
||||
newbie_row = next(i for i in items if i["gitea_login"] == "newbie")
|
||||
assert isinstance(newbie_row["rfc_invitations"], list)
|
||||
assert len(newbie_row["rfc_invitations"]) == 1
|
||||
invite = newbie_row["rfc_invitations"][0]
|
||||
assert invite["rfc_slug"] == "ohm"
|
||||
assert invite["role_in_rfc"] == "contributor"
|
||||
assert invite["inviter_login"] == "alice"
|
||||
|
||||
# Users with no invitations carry an empty array, not null.
|
||||
alice_row = next(i for i in items if i["gitea_login"] == "alice")
|
||||
assert alice_row["rfc_invitations"] == []
|
||||
@@ -0,0 +1,220 @@
|
||||
"""End-to-end integration tests for the v0.12.0 CloudFlare Turnstile
|
||||
gate on `/auth/otc/request` (§6.2 / roadmap item #10).
|
||||
|
||||
The release gates the OTC request endpoint behind a one-step
|
||||
browser-side Turnstile challenge before the bcrypt hash + SMTP send.
|
||||
The tests prove:
|
||||
|
||||
* Happy path: with the secret set, a valid token admits the request
|
||||
and the OTC envelope lands.
|
||||
* Failure path: with the secret set, a token siteverify rejects
|
||||
refuses the request with 400 and produces no envelope.
|
||||
* Missing-token: with the secret set, a request without a token
|
||||
refuses with 400.
|
||||
* Missing-secret-soft: with the secret unset AND
|
||||
`TURNSTILE_REQUIRED=false` (the v0.12.0 default), the request
|
||||
admits — this is the dev / "operator hasn't wired it yet" path.
|
||||
* Missing-secret-hard: with the secret unset AND
|
||||
`TURNSTILE_REQUIRED=true`, the request refuses with 500
|
||||
"auth misconfigured" — the production fail-closed path once
|
||||
the operator has flipped the policy.
|
||||
|
||||
The Turnstile siteverify call is mocked at the `httpx.post` boundary
|
||||
inside `app.turnstile` so no real keys are needed and no real
|
||||
CloudFlare call is made. The Gitea fakes from `test_propose_vertical`
|
||||
remain in scope so the rest of the app boots cleanly.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
FakeGitea,
|
||||
app_with_fake_gitea,
|
||||
tmp_env,
|
||||
)
|
||||
|
||||
|
||||
def _reset_outbound():
|
||||
from app import email as email_mod
|
||||
email_mod.reset_sent_envelopes()
|
||||
|
||||
|
||||
def _outbound_otc_envelopes(to_address: str | None = None) -> list[dict]:
|
||||
from app import email as email_mod
|
||||
out = []
|
||||
for env in email_mod.sent_envelopes():
|
||||
if env.get("kind") != "otc":
|
||||
continue
|
||||
if to_address is not None and env["to"] != to_address:
|
||||
continue
|
||||
out.append(env)
|
||||
return out
|
||||
|
||||
|
||||
def _patch_siteverify(monkeypatch, *, success: bool, error_codes: list[str] | None = None):
|
||||
"""Replace `httpx.post` inside `app.turnstile` with a stub that
|
||||
returns the requested success shape. The stub does not touch the
|
||||
real CloudFlare endpoint and never sees a real secret.
|
||||
"""
|
||||
captured = {}
|
||||
|
||||
def fake_post(url, *, data=None, timeout=None, **kwargs):
|
||||
captured["url"] = url
|
||||
captured["data"] = data
|
||||
body = {"success": bool(success)}
|
||||
if error_codes is not None:
|
||||
body["error-codes"] = error_codes
|
||||
return SimpleNamespace(json=lambda: body)
|
||||
|
||||
from app import turnstile as turnstile_mod
|
||||
monkeypatch.setattr(turnstile_mod.httpx, "post", fake_post)
|
||||
return captured
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Happy path: secret set, token valid → admit + OTC envelope lands
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_otc_request_admits_when_turnstile_token_is_valid(app_with_fake_gitea, monkeypatch):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
monkeypatch.setenv("CLOUDFLARE_TURNSTILE_SECRET", "test-secret-not-real")
|
||||
monkeypatch.setenv("TURNSTILE_REQUIRED", "true")
|
||||
captured = _patch_siteverify(monkeypatch, success=True)
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
r = client.post(
|
||||
"/auth/otc/request",
|
||||
json={"email": "alice@example.com", "turnstile_token": "fake-token-abc"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
# The siteverify call was made with the secret + the token we sent.
|
||||
assert captured["data"]["secret"] == "test-secret-not-real"
|
||||
assert captured["data"]["response"] == "fake-token-abc"
|
||||
# And the OTC dispatch ran — exactly one envelope to the address.
|
||||
envs = _outbound_otc_envelopes("alice@example.com")
|
||||
assert len(envs) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Failure path: secret set, siteverify says success=false → 400 + no envelope
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_otc_request_refuses_when_turnstile_siteverify_fails(app_with_fake_gitea, monkeypatch):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
monkeypatch.setenv("CLOUDFLARE_TURNSTILE_SECRET", "test-secret-not-real")
|
||||
monkeypatch.setenv("TURNSTILE_REQUIRED", "true")
|
||||
_patch_siteverify(monkeypatch, success=False, error_codes=["invalid-input-response"])
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
r = client.post(
|
||||
"/auth/otc/request",
|
||||
json={"email": "alice@example.com", "turnstile_token": "fake-bad-token"},
|
||||
)
|
||||
assert r.status_code == 400, r.text
|
||||
# The OTC bcrypt + SMTP path did not run — no envelope was buffered.
|
||||
assert _outbound_otc_envelopes("alice@example.com") == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Missing-token: secret set, no token → 400 + no envelope
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_otc_request_refuses_when_turnstile_token_is_missing(app_with_fake_gitea, monkeypatch):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
monkeypatch.setenv("CLOUDFLARE_TURNSTILE_SECRET", "test-secret-not-real")
|
||||
monkeypatch.setenv("TURNSTILE_REQUIRED", "true")
|
||||
# Even though we patch httpx.post, the missing-token check fires
|
||||
# before the siteverify call — so the patch is here only as a
|
||||
# safety net in case the implementation regresses to making the
|
||||
# network call anyway.
|
||||
_patch_siteverify(monkeypatch, success=False)
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
r = client.post(
|
||||
"/auth/otc/request",
|
||||
json={"email": "alice@example.com"}, # no turnstile_token field at all
|
||||
)
|
||||
assert r.status_code == 400, r.text
|
||||
assert _outbound_otc_envelopes("alice@example.com") == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Missing-secret-soft: no secret, TURNSTILE_REQUIRED=false (default) → admit
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_otc_request_admits_when_secret_unset_and_not_required(app_with_fake_gitea, monkeypatch):
|
||||
"""v0.12.0 default: the operator has not yet wired the Turnstile
|
||||
secret and has not enabled `TURNSTILE_REQUIRED`. The gate stays
|
||||
open — this is the dev / test / pre-rollout path. Once the
|
||||
operator confirms the secret is in place and flips
|
||||
`TURNSTILE_REQUIRED=true`, missing-secret becomes fail-closed
|
||||
(covered in test_otc_request_refuses_when_required_but_secret_unset).
|
||||
"""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
monkeypatch.delenv("CLOUDFLARE_TURNSTILE_SECRET", raising=False)
|
||||
monkeypatch.delenv("TURNSTILE_REQUIRED", raising=False)
|
||||
# The httpx.post inside turnstile must not be called in this path —
|
||||
# patch it to a sentinel that explodes if it ever runs.
|
||||
from app import turnstile as turnstile_mod
|
||||
|
||||
def must_not_be_called(*a, **kw):
|
||||
raise AssertionError("siteverify should not run when no secret is configured")
|
||||
|
||||
monkeypatch.setattr(turnstile_mod.httpx, "post", must_not_be_called)
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
r = client.post(
|
||||
"/auth/otc/request",
|
||||
json={"email": "alice@example.com"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
# The OTC path ran end-to-end — one envelope to the address.
|
||||
assert len(_outbound_otc_envelopes("alice@example.com")) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Missing-secret-hard: no secret, TURNSTILE_REQUIRED=true → 500 "misconfigured"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_otc_request_refuses_when_required_but_secret_unset(app_with_fake_gitea, monkeypatch):
|
||||
"""Once the operator has flipped `TURNSTILE_REQUIRED=true` to lock
|
||||
down production, a missing secret stops being a soft-fail and
|
||||
becomes a fail-closed 500. This is the regression-detection shape
|
||||
the §20.4 upgrade-steps MAY block calls out — flip the flag once
|
||||
the secret is wired so a future config drift fails loudly instead
|
||||
of silently disabling abuse defense.
|
||||
"""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
monkeypatch.delenv("CLOUDFLARE_TURNSTILE_SECRET", raising=False)
|
||||
monkeypatch.setenv("TURNSTILE_REQUIRED", "true")
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
r = client.post(
|
||||
"/auth/otc/request",
|
||||
json={"email": "alice@example.com", "turnstile_token": "doesnt-matter"},
|
||||
)
|
||||
assert r.status_code == 500, r.text
|
||||
assert _outbound_otc_envelopes("alice@example.com") == []
|
||||
@@ -49,3 +49,39 @@ VITE_PRIVACY_POLICY_URL=
|
||||
# Examples:
|
||||
# VITE_COOKIES_POLICY_URL=https://wiggleverse.org/cookies
|
||||
VITE_COOKIES_POLICY_URL=
|
||||
|
||||
# v0.12.0 / roadmap item #10: CloudFlare Turnstile site key (public).
|
||||
# Provision a Turnstile site at dash.cloudflare.com → Turnstile → Add
|
||||
# site. The site key (this var) is embedded into the frontend bundle at
|
||||
# build time and rendered by the Turnstile widget on the /login email-
|
||||
# entry step. The secret key (private) lives in the backend env as
|
||||
# CLOUDFLARE_TURNSTILE_SECRET — see backend/.env.example. Leave unset
|
||||
# in dev to skip the widget; the backend's TURNSTILE_REQUIRED policy
|
||||
# decides what happens to a tokenless request.
|
||||
#
|
||||
# Examples:
|
||||
# VITE_TURNSTILE_SITE_KEY=0x4AAAAAAA...
|
||||
VITE_TURNSTILE_SITE_KEY=
|
||||
|
||||
# v0.15.0 / roadmap item #13: Amplitude project API key (public).
|
||||
# Embedded in the frontend bundle at build time and used by the
|
||||
# analytics wrapper (`frontend/src/lib/analytics.js`) — which loads
|
||||
# `@amplitude/unified` (Analytics + Session Replay) when the user
|
||||
# has granted analytics consent (v0.13.0 cookie banner). Provision
|
||||
# an Amplitude project at app.amplitude.com → Projects → New, copy
|
||||
# the API key.
|
||||
#
|
||||
# Public by design: Amplitude browser keys are bundle-embedded
|
||||
# (visible in dev tools), same nature as VITE_TURNSTILE_SITE_KEY
|
||||
# (also public; the truly-secret half of that Turnstile pair is
|
||||
# CLOUDFLARE_TURNSTILE_SECRET on the backend). For deployments
|
||||
# behind flotilla, bind via `flotilla overlay set <deployment>
|
||||
# VITE_AMPLITUDE_API_KEY=<key>` — NOT `flotilla secret set`. The
|
||||
# vendor's installation wizard shows the key inline as a literal
|
||||
# string in the init call, confirming the public framing. Leave
|
||||
# unset in dev; the wrapper logs one console warning and no-ops
|
||||
# (the app continues to work).
|
||||
#
|
||||
# Examples:
|
||||
# VITE_AMPLITUDE_API_KEY=01234567890abcdef01234567890abcd
|
||||
VITE_AMPLITUDE_API_KEY=
|
||||
|
||||
Generated
+529
-10
@@ -1,13 +1,14 @@
|
||||
{
|
||||
"name": "rfc-app-frontend",
|
||||
"version": "0.13.0",
|
||||
"version": "0.15.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "rfc-app-frontend",
|
||||
"version": "0.13.0",
|
||||
"version": "0.15.0",
|
||||
"dependencies": {
|
||||
"@amplitude/unified": "^1.1.9",
|
||||
"@codemirror/commands": "^6.10.3",
|
||||
"@codemirror/lang-markdown": "^6.5.0",
|
||||
"@codemirror/language": "^6.12.3",
|
||||
@@ -30,6 +31,360 @@
|
||||
"vite": "^8.0.12"
|
||||
}
|
||||
},
|
||||
"node_modules/@amplitude/analytics-browser": {
|
||||
"version": "2.42.4",
|
||||
"resolved": "https://registry.npmjs.org/@amplitude/analytics-browser/-/analytics-browser-2.42.4.tgz",
|
||||
"integrity": "sha512-q1XUlaKQkLq2CFx8xsVEc+uekOwHlnDYyaMBzlQDf2vcEaPaQDb7LzJ7z4CFs4Jn9FyBGDNo4w3IYjv9L6xjGA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@amplitude/analytics-core": "2.48.2",
|
||||
"@amplitude/plugin-autocapture-browser": "1.27.2",
|
||||
"@amplitude/plugin-custom-enrichment-browser": "0.1.9",
|
||||
"@amplitude/plugin-event-property-attribution-browser": "0.2.1",
|
||||
"@amplitude/plugin-network-capture-browser": "1.10.1",
|
||||
"@amplitude/plugin-page-url-enrichment-browser": "0.7.11",
|
||||
"@amplitude/plugin-page-view-tracking-browser": "2.11.1",
|
||||
"@amplitude/plugin-web-vitals-browser": "1.1.33",
|
||||
"tslib": "^2.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@amplitude/analytics-client-common": {
|
||||
"version": "2.4.48",
|
||||
"resolved": "https://registry.npmjs.org/@amplitude/analytics-client-common/-/analytics-client-common-2.4.48.tgz",
|
||||
"integrity": "sha512-jdRvu8ux3aIf74FvTDZuSFR1mutzdrIg1ebXYqpKizs9upXz1AJnHClkldSw9i4yu924AJ2wudxq6dccHWlNiA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@amplitude/analytics-connector": "^1.4.8",
|
||||
"@amplitude/analytics-core": "2.48.2",
|
||||
"@amplitude/analytics-types": "2.11.1",
|
||||
"tslib": "^2.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@amplitude/analytics-connector": {
|
||||
"version": "1.6.4",
|
||||
"resolved": "https://registry.npmjs.org/@amplitude/analytics-connector/-/analytics-connector-1.6.4.tgz",
|
||||
"integrity": "sha512-SpIv0IQMNIq6SH3UqFGiaZyGSc7PBZwRdq7lvP0pBxW8i4Ny+8zwI0pV+VMfMHQwWY3wdIbWw5WQphNjpdq1/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@amplitude/analytics-core": {
|
||||
"version": "2.48.2",
|
||||
"resolved": "https://registry.npmjs.org/@amplitude/analytics-core/-/analytics-core-2.48.2.tgz",
|
||||
"integrity": "sha512-r9O+hsTnTsDa1p6QdyC0KbBPXupzoWz9053RQB9XQz8078LM+5KCMbCKYOrSYniH4DH/OM2kOUEdJlwdxIl/IA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@amplitude/analytics-connector": "^1.6.4",
|
||||
"@types/zen-observable": "0.8.3",
|
||||
"safe-json-stringify": "1.2.0",
|
||||
"tslib": "^2.4.1",
|
||||
"zen-observable": "0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@amplitude/analytics-types": {
|
||||
"version": "2.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@amplitude/analytics-types/-/analytics-types-2.11.1.tgz",
|
||||
"integrity": "sha512-wFEgb0t99ly2uJKm5oZ28Lti0Kh5RecR5XBkwfUpDzn84IoCIZ8GJTsMw/nThu8FZFc7xFDA4UAt76zhZKrs9A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@amplitude/engagement-browser": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@amplitude/engagement-browser/-/engagement-browser-1.0.9.tgz",
|
||||
"integrity": "sha512-zvPr0L5aLlOS3nG8scIkEEDMVK2y3MaMbgjYhMfYruhMpfsC/U0apov22nEc1RRrTwve2awEXruPRKf1TysqrQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@amplitude/analytics-types": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@amplitude/experiment-core": {
|
||||
"version": "0.13.1",
|
||||
"resolved": "https://registry.npmjs.org/@amplitude/experiment-core/-/experiment-core-0.13.1.tgz",
|
||||
"integrity": "sha512-ZHvR0dxTltasp8MiMcQ6qKsY20mWnODoy3oebGad6qaRR1ywpUi8IuLf5AwLTM35ZwgzEUTn9TEIWKLHpDwHMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-base64": "^3.7.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@amplitude/experiment-js-client": {
|
||||
"version": "1.21.1",
|
||||
"resolved": "https://registry.npmjs.org/@amplitude/experiment-js-client/-/experiment-js-client-1.21.1.tgz",
|
||||
"integrity": "sha512-chE/4qQG/5Cgl93Wqj1NEdgOL5LkqySLlfk1EN0f+7bJa52HpkGFALA2FeCNYf31Z5CglEeKX6dUMgL7y33SIw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@amplitude/analytics-connector": "^1.6.4",
|
||||
"@amplitude/experiment-core": "^0.13.1",
|
||||
"@amplitude/ua-parser-js": "^0.7.31",
|
||||
"base64-js": "1.5.1",
|
||||
"unfetch": "4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@amplitude/plugin-autocapture-browser": {
|
||||
"version": "1.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@amplitude/plugin-autocapture-browser/-/plugin-autocapture-browser-1.27.2.tgz",
|
||||
"integrity": "sha512-UTA/0IDw/f2nnK+S1XILqoI5pgUgMTEZokDS6+pC4wuYtmOS9uNAgKuyajzjW12uobybMHRpv7xLjCJ5khKGAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@amplitude/analytics-core": "2.48.2",
|
||||
"tslib": "^2.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@amplitude/plugin-custom-enrichment-browser": {
|
||||
"version": "0.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@amplitude/plugin-custom-enrichment-browser/-/plugin-custom-enrichment-browser-0.1.9.tgz",
|
||||
"integrity": "sha512-wemh2Tw3zgQ7sa7MUNyMGz9OR6VjTG4tlAMrLlDKbQ4tVkgNI3oAwOF7+0BA8qzgeMXX6iw+CEKaE+EC/okkuQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@amplitude/analytics-core": "2.48.2",
|
||||
"tslib": "^2.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@amplitude/plugin-event-property-attribution-browser": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@amplitude/plugin-event-property-attribution-browser/-/plugin-event-property-attribution-browser-0.2.1.tgz",
|
||||
"integrity": "sha512-xqBCZe0DYsKyQ1eELN2LM8adXwRE2eOi3SnvSu9SkS0GDXBYWinuPCuLqyc/3uD5hY2FLACWvakpU0tr7GDJgg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@amplitude/analytics-core": "2.48.2",
|
||||
"tslib": "^2.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@amplitude/plugin-experiment-browser": {
|
||||
"version": "1.0.0-beta.28",
|
||||
"resolved": "https://registry.npmjs.org/@amplitude/plugin-experiment-browser/-/plugin-experiment-browser-1.0.0-beta.28.tgz",
|
||||
"integrity": "sha512-NQz267zLi7vl2G2lx10yUrEoGOCe5K9iqcPSIjbTavGu/XGvsmqLDqBHhg+EkdEMAPwypoXnmtPEs3RMhX+1MA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@amplitude/analytics-core": "2.48.2",
|
||||
"@amplitude/experiment-js-client": "^1.15.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@amplitude/plugin-network-capture-browser": {
|
||||
"version": "1.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@amplitude/plugin-network-capture-browser/-/plugin-network-capture-browser-1.10.1.tgz",
|
||||
"integrity": "sha512-jROIAkUDPd25A/t8W5MpmsTiBat2qoJbCMoNBKKxLMNEaE8VYbheflByWLkm4enbHgWS7OveWy0i3Oc7uPCfAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@amplitude/analytics-core": "2.48.2",
|
||||
"tslib": "^2.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@amplitude/plugin-page-url-enrichment-browser": {
|
||||
"version": "0.7.11",
|
||||
"resolved": "https://registry.npmjs.org/@amplitude/plugin-page-url-enrichment-browser/-/plugin-page-url-enrichment-browser-0.7.11.tgz",
|
||||
"integrity": "sha512-u9JhUP/VenJifCSbdTz2YZZiXAphs3efzd+qx1SRAIU6d1swPh0g/GVw3sTwvH+4MZtw3SwVC1OFxmz+f2QVyA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@amplitude/analytics-core": "2.48.2",
|
||||
"tslib": "^2.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@amplitude/plugin-page-view-tracking-browser": {
|
||||
"version": "2.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@amplitude/plugin-page-view-tracking-browser/-/plugin-page-view-tracking-browser-2.11.1.tgz",
|
||||
"integrity": "sha512-tfXg6Uir6X1XuWsOOXE/EgZ9NvM7i2ktDdagydSrFN6OyVkMvqdjPKUZSSUPuHtOoomboi3WaZsTUfq1jkWP3w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@amplitude/analytics-core": "2.48.2",
|
||||
"tslib": "^2.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@amplitude/plugin-session-replay-browser": {
|
||||
"version": "1.31.0",
|
||||
"resolved": "https://registry.npmjs.org/@amplitude/plugin-session-replay-browser/-/plugin-session-replay-browser-1.31.0.tgz",
|
||||
"integrity": "sha512-b7kyYVEdW3EMR6cPXCfld+h8nQsuAR5o6vum8Glu+ofhFDfG4wj/mTJ0ITEaNbsJCfXniKQ3kFgTe6hTtxSFGQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@amplitude/analytics-client-common": "2.4.48",
|
||||
"@amplitude/analytics-core": "2.48.2",
|
||||
"@amplitude/analytics-types": "2.11.1",
|
||||
"@amplitude/rrweb-plugin-console-record": "2.0.0-alpha.40",
|
||||
"@amplitude/rrweb-record": "2.0.0-alpha.40",
|
||||
"@amplitude/session-replay-browser": "1.44.0",
|
||||
"idb-keyval": "^6.2.1",
|
||||
"tslib": "^2.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@amplitude/plugin-web-vitals-browser": {
|
||||
"version": "1.1.33",
|
||||
"resolved": "https://registry.npmjs.org/@amplitude/plugin-web-vitals-browser/-/plugin-web-vitals-browser-1.1.33.tgz",
|
||||
"integrity": "sha512-33FzxMH1Lr2lhvr5DDy3xD1HHWEI4KPLQsMUXqDTldkLl/ENNeBWcsljQTTDJipmRdS32I79KJhuHRNaoXd6fg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@amplitude/analytics-core": "2.48.2",
|
||||
"tslib": "^2.4.1",
|
||||
"web-vitals": "5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@amplitude/rrdom": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@amplitude/rrdom/-/rrdom-2.1.0.tgz",
|
||||
"integrity": "sha512-2dAtxXL02usBV2CSOnScLd3WoVqWaeiGpxN8LuXJ0r/NpLJkW1k876v2tRKAz5NrxPwSdjihsMmwCIXHpJhHfA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@amplitude/rrweb-snapshot": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@amplitude/rrweb": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@amplitude/rrweb/-/rrweb-2.1.1.tgz",
|
||||
"integrity": "sha512-6uA+5VE/VHumaXPXTTLGRogd/K9MDwd01jGteppeLzsX0PvqlDyY5aIi35yh9+q1iS6ciPBn/2NRg0lg4cFIlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@amplitude/rrdom": "^2.1.0",
|
||||
"@amplitude/rrweb-snapshot": "^2.1.0",
|
||||
"@amplitude/rrweb-types": "^2.1.0",
|
||||
"@amplitude/rrweb-utils": "^2.1.0",
|
||||
"@types/css-font-loading-module": "0.0.7",
|
||||
"@xstate/fsm": "^1.4.0",
|
||||
"base64-arraybuffer": "^1.0.1",
|
||||
"mitt": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@amplitude/rrweb-packer": {
|
||||
"version": "2.0.0-alpha.40",
|
||||
"resolved": "https://registry.npmjs.org/@amplitude/rrweb-packer/-/rrweb-packer-2.0.0-alpha.40.tgz",
|
||||
"integrity": "sha512-Btb6b9pS1IvDMbvyYxpUdTk9NRJugSoJjRCl7R6jP/iSlPWXoveJIwHaNFAS9ZmWUEK7HhyBJ8bKGFN3giUsDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@amplitude/rrweb-types": "^2.0.0-alpha.40",
|
||||
"fflate": "^0.4.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@amplitude/rrweb-plugin-console-record": {
|
||||
"version": "2.0.0-alpha.40",
|
||||
"resolved": "https://registry.npmjs.org/@amplitude/rrweb-plugin-console-record/-/rrweb-plugin-console-record-2.0.0-alpha.40.tgz",
|
||||
"integrity": "sha512-vtY7T/kGFl62nC1u7ZUXQvU7ulB70cZGVHPRN/SO9fzVfsY7y6rCmBfoc2jS5KmISdlgkVzMjY2r/EE2Gk9AQA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@amplitude/rrweb": "^2.0.0-alpha.40"
|
||||
}
|
||||
},
|
||||
"node_modules/@amplitude/rrweb-record": {
|
||||
"version": "2.0.0-alpha.40",
|
||||
"resolved": "https://registry.npmjs.org/@amplitude/rrweb-record/-/rrweb-record-2.0.0-alpha.40.tgz",
|
||||
"integrity": "sha512-5cJhQwzhymJWX5/XOtpWK0h2NLq9+t2YiO6ub0cdZ9F5AZizaRbsVH88int07DfX0YiXTKWbISezVuduCLqgSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@amplitude/rrweb": "^2.0.0-alpha.40",
|
||||
"@amplitude/rrweb-types": "^2.0.0-alpha.40"
|
||||
}
|
||||
},
|
||||
"node_modules/@amplitude/rrweb-snapshot": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@amplitude/rrweb-snapshot/-/rrweb-snapshot-2.1.0.tgz",
|
||||
"integrity": "sha512-xYQvOW73ig+5M7caqilA8j0S6MHWUULLeJNK+2VVvUqv8mr4FMT2DUAQiVBGCImNlb9Gu2rLUfCScMnVxn+EDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"postcss": "^8.4.38"
|
||||
}
|
||||
},
|
||||
"node_modules/@amplitude/rrweb-types": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@amplitude/rrweb-types/-/rrweb-types-2.1.0.tgz",
|
||||
"integrity": "sha512-S73tBI/04A6HCHgnrUNeeVOvnDTEoQnNrmZGyrZncJwRlTIX+6BQSYtBFofMag8GnAy9gA+NtC0TL0CnluOWBw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@amplitude/rrweb-utils": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@amplitude/rrweb-utils/-/rrweb-utils-2.1.0.tgz",
|
||||
"integrity": "sha512-dTCDnSiMMHZ10utYHJ8dSd/xkjFgdF67y74PkOzAPcCKW1rLxyJYcFOA3uPL2b7cIVVmoel/5NTp5eflaUaJfQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@amplitude/session-replay-browser": {
|
||||
"version": "1.44.0",
|
||||
"resolved": "https://registry.npmjs.org/@amplitude/session-replay-browser/-/session-replay-browser-1.44.0.tgz",
|
||||
"integrity": "sha512-8Ruep2TTDMcfVMKurSpBbVclBK/v8Lb3aSHFsYd/xOQ1E3CaKoAu39pplli28NWoUcW7unyVE7khkOa2zzn0Lw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@amplitude/analytics-client-common": "2.4.48",
|
||||
"@amplitude/analytics-core": "2.48.2",
|
||||
"@amplitude/analytics-types": "2.11.1",
|
||||
"@amplitude/experiment-core": "0.7.2",
|
||||
"@amplitude/rrweb-packer": "2.0.0-alpha.40",
|
||||
"@amplitude/rrweb-plugin-console-record": "2.0.0-alpha.40",
|
||||
"@amplitude/rrweb-record": "2.0.0-alpha.40",
|
||||
"@amplitude/rrweb-types": "2.0.0-alpha.40",
|
||||
"@amplitude/rrweb-utils": "2.0.0-alpha.40",
|
||||
"@amplitude/targeting": "0.2.0",
|
||||
"@rollup/plugin-replace": "^6.0.1",
|
||||
"idb": "8.0.0",
|
||||
"tslib": "^2.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@amplitude/session-replay-browser/node_modules/@amplitude/experiment-core": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/@amplitude/experiment-core/-/experiment-core-0.7.2.tgz",
|
||||
"integrity": "sha512-Wc2NWvgQ+bLJLeF0A9wBSPIaw0XuqqgkPKsoNFQrmS7r5Djd56um75In05tqmVntPJZRvGKU46pAp8o5tdf4mA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-base64": "^3.7.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@amplitude/session-replay-browser/node_modules/@amplitude/rrweb-types": {
|
||||
"version": "2.0.0-alpha.40",
|
||||
"resolved": "https://registry.npmjs.org/@amplitude/rrweb-types/-/rrweb-types-2.0.0-alpha.40.tgz",
|
||||
"integrity": "sha512-rP7CBDkzXupxOA7ukvC+zDYLuCtsz54TuJKC4+5O72Jsz4YdokLznKZRG34P6zXozfhGU0261qckk87lLY6mKQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@amplitude/session-replay-browser/node_modules/@amplitude/rrweb-utils": {
|
||||
"version": "2.0.0-alpha.40",
|
||||
"resolved": "https://registry.npmjs.org/@amplitude/rrweb-utils/-/rrweb-utils-2.0.0-alpha.40.tgz",
|
||||
"integrity": "sha512-i1CCt6MCjlqoeNc+1Hse5bz+ZbASaWaIJ0WdJZvnQjUCHH29Xy/QFouyOuor73RZ+UWX4s2tYSrUIdmBepXk3w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@amplitude/targeting": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@amplitude/targeting/-/targeting-0.2.0.tgz",
|
||||
"integrity": "sha512-/50ywTrC4hfcfJVBbh5DFbqMPPfaIOivZeb5Gb+OGM03QrA+lsUqdvtnKLNuWtceD4H6QQ2KFzPJ5aAJLyzVDA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@amplitude/analytics-client-common": ">=1 <3",
|
||||
"@amplitude/analytics-core": ">=1 <3",
|
||||
"@amplitude/analytics-types": ">=1 <3",
|
||||
"@amplitude/experiment-core": "0.7.2",
|
||||
"idb": "^8.0.0",
|
||||
"tslib": "^2.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@amplitude/targeting/node_modules/@amplitude/experiment-core": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/@amplitude/experiment-core/-/experiment-core-0.7.2.tgz",
|
||||
"integrity": "sha512-Wc2NWvgQ+bLJLeF0A9wBSPIaw0XuqqgkPKsoNFQrmS7r5Djd56um75In05tqmVntPJZRvGKU46pAp8o5tdf4mA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-base64": "^3.7.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@amplitude/ua-parser-js": {
|
||||
"version": "0.7.33",
|
||||
"resolved": "https://registry.npmjs.org/@amplitude/ua-parser-js/-/ua-parser-js-0.7.33.tgz",
|
||||
"integrity": "sha512-wKEtVR4vXuPT9cVEIJkYWnlF++Gx3BdLatPBM+SZ1ztVIvnhdGBZR/mn9x/PzyrMcRlZmyi6L56I2J3doVBnjA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/ua-parser-js"
|
||||
},
|
||||
{
|
||||
"type": "paypal",
|
||||
"url": "https://paypal.me/faisalman"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@amplitude/unified": {
|
||||
"version": "1.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@amplitude/unified/-/unified-1.1.9.tgz",
|
||||
"integrity": "sha512-YPgQbp/vDQ92GshHs2hfUxoeRnR3rRBWCoQ6wXgFjXQ1uiJf2tP0CBZWdrCStSDuhcpo2rsCz/Ek2LGq5J6SIQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@amplitude/analytics-browser": "2.42.4",
|
||||
"@amplitude/analytics-core": "2.48.2",
|
||||
"@amplitude/engagement-browser": "^1.0.3",
|
||||
"@amplitude/plugin-experiment-browser": "1.0.0-beta.28",
|
||||
"@amplitude/plugin-session-replay-browser": "1.31.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@antfu/install-pkg": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz",
|
||||
@@ -264,6 +619,12 @@
|
||||
"import-meta-resolve": "^4.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@lezer/common": {
|
||||
"version": "1.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
|
||||
@@ -657,6 +1018,49 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rollup/plugin-replace": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-6.0.3.tgz",
|
||||
"integrity": "sha512-J4RZarRvQAm5IF0/LwUUg+obsm+xZhYnbMXmXROyoSE1ATJe3oXSb9L5MMppdxP2ylNSjv6zFBwKYjcKMucVfA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@rollup/pluginutils": "^5.0.1",
|
||||
"magic-string": "^0.30.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"rollup": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/pluginutils": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz",
|
||||
"integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.0",
|
||||
"estree-walker": "^2.0.2",
|
||||
"picomatch": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"rollup": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/core": {
|
||||
"version": "3.23.6",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.23.6.tgz",
|
||||
@@ -1109,6 +1513,12 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/css-font-loading-module": {
|
||||
"version": "0.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.7.tgz",
|
||||
"integrity": "sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3": {
|
||||
"version": "7.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
|
||||
@@ -1362,6 +1772,12 @@
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/geojson": {
|
||||
"version": "7946.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||
@@ -1399,6 +1815,12 @@
|
||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/zen-observable": {
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/zen-observable/-/zen-observable-0.8.3.tgz",
|
||||
"integrity": "sha512-fbF6oTd4sGGy0xjHPKAt+eS2CrxJ3+6gQ3FGcBoIJR2TLAyCkCyI8JqZNy+FeON0AhVgNJoUumVoZQjBFUqHkw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@upsetjs/venn.js": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@upsetjs/venn.js/-/venn.js-2.0.0.tgz",
|
||||
@@ -1435,6 +1857,41 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@xstate/fsm": {
|
||||
"version": "1.6.5",
|
||||
"resolved": "https://registry.npmjs.org/@xstate/fsm/-/fsm-1.6.5.tgz",
|
||||
"integrity": "sha512-b5o1I6aLNeYlU/3CPlj/Z91ybk1gUsKT+5NAJI+2W4UjvS5KLG28K9v5UvNoFVjHV8PajVZ00RH3vnjyQO7ZAw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base64-arraybuffer": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
|
||||
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
|
||||
@@ -2021,6 +2478,12 @@
|
||||
"benchmarks"
|
||||
]
|
||||
},
|
||||
"node_modules/estree-walker": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
|
||||
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-equals": {
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
|
||||
@@ -2048,6 +2511,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.4.8",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.8.tgz",
|
||||
"integrity": "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -2081,6 +2550,18 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/idb": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/idb/-/idb-8.0.0.tgz",
|
||||
"integrity": "sha512-l//qvlAKGmQO31Qn7xdzagVPPaHTxXx199MhrAFuVBTPqydcPYBWjkrbv4Y0ktB+GmWOiwHl237UUOrLmQxLvw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/idb-keyval": {
|
||||
"version": "6.2.4",
|
||||
"resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.4.tgz",
|
||||
"integrity": "sha512-D/NzHWUmYJGXi++z67aMSrnisb9A3621CyRK5G89JyTlN13C8xf0g04DLxUKMufPem3e3L2JAXR6Z00OWy183Q==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/import-meta-resolve": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz",
|
||||
@@ -2100,6 +2581,12 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/js-base64": {
|
||||
"version": "3.7.8",
|
||||
"resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz",
|
||||
"integrity": "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/katex": {
|
||||
"version": "0.16.47",
|
||||
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz",
|
||||
@@ -2421,6 +2908,15 @@
|
||||
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/marked": {
|
||||
"version": "18.0.4",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.4.tgz",
|
||||
@@ -2474,11 +2970,16 @@
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/mitt": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
|
||||
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.12",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -2515,14 +3016,12 @@
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -2551,7 +3050,6 @@
|
||||
"version": "8.5.15",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -2828,6 +3326,12 @@
|
||||
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/safe-json-stringify": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/safe-json-stringify/-/safe-json-stringify-1.2.0.tgz",
|
||||
"integrity": "sha512-gH8eh2nZudPQO6TytOvbxnuhYBOvDBBLW52tz5q6X58lJcd/tkmqFR+5Z9adS8aJtURSXWThWy/xJtJwixErvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
@@ -2850,7 +3354,6 @@
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -2907,9 +3410,13 @@
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/unfetch": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/unfetch/-/unfetch-4.1.0.tgz",
|
||||
"integrity": "sha512-crP/n3eAPUJxZXM9T80/yv0YhkTEx2K1D3h7D1AJM6fzsWZrxdyRuLN0JH/dkZh1LNH8LxCnBzoPFCPbb2iGpg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.6.0",
|
||||
@@ -3016,6 +3523,18 @@
|
||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/web-vitals": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.1.0.tgz",
|
||||
"integrity": "sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/zen-observable": {
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/zen-observable/-/zen-observable-0.10.0.tgz",
|
||||
"integrity": "sha512-iI3lT0iojZhKwT5DaFy2Ce42n3yFcLdFyOh01G7H0flMY60P8MJuVFEoJoNwXlmAyQ45GrjL6AcZmmlv8A5rbw==",
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "rfc-app-frontend",
|
||||
"private": true,
|
||||
"version": "0.13.0",
|
||||
"version": "0.17.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -9,6 +9,7 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@amplitude/unified": "^1.1.9",
|
||||
"@codemirror/commands": "^6.10.3",
|
||||
"@codemirror/lang-markdown": "^6.5.0",
|
||||
"@codemirror/language": "^6.12.3",
|
||||
|
||||
@@ -410,6 +410,26 @@
|
||||
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;
|
||||
}
|
||||
@@ -435,6 +455,65 @@
|
||||
.otc-fallback a:hover { color: #1a1a1a; text-decoration: underline; }
|
||||
.otc-fallback-sep { color: #ccc; }
|
||||
|
||||
/* v0.11.0 — "trust this device for 30 days" checkbox on the verify
|
||||
step. Sits above the action row, padded so it doesn't crowd the
|
||||
passcode/code input. */
|
||||
.otc-trust-device {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
font-size: 13px; color: #444;
|
||||
margin: 8px 0 4px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.otc-trust-device input[type="checkbox"] {
|
||||
width: auto; margin: 0; cursor: pointer;
|
||||
}
|
||||
|
||||
/* v0.11.0 — /settings/devices revoke-device UI. */
|
||||
.device-list {
|
||||
list-style: none; padding: 0; margin: 12px 0 0;
|
||||
}
|
||||
.device-list-item {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 12px;
|
||||
border: 1px solid #eee; border-radius: 6px;
|
||||
padding: 10px 12px; margin: 0 0 8px;
|
||||
background: #fafafa;
|
||||
}
|
||||
.device-list-item .device-meta {
|
||||
flex: 1; min-width: 0;
|
||||
}
|
||||
.device-list-item .device-ua {
|
||||
font-size: 13px; color: #1a1a1a;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.device-list-item .device-stamps {
|
||||
font-size: 12px; color: #777;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.device-list-item button {
|
||||
font-size: 12px; padding: 4px 10px;
|
||||
border: 1px solid #ccc; border-radius: 4px;
|
||||
background: white; cursor: pointer;
|
||||
}
|
||||
.device-list-item button:hover:not(:disabled) {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
.device-revoke-all {
|
||||
margin-top: 8px;
|
||||
font-size: 13px; padding: 6px 12px;
|
||||
border: 1px solid #cb6a6a; border-radius: 4px;
|
||||
background: white; color: #cb6a6a; cursor: pointer;
|
||||
}
|
||||
.device-revoke-all:hover:not(:disabled) {
|
||||
background: #fff5f5;
|
||||
}
|
||||
.device-empty {
|
||||
font-size: 13px; color: #777;
|
||||
background: #fafafa; border: 1px solid #eee; border-radius: 6px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
/* --- Beta-pending page (post-OAuth-rejection) --- */
|
||||
|
||||
.beta-pending {
|
||||
@@ -466,6 +545,22 @@
|
||||
.btn-link-quiet { color: #666; text-decoration: none; font-size: 13px; }
|
||||
.btn-link-quiet:hover { color: #1a1a1a; text-decoration: underline; }
|
||||
|
||||
/* v0.8.0 — thin "your beta access is in review" banner. Shown on every
|
||||
page (other than /beta-pending itself, which carries the larger
|
||||
form of the message). Sits just under the app header so it doesn't
|
||||
compete with the catalog rail. */
|
||||
.pending-access-banner {
|
||||
background: #fff8e0;
|
||||
border-bottom: 1px solid #e6dca0;
|
||||
color: #4a3f00;
|
||||
font-size: 13px;
|
||||
padding: 8px 16px;
|
||||
text-align: center;
|
||||
}
|
||||
.pending-access-banner a {
|
||||
color: #4a3f00; text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ── §8 RFC view: three-column shape ─────────────────────────────────── */
|
||||
|
||||
.main-pane {
|
||||
@@ -1853,6 +1948,54 @@
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
font-size: 13px; cursor: pointer;
|
||||
}
|
||||
|
||||
/* v0.9.0 — admin user-management surface (roadmap item #7). */
|
||||
.admin-filter-chips {
|
||||
display: flex; gap: 6px; margin-bottom: 16px; flex-wrap: wrap;
|
||||
}
|
||||
.admin-chip {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
background: #fff; border: 1px solid #d1d5db; border-radius: 999px;
|
||||
padding: 4px 12px; font-size: 12px; color: #374151; cursor: pointer;
|
||||
}
|
||||
.admin-chip:hover { background: #f9fafb; }
|
||||
.admin-chip.active {
|
||||
background: #111; color: #fff; border-color: #111;
|
||||
}
|
||||
.admin-chip-count {
|
||||
font-size: 11px; opacity: 0.7;
|
||||
}
|
||||
.admin-users-table td { vertical-align: top; padding-top: 10px; padding-bottom: 10px; }
|
||||
.permission-cell { display: flex; flex-direction: column; gap: 4px; }
|
||||
.permission-actions { display: flex; gap: 6px; }
|
||||
.permission-badge {
|
||||
display: inline-block;
|
||||
font-size: 11px; font-weight: 600;
|
||||
padding: 2px 8px; border-radius: 999px;
|
||||
text-transform: uppercase; letter-spacing: 0.04em;
|
||||
width: max-content;
|
||||
}
|
||||
.permission-badge-pending {
|
||||
background: #fef3c7; color: #92400e;
|
||||
}
|
||||
.permission-badge-granted {
|
||||
background: #dcfce7; color: #166534;
|
||||
}
|
||||
.permission-badge-revoked {
|
||||
background: #fee2e2; color: #991b1b;
|
||||
}
|
||||
.permission-decided { font-size: 11px; }
|
||||
.user-row-reason td {
|
||||
background: #fffbeb; border-top: none !important;
|
||||
padding: 0 16px 12px !important;
|
||||
}
|
||||
.user-reason-block {
|
||||
border-left: 3px solid #f59e0b;
|
||||
padding: 8px 12px; font-size: 13px;
|
||||
background: #fffbeb;
|
||||
}
|
||||
.user-reason-block strong { display: block; margin-bottom: 4px; color: #92400e; }
|
||||
.user-reason-block p { margin: 0; white-space: pre-wrap; color: #374151; }
|
||||
.grad-queue { list-style: none; padding: 0; margin: 8px 0 24px; }
|
||||
.grad-queue li { padding: 8px 0; border-bottom: 1px solid #f3f4f6; }
|
||||
.grad-queue-link { color: #111; text-decoration: none; font-size: 14px; }
|
||||
|
||||
+120
-7
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Routes, Route, Link, useNavigate } from 'react-router-dom'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Routes, Route, Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { getMe, subscribeToNotifications } from './api'
|
||||
import { anonymize, EVENTS, identify, track } from './lib/analytics'
|
||||
import Catalog from './components/Catalog.jsx'
|
||||
import Inbox from './components/Inbox.jsx'
|
||||
import RFCView from './components/RFCView.jsx'
|
||||
@@ -11,8 +12,11 @@ import Landing from './components/Landing.jsx'
|
||||
import Login from './components/Login.jsx'
|
||||
import BetaPending from './components/BetaPending.jsx'
|
||||
import Philosophy from './components/Philosophy.jsx'
|
||||
import Docs from './components/Docs.jsx'
|
||||
import NotificationSettings from './components/NotificationSettings.jsx'
|
||||
import Admin from './components/Admin.jsx'
|
||||
import AcceptInvitation from './components/AcceptInvitation.jsx'
|
||||
import InviteClaim from './components/InviteClaim.jsx'
|
||||
import ToastHost, { showToast } from './components/ToastHost.jsx'
|
||||
import CookieConsentBanner from './components/CookieConsentBanner.jsx'
|
||||
import Privacy from './pages/Privacy.jsx'
|
||||
@@ -33,6 +37,56 @@ export default function App() {
|
||||
// event that bumps this.
|
||||
const [consentReopenTick, setConsentReopenTick] = useState(0)
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
// v0.15.0 — Page Viewed event taxonomy. We fire on every
|
||||
// route change; the analytics wrapper itself decides whether
|
||||
// anything ships out (consent + key check). The first fire is
|
||||
// also covered because `location` is set on mount.
|
||||
const lastPathRef = useRef(null)
|
||||
useEffect(() => {
|
||||
const path = location.pathname + (location.search || '')
|
||||
if (lastPathRef.current === path) return
|
||||
lastPathRef.current = path
|
||||
track(EVENTS.PAGE_VIEWED, { path: location.pathname })
|
||||
}, [location.pathname, location.search])
|
||||
|
||||
// v0.15.0 + #21 Part C — bind the authenticated user id AND
|
||||
// durable user properties to the analytics session when sign-in
|
||||
// lands; reset on sign-out (viewer flips to null). The wrapper
|
||||
// queues these calls until consent + init resolve, so the order
|
||||
// is safe even on a cold load.
|
||||
//
|
||||
// Property bag passed to identify (set vs setOnce per #21 Part C):
|
||||
// set: role, permission_state, passcode_set, device_trusted
|
||||
// (these can change mid-account-life — refresh each sign-in)
|
||||
// setOnce: first_sign_in_at, account_created_at
|
||||
// (immutable user-history markers — set on the first
|
||||
// sign-in that observes them, never overwritten)
|
||||
//
|
||||
// PII discipline: NO email, NO display_name, NO gitea_login passed
|
||||
// through — Amplitude only sees opaque ids + enums + timestamps +
|
||||
// booleans.
|
||||
const lastUserIdRef = useRef(null)
|
||||
useEffect(() => {
|
||||
const uid = me?.authenticated ? me.user?.id : null
|
||||
const viewer = me?.authenticated ? me.user : null
|
||||
if (uid != null && lastUserIdRef.current !== uid) {
|
||||
lastUserIdRef.current = uid
|
||||
const props = {}
|
||||
if (viewer?.role != null) props.role = viewer.role
|
||||
if (viewer?.permission_state != null) props.permission_state = viewer.permission_state
|
||||
if (viewer?.passcode_set != null) props.passcode_set = !!viewer.passcode_set
|
||||
if (viewer?.device_trusted != null) props.device_trusted = !!viewer.device_trusted
|
||||
if (viewer?.first_sign_in_at) props.first_sign_in_at = ['__setOnce__', viewer.first_sign_in_at]
|
||||
if (viewer?.created_at) props.account_created_at = ['__setOnce__', viewer.created_at]
|
||||
identify({ user_id: String(uid), properties: props })
|
||||
} else if (uid == null && lastUserIdRef.current != null) {
|
||||
// Sign-out edge — App-level reset is handled separately by the
|
||||
// sign-out gesture that fires User Signed Out. Clear our local
|
||||
// memo so a fresh sign-in re-fires identify.
|
||||
lastUserIdRef.current = null
|
||||
}
|
||||
}, [me?.authenticated, me?.user?.id, me?.user?.role, me?.user?.permission_state, me?.user?.passcode_set, me?.user?.device_trusted])
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => setConsentReopenTick(t => t + 1)
|
||||
@@ -87,11 +141,15 @@ export default function App() {
|
||||
// The deployment is in private beta: anonymous visitors get the full
|
||||
// app in read-only mode (viewer = null is passed through to every
|
||||
// component), and write affordances are hidden at the component
|
||||
// level. /beta-pending is the post-OAuth-rejection page reachable by
|
||||
// anyone. The original §14.1 Landing surface is retained for the
|
||||
// `/welcome` URL only, in case a deployment wants to link to it.
|
||||
// level. v0.8.0 (§6.1 / item #6): authenticated users with
|
||||
// `permission_state='pending'` also pass through as `viewer` with
|
||||
// their state attached — every write-gated affordance reads the
|
||||
// state and treats pending the same as anonymous, while reads
|
||||
// remain open. The /beta-pending page is the home root for a
|
||||
// pending user.
|
||||
const viewer = me?.authenticated ? me.user : null
|
||||
const isAdmin = viewer && (viewer.role === 'owner' || viewer.role === 'admin')
|
||||
const isPending = viewer && viewer.permission_state === 'pending'
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
@@ -107,6 +165,9 @@ export default function App() {
|
||||
<Link to="/philosophy" className="header-about" title="Why this exists (§14)">
|
||||
About
|
||||
</Link>
|
||||
<Link to="/docs" className="header-about" title="User guide">
|
||||
Docs
|
||||
</Link>
|
||||
{viewer && (
|
||||
<Link to="/settings/notifications" className="header-settings" title="Notification settings (§15)">
|
||||
Settings
|
||||
@@ -133,7 +194,20 @@ export default function App() {
|
||||
<>
|
||||
<span className="user-name">{viewer.display_name}</span>
|
||||
<span className={`user-role-badge role-${viewer.role}`}>{viewer.role}</span>
|
||||
<a className="btn-link" href="/auth/logout">Sign out</a>
|
||||
<a
|
||||
className="btn-link"
|
||||
href="/auth/logout"
|
||||
onClick={() => {
|
||||
// v0.15.0 — fire the sign-out event before the
|
||||
// hard nav. The wrapper's track() is sync-enqueue;
|
||||
// the underlying SDK flush is best-effort across
|
||||
// navigation. anonymize() clears the user binding
|
||||
// so any post-nav anonymous events on the next
|
||||
// page aren't attributed to the prior user.
|
||||
track(EVENTS.USER_SIGNED_OUT)
|
||||
anonymize()
|
||||
}}
|
||||
>Sign out</a>
|
||||
</>
|
||||
) : (
|
||||
<Link className="btn-signin-header" to="/login" title="Private beta — only invited emails can sign in">
|
||||
@@ -142,12 +216,24 @@ export default function App() {
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
{isPending && <PendingAccessBanner />}
|
||||
<div className="app-body">
|
||||
<Routes>
|
||||
<Route path="/welcome" element={<Landing />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/beta-pending" element={<BetaPending />} />
|
||||
<Route path="/beta-pending" element={<BetaPending viewer={viewer} />} />
|
||||
{/* v0.16.0 (item #12): per-RFC invitation acceptance landing.
|
||||
Anonymous viewers see a sign-in prompt; signed-in users
|
||||
see the preview + accept gesture. */}
|
||||
<Route path="/invitations/accept" element={
|
||||
<PolicyShell><AcceptInvitation viewer={viewer} /></PolicyShell>
|
||||
} />
|
||||
{/* 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="/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>} />
|
||||
@@ -216,6 +302,14 @@ function PhilosophyWithSidebar({ viewer }) {
|
||||
)
|
||||
}
|
||||
|
||||
function DocsWithSidebar({ viewer }) {
|
||||
return (
|
||||
<main className="chrome-pane">
|
||||
<Docs authenticated={!!viewer} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function NotificationSettingsWithSidebar({ viewer }) {
|
||||
return (
|
||||
<main className="chrome-pane">
|
||||
@@ -232,7 +326,26 @@ function AdminWithSidebar({ viewer }) {
|
||||
)
|
||||
}
|
||||
|
||||
function PendingAccessBanner() {
|
||||
// v0.8.0 — thin banner shown on every page (other than /beta-pending
|
||||
// itself, which carries the same message in larger form) when the
|
||||
// signed-in user's `permission_state='pending'`. Sign-out works
|
||||
// normally via the header affordance.
|
||||
return (
|
||||
<div className="pending-access-banner">
|
||||
Your beta access request is in review.{' '}
|
||||
<Link to="/beta-pending">Learn more →</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Welcome({ viewer }) {
|
||||
// v0.8.0 — a pending user landing on "/" gets the same page they'd
|
||||
// see at /beta-pending, inline. This is the post-OTC home root for
|
||||
// a user awaiting admin grant.
|
||||
if (viewer && viewer.permission_state === 'pending') {
|
||||
return <BetaPending viewer={viewer} />
|
||||
}
|
||||
if (!viewer) {
|
||||
return (
|
||||
<div className="welcome">
|
||||
|
||||
+211
-4
@@ -31,24 +31,125 @@ export async function getMe() {
|
||||
// migration — the new UI just no longer points at it primarily. These
|
||||
// two helpers drive the Login.jsx surface.
|
||||
|
||||
export async function requestOtc(email) {
|
||||
export async function requestOtc(email, { turnstileToken } = {}) {
|
||||
// v0.12.0 / roadmap item #10: when the Turnstile widget has produced
|
||||
// a token, send it alongside the email so the backend can siteverify
|
||||
// before the OTC dispatch. The backend treats a missing token as
|
||||
// either soft-fail (no secret wired AND TURNSTILE_REQUIRED=false)
|
||||
// or hard-fail (verification required) — the frontend stays
|
||||
// uninvolved in the policy.
|
||||
const body = { email }
|
||||
if (turnstileToken) body.turnstile_token = turnstileToken
|
||||
const res = await fetch('/auth/otc/request', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email }),
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function verifyOtc(email, code) {
|
||||
export async function verifyOtc(email, code, { trustDevice = false } = {}) {
|
||||
// v0.11.0 — `trustDevice` is the "trust this device for 30 days"
|
||||
// checkbox on the Login.jsx OTC step. When true, the server mints
|
||||
// a fresh device-trust row and sets the long-lived cookie; on
|
||||
// subsequent visits, the cookie skips the OTC roundtrip via
|
||||
// `startDeviceTrust()`.
|
||||
const res = await fetch('/auth/otc/verify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, code }),
|
||||
body: JSON.stringify({ email, code, trust_device: !!trustDevice }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
// ── v0.8.0: open beta-access request flow (§6.1 / §14.1) ─────────────────
|
||||
//
|
||||
// On the first OTC sign-in, the user lands in `permission_state='pending'`
|
||||
// and `/api/auth/me` reports `needs_profile=true`. The Login.jsx surface
|
||||
// then prompts for first/last/why and POSTs them here. After this lands,
|
||||
// the user sees the /beta-pending page until an admin grants access.
|
||||
|
||||
export async function submitBetaRequest({ first_name, last_name, beta_request_reason }) {
|
||||
const res = await fetch('/api/auth/me/beta-request', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ first_name, last_name, beta_request_reason }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
// ── v0.10.0: user-set passcodes after OTC (§6.2, roadmap item #8) ─────────
|
||||
//
|
||||
// After a successful OTC sign-in, a contributor may set a passcode and
|
||||
// use email + passcode for subsequent sign-ins. OTC remains the
|
||||
// forgot-passcode fallback — 5 consecutive verify failures locks the
|
||||
// passcode path for 15 minutes (HTTP 423); the OTC path is unaffected.
|
||||
|
||||
export async function checkPasscode(email) {
|
||||
// Anonymous endpoint. Returns `{has_passcode: boolean}` so the
|
||||
// Login.jsx flow can decide whether to render a passcode input or
|
||||
// fall back to OTC. We URL-encode the email so addresses with '+'
|
||||
// round-trip cleanly.
|
||||
const params = new URLSearchParams({ email })
|
||||
const res = await fetch(`/auth/passcode/check?${params}`)
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function verifyPasscode(email, passcode, { trustDevice = false } = {}) {
|
||||
// v0.11.0 — same trust-device opt-in as `verifyOtc`.
|
||||
const res = await fetch('/auth/passcode/verify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, passcode, trust_device: !!trustDevice }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
// ── v0.11.0: trust device for 30 days (§6.2, roadmap item #9) ─────────────
|
||||
//
|
||||
// On a returning visit with a valid device-trust cookie, `startDeviceTrust`
|
||||
// re-establishes the session without an OTC / passcode roundtrip. The
|
||||
// cookie is HttpOnly so the client cannot read it; the call is a pure POST
|
||||
// that the browser attaches the cookie to automatically.
|
||||
//
|
||||
// `listMyDevices`, `revokeMyDevice`, and `revokeAllMyDevices` drive the
|
||||
// /settings/devices revoke-device UI. The signed-in user is the implicit
|
||||
// subject; the cookie carries the session.
|
||||
|
||||
export async function startDeviceTrust() {
|
||||
const res = await fetch('/auth/device-trust/start', { method: 'POST' })
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function listMyDevices() {
|
||||
return jsonOrThrow(await fetch('/api/auth/me/devices'))
|
||||
}
|
||||
|
||||
export async function revokeMyDevice(deviceId) {
|
||||
return jsonOrThrow(await fetch(`/api/auth/me/devices/${deviceId}`, { method: 'DELETE' }))
|
||||
}
|
||||
|
||||
export async function revokeAllMyDevices() {
|
||||
return jsonOrThrow(await fetch('/api/auth/me/devices', { method: 'DELETE' }))
|
||||
}
|
||||
|
||||
export async function setPasscode(passcode) {
|
||||
// Requires an active session — the server returns 401 if not signed
|
||||
// in. The signed-in user is the implicit subject; the body carries
|
||||
// only the new passcode.
|
||||
const res = await fetch('/auth/passcode/set', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ passcode }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function clearPasscode() {
|
||||
const res = await fetch('/auth/passcode', { method: 'DELETE' })
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function listRFCs() {
|
||||
return jsonOrThrow(await fetch('/api/rfcs'))
|
||||
}
|
||||
@@ -221,6 +322,48 @@ export async function resolveThread(slug, branch, threadId) {
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
// ── v0.16.0: owner-only invite for per-RFC PR or PR-less discussion ──────
|
||||
//
|
||||
// roadmap item #12 / §6 / §10. The RFC's owner invites specific emails
|
||||
// to one of two per-RFC roles ('contributor' or 'discussant'); the
|
||||
// invitee accepts via the email-encoded token after signing in. The
|
||||
// platform-level grant remains the admin's decision (per item #6 /
|
||||
// v0.8.0) — these endpoints control per-RFC membership only.
|
||||
|
||||
export async function listRFCInvitations(slug) {
|
||||
return jsonOrThrow(await fetch(`/api/rfcs/${slug}/invitations`))
|
||||
}
|
||||
|
||||
export async function createRFCInvitation(slug, { inviteeEmail, roleInRFC }) {
|
||||
const res = await fetch(`/api/rfcs/${slug}/invitations`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ invitee_email: inviteeEmail, role_in_rfc: roleInRFC }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function revokeRFCInvitation(slug, invitationId) {
|
||||
const res = await fetch(`/api/rfcs/${slug}/invitations/${invitationId}/revoke`, {
|
||||
method: 'POST',
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function previewInvitation(token) {
|
||||
const params = new URLSearchParams({ token })
|
||||
return jsonOrThrow(await fetch(`/api/invitations/accept?${params}`))
|
||||
}
|
||||
|
||||
export async function acceptInvitation(token) {
|
||||
const res = await fetch('/api/invitations/accept', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
// ── v0.5.0: PR-less per-RFC discussion (§5 / §10) ────────────────────────
|
||||
//
|
||||
// The substrate is `threads.branch_name IS NULL` — the same threads
|
||||
@@ -573,6 +716,10 @@ export async function getPhilosophy() {
|
||||
return jsonOrThrow(await fetch('/api/philosophy'))
|
||||
}
|
||||
|
||||
export async function getDocs() {
|
||||
return jsonOrThrow(await fetch('/api/docs'))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Slice 7: admin neighborhood (§17 admin/* + user search for the §15.8 mute
|
||||
// typeahead).
|
||||
@@ -598,6 +745,19 @@ export async function setUserMute(userId, muted) {
|
||||
}))
|
||||
}
|
||||
|
||||
// v0.9.0 — roadmap item #7. Flip a user's permission_state between
|
||||
// 'pending', 'granted', and 'revoked'. The Users tab on the admin
|
||||
// page wires Grant / Revoke buttons against this endpoint; the
|
||||
// returned `changed` flag is false when the requested state already
|
||||
// matched the row.
|
||||
export async function setUserPermission(userId, state) {
|
||||
return jsonOrThrow(await fetch(`/api/admin/users/${userId}/permission`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ state }),
|
||||
}))
|
||||
}
|
||||
|
||||
export async function listAuditLog({ actionKind, actorUserId, rfcSlug, beforeId, limit } = {}) {
|
||||
const params = new URLSearchParams()
|
||||
if (actionKind) params.set('action_kind', actionKind)
|
||||
@@ -639,6 +799,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) {
|
||||
const params = new URLSearchParams()
|
||||
if (q) params.set('q', q)
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
// AcceptInvitation.jsx — v0.16.0 / roadmap item #12.
|
||||
//
|
||||
// The /invitations/accept?token=... landing page the invitation email
|
||||
// links to. The page:
|
||||
//
|
||||
// 1. Reads `?token=...` from the URL.
|
||||
// 2. Calls GET /api/invitations/accept?token=... to preview what the
|
||||
// invitation grants (RFC title, role-in-RFC, expiry, whether the
|
||||
// currently-signed-in user's email matches the invitee's).
|
||||
// 3. Renders a confirmation surface — name the RFC, name the role,
|
||||
// and either show "Accept" (when the email matches and the
|
||||
// invitation is still pending) or a refusal message (expired,
|
||||
// revoked, email mismatch).
|
||||
// 4. On accept, POST /api/invitations/accept lands the
|
||||
// rfc_collaborators row and the page redirects to the RFC's view.
|
||||
//
|
||||
// For an anonymous viewer who lands here without signing in, the
|
||||
// preview call 401s and the page tells them to sign in. After
|
||||
// signing in (via the existing OTC/passcode surface at /login) they
|
||||
// can return to the same URL — the token is stable.
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { acceptInvitation, previewInvitation } from '../api'
|
||||
import { EVENTS, identify, track } from '../lib/analytics'
|
||||
|
||||
export default function AcceptInvitation({ viewer }) {
|
||||
const [searchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
const token = searchParams.get('token') || ''
|
||||
|
||||
const [preview, setPreview] = useState(null)
|
||||
const [previewError, setPreviewError] = useState(null)
|
||||
const [accepting, setAccepting] = useState(false)
|
||||
const [acceptError, setAcceptError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setPreviewError('No invitation token in the URL.')
|
||||
return
|
||||
}
|
||||
if (!viewer) {
|
||||
// Not signed in — the preview endpoint will 401. We surface a
|
||||
// sign-in prompt without making the request.
|
||||
return
|
||||
}
|
||||
previewInvitation(token)
|
||||
.then(setPreview)
|
||||
.catch(err => setPreviewError(err.message || 'Could not load invitation.'))
|
||||
}, [token, viewer])
|
||||
|
||||
async function handleAccept() {
|
||||
setAccepting(true)
|
||||
setAcceptError(null)
|
||||
try {
|
||||
const result = await acceptInvitation(token)
|
||||
// v0.16.0 + #21 Part C — re-identify with per-RFC invite
|
||||
// properties on accept, BEFORE the track event fires, so the
|
||||
// Amplitude user record carries the invite context from the
|
||||
// moment of acceptance. setOnce on invited_at preserves the
|
||||
// first-accepted timestamp if the same user accepts multiple
|
||||
// RFC invitations.
|
||||
if (viewer?.id != null) {
|
||||
identify({
|
||||
user_id: String(viewer.id),
|
||||
properties: {
|
||||
invited_at: ['__setOnce__', new Date().toISOString()],
|
||||
last_invited_to_rfc: result.rfc_slug,
|
||||
last_invite_role_in_rfc: result.role_in_rfc || preview?.role_in_rfc,
|
||||
claim_method: 'rfc-invite',
|
||||
},
|
||||
})
|
||||
}
|
||||
track(EVENTS.INVITATION_ACCEPTED, {
|
||||
rfc_slug: result.rfc_slug,
|
||||
role_in_rfc: result.role_in_rfc || preview?.role_in_rfc,
|
||||
})
|
||||
navigate(`/rfc/${result.rfc_slug}`)
|
||||
} catch (err) {
|
||||
setAcceptError(err.message || 'Could not accept invitation.')
|
||||
} finally {
|
||||
setAccepting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="accept-invitation">
|
||||
<h1>Invitation link is malformed</h1>
|
||||
<p>No <code>token</code> parameter was found. Ask the person who
|
||||
invited you to re-send the link.</p>
|
||||
<p><Link to="/">Return to the catalog</Link></p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!viewer) {
|
||||
return (
|
||||
<div className="accept-invitation">
|
||||
<h1>Sign in to accept your invitation</h1>
|
||||
<p>
|
||||
You've been invited to collaborate on an RFC. Sign in first so we
|
||||
can attach the membership to your account, then return to this
|
||||
link.
|
||||
</p>
|
||||
<p>
|
||||
<Link to="/login" className="btn-primary">Sign in</Link>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (previewError) {
|
||||
return (
|
||||
<div className="accept-invitation">
|
||||
<h1>Invitation unavailable</h1>
|
||||
<p>{previewError}</p>
|
||||
<p><Link to="/">Return to the catalog</Link></p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!preview) {
|
||||
return <div className="accept-invitation">Loading invitation…</div>
|
||||
}
|
||||
|
||||
const { rfc_title, rfc_slug, role_in_rfc, status, invitee_email, email_matches_you } = preview
|
||||
|
||||
if (status === 'revoked') {
|
||||
return (
|
||||
<div className="accept-invitation">
|
||||
<h1>Invitation revoked</h1>
|
||||
<p>
|
||||
The owner of <strong>{rfc_title}</strong> revoked this invitation.
|
||||
Ask them to re-issue it if you should still have access.
|
||||
</p>
|
||||
<p><Link to={`/rfc/${rfc_slug}`}>Read the RFC anyway</Link></p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (status === 'expired') {
|
||||
return (
|
||||
<div className="accept-invitation">
|
||||
<h1>Invitation expired</h1>
|
||||
<p>
|
||||
This invitation to <strong>{rfc_title}</strong> has expired. Ask
|
||||
the RFC's owner to issue a fresh one.
|
||||
</p>
|
||||
<p><Link to={`/rfc/${rfc_slug}`}>Read the RFC anyway</Link></p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (status === 'accepted') {
|
||||
return (
|
||||
<div className="accept-invitation">
|
||||
<h1>Already accepted</h1>
|
||||
<p>
|
||||
You've already accepted this invitation. You can{' '}
|
||||
<Link to={`/rfc/${rfc_slug}`}>open {rfc_title}</Link> now.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!email_matches_you) {
|
||||
return (
|
||||
<div className="accept-invitation">
|
||||
<h1>This invitation is for a different account</h1>
|
||||
<p>
|
||||
This invitation was sent to <strong>{invitee_email}</strong>. You're
|
||||
currently signed in as <strong>{viewer.email || viewer.gitea_login}</strong>.
|
||||
Sign out and sign back in with the invited address to accept.
|
||||
</p>
|
||||
<p><a className="btn-link" href="/auth/logout">Sign out</a></p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="accept-invitation">
|
||||
<h1>Join {rfc_title}</h1>
|
||||
<p>
|
||||
You've been invited to <strong>{rfc_title}</strong> as a{' '}
|
||||
<strong>{role_in_rfc}</strong>.
|
||||
</p>
|
||||
<p style={{ color: '#666' }}>
|
||||
{role_in_rfc === 'contributor'
|
||||
? 'Contributors can open PRs against this RFC and join its discussion.'
|
||||
: 'Discussants can post in this RFC\'s discussion.'}
|
||||
</p>
|
||||
{acceptError && <div className="error-banner">{acceptError}</div>}
|
||||
<p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
onClick={handleAccept}
|
||||
disabled={accepting}
|
||||
>
|
||||
{accepting ? 'Accepting…' : `Accept and open ${rfc_title}`}
|
||||
</button>
|
||||
</p>
|
||||
<p>
|
||||
<Link to={`/rfc/${rfc_slug}`}>or just read the RFC without accepting</Link>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -16,13 +16,22 @@ import {
|
||||
listAdminUsers,
|
||||
setUserRole,
|
||||
setUserMute,
|
||||
setUserPermission,
|
||||
listAuditLog,
|
||||
listPermissionEvents,
|
||||
listGraduationQueue,
|
||||
listAllowlist,
|
||||
addAllowlistEmail,
|
||||
removeAllowlistEmail,
|
||||
createUserInvite,
|
||||
} from '../api.js'
|
||||
import { EVENTS, track } from '../lib/analytics.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 = [
|
||||
{ path: 'users', label: 'Users' },
|
||||
@@ -68,12 +77,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() {
|
||||
const [users, setUsers] = useState(null)
|
||||
const [busy, setBusy] = useState({})
|
||||
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() {
|
||||
setError(null)
|
||||
@@ -113,68 +140,400 @@ function UsersTab() {
|
||||
}
|
||||
}
|
||||
|
||||
async function flipPermission(userId, state) {
|
||||
setBusy(b => ({ ...b, [userId]: true }))
|
||||
setError(null)
|
||||
try {
|
||||
await setUserPermission(userId, state)
|
||||
// v0.15.0 — analytics: fire on a successful §6.1 grant/revoke.
|
||||
// action collapses the {pending → granted, revoked → granted}
|
||||
// edges onto `grant`, and `granted → revoked` onto `revoke`,
|
||||
// matching the roadmap's two-arm taxonomy.
|
||||
const action = state === 'granted' ? 'grant' : 'revoke'
|
||||
track(EVENTS.ADMIN_PERMISSION_DECISION, { action, target_user_id: String(userId) })
|
||||
// Refresh the full row so permission_decided_{at,by_*} update too.
|
||||
await refresh()
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setBusy(b => ({ ...b, [userId]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const c = { all: 0, pending: 0, granted: 0, revoked: 0 }
|
||||
if (users) {
|
||||
c.all = users.length
|
||||
for (const u of users) {
|
||||
const s = u.permission_state || 'granted'
|
||||
if (s in c) c[s] += 1
|
||||
}
|
||||
}
|
||||
return c
|
||||
}, [users])
|
||||
|
||||
if (users == null) return <p className="muted">Loading users…</p>
|
||||
|
||||
const filtered = stateFilter === 'all'
|
||||
? users
|
||||
: users.filter(u => (u.permission_state || 'granted') === stateFilter)
|
||||
|
||||
return (
|
||||
<div className="admin-tab">
|
||||
<header className="admin-tab-header">
|
||||
<h2>Users</h2>
|
||||
<p className="muted">
|
||||
Role changes write to <code>permission_events</code>. The §6.2
|
||||
write-mute applies to contributors only — promote to admin to
|
||||
remove a user's ability to write without silencing them.
|
||||
The pending bucket is the beta-access review queue (§6.1 /
|
||||
v0.8.0). Grant or revoke writes to <code>permission_events</code>
|
||||
and stamps <code>permission_decided_by</code> +{' '}
|
||||
<code>permission_decided_at</code>. Role and write-mute controls
|
||||
retain their v0.7.0 semantics — promote to admin to remove a
|
||||
user's ability to write without silencing them.
|
||||
</p>
|
||||
{/* 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>
|
||||
{error && <p className="settings-note warning">{error}</p>}
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Role</th>
|
||||
<th>Write-muted</th>
|
||||
<th>Last seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map(u => (
|
||||
<tr key={u.id}>
|
||||
<td>
|
||||
<div className="user-cell">
|
||||
<span className="user-handle">@{u.gitea_login}</span>
|
||||
<span className="muted">{u.display_name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<select
|
||||
value={u.role}
|
||||
onChange={e => changeRole(u.id, e.target.value)}
|
||||
disabled={!!busy[u.id]}
|
||||
>
|
||||
<option value="contributor">Contributor</option>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="owner">Owner</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
{u.role === 'contributor' ? (
|
||||
<label className="mute-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!u.muted}
|
||||
onChange={e => toggleMute(u.id, e.target.checked)}
|
||||
disabled={!!busy[u.id]}
|
||||
/>
|
||||
{u.muted ? 'Muted' : 'Active'}
|
||||
</label>
|
||||
) : (
|
||||
<span className="muted">N/A</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="muted">{u.last_seen_at}</td>
|
||||
{inviteModalOpen && (
|
||||
<CreateUserInviteModal
|
||||
onClose={() => setInviteModalOpen(false)}
|
||||
onSuccess={async () => {
|
||||
setInviteModalOpen(false)
|
||||
await refresh()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="admin-filter-chips">
|
||||
{STATE_CHIPS.map(chip => (
|
||||
<button
|
||||
key={chip.value}
|
||||
type="button"
|
||||
className={`admin-chip${stateFilter === chip.value ? ' active' : ''}`}
|
||||
onClick={() => setStateFilter(chip.value)}
|
||||
>
|
||||
{chip.label} <span className="admin-chip-count">{counts[chip.value] ?? 0}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<p className="muted">No users in this bucket.</p>
|
||||
) : (
|
||||
<table className="admin-table admin-users-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>State</th>
|
||||
<th>Role</th>
|
||||
<th>Write-muted</th>
|
||||
<th>Signed up</th>
|
||||
<th>Last seen</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map(u => (
|
||||
<UserRow
|
||||
key={u.id}
|
||||
user={u}
|
||||
busy={!!busy[u.id]}
|
||||
onChangeRole={role => changeRole(u.id, role)}
|
||||
onToggleMute={muted => toggleMute(u.id, muted)}
|
||||
onFlipPermission={state => flipPermission(u.id, state)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function UserRow({ user: u, busy, onChangeRole, onToggleMute, onFlipPermission }) {
|
||||
const state = u.permission_state || 'granted'
|
||||
const fullName = [u.first_name, u.last_name].filter(Boolean).join(' ').trim()
|
||||
const handle = u.gitea_login ? `@${u.gitea_login}` : (u.email || u.display_name)
|
||||
// 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,
|
||||
})
|
||||
// v0.17.0 + #21 Part C — Amplitude wiring. target_user_id is
|
||||
// the OHM user id the invite-create gesture provisioned;
|
||||
// initial_role is what the invitee inherits on claim.
|
||||
// custom_message_chars is a coarse signal of admin effort
|
||||
// (0 = template-only, 1+ = personalized). No PII.
|
||||
track(EVENTS.USER_INVITED, {
|
||||
target_user_id: result.invited_user_id != null
|
||||
? String(result.invited_user_id) : null,
|
||||
initial_role: result.role,
|
||||
custom_message_chars: (customMessage || '').length,
|
||||
})
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,38 +1,62 @@
|
||||
// BetaPending.jsx — the post-OAuth-rejection page.
|
||||
// BetaPending.jsx — the "your request is in review" page (§6.1 / §14.1).
|
||||
//
|
||||
// When a deployment is in private-beta mode (i.e. its `allowed_emails`
|
||||
// table has any rows), the OAuth callback redirects unrecognised users
|
||||
// here instead of provisioning them. The framework cannot know the
|
||||
// deployment operator's preferred contact channel — so the deployment
|
||||
// supplies one via VITE_BETA_CONTACT (an email, URL, or short
|
||||
// instruction). If unset, we render a generic ask-the-operator line.
|
||||
// v0.3.0 introduced this surface as the post-OAuth-rejection page (a
|
||||
// user whose email wasn't on the `allowed_emails` table bounced here).
|
||||
// v0.8.0 (roadmap item #6) repurposes it as the post-OTC pending-grant
|
||||
// page: any authenticated user whose `permission_state='pending'` lands
|
||||
// here on root visits, after a fresh-OTC profile capture, or via the
|
||||
// header "Your beta access is in review" affordance.
|
||||
//
|
||||
// The deployment supplies a contact channel via VITE_BETA_CONTACT (an
|
||||
// email, URL, or short instruction). If unset, we render a generic
|
||||
// ask-the-operator line.
|
||||
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
export default function BetaPending() {
|
||||
export default function BetaPending({ viewer }) {
|
||||
const contact = import.meta.env.VITE_BETA_CONTACT || ''
|
||||
const isPending = viewer?.permission_state === 'pending'
|
||||
return (
|
||||
<div className="beta-pending">
|
||||
<div className="beta-pending-inner">
|
||||
<h1>{import.meta.env.VITE_APP_NAME} is in private Beta.</h1>
|
||||
<p>
|
||||
Discussion and contribution are gated to invited emails for now.
|
||||
Reading is open — every super-draft, every active RFC, and every
|
||||
public conversation is visible without signing in.
|
||||
</p>
|
||||
<h1>
|
||||
{isPending
|
||||
? 'Your request is in review.'
|
||||
: `${import.meta.env.VITE_APP_NAME} is in private Beta.`}
|
||||
</h1>
|
||||
{isPending ? (
|
||||
<>
|
||||
<p>
|
||||
Thanks for telling us a bit about yourself. The deployment's
|
||||
admins are notified by email as soon as a request lands;
|
||||
we don't commit to a fixed SLA — turnaround depends on
|
||||
operator availability — and the deployment operator is
|
||||
the right person to ask if a wait runs long.
|
||||
</p>
|
||||
<p>
|
||||
While you wait, the catalog on the left lists every super-draft
|
||||
and active RFC in the framework — reading is open. Discussion
|
||||
and contribution unlock once your access is granted.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p>
|
||||
Discussion and contribution are gated to invited contributors for
|
||||
now. Reading is open — every super-draft, every active RFC, and
|
||||
every public conversation is visible without signing in.
|
||||
</p>
|
||||
)}
|
||||
{contact ? (
|
||||
<p className="beta-pending-contact">
|
||||
To request access, contact <strong>{contact}</strong> with the
|
||||
email address you'd like to sign in with.
|
||||
Questions? Contact <strong>{contact}</strong>.
|
||||
</p>
|
||||
) : (
|
||||
<p className="beta-pending-contact">
|
||||
To request access, contact the deployment operator with the email
|
||||
address you'd like to sign in with.
|
||||
Questions? Contact the deployment operator.
|
||||
</p>
|
||||
)}
|
||||
<div className="beta-pending-actions">
|
||||
<Link className="btn-primary" to="/">Browse as a guest</Link>
|
||||
<Link className="btn-primary" to="/">Browse the catalog</Link>
|
||||
<Link className="btn-link-quiet" to="/philosophy">Read the philosophy →</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,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,202 @@
|
||||
// InvitationsModal.jsx — v0.16.0 / roadmap item #12.
|
||||
//
|
||||
// The RFC owner's surface for issuing per-RFC invitations and watching
|
||||
// who has accepted. Opens from the RFC view's header strip when the
|
||||
// viewer is the RFC's owner (or a platform admin/owner). Non-owner
|
||||
// viewers never see the trigger.
|
||||
//
|
||||
// The modal shows two stacked sections:
|
||||
//
|
||||
// 1. "Invite someone" — email input + role picker
|
||||
// (contributor | discussant) + Send. The send goes through the
|
||||
// backend's POST /api/rfcs/<slug>/invitations, which both writes
|
||||
// the row and dispatches the email to the invitee. Success
|
||||
// refreshes the list below and clears the input.
|
||||
//
|
||||
// 2. "Existing invitations" — every invitation (pending +
|
||||
// accepted + revoked + expired) on this RFC, with revoke
|
||||
// buttons on the pending ones. The status of each row is the
|
||||
// effective status (the backend recomputes expired-from-pending
|
||||
// at read time so an unattended cron isn't required).
|
||||
//
|
||||
// No custom-message field — that belongs to item #16's platform-
|
||||
// level surface, not here. No bulk-invite — one email at a time
|
||||
// keeps the gesture deliberate.
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
createRFCInvitation,
|
||||
listRFCInvitations,
|
||||
revokeRFCInvitation,
|
||||
} from '../api'
|
||||
import { EVENTS, track } from '../lib/analytics'
|
||||
|
||||
const ROLE_OPTIONS = [
|
||||
{ value: 'contributor', label: 'Contributor — can open PRs and join discussion' },
|
||||
{ value: 'discussant', label: 'Discussant — can join discussion only' },
|
||||
]
|
||||
|
||||
export default function InvitationsModal({ slug, rfcTitle, onClose }) {
|
||||
const [invitations, setInvitations] = useState(null)
|
||||
const [loadError, setLoadError] = useState(null)
|
||||
const [inviteeEmail, setInviteeEmail] = useState('')
|
||||
const [roleInRFC, setRoleInRFC] = useState('contributor')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [submitError, setSubmitError] = useState(null)
|
||||
const [submitSuccess, setSubmitSuccess] = useState(null)
|
||||
const [revokingId, setRevokingId] = useState(null)
|
||||
|
||||
async function refresh() {
|
||||
setLoadError(null)
|
||||
try {
|
||||
const r = await listRFCInvitations(slug)
|
||||
setInvitations(r.items || [])
|
||||
} catch (e) {
|
||||
setLoadError(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { refresh() /* eslint-disable-line react-hooks/exhaustive-deps */ }, [slug])
|
||||
|
||||
async function handleSend(e) {
|
||||
e.preventDefault()
|
||||
const email = inviteeEmail.trim()
|
||||
if (!email) return
|
||||
setSubmitting(true)
|
||||
setSubmitError(null)
|
||||
setSubmitSuccess(null)
|
||||
try {
|
||||
await createRFCInvitation(slug, { inviteeEmail: email, roleInRFC })
|
||||
// v0.16.0 + #21 Part C — Amplitude wiring. No PII (the email
|
||||
// is the inviter's input, not the invitee's identity in our
|
||||
// analytics; we record the rfc_slug + role_in_rfc so a future
|
||||
// invite→accept correlation has both halves).
|
||||
track(EVENTS.INVITATION_SENT, { rfc_slug: slug, role_in_rfc: roleInRFC })
|
||||
setSubmitSuccess(`Invitation sent to ${email}.`)
|
||||
setInviteeEmail('')
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
setSubmitError(err.message || 'Failed to send invitation.')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRevoke(invitationId) {
|
||||
setRevokingId(invitationId)
|
||||
try {
|
||||
await revokeRFCInvitation(slug, invitationId)
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
setSubmitError(err.message || 'Failed to revoke invitation.')
|
||||
} finally {
|
||||
setRevokingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||
<div className="modal" style={{ maxWidth: 640 }}>
|
||||
<div className="modal-header">
|
||||
<h2>Invitations — {rfcTitle || slug}</h2>
|
||||
<button className="modal-close" onClick={onClose}>×</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<p style={{ marginTop: 0, color: '#666' }}>
|
||||
Invite people by email to contribute PRs against this RFC or to
|
||||
join its discussion. Anyone with the link can read this RFC;
|
||||
this surface controls who can <em>write</em>.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSend} className="invitations-form" style={{ marginTop: 16 }}>
|
||||
<label htmlFor="invitee-email">Invitee email</label>
|
||||
<input
|
||||
id="invitee-email"
|
||||
type="email"
|
||||
value={inviteeEmail}
|
||||
onChange={e => setInviteeEmail(e.target.value)}
|
||||
placeholder="someone@example.com"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
<label htmlFor="invitee-role" style={{ marginTop: 10 }}>Role on this RFC</label>
|
||||
<select
|
||||
id="invitee-role"
|
||||
value={roleInRFC}
|
||||
onChange={e => setRoleInRFC(e.target.value)}
|
||||
>
|
||||
{ROLE_OPTIONS.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<div style={{ marginTop: 12, display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<button type="submit" className="btn-primary" disabled={submitting}>
|
||||
{submitting ? 'Sending…' : 'Send invitation'}
|
||||
</button>
|
||||
{submitError && <span style={{ color: '#c33' }}>{submitError}</span>}
|
||||
{submitSuccess && <span style={{ color: '#383' }}>{submitSuccess}</span>}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<hr style={{ margin: '20px 0' }} />
|
||||
|
||||
<h3 style={{ margin: '0 0 8px' }}>Existing invitations</h3>
|
||||
{loadError && <div className="error-banner">{loadError}</div>}
|
||||
{invitations === null && <div>Loading…</div>}
|
||||
{invitations !== null && invitations.length === 0 && (
|
||||
<div style={{ color: '#666' }}>No invitations have been sent yet.</div>
|
||||
)}
|
||||
{invitations !== null && invitations.length > 0 && (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ textAlign: 'left', padding: 4 }}>Email</th>
|
||||
<th style={{ textAlign: 'left', padding: 4 }}>Role</th>
|
||||
<th style={{ textAlign: 'left', padding: 4 }}>Status</th>
|
||||
<th style={{ textAlign: 'left', padding: 4 }}>Sent</th>
|
||||
<th style={{ padding: 4 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{invitations.map(inv => (
|
||||
<tr key={inv.id} style={{ borderTop: '1px solid #eee' }}>
|
||||
<td style={{ padding: 4 }}>{inv.invitee_email}</td>
|
||||
<td style={{ padding: 4 }}>{inv.role_in_rfc}</td>
|
||||
<td style={{ padding: 4 }}>
|
||||
<span className={`invitation-status status-${inv.status}`}>
|
||||
{inv.status}
|
||||
</span>
|
||||
{inv.status === 'accepted' && inv.accepted_by_display && (
|
||||
<span style={{ color: '#666', marginLeft: 6 }}>
|
||||
by {inv.accepted_by_display}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: 4, color: '#666' }}>
|
||||
{inv.created_at?.slice(0, 10) || ''}
|
||||
</td>
|
||||
<td style={{ padding: 4, textAlign: 'right' }}>
|
||||
{inv.status === 'pending' && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-link"
|
||||
onClick={() => handleRevoke(inv.id)}
|
||||
disabled={revokingId === inv.id}
|
||||
>
|
||||
{revokingId === inv.id ? 'Revoking…' : 'Revoke'}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn-link" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
// 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'
|
||||
import { EVENTS, identify, track } from '../lib/analytics.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)
|
||||
// v0.17.0 + #21 Part C — identify the new user with their OHM
|
||||
// user_id BEFORE firing any track() event, so the Amplitude
|
||||
// user record is created with the OHM id from the first event
|
||||
// rather than as an anonymous device that retroactively links.
|
||||
// setOnce on invited_at + invited_by_admin_id + initial_role so
|
||||
// these are immutable user-history markers on the Amplitude
|
||||
// record.
|
||||
if (result.user?.id != null) {
|
||||
const setOnceProps = {
|
||||
claim_method: 'admin-invite',
|
||||
}
|
||||
if (result.invited_at) setOnceProps.invited_at = ['__setOnce__', result.invited_at]
|
||||
if (result.invited_by_admin_id != null) {
|
||||
setOnceProps.invited_by_admin_id = ['__setOnce__', String(result.invited_by_admin_id)]
|
||||
}
|
||||
if (result.user.role) setOnceProps.initial_role = ['__setOnce__', result.user.role]
|
||||
identify({
|
||||
user_id: String(result.user.id),
|
||||
properties: setOnceProps,
|
||||
})
|
||||
}
|
||||
track(EVENTS.INVITE_CLAIMED, {
|
||||
invited_by_admin_id: result.invited_by_admin_id != null
|
||||
? String(result.invited_by_admin_id) : null,
|
||||
initial_role: result.user?.role,
|
||||
needs_passcode: !!result.needs_passcode,
|
||||
trust_device: trustDevice,
|
||||
})
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,43 +1,169 @@
|
||||
// Login.jsx — v0.7.0's primary sign-in surface (§6.2).
|
||||
// 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.
|
||||
//
|
||||
// Two-step:
|
||||
// 1. Enter email → POST /auth/otc/request → on 200, advance.
|
||||
// On 429 (rate-limit), surface a "wait a moment" hint and keep
|
||||
// the user on step 1.
|
||||
// 2. Enter the six-digit code from the email → POST /auth/otc/verify
|
||||
// → on 200, redirect to the post-login landing. Cmd/Ctrl+Enter
|
||||
// on the code field is the keyboard shortcut.
|
||||
// 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.
|
||||
//
|
||||
// Server-side, /auth/otc/request always returns 202 for an unrecognized
|
||||
// email (so the allowlist gate doesn't leak), so this surface never
|
||||
// distinguishes "we couldn't reach you" from "we don't know you" —
|
||||
// it just advances to step 2. If a user is genuinely blocked, the
|
||||
// code never arrives.
|
||||
// Four-to-six-step flow (most users see three; the longest path is
|
||||
// pending-user with no passcode, who never sees the passcode steps):
|
||||
//
|
||||
// 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.
|
||||
// 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 } from '../api'
|
||||
import {
|
||||
requestOtc,
|
||||
verifyOtc,
|
||||
submitBetaRequest,
|
||||
checkPasscode,
|
||||
verifyPasscode,
|
||||
setPasscode as apiSetPasscode,
|
||||
startDeviceTrust,
|
||||
} from '../api'
|
||||
import TurnstileWidget, { turnstileEnabled } from './TurnstileWidget'
|
||||
import { EVENTS, track } from '../lib/analytics'
|
||||
|
||||
export default function Login() {
|
||||
// Steps: 'email' → 'passcode' or 'code' → (on the OTC path, after
|
||||
// verify) one of: 'capture-profile' (pending user), 'offer-passcode'
|
||||
// (no passcode yet), or straight to "/". 'set-passcode' is reached
|
||||
// from 'offer-passcode'.
|
||||
const [step, setStep] = useState('email')
|
||||
const [email, setEmail] = useState('')
|
||||
const [code, setCode] = useState('')
|
||||
const [passcode, setPasscode] = useState('')
|
||||
const [newPasscode, setNewPasscode] = useState('')
|
||||
// v0.11.0 — "trust this device for 30 days" checkbox, shared by the
|
||||
// OTC and passcode verify steps. The flag rides on the verify POST;
|
||||
// a checked box mints a device-trust row server-side and sets the
|
||||
// long-lived `rfc_device_trust` cookie. Defaults off so the user
|
||||
// makes an explicit choice — auth credentials shouldn't persist by
|
||||
// default.
|
||||
const [trustDevice, setTrustDevice] = useState(false)
|
||||
// v0.8.0 — capture-profile fields.
|
||||
const [firstName, setFirstName] = useState('')
|
||||
const [lastName, setLastName] = useState('')
|
||||
const [reason, setReason] = useState('')
|
||||
const [status, setStatus] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
// v0.12.0: Turnstile token captured by the widget. `null` means no
|
||||
// challenge solved yet (or the site key is unset, in which case the
|
||||
// widget surfaces null on mount). The token is single-use; we clear
|
||||
// it back to null right after we send it so a second request on the
|
||||
// same form remount re-challenges. `turnstileReady` is true once the
|
||||
// widget has produced a token OR the widget is not configured at
|
||||
// build time (no site key) — the submit button reads from it so the
|
||||
// form locks up when the operator has wired Turnstile but the user
|
||||
// hasn't solved the challenge yet.
|
||||
const [turnstileToken, setTurnstileToken] = useState(null)
|
||||
const turnstileOn = turnstileEnabled()
|
||||
const turnstileReady = !turnstileOn || !!turnstileToken
|
||||
const emailRef = useRef(null)
|
||||
const codeRef = useRef(null)
|
||||
const passcodeRef = useRef(null)
|
||||
const newPasscodeRef = useRef(null)
|
||||
const firstNameRef = useRef(null)
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
if (step === 'email') emailRef.current?.focus()
|
||||
else codeRef.current?.focus()
|
||||
else if (step === 'code') codeRef.current?.focus()
|
||||
else if (step === 'passcode') passcodeRef.current?.focus()
|
||||
else if (step === 'capture-profile') firstNameRef.current?.focus()
|
||||
else if (step === 'set-passcode') newPasscodeRef.current?.focus()
|
||||
}, [step])
|
||||
|
||||
// v0.11.0 — on mount, try the device-trust cookie path. If the
|
||||
// browser still carries a valid `rfc_device_trust` cookie from a
|
||||
// prior "trust this device" gesture, the server re-establishes the
|
||||
// session without any user input and we redirect home. The cookie
|
||||
// is HttpOnly so we can't peek at it; we just call the endpoint and
|
||||
// see whether it returns 200. 401 (no cookie / invalid / revoked)
|
||||
// is the structural-silent case — the user proceeds to the email
|
||||
// step normally. We do not surface any UI about the attempt; a
|
||||
// failure should be invisible.
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
await startDeviceTrust()
|
||||
if (!cancelled) {
|
||||
// v0.15.0 — analytics: device-trust cookie path is one of
|
||||
// three sign-in methods the taxonomy distinguishes.
|
||||
track(EVENTS.USER_SIGNED_IN, { method: 'trust-device' })
|
||||
window.location.assign('/')
|
||||
}
|
||||
} catch (_) {
|
||||
// No trusted device — fall through to the email step.
|
||||
}
|
||||
})()
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
async function submitEmail(e) {
|
||||
e.preventDefault()
|
||||
if (!email.trim() || !email.includes('@')) {
|
||||
@@ -47,20 +173,97 @@ export default function Login() {
|
||||
setBusy(true)
|
||||
setStatus('')
|
||||
try {
|
||||
await requestOtc(email.trim())
|
||||
setStep('code')
|
||||
setStatus('Check your inbox — a six-digit code is on the way.')
|
||||
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 request a code. Try again.')
|
||||
setStatus(err.message || 'Could not start sign-in. Try again.')
|
||||
}
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function submitPasscode(e) {
|
||||
if (e) e.preventDefault()
|
||||
if (!passcode.trim()) {
|
||||
setStatus('Enter your passcode.')
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
setStatus('')
|
||||
try {
|
||||
await verifyPasscode(email.trim(), passcode.trim(), { trustDevice })
|
||||
// v0.15.0 — analytics: passcode is the second of three
|
||||
// sign-in methods. trust-device gets credited separately when
|
||||
// the cookie-driven path fires above.
|
||||
track(EVENTS.USER_SIGNED_IN, { method: 'passcode' })
|
||||
// Reload so App.jsx's getMe() picks up the fresh session. A
|
||||
// returning passcode user is by definition already past the
|
||||
// §6.1 capture step (they couldn't have set a passcode while
|
||||
// pending), so we go straight to "/".
|
||||
window.location.assign('/')
|
||||
} catch (err) {
|
||||
if (err.status === 423) {
|
||||
// Lockout — auto-fall back to OTC. The OTC request endpoint
|
||||
// is independent of the passcode lockout, so this lands a
|
||||
// fresh code in the user's inbox immediately.
|
||||
setPasscode('')
|
||||
try {
|
||||
// v0.12.0: pass whatever token the widget on the passcode
|
||||
// step has produced. If the operator has Turnstile required
|
||||
// and the user hasn't solved the passcode-step widget, the
|
||||
// backend refuses and we bounce them back to email-entry
|
||||
// with a clear status (see catch below).
|
||||
await requestOtc(email.trim(), { turnstileToken })
|
||||
setTurnstileToken(null)
|
||||
setStep('code')
|
||||
setStatus(
|
||||
'Too many failed attempts. We sent a one-time code to your email — use it to sign in.',
|
||||
)
|
||||
} catch (e2) {
|
||||
setTurnstileToken(null)
|
||||
if (e2.status === 429) {
|
||||
setStep('code')
|
||||
setStatus(
|
||||
'Too many failed attempts. Wait a minute, then request a one-time code to sign in.',
|
||||
)
|
||||
} else if (e2.status === 400) {
|
||||
setStep('email')
|
||||
setStatus(
|
||||
'Too many failed attempts. Solve the challenge below to receive a one-time code.',
|
||||
)
|
||||
} else {
|
||||
setStatus(
|
||||
'Too many failed attempts. Use the "Use a code instead" link to sign in via email.',
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setStatus('Wrong passcode. Try again, or use a one-time code instead.')
|
||||
}
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function submitCode(e) {
|
||||
if (e) e.preventDefault()
|
||||
if (!code.trim() || code.trim().length !== 6) {
|
||||
@@ -70,10 +273,44 @@ export default function Login() {
|
||||
setBusy(true)
|
||||
setStatus('')
|
||||
try {
|
||||
await verifyOtc(email.trim(), code.trim())
|
||||
// Reload so App.jsx's getMe() picks up the fresh session. We
|
||||
// navigate to "/" via a hard load so any cached "anonymous"
|
||||
// view state in memory is dropped cleanly.
|
||||
await verifyOtc(email.trim(), code.trim(), { trustDevice })
|
||||
// v0.15.0 — analytics: OTC is the third sign-in method.
|
||||
// We fire it here regardless of whether the user then lands
|
||||
// in capture-profile or offer-passcode — sign-in has happened
|
||||
// server-side either way.
|
||||
track(EVENTS.USER_SIGNED_IN, { method: 'otc' })
|
||||
// OTC verified — the server has signed in the user. Fetch the
|
||||
// canonical /api/auth/me to decide where to land:
|
||||
// * needs_profile → §6.1 capture (then /beta-pending).
|
||||
// * no passcode → §6.2 offer-passcode (then /).
|
||||
// * otherwise → /.
|
||||
// needs_profile wins over has_passcode: a pending user can't yet
|
||||
// do anything that benefits from faster sign-in, so we don't
|
||||
// distract them with the passcode offer mid-admission.
|
||||
const meResp = await fetch('/api/auth/me', { credentials: 'include' })
|
||||
let me = null
|
||||
if (meResp.ok) {
|
||||
try {
|
||||
me = await meResp.json()
|
||||
} catch (_) {
|
||||
me = null
|
||||
}
|
||||
}
|
||||
if (me?.needs_profile === true) {
|
||||
setStep('capture-profile')
|
||||
setStatus('')
|
||||
setBusy(false)
|
||||
return
|
||||
}
|
||||
if (me?.has_passcode === false) {
|
||||
setStep('offer-passcode')
|
||||
setStatus('')
|
||||
setBusy(false)
|
||||
return
|
||||
}
|
||||
// Either /me returned the granted-with-passcode shape, or the
|
||||
// call failed but the session cookie is set — fall through to
|
||||
// a hard reload so App.jsx re-fetches and renders accordingly.
|
||||
window.location.assign('/')
|
||||
} catch (err) {
|
||||
setStatus('That code is invalid or expired. Try again, or request a new code.')
|
||||
@@ -81,6 +318,59 @@ export default function Login() {
|
||||
}
|
||||
}
|
||||
|
||||
async function submitProfile(e) {
|
||||
if (e) e.preventDefault()
|
||||
const fn = firstName.trim()
|
||||
const ln = lastName.trim()
|
||||
const why = reason.trim()
|
||||
if (!fn || !ln || !why) {
|
||||
setStatus('All three fields are required.')
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
setStatus('')
|
||||
try {
|
||||
await submitBetaRequest({
|
||||
first_name: fn,
|
||||
last_name: ln,
|
||||
beta_request_reason: why,
|
||||
})
|
||||
// v0.15.0 — analytics: a successful capture-profile submit is
|
||||
// the moment a beta-access request lands. No PII in the event
|
||||
// body (no name, no reason text); the count + timestamp is
|
||||
// what the funnel needs.
|
||||
track(EVENTS.BETA_ACCESS_REQUESTED)
|
||||
// Hard-load so App.jsx re-fetches /api/auth/me and picks up
|
||||
// the captured fields. The user stays permission_state='pending'
|
||||
// until an admin grants access — the next thing they should
|
||||
// see is the "your request is in review" page.
|
||||
window.location.assign('/beta-pending')
|
||||
} catch (err) {
|
||||
setStatus(err.message || 'Could not submit your request. Try again.')
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function submitNewPasscode(e) {
|
||||
if (e) e.preventDefault()
|
||||
const pc = newPasscode.trim()
|
||||
if (pc.length < 4) {
|
||||
setStatus('Passcode must be at least 4 characters.')
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
setStatus('')
|
||||
try {
|
||||
await apiSetPasscode(pc)
|
||||
window.location.assign('/')
|
||||
} catch (err) {
|
||||
// 422 carries the validation message verbatim (denylist /
|
||||
// length); surface it as-is so the user knows what to change.
|
||||
setStatus(err.message || 'Could not set passcode. Try a different one.')
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
function onCodeKey(e) {
|
||||
// §6.2 ergonomic: Cmd/Ctrl+Enter submits from the code field.
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||||
@@ -88,12 +378,69 @@ export default function Login() {
|
||||
}
|
||||
}
|
||||
|
||||
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">
|
||||
@@ -101,7 +448,8 @@ export default function Login() {
|
||||
{step === 'email' && (
|
||||
<form onSubmit={submitEmail}>
|
||||
<p className="otc-hint">
|
||||
Enter your email. We'll send you a one-time code.
|
||||
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}
|
||||
@@ -113,11 +461,85 @@ export default function Login() {
|
||||
required
|
||||
disabled={busy}
|
||||
/>
|
||||
<button type="submit" disabled={busy || !email.trim()}>
|
||||
{busy ? 'Sending…' : 'Send code'}
|
||||
{/*
|
||||
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">
|
||||
@@ -137,6 +559,17 @@ export default function Login() {
|
||||
required
|
||||
disabled={busy}
|
||||
/>
|
||||
{/* v0.11.0 — trust device for 30 days. Same shape as the
|
||||
passcode step; the user opts in deliberately. */}
|
||||
<label className="otc-trust-device">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={trustDevice}
|
||||
onChange={e => setTrustDevice(e.target.checked)}
|
||||
disabled={busy}
|
||||
/>
|
||||
<span>Trust this device for 30 days</span>
|
||||
</label>
|
||||
<div className="otc-actions">
|
||||
<button type="submit" disabled={busy || code.length !== 6}>
|
||||
{busy ? 'Signing in…' : 'Sign in'}
|
||||
@@ -155,12 +588,126 @@ export default function Login() {
|
||||
</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>}
|
||||
<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>
|
||||
{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>
|
||||
)
|
||||
|
||||
@@ -29,6 +29,12 @@ import {
|
||||
muteUser,
|
||||
searchUsers,
|
||||
getCookieConsent,
|
||||
getMe,
|
||||
setPasscode,
|
||||
clearPasscode,
|
||||
listMyDevices,
|
||||
revokeMyDevice,
|
||||
revokeAllMyDevices,
|
||||
} from '../api.js'
|
||||
import { getConsent, onConsentChange, hydrateFromServer } from '../lib/consent.js'
|
||||
|
||||
@@ -50,11 +56,282 @@ export default function NotificationSettings({ viewer }) {
|
||||
<QuietHoursSection />
|
||||
<WatchesSection />
|
||||
<MutesSection viewer={viewer} />
|
||||
<SignInSection />
|
||||
<DevicesSection />
|
||||
<PrivacyCookiesSection />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── v0.11.0: trusted devices (§6.2, roadmap item #9) ──────────────────────
|
||||
//
|
||||
// Lists the user's active device-trust rows and lets them revoke any
|
||||
// or all. A revoke marks the row dead server-side; the matching
|
||||
// device's next visit will be refused and the cookie cleared. The
|
||||
// surface intentionally does not single out the row whose cookie the
|
||||
// current request carries — every row reads identically, so the user
|
||||
// can revoke "this device" alongside any other from a single page.
|
||||
|
||||
function DevicesSection() {
|
||||
const [devices, setDevices] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
refresh()
|
||||
}, [])
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const { items } = await listMyDevices()
|
||||
setDevices(items || [])
|
||||
setError(null)
|
||||
} catch (e) {
|
||||
setError(e.message || 'Could not load trusted devices.')
|
||||
}
|
||||
}
|
||||
|
||||
async function onRevoke(deviceId) {
|
||||
setBusy(true)
|
||||
try {
|
||||
await revokeMyDevice(deviceId)
|
||||
await refresh()
|
||||
} catch (e) {
|
||||
setError(e.message || 'Could not revoke device.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function onRevokeAll() {
|
||||
if (!confirm('Revoke trust on every device, including this one? You will be asked to sign in via email next time.')) {
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
try {
|
||||
await revokeAllMyDevices()
|
||||
await refresh()
|
||||
} catch (e) {
|
||||
setError(e.message || 'Could not revoke devices.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionShell
|
||||
title="Trusted devices"
|
||||
subtitle="Devices where you've checked “Trust this device for 30 days.” Sign-in is automatic on these devices until the trust expires or you revoke it."
|
||||
>
|
||||
{devices === null && <p className="settings-note">Loading…</p>}
|
||||
{devices !== null && devices.length === 0 && (
|
||||
<p className="device-empty">
|
||||
No trusted devices. Sign in and check “Trust this device for 30 days”
|
||||
to add the device you're on now.
|
||||
</p>
|
||||
)}
|
||||
{devices !== null && devices.length > 0 && (
|
||||
<>
|
||||
<ul className="device-list">
|
||||
{devices.map(d => (
|
||||
<li key={d.id} className="device-list-item">
|
||||
<div className="device-meta">
|
||||
<div className="device-ua">{d.user_agent || 'Unknown device'}</div>
|
||||
<div className="device-stamps">
|
||||
Trusted {formatStamp(d.created_at)} · last seen {formatStamp(d.last_seen_at)} · expires {formatStamp(d.expires_at)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRevoke(d.id)}
|
||||
disabled={busy}
|
||||
title="Revoke trust on this device"
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button
|
||||
type="button"
|
||||
className="device-revoke-all"
|
||||
onClick={onRevokeAll}
|
||||
disabled={busy}
|
||||
>
|
||||
Revoke all devices
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{error && <p className="settings-note warning">{error}</p>}
|
||||
</SectionShell>
|
||||
)
|
||||
}
|
||||
|
||||
function formatStamp(stamp) {
|
||||
// The server emits SQLite `datetime('now')` strings (UTC, no
|
||||
// timezone marker). Parse defensively; fall back to the raw stamp
|
||||
// if Date can't make sense of it.
|
||||
if (!stamp) return '—'
|
||||
const d = new Date(stamp.replace(' ', 'T') + 'Z')
|
||||
if (Number.isNaN(d.getTime())) return stamp
|
||||
return d.toLocaleString()
|
||||
}
|
||||
|
||||
// ── §6.2 sign-in (v0.10.0 / roadmap item #8): passcode management ──────────
|
||||
|
||||
function SignInSection() {
|
||||
// Source of truth for `has_passcode` and `passcode_set_at` is the
|
||||
// /api/auth/me payload (v0.10.0 added both fields). We re-read after
|
||||
// every mutation so the surface reflects what just landed.
|
||||
const [me, setMe] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [mode, setMode] = useState('idle') // 'idle' | 'set' | 'change'
|
||||
const [draft, setDraft] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [savedNote, setSavedNote] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
getMe()
|
||||
.then(payload => setMe(payload.user || null))
|
||||
.catch(e => setError(e.message))
|
||||
}, [])
|
||||
|
||||
async function refresh() {
|
||||
const payload = await getMe()
|
||||
setMe(payload.user || null)
|
||||
}
|
||||
|
||||
async function save(e) {
|
||||
if (e) e.preventDefault()
|
||||
const pc = draft.trim()
|
||||
if (pc.length < 4) {
|
||||
setError('Passcode must be at least 4 characters.')
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
setSavedNote('')
|
||||
try {
|
||||
await setPasscode(pc)
|
||||
setDraft('')
|
||||
setMode('idle')
|
||||
setSavedNote('Passcode saved.')
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
// 422 carries the validation message verbatim (denylist /
|
||||
// length); surface it as-is so the user knows what to change.
|
||||
setError(err.message || 'Could not save passcode. Try a different one.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
setTimeout(() => setSavedNote(''), 2000)
|
||||
}
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (!confirm('Remove your passcode? You will sign in with a one-time code next time.')) {
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
setSavedNote('')
|
||||
try {
|
||||
await clearPasscode()
|
||||
setSavedNote('Passcode removed.')
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not remove passcode.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
setTimeout(() => setSavedNote(''), 2000)
|
||||
}
|
||||
}
|
||||
|
||||
if (!me) return <SectionShell title="Sign-in" subtitle={error || 'Loading…'} />
|
||||
|
||||
const hasPasscode = !!me.has_passcode
|
||||
|
||||
return (
|
||||
<SectionShell
|
||||
title="Sign-in"
|
||||
subtitle="How you sign in. A passcode lets you skip the one-time-code email; the one-time-code path is always available as a fallback (and as the recovery path if you forget your passcode)."
|
||||
>
|
||||
<div className="settings-row">
|
||||
<span className="settings-note">
|
||||
<strong>Passcode:</strong>{' '}
|
||||
{hasPasscode ? 'Set.' : 'Not set — you sign in with a one-time code each time.'}
|
||||
</span>
|
||||
</div>
|
||||
{hasPasscode && me.passcode_set_at && (
|
||||
<p className="settings-note muted">Set on {me.passcode_set_at}.</p>
|
||||
)}
|
||||
|
||||
{mode === 'idle' && (
|
||||
<div className="settings-row">
|
||||
{hasPasscode ? (
|
||||
<>
|
||||
<button
|
||||
className="btn-primary"
|
||||
onClick={() => { setMode('change'); setDraft(''); setError(null) }}
|
||||
disabled={busy}
|
||||
>
|
||||
Change passcode
|
||||
</button>
|
||||
<button
|
||||
className="btn-link-muted"
|
||||
onClick={remove}
|
||||
disabled={busy}
|
||||
>
|
||||
Remove passcode
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
className="btn-primary"
|
||||
onClick={() => { setMode('set'); setDraft(''); setError(null) }}
|
||||
disabled={busy}
|
||||
>
|
||||
Set passcode
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(mode === 'set' || mode === 'change') && (
|
||||
<form className="settings-row" onSubmit={save}>
|
||||
<label>
|
||||
{mode === 'change' ? 'New passcode' : 'Passcode'}
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={draft}
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
placeholder="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() {
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { draftPRText, openPR } from '../api'
|
||||
import { EVENTS, track } from '../lib/analytics'
|
||||
|
||||
export default function PRModal({ slug, branch, branchIsPrivate, onClose, onOpened }) {
|
||||
const [title, setTitle] = useState('')
|
||||
@@ -39,6 +40,9 @@ export default function PRModal({ slug, branch, branchIsPrivate, onClose, onOpen
|
||||
setError(null)
|
||||
try {
|
||||
const { pr_number } = await openPR(slug, branch, { title: title.trim(), description: description.trim() })
|
||||
// v0.15.0 — analytics: fire on §10.2 PR-open success. slug
|
||||
// and pr_number are the join keys; title/description stay out.
|
||||
track(EVENTS.PR_OPENED, { rfc_slug: slug, pr_number })
|
||||
onOpened?.(pr_number)
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
startResolutionBranch,
|
||||
withdrawPR,
|
||||
} from '../api'
|
||||
import { EVENTS, track } from '../lib/analytics'
|
||||
|
||||
export default function PRView({ viewer }) {
|
||||
const { slug, prNumber: prNumberParam } = useParams()
|
||||
@@ -135,6 +136,10 @@ export default function PRView({ viewer }) {
|
||||
anchorPayload: reviewDraft?.anchorPayload || {},
|
||||
quote: reviewDraft?.quote || null,
|
||||
})
|
||||
// v0.15.0 — analytics: fire on §10.4 review-comment success.
|
||||
// surface=pr distinguishes this from RFC discussion comments.
|
||||
// No body text or quote material in the event.
|
||||
track(EVENTS.COMMENT_POSTED, { rfc_slug: slug, pr_number: prNumber, surface: 'pr' })
|
||||
setReviewText('')
|
||||
setReviewDraft(null)
|
||||
await refresh()
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { proposeRFC } from '../api'
|
||||
import { EVENTS, track } from '../lib/analytics'
|
||||
|
||||
function slugify(title) {
|
||||
return title
|
||||
@@ -52,6 +53,10 @@ export default function ProposeModal({ viewer, onClose, onSubmitted }) {
|
||||
pitch: pitch.trim(),
|
||||
tags,
|
||||
})
|
||||
// v0.15.0 — analytics: fire on the §9.1 propose-RFC submit.
|
||||
// Slug is a stable, low-cardinality identifier (kebab-case
|
||||
// ascii); title and pitch stay out of the event body.
|
||||
track(EVENTS.RFC_PROPOSED, { rfc_slug: slug })
|
||||
onSubmitted?.(result)
|
||||
} catch (err) {
|
||||
setError(err.message || 'Submission failed.')
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
postDiscussionMessage,
|
||||
resolveDiscussionThread,
|
||||
} from '../api'
|
||||
import { EVENTS, track } from '../lib/analytics'
|
||||
|
||||
export default function RFCDiscussionPanel({ slug, viewer }) {
|
||||
const [threads, setThreads] = useState([])
|
||||
@@ -100,6 +101,10 @@ export default function RFCDiscussionPanel({ slug, viewer }) {
|
||||
void message_id
|
||||
}
|
||||
setComposer('')
|
||||
// v0.15.0 — analytics: fire on a successful discussion post.
|
||||
// surface=discussion distinguishes this from PR review comments
|
||||
// which fire from PRView with surface=pr. No body text.
|
||||
track(EVENTS.COMMENT_POSTED, { rfc_slug: slug, surface: 'discussion' })
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
|
||||
@@ -43,7 +43,9 @@ import RFCDiscussionPanel from './RFCDiscussionPanel.jsx'
|
||||
import ChangePanel, { diffWords } from './ChangePanel.jsx'
|
||||
import PRModal from './PRModal.jsx'
|
||||
import GraduateDialog from './GraduateDialog.jsx'
|
||||
import InvitationsModal from './InvitationsModal.jsx'
|
||||
import { claimOwnership } from '../api'
|
||||
import { EVENTS, track } from '../lib/analytics'
|
||||
|
||||
const MANUAL_IDLE_MS = 5 * 60 * 1000 // §8.6 idle window; exact value is impl detail.
|
||||
const MANUAL_DEBOUNCE_MS = 800
|
||||
@@ -121,7 +123,15 @@ export default function RFCView({ viewer }) {
|
||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
getRFC(slug).then(setEntry).catch(err => setError(err.message))
|
||||
getRFC(slug).then(entry => {
|
||||
setEntry(entry)
|
||||
// v0.15.0 — analytics: fire RFC Viewed once per slug load.
|
||||
// We key on the slug param rather than the loaded entry so a
|
||||
// re-render doesn't double-fire; the slug is the stable
|
||||
// identifier. id is included for join-friendliness in the
|
||||
// Amplitude dashboard.
|
||||
track(EVENTS.RFC_VIEWED, { rfc_slug: slug, rfc_id: entry?.id })
|
||||
}).catch(err => setError(err.message))
|
||||
listModels(slug)
|
||||
.then(({ models, default: def }) => {
|
||||
setModels(models || [])
|
||||
@@ -139,6 +149,11 @@ export default function RFCView({ viewer }) {
|
||||
const [showMetadataPane, setShowMetadataPane] = useState(false)
|
||||
const [showGraduateDialog, setShowGraduateDialog] = useState(false)
|
||||
const [claimError, setClaimError] = useState(null)
|
||||
// v0.16.0 (item #12): the per-RFC invitations modal. Visible only to
|
||||
// RFC owners (frontmatter) and platform admin/owner — the backend
|
||||
// gates the underlying endpoints regardless, so a leaked toggle
|
||||
// can't actually leak anything.
|
||||
const [showInvitationsModal, setShowInvitationsModal] = useState(false)
|
||||
|
||||
// Load main view + branch view whenever slug/branch changes.
|
||||
useEffect(() => {
|
||||
@@ -624,6 +639,20 @@ export default function RFCView({ viewer }) {
|
||||
Graduate to RFC repo
|
||||
</button>
|
||||
)}
|
||||
{/* v0.16.0 (item #12): owner-only invitations affordance.
|
||||
Shown when the viewer is named in the RFC's frontmatter
|
||||
`owners` list or holds a platform admin/owner role.
|
||||
Available on both super-drafts and active RFCs. */}
|
||||
{viewer && (viewer.role === 'owner' || viewer.role === 'admin' || (entry?.owners || []).includes(viewer.gitea_login)) && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-link"
|
||||
onClick={() => setShowInvitationsModal(true)}
|
||||
title="Invite collaborators to this RFC"
|
||||
>
|
||||
Invitations
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{claimError && (
|
||||
@@ -856,6 +885,14 @@ export default function RFCView({ viewer }) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{showInvitationsModal && (
|
||||
<InvitationsModal
|
||||
slug={slug}
|
||||
rfcTitle={entry?.title}
|
||||
onClose={() => setShowInvitationsModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showMetadataPane && (
|
||||
<MetadataPaneModal
|
||||
slug={slug}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
// TurnstileWidget.jsx — v0.12.0 / roadmap item #10.
|
||||
//
|
||||
// Renders the CloudFlare Turnstile JS widget on the email-entry step of
|
||||
// `/login`. Reads the site key from `import.meta.env.VITE_TURNSTILE_SITE_KEY`
|
||||
// (Vite convention — VITE_* prefix is build-time embedded). When the
|
||||
// site key is unset/empty, this component renders nothing and reports
|
||||
// a `null` token through `onToken` so the parent form can still submit.
|
||||
// The backend's `TURNSTILE_REQUIRED` policy decides what happens to a
|
||||
// request that arrives without a token; the frontend is intentionally
|
||||
// not in that loop. See `backend/app/turnstile.py` for the matrix.
|
||||
//
|
||||
// The CloudFlare script is loaded once per page on first widget mount.
|
||||
// Subsequent mounts (e.g. user goes back to email-entry after a failed
|
||||
// OTC request) reuse the script tag and re-render the widget on the
|
||||
// fresh container `div`. Unmounting removes the widget instance via
|
||||
// `turnstile.remove(widgetId)` so a remount produces a new challenge
|
||||
// rather than reusing a stale, already-consumed token.
|
||||
//
|
||||
// Turnstile contract:
|
||||
// * `data-callback` fires with the token string on a successful
|
||||
// challenge; the token is single-use and expires after ~5 minutes.
|
||||
// * `data-error-callback` fires on a failed challenge (network,
|
||||
// blocked, etc.); we surface a `null` token so the parent shows
|
||||
// a retry hint.
|
||||
// * `data-expired-callback` fires when the token times out before
|
||||
// submission; we also drop to `null` and re-render so the user
|
||||
// gets a fresh challenge on retry.
|
||||
//
|
||||
// We do **not** import the CloudFlare script at build time; loading it
|
||||
// dynamically here keeps the bundle clean of an external request the
|
||||
// page may not need (anonymous viewers reading RFCs never see Login).
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
const TURNSTILE_SCRIPT_URL = 'https://challenges.cloudflare.com/turnstile/v0/api.js'
|
||||
const SITE_KEY = import.meta.env.VITE_TURNSTILE_SITE_KEY || ''
|
||||
|
||||
// Promise-keyed: only one script tag, only one resolution chain.
|
||||
let scriptLoadPromise = null
|
||||
|
||||
function loadTurnstileScript() {
|
||||
if (typeof window === 'undefined') return Promise.resolve(null)
|
||||
if (window.turnstile) return Promise.resolve(window.turnstile)
|
||||
if (scriptLoadPromise) return scriptLoadPromise
|
||||
|
||||
scriptLoadPromise = new Promise((resolve, reject) => {
|
||||
const existing = document.querySelector(`script[src="${TURNSTILE_SCRIPT_URL}"]`)
|
||||
if (existing) {
|
||||
existing.addEventListener('load', () => resolve(window.turnstile))
|
||||
existing.addEventListener('error', reject)
|
||||
return
|
||||
}
|
||||
const script = document.createElement('script')
|
||||
script.src = TURNSTILE_SCRIPT_URL
|
||||
script.async = true
|
||||
script.defer = true
|
||||
script.addEventListener('load', () => resolve(window.turnstile))
|
||||
script.addEventListener('error', reject)
|
||||
document.head.appendChild(script)
|
||||
})
|
||||
return scriptLoadPromise
|
||||
}
|
||||
|
||||
export function turnstileEnabled() {
|
||||
return !!SITE_KEY
|
||||
}
|
||||
|
||||
export default function TurnstileWidget({ onToken, theme = 'auto' }) {
|
||||
const containerRef = useRef(null)
|
||||
const widgetIdRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!SITE_KEY) {
|
||||
// No site key configured → surface a null token immediately so
|
||||
// the parent form's submit-disabled gate doesn't lock up
|
||||
// waiting on a challenge that will never arrive. The backend
|
||||
// decides whether a tokenless request is admitted.
|
||||
onToken?.(null)
|
||||
return undefined
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
loadTurnstileScript()
|
||||
.then(turnstile => {
|
||||
if (cancelled || !turnstile || !containerRef.current) return
|
||||
widgetIdRef.current = turnstile.render(containerRef.current, {
|
||||
sitekey: SITE_KEY,
|
||||
theme,
|
||||
callback: token => onToken?.(token),
|
||||
'error-callback': () => onToken?.(null),
|
||||
'expired-callback': () => onToken?.(null),
|
||||
})
|
||||
})
|
||||
.catch(() => {
|
||||
// Script load failure — surface null so the parent can decide
|
||||
// what to do (today: still let submit through; the backend
|
||||
// policy decides admission).
|
||||
if (!cancelled) onToken?.(null)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (widgetIdRef.current && window.turnstile) {
|
||||
try {
|
||||
window.turnstile.remove(widgetIdRef.current)
|
||||
} catch (_) {
|
||||
// Already gone or never registered — nothing to clean up.
|
||||
}
|
||||
widgetIdRef.current = null
|
||||
}
|
||||
}
|
||||
// We intentionally do not list `onToken` in the dependency array;
|
||||
// a parent re-rendering with a fresh closure should not tear down
|
||||
// and rebuild the widget (which would consume a fresh challenge).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
if (!SITE_KEY) return null
|
||||
return <div ref={containerRef} className="turnstile-widget" />
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
// analytics.js — v0.15.0 / roadmap item #13.
|
||||
//
|
||||
// Wrapper around `@amplitude/unified` (Amplitude Analytics +
|
||||
// Session Replay) that gates SDK initialization on the user's
|
||||
// cookie/privacy consent (v0.13.0, `frontend/src/lib/consent.js`,
|
||||
// SPEC §14.5). The wrapper presents a stable surface to the rest
|
||||
// of the app:
|
||||
//
|
||||
// import { track, identify, anonymize } from './lib/analytics'
|
||||
//
|
||||
// track('RFC Viewed', { rfc_slug: 'open-human-model' })
|
||||
// identify({ user_id: 'u_123' })
|
||||
// anonymize() // call on sign-out
|
||||
//
|
||||
// At first import the wrapper:
|
||||
// 1. Calls `bootstrap()` once, which reads `getConsent()` and
|
||||
// subscribes to `onConsentChange()`. If consent.analytics is
|
||||
// true, it lazily imports the Amplitude SDK and calls
|
||||
// `amplitude.initAll(API_KEY, { analytics: { autocapture: true },
|
||||
// sessionReplay: { sampleRate: 1 } })`.
|
||||
// If consent.analytics is false (or undecided), the SDK is
|
||||
// not loaded — no network request, no cookies, no session
|
||||
// replay recording. A later consent change to `true` triggers
|
||||
// init at that moment.
|
||||
// 2. The wrapper queues `track()` and `identify()` calls made
|
||||
// before init finishes (lazy import + consent grant), and
|
||||
// drains the queue when init completes.
|
||||
// 3. If the user later flips consent from granted → denied, the
|
||||
// wrapper calls `amplitude.setOptOut(true)` so subsequent
|
||||
// events are dropped client-side and session replay stops
|
||||
// recording (the SDK is still loaded — we cannot unload a
|
||||
// script — but it stops firing).
|
||||
//
|
||||
// Consent precedence ladder:
|
||||
//
|
||||
// consent.analytics === true → init + track
|
||||
// consent.analytics === false → no init; or if already init,
|
||||
// setOptOut(true)
|
||||
// consent.recorded_at === null → treat as denied (banner is up;
|
||||
// the user has not yet chosen)
|
||||
//
|
||||
// Session replay scope: this release ships session replay at
|
||||
// `sampleRate: 1` (100% of sessions are recorded for full-DOM
|
||||
// playback). That is the vendor-recommended default for new
|
||||
// Amplitude deployments. The v0.13.0 consent banner's single
|
||||
// "analytics" toggle gates both events and session replay together —
|
||||
// a separate consent category for session-replay specifically is a
|
||||
// §19.2 follow-up.
|
||||
//
|
||||
// API key resolution:
|
||||
//
|
||||
// The build-time env var `VITE_AMPLITUDE_API_KEY` carries the
|
||||
// Amplitude project's API key. When it is unset/empty, the
|
||||
// wrapper logs one console warning and no-ops — every public
|
||||
// function becomes a deterministic no-op so dev environments
|
||||
// (and deployments that intentionally don't ship analytics)
|
||||
// keep working. The deploy gesture wires the key via flotilla's
|
||||
// `overlay set` verb (see CHANGELOG for the operator gesture):
|
||||
// Amplitude browser keys are bundle-embedded by design (visible
|
||||
// to anyone with dev tools, same nature as the v0.12.0
|
||||
// `VITE_TURNSTILE_SITE_KEY`), so the binding is overlay, not
|
||||
// secret.
|
||||
//
|
||||
// PII discipline:
|
||||
//
|
||||
// `identify({ user_id })` SHOULD pass only the opaque server-
|
||||
// side user id (the `viewer.id` integer or string). DO NOT pass
|
||||
// email, display name, IP, or any other PII through the SDK.
|
||||
// Event properties SHOULD likewise stay limited to ids and
|
||||
// enums; free-text fields (titles, comment bodies) MUST NOT be
|
||||
// sent.
|
||||
//
|
||||
// Event taxonomy: defined in `EVENTS` below. Callers SHOULD use
|
||||
// one of these names rather than firing arbitrary strings — that
|
||||
// keeps the Amplitude dashboard coherent over time.
|
||||
|
||||
import { getConsent, onConsentChange } from './consent.js'
|
||||
|
||||
const API_KEY = import.meta.env.VITE_AMPLITUDE_API_KEY || ''
|
||||
|
||||
// Public taxonomy. Keep this short and stable — new entries should
|
||||
// land via a release, not ad-hoc. The strings match the Amplitude
|
||||
// dashboard names exactly (Title Case, spaces, no punctuation).
|
||||
export const EVENTS = Object.freeze({
|
||||
PAGE_VIEWED: 'Page Viewed',
|
||||
RFC_VIEWED: 'RFC Viewed',
|
||||
USER_SIGNED_IN: 'User Signed In',
|
||||
USER_SIGNED_OUT: 'User Signed Out',
|
||||
RFC_PROPOSED: 'RFC Proposed',
|
||||
PR_OPENED: 'PR Opened',
|
||||
COMMENT_POSTED: 'Comment Posted',
|
||||
BETA_ACCESS_REQUESTED: 'Beta Access Requested',
|
||||
ADMIN_PERMISSION_DECISION: 'Admin Permission Decision',
|
||||
// v0.16.0 / item #12 — per-RFC owner invites.
|
||||
INVITATION_SENT: 'Invitation Sent',
|
||||
INVITATION_ACCEPTED: 'Invitation Accepted',
|
||||
// v0.17.0 / item #16 — admin-create user + invite email.
|
||||
USER_INVITED: 'User Invited',
|
||||
INVITE_CLAIMED: 'Invite Claimed',
|
||||
})
|
||||
|
||||
// Internal state.
|
||||
let _bootstrapped = false
|
||||
let _amplitude = null // The dynamically imported SDK module.
|
||||
let _initPromise = null // Pending init (lazy import + sdk.init).
|
||||
let _initialized = false // True after sdk.init has resolved.
|
||||
let _warnedNoKey = false
|
||||
let _pendingUserId = null // identify() called before init resolves.
|
||||
let _pendingProperties = null // identify({ properties }) or
|
||||
// setUserProperties() before init.
|
||||
const _queue = [] // {kind: 'track'|'identify'|'anonymize'|
|
||||
// 'setUserProperties', ...}
|
||||
|
||||
function warnNoKey() {
|
||||
if (_warnedNoKey) return
|
||||
_warnedNoKey = true
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
'[analytics] VITE_AMPLITUDE_API_KEY is unset; analytics events ' +
|
||||
'and session replay will not be sent. This is expected in dev; ' +
|
||||
'in production it means the operator has not yet run ' +
|
||||
'`flotilla overlay set <deployment> VITE_AMPLITUDE_API_KEY=<key>`.',
|
||||
)
|
||||
}
|
||||
|
||||
function consentGranted() {
|
||||
const c = getConsent()
|
||||
return !!(c && c.recorded_at && c.analytics)
|
||||
}
|
||||
|
||||
// Apply a {key: value} property bag as an Amplitude Identify event.
|
||||
// Used by both `identify({ properties })` and `setUserProperties`.
|
||||
function applyProperties(props) {
|
||||
if (!_initialized || !_amplitude || !props) return
|
||||
try {
|
||||
const id = new _amplitude.Identify()
|
||||
for (const [k, v] of Object.entries(props)) {
|
||||
if (v === undefined || v === null) continue
|
||||
if (Array.isArray(v) && v.length === 2 && v[0] === '__setOnce__') {
|
||||
id.setOnce(k, v[1])
|
||||
} else {
|
||||
id.set(k, v)
|
||||
}
|
||||
}
|
||||
_amplitude.identify(id)
|
||||
} catch (_) {
|
||||
// SDK errors are non-fatal; analytics is best-effort.
|
||||
}
|
||||
}
|
||||
|
||||
// Drain the queue. Called once init resolves.
|
||||
function drainQueue() {
|
||||
if (!_initialized || !_amplitude) return
|
||||
if (_pendingUserId != null) {
|
||||
try { _amplitude.setUserId(_pendingUserId) } catch (_) {}
|
||||
_pendingUserId = null
|
||||
}
|
||||
if (_pendingProperties != null) {
|
||||
applyProperties(_pendingProperties)
|
||||
_pendingProperties = null
|
||||
}
|
||||
while (_queue.length > 0) {
|
||||
const item = _queue.shift()
|
||||
try {
|
||||
if (item.kind === 'track') {
|
||||
_amplitude.track(item.name, item.props || {})
|
||||
} else if (item.kind === 'identify') {
|
||||
if (item.user_id != null) _amplitude.setUserId(item.user_id)
|
||||
if (item.properties != null) applyProperties(item.properties)
|
||||
} else if (item.kind === 'setUserProperties') {
|
||||
applyProperties(item.properties)
|
||||
} else if (item.kind === 'anonymize') {
|
||||
_amplitude.reset()
|
||||
}
|
||||
} catch (_) {
|
||||
// SDK errors are non-fatal; analytics is best-effort.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Lazy import + init. Resolves once the SDK is ready to take events.
|
||||
// Idempotent: subsequent calls return the same promise.
|
||||
async function initSdk() {
|
||||
if (_initPromise) return _initPromise
|
||||
if (!API_KEY) {
|
||||
warnNoKey()
|
||||
// Resolve immediately with a no-op shape; the wrapper's public
|
||||
// functions check API_KEY and short-circuit, so this never
|
||||
// actually runs SDK code.
|
||||
_initPromise = Promise.resolve(null)
|
||||
return _initPromise
|
||||
}
|
||||
_initPromise = (async () => {
|
||||
try {
|
||||
const mod = await import('@amplitude/unified')
|
||||
// The unified package exposes `initAll`, `track`,
|
||||
// `setUserId`, `reset`, `setOptOut` as named functions.
|
||||
// We hold the module so the queue drainer can call them
|
||||
// by name.
|
||||
_amplitude = mod
|
||||
// initAll wires up both Analytics and Session Replay in one
|
||||
// call. Vendor-recommended init shape from the Amplitude
|
||||
// installation wizard:
|
||||
// - analytics.autocapture: true — auto-instruments page
|
||||
// views, session start/end, clicks, and form interactions.
|
||||
// Our explicit `track('Page Viewed', …)` etc. layer on top
|
||||
// for app-specific names that survive renames.
|
||||
// - sessionReplay.sampleRate: 1 — record 100% of sessions
|
||||
// for full-DOM playback. Gated by the v0.13.0 consent
|
||||
// banner just like the rest of the SDK; never starts
|
||||
// recording without explicit analytics opt-in.
|
||||
const ret = mod.initAll(API_KEY, {
|
||||
analytics: { autocapture: true },
|
||||
sessionReplay: { sampleRate: 1 },
|
||||
})
|
||||
// initAll returns an AmplitudeReturn with a `.promise` accessor
|
||||
// (consistent with the legacy `init`). Some unified builds
|
||||
// resolve synchronously; await defensively.
|
||||
if (ret && ret.promise) await ret.promise
|
||||
_initialized = true
|
||||
drainQueue()
|
||||
} catch (err) {
|
||||
// Init failure is non-fatal; keep the wrapper alive so future
|
||||
// calls no-op. Log once for the operator.
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[analytics] Amplitude init failed:', err)
|
||||
_initialized = false
|
||||
}
|
||||
return _amplitude
|
||||
})()
|
||||
return _initPromise
|
||||
}
|
||||
|
||||
// Bootstrap is called lazily on first track/identify. It wires the
|
||||
// consent subscription so a later flip from denied→granted triggers
|
||||
// init at that moment, and granted→denied flips the opt-out.
|
||||
function bootstrap() {
|
||||
if (_bootstrapped) return
|
||||
_bootstrapped = true
|
||||
if (consentGranted()) {
|
||||
// Fire-and-forget; the queue catches any events that arrive
|
||||
// before init resolves.
|
||||
initSdk()
|
||||
}
|
||||
onConsentChange(snapshot => {
|
||||
const allowed = !!(snapshot && snapshot.recorded_at && snapshot.analytics)
|
||||
if (allowed && !_initPromise) {
|
||||
initSdk()
|
||||
} else if (allowed && _initialized && _amplitude) {
|
||||
// Re-enable in case we previously opted out.
|
||||
try { _amplitude.setOptOut(false) } catch (_) {}
|
||||
} else if (!allowed && _initialized && _amplitude) {
|
||||
// Granted → denied. Stop firing. We cannot unload the script
|
||||
// tag; setOptOut is the SDK's contract for "drop subsequent
|
||||
// events client-side".
|
||||
try { _amplitude.setOptOut(true) } catch (_) {}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Fire a track event. Safe to call before consent / init resolve;
|
||||
* the call is queued and drained once both are true. Drops the
|
||||
* event silently if API_KEY is empty (with a one-shot warn) or
|
||||
* consent.analytics is false. */
|
||||
export function track(name, props) {
|
||||
if (!API_KEY) { warnNoKey(); return }
|
||||
bootstrap()
|
||||
if (!consentGranted()) return
|
||||
if (_initialized && _amplitude) {
|
||||
try { _amplitude.track(name, props || {}) } catch (_) {}
|
||||
return
|
||||
}
|
||||
_queue.push({ kind: 'track', name, props })
|
||||
}
|
||||
|
||||
/** Attach an authenticated user id and optional durable properties.
|
||||
* Pass `{ user_id: '<opaque-id>', properties?: { role, first_sign_in_at, … } }`.
|
||||
* DO NOT pass email, display name, or other PII as user_id or in
|
||||
* properties. Idempotent — subsequent calls with the same id are
|
||||
* cheap; properties are merged into the Amplitude user record.
|
||||
*
|
||||
* To mark a property as setOnce (immutable after first write),
|
||||
* pass `properties: { first_sign_in_at: ['__setOnce__', '2026-05-28T…'] }`.
|
||||
* Bare values use Amplitude's `.set()` (mutable).
|
||||
*
|
||||
* Pattern (per #21 Part C):
|
||||
* - On sign-in success in App.jsx: identify with viewer.id + the
|
||||
* durable property bag (role, permission_state, first_sign_in_at
|
||||
* setOnce, passcode_set, device_trusted_count, account_created_at
|
||||
* setOnce).
|
||||
* - On invite-claim success in InviteClaim.jsx / AcceptInvitation.jsx:
|
||||
* identify with the new viewer.id + invitation-derived properties
|
||||
* (invited_by_admin_id, invited_at setOnce, initial_role, claim_method)
|
||||
* BEFORE firing any track() — so the Amplitude user record is
|
||||
* created with the OHM user_id from the first event, not as an
|
||||
* anonymous device that retroactively links. */
|
||||
export function identify({ user_id, properties } = {}) {
|
||||
if (!API_KEY) { warnNoKey(); return }
|
||||
if (user_id == null && properties == null) return
|
||||
bootstrap()
|
||||
if (!consentGranted()) {
|
||||
// Hold for when consent lands; identify-on-sign-in is a common
|
||||
// race with the consent banner choice.
|
||||
if (user_id != null) _pendingUserId = user_id
|
||||
if (properties != null) {
|
||||
_pendingProperties = { ..._pendingProperties, ...properties }
|
||||
}
|
||||
return
|
||||
}
|
||||
if (_initialized && _amplitude) {
|
||||
try {
|
||||
if (user_id != null) _amplitude.setUserId(user_id)
|
||||
if (properties != null) applyProperties(properties)
|
||||
} catch (_) {}
|
||||
return
|
||||
}
|
||||
if (user_id != null) _pendingUserId = user_id
|
||||
if (properties != null) {
|
||||
_pendingProperties = { ..._pendingProperties, ...properties }
|
||||
}
|
||||
_queue.push({ kind: 'identify', user_id, properties })
|
||||
}
|
||||
|
||||
/** Update durable user properties on the current Amplitude user
|
||||
* record mid-session — for state changes that shouldn't wait for the
|
||||
* next sign-in to surface (role grant/revoke, passcode set, device
|
||||
* trusted, etc.). Same property shape as `identify({ properties })`.
|
||||
* setOnce values use the `['__setOnce__', value]` sentinel pattern.
|
||||
* Has no effect if no identify has happened yet — set the user_id
|
||||
* via `identify()` first.
|
||||
*
|
||||
* Per #21 Part C: call this from any surface where the user's
|
||||
* Amplitude-relevant state changes mid-session, so the dashboard
|
||||
* stays current. */
|
||||
export function setUserProperties(properties) {
|
||||
if (!API_KEY) { warnNoKey(); return }
|
||||
if (properties == null) return
|
||||
bootstrap()
|
||||
if (!consentGranted()) {
|
||||
_pendingProperties = { ..._pendingProperties, ...properties }
|
||||
return
|
||||
}
|
||||
if (_initialized && _amplitude) {
|
||||
applyProperties(properties)
|
||||
return
|
||||
}
|
||||
_pendingProperties = { ..._pendingProperties, ...properties }
|
||||
_queue.push({ kind: 'setUserProperties', properties })
|
||||
}
|
||||
|
||||
/** Reset the user binding. Call this on sign-out so the next page
|
||||
* navigations are attributed to a fresh anonymous device id. Has
|
||||
* no effect when analytics is disabled.
|
||||
*
|
||||
* Per #21 Part C: clears both the user_id binding AND the pending
|
||||
* property cache, so a subsequent sign-in as a different user
|
||||
* starts with a fully fresh slate (no carry-over properties from
|
||||
* the previous user). */
|
||||
export function anonymize() {
|
||||
if (!API_KEY) { warnNoKey(); return }
|
||||
_pendingUserId = null
|
||||
_pendingProperties = null
|
||||
bootstrap()
|
||||
if (!consentGranted()) return
|
||||
if (_initialized && _amplitude) {
|
||||
try { _amplitude.reset() } catch (_) {}
|
||||
return
|
||||
}
|
||||
_queue.push({ kind: 'anonymize' })
|
||||
}
|
||||
|
||||
/** Test helper — exposed for unit tests, not for app code.
|
||||
* Resets module-level state so a fresh bootstrap cycle can be
|
||||
* exercised. */
|
||||
export function __resetForTests() {
|
||||
_bootstrapped = false
|
||||
_amplitude = null
|
||||
_initPromise = null
|
||||
_initialized = false
|
||||
_warnedNoKey = false
|
||||
_pendingUserId = null
|
||||
_pendingProperties = null
|
||||
_queue.length = 0
|
||||
}
|
||||
Reference in New Issue
Block a user