Compare commits
67 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 551d240967 | |||
| 76c82a5e96 | |||
| e8e555d8a4 | |||
| 7e595b6e5e | |||
| 1d716d0cb8 | |||
| f96883506e | |||
| 0c972c8af5 | |||
| d581010063 | |||
| 732b23b156 | |||
| 1558cc3a8b | |||
| 8a94e26f75 | |||
| 3c9109c392 | |||
| 019c8a9185 | |||
| 79a447c77b | |||
| fe044ed3db | |||
| bd3ef269d4 | |||
| 698821f065 | |||
| e794523079 | |||
| 28015ed1a2 | |||
| 376a6daddc | |||
| fb9b4fa422 | |||
| daebb54f47 | |||
| a598221812 | |||
| bada72f87e | |||
| 7d8371dea1 | |||
| 3a51425ec7 | |||
| 7c6c906db2 | |||
| 2ac20b1621 | |||
| 493d6b6eee | |||
| 959fc906de | |||
| b648b3ed45 | |||
| 54736de91c | |||
| adb5d25715 | |||
| cbf02d5507 | |||
| 317738ed79 | |||
| 5be2c48afe | |||
| cbc9949972 | |||
| e0d9ed7c5a | |||
| 69a166a6f2 | |||
| bb5137f176 | |||
| 477f496cbf | |||
| 822f4266f6 | |||
| 39e57706d9 | |||
| ac3513a686 | |||
| 31913b1e53 | |||
| 0562d53f86 | |||
| 4666c4abe7 | |||
| 281a844513 | |||
| d3daa97264 | |||
| e9fdc478f6 | |||
| 92059f319e | |||
| 213f6862d5 | |||
| 1456c8b73f | |||
| ee4925b6ac | |||
| 72f8457933 | |||
| b3f1b15f65 | |||
| 6fb68a95c7 | |||
| 7872b921ed | |||
| de28272914 | |||
| 55beba5c0a | |||
| ca8ba69acb | |||
| 8aa65014b4 | |||
| f8e797ab09 | |||
| 21743a08b1 | |||
| c92730a737 | |||
| 0f8b318afa | |||
| 21fcbc92d4 |
+2887
-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,698 @@
|
||||
# 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
|
||||
|
||||
Anyone can start the sign-in flow with their own email address — there
|
||||
is no invite-only allowlist. Sign-in is passwordless:
|
||||
|
||||
1. **Enter your email.** If the deployment has human verification
|
||||
enabled (a Cloudflare Turnstile challenge), you complete it here.
|
||||
2. **Enter the one-time code.** The app emails you a short numeric
|
||||
code; entering it signs you in. Codes expire after a few minutes,
|
||||
and repeated wrong entries briefly lock the email.
|
||||
3. **Set a passcode (optional).** After your first code sign-in you
|
||||
can set a passcode. On later visits you sign in with email +
|
||||
passcode, with the one-time code as the forgot-passcode fallback.
|
||||
4. **Trust this device (optional).** You can mark a device trusted for
|
||||
30 days to skip the code/passcode step on it. Trusted devices are
|
||||
listed in your settings and can be revoked individually or all at
|
||||
once.
|
||||
|
||||
### Getting write access
|
||||
|
||||
Signing in gives you an account, but write access is gated. The first
|
||||
time you sign in you're asked for your first name, last name, and a
|
||||
short note on why you'd like access; you then land on a "request in
|
||||
review" page. While your account is **pending**, you can read
|
||||
everything an anonymous visitor can but cannot write — no chat,
|
||||
propose, branch, PR, or discussion post. Once an admin **grants** your
|
||||
account you become a **contributor**, the role that carries every
|
||||
write affordance the app exposes, scoped by the per-RFC and per-branch
|
||||
rules described below.
|
||||
|
||||
An admin can also create your account ahead of time and email you an
|
||||
invite link. Clicking it claims the account and signs you in with the
|
||||
role the admin assigned, skipping the one-time-code step.
|
||||
|
||||
---
|
||||
|
||||
## 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 five
|
||||
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.
|
||||
- **Use case.** Optional. *What will you be using this RFC for?* —
|
||||
the concrete application driving the proposal, as distinct from the
|
||||
abstract case for it. Leaving it blank is fine.
|
||||
- **Tags.** Optional. If the deployment has AI tag suggestion
|
||||
enabled, suggested tags appear as you fill the form (with an inline
|
||||
note that the text you've entered is sent to the model that
|
||||
generates them); 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.
|
||||
|
||||
---
|
||||
|
||||
## Invitations, cross-references, and contribution requests
|
||||
|
||||
Three connected surfaces help the right people find and join the
|
||||
right RFC.
|
||||
|
||||
### Owner invitations
|
||||
|
||||
An RFC's owner (or an app-wide admin or owner) can invite a specific
|
||||
person to that RFC from the "Invitations" control in the RFC header.
|
||||
The invite names an email and a role for *this RFC*:
|
||||
|
||||
- **contributor** — can open PRs and join the discussion;
|
||||
- **discussant** — can join the discussion only.
|
||||
|
||||
The invitee gets an email with an accept link; accepting adds them as
|
||||
a collaborator on that RFC. The invitations panel lists every invite
|
||||
with its status (pending / accepted / expired / revoked); pending
|
||||
invites can be revoked. An invitation is per-RFC — it does not change
|
||||
the invitee's app-wide role, and it cannot lift the pending gate: the
|
||||
invitee still needs a granted account to write.
|
||||
|
||||
### RFC cross-links in PRs and comments
|
||||
|
||||
When a PR description or a comment mentions an existing active RFC —
|
||||
by its ID, its multi-word title, or its slug — the framework renders
|
||||
that mention as a link to the RFC. The matching is conservative by
|
||||
design (single common words are never auto-linked), and the links are
|
||||
computed at read time, so nothing is rewritten in what you typed.
|
||||
|
||||
### "Create" and "ask to contribute" offers
|
||||
|
||||
The same scan surfaces two affordances inline:
|
||||
|
||||
- If a term looks like it should have an RFC but none exists yet, a
|
||||
reader who has create rights sees a **"create RFC for '<term>'"**
|
||||
link that opens the propose modal with the title pre-filled.
|
||||
- If a term matches a *pending* RFC (a super-draft someone already
|
||||
owns), a signed-in reader sees an **"ask to contribute"** offer
|
||||
naming the owner. It opens a short request form — who you are, why
|
||||
you're asking, and optionally what you'd use the RFC for. The
|
||||
request lands in the owner's inbox; the owner can **accept** (which
|
||||
sends you an owner invitation) or **decline** (which notifies you).
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
Between anonymous and contributor sits one transient state:
|
||||
**pending**. A freshly signed-in account that hasn't been granted
|
||||
access yet (see [Signing in](#signing-in)) reads everything an
|
||||
anonymous visitor can, but no write affordance unlocks until an admin
|
||||
grants it. Granting promotes the account to contributor; an admin can
|
||||
also revoke a granted account back to a no-write state. These
|
||||
transitions are recorded in the `permission_events` log.
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## Privacy and cookies
|
||||
|
||||
A consent banner appears on your first visit and lets you choose
|
||||
which cookie categories to allow — essential always, with analytics
|
||||
and other categories opt-in. The choice is remembered and can be
|
||||
changed any time from the privacy/cookies controls in settings.
|
||||
|
||||
Analytics only load if you opt in: the framework defers the analytics
|
||||
SDK behind your consent, so declining means it is never initialized.
|
||||
The `/privacy` and `/cookies` pages describe what's collected and
|
||||
why; a deployment can point those pages at its own fuller policy.
|
||||
|
||||
---
|
||||
|
||||
## 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`.
|
||||
@@ -38,10 +38,22 @@ GITEA_WEBHOOK_SECRET=change-me-to-a-shared-secret
|
||||
# Comma-separated list of provider keys to enable. Per the §19.2
|
||||
# per-RFC-model topic, this is app-wide until that topic lands.
|
||||
ENABLED_MODELS=claude
|
||||
# ANTHROPIC_API_KEY also powers the §9.1 propose-RFC tag suggestions
|
||||
# (roadmap #27) — that surface always uses Claude Haiku for cost,
|
||||
# independent of ENABLED_MODELS. With no key set, tag suggestions are
|
||||
# simply unavailable (the modal hides the row); the rest of the app is
|
||||
# unaffected.
|
||||
ANTHROPIC_API_KEY=
|
||||
GOOGLE_API_KEY=
|
||||
OPENAI_API_KEY=
|
||||
|
||||
# --- Tag suggestions (§9.1 / roadmap #27) ---
|
||||
# Per-user rate limit on the suggest-tags endpoint (cost backstop; the
|
||||
# modal debounces and the endpoint is contributor-gated). Optional —
|
||||
# these defaults apply when unset.
|
||||
TAG_SUGGEST_RATE_MAX=30
|
||||
TAG_SUGGEST_RATE_WINDOW_SECONDS=60
|
||||
|
||||
# --- Email (§15.4) ---
|
||||
# Leave SMTP_HOST unset to use the stdout fallback — the integration
|
||||
# tests rely on it, and a dev environment without a real SMTP provider
|
||||
@@ -81,3 +93,32 @@ WEBHOOK_EMAIL_BOUNCE_SECRET=
|
||||
# Production default is hourly; tests override to seconds via the same
|
||||
# env var.
|
||||
HYGIENE_TICK_SECONDS=3600
|
||||
|
||||
# --- v0.7.0: email + one-time-code sign-in (§6.2) ---
|
||||
# How long a one-time code stays valid after issuance. Re-requesting
|
||||
# invalidates the prior code immediately regardless of TTL.
|
||||
OTC_TTL_MINUTES=10
|
||||
|
||||
# Per-email cooldown between successive /auth/otc/request calls. The
|
||||
# endpoint returns HTTP 429 when the cooldown blocks a request (the
|
||||
# loud-failure shape so the abuse path is visible). Set to 0 to
|
||||
# disable the cooldown — useful for tests but never in production.
|
||||
OTC_REQUEST_COOLDOWN_SECONDS=60
|
||||
|
||||
# --- v0.12.0: CloudFlare Turnstile gate on OTC dispatch (§6.2, item #10) ---
|
||||
# Provision a Turnstile site at dash.cloudflare.com → Turnstile → Add
|
||||
# site. The site key (public) goes in `frontend/.env` as
|
||||
# VITE_TURNSTILE_SITE_KEY. The secret key (private) goes here and is
|
||||
# what the backend POSTs to /siteverify alongside the user's response
|
||||
# token. Leave both unset for dev/test paths; the gate stays open when
|
||||
# the secret is absent AND TURNSTILE_REQUIRED=false (the default).
|
||||
CLOUDFLARE_TURNSTILE_SECRET=
|
||||
|
||||
# When `true`, /auth/otc/request fails closed (HTTP 500 "auth
|
||||
# misconfigured") if CLOUDFLARE_TURNSTILE_SECRET is unset. When `false`
|
||||
# (the default), a missing secret skips verification — useful in dev
|
||||
# and during the pre-rollout window when the operator hasn't wired
|
||||
# the secret yet. Flip to `true` once the secret is wired so a future
|
||||
# config drift surfaces as a loud 500 rather than a silent abuse-
|
||||
# defense disablement.
|
||||
TURNSTILE_REQUIRED=false
|
||||
|
||||
+556
-2
@@ -15,22 +15,32 @@ import json
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import PlainTextResponse, Response
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from . import (
|
||||
api_admin,
|
||||
api_branches,
|
||||
api_contributions,
|
||||
api_discussion,
|
||||
api_graduation,
|
||||
api_invitations,
|
||||
api_notifications,
|
||||
api_prs,
|
||||
auth,
|
||||
db,
|
||||
device_trust as device_trust_mod,
|
||||
docs as docs_mod,
|
||||
docs_sessions,
|
||||
docs_specs,
|
||||
entry as entry_mod,
|
||||
cache,
|
||||
funder,
|
||||
health,
|
||||
notify,
|
||||
philosophy,
|
||||
providers as providers_mod,
|
||||
tag_suggest,
|
||||
)
|
||||
from .bot import Bot
|
||||
from .config import Config
|
||||
@@ -43,6 +53,22 @@ class ProposeBody(BaseModel):
|
||||
slug: str = Field(min_length=1, max_length=80)
|
||||
pitch: str = Field(min_length=1)
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
# Roadmap #26: optional "What will you be using this RFC for?" — the
|
||||
# concrete ground-truth use case, distinct from the `pitch`'s abstract
|
||||
# "why is this needed." Optional (NULL/omitted accepted), no minimum,
|
||||
# generous cap matching the pitch's free-text body bound.
|
||||
proposed_use_case: str | None = Field(default=None, max_length=8000)
|
||||
|
||||
|
||||
class SuggestTagsBody(BaseModel):
|
||||
# Roadmap #27: the partial propose-RFC draft, sent as the user types
|
||||
# (debounced on the frontend). All fields optional — suggestions
|
||||
# refine as the draft fills in. `pitch` is the "why is this needed"
|
||||
# rationale; `use_case` is the #26 optional ground-truth field.
|
||||
# Bounds mirror the propose body's free-text caps.
|
||||
title: str = Field(default="", max_length=200)
|
||||
pitch: str = Field(default="", max_length=8000)
|
||||
use_case: str = Field(default="", max_length=8000)
|
||||
|
||||
|
||||
class DeclineBody(BaseModel):
|
||||
@@ -54,6 +80,29 @@ class FunderCredentialBody(BaseModel):
|
||||
api_key: str = Field(min_length=1, max_length=2048)
|
||||
|
||||
|
||||
class LastStateBody(BaseModel):
|
||||
# v0.23.0 / roadmap item #29: server-side sign-in state resume.
|
||||
# `route` is a frontend pathname the user was last on (bounded so a
|
||||
# hostile client can't stuff arbitrary blobs through). `state` is an
|
||||
# optional bag of *light* view state (scroll anchors, open tab,
|
||||
# filter chips). PRIVACY: it MUST NOT carry draft-buffer contents —
|
||||
# the frontend only ever sends ephemeral view state, and the column
|
||||
# comment in migration 022 + SPEC §6.2 are the binding contract.
|
||||
route: str = Field(min_length=1, max_length=2048)
|
||||
state: dict[str, Any] | None = None
|
||||
|
||||
|
||||
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,
|
||||
@@ -81,6 +130,22 @@ def make_router(
|
||||
# the §15.8 mute typeahead) and the §6/§17 admin surfaces
|
||||
# (role, write-mute, audit-log, graduation-readiness queue).
|
||||
router.include_router(api_admin.make_router(config))
|
||||
# v0.5.0: §5 / §7 / §10 — PR-less per-RFC discussion endpoints.
|
||||
# The substrate is the existing threads/thread_messages tables;
|
||||
# rows whose branch_name IS NULL scope to the RFC's main view.
|
||||
# Contribution still requires a PR (api_prs above); this surface
|
||||
# is for discussion that does not yet warrant a branch.
|
||||
router.include_router(api_discussion.make_router())
|
||||
# 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())
|
||||
# v0.29.0 (roadmap item #28 Part 3): offer-to-contribute-to-a-pending
|
||||
# (super-draft) RFC. Reuses the #12 invite flow (api_invitations above)
|
||||
# on accept; lands the request + owner notifications via §15 notify.
|
||||
router.include_router(api_contributions.make_router())
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# §17: /api/health — unauthenticated post-flight probe.
|
||||
@@ -104,6 +169,188 @@ 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"]}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# v0.19.0 / roadmap item #30 — /api/docs/sessions/*
|
||||
#
|
||||
# The framework mediates reads against the public
|
||||
# `wiggleverse/ohm-session-history` gitea repo so the rendered
|
||||
# `/docs/sessions/*` surface inherits the same chrome as the
|
||||
# /docs/user-guide route and doesn't require a cross-origin
|
||||
# gesture from the frontend. See backend/app/docs_sessions.py
|
||||
# for the cache shape and env knobs.
|
||||
#
|
||||
# The route mapping for the three `status` values returned by
|
||||
# the fetchers:
|
||||
#
|
||||
# "ok" → HTTP 200, payload as documented per endpoint
|
||||
# "404" → HTTP 200/404 depending on the endpoint (the
|
||||
# manifest's empty state is 200 + {} so the
|
||||
# frontend can short-circuit without an error
|
||||
# banner; transcripts/about return 404 so the
|
||||
# frontend can render its own empty-state)
|
||||
# "error" → HTTP 502, {"error": ..., "detail": ...} so the
|
||||
# frontend retry surface reads as "couldn't reach
|
||||
# the session-history repo" rather than as a
|
||||
# generic 5xx.
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.get("/api/docs/sessions/manifest")
|
||||
async def get_sessions_manifest() -> dict[str, Any]:
|
||||
result = await docs_sessions.fetch_manifest()
|
||||
if result["status"] == "ok":
|
||||
return result["manifest"]
|
||||
if result["status"] == "404":
|
||||
# Empty-state contract: render no session rows in the
|
||||
# flyout but don't show an error banner. The frontend
|
||||
# treats `{}` as "no sessions published yet".
|
||||
return {}
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail={
|
||||
"error": "session-history fetch failed",
|
||||
"detail": result.get("detail", "unknown"),
|
||||
},
|
||||
)
|
||||
|
||||
@router.get("/api/docs/sessions/about")
|
||||
async def get_sessions_about() -> Response:
|
||||
result = await docs_sessions.fetch_about()
|
||||
if result["status"] == "ok":
|
||||
return PlainTextResponse(
|
||||
content=result["body"],
|
||||
media_type="text/markdown; charset=utf-8",
|
||||
)
|
||||
if result["status"] == "404":
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="session-history README not yet published",
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail={
|
||||
"error": "session-history fetch failed",
|
||||
"detail": result.get("detail", "unknown"),
|
||||
},
|
||||
)
|
||||
|
||||
@router.get("/api/docs/sessions/{nnnn}/index")
|
||||
async def get_sessions_index(nnnn: str) -> dict[str, Any]:
|
||||
if not docs_sessions._is_valid_session_dir(nnnn):
|
||||
# 400 over 404: the request itself is malformed (the
|
||||
# session directory name doesn't match `^\d{4}$`),
|
||||
# distinct from "no such session published yet".
|
||||
raise HTTPException(status_code=400, detail="invalid session directory")
|
||||
result = await docs_sessions.fetch_session_index(nnnn)
|
||||
if result["status"] == "ok":
|
||||
return {"files": result["files"]}
|
||||
if result["status"] == "404":
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="no transcripts published for this session",
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail={
|
||||
"error": "session-history fetch failed",
|
||||
"detail": result.get("detail", "unknown"),
|
||||
},
|
||||
)
|
||||
|
||||
@router.get("/api/docs/sessions/{nnnn}/{filename}")
|
||||
async def get_sessions_transcript(nnnn: str, filename: str) -> Response:
|
||||
# Path-shape validation before any network — refuses anything
|
||||
# that would resolve outside the `NNNN/SESSION-...md` layout
|
||||
# (e.g. legacy `SESSION-A-TRANSCRIPT.md` at the repo root,
|
||||
# `../etc/passwd`, or any non-numeric session dir).
|
||||
if not docs_sessions._is_valid_session_dir(nnnn):
|
||||
raise HTTPException(status_code=400, detail="invalid session directory")
|
||||
if not docs_sessions._is_valid_transcript_filename(filename):
|
||||
raise HTTPException(status_code=400, detail="invalid transcript filename")
|
||||
result = await docs_sessions.fetch_transcript(nnnn, filename)
|
||||
if result["status"] == "ok":
|
||||
return PlainTextResponse(
|
||||
content=result["body"],
|
||||
media_type="text/markdown; charset=utf-8",
|
||||
)
|
||||
if result["status"] == "404":
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="transcript not found",
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail={
|
||||
"error": "session-history fetch failed",
|
||||
"detail": result.get("detail", "unknown"),
|
||||
},
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# v0.20.0 — /api/docs/specs/*
|
||||
#
|
||||
# Sibling of the v0.19.0 docs-sessions surface: the framework
|
||||
# mediates a fetch against the public gitea raw URL for each
|
||||
# configured spec so the rendered `/docs/specs/*` route inherits
|
||||
# the same chrome (and the same auth-less reach) as the user
|
||||
# guide and the session-history browser. See
|
||||
# backend/app/docs_specs.py for the manifest shape, the env
|
||||
# knobs, and the cache.
|
||||
#
|
||||
# Status-to-HTTP mapping mirrors docs_sessions:
|
||||
# "ok" → HTTP 200, payload as documented per endpoint
|
||||
# "404" → HTTP 200 / 404 (manifest 404 doesn't apply here —
|
||||
# the manifest is derived from env, never 404s; spec
|
||||
# 404 returns HTTP 404 so the frontend can render
|
||||
# "spec not yet published / unknown name")
|
||||
# "error" → HTTP 502
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.get("/api/docs/specs/manifest")
|
||||
async def get_specs_manifest() -> dict[str, Any]:
|
||||
# The manifest is derived from env (`OHM_DOCS_SPECS`) and
|
||||
# never fails — a malformed value falls back to the framework
|
||||
# default at parse time. So this endpoint always returns 200
|
||||
# + a list (the framework default is non-empty).
|
||||
result = docs_specs.fetch_specs_manifest()
|
||||
return {"specs": result["specs"]}
|
||||
|
||||
@router.get("/api/docs/specs/{name}")
|
||||
async def get_spec(name: str) -> Response:
|
||||
# Slug validation before any network — refuses `..`, `/`,
|
||||
# uppercase, whitespace, etc. Same defense-in-depth posture
|
||||
# as the docs-sessions transcript endpoint.
|
||||
if not docs_specs._is_valid_name(name):
|
||||
raise HTTPException(status_code=400, detail="invalid spec name")
|
||||
result = await docs_specs.fetch_spec(name)
|
||||
if result["status"] == "ok":
|
||||
return PlainTextResponse(
|
||||
content=result["body"],
|
||||
media_type="text/markdown; charset=utf-8",
|
||||
)
|
||||
if result["status"] == "404":
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="spec not found",
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail={
|
||||
"error": "specs fetch failed",
|
||||
"detail": result.get("detail", "unknown"),
|
||||
},
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Auth surface — reads role from our users table per §6.
|
||||
# ---------------------------------------------------------------
|
||||
@@ -113,6 +360,51 @@ 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
|
||||
# v0.23.0 / item #29: fold the sign-in-resume state onto the
|
||||
# same round-trip the frontend already makes on boot. When
|
||||
# resume is disabled (resume_enabled = 0) we hand back a null
|
||||
# route so the client never redirects; the stored row stays put
|
||||
# so re-enabling later resumes the last-known route.
|
||||
state_row = db.conn().execute(
|
||||
"SELECT last_route, last_route_state, resume_enabled "
|
||||
"FROM user_session_state WHERE user_id = ?",
|
||||
(user.user_id,),
|
||||
).fetchone()
|
||||
resume_enabled = bool(state_row["resume_enabled"]) if state_row else True
|
||||
last_route = (
|
||||
state_row["last_route"]
|
||||
if (state_row and resume_enabled)
|
||||
else None
|
||||
)
|
||||
last_route_state = None
|
||||
if state_row and resume_enabled and state_row["last_route_state"]:
|
||||
try:
|
||||
last_route_state = json.loads(state_row["last_route_state"])
|
||||
except (ValueError, TypeError):
|
||||
last_route_state = None
|
||||
return {
|
||||
"authenticated": True,
|
||||
"user": {
|
||||
@@ -122,9 +414,194 @@ 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.23.0 / item #29 — sign-in state resume.
|
||||
"resume_enabled": resume_enabled,
|
||||
"last_route": last_route,
|
||||
"last_route_state": last_route_state,
|
||||
},
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 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.23.0 (§6.2, roadmap item #29): server-side sign-in state
|
||||
# resume. The frontend debounce-posts the user's current route +
|
||||
# a small bag of light view state here on every route change; the
|
||||
# next sign-in reads `last_route` off `/api/auth/me` and redirects.
|
||||
#
|
||||
# Per-user (NOT per-device) — one row per user, keyed on user_id.
|
||||
# `resume_enabled` is the opt-out flag (default on); when it's 0
|
||||
# this endpoint no-ops so a user who turned resume off doesn't keep
|
||||
# silently rewriting their stored route. PRIVACY: the body carries
|
||||
# route + light state ONLY, never draft-buffer contents (migration
|
||||
# 022 column comment + SPEC §6.2 are the binding contract).
|
||||
#
|
||||
# `require_user` (not `require_contributor`) — a pending/granted
|
||||
# distinction is irrelevant for "remember where I was", and a
|
||||
# pending user navigating read-only surfaces should still resume.
|
||||
# Anonymous callers get the 401 `require_user` raises.
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.put("/api/me/last-state")
|
||||
async def put_last_state(body: LastStateBody, request: Request) -> dict[str, Any]:
|
||||
user = auth.require_user(request)
|
||||
# Respect the opt-out: if a row already exists with resume
|
||||
# disabled, leave it untouched and report the no-op. A first-
|
||||
# ever POST (no row yet) defaults to enabled and stores.
|
||||
existing = db.conn().execute(
|
||||
"SELECT resume_enabled FROM user_session_state WHERE user_id = ?",
|
||||
(user.user_id,),
|
||||
).fetchone()
|
||||
if existing is not None and not existing["resume_enabled"]:
|
||||
return {"ok": True, "stored": False}
|
||||
state_json = json.dumps(body.state) if body.state is not None else None
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO user_session_state
|
||||
(user_id, last_route, last_route_state, last_updated_at)
|
||||
VALUES (?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
last_route = excluded.last_route,
|
||||
last_route_state = excluded.last_route_state,
|
||||
last_updated_at = excluded.last_updated_at
|
||||
""",
|
||||
(user.user_id, body.route, state_json),
|
||||
)
|
||||
return {"ok": True, "stored": 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
|
||||
# ---------------------------------------------------------------
|
||||
@@ -186,12 +663,36 @@ def make_router(
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, "Not found")
|
||||
return _serialize_rfc(row)
|
||||
payload = _serialize_rfc(row)
|
||||
# Roadmap #26: surface the optional propose-time use case on the
|
||||
# RFC view. The idea PR closes on merge, but the canonical row in
|
||||
# `proposed_use_cases` persists; look it up by slug (the latest
|
||||
# 'rfc'-scope row for this slug). NULL == "left blank".
|
||||
uc = db.conn().execute(
|
||||
"""
|
||||
SELECT use_case FROM proposed_use_cases
|
||||
WHERE scope = 'rfc' AND rfc_slug = ?
|
||||
ORDER BY id DESC LIMIT 1
|
||||
""",
|
||||
(slug,),
|
||||
).fetchone()
|
||||
payload["proposed_use_case"] = uc["use_case"] if uc else None
|
||||
return payload
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# §7.3 / §9.3: pending ideas
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
def _proposal_use_case(pr_number: int) -> str | None:
|
||||
"""Roadmap #26: read the optional use case for an idea PR from the
|
||||
canonical side table. Returns None when none was supplied (the
|
||||
"left blank" sentinel the frontend renders tastefully)."""
|
||||
row = db.conn().execute(
|
||||
"SELECT use_case FROM proposed_use_cases WHERE scope = 'rfc' AND pr_number = ?",
|
||||
(pr_number,),
|
||||
).fetchone()
|
||||
return row["use_case"] if row else None
|
||||
|
||||
@router.get("/api/proposals")
|
||||
async def list_proposals() -> dict[str, Any]:
|
||||
rows = db.conn().execute(
|
||||
@@ -211,6 +712,7 @@ def make_router(
|
||||
"description": r["description"],
|
||||
"opened_by": r["opened_by"],
|
||||
"opened_at": r["opened_at"],
|
||||
"proposed_use_case": _proposal_use_case(r["pr_number"]),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
@@ -259,6 +761,7 @@ def make_router(
|
||||
"opened_at": row["opened_at"],
|
||||
"entry": entry_payload,
|
||||
"affordances": affordances,
|
||||
"proposed_use_case": _proposal_use_case(pr_number),
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
@@ -298,7 +801,11 @@ def make_router(
|
||||
proposed_at=entry_mod.today(),
|
||||
graduated_at=None,
|
||||
graduated_by=None,
|
||||
owners=[],
|
||||
# §9.2: the proposer is the implicit first owner at propose time.
|
||||
# The §13.1 claim flow exists for *other* contributors to become
|
||||
# owners on an RFC they didn't propose; the proposer never needs
|
||||
# to claim their own RFC.
|
||||
owners=[user.gitea_login],
|
||||
arbiters=[],
|
||||
tags=[t.strip() for t in payload.tags if t.strip()],
|
||||
body=payload.pitch.strip() + "\n",
|
||||
@@ -331,8 +838,55 @@ def make_router(
|
||||
# cache write is idempotent.)
|
||||
await cache.refresh_meta_pulls(config, gitea)
|
||||
|
||||
# Roadmap #26: persist the optional use case to the canonical,
|
||||
# reconcile-proof side table keyed by the idea PR number. NULL/
|
||||
# blank simply writes no row (absence == "left blank"). Done after
|
||||
# the refresh so the cache row exists; the mirror onto cached_prs
|
||||
# keeps the cache column in parity for any read that uses it.
|
||||
use_case = (payload.proposed_use_case or "").strip()
|
||||
if use_case:
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO proposed_use_cases (scope, rfc_slug, pr_number, use_case)
|
||||
VALUES ('rfc', ?, ?, ?)
|
||||
ON CONFLICT(scope, pr_number) DO UPDATE SET use_case = excluded.use_case
|
||||
""",
|
||||
(slug, pr["number"], use_case),
|
||||
)
|
||||
db.conn().execute(
|
||||
"UPDATE cached_prs SET proposed_use_case = ? WHERE pr_kind = 'idea' AND pr_number = ?",
|
||||
(use_case, pr["number"]),
|
||||
)
|
||||
|
||||
return {"pr_number": pr["number"], "slug": slug}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# §9.1 Slice 2 (roadmap #27): Claude Haiku tag suggestions as the
|
||||
# propose-RFC fields fill in. The modal debounce-posts the partial
|
||||
# draft; we constrain Haiku to the corpus's existing tag set and
|
||||
# return a short ranked list of clickable chips. Gated to
|
||||
# contributors (same gate as propose) so the cost surface is bounded
|
||||
# to people who can actually file an RFC; rate-limited per user as a
|
||||
# backstop. Degrades to an empty list (no error) when no Anthropic
|
||||
# key is bound, the corpus has no tags yet, or the draft is empty —
|
||||
# so the modal simply shows nothing extra.
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.post("/api/rfcs/suggest-tags")
|
||||
async def suggest_rfc_tags(payload: SuggestTagsBody, request: Request) -> dict[str, Any]:
|
||||
user = auth.require_contributor(request)
|
||||
if not tag_suggest.rate_limit_ok(user.user_id):
|
||||
raise HTTPException(429, "Too many tag-suggestion requests; please slow down.")
|
||||
provider = tag_suggest.haiku_provider(config)
|
||||
if provider is None:
|
||||
return {"suggestions": []}
|
||||
universe = tag_suggest.gather_tag_universe()
|
||||
draft = tag_suggest.Draft(
|
||||
title=payload.title, pitch=payload.pitch, use_case=payload.use_case
|
||||
)
|
||||
suggestions = tag_suggest.suggest(provider, draft, universe)
|
||||
return {"suggestions": suggestions}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# §9.3: merge / decline / withdraw an idea PR
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
+594
-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,6 +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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -63,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": [
|
||||
{
|
||||
@@ -83,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
|
||||
]
|
||||
@@ -131,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")
|
||||
@@ -247,6 +672,73 @@ def make_router(config: Config) -> APIRouter:
|
||||
"has_more": len(rows) == limit,
|
||||
}
|
||||
|
||||
@router.get("/api/admin/outbound-emails")
|
||||
async def list_outbound_emails(
|
||||
request: Request,
|
||||
kind: str | None = None,
|
||||
status: str | None = None,
|
||||
to_address: str | None = None,
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
before_id: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""v0.18.0 Slice 4: read-only inspection of the
|
||||
`outbound_emails` audit table.
|
||||
|
||||
Answers questions like "did this person ever get their
|
||||
invite?" without grepping VM logs. Filterable by kind
|
||||
('otc' | 'invite' | 'notification' | 'bundle' | 'digest'),
|
||||
status ('sent' | 'failed' | 'deferred' | 'bounced'), and
|
||||
to_address; the latter is exact-match because the audit
|
||||
question is usually "the specific person who said they
|
||||
didn't receive it." Per the proposal, no admin UI ships
|
||||
with v0.18.0 — operator queries via curl + jq for now.
|
||||
"""
|
||||
auth.require_admin(request)
|
||||
clauses: list[str] = []
|
||||
args: list[Any] = []
|
||||
if kind:
|
||||
clauses.append("kind = ?")
|
||||
args.append(kind)
|
||||
if status:
|
||||
clauses.append("status = ?")
|
||||
args.append(status)
|
||||
if to_address:
|
||||
clauses.append("LOWER(to_address) = LOWER(?)")
|
||||
args.append(to_address)
|
||||
if before_id is not None:
|
||||
clauses.append("id < ?")
|
||||
args.append(before_id)
|
||||
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
|
||||
rows = db.conn().execute(
|
||||
f"""
|
||||
SELECT id, to_address, from_address, subject, kind, sent_at,
|
||||
status, error, notification_id, message_id
|
||||
FROM outbound_emails
|
||||
{where}
|
||||
ORDER BY id DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(*args, limit),
|
||||
).fetchall()
|
||||
return {
|
||||
"items": [
|
||||
{
|
||||
"id": r["id"],
|
||||
"to_address": r["to_address"],
|
||||
"from_address": r["from_address"],
|
||||
"subject": r["subject"],
|
||||
"kind": r["kind"],
|
||||
"sent_at": r["sent_at"],
|
||||
"status": r["status"],
|
||||
"error": r["error"],
|
||||
"notification_id": r["notification_id"],
|
||||
"message_id": r["message_id"],
|
||||
}
|
||||
for r in rows
|
||||
],
|
||||
"has_more": len(rows) == limit,
|
||||
}
|
||||
|
||||
@router.get("/api/admin/permission-events")
|
||||
async def list_permission_events(
|
||||
request: Request,
|
||||
@@ -385,6 +877,103 @@ def make_router(config: Config) -> APIRouter:
|
||||
]
|
||||
}
|
||||
|
||||
# ----- Private-beta allowlist (`migrations/011_allowlist.sql`) -----
|
||||
|
||||
@router.get("/api/admin/allowlist")
|
||||
async def list_allowlist(request: Request) -> dict[str, Any]:
|
||||
auth.require_admin(request)
|
||||
rows = db.conn().execute(
|
||||
"""
|
||||
SELECT a.email, a.note, a.created_at,
|
||||
u.gitea_login AS added_by_login,
|
||||
u.display_name AS added_by_display
|
||||
FROM allowed_emails a
|
||||
LEFT JOIN users u ON u.id = a.added_by_user_id
|
||||
ORDER BY a.created_at DESC
|
||||
"""
|
||||
).fetchall()
|
||||
return {
|
||||
"active": len(rows) > 0,
|
||||
"items": [
|
||||
{
|
||||
"email": r["email"],
|
||||
"note": r["note"] or "",
|
||||
"added_by_login": r["added_by_login"],
|
||||
"added_by_display": r["added_by_display"],
|
||||
"created_at": r["created_at"],
|
||||
}
|
||||
for r in rows
|
||||
],
|
||||
}
|
||||
|
||||
@router.post("/api/admin/allowlist")
|
||||
async def add_allowlist(body: AllowlistAddBody, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_admin(request)
|
||||
email = body.email.strip()
|
||||
if "@" not in email or len(email.split("@")[-1]) < 2:
|
||||
raise HTTPException(422, "Email looks malformed")
|
||||
existing = db.conn().execute(
|
||||
"SELECT 1 FROM allowed_emails WHERE email = ? LIMIT 1", (email,)
|
||||
).fetchone()
|
||||
if existing is not None:
|
||||
raise HTTPException(409, "Email already on the allowlist")
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO allowed_emails (email, added_by_user_id, note)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
(email, viewer.user_id, body.note),
|
||||
)
|
||||
# Audit trail: when the email already maps to a known user, emit a
|
||||
# permission_events row so §6.5's log stays the single place to
|
||||
# look for "who let this person in." For brand-new emails the
|
||||
# allowed_emails row itself carries (added_by_user_id, created_at)
|
||||
# which is sufficient until the user actually signs in.
|
||||
subject = db.conn().execute(
|
||||
"SELECT id FROM users WHERE email = ? COLLATE NOCASE LIMIT 1", (email,)
|
||||
).fetchone()
|
||||
if subject is not None:
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO permission_events
|
||||
(actor_user_id, subject_user_id, event_kind, details)
|
||||
VALUES (?, ?, 'allowlist_added', ?)
|
||||
""",
|
||||
(
|
||||
viewer.user_id,
|
||||
subject["id"],
|
||||
json.dumps({"email": email, "note": body.note or ""}),
|
||||
),
|
||||
)
|
||||
return {"ok": True, "email": email}
|
||||
|
||||
@router.delete("/api/admin/allowlist/{email}")
|
||||
async def remove_allowlist(email: str, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_admin(request)
|
||||
existing = db.conn().execute(
|
||||
"SELECT 1 FROM allowed_emails WHERE email = ? LIMIT 1", (email,)
|
||||
).fetchone()
|
||||
if existing is None:
|
||||
raise HTTPException(404, "Email not on the allowlist")
|
||||
db.conn().execute("DELETE FROM allowed_emails WHERE email = ?", (email,))
|
||||
subject = db.conn().execute(
|
||||
"SELECT id FROM users WHERE email = ? COLLATE NOCASE LIMIT 1", (email,)
|
||||
).fetchone()
|
||||
if subject is not None:
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO permission_events
|
||||
(actor_user_id, subject_user_id, event_kind, details)
|
||||
VALUES (?, ?, 'allowlist_removed', ?)
|
||||
""",
|
||||
(
|
||||
viewer.user_id,
|
||||
subject["id"],
|
||||
json.dumps({"email": email}),
|
||||
),
|
||||
)
|
||||
return {"ok": True, "email": email}
|
||||
|
||||
return router
|
||||
|
||||
|
||||
|
||||
+76
-37
@@ -150,7 +150,7 @@ def make_router(
|
||||
# `refresh_meta_branches` writes is internal scaffolding for the
|
||||
# §10.1 has-commits-ahead check — the §9.4 dropdown's first
|
||||
# position is rendered separately as 'canonical body'.
|
||||
if _is_super_draft(rfc):
|
||||
if _is_meta_resident(rfc):
|
||||
branch_rows = db.conn().execute(
|
||||
"""
|
||||
SELECT branch_name, head_sha, state, last_commit_at, pinned
|
||||
@@ -180,7 +180,7 @@ def make_router(
|
||||
# per-RFC repo. For super-draft: meta_body_edit and meta_metadata
|
||||
# PRs on the meta repo. Same shape either way — the §9.4 dropdown
|
||||
# treats both as "open work against this entry."
|
||||
pr_kinds = ("meta_body_edit", "meta_metadata") if _is_super_draft(rfc) else ("rfc_branch",)
|
||||
pr_kinds = ("meta_body_edit", "meta_metadata") if _is_meta_resident(rfc) else ("rfc_branch",)
|
||||
placeholders = ",".join("?" * len(pr_kinds))
|
||||
pr_rows = db.conn().execute(
|
||||
f"""
|
||||
@@ -204,17 +204,18 @@ def make_router(
|
||||
for r in pr_rows
|
||||
]
|
||||
|
||||
# For super-drafts the cached body is entry.body already (see
|
||||
# cache._upsert_cached_rfc), so no extraction is needed.
|
||||
# §9.8 / §13.4 pre-graduation history: for active RFCs, surface
|
||||
# any `threads` or `changes` rows whose `branch_name` starts with
|
||||
# `edit-<slug>-` so the breadcrumb dropdown can render the
|
||||
# affordance as a distinct disclosure alongside main, open
|
||||
# branches, and open PRs. The slug is the canonical key per §2.3
|
||||
# before and after graduation, so the query is a straightforward
|
||||
# lookup — no data movement.
|
||||
# For meta-resident entries the cached body is entry.body already
|
||||
# (see cache._upsert_cached_rfc), so no extraction is needed.
|
||||
# Pre-graduation history is a LEGACY-only affordance: under the
|
||||
# meta-only topology (§1, §13.4) graduation moves nothing, so an
|
||||
# active RFC's edit branches are its *current* branches and already
|
||||
# surface in `branches` above — there is no separate pre-graduation
|
||||
# set. The disclosure is therefore computed only for a legacy
|
||||
# per-RFC-repo active entry (`repo` set), where edit branches on the
|
||||
# meta repo genuinely predate the per-RFC repo and would otherwise
|
||||
# not appear. After the RFC-0001 fold-back (§13.6) nothing matches.
|
||||
pre_grad: list[dict[str, Any]] = []
|
||||
if rfc["state"] == "active":
|
||||
if rfc["state"] == "active" and rfc["repo"]:
|
||||
pre_grad_rows = db.conn().execute(
|
||||
"""
|
||||
SELECT t.branch_name,
|
||||
@@ -279,12 +280,33 @@ 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()
|
||||
if not new_branch:
|
||||
new_branch = _auto_branch_name(viewer.gitea_login)
|
||||
_validate_branch_name(new_branch)
|
||||
# Meta-only topology (§1): an active RFC's branches live on the
|
||||
# shared meta repo, so the auto name must embed the slug for the
|
||||
# cache to attribute it (`edit-<slug>-<hex>`, recovered by
|
||||
# `_slug_from_branch_name`). A legacy per-RFC-repo entry can use
|
||||
# the slug-free `<login>-draft-<hex>` since every branch there
|
||||
# belongs to the one RFC. The auto name is trusted (it carries a
|
||||
# reserved `edit-` prefix by design); only a user-supplied name
|
||||
# is validated, mirroring `start_edit_branch`.
|
||||
new_branch = (
|
||||
_auto_edit_branch_name(slug) if _is_meta_resident(rfc)
|
||||
else _auto_branch_name(viewer.gitea_login)
|
||||
)
|
||||
else:
|
||||
_validate_branch_name(new_branch)
|
||||
try:
|
||||
await bot.cut_branch_from_main(
|
||||
viewer.as_actor(),
|
||||
@@ -317,8 +339,10 @@ def make_router(
|
||||
_ensure_branch_vis(slug, new_branch, creator_user_id=viewer.user_id)
|
||||
|
||||
# Make the cache aware immediately so the breadcrumb reflects
|
||||
# the new branch without waiting for the webhook hop.
|
||||
await cache.refresh_rfc_repo(config, gitea, slug)
|
||||
# the new branch without waiting for the webhook hop. Meta-resident
|
||||
# entries (§1) refresh meta branches; a legacy per-RFC repo refreshes
|
||||
# its own — `_refresh_cache_for` dispatches on residency.
|
||||
await _refresh_cache_for(rfc)
|
||||
|
||||
return {"branch_name": new_branch, "slug": slug}
|
||||
|
||||
@@ -331,6 +355,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()
|
||||
@@ -1064,14 +1096,14 @@ def make_router(
|
||||
return row
|
||||
|
||||
def _require_rfc_with_repo(slug: str):
|
||||
"""Used by every branch-scoped endpoint. For active RFCs, a repo is
|
||||
required. For super-drafts, the meta repo is the implicit target —
|
||||
no per-RFC repo check needed."""
|
||||
"""Used by every branch-scoped endpoint. Under the meta-only
|
||||
topology (§1) the meta repo is the implicit target for every
|
||||
entry — super-draft and active alike — so there is no per-RFC
|
||||
repo check. The name is retained for call-site stability; a
|
||||
withdrawn entry is still rejected."""
|
||||
row = _require_rfc(slug)
|
||||
if row["state"] == "withdrawn":
|
||||
raise HTTPException(409, "RFC is withdrawn")
|
||||
if row["state"] == "active" and not row["repo"]:
|
||||
raise HTTPException(409, "RFC has no repo")
|
||||
return row
|
||||
|
||||
def _require_active_rfc(slug: str):
|
||||
@@ -1089,22 +1121,28 @@ def make_router(
|
||||
def _is_super_draft(rfc) -> bool:
|
||||
return rfc["state"] == "super-draft"
|
||||
|
||||
def _is_meta_resident(rfc) -> bool:
|
||||
"""Meta-only topology (§1): an entry lives in the meta repo's
|
||||
`rfcs/<slug>.md` (super-draft or active-in-place) unless it carries
|
||||
a legacy per-RFC `repo:` — which nothing does after the RFC-0001
|
||||
fold-back (§13.6)."""
|
||||
return not rfc["repo"]
|
||||
|
||||
def _is_meta_branch_name(name: str) -> bool:
|
||||
"""A branch name shaped like one of the bot's meta-repo prefixes.
|
||||
§9.8's pre-graduation history affordance points the new RFC view
|
||||
at branches matching `edit-<slug>-...` even after the entry is
|
||||
active; treating those names as meta-repo targets lets the read
|
||||
path dispatch correctly without a separate endpoint."""
|
||||
Retained for the legacy per-RFC-repo read path; under meta-only
|
||||
every entry is already a meta target via `_is_meta_resident`."""
|
||||
return name != "main" and name.startswith((
|
||||
"edit-", "edit/", "metadata-", "metadata/", "claim/", "propose/",
|
||||
"graduate-",
|
||||
))
|
||||
|
||||
def _is_meta_target(rfc, branch: str) -> bool:
|
||||
"""Either a super-draft branch (active edit branch or the
|
||||
canonical body) or an active RFC's pre-graduation meta-repo
|
||||
branch surfaced through the §9.8 history affordance."""
|
||||
if _is_super_draft(rfc):
|
||||
"""A meta-resident entry (super-draft or active-in-place, §1)
|
||||
targets the meta repo for every branch. The branch-name fallback
|
||||
covers the retired per-RFC-repo case for any legacy entry that
|
||||
still carries a `repo:`."""
|
||||
if _is_meta_resident(rfc):
|
||||
return True
|
||||
return _is_meta_branch_name(branch)
|
||||
|
||||
@@ -1144,7 +1182,7 @@ def make_router(
|
||||
return entry_mod.serialize(entry)
|
||||
|
||||
async def _refresh_cache_for(rfc) -> None:
|
||||
if _is_super_draft(rfc):
|
||||
if _is_meta_resident(rfc):
|
||||
await cache.refresh_meta_repo(config, gitea)
|
||||
await cache.refresh_meta_branches(config, gitea)
|
||||
else:
|
||||
@@ -1253,12 +1291,13 @@ def make_router(
|
||||
return False
|
||||
if branch == "main":
|
||||
return False
|
||||
# §9.8: pre-graduation history branches are read-only on the
|
||||
# post-graduation surface. The contributor can re-cut against the
|
||||
# new repo's main if they still want the work, but the meta-repo
|
||||
# branches that lived on the super-draft are not editable from
|
||||
# the active-RFC view.
|
||||
if rfc["state"] == "active" and _is_meta_branch_name(branch):
|
||||
# §9.8 (LEGACY per-repo only): pre-graduation history branches are
|
||||
# read-only on the post-graduation surface of a per-RFC-repo active
|
||||
# entry. Under the meta-only topology (§1, §13.4) an active RFC's
|
||||
# `edit-<slug>-…` branches are its *current* editable branches, not
|
||||
# a frozen pre-graduation set, so this guard applies only when a
|
||||
# legacy `repo:` is set (nothing, after the RFC-0001 fold-back).
|
||||
if rfc["state"] == "active" and rfc["repo"] and _is_meta_branch_name(branch):
|
||||
return False
|
||||
if viewer.role in ("owner", "admin"):
|
||||
return True
|
||||
@@ -1285,7 +1324,7 @@ def make_router(
|
||||
|
||||
def _require_can_contribute(slug: str, branch: str, viewer) -> None:
|
||||
rfc = db.conn().execute(
|
||||
"SELECT state, owners_json, arbiters_json FROM cached_rfcs WHERE slug = ?",
|
||||
"SELECT state, repo, owners_json, arbiters_json FROM cached_rfcs WHERE slug = ?",
|
||||
(slug,),
|
||||
).fetchone()
|
||||
if not _can_contribute(rfc, slug, branch, viewer):
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
"""v0.29.0 / roadmap #28 Part 3 — offer-to-contribute-to-a-pending-RFC.
|
||||
|
||||
When the #28 scanner (see ``rfc_links.py``) matches a term in submitted
|
||||
PR/comment text to a **pending** RFC — a super-draft
|
||||
(``cached_rfcs.state='super-draft'``: accepted as an idea, owned, with a
|
||||
contribution surface, but not yet graduated to an active RFC) — the
|
||||
reader is offered an "ask to contribute" popover. This module is the
|
||||
backend for that flow:
|
||||
|
||||
* ``GET /api/rfcs/{slug}/contribution-target`` — what the
|
||||
contribute form needs (RFC title, owner display, the viewer's
|
||||
eligibility + whether they already have a pending ask).
|
||||
* ``POST /api/rfcs/{slug}/contribution-requests`` — submit the ask
|
||||
(who-I-am / why / optional use-case); lands a row + one §15
|
||||
notification per owner.
|
||||
* ``POST /api/rfcs/{slug}/contribution-requests/{id}/accept`` — owner:
|
||||
accept, which fires #12's owner-invite flow with the requester as the
|
||||
invitee (opening the RFC's discussion/contribution surface), then
|
||||
echoes a notification back to the requester.
|
||||
* ``POST /api/rfcs/{slug}/contribution-requests/{id}/decline`` — owner:
|
||||
decline; the request closes and the requester is notified.
|
||||
|
||||
"Pending" is scoped to a super-draft because that is the state with an
|
||||
owner to route to, a contribution surface to open, and a row in
|
||||
``cached_rfcs`` for the ``rfc_invitations`` FK the accept path reuses.
|
||||
Pre-merge idea PRs are deliberately out of scope (no contribution
|
||||
surface yet) — a documented future extension, mirroring the
|
||||
conservative scoping in ``rfc_links.py``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from . import api_invitations, auth, db, notify
|
||||
|
||||
# Field caps — generous for free text, bounded so a request row (and the
|
||||
# notification payload that carries it) can't be used to store unbounded
|
||||
# blobs. Mirrors the order-of-magnitude of the propose/tag-suggest caps.
|
||||
_WHO_MAX = 2000
|
||||
_WHY_MAX = 4000
|
||||
_USE_CASE_MAX = 4000
|
||||
_TERM_MAX = 200
|
||||
|
||||
|
||||
class ContributionRequestBody(BaseModel):
|
||||
# The term in the PR/comment text that surfaced the offer (the
|
||||
# super-draft's title/slug). Carried for the owner's context line.
|
||||
matched_term: str = Field(min_length=1, max_length=_TERM_MAX)
|
||||
who_i_am: str = Field(min_length=1, max_length=_WHO_MAX)
|
||||
why: str = Field(min_length=1, max_length=_WHY_MAX)
|
||||
use_case: str | None = Field(default=None, max_length=_USE_CASE_MAX)
|
||||
|
||||
|
||||
def _require_super_draft(slug: str):
|
||||
"""The contribute surface only operates on a *pending* RFC. 404 on
|
||||
unknown; 409 on a state that isn't a super-draft (active RFCs use the
|
||||
Part-1 link, not a contribute offer; withdrawn is closed)."""
|
||||
row = db.conn().execute(
|
||||
"SELECT slug, title, state, owners_json, proposed_by FROM cached_rfcs WHERE slug = ?",
|
||||
(slug,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, "RFC not found")
|
||||
if row["state"] != "super-draft":
|
||||
raise HTTPException(409, "RFC is not a pending super-draft")
|
||||
return row
|
||||
|
||||
|
||||
def _require_request(slug: str, request_id: int):
|
||||
row = db.conn().execute(
|
||||
"""
|
||||
SELECT id, rfc_slug, requester_user_id, matched_term, who_i_am, why,
|
||||
use_case, status
|
||||
FROM contribution_requests WHERE id = ? AND rfc_slug = ?
|
||||
""",
|
||||
(request_id, slug),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, "Contribution request not found")
|
||||
return row
|
||||
|
||||
|
||||
def _viewer_relationship(viewer, slug: str) -> str | None:
|
||||
"""Why this viewer can't *request* to contribute — or None if they can.
|
||||
Owners/admins already have the RFC; existing collaborators are already
|
||||
in. Both get a clear 409 rather than a useless self-request."""
|
||||
if auth.is_rfc_owner(viewer, slug) or viewer.role in ("owner", "admin"):
|
||||
return "You already own or administer this RFC."
|
||||
if auth.is_rfc_collaborator(viewer, slug):
|
||||
return "You're already a collaborator on this RFC."
|
||||
return None
|
||||
|
||||
|
||||
def make_router() -> APIRouter:
|
||||
router = APIRouter()
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# GET — what the contribute form needs to render + gate itself.
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.get("/api/rfcs/{slug}/contribution-target")
|
||||
async def contribution_target(slug: str, request: Request) -> dict[str, Any]:
|
||||
row = db.conn().execute(
|
||||
"SELECT slug, title, state, owners_json, proposed_by FROM cached_rfcs WHERE slug = ?",
|
||||
(slug,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, "RFC not found")
|
||||
|
||||
from . import rfc_links # local import: avoid a module import cycle
|
||||
|
||||
owner = rfc_links._owner_display(db.conn(), row["owners_json"], row["proposed_by"])
|
||||
viewer = auth.current_user(request)
|
||||
|
||||
eligible = True
|
||||
reason: str | None = None
|
||||
already_requested = False
|
||||
if row["state"] != "super-draft":
|
||||
eligible, reason = False, "This RFC is no longer pending."
|
||||
elif viewer is None:
|
||||
eligible, reason = False, "Sign in to ask to contribute."
|
||||
elif viewer.permission_state != "granted":
|
||||
eligible, reason = False, "Your beta access request is in review."
|
||||
else:
|
||||
reason = _viewer_relationship(viewer, slug)
|
||||
if reason is not None:
|
||||
eligible = False
|
||||
else:
|
||||
already_requested = bool(
|
||||
db.conn().execute(
|
||||
"""
|
||||
SELECT 1 FROM contribution_requests
|
||||
WHERE rfc_slug = ? AND requester_user_id = ? AND status = 'pending'
|
||||
LIMIT 1
|
||||
""",
|
||||
(slug, viewer.user_id),
|
||||
).fetchone()
|
||||
)
|
||||
|
||||
return {
|
||||
"slug": row["slug"],
|
||||
"title": row["title"],
|
||||
"owner": owner,
|
||||
"eligible": eligible and not already_requested,
|
||||
"reason": reason,
|
||||
"already_requested": already_requested,
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# POST — submit a contribute request.
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.post("/api/rfcs/{slug}/contribution-requests")
|
||||
async def create_contribution_request(
|
||||
slug: str, body: ContributionRequestBody, request: Request
|
||||
) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
_require_super_draft(slug)
|
||||
|
||||
reason = _viewer_relationship(viewer, slug)
|
||||
if reason is not None:
|
||||
raise HTTPException(409, reason)
|
||||
|
||||
who_i_am = body.who_i_am.strip()
|
||||
why = body.why.strip()
|
||||
use_case = (body.use_case or "").strip() or None
|
||||
matched_term = body.matched_term.strip()
|
||||
if not who_i_am or not why:
|
||||
raise HTTPException(422, "Both 'who I am' and 'why' are required.")
|
||||
|
||||
try:
|
||||
cur = db.conn().execute(
|
||||
"""
|
||||
INSERT INTO contribution_requests
|
||||
(rfc_slug, requester_user_id, matched_term, who_i_am, why, use_case)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(slug, viewer.user_id, matched_term, who_i_am, why, use_case),
|
||||
)
|
||||
except sqlite3.IntegrityError:
|
||||
# The partial unique index — one open request per (RFC, user).
|
||||
raise HTTPException(409, "You already have a pending request to contribute to this RFC.")
|
||||
request_id = cur.lastrowid
|
||||
|
||||
# One actionable notification per owner; stamp the first onto the
|
||||
# row as the inbox-action handle (any owner can act on the request).
|
||||
notif_ids = notify.fan_out_contribution_request(
|
||||
rfc_slug=slug,
|
||||
requester_user_id=viewer.user_id,
|
||||
request_id=request_id,
|
||||
matched_term=matched_term,
|
||||
who_i_am=who_i_am,
|
||||
why=why,
|
||||
use_case=use_case,
|
||||
)
|
||||
if notif_ids:
|
||||
db.conn().execute(
|
||||
"UPDATE contribution_requests SET notification_id = ? WHERE id = ?",
|
||||
(notif_ids[0], request_id),
|
||||
)
|
||||
|
||||
return {"id": request_id, "rfc_slug": slug, "status": "pending"}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# POST — owner accepts → fire #12's invite flow.
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.post("/api/rfcs/{slug}/contribution-requests/{request_id}/accept")
|
||||
async def accept_contribution_request(
|
||||
slug: str, request_id: int, request: Request
|
||||
) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
rfc = _require_super_draft(slug)
|
||||
if not auth.can_invite_to_rfc(viewer, slug):
|
||||
raise HTTPException(403, "Only the RFC's owner can act on contribution requests")
|
||||
|
||||
req = _require_request(slug, request_id)
|
||||
if req["status"] != "pending":
|
||||
raise HTTPException(409, f"This request was already {req['status']}.")
|
||||
|
||||
requester = db.conn().execute(
|
||||
"SELECT id, email FROM users WHERE id = ?", (req["requester_user_id"],)
|
||||
).fetchone()
|
||||
if requester is None or not (requester["email"] or "").strip():
|
||||
raise HTTPException(422, "The requester has no email address on file to invite.")
|
||||
|
||||
# Fire #12's owner-invite flow with the requester as the invitee.
|
||||
# If a pending contributor invitation already exists (the owner
|
||||
# invited them out-of-band first), reuse it rather than failing.
|
||||
try:
|
||||
invitation = api_invitations.issue_invitation(
|
||||
slug=slug,
|
||||
inviter_user_id=viewer.user_id,
|
||||
inviter_display=viewer.display_name or viewer.gitea_login or "An RFC owner",
|
||||
invitee_email=requester["email"],
|
||||
role_in_rfc="contributor",
|
||||
rfc_title=rfc["title"],
|
||||
)
|
||||
invitation_id = invitation["id"]
|
||||
except HTTPException as exc:
|
||||
if exc.status_code != 409:
|
||||
raise
|
||||
existing = db.conn().execute(
|
||||
"""
|
||||
SELECT id FROM rfc_invitations
|
||||
WHERE rfc_slug = ? AND invitee_email = ? COLLATE NOCASE
|
||||
AND role_in_rfc = 'contributor' AND status = 'pending'
|
||||
ORDER BY id DESC LIMIT 1
|
||||
""",
|
||||
(slug, requester["email"].strip()),
|
||||
).fetchone()
|
||||
invitation_id = existing["id"] if existing else None
|
||||
|
||||
db.conn().execute(
|
||||
"""
|
||||
UPDATE contribution_requests
|
||||
SET status = 'accepted', decided_at = datetime('now'),
|
||||
decided_by_user_id = ?, invitation_id = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(viewer.user_id, invitation_id, request_id),
|
||||
)
|
||||
notify.notify_contribution_decided(
|
||||
rfc_slug=slug,
|
||||
requester_user_id=req["requester_user_id"],
|
||||
decider_user_id=viewer.user_id,
|
||||
request_id=request_id,
|
||||
accepted=True,
|
||||
)
|
||||
return {"ok": True, "status": "accepted", "invitation_id": invitation_id}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# POST — owner declines.
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.post("/api/rfcs/{slug}/contribution-requests/{request_id}/decline")
|
||||
async def decline_contribution_request(
|
||||
slug: str, request_id: int, request: Request
|
||||
) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
_require_super_draft(slug)
|
||||
if not auth.can_invite_to_rfc(viewer, slug):
|
||||
raise HTTPException(403, "Only the RFC's owner can act on contribution requests")
|
||||
|
||||
req = _require_request(slug, request_id)
|
||||
if req["status"] != "pending":
|
||||
raise HTTPException(409, f"This request was already {req['status']}.")
|
||||
|
||||
db.conn().execute(
|
||||
"""
|
||||
UPDATE contribution_requests
|
||||
SET status = 'declined', decided_at = datetime('now'),
|
||||
decided_by_user_id = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(viewer.user_id, request_id),
|
||||
)
|
||||
notify.notify_contribution_decided(
|
||||
rfc_slug=slug,
|
||||
requester_user_id=req["requester_user_id"],
|
||||
decider_user_id=viewer.user_id,
|
||||
request_id=request_id,
|
||||
accepted=False,
|
||||
)
|
||||
return {"ok": True, "status": "declined"}
|
||||
|
||||
return router
|
||||
@@ -0,0 +1,355 @@
|
||||
"""§5 / §7 / §10 — PR-less per-RFC discussion endpoints (v0.5.0).
|
||||
|
||||
This module surfaces the discussion-without-PR shape committed by the
|
||||
roadmap's item #3. The substrate is the existing `threads` /
|
||||
`thread_messages` pair from §5: rows whose `branch_name` is NULL are
|
||||
scoped to the RFC's main view (the schema comment on the column says
|
||||
exactly this; until now no write path produced such rows). This module
|
||||
is the read+write surface for those rows.
|
||||
|
||||
Contribution still requires a PR: the §10 PR flow is unchanged, the
|
||||
branch-scoped chat in `api_branches.py` is unchanged, and accept /
|
||||
decline of AI `<change>` blocks still lives on a branch. What this
|
||||
module adds is the "discuss freely about the RFC, no branch yet" surface
|
||||
— a place to drop a question, a flag-style observation, or a multi-turn
|
||||
conversation that does not yet warrant cutting a branch.
|
||||
|
||||
Auth shape mirrors the v0.3.0 anonymous-read contract: reads are open,
|
||||
writes require `auth.require_contributor`. Item #4 ("anon discuss/
|
||||
contribute off-limits") tightens the read gate in v0.6.0; v0.5.0's
|
||||
write gate already holds the line.
|
||||
|
||||
Notification routing reuses the existing `fan_out_chat_message` path
|
||||
with `branch_name=None`; the `notifications.branch_name` column is
|
||||
nullable, and the inbox row prose ("@alice posted a chat message on
|
||||
<RFC title>") renders identically whether the chat lives on a branch
|
||||
or on the RFC's discussion surface. The existing
|
||||
`chat_message_in_participated_thread` / `chat_reply_to_my_message`
|
||||
event kinds carry both shapes; introducing a parallel
|
||||
`open_rfc_discussion_thread` / `post_rfc_discussion_message` enum pair
|
||||
would split routing without adding signal. The §15 §19.2 candidate
|
||||
"distinct event_kinds for PR-less discussion" notes the option for a
|
||||
future session if evidence demands the split.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from . import auth, chat as chat_layer, db, rfc_links
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request bodies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class DiscussionThreadCreateBody(BaseModel):
|
||||
"""A discussion thread is a `thread_kind='chat'`, `anchor_kind='whole-doc'`,
|
||||
`branch_name=NULL` row. Anchored-range / per-paragraph threads on the
|
||||
RFC discussion surface are a §19.2 candidate — the schema supports
|
||||
them; the UI work to surface a range-anchor on a non-branch view is
|
||||
the deferred part. v0.5.0 keeps the shape narrow."""
|
||||
label: str | None = Field(default=None, max_length=400)
|
||||
message: str | None = Field(default=None, max_length=20_000)
|
||||
|
||||
|
||||
class DiscussionMessageBody(BaseModel):
|
||||
text: str = Field(min_length=1, max_length=20_000)
|
||||
quote: str | None = Field(default=None, max_length=2000)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Router
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def make_router() -> APIRouter:
|
||||
router = APIRouter()
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# GET /api/rfcs/<slug>/discussion/threads
|
||||
# Lists every PR-less thread on the RFC. The default whole-doc thread
|
||||
# is materialized lazily on first list (mirroring the §8.12 branch-
|
||||
# chat default-thread treatment) so the UI always has a target for
|
||||
# the compose-message affordance.
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
@router.get("/api/rfcs/{slug}/discussion/threads")
|
||||
async def list_discussion_threads(slug: str, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.current_user(request)
|
||||
_require_rfc_readable(slug)
|
||||
# Ensure the default whole-doc discussion thread exists. We mint
|
||||
# it on first read regardless of viewer (anonymous viewers can
|
||||
# trigger the creation — the row's `created_by` is null in that
|
||||
# case, mirroring `_ensure_branch_chat_thread`).
|
||||
_ensure_discussion_thread(slug, viewer)
|
||||
rows = db.conn().execute(
|
||||
"""
|
||||
SELECT id, anchor_kind, anchor_payload, thread_kind, label, state,
|
||||
created_by, created_at, resolved_at, resolved_by
|
||||
FROM threads
|
||||
WHERE rfc_slug = ? AND branch_name IS NULL
|
||||
ORDER BY id
|
||||
""",
|
||||
(slug,),
|
||||
).fetchall()
|
||||
return {"items": [_serialize_thread(r) for r in rows]}
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# POST /api/rfcs/<slug>/discussion/threads
|
||||
# Open a fresh discussion thread. Writes require require_contributor
|
||||
# — anonymous viewers can read but cannot open a thread, per item
|
||||
# #4's hardening anticipated in v0.6.0 (we already enforce it here
|
||||
# to avoid the open window).
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
@router.post("/api/rfcs/{slug}/discussion/threads")
|
||||
async def create_discussion_thread(
|
||||
slug: str, body: DiscussionThreadCreateBody, request: Request
|
||||
) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
_require_rfc_readable(slug)
|
||||
# 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
|
||||
(rfc_slug, branch_name, anchor_kind, anchor_payload,
|
||||
thread_kind, label, created_by)
|
||||
VALUES (?, NULL, 'whole-doc', NULL, 'chat', ?, ?)
|
||||
""",
|
||||
(slug, body.label, viewer.user_id),
|
||||
)
|
||||
thread_id = cur.lastrowid
|
||||
message_id = None
|
||||
if body.message:
|
||||
message_id = chat_layer.append_user_message(
|
||||
thread_id=thread_id,
|
||||
author_user_id=viewer.user_id,
|
||||
text=body.message,
|
||||
quote=None,
|
||||
)
|
||||
return {"thread_id": thread_id, "message_id": message_id}
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# GET /api/rfcs/<slug>/discussion/threads/<thread_id>/messages
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
@router.get("/api/rfcs/{slug}/discussion/threads/{thread_id}/messages")
|
||||
async def get_discussion_thread_messages(
|
||||
slug: str, thread_id: int, request: Request
|
||||
) -> dict[str, Any]:
|
||||
_viewer = auth.current_user(request)
|
||||
_require_rfc_readable(slug)
|
||||
thread = _require_discussion_thread(slug, thread_id)
|
||||
rows = db.conn().execute(
|
||||
"""
|
||||
SELECT m.id, m.role, m.author_user_id,
|
||||
u.gitea_login AS author_login,
|
||||
u.display_name AS author_display,
|
||||
m.model_id, m.text, m.quote, m.created_at
|
||||
FROM thread_messages m
|
||||
LEFT JOIN users u ON u.id = m.author_user_id
|
||||
WHERE m.thread_id = ?
|
||||
ORDER BY m.id
|
||||
""",
|
||||
(thread_id,),
|
||||
).fetchall()
|
||||
# Roadmap #28 Part 1: enrich each discussion comment with RFC
|
||||
# auto-link segments scanned against the live accepted-RFC corpus
|
||||
# (read-time; see rfc_links.py). exclude_slug suppresses self-links
|
||||
# to this RFC inside its own discussion.
|
||||
link_index = rfc_links.build_index(db.conn(), exclude_slug=slug)
|
||||
messages = [_serialize_message(r) for r in rows]
|
||||
for m in messages:
|
||||
m["text_segments"] = link_index.segment(m["text"])
|
||||
return {
|
||||
"thread": _serialize_thread(thread),
|
||||
"messages": messages,
|
||||
}
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# POST /api/rfcs/<slug>/discussion/threads/<thread_id>/messages
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
@router.post("/api/rfcs/{slug}/discussion/threads/{thread_id}/messages")
|
||||
async def post_discussion_message(
|
||||
slug: str, thread_id: int, body: DiscussionMessageBody, request: Request
|
||||
) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
_require_rfc_readable(slug)
|
||||
# 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,
|
||||
author_user_id=viewer.user_id,
|
||||
text=body.text,
|
||||
quote=body.quote,
|
||||
)
|
||||
return {"ok": True, "message_id": message_id}
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# POST /api/rfcs/<slug>/discussion/threads/<thread_id>/resolve
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
@router.post("/api/rfcs/{slug}/discussion/threads/{thread_id}/resolve")
|
||||
async def resolve_discussion_thread(
|
||||
slug: str, thread_id: int, request: Request
|
||||
) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
rfc = _require_rfc_readable(slug)
|
||||
thread = _require_discussion_thread(slug, thread_id)
|
||||
if not _can_resolve(rfc, thread, viewer):
|
||||
raise HTTPException(
|
||||
403,
|
||||
"Only the thread creator, an RFC owner/arbiter, or an app admin/owner may resolve",
|
||||
)
|
||||
db.conn().execute(
|
||||
"""
|
||||
UPDATE threads
|
||||
SET state = 'resolved',
|
||||
resolved_by = ?,
|
||||
resolved_at = datetime('now')
|
||||
WHERE id = ?
|
||||
""",
|
||||
(viewer.user_id, thread_id),
|
||||
)
|
||||
return {"ok": True, "thread_id": thread_id}
|
||||
|
||||
return router
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _require_rfc_readable(slug: str):
|
||||
"""Per the v0.3.0 anonymous-read contract: any cached RFC is readable
|
||||
by anyone. Withdrawn entries refuse reads of every shape — same rule
|
||||
`_require_rfc_with_repo` in `api_branches.py` follows."""
|
||||
row = db.conn().execute(
|
||||
"SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, "RFC not found")
|
||||
if row["state"] == "withdrawn":
|
||||
raise HTTPException(409, "RFC is withdrawn")
|
||||
return row
|
||||
|
||||
|
||||
def _require_discussion_thread(slug: str, thread_id: int):
|
||||
"""A discussion thread is one whose (rfc_slug, branch_name) = (slug,
|
||||
NULL). Refuse cleanly if the thread id resolves to a branch-scoped
|
||||
thread instead — that lookup belongs on the branch endpoints."""
|
||||
row = db.conn().execute(
|
||||
"""
|
||||
SELECT * FROM threads
|
||||
WHERE id = ? AND rfc_slug = ? AND branch_name IS NULL
|
||||
""",
|
||||
(thread_id, slug),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Discussion thread not found")
|
||||
return row
|
||||
|
||||
|
||||
def _ensure_discussion_thread(slug: str, viewer) -> int:
|
||||
"""Per the §8.12 lazy-create pattern, materialize a default whole-doc
|
||||
chat thread on the RFC's discussion surface on first read. Created_by
|
||||
is null when an anonymous viewer triggers creation — the thread is
|
||||
structurally owned by the RFC, not by whoever opened the view."""
|
||||
row = db.conn().execute(
|
||||
"""
|
||||
SELECT id FROM threads
|
||||
WHERE rfc_slug = ? AND branch_name IS NULL
|
||||
AND anchor_kind = 'whole-doc' AND thread_kind = 'chat'
|
||||
ORDER BY id LIMIT 1
|
||||
""",
|
||||
(slug,),
|
||||
).fetchone()
|
||||
if row:
|
||||
return row["id"]
|
||||
cur = db.conn().execute(
|
||||
"""
|
||||
INSERT INTO threads
|
||||
(rfc_slug, branch_name, anchor_kind, thread_kind, label, created_by)
|
||||
VALUES (?, NULL, 'whole-doc', 'chat', NULL, ?)
|
||||
""",
|
||||
(slug, viewer.user_id if viewer else None),
|
||||
)
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def _can_resolve(rfc, thread, viewer) -> bool:
|
||||
if viewer is None:
|
||||
return False
|
||||
if viewer.role in ("owner", "admin"):
|
||||
return True
|
||||
owners = json.loads(rfc["owners_json"] or "[]")
|
||||
arbiters = json.loads(rfc["arbiters_json"] or "[]")
|
||||
if viewer.gitea_login in owners or viewer.gitea_login in arbiters:
|
||||
return True
|
||||
if thread["created_by"] == viewer.user_id:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Serializers — mirror api_branches.py's shape
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _serialize_thread(row) -> dict[str, Any]:
|
||||
payload = row["anchor_payload"]
|
||||
try:
|
||||
anchor = json.loads(payload) if payload else None
|
||||
except Exception:
|
||||
anchor = None
|
||||
return {
|
||||
"id": row["id"],
|
||||
"anchor_kind": row["anchor_kind"],
|
||||
"anchor_payload": anchor,
|
||||
"thread_kind": row["thread_kind"],
|
||||
"label": row["label"],
|
||||
"state": row["state"],
|
||||
"created_by": row["created_by"],
|
||||
"created_at": row["created_at"],
|
||||
"resolved_at": row["resolved_at"] if "resolved_at" in row.keys() else None,
|
||||
"resolved_by": row["resolved_by"] if "resolved_by" in row.keys() else None,
|
||||
}
|
||||
|
||||
|
||||
def _serialize_message(row) -> dict[str, Any]:
|
||||
return {
|
||||
"id": row["id"],
|
||||
"role": row["role"],
|
||||
"author_user_id": row["author_user_id"],
|
||||
"author_login": row["author_login"],
|
||||
"author_display": row["author_display"],
|
||||
"model_id": row["model_id"],
|
||||
"text": row["text"],
|
||||
"quote": row["quote"],
|
||||
"created_at": row["created_at"],
|
||||
}
|
||||
+133
-357
@@ -1,26 +1,30 @@
|
||||
"""Slice 5 API surface — the §13 graduation flow's endpoints and the
|
||||
in-process orchestrator that runs the §13.3 transactional sequence with
|
||||
rollback.
|
||||
"""§13 graduation flow — the meta-only in-place state flip.
|
||||
|
||||
Owns four routes per §17:
|
||||
Under the meta-only topology (SPEC §1), graduation no longer creates a
|
||||
per-RFC repo. It is a single frontmatter-flipping commit to the entry's
|
||||
`rfcs/<slug>.md` on the meta repo: open a PR that re-serializes the entry
|
||||
with `state: active`, the assigned integer `id`, `graduated_at` /
|
||||
`graduated_by`, and the dialog's owners — **leaving the body unchanged** —
|
||||
then auto-merge it. There is no repo to create, nothing to seed, and the
|
||||
body is neither moved nor stripped, so there is no multi-step transaction
|
||||
and no rollback (§13.3). If the open or merge fails, the entry stays a
|
||||
super-draft and we clean up the half-open PR/branch (the only artifact a
|
||||
mid-flip failure can leave behind).
|
||||
|
||||
Routes (§17):
|
||||
|
||||
- GET /api/rfcs/<slug>/blocking-prs (§13.2 precondition popover)
|
||||
- GET /api/rfcs/<slug>/graduate/check (§13.2 debounced validator)
|
||||
- POST /api/rfcs/<slug>/graduate (§13.3 kickoff)
|
||||
- POST /api/rfcs/<slug>/graduate (§13.3 the flip)
|
||||
- GET /api/rfcs/<slug>/graduate/progress (§13.3 SSE step stream)
|
||||
- GET /api/rfcs/<slug>/blocking-prs (informational; no longer a
|
||||
graduation precondition)
|
||||
|
||||
Plus the §13.1 claim PR endpoint (POST /api/rfcs/<slug>/claim), which is
|
||||
graduation's prerequisite for non-admins per §13.1.
|
||||
Plus the §13.1 claim PR endpoint (POST /api/rfcs/<slug>/claim).
|
||||
|
||||
The orchestrator runs in-process — each in-flight graduation lives in a
|
||||
small `GraduationState` keyed by slug, with an asyncio.Queue feeding the
|
||||
SSE handler. Per the §13.3 transactional contract, every forward step is
|
||||
paired with an undo; rollback runs the undos in reverse order from the
|
||||
last step that completed. §13.4's chat migration is a database semantic
|
||||
no-op (the threads' `(rfc_slug, branch_name='main')` rows are interpreted
|
||||
as super-draft canonical-body before graduation and as new-RFC main
|
||||
afterwards — same shape, different meaning), so the only DB work the
|
||||
sequence does is the audit-log rows the bot's `_log` writes per step.
|
||||
SSE handler. §13.4's chat/branch/history are a database no-op: every row
|
||||
is keyed by the slug per §2.3 and stays put across the flip.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -44,24 +48,18 @@ log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step machine
|
||||
# Step machine — two steps under meta-only: open the flip PR, merge it.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
STEP_KEYS = (
|
||||
"create_repo",
|
||||
"seed_files",
|
||||
"open_pr",
|
||||
"merge_pr",
|
||||
"refresh_cache",
|
||||
)
|
||||
|
||||
STEP_LABELS = {
|
||||
"create_repo": "Create per-RFC repository",
|
||||
"seed_files": "Seed RFC.md, README.md, and .rfc/metadata.yaml",
|
||||
"open_pr": "Open meta-repo graduation PR",
|
||||
"open_pr": "Open graduation PR (flip state to active)",
|
||||
"merge_pr": "Merge graduation PR",
|
||||
"refresh_cache": "Refresh catalog and views",
|
||||
}
|
||||
|
||||
|
||||
@@ -77,8 +75,6 @@ class StepState:
|
||||
class GraduationState:
|
||||
slug: str
|
||||
rfc_id: str
|
||||
repo_name: str
|
||||
repo_full: str
|
||||
owners: list[str]
|
||||
arbiters: list[str]
|
||||
steps: list[StepState]
|
||||
@@ -86,8 +82,6 @@ class GraduationState:
|
||||
finished: bool = False
|
||||
succeeded: bool = False
|
||||
error: str | None = None
|
||||
rollback_started: bool = False
|
||||
rollback_steps: list[StepState] = field(default_factory=list)
|
||||
new_pr_number: int | None = None
|
||||
graduation_branch: str | None = None
|
||||
|
||||
@@ -95,12 +89,9 @@ class GraduationState:
|
||||
return {
|
||||
"slug": self.slug,
|
||||
"rfc_id": self.rfc_id,
|
||||
"repo_full": self.repo_full,
|
||||
"steps": [_step_payload(s) for s in self.steps],
|
||||
"rollback_steps": [_step_payload(s) for s in self.rollback_steps],
|
||||
"finished": self.finished,
|
||||
"succeeded": self.succeeded,
|
||||
"rolled_back": self.rollback_started,
|
||||
"error": self.error,
|
||||
"pr_number": self.new_pr_number,
|
||||
}
|
||||
@@ -114,7 +105,7 @@ def _step_payload(s: StepState) -> dict:
|
||||
# is fine; the registry is keyed by slug to refuse concurrent graduations
|
||||
# of the same entry (the §13.2 atomic re-check is a separate defense
|
||||
# against a concurrent attempt of a DIFFERENT slug claiming the same
|
||||
# integer ID or repo name).
|
||||
# integer ID).
|
||||
_active: dict[str, GraduationState] = {}
|
||||
|
||||
|
||||
@@ -122,10 +113,10 @@ def _get_active(slug: str) -> GraduationState | None:
|
||||
return _active.get(slug)
|
||||
|
||||
|
||||
def _new_active(slug: str, *, rfc_id: str, repo_name: str, repo_full: str,
|
||||
def _new_active(slug: str, *, rfc_id: str,
|
||||
owners: list[str], arbiters: list[str]) -> GraduationState:
|
||||
state = GraduationState(
|
||||
slug=slug, rfc_id=rfc_id, repo_name=repo_name, repo_full=repo_full,
|
||||
slug=slug, rfc_id=rfc_id,
|
||||
owners=owners, arbiters=arbiters,
|
||||
steps=[StepState(key=k, label=STEP_LABELS[k]) for k in STEP_KEYS],
|
||||
)
|
||||
@@ -138,17 +129,9 @@ def _new_active(slug: str, *, rfc_id: str, repo_name: str, repo_full: str,
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# §13.2: Gitea repo name pattern. Gitea accepts alphanumerics, dashes,
|
||||
# dots, and underscores; cannot start with a dot. 100-char cap as a sane
|
||||
# upper bound — the spec doesn't pin a max but Gitea's enforcement does.
|
||||
_REPO_NAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,99}$")
|
||||
_RFC_ID_RE = re.compile(r"^RFC-\d{4,}$")
|
||||
|
||||
|
||||
def _is_valid_repo_name(name: str) -> bool:
|
||||
return bool(_REPO_NAME_RE.match(name)) and ".." not in name
|
||||
|
||||
|
||||
def _is_valid_rfc_id(rfc_id: str) -> bool:
|
||||
return bool(_RFC_ID_RE.match(rfc_id))
|
||||
|
||||
@@ -167,13 +150,6 @@ def _suggest_next_rfc_id() -> str:
|
||||
return f"RFC-{nxt:04d}"
|
||||
|
||||
|
||||
def _suggest_repo_name(slug: str, rfc_id: str) -> str:
|
||||
# rfc-NNNN-<slug> per §13.2's default. Strip the 'RFC-' prefix and
|
||||
# lowercase the number-pad.
|
||||
num = rfc_id.split("-", 1)[1] if "-" in rfc_id else "0001"
|
||||
return f"rfc-{num}-{slug}"
|
||||
|
||||
|
||||
def _rfc_id_taken(rfc_id: str, *, excluding_slug: str) -> bool:
|
||||
row = db.conn().execute(
|
||||
"SELECT slug FROM cached_rfcs WHERE rfc_id = ? AND slug != ?",
|
||||
@@ -189,7 +165,6 @@ def _rfc_id_taken(rfc_id: str, *, excluding_slug: str) -> bool:
|
||||
|
||||
class GraduateBody(BaseModel):
|
||||
rfc_id: str = Field(min_length=5, max_length=40)
|
||||
repo_name: str = Field(min_length=1, max_length=100)
|
||||
owners: list[str] = Field(min_length=1)
|
||||
|
||||
|
||||
@@ -206,19 +181,17 @@ def make_router(
|
||||
router = APIRouter()
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# §13.2: GET /api/rfcs/<slug>/blocking-prs
|
||||
# Lists open meta-repo PRs against rfcs/<slug>.md per the precondition
|
||||
# popover. Returns PR number, title, author, last-activity timestamp,
|
||||
# and the viewer's available actions (merge, withdraw, open-in-new-tab).
|
||||
# GET /api/rfcs/<slug>/blocking-prs
|
||||
# Lists open meta-repo body-edit PRs against rfcs/<slug>.md. Under the
|
||||
# meta-only topology (§9.8) these no longer block graduation — the body
|
||||
# is kept, so a body-edit PR coexists with the flip. Retained as an
|
||||
# informational surface (the dialog can show "N body-edit PRs open").
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
@router.get("/api/rfcs/{slug}/blocking-prs")
|
||||
async def list_blocking_prs(slug: str, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.current_user(request)
|
||||
rfc = _require_super_draft(slug)
|
||||
# §13's opening paragraph: only body-edit PRs block graduation.
|
||||
# Bare edit branches without an open PR do not block. The query
|
||||
# filters cached_prs to open meta_body_edit kinds for this slug.
|
||||
rows = db.conn().execute(
|
||||
"""
|
||||
SELECT pr_number, title, opened_by, opened_at, head_branch, pr_kind
|
||||
@@ -261,14 +234,15 @@ def make_router(
|
||||
"open_in_new_tab": True,
|
||||
},
|
||||
})
|
||||
return {"items": items}
|
||||
# `blocking` is a legacy field name kept for client compatibility;
|
||||
# under §9.8 these PRs do not block graduation.
|
||||
return {"items": items, "blocking": False}
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# §13.2: GET /api/rfcs/<slug>/graduate/check?id=&repo=
|
||||
# GET /api/rfcs/<slug>/graduate/check?id=
|
||||
# Inline validation for the Graduate dialog — debounced from the
|
||||
# client; the dialog calls this as the admin types. Returns per-field
|
||||
# collision/validity from the catalog cache plus a server-authoritative
|
||||
# repo-name collision check.
|
||||
# client. Two fields under meta-only: the integer ID and the owners
|
||||
# precondition. There is no repo name to validate (§13.2).
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
@router.get("/api/rfcs/{slug}/graduate/check")
|
||||
@@ -281,16 +255,7 @@ def make_router(
|
||||
# admins/owners, but the check itself is read-only.
|
||||
|
||||
candidate_id = (request.query_params.get("id") or "").strip()
|
||||
candidate_repo = (request.query_params.get("repo") or "").strip()
|
||||
|
||||
owners = json.loads(rfc["owners_json"] or "[]")
|
||||
blocking_count = db.conn().execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS n FROM cached_prs
|
||||
WHERE rfc_slug = ? AND state = 'open' AND pr_kind = 'meta_body_edit'
|
||||
""",
|
||||
(slug,),
|
||||
).fetchone()["n"]
|
||||
|
||||
# ID field
|
||||
id_payload: dict[str, Any] = {"value": candidate_id, "ok": True, "error": None}
|
||||
@@ -304,34 +269,6 @@ def make_router(
|
||||
id_payload["ok"] = False
|
||||
id_payload["error"] = f"Integer ID {candidate_id} is already taken"
|
||||
|
||||
# Repo field — validate pattern then probe Gitea for an existing
|
||||
# repo of that name under our org. The repo lookup is a single GET
|
||||
# so it's cheap to call on every keystroke (debounced from the
|
||||
# client per §13.2).
|
||||
repo_payload: dict[str, Any] = {"value": candidate_repo, "ok": True, "error": None}
|
||||
if not candidate_repo:
|
||||
repo_payload["ok"] = False
|
||||
repo_payload["error"] = "Repo name is required"
|
||||
elif not _is_valid_repo_name(candidate_repo):
|
||||
repo_payload["ok"] = False
|
||||
repo_payload["error"] = (
|
||||
"Repo name must be alphanumerics, dashes, dots, or underscores "
|
||||
"(start with alphanumeric)"
|
||||
)
|
||||
else:
|
||||
try:
|
||||
existing = await gitea.get_repo(config.gitea_org, candidate_repo)
|
||||
except GiteaError as e:
|
||||
# Network/auth flake — surface as a non-fatal hint; the
|
||||
# atomic server-side check at POST time is the authority.
|
||||
existing = None
|
||||
log.warning("graduate_check: Gitea get_repo error: %s", e)
|
||||
if existing is not None:
|
||||
repo_payload["ok"] = False
|
||||
repo_payload["error"] = (
|
||||
f"Repo `{config.gitea_org}/{candidate_repo}` already exists"
|
||||
)
|
||||
|
||||
# Owners precondition — §13's opening paragraph.
|
||||
owners_payload: dict[str, Any] = {
|
||||
"ok": len(owners) > 0,
|
||||
@@ -340,28 +277,13 @@ def make_router(
|
||||
"error": None if len(owners) > 0 else "No owners claimed yet",
|
||||
}
|
||||
|
||||
# Blocking PR precondition — §9.8 / §13's opening paragraph.
|
||||
prs_payload: dict[str, Any] = {
|
||||
"ok": blocking_count == 0,
|
||||
"count": blocking_count,
|
||||
"error": (
|
||||
None if blocking_count == 0
|
||||
else f"{blocking_count} open body-edit PR{'' if blocking_count == 1 else 's'} blocking graduation"
|
||||
),
|
||||
}
|
||||
|
||||
in_flight = _get_active(slug)
|
||||
any_invalid = not (
|
||||
id_payload["ok"] and repo_payload["ok"]
|
||||
and owners_payload["ok"] and prs_payload["ok"]
|
||||
)
|
||||
any_invalid = not (id_payload["ok"] and owners_payload["ok"])
|
||||
|
||||
return {
|
||||
"slug": slug,
|
||||
"id": id_payload,
|
||||
"repo": repo_payload,
|
||||
"owners": owners_payload,
|
||||
"blocking_prs": prs_payload,
|
||||
"can_submit": (not any_invalid) and (in_flight is None or in_flight.finished),
|
||||
"in_flight": (
|
||||
None if in_flight is None
|
||||
@@ -370,8 +292,8 @@ def make_router(
|
||||
}
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# §13.3: POST /api/rfcs/<slug>/graduate
|
||||
# Atomic re-validation, then kicks off the sequence as an async task.
|
||||
# POST /api/rfcs/<slug>/graduate
|
||||
# Atomic re-validation, then kicks off the flip as an async task.
|
||||
# The client opens GET /graduate/progress on confirm to watch the SSE.
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
@@ -392,9 +314,8 @@ def make_router(
|
||||
|
||||
# §13.2 atomic re-validation. The dialog's debounced check runs
|
||||
# client-side as the admin types; this is the authoritative check
|
||||
# that closes the dialog-open-to-confirm race.
|
||||
# that closes the dialog-open-to-confirm race on the integer ID.
|
||||
rfc_id = body.rfc_id.strip()
|
||||
repo_name = body.repo_name.strip()
|
||||
owners = [o.strip() for o in body.owners if o.strip()]
|
||||
if not owners:
|
||||
raise HTTPException(422, "Add at least one initial owner")
|
||||
@@ -402,35 +323,10 @@ def make_router(
|
||||
raise HTTPException(422, "ID must look like RFC-NNNN (at least four digits)")
|
||||
if _rfc_id_taken(rfc_id, excluding_slug=slug):
|
||||
raise HTTPException(409, f"Integer ID {rfc_id} is already taken")
|
||||
if not _is_valid_repo_name(repo_name):
|
||||
raise HTTPException(422, "Repo name must be alphanumerics, dashes, dots, or underscores")
|
||||
try:
|
||||
existing_repo = await gitea.get_repo(config.gitea_org, repo_name)
|
||||
except GiteaError as e:
|
||||
raise HTTPException(502, f"Gitea: {e.detail}")
|
||||
if existing_repo is not None:
|
||||
raise HTTPException(409, f"Repo `{config.gitea_org}/{repo_name}` already exists")
|
||||
|
||||
# §9.8 precondition gate — enforced before the bot starts the
|
||||
# sequence so the §13.3 rollback complexity does not grow. An
|
||||
# open body-edit PR against rfcs/<slug>.md would attempt to
|
||||
# re-introduce a body to a frontmatter-only entry after step 3.
|
||||
blocking = db.conn().execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS n FROM cached_prs
|
||||
WHERE rfc_slug = ? AND state = 'open' AND pr_kind = 'meta_body_edit'
|
||||
""",
|
||||
(slug,),
|
||||
).fetchone()["n"]
|
||||
if blocking > 0:
|
||||
raise HTTPException(
|
||||
409,
|
||||
f"{blocking} open body-edit PR{'' if blocking == 1 else 's'} block graduation",
|
||||
)
|
||||
|
||||
# Read the meta-repo entry once — we need the file's sha for the
|
||||
# graduation PR's update_file call and the original body so the
|
||||
# bot can seed RFC.md on the new repo with the migrated body.
|
||||
# graduation PR's update_file call and the body to carry through
|
||||
# unchanged (meta-only keeps the body in the entry, §13.3).
|
||||
fetched = await gitea.read_file(
|
||||
config.gitea_org, config.meta_repo, f"rfcs/{slug}.md", ref="main",
|
||||
)
|
||||
@@ -442,19 +338,17 @@ def make_router(
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Meta entry malformed: {e}")
|
||||
|
||||
repo_full = f"{config.gitea_org}/{repo_name}"
|
||||
arbiters = json.loads(rfc["arbiters_json"] or "[]") or owners[:1]
|
||||
|
||||
# Compose the graduated frontmatter — body stripped, graduation
|
||||
# fields filled. The serializer is run now so the PR-open step
|
||||
# has the contents pre-rendered (single source of truth for the
|
||||
# body migration vs. the meta-entry update).
|
||||
# Compose the graduated frontmatter — body KEPT, graduation fields
|
||||
# filled, repo left null (§1). Serialized now so the PR-open step
|
||||
# has the contents pre-rendered.
|
||||
graduated_entry = entry_mod.Entry(
|
||||
slug=slug,
|
||||
title=super_draft_entry.title,
|
||||
state="active",
|
||||
id=rfc_id,
|
||||
repo=repo_full,
|
||||
repo=None,
|
||||
proposed_by=super_draft_entry.proposed_by,
|
||||
proposed_at=super_draft_entry.proposed_at,
|
||||
graduated_at=entry_mod.today(),
|
||||
@@ -462,37 +356,30 @@ def make_router(
|
||||
owners=owners,
|
||||
arbiters=arbiters,
|
||||
tags=list(super_draft_entry.tags),
|
||||
body="",
|
||||
models=super_draft_entry.models,
|
||||
funder=super_draft_entry.funder,
|
||||
body=super_draft_entry.body,
|
||||
)
|
||||
graduated_contents = entry_mod.serialize(graduated_entry)
|
||||
|
||||
state = _new_active(
|
||||
slug, rfc_id=rfc_id, repo_name=repo_name, repo_full=repo_full,
|
||||
owners=owners, arbiters=arbiters,
|
||||
slug, rfc_id=rfc_id, owners=owners, arbiters=arbiters,
|
||||
)
|
||||
|
||||
# Audit: graduation started. The terminal `graduate_complete` /
|
||||
# `graduate_rollback` rows below close the linkable sequence.
|
||||
# `graduate_failed` rows below close the linkable sequence.
|
||||
_audit(
|
||||
viewer.user_id, viewer.gitea_login, "graduate_start",
|
||||
rfc_slug=slug,
|
||||
details={
|
||||
"rfc_id": rfc_id, "repo": repo_full, "owners": owners,
|
||||
"blocking_prs": blocking,
|
||||
},
|
||||
details={"rfc_id": rfc_id, "owners": owners},
|
||||
)
|
||||
|
||||
# Test seam: `?_sync=1` awaits the orchestrator inline so
|
||||
# integration tests can assert post-conditions without driving
|
||||
# the SSE. Production clients use the spec-described shape —
|
||||
# POST returns immediately, the client subscribes to the
|
||||
# progress SSE.
|
||||
# the SSE. Production clients POST then subscribe to the SSE.
|
||||
coro = _orchestrate(
|
||||
config=config, gitea=gitea, bot=bot,
|
||||
actor=viewer.as_actor(), state=state,
|
||||
super_draft_body=super_draft_entry.body,
|
||||
super_draft_title=super_draft_entry.title,
|
||||
super_draft_tags=list(super_draft_entry.tags),
|
||||
graduated_contents=graduated_contents,
|
||||
meta_file_sha=meta_sha,
|
||||
)
|
||||
@@ -505,29 +392,31 @@ def make_router(
|
||||
"ok": True,
|
||||
"slug": slug,
|
||||
"rfc_id": rfc_id,
|
||||
"repo": repo_full,
|
||||
"stream_url": f"/api/rfcs/{slug}/graduate/progress",
|
||||
"finished": state.finished,
|
||||
"succeeded": state.succeeded,
|
||||
}
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# §13.3: GET /api/rfcs/<slug>/graduate/progress
|
||||
# SSE stream of the step transitions. One event per step transition
|
||||
# (pending → running → done / failed), plus the trailing rollback
|
||||
# step's events if any earlier step fails.
|
||||
# GET /api/rfcs/<slug>/graduate/progress
|
||||
# SSE stream of the flip's step transitions (open_pr, merge_pr).
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
@router.get("/api/rfcs/{slug}/graduate/progress")
|
||||
async def graduate_progress(slug: str, request: Request):
|
||||
del request
|
||||
# The progress SSE surfaces admin-internal step detail (PR number)
|
||||
# that isn't part of the anonymous-read contract. POST /graduate is
|
||||
# gated to RFC owners/arbiters and app admins/owners; the read SSE
|
||||
# shares that operator-visible surface and requires an authenticated
|
||||
# viewer. We keep the floor at require_user (not require_contributor)
|
||||
# so a write-muted operator can still observe a graduation they
|
||||
# kicked off before being muted.
|
||||
auth.require_user(request)
|
||||
state = _get_active(slug)
|
||||
if state is None:
|
||||
raise HTTPException(404, "No graduation in flight for this slug")
|
||||
|
||||
async def event_stream():
|
||||
# Emit the current snapshot first so a late subscriber sees
|
||||
# the steps already completed.
|
||||
yield _sse_event("snapshot", state.to_payload())
|
||||
if state.finished:
|
||||
yield _sse_event("done", state.to_payload())
|
||||
@@ -546,22 +435,16 @@ def make_router(
|
||||
# §13.1: POST /api/rfcs/<slug>/claim
|
||||
# Opens a meta-repo PR adding the actor's gitea_login to the entry's
|
||||
# owners list. Anyone signed in may claim — the merge is gated to
|
||||
# owners/admins per §13.1 (which collapses to admins for unclaimed
|
||||
# entries since `owners` is empty).
|
||||
# owners/admins per §13.1.
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
@router.post("/api/rfcs/{slug}/claim")
|
||||
async def claim_ownership(slug: str, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
rfc = _require_super_draft(slug)
|
||||
# Refuse if the actor is already in owners — no-op claim.
|
||||
existing_owners = json.loads(rfc["owners_json"] or "[]")
|
||||
if viewer.gitea_login in existing_owners:
|
||||
return {"ok": True, "noop": True}
|
||||
# Refuse if a claim PR for this actor is already open. The branch
|
||||
# name `claim/<slug>` collides per actor implicitly since Gitea
|
||||
# refuses duplicate branch creation; we surface a clean 409 here
|
||||
# so the client doesn't see a 502.
|
||||
already = db.conn().execute(
|
||||
"""
|
||||
SELECT pr_number FROM cached_prs
|
||||
@@ -572,8 +455,6 @@ def make_router(
|
||||
if already:
|
||||
raise HTTPException(409, f"A claim PR is already open: #{already['pr_number']}")
|
||||
|
||||
# Compose the new entry contents — owners list with the claimant
|
||||
# appended.
|
||||
fetched = await gitea.read_file(
|
||||
config.gitea_org, config.meta_repo, f"rfcs/{slug}.md", ref="main",
|
||||
)
|
||||
@@ -617,7 +498,7 @@ def make_router(
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Orchestrator
|
||||
# Orchestrator — the §13.3 in-place flip
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -628,57 +509,21 @@ async def _orchestrate(
|
||||
bot: Bot,
|
||||
actor: Actor,
|
||||
state: GraduationState,
|
||||
super_draft_body: str,
|
||||
super_draft_title: str,
|
||||
super_draft_tags: list[str],
|
||||
graduated_contents: str,
|
||||
meta_file_sha: str,
|
||||
) -> None:
|
||||
"""Run §13.3 step by step. Each step:
|
||||
"""Open the flip PR, then merge it. Two steps, no transaction:
|
||||
|
||||
- marks itself `running` and pushes an event
|
||||
- calls the bot method (which writes to Gitea + audit log)
|
||||
- marks itself `done` (or `failed`) and pushes another event
|
||||
- open_pr fails → nothing was created; the entry stays a super-draft.
|
||||
- merge_pr fails → close the open PR and delete its branch (the only
|
||||
artifact a mid-flip failure can leave on the meta repo), then the
|
||||
entry stays a super-draft.
|
||||
|
||||
On failure at step N, every later step is marked `not-reached` and
|
||||
`_rollback` runs undoes in reverse from N-1 to 1.
|
||||
There is no rollback of a *merged* flip — once the meta-repo merge has
|
||||
landed, the path forward is §3's `withdraw` (§13.5).
|
||||
"""
|
||||
try:
|
||||
# ----- Step 1: create per-RFC repo -----
|
||||
await _start(state, "create_repo", f"Creating `{state.repo_full}`…")
|
||||
try:
|
||||
await bot.create_rfc_repo_for_graduation(
|
||||
actor, org=config.gitea_org, repo_name=state.repo_name,
|
||||
slug=state.slug, title=super_draft_title,
|
||||
)
|
||||
except GiteaError as e:
|
||||
await _fail(state, "create_repo", f"Gitea: {e.detail}")
|
||||
await _rollback(config=config, gitea=gitea, bot=bot, actor=actor,
|
||||
state=state, failed_at="create_repo")
|
||||
return
|
||||
await _done(state, "create_repo", state.repo_full)
|
||||
|
||||
# ----- Step 2: seed RFC.md, README.md, .rfc/metadata.yaml -----
|
||||
await _start(state, "seed_files", "Writing initial commit on main…")
|
||||
try:
|
||||
await bot.seed_graduated_rfc(
|
||||
actor,
|
||||
org=config.gitea_org, repo_name=state.repo_name,
|
||||
slug=state.slug, title=super_draft_title,
|
||||
rfc_body=super_draft_body, rfc_id=state.rfc_id,
|
||||
meta_full=config.meta_repo_full,
|
||||
meta_path=f"rfcs/{state.slug}.md",
|
||||
owners=state.owners, arbiters=state.arbiters,
|
||||
tags=super_draft_tags,
|
||||
)
|
||||
except GiteaError as e:
|
||||
await _fail(state, "seed_files", f"Gitea: {e.detail}")
|
||||
await _rollback(config=config, gitea=gitea, bot=bot, actor=actor,
|
||||
state=state, failed_at="seed_files")
|
||||
return
|
||||
await _done(state, "seed_files", "RFC.md, README.md, .rfc/metadata.yaml")
|
||||
|
||||
# ----- Step 3: open graduation PR -----
|
||||
# ----- Step 1: open the graduation PR (flip frontmatter) -----
|
||||
await _start(state, "open_pr", "Opening graduation PR…")
|
||||
try:
|
||||
pr = await bot.open_graduation_pr(
|
||||
@@ -687,19 +532,18 @@ async def _orchestrate(
|
||||
slug=state.slug,
|
||||
new_file_contents=graduated_contents,
|
||||
prior_sha=meta_file_sha,
|
||||
rfc_id=state.rfc_id, repo_full=state.repo_full,
|
||||
rfc_id=state.rfc_id,
|
||||
owners=state.owners,
|
||||
)
|
||||
except GiteaError as e:
|
||||
await _fail(state, "open_pr", f"Gitea: {e.detail}")
|
||||
await _rollback(config=config, gitea=gitea, bot=bot, actor=actor,
|
||||
state=state, failed_at="open_pr")
|
||||
await _finish_failed(state, failed_at="open_pr", on_behalf_of=actor.gitea_login)
|
||||
return
|
||||
state.new_pr_number = pr["number"]
|
||||
state.graduation_branch = pr["head"]["ref"]
|
||||
await _done(state, "open_pr", f"PR #{state.new_pr_number}")
|
||||
|
||||
# ----- Step 4: merge the graduation PR -----
|
||||
# ----- Step 2: merge the graduation PR -----
|
||||
await _start(state, "merge_pr", f"Merging PR #{state.new_pr_number}…")
|
||||
try:
|
||||
await bot.merge_graduation_pr(
|
||||
@@ -711,35 +555,28 @@ async def _orchestrate(
|
||||
)
|
||||
except GiteaError as e:
|
||||
await _fail(state, "merge_pr", f"Gitea: {e.detail}")
|
||||
await _rollback(config=config, gitea=gitea, bot=bot, actor=actor,
|
||||
state=state, failed_at="merge_pr")
|
||||
await _cleanup_unmerged(config=config, bot=bot, actor=actor, state=state)
|
||||
await _finish_failed(state, failed_at="merge_pr", on_behalf_of=actor.gitea_login)
|
||||
return
|
||||
await _done(state, "merge_pr", f"PR #{state.new_pr_number} merged")
|
||||
|
||||
# ----- Step 5: refresh the cache so the catalog flips immediately.
|
||||
# Per §13.3 step 5 the webhook flow is the steady-state path, but
|
||||
# we refresh inline so the dialog can transition to "graduation
|
||||
# complete" with the catalog row already showing `active`. A
|
||||
# cache-refresh failure does not unwind Git state — the
|
||||
# reconciler will catch up per §4.1.
|
||||
await _start(state, "refresh_cache", "Refreshing catalog and views…")
|
||||
# Refresh the cache so the catalog flips immediately. The webhook
|
||||
# flow is the steady-state path (§13.3); we refresh inline so the
|
||||
# dialog can transition to "graduation complete" with the catalog
|
||||
# row already showing `active`. A refresh failure does not unwind
|
||||
# the merge — the reconciler catches up per §4.1.
|
||||
try:
|
||||
await cache.refresh_meta_repo(config, gitea)
|
||||
await cache.refresh_meta_branches(config, gitea)
|
||||
await cache.refresh_meta_pulls(config, gitea)
|
||||
await cache.refresh_rfc_repo(config, gitea, state.slug)
|
||||
except Exception as e:
|
||||
log.warning("graduate refresh_cache failed for %s: %s", state.slug, e)
|
||||
await _done(state, "refresh_cache", f"Cache will catch up via reconciler ({e})")
|
||||
else:
|
||||
await _done(state, "refresh_cache", "Catalog and main view updated")
|
||||
log.warning("graduate cache refresh failed for %s: %s", state.slug, e)
|
||||
|
||||
# Terminal success row in the audit log.
|
||||
_audit(
|
||||
None, actor.gitea_login, "graduate_complete",
|
||||
rfc_slug=state.slug,
|
||||
details={
|
||||
"rfc_id": state.rfc_id, "repo": state.repo_full,
|
||||
"rfc_id": state.rfc_id,
|
||||
"owners": state.owners, "pr_number": state.new_pr_number,
|
||||
},
|
||||
)
|
||||
@@ -748,109 +585,38 @@ async def _orchestrate(
|
||||
await state.queue.put({"event": "completed", "payload": state.to_payload()})
|
||||
except Exception as e:
|
||||
log.exception("graduate: unexpected error for %s", state.slug)
|
||||
# Best-effort: mark the in-flight step failed, then roll back.
|
||||
running = next((s for s in state.steps if s.status == "running"), None)
|
||||
if running is not None:
|
||||
await _fail(state, running.key, f"unexpected: {e}")
|
||||
await _rollback(
|
||||
config=config, gitea=gitea, bot=bot, actor=actor,
|
||||
state=state, failed_at=running.key if running else "unknown",
|
||||
await _finish_failed(
|
||||
state, failed_at=running.key if running else "unknown",
|
||||
on_behalf_of=actor.gitea_login,
|
||||
)
|
||||
finally:
|
||||
# Push the sentinel so any open SSE handler returns.
|
||||
await state.queue.put(None)
|
||||
|
||||
|
||||
async def _rollback(
|
||||
*,
|
||||
config: Config, gitea: Gitea, bot: Bot, actor: Actor,
|
||||
state: GraduationState, failed_at: str,
|
||||
async def _cleanup_unmerged(
|
||||
*, config: Config, bot: Bot, actor: Actor, state: GraduationState,
|
||||
) -> None:
|
||||
"""Run undoes in reverse order from the last completed step. Each
|
||||
undo emits its own rollback-step event so the dialog can render the
|
||||
cleanup as a visible step appended to the stack per §13.3."""
|
||||
state.rollback_started = True
|
||||
# Mark every step after the failed one as not-reached so the rendered
|
||||
# stack is honest about what didn't run.
|
||||
seen_failure = False
|
||||
for s in state.steps:
|
||||
if s.status == "failed":
|
||||
seen_failure = True
|
||||
continue
|
||||
if seen_failure and s.status == "pending":
|
||||
s.status = "not-reached"
|
||||
|
||||
# Walk completed steps in reverse and run their inverses.
|
||||
for s in reversed(state.steps):
|
||||
if s.status != "done":
|
||||
continue
|
||||
undo = _UNDO_BY_STEP.get(s.key)
|
||||
if undo is None:
|
||||
continue
|
||||
rb = StepState(key=f"undo:{s.key}", label=f"Undo: {s.label}",
|
||||
status="running", detail="")
|
||||
state.rollback_steps.append(rb)
|
||||
await state.queue.put({"event": "rollback_step", "payload": state.to_payload()})
|
||||
try:
|
||||
detail = await undo(
|
||||
config=config, gitea=gitea, bot=bot, actor=actor, state=state,
|
||||
)
|
||||
except Exception as e:
|
||||
rb.status = "failed"
|
||||
rb.detail = f"{e}"
|
||||
await state.queue.put({"event": "rollback_step", "payload": state.to_payload()})
|
||||
continue
|
||||
rb.status = "done"
|
||||
rb.detail = detail or ""
|
||||
await state.queue.put({"event": "rollback_step", "payload": state.to_payload()})
|
||||
|
||||
_audit(
|
||||
None, actor.gitea_login, "graduate_rollback",
|
||||
rfc_slug=state.slug,
|
||||
details={
|
||||
"failed_at": failed_at,
|
||||
"error": state.error,
|
||||
"rfc_id": state.rfc_id,
|
||||
"repo": state.repo_full,
|
||||
"undone": [s.key for s in state.rollback_steps if s.status == "done"],
|
||||
},
|
||||
)
|
||||
state.finished = True
|
||||
state.succeeded = False
|
||||
await state.queue.put({"event": "rolled_back", "payload": state.to_payload()})
|
||||
|
||||
|
||||
async def _undo_create_repo(*, config, gitea, bot, actor, state) -> str:
|
||||
await bot.delete_rfc_repo(
|
||||
actor, org=config.gitea_org, repo_name=state.repo_name,
|
||||
slug=state.slug, reason="graduation rollback",
|
||||
)
|
||||
return f"Deleted `{state.repo_full}`"
|
||||
|
||||
|
||||
async def _undo_seed_files(*, config, gitea, bot, actor, state) -> str:
|
||||
# The seed commits live inside the per-RFC repo created in step 1;
|
||||
# deleting the repo (step 1's undo) reclaims them at the same time.
|
||||
# We surface a separate rollback step here so the rendered stack
|
||||
# mirrors the forward steps, but the work is folded into _undo_create_repo.
|
||||
return "Folded into repo deletion"
|
||||
|
||||
|
||||
async def _undo_open_pr(*, config, gitea, bot, actor, state) -> str:
|
||||
"""A merge failure leaves the flip PR open on its `graduate-<slug>-<hex>`
|
||||
branch. Close the PR and delete the branch so failed attempts don't
|
||||
accumulate on the meta repo. Best-effort — failures here are logged,
|
||||
not surfaced as a separate step (the entry already stays a super-draft).
|
||||
"""
|
||||
if state.new_pr_number is None:
|
||||
return "No PR opened"
|
||||
await bot.close_graduation_pr(
|
||||
actor,
|
||||
org=config.gitea_org, meta_repo=config.meta_repo,
|
||||
pr_number=state.new_pr_number,
|
||||
head_branch=state.graduation_branch or "",
|
||||
slug=state.slug, reason="graduation rollback",
|
||||
)
|
||||
# Per the §19.2 "graduation rollback's branch cleanup" candidate
|
||||
# that Slice 8 settles: delete the dash-suffixed branch on rollback
|
||||
# so failed-graduation branches don't accumulate on the meta repo.
|
||||
# The §12 hygiene sweep would catch this eventually, but closing
|
||||
# the loop here removes the chance of pile-up across retries.
|
||||
return
|
||||
try:
|
||||
await bot.close_graduation_pr(
|
||||
actor,
|
||||
org=config.gitea_org, meta_repo=config.meta_repo,
|
||||
pr_number=state.new_pr_number,
|
||||
head_branch=state.graduation_branch or "",
|
||||
slug=state.slug, reason="graduation merge failed",
|
||||
)
|
||||
except Exception:
|
||||
log.exception("graduate cleanup: close PR #%s failed", state.new_pr_number)
|
||||
branch_name = state.graduation_branch or ""
|
||||
if branch_name:
|
||||
try:
|
||||
@@ -861,24 +627,35 @@ async def _undo_open_pr(*, config, gitea, bot, actor, state) -> str:
|
||||
branch=branch_name,
|
||||
slug=state.slug,
|
||||
action_kind="delete_post_merge_branch",
|
||||
reason="graduation rollback",
|
||||
reason="graduation merge failed",
|
||||
)
|
||||
except Exception:
|
||||
log.exception("rollback: delete_branch failed for %s", branch_name)
|
||||
return f"Closed PR #{state.new_pr_number}"
|
||||
log.exception("graduate cleanup: delete_branch %s failed", branch_name)
|
||||
|
||||
|
||||
# merge_pr's undo is intentionally absent — once the meta-repo merge has
|
||||
# landed, graduation is irreversible per §13.5. If we ever reach a merged
|
||||
# state and a later step fails (which can't happen — refresh_cache failures
|
||||
# fold into success), there is no clean undo path; the user transitions
|
||||
# via §3's `withdraw` instead.
|
||||
|
||||
_UNDO_BY_STEP = {
|
||||
"create_repo": _undo_create_repo,
|
||||
"seed_files": _undo_seed_files,
|
||||
"open_pr": _undo_open_pr,
|
||||
}
|
||||
async def _finish_failed(state: GraduationState, *, failed_at: str, on_behalf_of: str) -> None:
|
||||
"""Mark any step after the failure as not-reached, write the audit
|
||||
row, and emit the terminal failed event."""
|
||||
seen_failure = False
|
||||
for s in state.steps:
|
||||
if s.status == "failed":
|
||||
seen_failure = True
|
||||
continue
|
||||
if seen_failure and s.status == "pending":
|
||||
s.status = "not-reached"
|
||||
_audit(
|
||||
None, on_behalf_of, "graduate_failed",
|
||||
rfc_slug=state.slug,
|
||||
details={
|
||||
"failed_at": failed_at,
|
||||
"error": state.error,
|
||||
"rfc_id": state.rfc_id,
|
||||
"pr_number": state.new_pr_number,
|
||||
},
|
||||
)
|
||||
state.finished = True
|
||||
state.succeeded = False
|
||||
await state.queue.put({"event": "failed", "payload": state.to_payload()})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -898,7 +675,7 @@ def _can_graduate(rfc, viewer) -> bool:
|
||||
|
||||
def _audit(
|
||||
actor_user_id: int | None,
|
||||
on_behalf_of: str,
|
||||
on_behalf_of: str | None,
|
||||
action_kind: str,
|
||||
*,
|
||||
rfc_slug: str | None = None,
|
||||
@@ -909,7 +686,7 @@ def _audit(
|
||||
"""Direct audit-log write for graduation lifecycle events that don't
|
||||
correspond to a single Gitea write. The per-step Gitea writes log
|
||||
themselves via the bot's `_log`; this is for the bracketing
|
||||
`graduate_start` / `graduate_complete` / `graduate_rollback` rows."""
|
||||
`graduate_start` / `graduate_complete` / `graduate_failed` rows."""
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO actions
|
||||
@@ -926,8 +703,7 @@ def _audit(
|
||||
json.dumps(details) if details else None,
|
||||
),
|
||||
)
|
||||
# §15 chokepoint per Slice 6: the bracket rows (graduate_start,
|
||||
# graduate_complete) drive their own notifications per §15.1.
|
||||
# §15 chokepoint: the bracket rows drive their own notifications.
|
||||
from . import notify
|
||||
notify.fan_out_from_action(
|
||||
actor_user_id=actor_user_id,
|
||||
|
||||
@@ -0,0 +1,593 @@
|
||||
"""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",
|
||||
)
|
||||
|
||||
return issue_invitation(
|
||||
slug=slug,
|
||||
inviter_user_id=viewer.user_id,
|
||||
inviter_display=viewer.display_name or viewer.gitea_login or "An RFC owner",
|
||||
invitee_email=body.invitee_email,
|
||||
role_in_rfc=body.role_in_rfc,
|
||||
rfc_title=rfc["title"],
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 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 issue_invitation(
|
||||
*,
|
||||
slug: str,
|
||||
inviter_user_id: int,
|
||||
inviter_display: str,
|
||||
invitee_email: str,
|
||||
role_in_rfc: str,
|
||||
rfc_title: str,
|
||||
) -> dict:
|
||||
"""Mint + persist + email one ``rfc_invitations`` row.
|
||||
|
||||
The single chokepoint for issuing an invitation: the owner's manual
|
||||
`POST /api/rfcs/{slug}/invitations` endpoint and roadmap #28 Part 3's
|
||||
accept path both route through here, so the dup-guard, token mint,
|
||||
insert, and transactional email stay identical.
|
||||
|
||||
Refuses (409) re-inviting an email that already has a pending
|
||||
invitation on this RFC at the same role. A different-role re-invite is
|
||||
allowed (the discussant → contributor upgrade) — the new row
|
||||
supersedes the old in the listing's natural ordering, and acceptance
|
||||
of either picks up the corresponding role.
|
||||
|
||||
Returns the new row's dict (including the raw token, for the owner's
|
||||
out-of-band share / the caller's record-keeping). A send failure logs
|
||||
and returns; the row stays so the owner can recover via the listing.
|
||||
"""
|
||||
invitee_email = invitee_email.strip()
|
||||
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, inviter_user_id, invitee_email, role_in_rfc, token, f"+{INVITATION_TTL_DAYS} days"),
|
||||
)
|
||||
invitation_id = cur.lastrowid
|
||||
|
||||
_send_invitation_email(
|
||||
to_address=invitee_email,
|
||||
inviter_display=inviter_display,
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
@@ -14,6 +14,8 @@ The endpoints in this module are:
|
||||
- `POST /api/users/me/quiet-hours` — set / clear
|
||||
- `POST /api/users/<id>/notification-mute` — §15.8
|
||||
- `DELETE /api/users/<id>/notification-mute` — §15.8
|
||||
- `GET /api/users/me/cookie-consent` — §14.5
|
||||
- `PUT /api/users/me/cookie-consent` — §14.5
|
||||
- `GET /api/email/unsubscribe` — §15.4 one-click
|
||||
- `POST /api/webhooks/email-bounce` — §15.4 receiver
|
||||
|
||||
@@ -71,6 +73,22 @@ class MarkReadBody(BaseModel):
|
||||
class BounceBody(BaseModel):
|
||||
email: str = Field(min_length=3, max_length=320)
|
||||
kind: str = Field(default="hard") # 'hard' or 'complaint'
|
||||
# v0.18.0 Slice 5: when the bounce provider includes the
|
||||
# original Message-ID, the framework correlates it back to
|
||||
# the matching `outbound_emails` row and stamps
|
||||
# `status='bounced'`. Optional — providers that don't surface
|
||||
# the Message-ID still flip the global opt-out via the email
|
||||
# match, but lose the per-message attribution.
|
||||
message_id: str | None = Field(default=None, max_length=1000)
|
||||
|
||||
|
||||
class CookieConsentBody(BaseModel):
|
||||
# `essential` is always true at the surface; we accept it for symmetry
|
||||
# but never persist a false value (the framework's strictly-necessary
|
||||
# cookies are not user-optional per SPEC §14.5).
|
||||
essential: bool = True
|
||||
analytics: bool = False
|
||||
other: bool = False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -362,8 +380,110 @@ def make_router(config: Config) -> APIRouter:
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
# ----- Cookie consent (v0.13.0 / roadmap item #11; SPEC §14.5) -----
|
||||
#
|
||||
# The shape is intentionally small: three flags + a recorded-at stamp.
|
||||
# The banner's local-vs-server precedence rule lives in the frontend
|
||||
# (`consent.js`): on sign-in, the server row (if any) overrides local;
|
||||
# otherwise local is uploaded.
|
||||
|
||||
@router.get("/api/users/me/cookie-consent")
|
||||
async def get_cookie_consent(request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_user(request)
|
||||
row = db.conn().execute(
|
||||
"""
|
||||
SELECT essential, analytics, other_cookies, recorded_at
|
||||
FROM cookie_consent WHERE user_id = ?
|
||||
""",
|
||||
(viewer.user_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return {
|
||||
"essential": True,
|
||||
"analytics": False,
|
||||
"other": False,
|
||||
"recorded_at": None,
|
||||
}
|
||||
return {
|
||||
"essential": bool(row["essential"]),
|
||||
"analytics": bool(row["analytics"]),
|
||||
"other": bool(row["other_cookies"]),
|
||||
"recorded_at": row["recorded_at"],
|
||||
}
|
||||
|
||||
@router.put("/api/users/me/cookie-consent")
|
||||
async def set_cookie_consent(body: CookieConsentBody, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_user(request)
|
||||
# `essential` is the framework's strictly-necessary set; the
|
||||
# surface accepts the flag for symmetry but never persists a
|
||||
# false value. SPEC §14.5: a deployment that wants to make
|
||||
# session-cookie storage optional must change the framework
|
||||
# contract, not flip a flag here.
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO cookie_consent
|
||||
(user_id, essential, analytics, other_cookies, recorded_at)
|
||||
VALUES (?, 1, ?, ?, datetime('now'))
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
essential = 1,
|
||||
analytics = excluded.analytics,
|
||||
other_cookies = excluded.other_cookies,
|
||||
recorded_at = excluded.recorded_at
|
||||
""",
|
||||
(
|
||||
viewer.user_id,
|
||||
1 if body.analytics else 0,
|
||||
1 if body.other else 0,
|
||||
),
|
||||
)
|
||||
row = db.conn().execute(
|
||||
"SELECT recorded_at FROM cookie_consent WHERE user_id = ?",
|
||||
(viewer.user_id,),
|
||||
).fetchone()
|
||||
return {
|
||||
"ok": True,
|
||||
"essential": True,
|
||||
"analytics": bool(body.analytics),
|
||||
"other": bool(body.other),
|
||||
"recorded_at": row["recorded_at"] if row else None,
|
||||
}
|
||||
|
||||
# ----- Email: one-click unsubscribe + bounce webhook -----
|
||||
|
||||
# v0.18.0: the category → column map. The `all` synthetic
|
||||
# category lands the bundle's one-click on the global opt-out
|
||||
# flag (per `email._send_bundle` in v0.18.0 Slice 2 — a bundle
|
||||
# spans multiple categories, so a per-category flip wouldn't
|
||||
# honor the user's intent).
|
||||
_CATEGORY_COLUMN: dict[str, str] = {
|
||||
"personal-direct": "email_personal_direct",
|
||||
"structural": "email_watched_structural",
|
||||
"admin-actionable": "email_admin_actionable",
|
||||
"all": "email_opt_out_all",
|
||||
}
|
||||
|
||||
def _apply_unsubscribe(user_id: int, category: str) -> bool:
|
||||
"""Flip the matching column. Returns True on success, False
|
||||
if the category is unknown. Idempotent — running twice on
|
||||
the same (user, category) is harmless (it sets the column
|
||||
to its current value)."""
|
||||
column = _CATEGORY_COLUMN.get(category)
|
||||
if column is None:
|
||||
return False
|
||||
# `all` sets the flag to 1 (opt out); per-category sets to 0
|
||||
# (turn that category off). The column semantic is "1 means
|
||||
# don't send"; the per-category booleans are "1 means do
|
||||
# send". Different polarities, hence the case split.
|
||||
if category == "all":
|
||||
db.conn().execute(
|
||||
f"UPDATE users SET {column} = 1 WHERE id = ?", (user_id,)
|
||||
)
|
||||
else:
|
||||
db.conn().execute(
|
||||
f"UPDATE users SET {column} = 0 WHERE id = ?", (user_id,)
|
||||
)
|
||||
return True
|
||||
|
||||
@router.get("/api/email/unsubscribe")
|
||||
async def email_unsubscribe(t: str = Query(..., description="Signed token from the email footer")) -> HTMLResponse:
|
||||
try:
|
||||
@@ -374,20 +494,51 @@ def make_router(config: Config) -> APIRouter:
|
||||
"<p>Open the app to manage your notification preferences directly.</p>",
|
||||
status_code=400,
|
||||
)
|
||||
column = {
|
||||
"personal-direct": "email_personal_direct",
|
||||
"structural": "email_watched_structural",
|
||||
"admin-actionable": "email_admin_actionable",
|
||||
}.get(category)
|
||||
if column is None:
|
||||
if not _apply_unsubscribe(user_id, category):
|
||||
return HTMLResponse(
|
||||
f"<h1>Unknown category</h1><p>{category}</p>", status_code=400
|
||||
)
|
||||
db.conn().execute(f"UPDATE users SET {column} = 0 WHERE id = ?", (user_id,))
|
||||
return HTMLResponse(
|
||||
f"<h1>Unsubscribed</h1><p>You will no longer receive {category} emails. "
|
||||
f"You can re-enable them in your notification preferences.</p>"
|
||||
)
|
||||
if category == "all":
|
||||
body = (
|
||||
"<h1>Unsubscribed</h1><p>You will no longer receive any email "
|
||||
"from this app. You can re-enable individual categories from "
|
||||
"your notification preferences after signing in.</p>"
|
||||
)
|
||||
else:
|
||||
body = (
|
||||
f"<h1>Unsubscribed</h1><p>You will no longer receive {category} emails. "
|
||||
f"You can re-enable them in your notification preferences.</p>"
|
||||
)
|
||||
return HTMLResponse(body)
|
||||
|
||||
@router.post("/api/email/unsubscribe")
|
||||
async def email_unsubscribe_post(
|
||||
request: Request,
|
||||
t: str = Query(..., description="Signed token from the List-Unsubscribe header"),
|
||||
) -> dict[str, Any]:
|
||||
"""v0.18.0: RFC 8058 one-click endpoint.
|
||||
|
||||
Gmail and Yahoo POST `List-Unsubscribe=One-Click` (as a
|
||||
form-encoded body) to the URL in the `List-Unsubscribe`
|
||||
header when the user clicks their MUA's "Unsubscribe"
|
||||
button. The endpoint MUST accept POST (per the
|
||||
`List-Unsubscribe-Post` header we advertise) and MUST be
|
||||
idempotent.
|
||||
|
||||
The body content is checked loosely — RFC 8058 says it
|
||||
SHOULD be exactly `List-Unsubscribe=One-Click`, but some
|
||||
intermediaries strip / re-encode the body, so the
|
||||
framework accepts any POST to the URL once the token
|
||||
verifies. The bar is that the token signature carries the
|
||||
authority; the body is hint-only.
|
||||
"""
|
||||
try:
|
||||
user_id, category = email_mod.verify_unsubscribe_token(t)
|
||||
except BadSignature:
|
||||
raise HTTPException(400, "Invalid or expired token")
|
||||
if not _apply_unsubscribe(user_id, category):
|
||||
raise HTTPException(400, f"Unknown category: {category}")
|
||||
return {"ok": True, "category": category}
|
||||
|
||||
@router.post("/api/webhooks/email-bounce")
|
||||
async def email_bounce(body: BounceBody, request: Request) -> dict[str, Any]:
|
||||
@@ -406,21 +557,70 @@ def make_router(config: Config) -> APIRouter:
|
||||
# stays unauthenticated for dev (the v1 contract).
|
||||
import os as _os
|
||||
expected = _os.environ.get("WEBHOOK_EMAIL_BOUNCE_SECRET", "").strip()
|
||||
if expected:
|
||||
# v0.25.0 (audit 0026 M5): fail closed. An unset secret used to
|
||||
# leave this endpoint fully unauthenticated — anyone could suppress
|
||||
# any user's mail by POSTing their address (email_opt_out_all flip
|
||||
# below). Now an unset secret DISABLES the endpoint (503) instead
|
||||
# of opening it. A dev that genuinely wants it open opts in
|
||||
# explicitly with RFC_APP_INSECURE_BOUNCE_WEBHOOK=1, mirroring the
|
||||
# RFC_APP_INSECURE_WEBHOOKS dev-bypass on the Gitea hook.
|
||||
if not expected:
|
||||
if _os.environ.get("RFC_APP_INSECURE_BOUNCE_WEBHOOK", "").strip() == "1":
|
||||
log.warning(
|
||||
"email-bounce webhook running UNAUTHENTICATED "
|
||||
"(RFC_APP_INSECURE_BOUNCE_WEBHOOK=1) — never set this in production"
|
||||
)
|
||||
else:
|
||||
log.error(
|
||||
"email-bounce webhook refused: WEBHOOK_EMAIL_BOUNCE_SECRET is unset "
|
||||
"(set the secret to enable, or RFC_APP_INSECURE_BOUNCE_WEBHOOK=1 for dev)"
|
||||
)
|
||||
raise HTTPException(503, "Bounce webhook not configured")
|
||||
else:
|
||||
received = request.headers.get("X-Webhook-Secret", "")
|
||||
import hmac as _hmac
|
||||
if not received or not _hmac.compare_digest(expected, received):
|
||||
raise HTTPException(401, "Invalid webhook signature")
|
||||
# v0.18.0 Slice 5: correlate the bounce back to the
|
||||
# matching outbound_emails row if the provider supplied
|
||||
# the Message-ID. The hard-bounce -> global-opt-out
|
||||
# logic below still fires regardless; this is an
|
||||
# additional audit signal.
|
||||
correlated_row_id: int | None = None
|
||||
if body.message_id:
|
||||
correlated = db.conn().execute(
|
||||
"SELECT id FROM outbound_emails WHERE message_id = ?",
|
||||
(body.message_id,),
|
||||
).fetchone()
|
||||
if correlated is not None:
|
||||
correlated_row_id = correlated["id"]
|
||||
db.conn().execute(
|
||||
"UPDATE outbound_emails SET status = 'bounced', "
|
||||
"error = COALESCE(error, '') || ? WHERE id = ?",
|
||||
(f"bounce ({body.kind})", correlated_row_id),
|
||||
)
|
||||
log.info(
|
||||
"email-bounce: correlated message_id=%s -> outbound_emails.id=%s",
|
||||
body.message_id, correlated_row_id,
|
||||
)
|
||||
else:
|
||||
log.info(
|
||||
"email-bounce: message_id=%s did not match any "
|
||||
"outbound_emails row (provider may be replaying an old bounce, "
|
||||
"or the row was pruned)",
|
||||
body.message_id,
|
||||
)
|
||||
|
||||
row = db.conn().execute(
|
||||
"SELECT id FROM users WHERE LOWER(email) = LOWER(?)", (body.email,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return {"ok": True, "matched": False}
|
||||
return {"ok": True, "matched": False, "correlated_id": correlated_row_id}
|
||||
db.conn().execute(
|
||||
"UPDATE users SET email_opt_out_all = 1 WHERE id = ?", (row["id"],),
|
||||
)
|
||||
log.info("email-bounce: opted out user %s (%s)", row["id"], body.kind)
|
||||
return {"ok": True, "matched": True}
|
||||
return {"ok": True, "matched": True, "correlated_id": correlated_row_id}
|
||||
|
||||
return router
|
||||
|
||||
|
||||
+78
-12
@@ -23,7 +23,7 @@ from typing import Any
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from . import auth, cache, chat as chat_layer, db, entry as entry_mod, funder, models_resolver
|
||||
from . import auth, cache, chat as chat_layer, db, entry as entry_mod, funder, models_resolver, rfc_links
|
||||
from .bot import Bot
|
||||
from .config import Config
|
||||
from .gitea import Gitea, GiteaError
|
||||
@@ -42,6 +42,11 @@ RFC_FILE_PATH = "RFC.md"
|
||||
class OpenPRBody(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=240)
|
||||
description: str = Field(max_length=8000)
|
||||
# Roadmap #26: optional "What will you be using this change for?" —
|
||||
# the concrete ground-truth use case sibling to the required
|
||||
# "why is this change needed" (the `description`). Optional, generous
|
||||
# cap matching the description bound.
|
||||
proposed_use_case: str | None = Field(default=None, max_length=8000)
|
||||
|
||||
|
||||
class PRDescriptionBody(BaseModel):
|
||||
@@ -112,6 +117,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")
|
||||
@@ -162,6 +178,26 @@ def make_router(
|
||||
raise HTTPException(502, f"Gitea: {e.detail}")
|
||||
|
||||
await _refresh_after_pr_write(rfc)
|
||||
|
||||
# Roadmap #26: persist the optional use case to the canonical,
|
||||
# reconcile-proof side table keyed by the PR number. Blank/omitted
|
||||
# writes no row (absence == "left blank"). The mirror onto
|
||||
# cached_prs keeps the cache column in parity.
|
||||
use_case = (body.proposed_use_case or "").strip()
|
||||
if use_case:
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO proposed_use_cases (scope, rfc_slug, pr_number, use_case)
|
||||
VALUES ('pr', ?, ?, ?)
|
||||
ON CONFLICT(scope, pr_number) DO UPDATE SET use_case = excluded.use_case
|
||||
""",
|
||||
(slug, pr["number"], use_case),
|
||||
)
|
||||
db.conn().execute(
|
||||
"UPDATE cached_prs SET proposed_use_case = ? WHERE rfc_slug = ? AND pr_number = ?",
|
||||
(use_case, slug, pr["number"]),
|
||||
)
|
||||
|
||||
return {"pr_number": pr["number"], "slug": slug, "branch": branch}
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
@@ -177,6 +213,12 @@ def make_router(
|
||||
path = _file_path_for(rfc)
|
||||
head_branch = pr_row["head_branch"]
|
||||
|
||||
# Roadmap #28 Part 1: build the RFC auto-link index once for this
|
||||
# PR view (read-time enrichment against the live accepted-RFC
|
||||
# corpus; see rfc_links.py). exclude_slug suppresses self-links to
|
||||
# this RFC inside its own PR.
|
||||
link_index = rfc_links.build_index(db.conn(), exclude_slug=slug)
|
||||
|
||||
# §11.3: PRs are always public; no visibility check.
|
||||
main_fetched = await gitea.read_file(owner, repo, path, ref="main")
|
||||
main_body = _extract_body(rfc, (main_fetched or ("", ""))[0])
|
||||
@@ -223,6 +265,13 @@ def make_router(
|
||||
for r in msg_rows:
|
||||
messages_by_thread.setdefault(r["thread_id"], []).append(_serialize_message(r))
|
||||
|
||||
# Roadmap #28 Part 1: enrich every comment with RFC auto-link
|
||||
# segments (read-time; see rfc_links.py). The description is
|
||||
# enriched alongside it in the return dict below.
|
||||
for _msgs in messages_by_thread.values():
|
||||
for _m in _msgs:
|
||||
_m["text_segments"] = link_index.segment(_m["text"])
|
||||
|
||||
# Per-user seen cursor per §10.3. Anonymous viewers get no
|
||||
# cursor — they always see "everything new" but cannot advance
|
||||
# the cursor (no row to write to).
|
||||
@@ -289,6 +338,8 @@ def make_router(
|
||||
"pr_number": pr_number,
|
||||
"title": pr_row["title"],
|
||||
"description": pr_row["description"],
|
||||
"description_segments": link_index.segment(pr_row["description"]),
|
||||
"proposed_use_case": _pr_use_case(pr_number),
|
||||
"state": pr_row["state"],
|
||||
"opened_by": pr_row["opened_by"],
|
||||
"opened_at": pr_row["opened_at"],
|
||||
@@ -552,7 +603,7 @@ def make_router(
|
||||
repo=repo,
|
||||
slug=slug,
|
||||
file_path=_file_path_for(rfc),
|
||||
is_super_draft=_is_super_draft(rfc),
|
||||
is_super_draft=_is_meta_resident(rfc),
|
||||
original_branch=original_branch,
|
||||
resolution_branch=resolution_branch,
|
||||
)
|
||||
@@ -620,32 +671,36 @@ def make_router(
|
||||
"""Used by the §10 PR-flow read and write paths. Per §17's routing-
|
||||
collapse rule, a super-draft RFC also routes here — its body-edit
|
||||
PRs are meta-repo PRs with pr_kind='meta_body_edit', but the API
|
||||
surface is identical."""
|
||||
surface is identical. Under the meta-only topology (§1) an active
|
||||
RFC is meta-resident too (repo is null) — that is normal, not an
|
||||
error, so there is no per-RFC-repo precondition."""
|
||||
row = _require_rfc(slug)
|
||||
if row["state"] not in ("active", "super-draft"):
|
||||
raise HTTPException(409, f"RFC is {row['state']}")
|
||||
if row["state"] == "active" and not row["repo"]:
|
||||
raise HTTPException(409, "RFC has no repo")
|
||||
return row
|
||||
|
||||
def _is_super_draft(rfc) -> bool:
|
||||
return rfc["state"] == "super-draft"
|
||||
def _is_meta_resident(rfc) -> bool:
|
||||
"""Meta-only topology (§1): an entry lives in the meta repo's
|
||||
`rfcs/<slug>.md` (super-draft or active-in-place) unless it carries
|
||||
a legacy per-RFC `repo:` — which nothing does after the RFC-0001
|
||||
fold-back (§13.6). Drives the body/path/repo dispatch below."""
|
||||
return not rfc["repo"]
|
||||
|
||||
def _owner_repo(rfc) -> tuple[str, str]:
|
||||
if _is_super_draft(rfc):
|
||||
if _is_meta_resident(rfc):
|
||||
return config.gitea_org, config.meta_repo
|
||||
owner, repo = rfc["repo"].split("/", 1)
|
||||
return owner, repo
|
||||
|
||||
def _file_path_for(rfc) -> str:
|
||||
if _is_super_draft(rfc):
|
||||
if _is_meta_resident(rfc):
|
||||
return f"rfcs/{rfc['slug']}.md"
|
||||
return RFC_FILE_PATH
|
||||
|
||||
def _extract_body(rfc, file_contents: str) -> str:
|
||||
"""For super-draft entries the file on disk is the full
|
||||
"""For meta-resident entries the file on disk is the full
|
||||
frontmatter+body envelope; the editable body is entry.body."""
|
||||
if not _is_super_draft(rfc):
|
||||
if not _is_meta_resident(rfc):
|
||||
return file_contents
|
||||
try:
|
||||
entry = entry_mod.parse(file_contents)
|
||||
@@ -709,7 +764,7 @@ def make_router(
|
||||
return row["original_pr_number"] if row else None
|
||||
|
||||
async def _refresh_after_pr_write(rfc) -> None:
|
||||
if _is_super_draft(rfc):
|
||||
if _is_meta_resident(rfc):
|
||||
await cache.refresh_meta_repo(config, gitea)
|
||||
await cache.refresh_meta_branches(config, gitea)
|
||||
await cache.refresh_meta_pulls(config, gitea)
|
||||
@@ -751,6 +806,17 @@ def _can_edit_pr_text(rfc, pr_row, viewer) -> bool:
|
||||
return _can_withdraw(rfc, pr_row, viewer)
|
||||
|
||||
|
||||
def _pr_use_case(pr_number: int) -> str | None:
|
||||
"""Roadmap #26: the optional propose-PR use case from the canonical
|
||||
side table, or None when the change was opened without one ("left
|
||||
blank")."""
|
||||
row = db.conn().execute(
|
||||
"SELECT use_case FROM proposed_use_cases WHERE scope = 'pr' AND pr_number = ?",
|
||||
(pr_number,),
|
||||
).fetchone()
|
||||
return row["use_case"] if row else None
|
||||
|
||||
|
||||
def _pr_capabilities(rfc, pr_row, viewer) -> dict:
|
||||
return {
|
||||
"can_merge": _can_merge(rfc, viewer) and pr_row["state"] == "open",
|
||||
|
||||
+259
-6
@@ -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(
|
||||
@@ -77,6 +83,50 @@ async def fetch_user_profile(config: Config, access_token: str) -> dict[str, Any
|
||||
return resp.json()
|
||||
|
||||
|
||||
def allowlist_is_active() -> bool:
|
||||
"""The private-beta gate is on iff the `allowed_emails` table has any
|
||||
rows. Empty list means "open" — any successful OAuth provisions a
|
||||
user; first row added flips the deployment into private-beta mode.
|
||||
See `migrations/011_allowlist.sql` for the reasoning.
|
||||
"""
|
||||
row = db.conn().execute("SELECT 1 FROM allowed_emails LIMIT 1").fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
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).
|
||||
3. A `users` row already exists for this `gitea_id` — grandfather
|
||||
per `migrations/011_allowlist.sql`.
|
||||
"""
|
||||
gitea_id = profile.get("id")
|
||||
if gitea_id is not None:
|
||||
existing = db.conn().execute(
|
||||
"SELECT 1 FROM users WHERE gitea_id = ? LIMIT 1", (gitea_id,)
|
||||
).fetchone()
|
||||
if existing is not None:
|
||||
return True
|
||||
if not allowlist_is_active():
|
||||
return True
|
||||
email = (profile.get("email") or "").strip()
|
||||
if not email:
|
||||
return False
|
||||
row = db.conn().execute(
|
||||
"SELECT 1 FROM allowed_emails WHERE email = ? LIMIT 1", (email,)
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
def provision_user(config: Config, profile: dict[str, Any]) -> SessionUser:
|
||||
"""Insert or update the users row for this Gitea profile.
|
||||
|
||||
@@ -95,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
|
||||
@@ -123,6 +183,7 @@ def provision_user(config: Config, profile: dict[str, Any]) -> SessionUser:
|
||||
email=email,
|
||||
avatar_url=avatar,
|
||||
role=role,
|
||||
permission_state=permission_state,
|
||||
)
|
||||
|
||||
|
||||
@@ -141,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,
|
||||
}
|
||||
|
||||
|
||||
@@ -151,19 +218,31 @@ def current_user(request: Request) -> SessionUser | None:
|
||||
# Re-read the role from the database every request so role changes
|
||||
# take effect on the next API call without forcing a logout.
|
||||
row = db.conn().execute(
|
||||
"SELECT id, gitea_id, gitea_login, email, display_name, avatar_url, role FROM users WHERE id = ?",
|
||||
"SELECT id, gitea_id, gitea_login, email, display_name, avatar_url, role, permission_state FROM users WHERE id = ?",
|
||||
(raw["user_id"],),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
# v0.7.0: OTC-provisioned users have NULL gitea_id / gitea_login.
|
||||
# Coerce nulls to the SessionUser's typed defaults so downstream
|
||||
# code (Actor, _on_behalf_trailer) reads a stable shape regardless
|
||||
# of which sign-in path the row came from. The DB remains the
|
||||
# source of truth for "is this an OAuth-linked user" (gitea_id IS
|
||||
# NOT NULL); the in-memory SessionUser is the per-request handle.
|
||||
# v0.8.0: permission_state comes off the row directly. A NULL
|
||||
# column value (shouldn't happen under the migration's
|
||||
# NOT NULL DEFAULT, but be defensive) reads as 'granted' so the
|
||||
# gate fails open for grandfathered surfaces rather than locking
|
||||
# everyone out on a malformed row.
|
||||
return SessionUser(
|
||||
user_id=row["id"],
|
||||
gitea_id=row["gitea_id"],
|
||||
gitea_login=row["gitea_login"],
|
||||
gitea_id=row["gitea_id"] or 0,
|
||||
gitea_login=row["gitea_login"] or "",
|
||||
display_name=row["display_name"],
|
||||
email=row["email"] or "",
|
||||
avatar_url=row["avatar_url"] or "",
|
||||
role=row["role"],
|
||||
permission_state=row["permission_state"] or "granted",
|
||||
)
|
||||
|
||||
|
||||
@@ -175,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
|
||||
|
||||
|
||||
@@ -191,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)
|
||||
|
||||
+13
-136
@@ -695,111 +695,7 @@ class Bot:
|
||||
)
|
||||
return sha
|
||||
|
||||
# ----- §13 graduation: per-step primitives and rollback inverses -----
|
||||
|
||||
async def create_rfc_repo_for_graduation(
|
||||
self,
|
||||
actor: Actor,
|
||||
*,
|
||||
org: str,
|
||||
repo_name: str,
|
||||
slug: str,
|
||||
title: str,
|
||||
) -> dict:
|
||||
"""§13.3 step 1: create the per-RFC repo.
|
||||
|
||||
Empty repo (no auto-init) — `seed_graduated_rfc` writes the first
|
||||
commit on `main`. Returns the Gitea repo payload."""
|
||||
repo = await self._gitea.create_org_repo(
|
||||
org, repo_name, description=f"RFC: {title}"
|
||||
)
|
||||
_log(
|
||||
actor,
|
||||
"graduate_repo_create",
|
||||
rfc_slug=slug,
|
||||
details={"repo": f"{org}/{repo_name}", "title": title},
|
||||
)
|
||||
return repo
|
||||
|
||||
async def seed_graduated_rfc(
|
||||
self,
|
||||
actor: Actor,
|
||||
*,
|
||||
org: str,
|
||||
repo_name: str,
|
||||
slug: str,
|
||||
title: str,
|
||||
rfc_body: str,
|
||||
rfc_id: str,
|
||||
meta_full: str,
|
||||
meta_path: str,
|
||||
owners: list[str],
|
||||
arbiters: list[str],
|
||||
tags: list[str],
|
||||
) -> str:
|
||||
"""§13.3 step 2: seed RFC.md, README.md, .rfc/metadata.yaml on the
|
||||
new repo's `main`. Three create_file calls; one audit row.
|
||||
|
||||
Returns the final commit sha on main.
|
||||
"""
|
||||
import yaml as _yaml
|
||||
|
||||
ae = actor.email or f"{actor.gitea_login}@users.noreply"
|
||||
# 2a) RFC.md — the document. The super-draft's body is migrated
|
||||
# verbatim per §13.3; if the body is empty we seed a minimal
|
||||
# placeholder so the editor has something to render on first open.
|
||||
body = rfc_body.strip() + "\n" if rfc_body.strip() else (
|
||||
f"# {title}\n\n*RFC.md to be filled in — the super-draft graduated with an empty body.*\n"
|
||||
)
|
||||
rfc_msg = _stamp_single(f"Seed RFC.md from super-draft {slug}", actor)
|
||||
rfc_result = await self._gitea.create_file(
|
||||
org, repo_name, "RFC.md",
|
||||
content=body, message=rfc_msg, branch="main",
|
||||
author_name=actor.display_name, author_email=ae,
|
||||
)
|
||||
# 2b) README.md — header pointing back at the meta-repo entry.
|
||||
readme = (
|
||||
f"# {rfc_id} — {title}\n\n"
|
||||
f"This repository carries the canonical text of {rfc_id}.\n"
|
||||
f"The meta-repo entry is `{meta_path}` in `{meta_full}`.\n\n"
|
||||
f"The RFC body is in `RFC.md`. Contributions go through the\n"
|
||||
f"app's §8 RFC view — open a branch, propose changes, land a PR.\n"
|
||||
)
|
||||
readme_msg = _stamp_single(f"Seed README.md for {rfc_id}", actor)
|
||||
await self._gitea.create_file(
|
||||
org, repo_name, "README.md",
|
||||
content=readme, message=readme_msg, branch="main",
|
||||
author_name=actor.display_name, author_email=ae,
|
||||
)
|
||||
# 2c) .rfc/metadata.yaml — mirror of meta-repo frontmatter for
|
||||
# future tooling (linting, automation, CI lookups).
|
||||
meta_yaml = _yaml.safe_dump(
|
||||
{
|
||||
"slug": slug, "title": title, "id": rfc_id,
|
||||
"owners": owners, "arbiters": arbiters, "tags": list(tags),
|
||||
},
|
||||
sort_keys=False,
|
||||
)
|
||||
meta_msg = _stamp_single(f"Seed .rfc/metadata.yaml for {rfc_id}", actor)
|
||||
meta_result = await self._gitea.create_file(
|
||||
org, repo_name, ".rfc/metadata.yaml",
|
||||
content=meta_yaml, message=meta_msg, branch="main",
|
||||
author_name=actor.display_name, author_email=ae,
|
||||
)
|
||||
last_sha = (
|
||||
meta_result.get("commit", {}).get("sha")
|
||||
or rfc_result.get("commit", {}).get("sha")
|
||||
or ""
|
||||
)
|
||||
_log(
|
||||
actor,
|
||||
"graduate_repo_seed",
|
||||
rfc_slug=slug,
|
||||
branch_name="main",
|
||||
bot_commit_sha=last_sha,
|
||||
details={"repo": f"{org}/{repo_name}", "rfc_id": rfc_id},
|
||||
)
|
||||
return last_sha
|
||||
# ----- §13 graduation (meta-only): open + merge the flip PR -----
|
||||
|
||||
async def open_graduation_pr(
|
||||
self,
|
||||
@@ -811,13 +707,14 @@ class Bot:
|
||||
new_file_contents: str,
|
||||
prior_sha: str,
|
||||
rfc_id: str,
|
||||
repo_full: str,
|
||||
owners: list[str],
|
||||
) -> dict:
|
||||
"""§13.3 step 3: open a PR against the meta repo that strips the
|
||||
super-draft body and fills graduation frontmatter fields. Branch
|
||||
name uses the `graduate-<slug>-<6hex>` shape — dash-separated like
|
||||
the other meta-repo branches per the §19.2 path-routing candidate.
|
||||
"""§13.3 (meta-only): open a PR against the meta repo that flips the
|
||||
entry's frontmatter to `state: active` with the integer `id` and
|
||||
graduation stamps — **keeping the body unchanged** (§1 meta-only
|
||||
topology; no repo is created and no body is stripped). Branch name
|
||||
uses the `graduate-<slug>-<6hex>` shape — dash-separated like the
|
||||
other meta-repo branches per the §19.2 path-routing candidate.
|
||||
"""
|
||||
import secrets
|
||||
|
||||
@@ -844,11 +741,11 @@ class Bot:
|
||||
pr_body_text = (
|
||||
f"Graduates super-draft `{slug}` to active.\n\n"
|
||||
f"- ID: `{rfc_id}`\n"
|
||||
f"- Repo: `{repo_full}`\n"
|
||||
f"- Owners: {owners_str}\n\n"
|
||||
f"The meta-repo entry becomes frontmatter-only; the canonical body\n"
|
||||
f"moves to `RFC.md` in the new repo. The graduation sequence is\n"
|
||||
f"transactional per §13.3."
|
||||
f"This is an in-place state flip per the meta-only topology\n"
|
||||
f"(SPEC §1, §13.3): the entry `rfcs/{slug}.md` keeps its body and\n"
|
||||
f"stays in the meta repo. Only the frontmatter changes — `state`,\n"
|
||||
f"`id`, and the graduation stamps."
|
||||
)
|
||||
_subject, pr_body = _stamp("", pr_body_text, actor)
|
||||
pr = await self._gitea.create_pull(
|
||||
@@ -862,7 +759,7 @@ class Bot:
|
||||
branch_name=branch,
|
||||
pr_number=pr["number"],
|
||||
bot_commit_sha=commit_sha,
|
||||
details={"pr_title": pr_title, "rfc_id": rfc_id, "repo": repo_full},
|
||||
details={"pr_title": pr_title, "rfc_id": rfc_id},
|
||||
)
|
||||
return pr
|
||||
|
||||
@@ -912,27 +809,7 @@ class Bot:
|
||||
details={"rfc_id": rfc_id},
|
||||
)
|
||||
|
||||
# ----- §13.3 rollback inverses -----
|
||||
|
||||
async def delete_rfc_repo(
|
||||
self,
|
||||
actor: Actor,
|
||||
*,
|
||||
org: str,
|
||||
repo_name: str,
|
||||
slug: str,
|
||||
reason: str,
|
||||
) -> None:
|
||||
"""Undo of `create_rfc_repo_for_graduation`. Records `graduate_repo_delete`
|
||||
in the audit log with the rollback reason so the §13.3 stack's
|
||||
rendered failure surface can be reconstructed from `actions`."""
|
||||
await self._gitea.delete_repo(org, repo_name)
|
||||
_log(
|
||||
actor,
|
||||
"graduate_repo_delete",
|
||||
rfc_slug=slug,
|
||||
details={"repo": f"{org}/{repo_name}", "reason": reason},
|
||||
)
|
||||
# ----- §13.3 (meta-only): cleanup of an unmerged flip PR -----
|
||||
|
||||
async def close_graduation_pr(
|
||||
self,
|
||||
|
||||
+42
-4
@@ -219,6 +219,19 @@ async def refresh_rfc_repo(config: Config, gitea: Gitea, slug: str) -> None:
|
||||
open_pulls, closed_pulls = [], []
|
||||
for pull in open_pulls + closed_pulls:
|
||||
head_branch = pull.get("head", {}).get("ref", "")
|
||||
# Same deleted-branch recovery as refresh_meta_pulls: a merged-and-
|
||||
# deleted PR's `head.ref` collapses to `refs/pull/<N>/head`. Here
|
||||
# the slug is known (param), so state still updates correctly and
|
||||
# no ghost forms — but blindly storing the sentinel would clobber
|
||||
# the real branch name api_prs.py relies on as a fallback ref when
|
||||
# the merge commit is gone. Recover it from the stored row.
|
||||
if not head_branch or head_branch.startswith("refs/pull/"):
|
||||
prior = db.conn().execute(
|
||||
"SELECT head_branch FROM cached_prs WHERE repo = ? AND pr_number = ?",
|
||||
(repo_full, pull["number"]),
|
||||
).fetchone()
|
||||
if prior and prior["head_branch"]:
|
||||
head_branch = prior["head_branch"]
|
||||
state = _state_from_pull(pull)
|
||||
gitea_opener = (pull.get("user") or {}).get("login") or ""
|
||||
opened_by = _resolve_actor(
|
||||
@@ -329,9 +342,13 @@ async def refresh_meta_branches(config: Config, gitea: Gitea) -> None:
|
||||
if not slug:
|
||||
continue
|
||||
rfc = db.conn().execute(
|
||||
"SELECT state FROM cached_rfcs WHERE slug = ?", (slug,)
|
||||
"SELECT state, repo FROM cached_rfcs WHERE slug = ?", (slug,)
|
||||
).fetchone()
|
||||
if not rfc or rfc["state"] != "super-draft":
|
||||
# Meta-only topology (§1): edit branches live on the meta repo for
|
||||
# every meta-resident entry — super-drafts and active RFCs alike
|
||||
# (active RFCs are graduated in place and keep editing here, §13).
|
||||
# A legacy per-RFC repo (repo set) is the only thing excluded.
|
||||
if not rfc or rfc["repo"] or rfc["state"] not in ("super-draft", "active"):
|
||||
continue
|
||||
edit_keys_seen.add((slug, name))
|
||||
db.conn().execute(
|
||||
@@ -352,7 +369,8 @@ async def refresh_meta_branches(config: Config, gitea: Gitea) -> None:
|
||||
# diverges from this single point.
|
||||
if meta_main_sha:
|
||||
super_drafts = db.conn().execute(
|
||||
"SELECT slug FROM cached_rfcs WHERE state = 'super-draft'"
|
||||
"SELECT slug FROM cached_rfcs "
|
||||
"WHERE repo IS NULL AND state IN ('super-draft', 'active')"
|
||||
).fetchall()
|
||||
for r in super_drafts:
|
||||
db.conn().execute(
|
||||
@@ -374,7 +392,8 @@ async def refresh_meta_branches(config: Config, gitea: Gitea) -> None:
|
||||
SELECT b.rfc_slug, b.branch_name
|
||||
FROM cached_branches b
|
||||
JOIN cached_rfcs r ON r.slug = b.rfc_slug
|
||||
WHERE r.state = 'super-draft'
|
||||
WHERE r.repo IS NULL
|
||||
AND r.state IN ('super-draft', 'active')
|
||||
AND b.state != 'deleted'
|
||||
AND b.branch_name != 'main'
|
||||
"""
|
||||
@@ -431,6 +450,25 @@ async def refresh_meta_pulls(config: Config, gitea: Gitea) -> None:
|
||||
|
||||
for pull in open_pulls + closed_pulls:
|
||||
head_branch = pull.get("head", {}).get("ref", "")
|
||||
# A merged-and-deleted PR's branch is no longer reported by Gitea
|
||||
# as its real name — the `head.ref` collapses to the synthetic
|
||||
# `refs/pull/<N>/head` sentinel (or empty). The slug + kind both
|
||||
# derive from the branch name, so a deleted branch would parse to
|
||||
# slug=None and the row would be skipped forever, freezing the
|
||||
# cached_prs row at its last-seen `state='open'` — a permanent
|
||||
# ghost "pending idea" for an entry that has actually merged
|
||||
# (caught when the operator authoring lane in ROADMAP #35 merged
|
||||
# an idea PR with the branch deleted; the web UX leaves branches
|
||||
# in place so it never tripped this). Recover the original branch
|
||||
# from the row we already stored when the PR was open — that row
|
||||
# retains the real `head_branch` (migration 002).
|
||||
if not head_branch or head_branch.startswith("refs/pull/"):
|
||||
prior = db.conn().execute(
|
||||
"SELECT head_branch FROM cached_prs WHERE repo = ? AND pr_number = ?",
|
||||
(repo_full, pull["number"]),
|
||||
).fetchone()
|
||||
if prior and prior["head_branch"]:
|
||||
head_branch = prior["head_branch"]
|
||||
slug = _slug_from_head_branch(head_branch)
|
||||
if slug is None:
|
||||
continue
|
||||
|
||||
+6
-1
@@ -168,10 +168,15 @@ def _fan_out_chat(thread_id: int, author_user_id: int, message_id: int) -> None:
|
||||
).fetchone()
|
||||
if pr_row:
|
||||
pr_number = pr_row["pr_number"]
|
||||
# v0.5.0 (§5 / §10 — PR-less discussion): a thread with
|
||||
# branch_name IS NULL is scoped to the RFC's main view. Pass None
|
||||
# through to the notify chokepoint so the notifications row keeps
|
||||
# `branch_name` null — coercing it to "main" would misroute the
|
||||
# §15.7 chat-seen reconciler (which keys on branch_name).
|
||||
notify.fan_out_chat_message(
|
||||
actor_user_id=author_user_id,
|
||||
rfc_slug=row["rfc_slug"],
|
||||
branch_name=row["branch_name"] or "main",
|
||||
branch_name=row["branch_name"],
|
||||
thread_id=thread_id,
|
||||
message_id=message_id,
|
||||
is_review_thread=(row["thread_kind"] == "review"),
|
||||
|
||||
+15
-1
@@ -60,6 +60,20 @@ def load_config() -> Config:
|
||||
|
||||
enabled = [m.strip() for m in _optional("ENABLED_MODELS", "claude").split(",") if m.strip()]
|
||||
|
||||
# v0.18.0: `GITEA_WEBHOOK_SECRET` is now mandatory (per the
|
||||
# email + webhook hygiene proposal). An empty value used to
|
||||
# silently accept unsigned webhook POSTs — that was the
|
||||
# invisible-failure shape the proposal targets. Now the
|
||||
# framework refuses to start when the secret is empty unless
|
||||
# the operator opts into the dev-bypass with
|
||||
# `RFC_APP_INSECURE_WEBHOOKS=1`. Local-dev deployments without
|
||||
# a wired Gitea hook set the bypass; production MUST NOT.
|
||||
insecure_webhooks = os.environ.get("RFC_APP_INSECURE_WEBHOOKS", "").strip() == "1"
|
||||
if insecure_webhooks:
|
||||
webhook_secret = _optional("GITEA_WEBHOOK_SECRET")
|
||||
else:
|
||||
webhook_secret = _required("GITEA_WEBHOOK_SECRET")
|
||||
|
||||
return Config(
|
||||
gitea_url=_required("GITEA_URL").rstrip("/"),
|
||||
gitea_bot_user=_required("GITEA_BOT_USER"),
|
||||
@@ -72,7 +86,7 @@ def load_config() -> Config:
|
||||
secret_key=_required("SECRET_KEY"),
|
||||
database_path=database_path,
|
||||
owner_gitea_login=_optional("OWNER_GITEA_LOGIN"),
|
||||
webhook_secret=_optional("GITEA_WEBHOOK_SECRET"),
|
||||
webhook_secret=webhook_secret,
|
||||
enabled_models=enabled,
|
||||
anthropic_api_key=_optional("ANTHROPIC_API_KEY"),
|
||||
google_api_key=_optional("GOOGLE_API_KEY"),
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
"""§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
|
||||
|
||||
@property
|
||||
def cookie_value(self) -> str:
|
||||
"""The value to put in the `rfc_device_trust` cookie: the row-id
|
||||
selector joined to the raw token (v0.25.0 / audit 0026 M1). The
|
||||
selector lets `lookup` read one indexed row instead of scanning."""
|
||||
return f"{self.row_id}.{self.raw_token}"
|
||||
|
||||
|
||||
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")
|
||||
|
||||
# v0.25.0 (audit 0026 M1): the cookie is "<row_id>.<raw_token>". We
|
||||
# parse the row-id selector and read exactly ONE row by its indexed
|
||||
# primary key, then bcrypt-check the token against that single row.
|
||||
#
|
||||
# The previous shape read EVERY device_trust row (all users, including
|
||||
# revoked/expired) and bcrypt-checked each — an unauthenticated
|
||||
# CPU-amplification DoS reachable at /auth/device-trust/start that
|
||||
# grew without bound as the table accumulated. bcrypt's per-row salt
|
||||
# is why we can't SELECT by hash; carrying the row-id in the cookie is
|
||||
# the standard fix (the id is not secret; the token still is).
|
||||
selector, sep, token = raw.partition(".")
|
||||
if not sep or not selector.isdigit() or not token:
|
||||
# Legacy bare-token cookies (pre-v0.25.0) and malformed values land
|
||||
# here. We refuse rather than fall back to a full-table scan, so
|
||||
# the amplification path is fully closed; affected users simply
|
||||
# re-authenticate once via OTC/passcode and get a new cookie.
|
||||
return LookupOutcome(ok=False, user=None, reason="invalid")
|
||||
|
||||
matched = db.conn().execute(
|
||||
"""
|
||||
SELECT id, user_id, device_token_hash, expires_at, revoked_at
|
||||
FROM device_trust
|
||||
WHERE id = ?
|
||||
""",
|
||||
(int(selector),),
|
||||
).fetchone()
|
||||
|
||||
# One bcrypt check, against the selected row only. A wrong/forged token
|
||||
# for a real id reads as 'unknown' (cookie cleared), same as a missing
|
||||
# row — a probing client can't distinguish the two.
|
||||
if matched is None or not _check(token, matched["device_token_hash"]):
|
||||
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
|
||||
+13
-1
@@ -180,7 +180,19 @@ def assemble_for_user(
|
||||
|
||||
subject = _subject(eligible, cadence)
|
||||
body = _body(eligible, cadence, cfg)
|
||||
sent = email_mod._deliver(cfg, email, subject, body)
|
||||
# v0.18.0: the digest is the bulk-adjacent surface par excellence
|
||||
# (it can carry weeks of accumulated activity), so it gets the
|
||||
# full one-click unsubscribe to the global opt-out. Per-category
|
||||
# opt-outs are managed from the preferences page; this footer is
|
||||
# the "stop sending me anything" escape hatch Gmail and Yahoo
|
||||
# expect for senders at this tier.
|
||||
unsubscribe_url = email_mod.make_unsubscribe_url(user_id, "all")
|
||||
sent = email_mod._deliver(
|
||||
cfg, email, subject, body,
|
||||
unsubscribe_mailto=cfg.unsubscribe_mailto,
|
||||
unsubscribe_url=unsubscribe_url,
|
||||
kind="digest",
|
||||
)
|
||||
if not sent:
|
||||
return False
|
||||
ids = [r["id"] for r, _ in eligible]
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1,358 @@
|
||||
"""§14 + roadmap item #30 — on-site sessions-history browser source.
|
||||
|
||||
Sibling of `docs.py` / `philosophy.py` but with a different read shape:
|
||||
the bodies here live in the **public** `wiggleverse/ohm-session-history`
|
||||
gitea repo (transcripts of every OHM build session, published per the
|
||||
ohm-infra SESSION-PROTOCOL.md), not on disk. The framework mediates
|
||||
the gitea fetch on behalf of the browser so the rendered `/docs/sessions/*`
|
||||
surface inherits the same chrome as `/philosophy` and `/docs/user-guide`
|
||||
and stays free of any cross-origin gestures from the frontend.
|
||||
|
||||
Three read endpoints, all anonymous-reachable:
|
||||
|
||||
GET /api/docs/sessions/manifest — sessions.json (title manifest)
|
||||
GET /api/docs/sessions/about — README.md (the about page)
|
||||
GET /api/docs/sessions/<NNNN>/<file> — a transcript body
|
||||
GET /api/docs/sessions/<NNNN>/index — per-session file listing
|
||||
|
||||
All three sit behind a small in-process TTL cache (manifest TTL default
|
||||
60 s, content TTL default 300 s). Negative results (404 from gitea) are
|
||||
also cached at the content TTL to avoid hammering gitea when a
|
||||
deployment hasn't yet been populated with transcripts. The cache key
|
||||
is the URL path on the gitea raw base (or the contents API for the
|
||||
per-session listing); the cache lives in-process, plain dict +
|
||||
`time.monotonic()` check, no external dep.
|
||||
|
||||
Env knobs:
|
||||
|
||||
OHM_SESSION_HISTORY_RAW_BASE
|
||||
Override the gitea raw base URL. Default points at OHM's canonical
|
||||
transcript repo:
|
||||
https://git.wiggleverse.org/wiggleverse/ohm-session-history/raw/branch/main
|
||||
The framework-default value is OHM-flavored because OHM is the
|
||||
only deployment to date — a deployment running its own
|
||||
transcript repo overrides this via flotilla's overlay.
|
||||
|
||||
OHM_SESSION_HISTORY_CONTENTS_BASE
|
||||
Override the gitea contents-API base URL (for the per-session
|
||||
listing endpoint, which enumerates files inside a `NNNN/` folder).
|
||||
Default:
|
||||
https://git.wiggleverse.org/api/v1/repos/wiggleverse/ohm-session-history/contents
|
||||
|
||||
OHM_DOCS_SESSIONS_MANIFEST_TTL_SEC
|
||||
Cache TTL for the manifest (default 60 s). The manifest is small
|
||||
and changes when a new session is added; 60 s strikes a balance
|
||||
between freshness and gitea load.
|
||||
|
||||
OHM_DOCS_SESSIONS_CONTENT_TTL_SEC
|
||||
Cache TTL for transcript bodies + README + per-session listings
|
||||
(default 300 s = 5 minutes). Transcripts are append-only once
|
||||
published, so 5 minutes of staleness is harmless.
|
||||
|
||||
§3 invariant 1 is preserved: the framework holds no secret bytes; the
|
||||
gitea repo is public, the fetch carries no auth header.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_DEFAULT_RAW_BASE = (
|
||||
"https://git.wiggleverse.org/wiggleverse/ohm-session-history/raw/branch/main"
|
||||
)
|
||||
_DEFAULT_CONTENTS_BASE = (
|
||||
"https://git.wiggleverse.org/api/v1/repos/wiggleverse/ohm-session-history/contents"
|
||||
)
|
||||
_DEFAULT_MANIFEST_TTL_SEC = 60.0
|
||||
_DEFAULT_CONTENT_TTL_SEC = 300.0
|
||||
|
||||
# The transcript filename shape per SESSION-PROTOCOL.md §1. The
|
||||
# `<start>--<end>` suffix is optional so legacy renamed-letter
|
||||
# transcripts (e.g. `SESSION-0009.0-TRANSCRIPT.md` without timestamps)
|
||||
# remain reachable. The `\.\d+(\.\d+)*` after the session number
|
||||
# accommodates `0017.0`, `0017.1`, `0017.1.1`, etc.
|
||||
_TRANSCRIPT_FILENAME_RE = re.compile(
|
||||
r"^SESSION-\d{4}\.\d+(\.\d+)*-TRANSCRIPT"
|
||||
r"(-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}--\d{4}-\d{2}-\d{2}T\d{2}-\d{2})?"
|
||||
r"\.md$"
|
||||
)
|
||||
_SESSION_DIR_RE = re.compile(r"^\d{4}$")
|
||||
|
||||
_HTTP_TIMEOUT_SEC = 5.0
|
||||
|
||||
|
||||
def _env_float(name: str, default: float) -> float:
|
||||
raw = os.environ.get(name, "").strip()
|
||||
if not raw:
|
||||
return default
|
||||
try:
|
||||
return float(raw)
|
||||
except ValueError:
|
||||
log.warning("invalid %s=%r — falling back to %s", name, raw, default)
|
||||
return default
|
||||
|
||||
|
||||
def _raw_base() -> str:
|
||||
return os.environ.get("OHM_SESSION_HISTORY_RAW_BASE", "").strip() or _DEFAULT_RAW_BASE
|
||||
|
||||
|
||||
def _contents_base() -> str:
|
||||
return (
|
||||
os.environ.get("OHM_SESSION_HISTORY_CONTENTS_BASE", "").strip()
|
||||
or _DEFAULT_CONTENTS_BASE
|
||||
)
|
||||
|
||||
|
||||
def _manifest_ttl() -> float:
|
||||
return _env_float("OHM_DOCS_SESSIONS_MANIFEST_TTL_SEC", _DEFAULT_MANIFEST_TTL_SEC)
|
||||
|
||||
|
||||
def _content_ttl() -> float:
|
||||
return _env_float("OHM_DOCS_SESSIONS_CONTENT_TTL_SEC", _DEFAULT_CONTENT_TTL_SEC)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# In-process TTL cache
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Plain dict + `time.monotonic()` check, no external dep. The cache
|
||||
# value is a `(stored_at, payload)` tuple; `payload` may carry an
|
||||
# error-shape sentinel for negative caching (404s). Lock guards
|
||||
# read-modify-write across worker tasks; entries are immutable once
|
||||
# stored so reads under the lock are fast.
|
||||
|
||||
_lock = threading.Lock()
|
||||
_cache: dict[str, tuple[float, dict[str, Any]]] = {}
|
||||
|
||||
|
||||
def _cache_get(key: str, ttl_sec: float) -> dict[str, Any] | None:
|
||||
with _lock:
|
||||
entry = _cache.get(key)
|
||||
if entry is None:
|
||||
return None
|
||||
stored_at, payload = entry
|
||||
if time.monotonic() - stored_at > ttl_sec:
|
||||
# Don't evict here; let _cache_put overwrite on next fetch.
|
||||
# The stale entry is gated by the TTL check, so it stays
|
||||
# invisible to readers regardless.
|
||||
return None
|
||||
return payload
|
||||
|
||||
|
||||
def _cache_put(key: str, payload: dict[str, Any]) -> None:
|
||||
with _lock:
|
||||
_cache[key] = (time.monotonic(), payload)
|
||||
|
||||
|
||||
def reset_cache() -> None:
|
||||
"""Drop every cached entry. Test seam — not called in production."""
|
||||
with _lock:
|
||||
_cache.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public fetch surface
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Each fetcher returns a `{status, ...}` dict. `status` is one of:
|
||||
# "ok" — payload field carries the body
|
||||
# "404" — gitea returned 404 (or content was missing)
|
||||
# "error" — gitea returned 5xx, timed out, or returned malformed data
|
||||
#
|
||||
# The route layer maps these onto HTTP responses; keeping the mapping
|
||||
# out of this module makes the cache transparent to the test harness.
|
||||
|
||||
|
||||
async def _http_get(url: str) -> tuple[int, str]:
|
||||
"""Perform a single GET against `url`; return (status_code, body).
|
||||
|
||||
On timeout or network error, returns (599, error_message). The 599
|
||||
pseudo-status maps to a 502 at the route layer the same way an
|
||||
upstream 5xx does — the caller doesn't care which leg of the
|
||||
network broke.
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT_SEC) as client:
|
||||
r = await client.get(url)
|
||||
return r.status_code, r.text
|
||||
except httpx.HTTPError as e:
|
||||
log.warning("gitea fetch failed for %s: %s", url, e)
|
||||
return 599, f"fetch error: {e}"
|
||||
|
||||
|
||||
def _is_valid_session_dir(nnnn: str) -> bool:
|
||||
return bool(_SESSION_DIR_RE.match(nnnn))
|
||||
|
||||
|
||||
def _is_valid_transcript_filename(filename: str) -> bool:
|
||||
return bool(_TRANSCRIPT_FILENAME_RE.match(filename))
|
||||
|
||||
|
||||
async def fetch_manifest() -> dict[str, Any]:
|
||||
"""Fetch and parse `sessions.json` from the public repo.
|
||||
|
||||
Returns one of:
|
||||
{"status": "ok", "manifest": {...}} — successful parse
|
||||
{"status": "404"} — gitea 404 (empty state)
|
||||
{"status": "error", "detail": "..."} — 5xx / timeout / bad JSON
|
||||
"""
|
||||
cache_key = "manifest"
|
||||
cached = _cache_get(cache_key, _manifest_ttl())
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
url = f"{_raw_base()}/sessions.json"
|
||||
status, body = await _http_get(url)
|
||||
|
||||
if status == 200:
|
||||
try:
|
||||
import json
|
||||
|
||||
data = json.loads(body)
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
payload: dict[str, Any] = {
|
||||
"status": "error",
|
||||
"detail": f"sessions.json malformed: {e}",
|
||||
}
|
||||
# Don't cache parse errors — give the upstream a chance to
|
||||
# fix the file without waiting for TTL expiry.
|
||||
return payload
|
||||
if not isinstance(data, dict):
|
||||
return {
|
||||
"status": "error",
|
||||
"detail": "sessions.json is not a JSON object",
|
||||
}
|
||||
payload = {"status": "ok", "manifest": data}
|
||||
_cache_put(cache_key, payload)
|
||||
return payload
|
||||
|
||||
if status == 404:
|
||||
payload = {"status": "404"}
|
||||
_cache_put(cache_key, payload)
|
||||
return payload
|
||||
|
||||
return {"status": "error", "detail": f"upstream returned {status}"}
|
||||
|
||||
|
||||
async def fetch_about() -> dict[str, Any]:
|
||||
"""Fetch the repo's README.md (rendered as the /docs/sessions/about page).
|
||||
|
||||
Returns one of:
|
||||
{"status": "ok", "body": "..."}
|
||||
{"status": "404"}
|
||||
{"status": "error", "detail": "..."}
|
||||
"""
|
||||
cache_key = "about:README.md"
|
||||
cached = _cache_get(cache_key, _content_ttl())
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
url = f"{_raw_base()}/README.md"
|
||||
status, body = await _http_get(url)
|
||||
|
||||
if status == 200:
|
||||
payload: dict[str, Any] = {"status": "ok", "body": body}
|
||||
_cache_put(cache_key, payload)
|
||||
return payload
|
||||
if status == 404:
|
||||
payload = {"status": "404"}
|
||||
_cache_put(cache_key, payload)
|
||||
return payload
|
||||
return {"status": "error", "detail": f"upstream returned {status}"}
|
||||
|
||||
|
||||
async def fetch_transcript(nnnn: str, filename: str) -> dict[str, Any]:
|
||||
"""Fetch a single transcript body from `{nnnn}/{filename}` in the repo.
|
||||
|
||||
The caller is expected to have validated `nnnn` and `filename`
|
||||
against `_is_valid_session_dir` / `_is_valid_transcript_filename`
|
||||
before calling this — invalid paths shouldn't reach the network.
|
||||
"""
|
||||
cache_key = f"transcript:{nnnn}/{filename}"
|
||||
cached = _cache_get(cache_key, _content_ttl())
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
url = f"{_raw_base()}/{nnnn}/{filename}"
|
||||
status, body = await _http_get(url)
|
||||
|
||||
if status == 200:
|
||||
payload: dict[str, Any] = {"status": "ok", "body": body}
|
||||
_cache_put(cache_key, payload)
|
||||
return payload
|
||||
if status == 404:
|
||||
payload = {"status": "404"}
|
||||
_cache_put(cache_key, payload)
|
||||
return payload
|
||||
return {"status": "error", "detail": f"upstream returned {status}"}
|
||||
|
||||
|
||||
async def fetch_session_index(nnnn: str) -> dict[str, Any]:
|
||||
"""List the transcript filenames inside the `{nnnn}/` folder.
|
||||
|
||||
Uses gitea's contents API (one HTTP per session-index page-view per
|
||||
cache-TTL) rather than the raw URL — there's no flat way to list a
|
||||
folder via the raw mount.
|
||||
|
||||
Returns one of:
|
||||
{"status": "ok", "files": ["SESSION-...md", ...]}
|
||||
{"status": "404"}
|
||||
{"status": "error", "detail": "..."}
|
||||
|
||||
Only filenames that match `_is_valid_transcript_filename` are
|
||||
surfaced — sibling files (e.g. an attached `notes.md`) are ignored
|
||||
so the /docs/sessions/<NNNN> page never lists a non-transcript
|
||||
masquerading as one.
|
||||
"""
|
||||
cache_key = f"index:{nnnn}"
|
||||
cached = _cache_get(cache_key, _content_ttl())
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
url = f"{_contents_base()}/{nnnn}"
|
||||
status, body = await _http_get(url)
|
||||
|
||||
if status == 200:
|
||||
try:
|
||||
import json
|
||||
|
||||
data = json.loads(body)
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
return {
|
||||
"status": "error",
|
||||
"detail": f"contents API response malformed: {e}",
|
||||
}
|
||||
if not isinstance(data, list):
|
||||
return {
|
||||
"status": "error",
|
||||
"detail": "contents API returned non-list",
|
||||
}
|
||||
files: list[str] = []
|
||||
for entry in data:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
if entry.get("type") != "file":
|
||||
continue
|
||||
name = entry.get("name")
|
||||
if not isinstance(name, str):
|
||||
continue
|
||||
if _is_valid_transcript_filename(name):
|
||||
files.append(name)
|
||||
files.sort()
|
||||
payload: dict[str, Any] = {"status": "ok", "files": files}
|
||||
_cache_put(cache_key, payload)
|
||||
return payload
|
||||
if status == 404:
|
||||
payload = {"status": "404"}
|
||||
_cache_put(cache_key, payload)
|
||||
return payload
|
||||
return {"status": "error", "detail": f"upstream returned {status}"}
|
||||
@@ -0,0 +1,326 @@
|
||||
"""v0.20.0 — on-site framework-specs surface source.
|
||||
|
||||
Sibling of `docs_sessions.py` (v0.19.0 / roadmap item #30): the
|
||||
framework mediates a gitea fetch on behalf of the browser so the
|
||||
rendered `/docs/specs/*` surface inherits the same chrome as
|
||||
`/docs/user-guide` and `/docs/sessions/*` and stays free of any
|
||||
cross-origin gestures from the frontend.
|
||||
|
||||
Two read endpoints, both anonymous-reachable:
|
||||
|
||||
GET /api/docs/specs/manifest — the configured spec list
|
||||
GET /api/docs/specs/<name> — a single spec body (markdown)
|
||||
|
||||
The framework-default manifest is OHM-flavored (rfc-app's own SPEC.md
|
||||
+ flotilla's SPEC.md on `git.wiggleverse.org`) for the same reason
|
||||
`docs_sessions.py`'s defaults are: OHM is the only deployment to
|
||||
date. A deployment running its own spec set overrides the manifest
|
||||
via the `OHM_DOCS_SPECS` env var (set through flotilla's overlay).
|
||||
History is intentionally not surfaced here — the operator-stated
|
||||
intent is "current version only; git is the history surface".
|
||||
|
||||
Per-spec entries carry three fields:
|
||||
|
||||
name — URL-safe slug (`[a-z0-9-]+`) — the path segment
|
||||
title — human-readable label shown in the nav and the page header
|
||||
url — the upstream raw URL the framework fetches
|
||||
|
||||
Validation:
|
||||
|
||||
- The configured list must be a JSON array of `{name, title, url}`
|
||||
objects. A malformed `OHM_DOCS_SPECS` value (bad JSON, wrong
|
||||
shape, invalid slug) logs a warning and falls back to the default
|
||||
so a typo in the overlay doesn't crash startup.
|
||||
- Each `name` is checked against `^[a-z0-9-]+$` before the manifest
|
||||
is accepted. The route layer also validates the path-bound `name`
|
||||
parameter before any network call, so a malformed URL never
|
||||
reaches the cache or the upstream.
|
||||
|
||||
Cache shape mirrors `docs_sessions.py`: in-process `dict` + monotonic
|
||||
TTL check, negative results (404) cached, no external dep. The
|
||||
manifest is cheap (parsed from an env var, no network), so it has no
|
||||
TTL — every request re-derives it. Per-spec content has a 5-minute
|
||||
default TTL (env-tunable via `OHM_DOCS_SPECS_CONTENT_TTL_SEC`).
|
||||
|
||||
§3 invariant 1 is preserved: the framework holds no secret bytes;
|
||||
the upstream specs are public-repo raw URLs, the fetch carries no
|
||||
auth header.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# The framework-default spec set. OHM-flavored per the same precedent
|
||||
# `docs_sessions.py` set: the only live deployment is OHM, so the
|
||||
# default points there. A deployment running its own specs overrides
|
||||
# `OHM_DOCS_SPECS` via the overlay.
|
||||
_DEFAULT_SPECS: list[dict[str, str]] = [
|
||||
{
|
||||
"name": "rfc-app",
|
||||
"title": "rfc-app SPEC",
|
||||
"url": (
|
||||
"https://git.wiggleverse.org/ben.stull/rfc-app/"
|
||||
"raw/branch/main/SPEC.md"
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "flotilla",
|
||||
"title": "flotilla SPEC",
|
||||
"url": (
|
||||
"https://git.wiggleverse.org/wiggleverse/ohm-rfc-app-flotilla/"
|
||||
"raw/branch/main/SPEC.md"
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
_DEFAULT_CONTENT_TTL_SEC = 300.0
|
||||
|
||||
# URL-safe slug. Matches `docs_sessions.py`'s `_SESSION_DIR_RE` spirit
|
||||
# (rejecting anything that could resolve outside the intended layout)
|
||||
# but with the lowercase-alphanumeric-plus-dash shape the manifest
|
||||
# enforces. Path traversal (`..`), separators (`/`), tilde, uppercase,
|
||||
# and whitespace all fail this regex; the route layer rejects 400
|
||||
# before any cache or network call.
|
||||
_NAME_RE = re.compile(r"^[a-z0-9-]+$")
|
||||
|
||||
_HTTP_TIMEOUT_SEC = 5.0
|
||||
|
||||
|
||||
def _env_float(name: str, default: float) -> float:
|
||||
raw = os.environ.get(name, "").strip()
|
||||
if not raw:
|
||||
return default
|
||||
try:
|
||||
return float(raw)
|
||||
except ValueError:
|
||||
log.warning("invalid %s=%r — falling back to %s", name, raw, default)
|
||||
return default
|
||||
|
||||
|
||||
def _content_ttl() -> float:
|
||||
return _env_float("OHM_DOCS_SPECS_CONTENT_TTL_SEC", _DEFAULT_CONTENT_TTL_SEC)
|
||||
|
||||
|
||||
def _is_valid_name(name: str) -> bool:
|
||||
"""Slug guard for path-bound `name` parameters.
|
||||
|
||||
Mirrors `docs_sessions._is_valid_session_dir`'s contract: the
|
||||
route layer calls this before any network or cache work, so a
|
||||
malformed name never escapes the FastAPI surface.
|
||||
"""
|
||||
return bool(isinstance(name, str) and _NAME_RE.match(name))
|
||||
|
||||
|
||||
def _parse_spec_entry(entry: Any) -> dict[str, str] | None:
|
||||
"""Validate a single manifest entry; return None if invalid.
|
||||
|
||||
Required fields: `name`, `title`, `url`. All three must be
|
||||
non-empty strings; `name` must match `_NAME_RE`. The validator is
|
||||
strict: an entry that fails any check is dropped from the manifest
|
||||
(and the caller logs at warning level).
|
||||
"""
|
||||
if not isinstance(entry, dict):
|
||||
return None
|
||||
name = entry.get("name")
|
||||
title = entry.get("title")
|
||||
url = entry.get("url")
|
||||
if not isinstance(name, str) or not _is_valid_name(name):
|
||||
return None
|
||||
if not isinstance(title, str) or not title.strip():
|
||||
return None
|
||||
if not isinstance(url, str) or not url.strip():
|
||||
return None
|
||||
return {"name": name, "title": title.strip(), "url": url.strip()}
|
||||
|
||||
|
||||
def _load_configured_specs() -> list[dict[str, str]]:
|
||||
"""Parse `OHM_DOCS_SPECS` (if set) or return the default list.
|
||||
|
||||
Malformed JSON or wrong-shape values log a warning and fall back
|
||||
to the default — the deployment continues to render the spec
|
||||
surface rather than crashing startup. The strict validation (each
|
||||
entry's name slug, presence of all three fields) drops bad entries
|
||||
one-by-one; if every entry is dropped, the default applies.
|
||||
"""
|
||||
raw = os.environ.get("OHM_DOCS_SPECS", "").strip()
|
||||
if not raw:
|
||||
return list(_DEFAULT_SPECS)
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
log.warning(
|
||||
"OHM_DOCS_SPECS is not valid JSON (%s) — falling back to default", e
|
||||
)
|
||||
return list(_DEFAULT_SPECS)
|
||||
if not isinstance(parsed, list):
|
||||
log.warning(
|
||||
"OHM_DOCS_SPECS must be a JSON array — falling back to default"
|
||||
)
|
||||
return list(_DEFAULT_SPECS)
|
||||
out: list[dict[str, str]] = []
|
||||
seen: set[str] = set()
|
||||
for entry in parsed:
|
||||
validated = _parse_spec_entry(entry)
|
||||
if validated is None:
|
||||
log.warning(
|
||||
"OHM_DOCS_SPECS entry %r failed validation — dropped", entry
|
||||
)
|
||||
continue
|
||||
if validated["name"] in seen:
|
||||
log.warning(
|
||||
"OHM_DOCS_SPECS has duplicate name %r — dropped", validated["name"]
|
||||
)
|
||||
continue
|
||||
seen.add(validated["name"])
|
||||
out.append(validated)
|
||||
if not out:
|
||||
log.warning(
|
||||
"OHM_DOCS_SPECS yielded no valid entries — falling back to default"
|
||||
)
|
||||
return list(_DEFAULT_SPECS)
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# In-process TTL cache
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Same shape as `docs_sessions.py`: plain dict + `time.monotonic()` check,
|
||||
# no external dep. The cache value is a `(stored_at, payload)` tuple;
|
||||
# `payload` may carry an error-shape sentinel for negative caching (404s).
|
||||
# Lock guards read-modify-write across worker tasks; entries are immutable
|
||||
# once stored so reads under the lock are fast.
|
||||
|
||||
_lock = threading.Lock()
|
||||
_cache: dict[str, tuple[float, dict[str, Any]]] = {}
|
||||
|
||||
|
||||
def _cache_get(key: str, ttl_sec: float) -> dict[str, Any] | None:
|
||||
with _lock:
|
||||
entry = _cache.get(key)
|
||||
if entry is None:
|
||||
return None
|
||||
stored_at, payload = entry
|
||||
if time.monotonic() - stored_at > ttl_sec:
|
||||
return None
|
||||
return payload
|
||||
|
||||
|
||||
def _cache_put(key: str, payload: dict[str, Any]) -> None:
|
||||
with _lock:
|
||||
_cache[key] = (time.monotonic(), payload)
|
||||
|
||||
|
||||
def reset_cache() -> None:
|
||||
"""Drop every cached entry. Test seam — not called in production."""
|
||||
with _lock:
|
||||
_cache.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public fetch surface
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Each fetcher returns a `{status, ...}` dict, same convention as
|
||||
# `docs_sessions.py`:
|
||||
# "ok" — payload field carries the body / manifest
|
||||
# "404" — gitea returned 404 (or the configured name doesn't exist)
|
||||
# "error" — gitea returned 5xx, timed out, or returned malformed data
|
||||
#
|
||||
# The route layer maps these onto HTTP responses; keeping the mapping
|
||||
# out of this module makes the cache transparent to the test harness.
|
||||
|
||||
|
||||
async def _http_get(url: str) -> tuple[int, str]:
|
||||
"""Perform a single GET against `url`; return (status_code, body).
|
||||
|
||||
On timeout or network error, returns (599, error_message). The 599
|
||||
pseudo-status maps to a 502 at the route layer the same way an
|
||||
upstream 5xx does — the caller doesn't care which leg of the
|
||||
network broke.
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT_SEC) as client:
|
||||
r = await client.get(url)
|
||||
return r.status_code, r.text
|
||||
except httpx.HTTPError as e:
|
||||
log.warning("specs fetch failed for %s: %s", url, e)
|
||||
return 599, f"fetch error: {e}"
|
||||
|
||||
|
||||
def fetch_specs_manifest() -> dict[str, Any]:
|
||||
"""Return the configured spec manifest.
|
||||
|
||||
The manifest is derived from the `OHM_DOCS_SPECS` env var (or the
|
||||
framework default if unset / malformed) and carries no network
|
||||
work — it's safe to call on every request. The return shape mirrors
|
||||
the docs_sessions manifest endpoint for frontend consistency:
|
||||
|
||||
{"status": "ok", "specs": [{"name", "title", "url"}, ...]}
|
||||
|
||||
The "url" field is exposed in the manifest so the frontend can
|
||||
offer a "view source on gitea" affordance alongside each rendered
|
||||
spec (operator-stated intent: "include the history so you can see
|
||||
it in git" — that gesture lives in the source link, not on the
|
||||
rendered page).
|
||||
"""
|
||||
specs = _load_configured_specs()
|
||||
return {"status": "ok", "specs": specs}
|
||||
|
||||
|
||||
async def fetch_spec(name: str) -> dict[str, Any]:
|
||||
"""Fetch a single spec body by its manifest `name`.
|
||||
|
||||
The caller is expected to have validated `name` against
|
||||
`_is_valid_name` before calling this — an invalid name shouldn't
|
||||
reach the network. We re-check inside as defense-in-depth: a
|
||||
bogus name here returns the same `{status: "404"}` shape so the
|
||||
route layer's `404 → HTTP 404` mapping handles it uniformly.
|
||||
|
||||
Returns one of:
|
||||
{"status": "ok", "body": "..."}
|
||||
{"status": "404"} — no such spec OR upstream 404
|
||||
{"status": "error", "detail": "..."} — upstream 5xx / timeout
|
||||
"""
|
||||
if not _is_valid_name(name):
|
||||
return {"status": "404"}
|
||||
|
||||
cache_key = f"spec:{name}"
|
||||
cached = _cache_get(cache_key, _content_ttl())
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
specs = _load_configured_specs()
|
||||
match = next((s for s in specs if s["name"] == name), None)
|
||||
if match is None:
|
||||
# Cache the negative — a deployment with an unstable manifest
|
||||
# would still benefit from the TTL window, and the cached 404
|
||||
# is automatically displaced when the next request happens
|
||||
# after TTL expiry.
|
||||
payload: dict[str, Any] = {"status": "404"}
|
||||
_cache_put(cache_key, payload)
|
||||
return payload
|
||||
|
||||
url = match["url"]
|
||||
status, body = await _http_get(url)
|
||||
|
||||
if status == 200:
|
||||
payload = {"status": "ok", "body": body}
|
||||
_cache_put(cache_key, payload)
|
||||
return payload
|
||||
if status == 404:
|
||||
payload = {"status": "404"}
|
||||
_cache_put(cache_key, payload)
|
||||
return payload
|
||||
return {"status": "error", "detail": f"upstream returned {status}"}
|
||||
+176
-10
@@ -24,7 +24,6 @@ import os
|
||||
import smtplib
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, time, timezone
|
||||
from email.message import EmailMessage
|
||||
from email.utils import formataddr
|
||||
from itertools import groupby
|
||||
from typing import Any
|
||||
@@ -33,6 +32,7 @@ from urllib.parse import urlencode
|
||||
from itsdangerous import BadSignature, URLSafeSerializer
|
||||
|
||||
from . import db
|
||||
from .email_envelope import build_envelope
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -69,6 +69,7 @@ class EmailConfig:
|
||||
app_url: str
|
||||
bundle_threshold: int
|
||||
enabled: bool
|
||||
unsubscribe_mailto: str
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "EmailConfig":
|
||||
@@ -84,6 +85,16 @@ class EmailConfig:
|
||||
app_url=os.environ.get("APP_URL", "http://localhost:8000").rstrip("/"),
|
||||
bundle_threshold=int(os.environ.get("EMAIL_BUNDLE_THRESHOLD", "5")),
|
||||
enabled=os.environ.get("EMAIL_ENABLED", "1") not in ("0", "false", "False"),
|
||||
# v0.18.0: the `List-Unsubscribe: <mailto:…>` target on
|
||||
# invite + notification mail. Defaults to the From
|
||||
# address when unset; a deployment can route opt-out
|
||||
# mail to a separate mailbox (e.g., a humans-monitored
|
||||
# account distinct from the no-reply notifications
|
||||
# sender) by setting this explicitly.
|
||||
unsubscribe_mailto=os.environ.get(
|
||||
"EMAIL_UNSUBSCRIBE_MAILTO",
|
||||
os.environ.get("EMAIL_FROM", "notifications@wiggleverse.local"),
|
||||
).strip(),
|
||||
)
|
||||
|
||||
|
||||
@@ -98,6 +109,14 @@ def _signer() -> URLSafeSerializer:
|
||||
|
||||
|
||||
def make_unsubscribe_url(user_id: int, category: str) -> str:
|
||||
"""Build the §15.4 per-category one-click URL.
|
||||
|
||||
`category` is one of `personal-direct`, `structural`,
|
||||
`admin-actionable` (the three per-category flags) or `all`
|
||||
(v0.18.0: the bundle path, which sets `email_opt_out_all = 1`
|
||||
because a bundle covers multiple categories and a per-category
|
||||
opt-out wouldn't honor the user's intent).
|
||||
"""
|
||||
cfg = EmailConfig.from_env()
|
||||
token = _signer().dumps({"u": user_id, "c": category})
|
||||
qs = urlencode({"t": token})
|
||||
@@ -139,6 +158,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",
|
||||
}
|
||||
|
||||
|
||||
@@ -246,7 +269,21 @@ def _send_one(user: Any, notif_id: int, payload: dict, category: str) -> None:
|
||||
return
|
||||
subject = _subject(payload)
|
||||
body = _body(payload, user["id"], category, cfg)
|
||||
sent = _deliver(cfg, user["email"], subject, body)
|
||||
# v0.18.0: notification mail is bulk-adjacent (a watcher can
|
||||
# accumulate dozens of structural events on a busy RFC), so it
|
||||
# carries the full one-click unsubscribe — Gmail and Yahoo
|
||||
# require this for senders at OHM's volume tier per RFC 8058.
|
||||
unsubscribe_url = make_unsubscribe_url(user["id"], category)
|
||||
sent = _deliver(
|
||||
cfg,
|
||||
user["email"],
|
||||
subject,
|
||||
body,
|
||||
unsubscribe_mailto=cfg.unsubscribe_mailto,
|
||||
unsubscribe_url=unsubscribe_url,
|
||||
kind="notification",
|
||||
notification_id=notif_id,
|
||||
)
|
||||
if not sent:
|
||||
return
|
||||
db.conn().execute(
|
||||
@@ -285,6 +322,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:
|
||||
@@ -294,23 +338,65 @@ def _deep_link(payload: dict, cfg: EmailConfig) -> str:
|
||||
return cfg.app_url
|
||||
|
||||
|
||||
def _deliver(cfg: EmailConfig, to_address: str, subject: str, body: str) -> bool:
|
||||
def _deliver(
|
||||
cfg: EmailConfig,
|
||||
to_address: str,
|
||||
subject: str,
|
||||
body: str,
|
||||
*,
|
||||
unsubscribe_mailto: str | None = None,
|
||||
unsubscribe_url: str | None = None,
|
||||
kind: str = "notification",
|
||||
notification_id: int | None = None,
|
||||
) -> bool:
|
||||
"""Build the envelope via the shared `build_envelope` helper and
|
||||
hand it to SMTP.
|
||||
|
||||
The `_SENT` buffer carries the helper's `EmailMessage` under
|
||||
`message` plus the legacy `to`/`from`/`subject`/`body` keys for
|
||||
backward-compatibility with tests that read those directly.
|
||||
Newer tests can assert on the header surface by inspecting
|
||||
`envelope["message"]`.
|
||||
|
||||
v0.18.0 Slice 4: also writes one row to `outbound_emails`
|
||||
capturing the attempt. status='sent' on success, 'failed' on
|
||||
SMTP exception, 'deferred' on the dev-fallback path (no
|
||||
SMTP_HOST configured — the send didn't happen, but the row
|
||||
records the attempt so the admin endpoint can answer "did the
|
||||
framework try?").
|
||||
"""
|
||||
msg = build_envelope(
|
||||
to_address=to_address,
|
||||
from_address=cfg.from_address,
|
||||
from_name=cfg.from_name,
|
||||
subject=subject,
|
||||
body_plain=body,
|
||||
unsubscribe_mailto=unsubscribe_mailto,
|
||||
unsubscribe_url=unsubscribe_url,
|
||||
)
|
||||
envelope = {
|
||||
"to": to_address,
|
||||
"from": formataddr((cfg.from_name, cfg.from_address)),
|
||||
"subject": subject,
|
||||
"body": body,
|
||||
"message": msg,
|
||||
"kind": kind,
|
||||
}
|
||||
_SENT.append(envelope)
|
||||
message_id = msg["Message-ID"]
|
||||
if not cfg.smtp_host:
|
||||
log.info("email (stdout fallback): to=%s subject=%s", to_address, subject)
|
||||
record_outbound(
|
||||
to_address=to_address,
|
||||
from_address=cfg.from_address,
|
||||
subject=subject,
|
||||
kind=kind,
|
||||
status="deferred",
|
||||
message_id=message_id,
|
||||
notification_id=notification_id,
|
||||
)
|
||||
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:
|
||||
@@ -320,12 +406,78 @@ def _deliver(cfg: EmailConfig, to_address: str, subject: str, body: str) -> bool
|
||||
smtp.send_message(msg)
|
||||
finally:
|
||||
smtp.quit()
|
||||
record_outbound(
|
||||
to_address=to_address,
|
||||
from_address=cfg.from_address,
|
||||
subject=subject,
|
||||
kind=kind,
|
||||
status="sent",
|
||||
message_id=message_id,
|
||||
notification_id=notification_id,
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
log.exception("email send failed: to=%s subject=%s", to_address, subject)
|
||||
record_outbound(
|
||||
to_address=to_address,
|
||||
from_address=cfg.from_address,
|
||||
subject=subject,
|
||||
kind=kind,
|
||||
status="failed",
|
||||
error=f"{type(exc).__name__}: {exc}",
|
||||
message_id=message_id,
|
||||
notification_id=notification_id,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def record_outbound(
|
||||
*,
|
||||
to_address: str,
|
||||
from_address: str,
|
||||
subject: str,
|
||||
kind: str,
|
||||
status: str,
|
||||
error: str | None = None,
|
||||
notification_id: int | None = None,
|
||||
message_id: str | None = None,
|
||||
) -> int | None:
|
||||
"""v0.18.0 Slice 4: write one row to `outbound_emails`.
|
||||
|
||||
Returns the inserted row's id, or `None` if the DB connection
|
||||
isn't initialized (which happens in unit tests that don't boot
|
||||
the full app — the write is best-effort and never raises).
|
||||
"""
|
||||
try:
|
||||
cur = db.conn().execute(
|
||||
"""
|
||||
INSERT INTO outbound_emails
|
||||
(to_address, from_address, subject, kind, sent_at, status,
|
||||
error, notification_id, message_id)
|
||||
VALUES (?, ?, ?, ?, datetime('now'), ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
to_address,
|
||||
from_address,
|
||||
subject,
|
||||
kind,
|
||||
status,
|
||||
error,
|
||||
notification_id,
|
||||
message_id,
|
||||
),
|
||||
)
|
||||
return cur.lastrowid
|
||||
except RuntimeError:
|
||||
# db.conn() raises RuntimeError if init() hasn't been called.
|
||||
# Pure-helper unit tests for build_envelope hit this path; the
|
||||
# audit row is best-effort and not part of the contract.
|
||||
return None
|
||||
except Exception:
|
||||
log.exception("outbound_emails write failed: to=%s subject=%s", to_address, subject)
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Quiet-hours release pass — called from the digest job
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -429,13 +581,27 @@ def _send_bundle(cfg: EmailConfig, user: Any, emailable: list) -> int:
|
||||
for r, _cat, extras in group_rows:
|
||||
summary = _summary_for(r["event_kind"], r["actor_display"], r["rfc_title"], extras)
|
||||
sections.append(f" · {summary}")
|
||||
# v0.18.0: the bundle covers multiple categories, so a
|
||||
# per-category opt-out can't honor the user's intent. The
|
||||
# `all` category lands at the §15.4 endpoint and sets
|
||||
# `email_opt_out_all = 1`.
|
||||
unsubscribe_url = make_unsubscribe_url(user["id"], "all")
|
||||
body = (
|
||||
"Activity on RFCs you watch, accumulated during your quiet hours:\n"
|
||||
+ "\n".join(sections)
|
||||
+ f"\n\nOpen your inbox: {cfg.app_url}/inbox\n"
|
||||
+ f"Manage all preferences: {cfg.app_url}/settings/notifications\n"
|
||||
+ f"Unsubscribe from all email: {unsubscribe_url}\n"
|
||||
)
|
||||
sent = _deliver(
|
||||
cfg,
|
||||
user["email"],
|
||||
subject,
|
||||
body,
|
||||
unsubscribe_mailto=cfg.unsubscribe_mailto,
|
||||
unsubscribe_url=unsubscribe_url,
|
||||
kind="bundle",
|
||||
)
|
||||
sent = _deliver(cfg, user["email"], subject, body)
|
||||
if not sent:
|
||||
return 0
|
||||
ids = [r["id"] for r, _, _ in emailable]
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
"""v0.18.0 / roadmap items #18 + #20: a shared envelope builder.
|
||||
|
||||
Every outbound mail in rfc-app today (OTC, admin-invite, watcher
|
||||
notification, "while you were away" bundle, per-RFC invite) constructs
|
||||
its own `email.message.EmailMessage` ad-hoc. The four sites diverged
|
||||
just enough to be a deliverability hazard: missing `Date`, missing
|
||||
`Message-ID`, no `Auto-Submitted`, no `List-Unsubscribe` on the
|
||||
bulk-adjacent paths, no `multipart/alternative` body.
|
||||
|
||||
This module is the one place an `EmailMessage` is constructed. Every
|
||||
send path imports `build_envelope` and calls it; the headers that
|
||||
matter for inbox placement (Date, Message-ID, Auto-Submitted) land
|
||||
uniformly, and the per-kind variations (unsubscribe semantics,
|
||||
HTML alternative) are explicit arguments rather than buried in
|
||||
each call site.
|
||||
|
||||
Per the v0.18.0 proposal at `~/git/ohm-infra/RFC-APP-EMAIL-HYGIENE-PROPOSAL.md`,
|
||||
the per-kind unsubscribe matrix is:
|
||||
|
||||
* OTC: no `List-Unsubscribe` (the recipient explicitly requested
|
||||
the code; advertising an unsubscribe header would imply OHM has
|
||||
them on a list, which it doesn't).
|
||||
* Admin invite / per-RFC invite: `mailto:` form only (the
|
||||
recipient isn't a user yet, so there's no per-user opt-out row
|
||||
to flip; the operator handles ad-hoc opt-outs manually).
|
||||
* Watcher notification / bundle: full `mailto:` + signed-URL
|
||||
`List-Unsubscribe` plus `List-Unsubscribe-Post:
|
||||
List-Unsubscribe=One-Click` per RFC 8058 (Gmail and Yahoo
|
||||
enforce this for bulk-adjacent senders).
|
||||
|
||||
The `is_transactional` flag governs `Auto-Submitted: auto-generated`,
|
||||
which prevents auto-responder loops on every kind of mail we send.
|
||||
All five mail kinds today are transactional in the SMTP sense (no
|
||||
human is at the From mailbox watching for replies), so the default
|
||||
is True; the argument is exposed for future symmetry.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from email.message import EmailMessage
|
||||
from email.utils import formataddr, formatdate, make_msgid
|
||||
|
||||
|
||||
def build_envelope(
|
||||
*,
|
||||
to_address: str,
|
||||
from_address: str,
|
||||
from_name: str,
|
||||
subject: str,
|
||||
body_plain: str,
|
||||
body_html: str | None = None,
|
||||
reply_to: str | None = None,
|
||||
unsubscribe_mailto: str | None = None,
|
||||
unsubscribe_url: str | None = None,
|
||||
is_transactional: bool = True,
|
||||
msgid_domain: str | None = None,
|
||||
) -> EmailMessage:
|
||||
"""Compose an `EmailMessage` with hardened headers.
|
||||
|
||||
`to_address` / `from_address` are bare RFC 5322 addresses;
|
||||
`from_name` is the display label that goes through `formataddr`
|
||||
so spaces / commas in the display string are encoded correctly.
|
||||
|
||||
`body_plain` is mandatory. `body_html` is **reserved and not yet
|
||||
enabled** (security-audit-0026 I3): no send path supplies it today —
|
||||
every rfc-app mail is plain text — and passing it raises
|
||||
`NotImplementedError`. The parameter is kept in the signature for
|
||||
documented future symmetry: when HTML mail is enabled it will land
|
||||
as the second part of a `multipart/alternative` body (text/plain
|
||||
first per RFC 2046, so a plain-text client picking the first part
|
||||
still gets the readable text). Enabling it is a deliberate act — the
|
||||
caller MUST HTML-escape any user content into `body_html` first (cf.
|
||||
the C1 stored-XSS class: a mail client renders the HTML) and remove
|
||||
the guard below in the same change.
|
||||
|
||||
`reply_to`, when set, lets a send path point replies at a
|
||||
different mailbox than the From line (e.g., a watcher
|
||||
notification with From=notifications@... but Reply-To=
|
||||
ohm@... so a confused recipient who hits Reply lands at a
|
||||
monitored mailbox).
|
||||
|
||||
`unsubscribe_mailto` / `unsubscribe_url` populate
|
||||
`List-Unsubscribe`. If `unsubscribe_url` is set, the helper also
|
||||
emits `List-Unsubscribe-Post: List-Unsubscribe=One-Click` per
|
||||
RFC 8058 — Gmail and Yahoo POST that payload on the user's
|
||||
one-click action. (Send paths that wire `unsubscribe_url`
|
||||
therefore MUST also expose a matching POST endpoint that accepts
|
||||
the same token; see `api_notifications.py:email_unsubscribe`.)
|
||||
|
||||
`msgid_domain` defaults to the @-domain of `from_address` so
|
||||
Message-IDs are aligned with the sending domain by default. A
|
||||
deployment that wants the Message-ID domain to track a different
|
||||
surface (e.g., a tracking-domain that's separate from the From
|
||||
domain) can override.
|
||||
|
||||
`Date` is RFC 5322 formatted via `email.utils.formatdate`; the
|
||||
`localtime=True` setting picks the running process's local
|
||||
timezone, which is what every popular MUA does too. (A
|
||||
deployment running in UTC stamps UTC; that's correct, not a
|
||||
bug.)
|
||||
"""
|
||||
msg = EmailMessage()
|
||||
msg["From"] = formataddr((from_name, from_address))
|
||||
msg["To"] = to_address
|
||||
msg["Subject"] = subject
|
||||
msg["Date"] = formatdate(localtime=True)
|
||||
# If the caller didn't pin a Message-ID domain, derive it from the
|
||||
# From address. `make_msgid` accepts None and falls back to the
|
||||
# local hostname, which is the wrong shape for a deliverable
|
||||
# message (the hostname might be `gke-pool-xxx`); a deployment
|
||||
# without a configured From would surface that as a build-time
|
||||
# config error elsewhere, so the fallback here is just defensive.
|
||||
if msgid_domain is None:
|
||||
if "@" in from_address:
|
||||
msgid_domain = from_address.split("@", 1)[1]
|
||||
else:
|
||||
msgid_domain = "localhost"
|
||||
msg["Message-ID"] = make_msgid(domain=msgid_domain)
|
||||
if reply_to:
|
||||
msg["Reply-To"] = reply_to
|
||||
if is_transactional:
|
||||
# RFC 3834: prevents auto-responders (vacation replies, etc.)
|
||||
# from triggering on this message. Every kind of mail rfc-app
|
||||
# sends today is transactional in this sense.
|
||||
msg["Auto-Submitted"] = "auto-generated"
|
||||
if unsubscribe_mailto or unsubscribe_url:
|
||||
parts: list[str] = []
|
||||
if unsubscribe_mailto:
|
||||
parts.append(f"<mailto:{unsubscribe_mailto}>")
|
||||
if unsubscribe_url:
|
||||
parts.append(f"<{unsubscribe_url}>")
|
||||
msg["List-Unsubscribe"] = ", ".join(parts)
|
||||
if unsubscribe_url:
|
||||
# RFC 8058 one-click. Gmail and Yahoo POST the payload
|
||||
# `List-Unsubscribe=One-Click` to the URL on the user's
|
||||
# one-click action; the matching POST endpoint must be
|
||||
# idempotent and not require auth. See
|
||||
# `api_notifications.py` for the receiver.
|
||||
msg["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click"
|
||||
if body_html is not None:
|
||||
# I3 (security-audit-0026): the multipart/alternative HTML path
|
||||
# is intentionally NOT enabled. No send path passes `body_html`
|
||||
# today, and emitting an HTML body built from user-supplied
|
||||
# content without escaping it first would reintroduce the C1
|
||||
# stored-XSS class in the mail channel (the recipient's client
|
||||
# renders the HTML). Fail loudly here rather than silently
|
||||
# shipping HTML: enabling HTML mail is a deliberate change that
|
||||
# MUST HTML-escape user content at the call site and remove this
|
||||
# guard together. The text/plain path below is the only live one.
|
||||
raise NotImplementedError(
|
||||
"HTML email is not enabled (security-audit-0026 I3): do not "
|
||||
"pass body_html until user content is HTML-escaped at the "
|
||||
"call site and this guard is intentionally removed."
|
||||
)
|
||||
msg.set_content(body_plain)
|
||||
return msg
|
||||
@@ -0,0 +1,166 @@
|
||||
"""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.utils import formataddr
|
||||
|
||||
from .email import EmailConfig, _SENT, record_outbound
|
||||
from .email_envelope import build_envelope
|
||||
|
||||
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)
|
||||
# v0.18.0: invite mail carries a `List-Unsubscribe: <mailto:…>`
|
||||
# only (no signed URL) — the invitee isn't a user yet, so there
|
||||
# is no per-user opt-out row to flip. The operator handles
|
||||
# ad-hoc opt-outs from the mailto: target. Per the proposal's
|
||||
# "Tradeoff discussion": the invite was unsolicited from the
|
||||
# recipient's perspective, so the courtesy header is right;
|
||||
# but it can't be a one-click URL because the row doesn't
|
||||
# exist yet.
|
||||
msg = build_envelope(
|
||||
to_address=to_address,
|
||||
from_address=cfg.from_address,
|
||||
from_name=cfg.from_name,
|
||||
subject=subject,
|
||||
body_plain=body,
|
||||
unsubscribe_mailto=cfg.unsubscribe_mailto,
|
||||
)
|
||||
envelope = {
|
||||
"to": to_address,
|
||||
"from": formataddr((cfg.from_name, cfg.from_address)),
|
||||
"subject": subject,
|
||||
"body": body,
|
||||
"kind": "invite",
|
||||
"message": msg,
|
||||
}
|
||||
_SENT.append(envelope)
|
||||
|
||||
message_id = msg["Message-ID"]
|
||||
if not cfg.enabled:
|
||||
log.info("invite email disabled (EMAIL_ENABLED=0): to=%s", to_address)
|
||||
record_outbound(
|
||||
to_address=to_address, from_address=cfg.from_address,
|
||||
subject=subject, kind="invite", status="deferred", message_id=message_id,
|
||||
)
|
||||
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)
|
||||
record_outbound(
|
||||
to_address=to_address, from_address=cfg.from_address,
|
||||
subject=subject, kind="invite", status="deferred", message_id=message_id,
|
||||
)
|
||||
return True
|
||||
|
||||
try:
|
||||
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()
|
||||
record_outbound(
|
||||
to_address=to_address, from_address=cfg.from_address,
|
||||
subject=subject, kind="invite", status="sent", message_id=message_id,
|
||||
)
|
||||
return True
|
||||
except Exception as exc:
|
||||
log.exception("invite email send failed: to=%s", to_address)
|
||||
record_outbound(
|
||||
to_address=to_address, from_address=cfg.from_address,
|
||||
subject=subject, kind="invite", status="failed",
|
||||
error=f"{type(exc).__name__}: {exc}", message_id=message_id,
|
||||
)
|
||||
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,122 @@
|
||||
"""Outbound OTC email — a thin wrapper over the existing SMTP layer.
|
||||
|
||||
The §15.4 notification mailer in `email.py` is purpose-built for
|
||||
inbox-driven mail (unsubscribe footers, quiet-hours holds, bundling).
|
||||
OTC mail is structurally different: it carries a credential, has no
|
||||
inbox row behind it, and ignores user-preferences (a contributor
|
||||
who's opted out of every notification still needs to receive the
|
||||
code they explicitly requested).
|
||||
|
||||
So this module reuses `EmailConfig.from_env()` for the SMTP plumbing
|
||||
and the From identity, but writes its own envelope. In dev (no
|
||||
SMTP_HOST set), the envelope is logged at INFO level and pushed to
|
||||
the same `_SENT` buffer the notification mailer uses, so the
|
||||
integration tests can assert on the outbound shape without standing
|
||||
up an SMTP server.
|
||||
|
||||
The send is synchronous. The `/auth/otc/request` endpoint always
|
||||
returns 202 regardless of send outcome — the user-facing surface
|
||||
doesn't know whether the SMTP relay was reachable, since revealing
|
||||
that would let an attacker probe for valid emails on a tight loop.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import smtplib
|
||||
from email.utils import formataddr
|
||||
|
||||
from .email import EmailConfig, _SENT, record_outbound
|
||||
from .email_envelope import build_envelope
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def send_otc_email(to_address: str, code: str) -> bool:
|
||||
"""Compose and send the one-time-code email. Returns True on the
|
||||
happy path; False on SMTP failure. The notifier-side buffer
|
||||
`_SENT` is appended either way so tests can assert on content.
|
||||
|
||||
The subject and body intentionally avoid branding strings that
|
||||
belong to a deployment — only `EMAIL_FROM_NAME` (operator-supplied
|
||||
via env) lands in the From line. The body names the code, the
|
||||
TTL, and a single instruction line. No tracking pixel, no
|
||||
deep-link query, no embedded JS — plain text only."""
|
||||
cfg = EmailConfig.from_env()
|
||||
subject = f"Your sign-in code for {cfg.from_name}"
|
||||
body = _body(code, cfg)
|
||||
# v0.18.0: OTC mail carries NO List-Unsubscribe — the recipient
|
||||
# explicitly requested the code; advertising an unsubscribe
|
||||
# header would imply OHM has them on a list, which it doesn't.
|
||||
# See the proposal's "Tradeoff discussion" for the binding
|
||||
# rationale.
|
||||
msg = build_envelope(
|
||||
to_address=to_address,
|
||||
from_address=cfg.from_address,
|
||||
from_name=cfg.from_name,
|
||||
subject=subject,
|
||||
body_plain=body,
|
||||
)
|
||||
envelope = {
|
||||
"to": to_address,
|
||||
"from": formataddr((cfg.from_name, cfg.from_address)),
|
||||
"subject": subject,
|
||||
"body": body,
|
||||
"kind": "otc",
|
||||
"message": msg,
|
||||
}
|
||||
_SENT.append(envelope)
|
||||
|
||||
message_id = msg["Message-ID"]
|
||||
if not cfg.enabled:
|
||||
log.info("otc email disabled (EMAIL_ENABLED=0): to=%s", to_address)
|
||||
record_outbound(
|
||||
to_address=to_address, from_address=cfg.from_address,
|
||||
subject=subject, kind="otc", status="deferred", message_id=message_id,
|
||||
)
|
||||
return True
|
||||
if not cfg.smtp_host:
|
||||
# Dev fallback: surface the code at INFO so the operator can
|
||||
# complete a sign-in flow without an SMTP relay. In production
|
||||
# SMTP_HOST is always set per OHM's overlay.
|
||||
log.info("otc email (stdout fallback): to=%s code=%s", to_address, code)
|
||||
record_outbound(
|
||||
to_address=to_address, from_address=cfg.from_address,
|
||||
subject=subject, kind="otc", status="deferred", message_id=message_id,
|
||||
)
|
||||
return True
|
||||
|
||||
try:
|
||||
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()
|
||||
record_outbound(
|
||||
to_address=to_address, from_address=cfg.from_address,
|
||||
subject=subject, kind="otc", status="sent", message_id=message_id,
|
||||
)
|
||||
return True
|
||||
except Exception as exc:
|
||||
log.exception("otc email send failed: to=%s", to_address)
|
||||
record_outbound(
|
||||
to_address=to_address, from_address=cfg.from_address,
|
||||
subject=subject, kind="otc", status="failed",
|
||||
error=f"{type(exc).__name__}: {exc}", message_id=message_id,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def _body(code: str, cfg: EmailConfig) -> str:
|
||||
return (
|
||||
f"Your sign-in code is:\n\n"
|
||||
f" {code}\n\n"
|
||||
f"Enter this code in the sign-in screen to finish signing in.\n"
|
||||
f"The code expires in 10 minutes. If you did not request this,\n"
|
||||
f"you can safely ignore this email — no account was created.\n\n"
|
||||
f"---\n"
|
||||
f"{cfg.from_name} · {cfg.app_url}\n"
|
||||
)
|
||||
@@ -284,9 +284,10 @@ async def _delete_branch_via_bot(
|
||||
reason: str,
|
||||
) -> bool:
|
||||
"""Call `bot.delete_branch` with the system actor. Resolves the
|
||||
`(org, repo)` pair from the slug: super-draft edit branches and
|
||||
graduation branches live on the meta repo; active-RFC branches
|
||||
live on the per-RFC repo named by `cached_rfcs.repo`.
|
||||
`(org, repo)` pair from the slug: under the meta-only topology (§1)
|
||||
every meta-resident entry's edit branches and graduation branches
|
||||
live on the meta repo; a legacy per-RFC repo (a `repo:` that survives
|
||||
from before the fold-back, §13.6) is named by `cached_rfcs.repo`.
|
||||
|
||||
Returns True on a clean delete; False if the rfc row is missing
|
||||
(we leave the branch row in place — a subsequent reconciler sweep
|
||||
@@ -297,12 +298,13 @@ async def _delete_branch_via_bot(
|
||||
if rfc is None:
|
||||
log.warning("hygiene: cannot delete %s/%s — slug missing from cache", slug, branch)
|
||||
return False
|
||||
if rfc["state"] == "super-draft":
|
||||
if not rfc["repo"]:
|
||||
owner, repo = config.gitea_org, config.meta_repo
|
||||
elif rfc["state"] == "active" and rfc["repo"] and "/" in rfc["repo"]:
|
||||
elif "/" in rfc["repo"]:
|
||||
owner, repo = rfc["repo"].split("/", 1)
|
||||
else:
|
||||
log.warning("hygiene: cannot resolve repo for %s state=%s", slug, rfc["state"])
|
||||
log.warning("hygiene: cannot resolve repo for %s state=%s repo=%r",
|
||||
slug, rfc["state"], rfc["repo"])
|
||||
return False
|
||||
try:
|
||||
await bot.delete_branch(
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
"""§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.
|
||||
* `created_at` / `last_seen_at` — NOT set here, so both fall
|
||||
through to the column default `datetime('now')` (the column is
|
||||
`NOT NULL`; see `migrations/001_users_and_audit.sql` and the
|
||||
longer note below). The "invited but not yet arrived" state is
|
||||
therefore NOT carried on the user row — it is the existence of
|
||||
an unclaimed `user_invite_tokens` row, surfaced as the listing's
|
||||
`pending_invite` field. Consumers that want a truthful
|
||||
last-seen MUST treat a pending-invite row as never-seen rather
|
||||
than trusting `last_seen_at` (every real sign-in path stamps it
|
||||
to now, but an unclaimed invite has never hit one).
|
||||
* `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
|
||||
]
|
||||
+479
-4
@@ -7,14 +7,32 @@ no need for a separate worker.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import APIRouter, FastAPI, HTTPException, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi import APIRouter, FastAPI, HTTPException, Request, Response
|
||||
from fastapi.responses import JSONResponse, RedirectResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from . import api as api_routes, auth, cache, db, digest, hygiene, providers as providers_mod, webhooks
|
||||
from . import (
|
||||
api as api_routes,
|
||||
auth,
|
||||
cache,
|
||||
db,
|
||||
device_trust as device_trust_mod,
|
||||
digest,
|
||||
email_otc,
|
||||
hygiene,
|
||||
invites as invites_mod,
|
||||
otc,
|
||||
passcode as passcode_mod,
|
||||
providers as providers_mod,
|
||||
ratelimit,
|
||||
turnstile,
|
||||
webhooks,
|
||||
)
|
||||
from .bot import Bot
|
||||
from .config import load_config
|
||||
from .gitea import Gitea
|
||||
@@ -23,6 +41,59 @@ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name
|
||||
log = logging.getLogger("rfc_app")
|
||||
|
||||
|
||||
class OtcRequestBody(BaseModel):
|
||||
email: str = Field(min_length=3, max_length=320)
|
||||
# v0.12.0 / roadmap item #10: CloudFlare Turnstile token from the
|
||||
# frontend widget. Optional in the body so a deployment that has
|
||||
# not yet wired the Turnstile site key (or a dev environment with
|
||||
# the widget intentionally skipped) still routes through the same
|
||||
# endpoint; the backend turnstile.verify_token call decides whether
|
||||
# to admit the request based on `TURNSTILE_REQUIRED` + presence of
|
||||
# the secret.
|
||||
turnstile_token: str | None = Field(default=None, max_length=4096)
|
||||
|
||||
|
||||
class OtcVerifyBody(BaseModel):
|
||||
email: str = Field(min_length=3, max_length=320)
|
||||
code: str = Field(min_length=1, max_length=16)
|
||||
# v0.11.0 — "trust this device for 30 days" checkbox on the Login.jsx
|
||||
# OTC step. When true and verify succeeds, the server issues a fresh
|
||||
# device-trust row and sets the `rfc_device_trust` cookie on the
|
||||
# response. Defaults to false so existing clients that don't send
|
||||
# the flag continue to behave the way they did pre-v0.11.0.
|
||||
trust_device: bool = False
|
||||
|
||||
|
||||
class PasscodeSetBody(BaseModel):
|
||||
passcode: str = Field(min_length=1, max_length=64)
|
||||
|
||||
|
||||
class PasscodeVerifyBody(BaseModel):
|
||||
email: str = Field(min_length=3, max_length=320)
|
||||
passcode: str = Field(min_length=1, max_length=64)
|
||||
# v0.11.0 — same trust-device opt-in as the OTC verify body.
|
||||
trust_device: bool = False
|
||||
|
||||
|
||||
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
|
||||
async def lifespan(app: FastAPI):
|
||||
config = load_config()
|
||||
@@ -73,12 +144,20 @@ def create_app() -> FastAPI:
|
||||
# eagerly via load_config(). Everything else waits for lifespan.
|
||||
config = load_config()
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
# v0.25.0 (audit 0026 M4): the session cookie is the primary 30-day
|
||||
# auth credential and must carry `Secure` in production so it never
|
||||
# travels cleartext. Default to Secure; a dev box serving over plain
|
||||
# http opts out with SESSION_COOKIE_SECURE=false. Production (OHM is
|
||||
# HTTPS-only with an HTTP->HTTPS 301) leaves this unset → Secure on.
|
||||
session_secure = os.environ.get("SESSION_COOKIE_SECURE", "true").strip().lower() not in (
|
||||
"0", "false", "no", "off",
|
||||
)
|
||||
app.add_middleware(
|
||||
SessionMiddleware,
|
||||
secret_key=config.secret_key,
|
||||
session_cookie="rfc_session",
|
||||
max_age=60 * 60 * 24 * 30,
|
||||
https_only=False,
|
||||
https_only=session_secure,
|
||||
)
|
||||
return app
|
||||
|
||||
@@ -86,6 +165,49 @@ def create_app() -> FastAPI:
|
||||
app = create_app()
|
||||
|
||||
|
||||
def _set_device_trust_cookie(response: Response, cookie_value: str) -> None:
|
||||
"""Attach the v0.11.0 device-trust cookie to the response.
|
||||
|
||||
HttpOnly + Secure + SameSite=Lax + 30-day Max-Age + Path=/. As of
|
||||
v0.25.0 (audit 0026 M1) the value is `IssueOutcome.cookie_value` —
|
||||
"<row_id>.<raw_token>" — so `device_trust.lookup` can read one indexed
|
||||
row instead of scanning; server-side storage remains the bcrypt hash
|
||||
of the token half only. 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
|
||||
device-trust cookie holds a 30-day credential and must never travel
|
||||
cleartext. (The session cookie now also defaults to Secure; see M4 in
|
||||
`create_app`.)
|
||||
"""
|
||||
response.set_cookie(
|
||||
key=device_trust_mod.COOKIE_NAME,
|
||||
value=cookie_value,
|
||||
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()
|
||||
|
||||
@@ -107,6 +229,12 @@ def _oauth_router(config) -> APIRouter:
|
||||
if not access_token:
|
||||
raise HTTPException(400, "Token exchange failed")
|
||||
profile = await auth.fetch_user_profile(config, access_token)
|
||||
if not auth.is_allowed_sign_in(profile):
|
||||
# Private-beta gate: clear any partial OAuth state and bounce to
|
||||
# the public /beta-pending page. The session is left empty so the
|
||||
# rejected viewer continues as anonymous read-only.
|
||||
request.session.pop(auth.SESSION_STATE_KEY, None)
|
||||
return RedirectResponse("/beta-pending")
|
||||
user = auth.provision_user(config, profile)
|
||||
auth.store_session(request, user)
|
||||
return RedirectResponse("/")
|
||||
@@ -116,4 +244,351 @@ def _oauth_router(config) -> APIRouter:
|
||||
request.session.clear()
|
||||
return RedirectResponse("/")
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# v0.7.0: email + one-time-code sign-in (§6.2).
|
||||
#
|
||||
# Replaces the OAuth gesture as the primary human-auth path. The
|
||||
# /auth/callback handler above remains functional as a fallback;
|
||||
# the new UI no longer surfaces it. A future release retires the
|
||||
# OAuth path entirely once every active user has signed in at
|
||||
# least once via OTC.
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.post("/auth/otc/request")
|
||||
async def otc_request(body: OtcRequestBody, request: Request):
|
||||
# v0.25.0 (audit 0026 H1/L2): per-IP brake at the cheapest point,
|
||||
# before the Turnstile network call or any bcrypt/SMTP work.
|
||||
if not ratelimit.otc_request_limiter.allow(ratelimit.client_key(request)):
|
||||
raise HTTPException(429, "Too many requests; please wait a few minutes")
|
||||
# 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 = await turnstile.verify_token(body.turnstile_token, client_ip=client_ip)
|
||||
if not ts.ok:
|
||||
if ts.reason == "misconfigured":
|
||||
# TURNSTILE_REQUIRED=true but the secret is unset. This
|
||||
# is an operator/config problem, not a client problem;
|
||||
# surface as 500 so the operator notices in their logs
|
||||
# rather than blaming the user's browser.
|
||||
raise HTTPException(500, "auth misconfigured")
|
||||
# missing-token / failed / network → uniform 400 so the
|
||||
# response does not enumerate which leg of the challenge
|
||||
# broke. The reason is in the server logs.
|
||||
raise HTTPException(400, "verification failed")
|
||||
|
||||
outcome = otc.request_code(body.email)
|
||||
if outcome.reason == "cooldown":
|
||||
# Loud failure per the rate-limit primitive — the abuse
|
||||
# surface should be visible to clients hammering /request.
|
||||
raise HTTPException(429, "Wait before requesting another code")
|
||||
if outcome.sent and outcome.code is not None:
|
||||
email_otc.send_otc_email(body.email.strip(), outcome.code)
|
||||
# 202 regardless of allowlist/invalid — don't leak which
|
||||
# emails are recognized.
|
||||
return {"ok": True}
|
||||
|
||||
@router.post("/auth/otc/verify")
|
||||
async def otc_verify(body: OtcVerifyBody, request: Request, response: Response):
|
||||
# v0.25.0 (audit 0026 H1): per-IP brake against fan-out guessing,
|
||||
# plus the per-email lockout enforced inside otc.verify_code.
|
||||
ip = ratelimit.client_key(request)
|
||||
if not ratelimit.verify_limiter.allow(ip):
|
||||
raise HTTPException(429, "Too many attempts; please wait a few minutes")
|
||||
result = otc.verify_code(body.email, body.code)
|
||||
if result.reason == "locked":
|
||||
raise HTTPException(
|
||||
423,
|
||||
{
|
||||
"detail": "Too many failed attempts; wait a few minutes or request a new code",
|
||||
"locked_until": result.locked_until,
|
||||
},
|
||||
)
|
||||
if not result.ok or result.user is None:
|
||||
raise HTTPException(400, "Invalid or expired code")
|
||||
# Legit sign-in: clear this IP's window so a user who fat-fingered
|
||||
# a couple of codes isn't left throttled.
|
||||
ratelimit.verify_limiter.reset(ip)
|
||||
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.cookie_value)
|
||||
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(request: Request, 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.
|
||||
|
||||
v0.25.0 (audit 0026 L3): per-IP rate limit so the has-passcode
|
||||
boolean can't be bulk-harvested to enumerate accounts."""
|
||||
if not ratelimit.check_limiter.allow(ratelimit.client_key(request)):
|
||||
raise HTTPException(429, "Too many requests; please wait a few minutes")
|
||||
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`."""
|
||||
# v0.25.0 (audit 0026 H1): per-IP brake in front of the per-account
|
||||
# passcode lockout, so fan-out across emails is throttled too.
|
||||
ip = ratelimit.client_key(request)
|
||||
if not ratelimit.verify_limiter.allow(ip):
|
||||
raise HTTPException(429, "Too many attempts; please wait a few minutes")
|
||||
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")
|
||||
ratelimit.verify_limiter.reset(ip)
|
||||
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.cookie_value)
|
||||
return {
|
||||
"ok": True,
|
||||
"user": {
|
||||
"id": result.user.user_id,
|
||||
"display_name": result.user.display_name,
|
||||
"email": result.user.email,
|
||||
"role": result.user.role,
|
||||
},
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# v0.17.0: admin-create user + invite claim (§6.1, roadmap item #16).
|
||||
#
|
||||
# The admin-create surface lives at POST /api/admin/users (see
|
||||
# api_admin.py); this endpoint is the corresponding claim path the
|
||||
# invitee hits when they click the link in their invite email.
|
||||
# The frontend route `/invites/claim?token=…` reads the token from
|
||||
# the URL and POSTs it here.
|
||||
#
|
||||
# The claim itself is the first-sign-in for the invitee: clicking
|
||||
# the unique token in the email is proof of email control per the
|
||||
# roadmap, so this endpoint skips the OTC step entirely on first
|
||||
# sign-in. The session cookie lands; the response tells the
|
||||
# frontend whether to route to passcode-set (if v0.10.0 passcode
|
||||
# flow is in play and the user has not yet set a passcode) or to
|
||||
# home.
|
||||
#
|
||||
# The endpoint is anonymous-reachable: the entire point is to
|
||||
# establish the session, so we do not gate it on `require_user`.
|
||||
# The trust-device opt-in mirrors the v0.11.0 OTC/passcode verify
|
||||
# contract (the body's `trust_device` flag, when true, mints a
|
||||
# fresh device-trust row on the same response so the invitee
|
||||
# lands trusted on their first device).
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.post("/api/invites/claim")
|
||||
async def invites_claim(body: InviteClaimBody, request: Request, response: Response):
|
||||
result = invites_mod.claim(body.token)
|
||||
if result.reason == "expired":
|
||||
# The token's TTL window passed without a claim. HTTP 410
|
||||
# (Gone) so the frontend can render a "this invite has
|
||||
# expired — please contact the admin for a fresh one"
|
||||
# message distinct from the generic invalid-token shape.
|
||||
raise HTTPException(410, "This invite has expired")
|
||||
if result.reason == "claimed":
|
||||
# The token was already consumed. HTTP 410 for the same
|
||||
# reason — the row is dead either way.
|
||||
raise HTTPException(410, "This invite has already been claimed")
|
||||
if not result.ok or result.user is None:
|
||||
# 'unknown' / 'invalid' — the token does not match any
|
||||
# active invite row. HTTP 400 so it reads distinct from
|
||||
# the dead-token shape above.
|
||||
raise HTTPException(400, "Invalid invite token")
|
||||
|
||||
# Establish the session. From here on the invitee is signed
|
||||
# in as the pre-provisioned user row carrying their
|
||||
# pre-assigned role.
|
||||
auth.store_session(request, result.user)
|
||||
|
||||
# v0.11.0 — opt-in device trust on the claim response. Same
|
||||
# contract as OTC/passcode verify: when the body's flag is
|
||||
# true, the server mints a fresh device-trust row and sets
|
||||
# the long-lived cookie, so the invitee skips the email step
|
||||
# on subsequent visits to the same browser.
|
||||
if body.trust_device:
|
||||
ua = request.headers.get("user-agent", "")
|
||||
outcome = device_trust_mod.issue(result.user.user_id, ua)
|
||||
_set_device_trust_cookie(response, outcome.cookie_value)
|
||||
|
||||
# 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
|
||||
|
||||
+176
-1
@@ -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,11 +209,152 @@ 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_contribution_request(
|
||||
*,
|
||||
rfc_slug: str,
|
||||
requester_user_id: int,
|
||||
request_id: int,
|
||||
matched_term: str,
|
||||
who_i_am: str,
|
||||
why: str,
|
||||
use_case: str | None,
|
||||
) -> list[int]:
|
||||
"""Roadmap #28 Part 3: a reader asked to contribute to a pending
|
||||
(super-draft) RFC. Land one actionable notification per owner and
|
||||
return their ids (the caller stamps the first onto the request row as
|
||||
the inbox-action handle).
|
||||
|
||||
Personal-direct: the owner is the named subject of the request, so the
|
||||
§15.4 email gate consults `email_personal_direct` exactly as for the
|
||||
other owner-facing personal events — no new preference column is
|
||||
needed. The request's three free-text fields ride along in the payload
|
||||
so the inbox row can show the full ask inline without a second fetch.
|
||||
Actor is the requester per §15.9.
|
||||
"""
|
||||
requester = db.conn().execute(
|
||||
"SELECT display_name FROM users WHERE id = ?", (requester_user_id,)
|
||||
).fetchone()
|
||||
display = (requester["display_name"] if requester else None) or "Someone"
|
||||
details = {
|
||||
"request_id": request_id,
|
||||
"matched_term": matched_term,
|
||||
"requester_user_id": requester_user_id,
|
||||
"requester_display": display,
|
||||
"who_i_am": who_i_am,
|
||||
"why": why,
|
||||
"use_case": use_case or "",
|
||||
}
|
||||
notif_ids: list[int] = []
|
||||
for recipient_id in _entry_owner_user_ids(rfc_slug):
|
||||
if recipient_id == requester_user_id:
|
||||
continue
|
||||
notif_ids.append(
|
||||
_emit_one(
|
||||
recipient_user_id=recipient_id,
|
||||
event_kind="contribution_request_on_pending_rfc",
|
||||
category=CATEGORY_PERSONAL,
|
||||
actor_user_id=requester_user_id,
|
||||
rfc_slug=rfc_slug,
|
||||
branch_name=None,
|
||||
pr_number=None,
|
||||
details=details,
|
||||
)
|
||||
)
|
||||
return notif_ids
|
||||
|
||||
|
||||
def notify_contribution_decided(
|
||||
*,
|
||||
rfc_slug: str,
|
||||
requester_user_id: int,
|
||||
decider_user_id: int,
|
||||
request_id: int,
|
||||
accepted: bool,
|
||||
) -> None:
|
||||
"""Roadmap #28 Part 3: tell the requester an owner accepted or declined
|
||||
their contribute request. On accept the requester also receives the
|
||||
#12 invitation email out-of-band; this inbox row is the in-app echo
|
||||
that points them at it."""
|
||||
_emit_one(
|
||||
recipient_user_id=requester_user_id,
|
||||
event_kind=(
|
||||
"contribution_request_accepted" if accepted else "contribution_request_declined"
|
||||
),
|
||||
category=CATEGORY_PERSONAL,
|
||||
actor_user_id=decider_user_id,
|
||||
rfc_slug=rfc_slug,
|
||||
branch_name=None,
|
||||
pr_number=None,
|
||||
details={"request_id": request_id},
|
||||
)
|
||||
|
||||
|
||||
def fan_out_chat_message(
|
||||
*,
|
||||
actor_user_id: int,
|
||||
rfc_slug: str,
|
||||
branch_name: str,
|
||||
branch_name: str | None,
|
||||
thread_id: int,
|
||||
message_id: int,
|
||||
is_review_thread: bool = False,
|
||||
@@ -227,6 +369,14 @@ def fan_out_chat_message(
|
||||
(state='watching', i.e. full stream) get a churn-class
|
||||
`chat_message_in_participated_thread`. The two are union'd so a user
|
||||
who is both gets only the personal-direct row.
|
||||
|
||||
v0.5.0: `branch_name` may be None — that is the PR-less per-RFC
|
||||
discussion shape (`threads.branch_name IS NULL`, §5). The
|
||||
notifications row carries the null through; the inbox prose renders
|
||||
identically whether the chat lives on a branch or on the RFC's
|
||||
discussion surface, and the §15.7 reconciler keys on
|
||||
(rfc_slug, branch_name) so a null branch correctly matches the
|
||||
PR-less discussion's eventual chat-seen-equivalent advance.
|
||||
"""
|
||||
_bump_auto_watch(actor_user_id, rfc_slug)
|
||||
|
||||
@@ -699,6 +849,26 @@ 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 == "contribution_request_on_pending_rfc":
|
||||
# Roadmap #28 Part 3: owner-facing, actionable. The term is the
|
||||
# super-draft reference that surfaced the offer; the inbox row
|
||||
# renders Accept/Decline beneath this line.
|
||||
term = extras.get("matched_term") or title
|
||||
return f"{actor} wants to contribute to your pending RFC for '{term}'."
|
||||
if event_kind == "contribution_request_accepted":
|
||||
return f"{actor} accepted your request to contribute to {title} — check your email to accept the invitation."
|
||||
if event_kind == "contribution_request_declined":
|
||||
return f"{actor} declined your request to contribute to {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}"
|
||||
|
||||
|
||||
@@ -804,6 +974,11 @@ def list_inbox(
|
||||
"read_at": row["read_at"],
|
||||
"category": extras.get("category"),
|
||||
"summary": render_summary(row["event_kind"], row["actor_display"], row["rfc_title"], extras),
|
||||
# The row's payload, surfaced for kinds that render inline
|
||||
# detail (e.g. #28 Part 3's contribute-request who/why/use-case
|
||||
# + Accept/Decline). Safe to expose: a recipient only ever sees
|
||||
# their own notifications.
|
||||
"extras": extras,
|
||||
})
|
||||
|
||||
if bundled:
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
"""§6.2 / v0.7.0 / v0.8.0: email + one-time-code sign-in.
|
||||
|
||||
Replaces the Gitea OAuth gesture as the primary human-auth path. The
|
||||
Gitea bot user + token are still needed for server-side git
|
||||
operations (repo reads, PR creation); only the operator-facing
|
||||
sign-in surface moves through this module.
|
||||
|
||||
The shape:
|
||||
|
||||
* `request_code(email)` generates a 6-digit decimal code,
|
||||
hashes it (bcrypt), stores the hash + expiry in `otc_codes`,
|
||||
and dispatches a plain-text email via `email_otc.send`. It
|
||||
invalidates any prior unused codes for the same email so a
|
||||
re-request keeps the surface to one outstanding code per
|
||||
address. The TTL comes from `OTC_TTL_MINUTES` (default 10).
|
||||
A per-email cooldown (`OTC_REQUEST_COOLDOWN_SECONDS`, default
|
||||
60) refuses back-to-back requests inside the window.
|
||||
|
||||
* `verify_code(email, code)` walks the most recent unconsumed
|
||||
non-expired row for the email, checks the bcrypt hash, marks
|
||||
the row consumed, and returns the linked or freshly-provisioned
|
||||
user row.
|
||||
|
||||
* `provision_or_link_user(email)` is the migration path: if a
|
||||
`users` row already carries `email` (case-insensitive), it is
|
||||
reused — `gitea_id` is left alone so a grandfathered OAuth-era
|
||||
user keeps the linker intact. Otherwise a fresh contributor
|
||||
row is provisioned with `gitea_id = NULL`, `gitea_login = NULL`,
|
||||
and `permission_state = 'pending'` (v0.8.0 — see below).
|
||||
|
||||
The endpoints in `main.py` thin-wrap this module.
|
||||
|
||||
v0.8.0 (roadmap item #6) replaces the v0.3.0 `allowed_emails` gate at
|
||||
the request surface. The request handler used to silently drop OTC
|
||||
requests for emails not on the allowlist; now any valid email
|
||||
receives a code. The admission gate moves to `permission_state` on
|
||||
the freshly-provisioned `users` row: a fresh user lands in 'pending'
|
||||
and waits for an admin grant before write endpoints accept them.
|
||||
Read surfaces stay open (the same blast radius v0.6.0 / item #4
|
||||
already audited for anonymous viewers).
|
||||
|
||||
The `allowed_emails` table itself stays in the schema as a
|
||||
fast-path bypass — the admin UI from v0.3.0 continues to manage it,
|
||||
and a future release (v0.9.0's admin user-management page) collapses
|
||||
the two admission surfaces into one. The OTC request path no
|
||||
longer consults the table.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
from dataclasses import dataclass
|
||||
|
||||
import bcrypt
|
||||
|
||||
from . import db
|
||||
from .auth import SessionUser
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tunables — env-driven with defaults so v0.7.0 needs no new secrets.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _ttl_minutes() -> int:
|
||||
raw = os.environ.get("OTC_TTL_MINUTES", "").strip()
|
||||
if not raw:
|
||||
return 10
|
||||
try:
|
||||
return max(1, int(raw))
|
||||
except ValueError:
|
||||
return 10
|
||||
|
||||
|
||||
def _cooldown_seconds() -> int:
|
||||
raw = os.environ.get("OTC_REQUEST_COOLDOWN_SECONDS", "").strip()
|
||||
if not raw:
|
||||
return 60
|
||||
try:
|
||||
return max(0, int(raw))
|
||||
except ValueError:
|
||||
return 60
|
||||
|
||||
|
||||
# v0.25.0 / security audit 0026 (H1): per-email OTC verify lockout,
|
||||
# mirroring the passcode path (passcode.py). Five consecutive wrong codes
|
||||
# for an email lock its OTC verify for 15 minutes. The per-IP limiter in
|
||||
# ratelimit.py is the primary brute-force brake; this is the durable,
|
||||
# passcode-parity layer.
|
||||
LOCKOUT_AFTER_FAILED_ATTEMPTS = 5
|
||||
LOCKOUT_DURATION_MINUTES = 15
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Code generation + hashing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _new_code() -> str:
|
||||
"""Six decimal digits. `secrets.randbelow` is CSPRNG-backed so the
|
||||
code resists guessing even at the small (10^6) keyspace. The TTL
|
||||
+ rate-limit are what carry the security weight — the entropy of a
|
||||
six-digit code by itself is intentionally human-readable."""
|
||||
return f"{secrets.randbelow(1_000_000):06d}"
|
||||
|
||||
|
||||
def _hash_code(code: str) -> str:
|
||||
"""bcrypt over the code bytes. The hash is stored at rest; the code
|
||||
itself only travels in the outbound email and the inbound verify
|
||||
body."""
|
||||
return bcrypt.hashpw(code.encode("utf-8"), bcrypt.gensalt()).decode("ascii")
|
||||
|
||||
|
||||
def _check_code(code: str, code_hash: str) -> bool:
|
||||
try:
|
||||
return bcrypt.checkpw(code.encode("utf-8"), code_hash.encode("ascii"))
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request path
|
||||
#
|
||||
# v0.8.0: the allowlist gate from v0.7.0 / v0.3.0 is removed here. Any
|
||||
# valid email receives a code; the admission gate moved to
|
||||
# `permission_state` on the freshly-provisioned `users` row (see
|
||||
# `provision_or_link_user`). The `allowed_emails` table stays in the
|
||||
# schema (admin UI from v0.3.0 still manages it); v0.9.0's admin
|
||||
# user-management page will collapse the two surfaces.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class RequestOutcome:
|
||||
"""The outcome of a `request_code` call.
|
||||
|
||||
`code` is None whenever no code was generated — the cooldown
|
||||
window blocked the request or the email was syntactically
|
||||
invalid. The caller (the API endpoint) does not surface the
|
||||
invalid-email shape to the user; it returns 202 either way.
|
||||
The cooldown shape surfaces as a loud 429 per the v0.7.0
|
||||
contract.
|
||||
"""
|
||||
sent: bool
|
||||
code: str | None
|
||||
reason: str # 'sent' | 'cooldown' | 'invalid'
|
||||
|
||||
|
||||
def request_code(email: str) -> RequestOutcome:
|
||||
email = (email or "").strip()
|
||||
if not email or "@" not in email:
|
||||
return RequestOutcome(sent=False, code=None, reason="invalid")
|
||||
|
||||
# Cooldown: refuse if a code was issued for this email in the last
|
||||
# COOLDOWN_SECONDS. We surface it as a distinct outcome so the
|
||||
# endpoint can return 429 — the spec calls this out as a "loud
|
||||
# failure" so the abuse path is visible rather than swallowed.
|
||||
cooldown = _cooldown_seconds()
|
||||
if cooldown > 0:
|
||||
row = db.conn().execute(
|
||||
f"""
|
||||
SELECT 1 FROM otc_codes
|
||||
WHERE email = ?
|
||||
AND datetime(created_at, '+{cooldown} seconds') > datetime('now')
|
||||
LIMIT 1
|
||||
""",
|
||||
(email,),
|
||||
).fetchone()
|
||||
if row is not None:
|
||||
return RequestOutcome(sent=False, code=None, reason="cooldown")
|
||||
|
||||
# Invalidate prior unused codes for this email. A re-request is
|
||||
# always for the most recent code; older codes are dead.
|
||||
db.conn().execute(
|
||||
"""
|
||||
UPDATE otc_codes
|
||||
SET consumed_at = datetime('now')
|
||||
WHERE email = ?
|
||||
AND consumed_at IS NULL
|
||||
""",
|
||||
(email,),
|
||||
)
|
||||
|
||||
code = _new_code()
|
||||
code_hash = _hash_code(code)
|
||||
ttl = _ttl_minutes()
|
||||
db.conn().execute(
|
||||
f"""
|
||||
INSERT INTO otc_codes (email, code_hash, expires_at)
|
||||
VALUES (?, ?, datetime('now', '+{ttl} minutes'))
|
||||
""",
|
||||
(email, code_hash),
|
||||
)
|
||||
return RequestOutcome(sent=True, code=code, reason="sent")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Verify path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class VerifyOutcome:
|
||||
"""Result of a `verify_code` call.
|
||||
|
||||
`user` is populated only on success. `reason` distinguishes the
|
||||
failure modes the UI can render — 'expired', 'consumed', 'wrong',
|
||||
'unknown' (no outstanding code at all). The endpoint maps the
|
||||
failure modes to a single 400 with a generic message; the reason
|
||||
is logged for the operator.
|
||||
"""
|
||||
ok: bool
|
||||
user: SessionUser | None
|
||||
reason: str
|
||||
# v0.25.0 (H1): ISO-8601 stamp when reason == 'locked'.
|
||||
locked_until: str | None = None
|
||||
|
||||
|
||||
def _verify_lockout_until(email: str) -> str | None:
|
||||
"""Return the active lockout stamp for `email`, or None if not locked.
|
||||
|
||||
Clears an elapsed lockout (and resets the counter) as a side effect so
|
||||
the next failure starts a fresh budget — mirrors passcode.verify_passcode.
|
||||
"""
|
||||
row = db.conn().execute(
|
||||
"SELECT failed_attempts, locked_until FROM otc_verify_state WHERE email = ?",
|
||||
(email,),
|
||||
).fetchone()
|
||||
if row is None or not row["locked_until"]:
|
||||
return None
|
||||
still_locked = db.conn().execute(
|
||||
"SELECT datetime(?) > datetime('now') AS locked", (row["locked_until"],),
|
||||
).fetchone()["locked"]
|
||||
if still_locked:
|
||||
return row["locked_until"]
|
||||
db.conn().execute(
|
||||
"UPDATE otc_verify_state SET failed_attempts = 0, locked_until = NULL WHERE email = ?",
|
||||
(email,),
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _record_verify_failure(email: str) -> None:
|
||||
"""Increment the per-email failure counter; stamp a lockout once it
|
||||
crosses the threshold. Mirrors the passcode lockout shape."""
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO otc_verify_state (email, failed_attempts)
|
||||
VALUES (?, 1)
|
||||
ON CONFLICT(email) DO UPDATE SET failed_attempts = failed_attempts + 1
|
||||
""",
|
||||
(email,),
|
||||
)
|
||||
count = db.conn().execute(
|
||||
"SELECT failed_attempts FROM otc_verify_state WHERE email = ?", (email,),
|
||||
).fetchone()["failed_attempts"]
|
||||
if count >= LOCKOUT_AFTER_FAILED_ATTEMPTS:
|
||||
db.conn().execute(
|
||||
f"""
|
||||
UPDATE otc_verify_state
|
||||
SET locked_until = datetime('now', '+{LOCKOUT_DURATION_MINUTES} minutes')
|
||||
WHERE email = ?
|
||||
""",
|
||||
(email,),
|
||||
)
|
||||
|
||||
|
||||
def _clear_verify_state(email: str) -> None:
|
||||
db.conn().execute("DELETE FROM otc_verify_state WHERE email = ?", (email,))
|
||||
|
||||
|
||||
def verify_code(email: str, code: str) -> VerifyOutcome:
|
||||
email = (email or "").strip()
|
||||
code = (code or "").strip()
|
||||
if not email or not code:
|
||||
return VerifyOutcome(ok=False, user=None, reason="invalid")
|
||||
|
||||
# v0.25.0 (H1): refuse before spending any bcrypt if this email is in
|
||||
# its OTC-verify lockout window. The passcode path is unaffected — a
|
||||
# locked-out OTC user can still set/use a passcode, and vice versa.
|
||||
locked_until = _verify_lockout_until(email)
|
||||
if locked_until:
|
||||
return VerifyOutcome(ok=False, user=None, reason="locked", locked_until=locked_until)
|
||||
|
||||
rows = db.conn().execute(
|
||||
"""
|
||||
SELECT id, code_hash, expires_at, consumed_at
|
||||
FROM otc_codes
|
||||
WHERE email = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT 5
|
||||
""",
|
||||
(email,),
|
||||
).fetchall()
|
||||
if not rows:
|
||||
return VerifyOutcome(ok=False, user=None, reason="unknown")
|
||||
|
||||
# Walk the recent rows so a user who pasted an older code still
|
||||
# gets a sensible error — without this, the most-recent-row check
|
||||
# would mask "you entered yesterday's code" as "wrong code".
|
||||
matched = None
|
||||
for row in rows:
|
||||
if _check_code(code, row["code_hash"]):
|
||||
matched = row
|
||||
break
|
||||
|
||||
if matched is None:
|
||||
# A genuine wrong guess against this email — the brute-force
|
||||
# signal. Count it toward the lockout threshold (H1).
|
||||
_record_verify_failure(email)
|
||||
return VerifyOutcome(ok=False, user=None, reason="wrong")
|
||||
|
||||
if matched["consumed_at"] is not None:
|
||||
return VerifyOutcome(ok=False, user=None, reason="consumed")
|
||||
|
||||
expired = db.conn().execute(
|
||||
"SELECT datetime(?) < datetime('now') AS expired",
|
||||
(matched["expires_at"],),
|
||||
).fetchone()["expired"]
|
||||
if expired:
|
||||
return VerifyOutcome(ok=False, user=None, reason="expired")
|
||||
|
||||
# Stamp consumed before provisioning so a parallel verify of the
|
||||
# same row can't double-sign-in.
|
||||
db.conn().execute(
|
||||
"UPDATE otc_codes SET consumed_at = datetime('now') WHERE id = ?",
|
||||
(matched["id"],),
|
||||
)
|
||||
# Success wipes the per-email failure counter (H1).
|
||||
_clear_verify_state(email)
|
||||
user = provision_or_link_user(email)
|
||||
return VerifyOutcome(ok=True, user=user, reason="ok")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provisioning — the migration path from OAuth identity to email identity.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def provision_or_link_user(email: str) -> SessionUser:
|
||||
"""Link the OTC sign-in to a `users` row.
|
||||
|
||||
Match order:
|
||||
1. An existing row whose email equals (case-insensitive) the
|
||||
requested email — the OAuth-era user is grandfathered in via
|
||||
this path. `gitea_id` is preserved so a future OAuth round
|
||||
trip still resolves the same row. `permission_state` is
|
||||
read off the row as-is — grandfathered users come through
|
||||
migration with 'granted' (the column default), so their
|
||||
contributor capabilities are unaffected.
|
||||
2. Otherwise: a fresh contributor row with `gitea_id = NULL`,
|
||||
`gitea_login = NULL`, and `permission_state = 'pending'`
|
||||
(v0.8.0). The display name defaults to the local part of
|
||||
the email (everything before the `@`); a separate
|
||||
`POST /auth/me/beta-request` call lands first name / last
|
||||
name / "why I want access" on the same row.
|
||||
|
||||
The §6.1 owner-zero bootstrap still applies: if the email matches
|
||||
the configured `OWNER_GITEA_LOGIN`-derived owner identity, the row
|
||||
is provisioned with role='owner'. v0.7.0 keeps that field as the
|
||||
Gitea login (so existing deployments don't break); a future
|
||||
release may add a parallel `OWNER_EMAIL` env if the OAuth route is
|
||||
dropped entirely.
|
||||
"""
|
||||
email = email.strip()
|
||||
existing = db.conn().execute(
|
||||
"SELECT * FROM users WHERE email = ? COLLATE NOCASE",
|
||||
(email,),
|
||||
).fetchone()
|
||||
if existing is not None:
|
||||
db.conn().execute(
|
||||
"UPDATE users SET last_seen_at = datetime('now') WHERE id = ?",
|
||||
(existing["id"],),
|
||||
)
|
||||
return SessionUser(
|
||||
user_id=existing["id"],
|
||||
gitea_id=existing["gitea_id"] or 0,
|
||||
gitea_login=existing["gitea_login"] or "",
|
||||
display_name=existing["display_name"],
|
||||
email=existing["email"] or email,
|
||||
avatar_url=existing["avatar_url"] or "",
|
||||
role=existing["role"],
|
||||
permission_state=existing["permission_state"] or "granted",
|
||||
)
|
||||
|
||||
display = email.split("@", 1)[0] or email
|
||||
# v0.8.0: 'pending' is the explicit insert value; the migration
|
||||
# default of 'granted' is what passes grandfathered users
|
||||
# through. A fresh OTC user lands in 'pending' regardless of
|
||||
# what the migration default says, so the gate engages reliably
|
||||
# even if a future migration changes the default.
|
||||
cur = db.conn().execute(
|
||||
"""
|
||||
INSERT INTO users (gitea_id, gitea_login, email, display_name, avatar_url, role, permission_state)
|
||||
VALUES (NULL, NULL, ?, ?, '', 'contributor', 'pending')
|
||||
""",
|
||||
(email, display),
|
||||
)
|
||||
user_id = cur.lastrowid
|
||||
return SessionUser(
|
||||
user_id=user_id,
|
||||
gitea_id=0,
|
||||
gitea_login="",
|
||||
display_name=display,
|
||||
email=email,
|
||||
avatar_url="",
|
||||
role="contributor",
|
||||
permission_state="pending",
|
||||
)
|
||||
@@ -0,0 +1,367 @@
|
||||
"""§6.2 / v0.10.0: user-set passcodes after OTC (roadmap item #8).
|
||||
|
||||
After a successful OTC sign-in, a contributor may set a passcode and
|
||||
use email + passcode for subsequent sign-ins. OTC remains the fallback
|
||||
— a forgotten passcode is recovered by requesting a fresh OTC.
|
||||
|
||||
This module is the state machine behind the four `/auth/passcode/*`
|
||||
endpoints (`set`, `clear`, `verify`, `check`). The endpoints in
|
||||
`main.py` thin-wrap these helpers in the same shape the OTC module
|
||||
uses (see `otc.py`).
|
||||
|
||||
Shape:
|
||||
|
||||
* `set_passcode(user_id, passcode)` — bcrypt-hash the passcode and
|
||||
write it to `users.passcode_hash` + `users.passcode_set_at`.
|
||||
Validation (length, denylist) happens here, not at the endpoint,
|
||||
so the rule lives in one place. Replaces any prior passcode.
|
||||
* `clear_passcode(user_id)` — null out `passcode_hash` and
|
||||
`passcode_set_at`. The user is back to OTC-only.
|
||||
* `verify_passcode(email, passcode)` — locate the user by email,
|
||||
check lockout, compare via bcrypt, manage the failure counter,
|
||||
and return a populated `SessionUser` on success.
|
||||
* `passcode_status(email)` — does this email have a passcode set?
|
||||
Used by the `/auth/passcode/check` endpoint that the Login.jsx
|
||||
flow consults after the user types their email.
|
||||
|
||||
Lockout is a v1 shape: 5 consecutive failures sets
|
||||
`passcode_locked_until` to `now + 15 minutes`, after which a verify
|
||||
attempt that lands inside the window returns HTTP 423. The OTC path
|
||||
is unaffected by the lockout — a user can request and verify a fresh
|
||||
OTC to sign in while their passcode is locked out, and `verify_code`
|
||||
in `otc.py` does not consult these columns.
|
||||
|
||||
The lockout window and the failure threshold are hard-coded here.
|
||||
Tuning them via env vars (or moving to per-IP rate-limiting) is a
|
||||
§19.2 candidate; see SPEC §19.2.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
import bcrypt
|
||||
|
||||
from . import db
|
||||
from .auth import SessionUser
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tunables — intentionally hard-coded in v0.10.0 (see module docstring).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
LOCKOUT_AFTER_FAILED_ATTEMPTS = 5
|
||||
LOCKOUT_DURATION_MINUTES = 15
|
||||
|
||||
PASSCODE_MIN_LENGTH = 4
|
||||
PASSCODE_MAX_LENGTH = 20
|
||||
|
||||
# A small denylist of patterns we never want a passcode to be. The
|
||||
# rule is "no obvious patterns"; the list is deliberately small —
|
||||
# every entry here is a verbatim string match. A heavier check
|
||||
# (sequential digits, single-character runs of length >= N, etc.)
|
||||
# is a §19.2 candidate.
|
||||
PASSCODE_DENYLIST: frozenset[str] = frozenset(
|
||||
{
|
||||
"0000",
|
||||
"1111",
|
||||
"2222",
|
||||
"3333",
|
||||
"4444",
|
||||
"5555",
|
||||
"6666",
|
||||
"7777",
|
||||
"8888",
|
||||
"9999",
|
||||
"1234",
|
||||
"12345",
|
||||
"123456",
|
||||
"1234567",
|
||||
"12345678",
|
||||
"123456789",
|
||||
"1234567890",
|
||||
"0123",
|
||||
"01234",
|
||||
"012345",
|
||||
"0123456",
|
||||
"01234567",
|
||||
"012345678",
|
||||
"0123456789",
|
||||
"abcd",
|
||||
"abcde",
|
||||
"abcdef",
|
||||
"qwer",
|
||||
"qwerty",
|
||||
"asdf",
|
||||
"asdfg",
|
||||
"asdfgh",
|
||||
"aaaa",
|
||||
"bbbb",
|
||||
"cccc",
|
||||
"password",
|
||||
"letmein",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class PasscodeValidationError(Exception):
|
||||
"""The proposed passcode failed validation. The endpoint surface
|
||||
maps this to HTTP 422 with the message intact."""
|
||||
|
||||
|
||||
def _validate(passcode: str) -> str:
|
||||
"""Return the normalized passcode (stripped) or raise.
|
||||
|
||||
Rules:
|
||||
* 4-20 characters after stripping leading/trailing whitespace.
|
||||
* Not on the small denylist of obvious patterns.
|
||||
|
||||
No character-class restriction beyond that — the spec says
|
||||
"numeric PIN or short alphanumeric"; we don't refuse other
|
||||
characters because the entropy isn't load-bearing (the per-account
|
||||
lockout is what carries the security weight, mirroring the OTC
|
||||
shape from v0.7.0).
|
||||
"""
|
||||
pc = (passcode or "").strip()
|
||||
if not pc:
|
||||
raise PasscodeValidationError("Passcode is required")
|
||||
if len(pc) < PASSCODE_MIN_LENGTH:
|
||||
raise PasscodeValidationError(
|
||||
f"Passcode must be at least {PASSCODE_MIN_LENGTH} characters"
|
||||
)
|
||||
if len(pc) > PASSCODE_MAX_LENGTH:
|
||||
raise PasscodeValidationError(
|
||||
f"Passcode must be at most {PASSCODE_MAX_LENGTH} characters"
|
||||
)
|
||||
if pc.lower() in PASSCODE_DENYLIST:
|
||||
raise PasscodeValidationError("Passcode is too common; pick something less obvious")
|
||||
return pc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hashing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _hash(passcode: str) -> str:
|
||||
return bcrypt.hashpw(passcode.encode("utf-8"), bcrypt.gensalt()).decode("ascii")
|
||||
|
||||
|
||||
def _check(passcode: str, passcode_hash: str) -> bool:
|
||||
try:
|
||||
return bcrypt.checkpw(passcode.encode("utf-8"), passcode_hash.encode("ascii"))
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Set / clear
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def set_passcode(user_id: int, passcode: str) -> None:
|
||||
"""Hash and store the passcode. Replaces any prior passcode on the
|
||||
same row; clears the failure counter and lockout (a user setting a
|
||||
fresh passcode is implicitly re-authenticating their account)."""
|
||||
pc = _validate(passcode)
|
||||
h = _hash(pc)
|
||||
db.conn().execute(
|
||||
"""
|
||||
UPDATE users
|
||||
SET passcode_hash = ?,
|
||||
passcode_set_at = datetime('now'),
|
||||
passcode_failed_attempts = 0,
|
||||
passcode_locked_until = NULL
|
||||
WHERE id = ?
|
||||
""",
|
||||
(h, user_id),
|
||||
)
|
||||
|
||||
|
||||
def clear_passcode(user_id: int) -> None:
|
||||
"""Remove the passcode. The user is back to OTC-only on next sign-in."""
|
||||
db.conn().execute(
|
||||
"""
|
||||
UPDATE users
|
||||
SET passcode_hash = NULL,
|
||||
passcode_set_at = NULL,
|
||||
passcode_failed_attempts = 0,
|
||||
passcode_locked_until = NULL
|
||||
WHERE id = ?
|
||||
""",
|
||||
(user_id,),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Check (status surface for the Login.jsx flow)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class PasscodeStatus:
|
||||
"""The shape `/auth/passcode/check` returns.
|
||||
|
||||
`has_passcode` is the only signal the frontend needs to decide
|
||||
whether to show a passcode input or an OTC request step. We do
|
||||
not leak the hash, the set-at timestamp, or the lockout state —
|
||||
a probing client that wants to know "is this account locked
|
||||
out" can attempt a verify and read the 423.
|
||||
"""
|
||||
has_passcode: bool
|
||||
|
||||
|
||||
def passcode_status(email: str) -> PasscodeStatus:
|
||||
email = (email or "").strip()
|
||||
if not email or "@" not in email:
|
||||
return PasscodeStatus(has_passcode=False)
|
||||
row = db.conn().execute(
|
||||
"SELECT passcode_hash FROM users WHERE email = ? COLLATE NOCASE",
|
||||
(email,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return PasscodeStatus(has_passcode=False)
|
||||
return PasscodeStatus(has_passcode=bool(row["passcode_hash"]))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Verify
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class VerifyOutcome:
|
||||
"""Result of a `verify_passcode` call.
|
||||
|
||||
`reason` distinguishes the failure modes the endpoint surfaces as
|
||||
distinct HTTP shapes:
|
||||
* 'ok' — populated `user`, HTTP 200.
|
||||
* 'unknown' — no user with this email, HTTP 400 (generic).
|
||||
* 'no_passcode' — user exists but never set a passcode, HTTP 400
|
||||
(the frontend should fall back to OTC).
|
||||
* 'locked' — user is currently in the lockout window, HTTP
|
||||
423. `locked_until` carries the ISO-8601 stamp for the client.
|
||||
* 'wrong' — passcode didn't match. HTTP 400. If the failure
|
||||
crossed the lockout threshold the row is now locked; the
|
||||
endpoint surfaces this as a fresh `locked` response on the
|
||||
next attempt rather than collapsing the two states here.
|
||||
"""
|
||||
ok: bool
|
||||
user: SessionUser | None
|
||||
reason: str
|
||||
locked_until: str | None = None
|
||||
|
||||
|
||||
def verify_passcode(email: str, passcode: str) -> VerifyOutcome:
|
||||
email = (email or "").strip()
|
||||
passcode = (passcode or "").strip()
|
||||
if not email or not passcode:
|
||||
return VerifyOutcome(ok=False, user=None, reason="unknown")
|
||||
|
||||
row = db.conn().execute(
|
||||
"""
|
||||
SELECT id, gitea_id, gitea_login, email, display_name, avatar_url, role,
|
||||
passcode_hash, passcode_failed_attempts, passcode_locked_until
|
||||
FROM users
|
||||
WHERE email = ? COLLATE NOCASE
|
||||
""",
|
||||
(email,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return VerifyOutcome(ok=False, user=None, reason="unknown")
|
||||
if not row["passcode_hash"]:
|
||||
return VerifyOutcome(ok=False, user=None, reason="no_passcode")
|
||||
|
||||
# Lockout check: if `passcode_locked_until` is populated and in the
|
||||
# future, the verify is refused without touching the hash. Once the
|
||||
# window has elapsed we let the verify proceed; the failed-attempts
|
||||
# counter is also reset so the user gets a fresh 5-attempt budget.
|
||||
locked_until = row["passcode_locked_until"]
|
||||
if locked_until:
|
||||
still_locked = db.conn().execute(
|
||||
"SELECT datetime(?) > datetime('now') AS still_locked",
|
||||
(locked_until,),
|
||||
).fetchone()["still_locked"]
|
||||
if still_locked:
|
||||
return VerifyOutcome(
|
||||
ok=False,
|
||||
user=None,
|
||||
reason="locked",
|
||||
locked_until=locked_until,
|
||||
)
|
||||
# Lockout expired — clear the counter so the next failure starts
|
||||
# from zero, and continue with the verify.
|
||||
db.conn().execute(
|
||||
"""
|
||||
UPDATE users
|
||||
SET passcode_failed_attempts = 0,
|
||||
passcode_locked_until = NULL
|
||||
WHERE id = ?
|
||||
""",
|
||||
(row["id"],),
|
||||
)
|
||||
|
||||
if _check(passcode, row["passcode_hash"]):
|
||||
# Success: clear the counter (a single success wipes the
|
||||
# accumulated failures — the threshold tracks *consecutive*
|
||||
# failures).
|
||||
db.conn().execute(
|
||||
"""
|
||||
UPDATE users
|
||||
SET passcode_failed_attempts = 0,
|
||||
passcode_locked_until = NULL,
|
||||
last_seen_at = datetime('now')
|
||||
WHERE id = ?
|
||||
""",
|
||||
(row["id"],),
|
||||
)
|
||||
return VerifyOutcome(
|
||||
ok=True,
|
||||
user=SessionUser(
|
||||
user_id=row["id"],
|
||||
gitea_id=row["gitea_id"] or 0,
|
||||
gitea_login=row["gitea_login"] or "",
|
||||
display_name=row["display_name"],
|
||||
email=row["email"] or email,
|
||||
avatar_url=row["avatar_url"] or "",
|
||||
role=row["role"],
|
||||
),
|
||||
reason="ok",
|
||||
)
|
||||
|
||||
# Failure: increment the counter. If this push crosses the
|
||||
# threshold, stamp the lockout. The next verify attempt against
|
||||
# the same row returns 423 with the `locked_until` stamp.
|
||||
next_count = (row["passcode_failed_attempts"] or 0) + 1
|
||||
if next_count >= LOCKOUT_AFTER_FAILED_ATTEMPTS:
|
||||
db.conn().execute(
|
||||
f"""
|
||||
UPDATE users
|
||||
SET passcode_failed_attempts = ?,
|
||||
passcode_locked_until = datetime('now', '+{LOCKOUT_DURATION_MINUTES} minutes')
|
||||
WHERE id = ?
|
||||
""",
|
||||
(next_count, row["id"]),
|
||||
)
|
||||
new_locked_until = db.conn().execute(
|
||||
"SELECT passcode_locked_until FROM users WHERE id = ?",
|
||||
(row["id"],),
|
||||
).fetchone()["passcode_locked_until"]
|
||||
return VerifyOutcome(
|
||||
ok=False,
|
||||
user=None,
|
||||
reason="locked",
|
||||
locked_until=new_locked_until,
|
||||
)
|
||||
db.conn().execute(
|
||||
"UPDATE users SET passcode_failed_attempts = ? WHERE id = ?",
|
||||
(next_count, row["id"]),
|
||||
)
|
||||
return VerifyOutcome(ok=False, user=None, reason="wrong")
|
||||
@@ -184,6 +184,21 @@ def load_providers(env: dict) -> dict[str, BaseProvider]:
|
||||
return providers
|
||||
|
||||
|
||||
def construct_haiku(api_key: str) -> AnthropicProvider:
|
||||
"""A dedicated Claude Haiku provider, independent of the
|
||||
`ENABLED_MODELS` chat-picker universe.
|
||||
|
||||
The §9.1 tag-suggestion surface (roadmap #27) always wants the
|
||||
cheap + fast model regardless of which models the operator surfaces
|
||||
in the §8.12 picker, so it constructs Haiku directly from the
|
||||
operator's Anthropic key rather than going through `load_providers`.
|
||||
The model id is sourced from the same `_CLAUDE_VARIANTS` table the
|
||||
picker uses, so a model-string bump lands in one place.
|
||||
"""
|
||||
model, name = _CLAUDE_VARIANTS["claude-haiku"]
|
||||
return AnthropicProvider(api_key=api_key, model=model, display_name=name)
|
||||
|
||||
|
||||
def load_from_config(config) -> dict[str, BaseProvider]:
|
||||
"""Convenience adapter so callers can pass our Config dataclass directly."""
|
||||
env = {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"""In-process per-IP sliding-window rate limiter (security audit 0026, H1).
|
||||
|
||||
The auth verify endpoints (`/auth/otc/verify`, `/auth/passcode/verify`)
|
||||
had no per-IP brake, so an attacker could fan out guesses against a
|
||||
target identity bounded only by bcrypt cost. This module is the brake.
|
||||
|
||||
It is deliberately tiny: §4.2 says the app is a single process with a
|
||||
colocated SQLite file, so an in-memory dict of `key -> deque[timestamps]`
|
||||
is sufficient and needs no shared store. State resets on restart, which
|
||||
fails *open* for a brief window — acceptable because the per-email OTC
|
||||
lockout (`otc_verify_state`) and the passcode lockout both persist in the
|
||||
database and carry the durable guarantee; this limiter is the
|
||||
anti-fan-out layer on top.
|
||||
|
||||
Chosen over a per-identity lockout *as the primary control* because a
|
||||
per-IP window throttles the attacker without letting them grief a victim
|
||||
by locking that victim's account (the known downside of identity
|
||||
lockouts). Both layers run together.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from collections import defaultdict, deque
|
||||
|
||||
|
||||
class SlidingWindowLimiter:
|
||||
"""Allow at most `max_events` per `window_seconds` per key.
|
||||
|
||||
`allow(key)` records an event and returns True if the key is still
|
||||
within budget, False if it has exceeded it. Timestamps use a
|
||||
monotonic clock so the limiter is immune to wall-clock jumps.
|
||||
"""
|
||||
|
||||
def __init__(self, max_events: int, window_seconds: float) -> None:
|
||||
self.max_events = max_events
|
||||
self.window_seconds = window_seconds
|
||||
self._events: dict[str, deque[float]] = defaultdict(deque)
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def allow(self, key: str) -> bool:
|
||||
now = time.monotonic()
|
||||
cutoff = now - self.window_seconds
|
||||
with self._lock:
|
||||
q = self._events[key]
|
||||
while q and q[0] < cutoff:
|
||||
q.popleft()
|
||||
if len(q) >= self.max_events:
|
||||
return False
|
||||
q.append(now)
|
||||
# Opportunistic cleanup so idle keys don't accumulate forever.
|
||||
if not q:
|
||||
self._events.pop(key, None)
|
||||
return True
|
||||
|
||||
def reset(self, key: str) -> None:
|
||||
"""Drop a key's window — e.g. after a successful sign-in so a
|
||||
legitimate user who fat-fingered a few times isn't throttled."""
|
||||
with self._lock:
|
||||
self._events.pop(key, None)
|
||||
|
||||
|
||||
# Module-level limiters shared across requests (one process, so module
|
||||
# state is the natural home). Tunables are intentionally generous enough
|
||||
# not to bother a human retyping a code, tight enough to kill fan-out:
|
||||
# * verify: 10 attempts / 5 min / IP across the auth verify surfaces.
|
||||
# * otc request: 5 sends / 5 min / IP (Turnstile is the primary gate;
|
||||
# this is defense in depth against a solved-challenge replay loop).
|
||||
verify_limiter = SlidingWindowLimiter(max_events=10, window_seconds=300)
|
||||
otc_request_limiter = SlidingWindowLimiter(max_events=5, window_seconds=300)
|
||||
# /auth/passcode/check is an anonymous has-passcode oracle (audit 0026 L3).
|
||||
# It's a legitimate Login-flow affordance, so the budget is generous —
|
||||
# enough for a human typing emails, tight enough to stop bulk scraping.
|
||||
check_limiter = SlidingWindowLimiter(max_events=30, window_seconds=300)
|
||||
|
||||
|
||||
def _reset_all_for_tests() -> None:
|
||||
"""Clear every module-level limiter's window. Test support only — the
|
||||
limiters are process-global singletons, so without a per-test reset
|
||||
one test's requests bleed into the next and later tests trip the
|
||||
budget (429). Not called in production."""
|
||||
for lim in (verify_limiter, otc_request_limiter, check_limiter):
|
||||
with lim._lock:
|
||||
lim._events.clear()
|
||||
|
||||
|
||||
def client_key(request) -> str:
|
||||
"""Best-effort client identity for limiting. Behind nginx the app is
|
||||
started with `--forwarded-allow-ips 127.0.0.1`, so `request.client.host`
|
||||
reflects the real client IP via Uvicorn's ProxyHeaders handling."""
|
||||
client = getattr(request, "client", None)
|
||||
return client.host if client and client.host else "unknown"
|
||||
@@ -0,0 +1,333 @@
|
||||
"""Roadmap #28 — scan submitted prose for RFC-shaped references.
|
||||
|
||||
The scanner splits a plain-text PR description / comment body into a list
|
||||
of *segments* the frontend renders: plain-text runs interleaved with
|
||||
typed link segments. The backend never emits HTML — the frontend maps
|
||||
each segment onto a React node — so the surface is XSS-safe by
|
||||
construction and independent of any HTML-sanitization layer.
|
||||
|
||||
Three buckets, one scan (Parts 1–3):
|
||||
|
||||
* ``{"type": "rfc", ...}`` — Part 1. The term matches an
|
||||
**accepted** (``state='active'``) RFC; renders as a link to it.
|
||||
* ``{"type": "rfc-pending", ...}`` — Part 3. The term matches a
|
||||
**pending** RFC — a super-draft (``state='super-draft'``: accepted
|
||||
as an idea but not yet graduated to an active RFC) — which has an
|
||||
owner and a contribution surface. Renders as an "ask to contribute"
|
||||
affordance carrying the owner's display name.
|
||||
* ``{"type": "rfc-candidate", ...}`` — Part 2. The term is a
|
||||
strong-candidate that does **not** yet have a defining RFC. Renders
|
||||
(for a viewer with create rights) as a "create RFC for '<term>'"
|
||||
affordance that pre-fills the propose flow.
|
||||
|
||||
Precedence at any position is active > pending > candidate, then
|
||||
longest-match-first — an active link always wins over a contribute offer
|
||||
which always wins over a create offer for the same span.
|
||||
|
||||
**Read-time enrichment, not submit-time persistence** (unchanged from
|
||||
Part 1): drafts are never scanned, only submitted content on the read
|
||||
paths, so links/offers track the *live* corpus. The active-RFC corpus,
|
||||
super-draft corpus, and tag taxonomy are all small and cache-resident,
|
||||
so building the index and scanning a ≤20k-char body per read is cheap.
|
||||
|
||||
**Matching stays conservative by design.** A reference links/offers only
|
||||
when it is unlikely to be coincidental:
|
||||
|
||||
* ``rfc_id`` tokens (e.g. ``RFC-0001``) — inherently specific.
|
||||
* Multi-word titles (containing whitespace, e.g. ``Open Human Model``).
|
||||
* Hyphenated slugs (containing ``-``, e.g. ``open-human-model``).
|
||||
|
||||
Single common-word titles/slugs are deliberately NOT matched — they
|
||||
would turn every prose occurrence into an affordance.
|
||||
|
||||
**Part 2 candidate heuristic.** A candidate term is a **multi-word tag**
|
||||
from the #27 tag taxonomy (the de-facto set of tags the corpus already
|
||||
carries) that has no defining RFC (no active or super-draft RFC whose
|
||||
slug or title is that term). Multi-word is the same false-positive guard
|
||||
the title rule uses: a single common tag word (``identity``) would be
|
||||
far too noisy. Broader candidate detection — capitalized multi-word
|
||||
phrases mined from the text, terms repeated across recently-touched PRs,
|
||||
or the #27 Haiku (``ANTHROPIC_API_KEY``) pathway — is a sanctioned but
|
||||
deferred extension; the conservative tag-taxonomy heuristic is chosen
|
||||
here to match Part 1's false-positive-averse philosophy.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any, Iterable, NamedTuple
|
||||
|
||||
|
||||
class Term(NamedTuple):
|
||||
"""One match key plus what to emit when it hits.
|
||||
|
||||
``key`` is the lowercase span to match (word-boundary, longest-first).
|
||||
``kind`` is ``'active' | 'pending' | 'candidate'`` and selects the
|
||||
emitted segment shape. ``slug``/``title`` carry the target RFC (active
|
||||
+ pending); ``owner`` is the pending RFC's owner display name;
|
||||
``term`` is the candidate's canonical display spelling.
|
||||
"""
|
||||
|
||||
key: str
|
||||
kind: str = "active"
|
||||
slug: str = ""
|
||||
title: str = ""
|
||||
owner: str = ""
|
||||
term: str = ""
|
||||
|
||||
|
||||
# Lower number = higher precedence when two keys of equal length match at
|
||||
# the same position. A real link beats a contribute offer beats a create
|
||||
# offer.
|
||||
_KIND_PRIORITY = {"active": 0, "pending": 1, "candidate": 2}
|
||||
|
||||
|
||||
def _coerce(t: Term | tuple) -> Term:
|
||||
"""Accept the legacy ``(key, slug, title)`` 3-tuple (treated as an
|
||||
active term) alongside :class:`Term`, so direct unit-test callers and
|
||||
older call sites keep working."""
|
||||
if isinstance(t, Term):
|
||||
return t
|
||||
key, slug, title = t # legacy active 3-tuple
|
||||
return Term(key=key, kind="active", slug=slug, title=title)
|
||||
|
||||
|
||||
def _is_word_char(c: str) -> bool:
|
||||
"""Word-boundary test. Hyphen and underscore count as word chars so a
|
||||
match can't begin or end in the middle of a kebab/snake token."""
|
||||
return c.isalnum() or c in ("-", "_")
|
||||
|
||||
|
||||
def _emit(term: Term, label: str) -> dict[str, Any]:
|
||||
"""The segment dict for a matched ``term``; ``label`` preserves source
|
||||
casing."""
|
||||
if term.kind == "pending":
|
||||
return {
|
||||
"type": "rfc-pending",
|
||||
"slug": term.slug,
|
||||
"label": label,
|
||||
"title": term.title,
|
||||
"owner": term.owner,
|
||||
}
|
||||
if term.kind == "candidate":
|
||||
return {"type": "rfc-candidate", "label": label, "term": term.term}
|
||||
return {"type": "rfc", "slug": term.slug, "label": label, "title": term.title}
|
||||
|
||||
|
||||
def segment_text(text: str | None, terms: Iterable[Term | tuple]) -> list[dict[str, Any]]:
|
||||
"""Split ``text`` into text / link segments against ``terms``.
|
||||
|
||||
``terms`` are :class:`Term` objects (or legacy ``(key, slug, title)``
|
||||
active 3-tuples). Matching is case-insensitive, respects word
|
||||
boundaries on both ends, and prefers the longest key — then higher
|
||||
:data:`_KIND_PRIORITY` — at any position.
|
||||
|
||||
Always returns at least one segment; for empty/None input that is a
|
||||
single empty text segment, so callers can render uniformly.
|
||||
"""
|
||||
ordered = sorted(
|
||||
(_coerce(t) for t in terms),
|
||||
key=lambda t: (-len(t.key), _KIND_PRIORITY.get(t.kind, 9)),
|
||||
)
|
||||
if not text:
|
||||
return [{"type": "text", "text": text or ""}]
|
||||
|
||||
out: list[dict[str, Any]] = []
|
||||
buf: list[str] = []
|
||||
low = text.lower()
|
||||
n = len(text)
|
||||
i = 0
|
||||
while i < n:
|
||||
match: tuple[Term, int] | None = None
|
||||
for term in ordered:
|
||||
klen = len(term.key)
|
||||
if klen == 0 or not low.startswith(term.key, i):
|
||||
continue
|
||||
before = text[i - 1] if i > 0 else ""
|
||||
after = text[i + klen] if i + klen < n else ""
|
||||
if _is_word_char(before) or _is_word_char(after):
|
||||
continue
|
||||
match = (term, klen)
|
||||
break
|
||||
if match is not None:
|
||||
term, klen = match
|
||||
if buf:
|
||||
out.append({"type": "text", "text": "".join(buf)})
|
||||
buf = []
|
||||
out.append(_emit(term, text[i:i + klen]))
|
||||
i += klen
|
||||
else:
|
||||
buf.append(text[i])
|
||||
i += 1
|
||||
if buf:
|
||||
out.append({"type": "text", "text": "".join(buf)})
|
||||
return out
|
||||
|
||||
|
||||
def _keys_for(slug: str, title: str, rfc_id: str | None) -> Iterable[str]:
|
||||
"""The match keys an RFC contributes. See the module docstring for why
|
||||
each gate exists (conservative, false-positive-averse)."""
|
||||
if rfc_id:
|
||||
rid = rfc_id.strip()
|
||||
if len(rid) >= 2:
|
||||
yield rid.lower()
|
||||
if title:
|
||||
t = title.strip()
|
||||
# Multi-word titles only — a single common word is too noisy.
|
||||
if len(t) >= 2 and (" " in t or "\t" in t):
|
||||
yield t.lower()
|
||||
if slug:
|
||||
s = slug.strip()
|
||||
# Hyphenated slugs only — a single-token slug is a bare word.
|
||||
if len(s) >= 2 and "-" in s:
|
||||
yield s.lower()
|
||||
|
||||
|
||||
def _slugify(term: str) -> str:
|
||||
"""Deterministic kebab-case — mirrors the propose modal's slugify so a
|
||||
tag's would-be slug compares correctly against existing RFC slugs."""
|
||||
return re.sub(r"-+$", "", re.sub(r"^-+", "", re.sub(r"[^a-z0-9]+", "-", term.lower().strip())))
|
||||
|
||||
|
||||
class LinkIndex:
|
||||
"""A reusable term index built once per request and applied to many
|
||||
bodies (a PR's description plus every comment on it)."""
|
||||
|
||||
def __init__(self, terms: Iterable[Term | tuple]):
|
||||
# Coerce + order once; segment_text re-sorts defensively but a
|
||||
# pre-sorted list keeps the per-body cost to the scan itself.
|
||||
self._terms: list[Term] = sorted(
|
||||
(_coerce(t) for t in terms),
|
||||
key=lambda t: (-len(t.key), _KIND_PRIORITY.get(t.kind, 9)),
|
||||
)
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return bool(self._terms)
|
||||
|
||||
def segment(self, text: str | None) -> list[dict[str, Any]]:
|
||||
return segment_text(text, self._terms)
|
||||
|
||||
|
||||
def _owner_display(conn, owners_json: str | None, proposed_by: str | None) -> str:
|
||||
"""The display name to show for a pending RFC's owner. First entry of
|
||||
``owners_json`` resolved to its user row's display name, falling back
|
||||
to the bare login, then ``proposed_by``, then a neutral noun."""
|
||||
login = None
|
||||
try:
|
||||
owners = json.loads(owners_json or "[]")
|
||||
if isinstance(owners, list):
|
||||
login = next((o for o in owners if isinstance(o, str) and o.strip()), None)
|
||||
except (ValueError, TypeError):
|
||||
login = None
|
||||
if login:
|
||||
row = conn.execute(
|
||||
"SELECT display_name FROM users WHERE gitea_login = ?", (login,)
|
||||
).fetchone()
|
||||
if row and row["display_name"]:
|
||||
return row["display_name"]
|
||||
return login
|
||||
return (proposed_by or "").strip() or "the proposer"
|
||||
|
||||
|
||||
def _tag_universe(conn) -> list[str]:
|
||||
"""Distinct tags across the cached corpus (the #27 de-facto taxonomy),
|
||||
preserving original spelling; case-deduped."""
|
||||
rows = conn.execute("SELECT tags_json FROM cached_rfcs").fetchall()
|
||||
out: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for r in rows:
|
||||
try:
|
||||
tags = json.loads(r["tags_json"] or "[]")
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
if not isinstance(tags, list):
|
||||
continue
|
||||
for t in tags:
|
||||
if not isinstance(t, str):
|
||||
continue
|
||||
tag = t.strip()
|
||||
low = tag.lower()
|
||||
if tag and low not in seen:
|
||||
seen.add(low)
|
||||
out.append(tag)
|
||||
return out
|
||||
|
||||
|
||||
def build_index(
|
||||
conn,
|
||||
*,
|
||||
exclude_slug: str | None = None,
|
||||
include_pending: bool = True,
|
||||
include_candidates: bool = True,
|
||||
) -> LinkIndex:
|
||||
"""Build a :class:`LinkIndex` over the three buckets.
|
||||
|
||||
``exclude_slug`` drops the RFC the surrounding surface is itself scoped
|
||||
to, so an RFC's own title/id/slug don't self-link (or self-offer)
|
||||
inside its own PR or discussion. Precedence is enforced by insertion
|
||||
order — active keys are added first and a later bucket never overrides
|
||||
an already-claimed key.
|
||||
"""
|
||||
terms: list[Term] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
def add(key: str, term: Term) -> None:
|
||||
if key in seen:
|
||||
return
|
||||
seen.add(key)
|
||||
terms.append(term)
|
||||
|
||||
# --- Part 1: accepted (active) RFCs. ORDER BY slug makes key
|
||||
# de-duplication deterministic when two RFCs would contribute the
|
||||
# same key (first slug wins). ---
|
||||
active_rows = conn.execute(
|
||||
"SELECT slug, title, rfc_id FROM cached_rfcs WHERE state = 'active' ORDER BY slug"
|
||||
).fetchall()
|
||||
# Track every slug + title that *has* a defining RFC, so Part 2 never
|
||||
# offers to create one that already exists (active or pending).
|
||||
defined_slugs: set[str] = set()
|
||||
defined_titles: set[str] = set()
|
||||
for r in active_rows:
|
||||
slug = r["slug"]
|
||||
defined_slugs.add((slug or "").lower())
|
||||
defined_titles.add((r["title"] or "").strip().lower())
|
||||
if exclude_slug is not None and slug == exclude_slug:
|
||||
continue
|
||||
title = r["title"] or ""
|
||||
rfc_id = r["rfc_id"] if "rfc_id" in r.keys() else None
|
||||
for key in _keys_for(slug, title, rfc_id):
|
||||
add(key, Term(key=key, kind="active", slug=slug, title=title))
|
||||
|
||||
# --- Part 3: pending (super-draft) RFCs. ---
|
||||
pending_rows = conn.execute(
|
||||
"""
|
||||
SELECT slug, title, rfc_id, owners_json, proposed_by
|
||||
FROM cached_rfcs WHERE state = 'super-draft' ORDER BY slug
|
||||
"""
|
||||
).fetchall()
|
||||
for r in pending_rows:
|
||||
slug = r["slug"]
|
||||
defined_slugs.add((slug or "").lower())
|
||||
defined_titles.add((r["title"] or "").strip().lower())
|
||||
if not include_pending:
|
||||
continue
|
||||
if exclude_slug is not None and slug == exclude_slug:
|
||||
continue
|
||||
title = r["title"] or ""
|
||||
rfc_id = r["rfc_id"] if "rfc_id" in r.keys() else None
|
||||
owner = _owner_display(conn, r["owners_json"], r["proposed_by"])
|
||||
for key in _keys_for(slug, title, rfc_id):
|
||||
add(key, Term(key=key, kind="pending", slug=slug, title=title, owner=owner))
|
||||
|
||||
# --- Part 2: strong-candidate terms with no defining RFC. ---
|
||||
if include_candidates:
|
||||
for tag in _tag_universe(conn):
|
||||
low = tag.lower()
|
||||
# Conservative: multi-word tags only (same guard as titles).
|
||||
if " " not in tag and "\t" not in tag:
|
||||
continue
|
||||
if low in defined_titles or low in defined_slugs or _slugify(tag) in defined_slugs:
|
||||
continue
|
||||
add(low, Term(key=low, kind="candidate", term=tag))
|
||||
|
||||
return LinkIndex(terms)
|
||||
@@ -0,0 +1,244 @@
|
||||
"""Roadmap #27 — Claude Haiku tag suggestions for the propose-RFC modal.
|
||||
|
||||
A cheap, fast assist: given the partial RFC draft a user is typing, ask
|
||||
Claude Haiku to recommend tags drawn ONLY from the tags already in use
|
||||
across the corpus. v1 has no curated tag list — tags are free-form chip
|
||||
input (§9.1) — so "the taxonomy" is the de-facto set of distinct tags
|
||||
the existing RFCs already carry. The model is constrained to that set
|
||||
and MUST NOT invent new tags; taxonomy extension (letting the model
|
||||
propose genuinely new tags) is a deferred follow-up per the roadmap.
|
||||
|
||||
Why Haiku specifically: cost. Picking a few reasonable tags from a known
|
||||
set is well within Haiku's range, and the modal fires this repeatedly as
|
||||
the user types, so the per-call price has to stay small.
|
||||
|
||||
The whole surface degrades to silence rather than error: no Anthropic
|
||||
key, no provider, an empty corpus, a rate-limited caller, an empty
|
||||
draft, or an unparseable model reply all yield an empty suggestion list.
|
||||
The propose modal hides its suggestion row on an empty list, so the
|
||||
fallback is simply the existing free-form chip input with nothing extra
|
||||
shown.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
from . import db
|
||||
from .providers import BaseProvider, construct_haiku
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Bound the universe handed to the model so a large corpus can't blow up
|
||||
# the prompt size (and the cost). The most-common tags matter most.
|
||||
_UNIVERSE_CAP = 200
|
||||
|
||||
# How many suggestions we ever return to the modal.
|
||||
DEFAULT_MAX_SUGGESTIONS = 6
|
||||
|
||||
|
||||
@dataclass
|
||||
class Draft:
|
||||
"""The partial propose-RFC draft. `pitch` is the "why is this needed"
|
||||
rationale; `use_case` is the #26 optional ground-truth field."""
|
||||
|
||||
title: str = ""
|
||||
pitch: str = ""
|
||||
use_case: str = ""
|
||||
|
||||
def is_empty(self) -> bool:
|
||||
return not (self.title.strip() or self.pitch.strip() or self.use_case.strip())
|
||||
|
||||
|
||||
def haiku_provider(config) -> BaseProvider | None:
|
||||
"""Construct a dedicated Claude Haiku provider from the operator's
|
||||
Anthropic key, or return None when no key is configured.
|
||||
|
||||
None means "suggestions unavailable" — the caller returns an empty
|
||||
list and the modal shows nothing. This is the seam tests monkeypatch
|
||||
to inject a fake provider without a real key. There is no RFC slug at
|
||||
propose time, so the §6.7 per-RFC funder path deliberately does not
|
||||
apply: tag suggestion always runs on the operator's own key.
|
||||
"""
|
||||
key = getattr(config, "anthropic_api_key", "") or ""
|
||||
if not key:
|
||||
return None
|
||||
return construct_haiku(key)
|
||||
|
||||
|
||||
def gather_tag_universe(cap: int = _UNIVERSE_CAP) -> list[str]:
|
||||
"""Every distinct tag in use across the cached corpus, most-common
|
||||
first (ties broken alphabetically for determinism), capped.
|
||||
|
||||
This is the universe the model is constrained to. An empty corpus
|
||||
yields an empty list, which short-circuits suggestion to silence.
|
||||
"""
|
||||
rows = db.conn().execute("SELECT tags_json FROM cached_rfcs").fetchall()
|
||||
counts: dict[str, int] = {}
|
||||
for r in rows:
|
||||
try:
|
||||
tags = json.loads(r["tags_json"] or "[]")
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
if not isinstance(tags, list):
|
||||
continue
|
||||
for t in tags:
|
||||
if not isinstance(t, str):
|
||||
continue
|
||||
tag = t.strip()
|
||||
if tag:
|
||||
counts[tag] = counts.get(tag, 0) + 1
|
||||
ranked = sorted(counts, key=lambda t: (-counts[t], t.lower()))
|
||||
return ranked[:cap]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rate limiting — in-process, per-user sliding window.
|
||||
#
|
||||
# Cost control, not security: the `require_contributor` gate already
|
||||
# bounds callers to authenticated beta users, and the modal debounces.
|
||||
# This is the backstop against a stuck/abusive client hammering the
|
||||
# endpoint. In-memory is sufficient (single-process uvicorn on the VM)
|
||||
# and resets on restart, which is fine for a cost guard.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_CALLS: dict[int, list[float]] = {}
|
||||
|
||||
|
||||
def _rate_config() -> tuple[int, float]:
|
||||
try:
|
||||
max_calls = int(os.environ.get("TAG_SUGGEST_RATE_MAX", "30"))
|
||||
except ValueError:
|
||||
max_calls = 30
|
||||
try:
|
||||
window = float(os.environ.get("TAG_SUGGEST_RATE_WINDOW_SECONDS", "60"))
|
||||
except ValueError:
|
||||
window = 60.0
|
||||
return max_calls, window
|
||||
|
||||
|
||||
def rate_limit_ok(user_id: int, *, now: float | None = None) -> bool:
|
||||
"""True if this call is within the per-user window; records the call.
|
||||
`now` is injectable for tests (defaults to a monotonic clock)."""
|
||||
max_calls, window = _rate_config()
|
||||
t = time.monotonic() if now is None else now
|
||||
calls = _CALLS.setdefault(user_id, [])
|
||||
cutoff = t - window
|
||||
calls[:] = [c for c in calls if c > cutoff]
|
||||
if len(calls) >= max_calls:
|
||||
return False
|
||||
calls.append(t)
|
||||
return True
|
||||
|
||||
|
||||
def reset_rate_limits() -> None:
|
||||
"""Test seam — clear the in-process window state."""
|
||||
_CALLS.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Prompt + parse.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SYSTEM = (
|
||||
"You suggest topic tags for a draft RFC — a structured proposal "
|
||||
"document in a collection. You are given the draft's title, its "
|
||||
"rationale, an optional use case, and the exact set of tags already "
|
||||
"in use across the collection. Choose the tags from that set that "
|
||||
"best fit the draft.\n"
|
||||
"Rules:\n"
|
||||
"- Choose ONLY from the provided tag set. Never invent a tag.\n"
|
||||
"- Order best-fit first. Omit weak fits rather than padding the list.\n"
|
||||
"- Return at most {max} tags.\n"
|
||||
"Respond with ONLY a JSON array, no prose, of the form:\n"
|
||||
'[{{"tag": "<exact tag from the set>", "confidence": <number between 0 and 1>}}]\n'
|
||||
"If no tag in the set fits the draft, return []."
|
||||
)
|
||||
|
||||
|
||||
def _build_messages(draft: Draft, universe: list[str], max_suggestions: int):
|
||||
system = _SYSTEM.format(max=max_suggestions)
|
||||
parts: list[str] = []
|
||||
if draft.title.strip():
|
||||
parts.append(f"Title: {draft.title.strip()[:300]}")
|
||||
if draft.pitch.strip():
|
||||
parts.append(f"Why this RFC is needed:\n{draft.pitch.strip()[:4000]}")
|
||||
if draft.use_case.strip():
|
||||
parts.append(f"What it will be used for:\n{draft.use_case.strip()[:4000]}")
|
||||
parts.append(
|
||||
"Tags already in use (choose only from these):\n" + ", ".join(universe)
|
||||
)
|
||||
return system, [{"role": "user", "content": "\n\n".join(parts)}]
|
||||
|
||||
|
||||
def _extract_json_array(text: str):
|
||||
start = text.find("[")
|
||||
end = text.rfind("]")
|
||||
if start == -1 or end == -1 or end < start:
|
||||
return None
|
||||
try:
|
||||
return json.loads(text[start : end + 1])
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def parse_reply(text: str, universe: list[str], max_suggestions: int) -> list[dict]:
|
||||
"""Parse the model reply into a clean, universe-constrained list.
|
||||
|
||||
Tolerant of the model returning bare strings or objects, extra prose
|
||||
around the JSON, unknown/invented tags (dropped), duplicate tags
|
||||
(deduped), and missing/garbage confidences (defaulted + clamped).
|
||||
"""
|
||||
data = _extract_json_array(text or "")
|
||||
if not isinstance(data, list):
|
||||
return []
|
||||
# Map back to the canonical spelling in the universe, case-insensitively,
|
||||
# so a model that lowercases a tag still resolves to the real one.
|
||||
canonical = {t.lower(): t for t in universe}
|
||||
out: list[dict] = []
|
||||
seen: set[str] = set()
|
||||
for item in data:
|
||||
if isinstance(item, dict):
|
||||
raw = item.get("tag")
|
||||
conf = item.get("confidence")
|
||||
elif isinstance(item, str):
|
||||
raw, conf = item, None
|
||||
else:
|
||||
continue
|
||||
if not isinstance(raw, str):
|
||||
continue
|
||||
tag = canonical.get(raw.strip().lower())
|
||||
if tag is None or tag in seen:
|
||||
continue
|
||||
try:
|
||||
c = float(conf) if conf is not None else 0.5
|
||||
except (ValueError, TypeError):
|
||||
c = 0.5
|
||||
c = max(0.0, min(1.0, c))
|
||||
out.append({"tag": tag, "confidence": round(c, 3)})
|
||||
seen.add(tag)
|
||||
if len(out) >= max_suggestions:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def suggest(
|
||||
provider: BaseProvider,
|
||||
draft: Draft,
|
||||
universe: list[str],
|
||||
max_suggestions: int = DEFAULT_MAX_SUGGESTIONS,
|
||||
) -> list[dict]:
|
||||
"""Orchestrate one suggestion call. Returns [] for an empty draft or
|
||||
empty universe (no model call), and for any provider/parse failure."""
|
||||
if draft.is_empty() or not universe:
|
||||
return []
|
||||
system, history = _build_messages(draft, universe, max_suggestions)
|
||||
try:
|
||||
text = provider.send(system, history)
|
||||
except Exception as exc: # provider/network failure → silent empty
|
||||
log.warning("tag-suggest provider failed: %s", exc)
|
||||
return []
|
||||
return parse_reply(text, universe, max_suggestions)
|
||||
@@ -0,0 +1,166 @@
|
||||
"""§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
|
||||
|
||||
|
||||
async def _siteverify_post(url: str, data: dict) -> httpx.Response:
|
||||
"""Perform the siteverify POST on an `httpx.AsyncClient`.
|
||||
|
||||
Isolated as a narrow seam (I4, security-audit-0026): the call is
|
||||
awaited so a slow CloudFlare response can't block the event loop,
|
||||
and tests patch *this function* rather than the shared
|
||||
`httpx.AsyncClient` (which other modules — gitea, docs — also
|
||||
construct, so a global patch would break app boot).
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
return await client.post(url, data=data)
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
async 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"
|
||||
|
||||
Async (I4, security-audit-0026): the siteverify call is awaited on an
|
||||
`httpx.AsyncClient` so a slow CloudFlare response can't block the
|
||||
event loop (the prior synchronous `httpx.post` stalled the single
|
||||
worker for up to the 10s timeout). Callers must `await` it.
|
||||
|
||||
Tests monkeypatch `_siteverify_post` (the narrow async seam) to avoid
|
||||
touching the real CloudFlare endpoint and to keep the patch off the
|
||||
shared `httpx.AsyncClient`. 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 = await _siteverify_post(_siteverify_url(), data)
|
||||
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")
|
||||
+33
-1
@@ -12,6 +12,7 @@ import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException, Request
|
||||
|
||||
@@ -40,7 +41,27 @@ def make_router(config: Config, gitea: Gitea) -> APIRouter:
|
||||
x_gitea_signature: str = Header(default=""),
|
||||
):
|
||||
body = await request.body()
|
||||
if config.webhook_secret:
|
||||
# v0.18.0: defense in depth. config.py refuses to start
|
||||
# when the secret is empty unless `RFC_APP_INSECURE_WEBHOOKS=1`
|
||||
# is set; this branch catches the dev-bypass case (the only
|
||||
# path where `config.webhook_secret` can be empty) and surfaces
|
||||
# it loudly to the client. A POST that lands here with an
|
||||
# empty secret on a production deployment indicates a
|
||||
# mis-configuration (somebody flipped the bypass in prod),
|
||||
# and the loud 500 is the proposal's whole point.
|
||||
insecure = os.environ.get("RFC_APP_INSECURE_WEBHOOKS", "").strip() == "1"
|
||||
if not config.webhook_secret:
|
||||
if not insecure:
|
||||
log.error(
|
||||
"webhook receiver misconfigured: GITEA_WEBHOOK_SECRET is empty "
|
||||
"and RFC_APP_INSECURE_WEBHOOKS=1 is not set"
|
||||
)
|
||||
raise HTTPException(status_code=500, detail="Webhook receiver misconfigured")
|
||||
log.warning(
|
||||
"webhook receiver running with RFC_APP_INSECURE_WEBHOOKS=1 — "
|
||||
"signature verification is DISABLED. Production deployments MUST NOT set this."
|
||||
)
|
||||
else:
|
||||
if not _verify_signature(body, x_gitea_signature, config.webhook_secret):
|
||||
raise HTTPException(status_code=401, detail="Invalid signature")
|
||||
|
||||
@@ -68,6 +89,17 @@ def make_router(config: Config, gitea: Gitea) -> APIRouter:
|
||||
slug = _slug_for_repo(repo_full)
|
||||
if slug:
|
||||
await cache.refresh_rfc_repo(config, gitea, slug)
|
||||
else:
|
||||
# v0.18.0: the proposal's "unknown-repo logging"
|
||||
# gesture — a hook on a fork or a stale repo binding
|
||||
# used to silently 200-OK here, hiding the
|
||||
# misconfiguration. Now the operator sees it in
|
||||
# the log.
|
||||
log.info(
|
||||
"webhook received for unknown repo: repo_full=%s event=%s "
|
||||
"(no cached_rfcs row matched; hook may be on a fork or stale)",
|
||||
repo_full, event,
|
||||
)
|
||||
except Exception:
|
||||
log.exception("webhook refresh failed")
|
||||
raise HTTPException(status_code=500, detail="Refresh failed")
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
-- Private-beta email allowlist.
|
||||
--
|
||||
-- The framework supports a deployment-gated sign-in mode: when this
|
||||
-- table contains rows, only emails listed here (case-insensitively)
|
||||
-- may sign in via OAuth. Users already provisioned in the `users`
|
||||
-- table are grandfathered in by gitea_id and never re-checked against
|
||||
-- this list — so the operator who allow-listed themselves, signed in
|
||||
-- once, then removed their own email from the list does not lose
|
||||
-- access.
|
||||
--
|
||||
-- An empty `allowed_emails` table is the "open" state: no allowlist
|
||||
-- gate runs, and any successful OAuth sign-in provisions a new user
|
||||
-- as before. This means a fresh framework install behaves exactly as
|
||||
-- prior versions until the operator adds the first row, at which
|
||||
-- point the gate turns on for everyone not yet in `users`.
|
||||
|
||||
CREATE TABLE allowed_emails (
|
||||
email TEXT PRIMARY KEY COLLATE NOCASE,
|
||||
added_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
note TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
@@ -0,0 +1,105 @@
|
||||
-- §6.2 / v0.7.0: email + one-time-code sign-in.
|
||||
--
|
||||
-- Replaces the Gitea OAuth gesture as the primary human-auth path.
|
||||
-- The Gitea bot user + token are still needed for server-side git
|
||||
-- operations (repo reads, PR creation); only the operator-facing
|
||||
-- sign-in surface moves. The /auth/callback OAuth route remains
|
||||
-- functional during migration as a fallback, scheduled for removal
|
||||
-- in a future release once every active user has signed in via OTC
|
||||
-- at least once.
|
||||
--
|
||||
-- A row in `otc_codes` represents an outstanding 6-digit code that
|
||||
-- was emailed to `email`. Codes are stored hashed (bcrypt) rather
|
||||
-- than plaintext, so a database compromise does not expose the
|
||||
-- in-flight code. TTL is enforced by `expires_at`. Each `verify`
|
||||
-- success stamps `consumed_at` and refuses every later attempt
|
||||
-- against the same row.
|
||||
--
|
||||
-- The §6.2 identity model under v0.7.0:
|
||||
--
|
||||
-- * `users.email` is the primary identity key for new sign-ins.
|
||||
-- * `users.gitea_id` stays populated for users grandfathered in
|
||||
-- via the OAuth-era flow; new users have `gitea_id = NULL`.
|
||||
-- The unique-constraint on `gitea_id` is relaxed (in v0.5.0 it
|
||||
-- was `INTEGER UNIQUE NOT NULL`) to permit the NULL.
|
||||
-- * `users.email` becomes a (case-insensitive) unique key. An
|
||||
-- existing OAuth user whose Gitea profile carried an email is
|
||||
-- linked on first OTC sign-in; if no row matches, a fresh
|
||||
-- contributor row is provisioned.
|
||||
--
|
||||
-- New env vars (v0.7.0):
|
||||
-- * `OTC_TTL_MINUTES` (default 10): how long a code stays valid.
|
||||
-- * `OTC_REQUEST_COOLDOWN_SECONDS` (default 60): per-email rate
|
||||
-- limit between successive `/auth/otc/request` calls.
|
||||
|
||||
CREATE TABLE otc_codes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL COLLATE NOCASE,
|
||||
code_hash TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
expires_at TEXT NOT NULL,
|
||||
consumed_at TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX idx_otc_codes_email ON otc_codes (email, consumed_at, expires_at);
|
||||
|
||||
-- Relax `users.gitea_id` from `INTEGER UNIQUE NOT NULL` to a nullable
|
||||
-- column with a partial unique index that ignores nulls. SQLite does
|
||||
-- not support ALTER COLUMN, so we rebuild the table.
|
||||
--
|
||||
-- A few defensive notes:
|
||||
-- * Every foreign key into `users(id)` continues to resolve — `id`
|
||||
-- is the same INTEGER PRIMARY KEY in the rebuilt table.
|
||||
-- * `email` is now declared NOCASE so a `WHERE email = ?` match
|
||||
-- is case-insensitive without changing every read site. The
|
||||
-- prior column accepted any text; existing rows pass through
|
||||
-- unchanged.
|
||||
-- * `gitea_login` likewise relaxes from NOT NULL to nullable, so
|
||||
-- users provisioned by OTC alone don't carry a synthetic login.
|
||||
|
||||
CREATE TABLE users_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
gitea_id INTEGER,
|
||||
gitea_login TEXT,
|
||||
email TEXT COLLATE NOCASE,
|
||||
display_name TEXT NOT NULL,
|
||||
avatar_url TEXT,
|
||||
role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'contributor')),
|
||||
muted INTEGER NOT NULL DEFAULT 0,
|
||||
email_personal_direct INTEGER NOT NULL DEFAULT 1,
|
||||
email_watched_structural INTEGER NOT NULL DEFAULT 0,
|
||||
email_admin_actionable INTEGER NOT NULL DEFAULT 1,
|
||||
email_opt_out_all INTEGER NOT NULL DEFAULT 0,
|
||||
digest_cadence TEXT NOT NULL DEFAULT 'weekly' CHECK (digest_cadence IN ('off', 'weekly', 'daily')),
|
||||
notification_quiet_hours_start TEXT,
|
||||
notification_quiet_hours_end TEXT,
|
||||
notification_quiet_hours_timezone TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
last_seen_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
INSERT INTO users_new (
|
||||
id, gitea_id, gitea_login, email, display_name, avatar_url, role,
|
||||
muted, email_personal_direct, email_watched_structural,
|
||||
email_admin_actionable, email_opt_out_all, digest_cadence,
|
||||
notification_quiet_hours_start, notification_quiet_hours_end,
|
||||
notification_quiet_hours_timezone, created_at, last_seen_at
|
||||
)
|
||||
SELECT
|
||||
id, gitea_id, gitea_login, email, display_name, avatar_url, role,
|
||||
muted, email_personal_direct, email_watched_structural,
|
||||
email_admin_actionable, email_opt_out_all, digest_cadence,
|
||||
notification_quiet_hours_start, notification_quiet_hours_end,
|
||||
notification_quiet_hours_timezone, created_at, last_seen_at
|
||||
FROM users;
|
||||
|
||||
DROP TABLE users;
|
||||
ALTER TABLE users_new RENAME TO users;
|
||||
|
||||
CREATE INDEX idx_users_role ON users (role);
|
||||
-- Partial unique indexes so NULLs are permitted but populated values
|
||||
-- collide. Gitea linkage stays unique per gitea_id; OTC-era identity
|
||||
-- is keyed on email (case-insensitive via NOCASE on the column).
|
||||
CREATE UNIQUE INDEX idx_users_gitea_id ON users (gitea_id) WHERE gitea_id IS NOT NULL;
|
||||
CREATE UNIQUE INDEX idx_users_gitea_login ON users (gitea_login) WHERE gitea_login IS NOT NULL;
|
||||
CREATE UNIQUE INDEX idx_users_email ON users (email) WHERE email IS NOT NULL AND email != '';
|
||||
@@ -0,0 +1,35 @@
|
||||
-- v0.13.0 / roadmap item #11 — cookie consent.
|
||||
--
|
||||
-- The framework now ships a non-modal cookie consent banner per the
|
||||
-- privacy-and-cookies UX (SPEC §14.5 / §14.6). Authenticated viewers
|
||||
-- get their choice persisted server-side so it survives sign-out /
|
||||
-- sign-in across devices; anonymous viewers persist their choice in
|
||||
-- localStorage only.
|
||||
--
|
||||
-- Shape: a single row per user, three flags, plus a recorded-at stamp.
|
||||
-- The flags are:
|
||||
-- - essential: the framework's strictly-necessary cookies (session,
|
||||
-- itsdangerous-signed payloads, CSRF if any). Permanently
|
||||
-- true at the API surface — included in the row for
|
||||
-- symmetry with the analytics / other flags rather than
|
||||
-- because the user can switch it off.
|
||||
-- - analytics: reserved for the §13 analytics SDK gating that lands
|
||||
-- in v0.15.0. Off by default; opt-in via the banner.
|
||||
-- - other: everything else (third-party embeds, social widgets).
|
||||
-- Off by default; opt-in via the banner.
|
||||
--
|
||||
-- A NULL recorded_at means "no choice yet" — the banner should re-prompt
|
||||
-- the next time the user signs in on a fresh device. Once recorded_at is
|
||||
-- set, the banner is hidden until the user re-opens it from the
|
||||
-- /settings/notifications "Privacy & cookies" tab.
|
||||
--
|
||||
-- The row is created lazily on first PUT. Absence of a row is equivalent
|
||||
-- to NULL recorded_at — the banner shows.
|
||||
|
||||
CREATE TABLE cookie_consent (
|
||||
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
essential INTEGER NOT NULL DEFAULT 1 CHECK (essential IN (0, 1)),
|
||||
analytics INTEGER NOT NULL DEFAULT 0 CHECK (analytics IN (0, 1)),
|
||||
other_cookies INTEGER NOT NULL DEFAULT 0 CHECK (other_cookies IN (0, 1)),
|
||||
recorded_at TEXT
|
||||
);
|
||||
@@ -0,0 +1,76 @@
|
||||
-- §6.1 / §6.2 / §14.1 / v0.8.0: open beta-access request flow (roadmap item #6).
|
||||
--
|
||||
-- This release replaces v0.3.0's `allowed_emails` allowlist as the
|
||||
-- admission control. Anyone with a valid email can sign in via the
|
||||
-- v0.7.0 OTC flow; a fresh user lands in `permission_state='pending'`
|
||||
-- until an admin grants access. The first-OTC flow captures three
|
||||
-- profile fields (first name, last name, free-text "why I should be
|
||||
-- included in the beta") that the admin sees when triaging the
|
||||
-- request queue. The `allowed_emails` table stays in the schema as a
|
||||
-- fast-path bypass — populated rows are still readable by the
|
||||
-- existing admin UI; the OTC `/request` handler no longer consults
|
||||
-- it. v0.9.0's admin user-management page will replace the
|
||||
-- allowlist UI entirely.
|
||||
--
|
||||
-- Schema additions:
|
||||
--
|
||||
-- * `permission_state` — three-state CHECK: 'pending' | 'granted' |
|
||||
-- 'revoked'. Default 'granted' so every row at migration time
|
||||
-- passes through unaffected; only newly provisioned OTC users
|
||||
-- land in 'pending' (the OTC verify path sets the column
|
||||
-- explicitly on a fresh row, per `app/otc.py`). 'revoked' is the
|
||||
-- admin gesture for an account that earned a grant then later
|
||||
-- lost it; v0.8.0 doesn't surface a revoke UI, but the schema
|
||||
-- slot is here so v0.9.0's admin user-management page can flip
|
||||
-- the column without another migration.
|
||||
--
|
||||
-- * `first_name`, `last_name` — nullable TEXT. Captured on the
|
||||
-- first OTC sign-in via `POST /auth/me/beta-request`. Existing
|
||||
-- rows (OAuth-era users, OTC users provisioned in v0.7.0) carry
|
||||
-- NULL through the migration; the admin queue treats an
|
||||
-- unpopulated capture as "auto-grandfathered" since the row's
|
||||
-- `permission_state` is already 'granted'.
|
||||
--
|
||||
-- * `beta_request_reason` — nullable TEXT. The free-text "why I
|
||||
-- should be included" from the capture form. Bounded to ~4000
|
||||
-- chars at the endpoint layer (no DB-level constraint —
|
||||
-- SQLite's TEXT is unbounded).
|
||||
--
|
||||
-- * `permission_decided_by` — nullable INTEGER. The `users.id` of
|
||||
-- the admin who flipped `permission_state` from 'pending' to
|
||||
-- 'granted' (or 'granted' to 'revoked'). NULL for grandfathered
|
||||
-- rows (they were never decided — they passed through at
|
||||
-- migration). ON DELETE SET NULL because losing the admin row
|
||||
-- should not cascade-delete the user whose access they granted.
|
||||
--
|
||||
-- * `permission_decided_at` — nullable TEXT timestamp (ISO 8601,
|
||||
-- same shape as the existing `created_at` / `last_seen_at`).
|
||||
-- Co-populated with `permission_decided_by` on each decision.
|
||||
--
|
||||
-- Grandfathered-row invariant:
|
||||
--
|
||||
-- Every row that exists at migration time has
|
||||
-- `permission_state='granted'` and `permission_decided_by=NULL`
|
||||
-- (the column default + NULL preservation). v0.8.0's auth gate
|
||||
-- reads `permission_state='granted'` as the admission check, so
|
||||
-- no existing user is locked out by the upgrade. v0.7.0's OTC
|
||||
-- path is patched in the same release to set
|
||||
-- `permission_state='pending'` explicitly on a fresh row, so the
|
||||
-- gate engages only for users provisioned after the upgrade.
|
||||
|
||||
ALTER TABLE users ADD COLUMN permission_state TEXT NOT NULL DEFAULT 'granted'
|
||||
CHECK (permission_state IN ('pending', 'granted', 'revoked'));
|
||||
|
||||
ALTER TABLE users ADD COLUMN first_name TEXT;
|
||||
ALTER TABLE users ADD COLUMN last_name TEXT;
|
||||
ALTER TABLE users ADD COLUMN beta_request_reason TEXT;
|
||||
|
||||
ALTER TABLE users ADD COLUMN permission_decided_by INTEGER
|
||||
REFERENCES users(id) ON DELETE SET NULL;
|
||||
ALTER TABLE users ADD COLUMN permission_decided_at TEXT;
|
||||
|
||||
-- Index for the v0.9.0 admin queue: list pending requests ordered by
|
||||
-- when the user's row was created (the implicit "request received at"
|
||||
-- timestamp, since v0.8.0 sets pending at the same moment as the row
|
||||
-- itself is inserted via the OTC verify path).
|
||||
CREATE INDEX idx_users_permission_state ON users (permission_state);
|
||||
@@ -0,0 +1,52 @@
|
||||
-- §6.2 / v0.10.0: user-set passcodes after OTC (roadmap item #8).
|
||||
--
|
||||
-- After a successful OTC sign-in, a contributor may set a passcode
|
||||
-- (numeric PIN or short alphanumeric). Subsequent sign-ins on the same
|
||||
-- account can use email + passcode instead of email + OTC. OTC remains
|
||||
-- the structural fallback — a forgotten passcode is recovered by
|
||||
-- requesting a fresh OTC and signing in via that path. Per-account
|
||||
-- lockout after 5 consecutive verify failures redirects the user to
|
||||
-- the OTC path for 15 minutes; the OTC path itself is unaffected by
|
||||
-- the passcode lockout (a locked-out user can still receive a fresh
|
||||
-- code and sign in).
|
||||
--
|
||||
-- The columns are additive to the `users` table from `012_otc.sql`.
|
||||
-- v0.8.0's `permission_state` column (roadmap item #6) lands in the
|
||||
-- driver's integration order ahead of this migration; we do not touch
|
||||
-- that column here. v0.7.0's nullable-`gitea_id`/`gitea_login` shape
|
||||
-- is preserved verbatim.
|
||||
--
|
||||
-- Storage shape:
|
||||
--
|
||||
-- * `passcode_hash` (nullable) — bcrypt hash of the passcode.
|
||||
-- NULL means "no passcode set"; the user is OTC-only.
|
||||
-- * `passcode_set_at` (nullable) — timestamp of the most recent
|
||||
-- `passcode/set` call. Updated when a passcode is set or
|
||||
-- replaced; cleared when the passcode is removed.
|
||||
-- * `passcode_failed_attempts` — count of consecutive failed
|
||||
-- verify attempts since the last successful verify (or since
|
||||
-- the lockout cleared). Resets to 0 on success and on lockout
|
||||
-- expiry. Defaults to 0 so existing rows post-migration are
|
||||
-- not implicitly half-locked.
|
||||
-- * `passcode_locked_until` (nullable) — if populated and the
|
||||
-- timestamp is in the future, passcode verify is refused with
|
||||
-- HTTP 423. Cleared on successful verify after the window
|
||||
-- expires, or by the operator via direct DB intervention if
|
||||
-- ever needed (no admin endpoint surfaces this in v1).
|
||||
--
|
||||
-- v0.10.0 introduces no new env vars. The lockout window (5 attempts,
|
||||
-- 15 minutes) is hard-coded in `backend/app/passcode.py`; raising or
|
||||
-- lowering it is a future-§19.2 candidate. Passcode hashing reuses
|
||||
-- the bcrypt dependency added in v0.7.0 for OTC; no new secret is
|
||||
-- required (the existing `SECRET_KEY` continues to sign sessions).
|
||||
--
|
||||
-- Note on SQLite: ALTER TABLE ... ADD COLUMN is supported, so this
|
||||
-- migration does not need the rebuild dance that `012_otc.sql`
|
||||
-- required. The runner wraps each file in a single BEGIN/COMMIT
|
||||
-- block — see `backend/app/db.py` — so either every ADD COLUMN
|
||||
-- here lands or none do.
|
||||
|
||||
ALTER TABLE users ADD COLUMN passcode_hash TEXT;
|
||||
ALTER TABLE users ADD COLUMN passcode_set_at TEXT;
|
||||
ALTER TABLE users ADD COLUMN passcode_failed_attempts INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE users ADD COLUMN passcode_locked_until TEXT;
|
||||
@@ -0,0 +1,75 @@
|
||||
-- §6.2 / v0.11.0: trust device for 30 days (roadmap item #9).
|
||||
--
|
||||
-- After a successful OTC or passcode sign-in, the user can check
|
||||
-- "trust this device for 30 days." The framework then issues a
|
||||
-- server-issued opaque device-trust token, stores its hash on this
|
||||
-- table, and sets a long-lived HttpOnly + Secure + SameSite=Lax
|
||||
-- cookie carrying the raw token. On a subsequent visit, the cookie is
|
||||
-- presented at `/auth/device-trust/start`; if the server can match the
|
||||
-- hash to a non-expired non-revoked row, the user is signed in without
|
||||
-- another OTC / passcode round-trip.
|
||||
--
|
||||
-- v0.11.0 introduces no new env vars. The 30-day window is hard-coded
|
||||
-- in `backend/app/device_trust.py`; raising or lowering it (or making
|
||||
-- it user-selectable) is a §19.2 candidate, alongside the cross-device
|
||||
-- session-revocation surface this table will eventually share with the
|
||||
-- v0.10.0 passcode-lockout shape (see SPEC §19.2 / SESSIONS-AND-DEVICES).
|
||||
--
|
||||
-- Storage shape:
|
||||
--
|
||||
-- * `id` — surrogate key. Lets the revoke-device UI address a single
|
||||
-- row by id without leaking the token shape.
|
||||
-- * `user_id` — FK into users(id) with cascade on delete. A deleted
|
||||
-- user automatically loses every trusted device.
|
||||
-- * `device_token_hash` — bcrypt hash of the random opaque token
|
||||
-- issued at trust-time. The raw token only ever lives in the
|
||||
-- outbound `Set-Cookie` header and the inbound `Cookie` header;
|
||||
-- server-side storage is the hash, so a DB compromise does not
|
||||
-- hand attackers a stash of valid device tokens.
|
||||
-- * `created_at` — when the row was issued.
|
||||
-- * `expires_at` — `created_at + 30 days`. A row past this timestamp
|
||||
-- is dead; the lookup path refuses it without further checks.
|
||||
-- * `user_agent` — the User-Agent header captured at issuance.
|
||||
-- Stored verbatim (truncated to 1024 chars at the application
|
||||
-- layer) so the revoke-device UI can show a rough device label.
|
||||
-- Not used for any auth decision — purely a hint to the user
|
||||
-- reviewing their device list.
|
||||
-- * `last_seen_at` — refreshed every time the row authenticates a
|
||||
-- request. Lets the revoke-device UI surface "last used 3 days
|
||||
-- ago" so the user can tell which row corresponds to which
|
||||
-- device.
|
||||
-- * `revoked_at` — NULL means active; non-NULL stamps when the user
|
||||
-- (or admin) revoked the row. Lookups treat any non-NULL value
|
||||
-- as "this row is dead" without consulting the expiry; the
|
||||
-- revoke gesture is intentionally one-way (a revoked device must
|
||||
-- re-trust to come back online).
|
||||
--
|
||||
-- Indexing: a unique index on `device_token_hash` so collisions are
|
||||
-- detectable at insert time (the token space is 256 bits of CSPRNG
|
||||
-- entropy, so a collision is structurally impossible, but the
|
||||
-- declaration documents the invariant). A separate index on
|
||||
-- `(user_id, revoked_at)` so the revoke-device UI's list query is
|
||||
-- a covering walk.
|
||||
--
|
||||
-- The bcrypt dependency reused here was added in v0.7.0 for OTC and
|
||||
-- extended in v0.10.0 for passcodes; v0.11.0 needs no new dep.
|
||||
--
|
||||
-- The cookie shape: `rfc_device_trust` carries the raw token,
|
||||
-- HttpOnly, Secure, SameSite=Lax, Max-Age=2592000 (30 days). It is
|
||||
-- "essential" per the v0.13.0 cookie-consent banner (it is part of
|
||||
-- authentication, not analytics), so it is set regardless of the
|
||||
-- user's analytics / other-cookies choices.
|
||||
|
||||
CREATE TABLE device_trust (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
device_token_hash TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
expires_at TEXT NOT NULL,
|
||||
user_agent TEXT NOT NULL DEFAULT '',
|
||||
last_seen_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
revoked_at TEXT
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_device_trust_token_hash ON device_trust (device_token_hash);
|
||||
CREATE INDEX idx_device_trust_user ON device_trust (user_id, revoked_at);
|
||||
@@ -0,0 +1,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,30 @@
|
||||
-- v0.18.0 Slice 4: outbound_emails audit table.
|
||||
--
|
||||
-- Per the v0.18.0 email + webhook hygiene proposal §3, every send
|
||||
-- helper writes a row to this table before returning, regardless
|
||||
-- of outcome. status='sent' on success, 'failed' on exception,
|
||||
-- 'deferred' on the dev-fallback path (no SMTP_HOST configured).
|
||||
--
|
||||
-- The table is queried by `GET /api/admin/outbound-emails` to
|
||||
-- answer "did this person ever get their invite?" without having
|
||||
-- to grep VM logs, and by the v0.18.0 Slice 5 bounce-correlation
|
||||
-- hook (which looks up message_id when a POST lands at
|
||||
-- /api/webhooks/email-bounce and marks the matching row
|
||||
-- status='bounced').
|
||||
|
||||
CREATE TABLE IF NOT EXISTS outbound_emails (
|
||||
id INTEGER PRIMARY KEY,
|
||||
to_address TEXT NOT NULL,
|
||||
from_address TEXT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
kind TEXT NOT NULL, -- 'otc' | 'invite' | 'notification' | 'bundle' | 'digest' | 'rfc-invite'
|
||||
sent_at TEXT NOT NULL, -- ISO 8601, time the send was attempted
|
||||
status TEXT NOT NULL, -- 'sent' | 'failed' | 'deferred' | 'bounced'
|
||||
error TEXT, -- exception class + message if status='failed'
|
||||
notification_id INTEGER, -- nullable FK to notifications.id for the watcher path
|
||||
message_id TEXT -- the Message-ID header value, for bounce correlation
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_outbound_emails_to ON outbound_emails(to_address);
|
||||
CREATE INDEX IF NOT EXISTS idx_outbound_emails_sent_at ON outbound_emails(sent_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_outbound_emails_message ON outbound_emails(message_id);
|
||||
@@ -0,0 +1,47 @@
|
||||
-- Roadmap #26 (rfc-app v0.22.0): the optional "What will you be using
|
||||
-- this for?" capture on the two propose surfaces.
|
||||
--
|
||||
-- The roadmap's framing names "the rfcs table" and "the PR-metadata
|
||||
-- table" for a `proposed_use_case TEXT NULL` column. In this deployment
|
||||
-- those two surfaces are the cache tables `cached_rfcs` and `cached_prs`
|
||||
-- (002_cache.sql). We add the nullable column to each, matching the
|
||||
-- existing naming convention (no NOT NULL, no default — NULL is the
|
||||
-- "left blank" sentinel the view surfaces render tastefully).
|
||||
--
|
||||
-- BUT: those tables are *cache*, rebuilt from Gitea by the §4.1
|
||||
-- reconciler (cache.py). The reconciler's INSERT...ON CONFLICT DO UPDATE
|
||||
-- sets only the columns it knows about, so an unlisted column is
|
||||
-- preserved on the update path — yet a propose/open never *writes* the
|
||||
-- column through the cache (the write path is endpoint -> Gitea ->
|
||||
-- reconcile, and the reconciler does not carry this field). So the cache
|
||||
-- column alone would always read NULL.
|
||||
--
|
||||
-- The durable home is therefore a dedicated app-truth table the propose
|
||||
-- /open endpoints write directly (keyed by the PR number, which is the
|
||||
-- stable identity for both idea PRs and rfc_branch PRs) and the view
|
||||
-- endpoints read back. This is not cache — it is canonical and survives
|
||||
-- any reconcile. The cache columns are added too for parity with the
|
||||
-- roadmap's literal shape and for any future reconciler that learns to
|
||||
-- carry the field, but the side table is the source of truth read at
|
||||
-- view time.
|
||||
|
||||
ALTER TABLE cached_rfcs ADD COLUMN proposed_use_case TEXT;
|
||||
ALTER TABLE cached_prs ADD COLUMN proposed_use_case TEXT;
|
||||
|
||||
-- Canonical, reconcile-proof store. One row per propose/open that
|
||||
-- supplied a use case. `scope` distinguishes the propose-RFC surface
|
||||
-- ('rfc') from the propose-PR-against-an-RFC surface ('pr'); `pr_number`
|
||||
-- is the join key the endpoints already have in hand. NULL/omitted use
|
||||
-- cases simply never write a row here, so absence == "left blank".
|
||||
CREATE TABLE proposed_use_cases (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
scope TEXT NOT NULL CHECK (scope IN ('rfc', 'pr')),
|
||||
rfc_slug TEXT NOT NULL,
|
||||
pr_number INTEGER NOT NULL,
|
||||
use_case TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE (scope, pr_number)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_proposed_use_cases_lookup ON proposed_use_cases (scope, pr_number);
|
||||
CREATE INDEX idx_proposed_use_cases_slug ON proposed_use_cases (scope, rfc_slug);
|
||||
@@ -0,0 +1,54 @@
|
||||
-- v0.23.0 / roadmap item #29: server-side sign-in state resume.
|
||||
--
|
||||
-- Track each authenticated user's last-viewed route + a small bag of
|
||||
-- "light" component state so that the *next* sign-in can land the user
|
||||
-- back where they left off, rather than always dropping them on the
|
||||
-- empty-state home view.
|
||||
--
|
||||
-- Storage shape (one row per user — per-user, NOT per-device, per the
|
||||
-- #29 "safe default"):
|
||||
--
|
||||
-- * `user_id` — PRIMARY KEY and FK into users(id) with cascade on
|
||||
-- delete. INTEGER to match users.id (INTEGER PRIMARY KEY
|
||||
-- AUTOINCREMENT). A deleted user automatically loses their stored
|
||||
-- resume state. One row per user means a later sign-in on any
|
||||
-- device resumes the most-recently-recorded route — the per-user
|
||||
-- model the roadmap asks for.
|
||||
--
|
||||
-- * `last_route` — the frontend pathname the user was last on
|
||||
-- (e.g. "/rfc/open-human-model"). TEXT, nullable until the first
|
||||
-- route-change POST lands. NEVER contains draft-buffer contents —
|
||||
-- it is a route only. See SPEC §6.2 "Sign-in state resume
|
||||
-- (privacy)".
|
||||
--
|
||||
-- * `last_route_state` — a JSON-encoded bag of *light* component
|
||||
-- state (scroll anchors, open-tab selection, filter chips, etc.).
|
||||
-- SQLite has no native JSONB; we store JSON as TEXT exactly as the
|
||||
-- rest of the app stores its JSON blobs (json.dumps / json.loads,
|
||||
-- cf. permission_events.details, actions.details). Nullable.
|
||||
-- PRIVACY INVARIANT: this column MUST NOT carry draft-buffer text,
|
||||
-- PR bodies, comment drafts, or any user-typed content — only
|
||||
-- ephemeral view state safe to replay. The PUT handler is the
|
||||
-- enforcement point; the column comment is the contract.
|
||||
--
|
||||
-- * `resume_enabled` — the per-user opt-out flag. 1 (default) means
|
||||
-- "resume me where I left off"; 0 means "always land on home". The
|
||||
-- PUT handler no-ops the upsert when this is 0, and the read path
|
||||
-- refuses to hand back a stored route when this is 0. A
|
||||
-- profile-settings toggle UI to flip this is a follow-up (the
|
||||
-- column + default-on behavior ship now); see CHANGELOG v0.23.0.
|
||||
--
|
||||
-- * `last_updated_at` — TEXT timestamp, app convention
|
||||
-- `datetime('now')`, matching device_trust.last_seen_at /
|
||||
-- users.last_seen_at. Refreshed on every successful upsert.
|
||||
--
|
||||
-- No new env vars. The debounce interval for the frontend route-change
|
||||
-- POST is a frontend constant (~1s), not a server knob.
|
||||
|
||||
CREATE TABLE user_session_state (
|
||||
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
last_route TEXT,
|
||||
last_route_state TEXT, -- JSON-encoded light state, nullable
|
||||
resume_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
last_updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
@@ -0,0 +1,18 @@
|
||||
-- v0.25.0 / security audit 0026, finding H1.
|
||||
--
|
||||
-- The OTC verify path had no attempt-limit or lockout, unlike the
|
||||
-- passcode path (015_passcode.sql gave users.passcode_failed_attempts +
|
||||
-- passcode_locked_until). This table gives the OTC verify endpoint the
|
||||
-- same per-identity lockout shape. It is keyed by email rather than
|
||||
-- user_id because an OTC sign-in may not have a users row yet — the row
|
||||
-- is provisioned only on a *successful* verify, so the lockout state has
|
||||
-- to survive independently of it.
|
||||
--
|
||||
-- The per-IP rate limiter (app/ratelimit.py) is the primary brute-force
|
||||
-- defense; this table is the parity layer that mirrors the passcode
|
||||
-- lockout and persists across restarts.
|
||||
CREATE TABLE IF NOT EXISTS otc_verify_state (
|
||||
email TEXT PRIMARY KEY,
|
||||
failed_attempts INTEGER NOT NULL DEFAULT 0,
|
||||
locked_until TEXT
|
||||
);
|
||||
@@ -0,0 +1,59 @@
|
||||
-- v0.29.0 / roadmap #28 Part 3 — offer-to-contribute-to-a-pending-RFC.
|
||||
--
|
||||
-- When the #28 scanner matches a term in submitted PR/comment text to a
|
||||
-- *pending* RFC (a super-draft: accepted-as-an-idea but not yet graduated
|
||||
-- to an active RFC), the reader is offered a "ask to contribute" popover.
|
||||
-- Submitting it lands a row here AND a notification in each owner's §15
|
||||
-- inbox; the owner can accept (which fires #12's owner-invite flow with
|
||||
-- the requester as the invitee) or decline (the requester is notified and
|
||||
-- the request closes).
|
||||
--
|
||||
-- A "pending RFC" is scoped to a super-draft (cached_rfcs.state =
|
||||
-- 'super-draft'): it is in cached_rfcs (so the rfc_invitations FK that the
|
||||
-- accept path reuses resolves), it carries owners (owners_json) to route
|
||||
-- the request to, and it already has a discussion/contribution surface to
|
||||
-- open. Pre-merge idea PRs (not yet in cached_rfcs, no contribution
|
||||
-- surface) are deliberately out of scope — see backend/app/rfc_links.py.
|
||||
--
|
||||
-- The request row is the persistent record; the inbox notification is the
|
||||
-- owner-facing actionable surface keyed back to it via `notification_id`.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS contribution_requests (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
rfc_slug TEXT NOT NULL
|
||||
REFERENCES cached_rfcs(slug) ON DELETE CASCADE,
|
||||
requester_user_id INTEGER NOT NULL
|
||||
REFERENCES users(id) ON DELETE CASCADE,
|
||||
-- The term in the PR/comment text that surfaced the offer (e.g. the
|
||||
-- super-draft's title). Carried for the owner's context line and the
|
||||
-- requester's "what RFC" anchor; not a foreign key.
|
||||
matched_term TEXT NOT NULL,
|
||||
-- The three contribute-request fields (§15 / #26 vocabulary).
|
||||
-- `who_i_am` and `why` are required; `use_case` mirrors #26's
|
||||
-- optional ground-truth field.
|
||||
who_i_am TEXT NOT NULL,
|
||||
why TEXT NOT NULL,
|
||||
use_case TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending', 'accepted', 'declined')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
decided_at TEXT,
|
||||
decided_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
-- The rfc_invitations row minted on accept (the #12 reuse), and the
|
||||
-- owner-facing notification row that carries the Accept/Decline action.
|
||||
invitation_id INTEGER REFERENCES rfc_invitations(id) ON DELETE SET NULL,
|
||||
notification_id INTEGER REFERENCES notifications(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_contribution_requests_rfc
|
||||
ON contribution_requests(rfc_slug, status);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_contribution_requests_requester
|
||||
ON contribution_requests(requester_user_id, status);
|
||||
|
||||
-- At most one open (pending) request per (RFC, requester): a second ask
|
||||
-- while one is still pending is a 409, not a duplicate row. A decided
|
||||
-- request (accepted/declined) does not block a fresh ask later.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_contribution_requests_one_open
|
||||
ON contribution_requests(rfc_slug, requester_user_id)
|
||||
WHERE status = 'pending';
|
||||
@@ -8,3 +8,4 @@ anthropic>=0.39
|
||||
google-generativeai>=0.8
|
||||
openai>=1.50
|
||||
PyYAML>=6.0
|
||||
bcrypt>=4.2
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Shared pytest fixtures for the backend suite.
|
||||
|
||||
Added in v0.27.0 (security audit 0026) alongside the new per-IP rate
|
||||
limiter. The limiters in `app.ratelimit` are process-global singletons,
|
||||
so their state survives across tests within a run; without a reset, the
|
||||
accumulated requests from earlier tests exhaust the budget and later
|
||||
tests see spurious 429s. This autouse fixture gives every test a clean
|
||||
limiter window.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from app import ratelimit
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_rate_limiters():
|
||||
ratelimit._reset_all_for_tests()
|
||||
yield
|
||||
ratelimit._reset_all_for_tests()
|
||||
@@ -0,0 +1,728 @@
|
||||
"""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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# v0.18.0: invite-envelope header shape — Slice 2
|
||||
#
|
||||
# Invite mail goes through `build_envelope` and MUST land Date,
|
||||
# Message-ID, Auto-Submitted, AND a `List-Unsubscribe: <mailto:…>`
|
||||
# (no URL — the invitee isn't a user yet, so no per-user opt-out
|
||||
# row exists). The mailto: target is the operator's `EMAIL_FROM`
|
||||
# by default; the operator can override via `EMAIL_UNSUBSCRIBE_MAILTO`.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _provision_admin_and_send_invite(client, app_with_fake_gitea_fixture, *, to: str = "headers@ex.co"):
|
||||
provision_user_row(user_id=400, login="adminH", role="admin")
|
||||
sign_in_as(
|
||||
client, user_id=400, gitea_login="adminH",
|
||||
display_name="Admin H", role="admin",
|
||||
email="adminh@test",
|
||||
)
|
||||
_reset_outbound()
|
||||
r = client.post(
|
||||
"/api/admin/users",
|
||||
json={
|
||||
"email": to,
|
||||
"first_name": "Header",
|
||||
"last_name": "Test",
|
||||
"role": "contributor",
|
||||
"custom_message": "",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
return _outbound_invite_envelopes(to)[-1]
|
||||
|
||||
|
||||
def test_invite_envelope_sets_date_messageid_autosubmitted(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from email.utils import parsedate_to_datetime
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
env = _provision_admin_and_send_invite(client, (app, _fake))
|
||||
msg = env["message"]
|
||||
assert parsedate_to_datetime(msg["Date"]) is not None
|
||||
assert msg["Message-ID"].startswith("<") and msg["Message-ID"].endswith(">")
|
||||
assert msg["Auto-Submitted"] == "auto-generated"
|
||||
|
||||
|
||||
def test_invite_envelope_has_mailto_list_unsubscribe_only(app_with_fake_gitea):
|
||||
"""The invitee isn't a user yet — no per-user opt-out URL is
|
||||
available. The `List-Unsubscribe` MUST be a mailto: form, and
|
||||
the `List-Unsubscribe-Post` header MUST be absent (the
|
||||
one-click semantic requires a URL the MUA can POST to)."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
env = _provision_admin_and_send_invite(client, (app, _fake))
|
||||
msg = env["message"]
|
||||
lu = msg["List-Unsubscribe"]
|
||||
assert lu is not None and lu.startswith("<mailto:")
|
||||
# No URL part — invite is mailto-only.
|
||||
assert "https://" not in lu and "http://" not in lu
|
||||
assert msg["List-Unsubscribe-Post"] is None
|
||||
|
||||
|
||||
def test_invite_envelope_respects_email_unsubscribe_mailto_override(app_with_fake_gitea, monkeypatch):
|
||||
"""When `EMAIL_UNSUBSCRIBE_MAILTO` is set, the mailto: target on
|
||||
`List-Unsubscribe` honors it (lets a deployment route opt-outs
|
||||
to a humans-monitored mailbox distinct from the no-reply
|
||||
sender)."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
monkeypatch.setenv("EMAIL_UNSUBSCRIBE_MAILTO", "ohm@wiggleverse.org?subject=remove")
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
env = _provision_admin_and_send_invite(client, (app, _fake))
|
||||
msg = env["message"]
|
||||
assert "ohm@wiggleverse.org?subject=remove" in msg["List-Unsubscribe"]
|
||||
@@ -0,0 +1,425 @@
|
||||
"""End-to-end integration tests for v0.9.0's admin user-management page
|
||||
and new-beta-request notifications (roadmap item #7, §6.1 / §15).
|
||||
|
||||
The release lands two halves of the same surface:
|
||||
|
||||
* **Admin notification on new beta request.** When a pending user
|
||||
submits `POST /api/auth/me/beta-request`, every owner/admin
|
||||
receives a `new_beta_request` notification (the §15 substrate
|
||||
insert lands the row; the §15.4 email path dispatches subject to
|
||||
the recipient's `email_admin_actionable` toggle).
|
||||
|
||||
* **Admin user-management surface** at `/admin/users`. The
|
||||
`GET /api/admin/users` listing carries every user with their
|
||||
permission_state, profile fields, sign-up reason, and decision
|
||||
audit. The new `POST /api/admin/users/<id>/permission` endpoint
|
||||
flips the column and writes a `permission_events` row.
|
||||
|
||||
The tests prove:
|
||||
|
||||
* The first beta-request submission fans a `new_beta_request`
|
||||
row out to every admin/owner (and not to the requester
|
||||
themselves). The row carries the captured profile in
|
||||
`payload.extras`.
|
||||
* Re-submitting the form from the same pending user doesn't
|
||||
re-fan (we only notify on the row's first complete state).
|
||||
* `GET /api/admin/users` carries the v0.9.0 columns
|
||||
(permission_state, first/last/reason, decided_by).
|
||||
* `POST /api/admin/users/<id>/permission` flips the state,
|
||||
stamps decided_by/at, and writes a `permission_events` row.
|
||||
* The endpoint refuses self-flip (422) and refuses non-admin
|
||||
callers (403).
|
||||
* The endpoint accepts only the three valid states (422 on
|
||||
anything else).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401 — fixtures land via import
|
||||
FakeGitea,
|
||||
app_with_fake_gitea,
|
||||
provision_user_row,
|
||||
sign_in_as,
|
||||
tmp_env,
|
||||
)
|
||||
|
||||
|
||||
def _reset_outbound():
|
||||
from app import email as email_mod
|
||||
email_mod.reset_sent_envelopes()
|
||||
|
||||
|
||||
def _outbound_otc_codes(to_address: str | None = None) -> list[str]:
|
||||
from app import email as email_mod
|
||||
out = []
|
||||
for env in email_mod.sent_envelopes():
|
||||
if env.get("kind") != "otc":
|
||||
continue
|
||||
if to_address is not None and env["to"] != to_address:
|
||||
continue
|
||||
for line in env["body"].splitlines():
|
||||
tok = line.strip()
|
||||
if tok.isdigit() and len(tok) == 6:
|
||||
out.append(tok)
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def _provision_pending_user(client, email: str) -> int:
|
||||
"""Sign in a fresh OTC user (lands `pending`) and return their user_id."""
|
||||
from app import db
|
||||
_reset_outbound()
|
||||
client.post("/auth/otc/request", json={"email": email})
|
||||
code = _outbound_otc_codes(email)[-1]
|
||||
client.post("/auth/otc/verify", json={"email": email, "code": code})
|
||||
row = db.conn().execute(
|
||||
"SELECT id FROM users WHERE email = ? COLLATE NOCASE", (email,)
|
||||
).fetchone()
|
||||
return row["id"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Admin notification on beta-request submission
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_beta_request_submission_notifies_every_admin(app_with_fake_gitea):
|
||||
"""First-time submission of a beta-request fans a notification out
|
||||
to every owner and admin. The requester themselves never receives
|
||||
a row (filtered out by user_id even if they happened to be in the
|
||||
admin set, which they aren't in practice — fresh OTC users are
|
||||
`contributor`+`pending`)."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
# Provision two admins and one owner so the fan-out has multiple
|
||||
# targets. The OWNER_GITEA_LOGIN-derived ownership doesn't fire
|
||||
# here (no OAuth round-trip in this path); we seed the role
|
||||
# directly.
|
||||
provision_user_row(user_id=10, login="ownerzero", role="owner")
|
||||
provision_user_row(user_id=11, login="admin_one", role="admin")
|
||||
provision_user_row(user_id=12, login="admin_two", role="admin")
|
||||
provision_user_row(user_id=13, login="contrib_one", role="contributor")
|
||||
|
||||
# Sign in a fresh OTC user → permission_state='pending'.
|
||||
requester_id = _provision_pending_user(client, "newbie@example.com")
|
||||
|
||||
# Capture-form submit.
|
||||
r = client.post(
|
||||
"/api/auth/me/beta-request",
|
||||
json={
|
||||
"first_name": "Newt",
|
||||
"last_name": "Newcomer",
|
||||
"beta_request_reason": "I want to write the Human RFC.",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# Every owner + admin gets a `new_beta_request` notification.
|
||||
# The contributor (id=13) does not. The requester (whoever id
|
||||
# they got) does not.
|
||||
rows = db.conn().execute(
|
||||
"""
|
||||
SELECT recipient_user_id, event_kind, actor_user_id, payload
|
||||
FROM notifications
|
||||
WHERE event_kind = 'new_beta_request'
|
||||
"""
|
||||
).fetchall()
|
||||
recipients = sorted(r["recipient_user_id"] for r in rows)
|
||||
assert recipients == [10, 11, 12], f"unexpected recipients: {recipients}"
|
||||
# Actor is the requester (§15.9: never the bot).
|
||||
for r in rows:
|
||||
assert r["actor_user_id"] == requester_id
|
||||
import json as _json
|
||||
extras = _json.loads(r["payload"])
|
||||
assert extras["requester_first_name"] == "Newt"
|
||||
assert extras["requester_last_name"] == "Newcomer"
|
||||
assert extras["requester_email"] == "newbie@example.com"
|
||||
|
||||
|
||||
def test_beta_request_resubmit_does_not_re_notify(app_with_fake_gitea):
|
||||
"""Once a user has completed the capture form, re-submitting it
|
||||
(the endpoint is idempotent for pending users) must not re-fan a
|
||||
fresh notification to every admin — that would carpet-bomb the
|
||||
inbox on every typo correction."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=20, login="adminzero", role="admin")
|
||||
_provision_pending_user(client, "carpet@example.com")
|
||||
|
||||
body = {
|
||||
"first_name": "Carpet",
|
||||
"last_name": "Bomb",
|
||||
"beta_request_reason": "first draft",
|
||||
}
|
||||
r1 = client.post("/api/auth/me/beta-request", json=body)
|
||||
assert r1.status_code == 200
|
||||
|
||||
# Re-submit with edited reason — endpoint accepts (idempotent
|
||||
# update), but the admin inbox stays at one row.
|
||||
body2 = dict(body, beta_request_reason="cleaner final draft")
|
||||
r2 = client.post("/api/auth/me/beta-request", json=body2)
|
||||
assert r2.status_code == 200
|
||||
|
||||
rows = db.conn().execute(
|
||||
"SELECT COUNT(*) AS n FROM notifications WHERE event_kind = 'new_beta_request'"
|
||||
).fetchone()
|
||||
assert rows["n"] == 1
|
||||
|
||||
|
||||
def test_beta_request_notification_is_admin_actionable_category(app_with_fake_gitea):
|
||||
"""The §15.4 category mapping must route `new_beta_request` to the
|
||||
admin-actionable bucket so the email gate consults
|
||||
`email_admin_actionable` (and skips for non-admin recipients).
|
||||
"""
|
||||
from app import email as email_mod
|
||||
|
||||
assert email_mod.category_for("new_beta_request", "structural") == "admin-actionable"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /api/admin/users — listing carries the v0.9.0 columns
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_admin_users_listing_carries_permission_columns(app_with_fake_gitea):
|
||||
"""The Users tab consumes this shape — confirm every required
|
||||
column is on the response."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
# Seed an admin and a pending user with all the v0.8.0 columns
|
||||
# populated. Direct-DB insert avoids the OTC dance (which would
|
||||
# overwrite the cookie); the test above proves the capture
|
||||
# pathway end-to-end and this one just exercises the listing
|
||||
# surface's shape.
|
||||
provision_user_row(user_id=30, login="ben", role="owner")
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO users (id, gitea_id, gitea_login, email,
|
||||
display_name, avatar_url, role,
|
||||
permission_state, first_name, last_name,
|
||||
beta_request_reason)
|
||||
VALUES (31, NULL, NULL, 'pendinguser@example.com',
|
||||
'pendinguser', '', 'contributor',
|
||||
'pending', 'Penn', 'Ding', 'I want in.')
|
||||
"""
|
||||
)
|
||||
|
||||
sign_in_as(
|
||||
client, user_id=30, gitea_login="ben",
|
||||
display_name="Ben", role="owner",
|
||||
)
|
||||
|
||||
r = client.get("/api/admin/users")
|
||||
assert r.status_code == 200
|
||||
items = r.json()["items"]
|
||||
assert isinstance(items, list)
|
||||
pending = next(
|
||||
(i for i in items if i["email"] == "pendinguser@example.com"), None,
|
||||
)
|
||||
assert pending is not None
|
||||
assert pending["permission_state"] == "pending"
|
||||
assert pending["first_name"] == "Penn"
|
||||
assert pending["last_name"] == "Ding"
|
||||
assert pending["beta_request_reason"] == "I want in."
|
||||
assert pending["permission_decided_at"] is None
|
||||
assert pending["permission_decided_by_login"] is None
|
||||
# Pending bucket is listed first (sort order).
|
||||
assert items[0]["permission_state"] == "pending"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /api/admin/users/<id>/permission — the flip endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_permission_flip_grant_promotes_pending_to_granted(app_with_fake_gitea):
|
||||
"""The end-to-end gesture: a fresh OTC user lands pending, an admin
|
||||
flips them to granted via the endpoint, the row reflects the new
|
||||
state + decided_by/at, and a `permission_events` audit row lands."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
# Pending user.
|
||||
pending_id = _provision_pending_user(client, "flip@example.com")
|
||||
|
||||
# Admin acting on them.
|
||||
provision_user_row(user_id=40, login="adminflipper", role="admin")
|
||||
sign_in_as(
|
||||
client, user_id=40, gitea_login="adminflipper",
|
||||
display_name="Admin Flipper", role="admin",
|
||||
)
|
||||
|
||||
r = client.post(
|
||||
f"/api/admin/users/{pending_id}/permission",
|
||||
json={"state": "granted"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["permission_state"] == "granted"
|
||||
assert body["changed"] is True
|
||||
|
||||
# Row reflects the new state + decision stamp.
|
||||
row = db.conn().execute(
|
||||
"SELECT permission_state, permission_decided_by, permission_decided_at "
|
||||
"FROM users WHERE id = ?",
|
||||
(pending_id,),
|
||||
).fetchone()
|
||||
assert row["permission_state"] == "granted"
|
||||
assert row["permission_decided_by"] == 40
|
||||
assert row["permission_decided_at"] is not None
|
||||
|
||||
# Audit row landed in permission_events.
|
||||
events = db.conn().execute(
|
||||
"""
|
||||
SELECT actor_user_id, subject_user_id, event_kind
|
||||
FROM permission_events
|
||||
WHERE event_kind = 'permission_granted'
|
||||
"""
|
||||
).fetchall()
|
||||
assert len(events) == 1
|
||||
assert events[0]["actor_user_id"] == 40
|
||||
assert events[0]["subject_user_id"] == pending_id
|
||||
|
||||
|
||||
def test_permission_flip_revoke_promotes_granted_to_revoked(app_with_fake_gitea):
|
||||
"""Revoke is the symmetric gesture. Used when an account earned a
|
||||
grant then later lost it (§6.1 / `revoked` state)."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=50, login="goner", role="contributor")
|
||||
# Default permission_state is 'granted' via the column default.
|
||||
provision_user_row(user_id=51, login="adminrevoker", role="admin")
|
||||
sign_in_as(
|
||||
client, user_id=51, gitea_login="adminrevoker",
|
||||
display_name="Admin Revoker", role="admin",
|
||||
)
|
||||
|
||||
r = client.post(
|
||||
"/api/admin/users/50/permission",
|
||||
json={"state": "revoked"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
row = db.conn().execute(
|
||||
"SELECT permission_state FROM users WHERE id = 50"
|
||||
).fetchone()
|
||||
assert row["permission_state"] == "revoked"
|
||||
|
||||
events = db.conn().execute(
|
||||
"SELECT event_kind FROM permission_events "
|
||||
"WHERE event_kind = 'permission_revoked' AND subject_user_id = 50"
|
||||
).fetchall()
|
||||
assert len(events) == 1
|
||||
|
||||
|
||||
def test_permission_flip_refuses_self(app_with_fake_gitea):
|
||||
"""Symmetric to set_mute / set_role: an admin can't self-flip.
|
||||
The state-change channel for one's own grant is somebody else's
|
||||
hand."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=60, login="selfflipper", role="admin")
|
||||
sign_in_as(
|
||||
client, user_id=60, gitea_login="selfflipper",
|
||||
display_name="Self Flipper", role="admin",
|
||||
)
|
||||
r = client.post(
|
||||
"/api/admin/users/60/permission",
|
||||
json={"state": "revoked"},
|
||||
)
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
def test_permission_flip_refuses_non_admin(app_with_fake_gitea):
|
||||
"""The endpoint is admin-only (§17 admin/* requires require_admin).
|
||||
A contributor caller is refused 403; an anonymous caller 401."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=70, login="target", role="contributor")
|
||||
provision_user_row(user_id=71, login="contrib", role="contributor")
|
||||
sign_in_as(
|
||||
client, user_id=71, gitea_login="contrib",
|
||||
display_name="Contrib", role="contributor",
|
||||
)
|
||||
r = client.post(
|
||||
"/api/admin/users/70/permission",
|
||||
json={"state": "granted"},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
client.cookies.clear()
|
||||
r = client.post(
|
||||
"/api/admin/users/70/permission",
|
||||
json={"state": "granted"},
|
||||
)
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_permission_flip_refuses_invalid_state(app_with_fake_gitea):
|
||||
"""Pydantic regex pattern refuses anything outside the three
|
||||
canonical states with 422."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=80, login="targetx", role="contributor")
|
||||
provision_user_row(user_id=81, login="adminx", role="admin")
|
||||
sign_in_as(
|
||||
client, user_id=81, gitea_login="adminx",
|
||||
display_name="Admin X", role="admin",
|
||||
)
|
||||
r = client.post(
|
||||
"/api/admin/users/80/permission",
|
||||
json={"state": "banished"},
|
||||
)
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
def test_permission_flip_no_op_when_state_already_matches(app_with_fake_gitea):
|
||||
"""An admin flipping a granted user to granted gets 200 with
|
||||
`changed: false` — no audit row, no decided_at update."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=90, login="alreadygranted", role="contributor")
|
||||
provision_user_row(user_id=91, login="adminN", role="admin")
|
||||
sign_in_as(
|
||||
client, user_id=91, gitea_login="adminN",
|
||||
display_name="Admin N", role="admin",
|
||||
)
|
||||
|
||||
before_events = db.conn().execute(
|
||||
"SELECT COUNT(*) AS n FROM permission_events"
|
||||
).fetchone()["n"]
|
||||
|
||||
r = client.post(
|
||||
"/api/admin/users/90/permission",
|
||||
json={"state": "granted"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["changed"] is False
|
||||
|
||||
after_events = db.conn().execute(
|
||||
"SELECT COUNT(*) AS n FROM permission_events"
|
||||
).fetchone()["n"]
|
||||
assert after_events == before_events
|
||||
@@ -0,0 +1,476 @@
|
||||
"""v0.6.0 (roadmap item #4) — "anon discuss + contribute off-limits"
|
||||
vertical.
|
||||
|
||||
A sweep-the-edges hardening release. The v0.3.0 release hid the write
|
||||
affordances from anonymous viewers; v0.5.0 added the PR-less discussion
|
||||
surface with its own write gate. v0.6.0 audits both: every write-shaped
|
||||
endpoint refuses anonymous callers with 401 (or 403 when the role check
|
||||
runs after the auth check), and every anonymous-read surface stays
|
||||
reachable.
|
||||
|
||||
This test is the regression net for the audit. It walks each module's
|
||||
representative write endpoint as an anonymous client and asserts the
|
||||
401/403, then walks the same surfaces' representative read endpoints
|
||||
as anonymous and asserts the 200. The intent is breadth over depth:
|
||||
one assertion per write endpoint family is enough to catch a
|
||||
regression where someone strips the `auth.require_contributor` line.
|
||||
|
||||
Endpoints covered (one or two from each module):
|
||||
|
||||
- api.py: propose, decline (admin), withdraw,
|
||||
funder credentials POST/DELETE, funder consent
|
||||
POST/DELETE
|
||||
- api_branches.py: promote-to-branch, start-edit-branch, metadata,
|
||||
manual-flush, visibility, grants POST/DELETE,
|
||||
threads POST, thread messages POST, resolve,
|
||||
chat-seen, change accept/decline/reask
|
||||
- api_prs.py: pr-draft, open-pr, seen, review, merge, withdraw,
|
||||
description, resolution-branch
|
||||
- api_discussion.py: thread create, message post, resolve
|
||||
- api_admin.py: role POST, mute POST, allowlist POST/DELETE
|
||||
- api_notifications.py: prefs POST, watch POST, mark-read POST,
|
||||
quiet-hours POST, user-mute POST/DELETE
|
||||
- api_graduation.py: graduate POST, claim POST, progress GET
|
||||
|
||||
The §15.7 reads (`/api/notifications`, `/api/watches`,
|
||||
`/api/users/me/*`) are per-user surfaces — they require an
|
||||
authenticated viewer by definition; an anonymous 401 on those reads is
|
||||
shape-correct, not a regression. The test does not assert reads on
|
||||
those.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
# Reuse the fixture / session / fake-Gitea harness from Slice 1.
|
||||
from test_propose_vertical import ( # noqa: F401 — fixtures land via import
|
||||
FakeGitea,
|
||||
app_with_fake_gitea,
|
||||
provision_user_row,
|
||||
sign_in_as,
|
||||
tmp_env,
|
||||
)
|
||||
from test_rfc_view_vertical import SEED_BODY, seed_active_rfc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_anonymous_can_read_every_public_surface(app_with_fake_gitea):
|
||||
"""Per §14 / the v0.3.0 anonymous-read contract: the catalog, the
|
||||
RFC view, the PR-less discussion surface, the philosophy page, and
|
||||
the health probe must remain reachable for unauthenticated viewers.
|
||||
This is the read side of the item #4 contract — the read surfaces
|
||||
must NOT regress to require auth as the write gates tighten.
|
||||
"""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="alice", role="contributor")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
|
||||
# No session cookie — viewer is anonymous.
|
||||
client.cookies.clear()
|
||||
|
||||
# The five read surfaces an anonymous viewer must reach.
|
||||
assert client.get("/api/health").status_code == 200
|
||||
assert client.get("/api/philosophy").status_code == 200
|
||||
assert client.get("/api/auth/me").status_code == 200
|
||||
assert client.get("/api/rfcs").status_code == 200
|
||||
assert client.get("/api/rfcs/ohm").status_code == 200
|
||||
assert client.get("/api/rfcs/ohm/main").status_code == 200
|
||||
assert client.get("/api/rfcs/ohm/discussion/threads").status_code == 200
|
||||
assert client.get("/api/proposals").status_code == 200
|
||||
|
||||
|
||||
def test_anonymous_propose_refused(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
client.cookies.clear()
|
||||
r = client.post(
|
||||
"/api/rfcs/propose",
|
||||
json={"title": "X", "slug": "x", "pitch": "p", "tags": []},
|
||||
)
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_anonymous_proposal_admin_paths_refused(app_with_fake_gitea):
|
||||
"""The admin-gated proposal actions — merge, decline — must refuse
|
||||
anonymous callers with 401 (the auth check runs before the role
|
||||
check; both refusals are correct, but 401 is the structural signal
|
||||
"no session at all")."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
client.cookies.clear()
|
||||
# PR number doesn't need to exist — the gate runs first.
|
||||
assert client.post("/api/proposals/1/merge").status_code == 401
|
||||
assert (
|
||||
client.post("/api/proposals/1/decline", json={"comment": "no"}).status_code
|
||||
== 401
|
||||
)
|
||||
assert client.post("/api/proposals/1/withdraw").status_code == 401
|
||||
|
||||
|
||||
def test_anonymous_branch_writes_refused_on_active_rfc(app_with_fake_gitea):
|
||||
"""Branch-scoped writes on an active RFC: promote-to-branch,
|
||||
manual-flush, visibility, grants, threads create, message post,
|
||||
resolve, chat-seen, change accept/decline/reask. All must 401 for
|
||||
anonymous callers."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
client.cookies.clear()
|
||||
|
||||
# Branch-scoped writes — slug + branch values are placeholders;
|
||||
# the auth gate runs before any state lookup.
|
||||
slug = "ohm"
|
||||
branch = "feature-x"
|
||||
|
||||
assert (
|
||||
client.post(
|
||||
f"/api/rfcs/{slug}/branches/main/promote-to-branch",
|
||||
json={},
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
f"/api/rfcs/{slug}/branches/{branch}/manual-flush",
|
||||
json={"new_content": "hi", "paragraph_count": 1},
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
f"/api/rfcs/{slug}/branches/{branch}/visibility",
|
||||
json={"read_public": False},
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
f"/api/rfcs/{slug}/branches/{branch}/grants",
|
||||
json={"grantee_gitea_login": "alice"},
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.delete(
|
||||
f"/api/rfcs/{slug}/branches/{branch}/grants/alice",
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
f"/api/rfcs/{slug}/branches/{branch}/threads",
|
||||
json={"thread_kind": "chat", "anchor_kind": "whole-doc"},
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
f"/api/rfcs/{slug}/branches/{branch}/threads/1/messages",
|
||||
json={"text": "hi"},
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
f"/api/rfcs/{slug}/branches/{branch}/threads/1/resolve",
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
f"/api/rfcs/{slug}/branches/{branch}/chat-seen",
|
||||
json={"last_seen_message_id": 1},
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
f"/api/rfcs/{slug}/branches/{branch}/changes/1/accept",
|
||||
json={"proposed": "x"},
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
f"/api/rfcs/{slug}/branches/{branch}/changes/1/decline",
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
f"/api/rfcs/{slug}/branches/{branch}/changes/1/reask",
|
||||
).status_code == 401
|
||||
)
|
||||
# Chat stream — POST shaped, same auth gate.
|
||||
assert (
|
||||
client.post(
|
||||
f"/api/rfcs/{slug}/branches/{branch}/threads/1/chat",
|
||||
json={"text": "hi"},
|
||||
).status_code == 401
|
||||
)
|
||||
|
||||
|
||||
def test_anonymous_super_draft_writes_refused(app_with_fake_gitea):
|
||||
"""Super-draft-scoped writes: start-edit-branch and metadata. The
|
||||
PR open / merge paths share the gate via api_prs.py — see the
|
||||
PR-flow test below for those."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
client.cookies.clear()
|
||||
assert (
|
||||
client.post(
|
||||
"/api/rfcs/anything/start-edit-branch", json={}
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
"/api/rfcs/anything/metadata", json={"title": "x"}
|
||||
).status_code == 401
|
||||
)
|
||||
|
||||
|
||||
def test_anonymous_pr_flow_writes_refused(app_with_fake_gitea):
|
||||
"""All §10 PR-flow writes — open, merge, withdraw, description,
|
||||
review, seen, pr-draft, resolution-branch — must 401 for anonymous."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
client.cookies.clear()
|
||||
slug, branch, pr = "ohm", "feature-x", 1
|
||||
assert (
|
||||
client.post(
|
||||
f"/api/rfcs/{slug}/branches/{branch}/pr-draft"
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
f"/api/rfcs/{slug}/branches/{branch}/open-pr",
|
||||
json={"title": "t", "description": "d"},
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
f"/api/rfcs/{slug}/prs/{pr}/seen",
|
||||
json={"last_seen_message_id": 1},
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
f"/api/rfcs/{slug}/prs/{pr}/review",
|
||||
json={"text": "x", "anchor_payload": {}},
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
f"/api/rfcs/{slug}/prs/{pr}/merge"
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
f"/api/rfcs/{slug}/prs/{pr}/withdraw"
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
f"/api/rfcs/{slug}/prs/{pr}/description",
|
||||
json={"title": "t", "description": "d"},
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
f"/api/rfcs/{slug}/prs/{pr}/resolution-branch"
|
||||
).status_code == 401
|
||||
)
|
||||
|
||||
|
||||
def test_anonymous_discussion_writes_refused(app_with_fake_gitea):
|
||||
"""The v0.5.0 PR-less discussion surface — write gates must hold.
|
||||
This duplicates the assertion in `test_discussion_vertical.py` and
|
||||
keeps it here too as the canonical home for the item #4 audit."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
client.cookies.clear()
|
||||
assert (
|
||||
client.post(
|
||||
"/api/rfcs/ohm/discussion/threads",
|
||||
json={"message": "drive-by"},
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
"/api/rfcs/ohm/discussion/threads/1/messages",
|
||||
json={"text": "drive-by"},
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
"/api/rfcs/ohm/discussion/threads/1/resolve"
|
||||
).status_code == 401
|
||||
)
|
||||
|
||||
|
||||
def test_anonymous_admin_writes_refused(app_with_fake_gitea):
|
||||
"""Admin surfaces — role, mute, allowlist — refuse anonymous.
|
||||
The auth check runs before the require_admin role check, so the
|
||||
response is 401."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
client.cookies.clear()
|
||||
assert (
|
||||
client.post(
|
||||
"/api/admin/users/1/role", json={"role": "admin"}
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
"/api/admin/users/1/mute", json={"muted": True}
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
"/api/admin/allowlist", json={"email": "x@y.z"}
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.delete("/api/admin/allowlist/x@y.z").status_code == 401
|
||||
)
|
||||
# Admin reads also gated.
|
||||
assert client.get("/api/admin/users").status_code == 401
|
||||
assert client.get("/api/admin/audit").status_code == 401
|
||||
assert client.get("/api/admin/permission-events").status_code == 401
|
||||
assert client.get("/api/admin/graduation-queue").status_code == 401
|
||||
assert client.get("/api/admin/allowlist").status_code == 401
|
||||
|
||||
|
||||
def test_anonymous_notification_writes_refused(app_with_fake_gitea):
|
||||
"""Notification preference / watch / mark-read / user-mute writes —
|
||||
all per-user surfaces, all require an authenticated viewer."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
client.cookies.clear()
|
||||
assert (
|
||||
client.post(
|
||||
"/api/users/me/notification-preferences",
|
||||
json={"email_personal_direct": False},
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
"/api/users/me/quiet-hours",
|
||||
json={"start": None, "end": None, "timezone": None},
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post("/api/rfcs/ohm/watch", json={"state": "watching"}).status_code
|
||||
== 401
|
||||
)
|
||||
assert client.post("/api/notifications/1/read").status_code == 401
|
||||
assert (
|
||||
client.post("/api/notifications/read", json={}).status_code == 401
|
||||
)
|
||||
assert client.post("/api/users/1/notification-mute").status_code == 401
|
||||
assert client.delete("/api/users/1/notification-mute").status_code == 401
|
||||
|
||||
|
||||
def test_anonymous_funder_writes_refused(app_with_fake_gitea):
|
||||
"""§6.7 funder credential + consent writes — registering a key,
|
||||
consenting to fund — all refuse anonymous callers."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
client.cookies.clear()
|
||||
assert (
|
||||
client.post(
|
||||
"/api/users/me/funder/credentials",
|
||||
json={"provider": "anthropic", "api_key": "sk-test"},
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.delete(
|
||||
"/api/users/me/funder/credentials/anthropic"
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post("/api/rfcs/ohm/funder/consent").status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.delete("/api/rfcs/ohm/funder/consent").status_code == 401
|
||||
)
|
||||
|
||||
|
||||
def test_anonymous_graduation_writes_refused(app_with_fake_gitea):
|
||||
"""§13 graduation: the POST kickoff and POST claim both refuse
|
||||
anonymous. The progress SSE was gated to require_user in v0.6.0
|
||||
(item #4) since it surfaces admin-internal step detail."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
client.cookies.clear()
|
||||
assert (
|
||||
client.post(
|
||||
"/api/rfcs/anything/graduate",
|
||||
json={
|
||||
"rfc_id": "RFC-0001",
|
||||
"repo_name": "rfc-0001-x",
|
||||
"owners": ["alice"],
|
||||
},
|
||||
).status_code == 401
|
||||
)
|
||||
assert client.post("/api/rfcs/anything/claim").status_code == 401
|
||||
# v0.6.0 tightening: progress SSE now requires require_user.
|
||||
# No graduation is in flight, but the auth check runs first.
|
||||
assert (
|
||||
client.get("/api/rfcs/anything/graduate/progress").status_code == 401
|
||||
)
|
||||
|
||||
|
||||
def test_anonymous_can_read_published_pr_view(app_with_fake_gitea):
|
||||
"""The PR review page is §11.3 universal-public — once a PR is
|
||||
open, anonymous viewers can read it. This guards against a
|
||||
regression where the read endpoint accidentally grows an auth
|
||||
gate."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
# Seed an open PR row directly — the cache shape is enough for
|
||||
# the read endpoint; the live Gitea fetch falls back gracefully.
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO cached_prs
|
||||
(rfc_slug, pr_kind, repo, pr_number, title, description, state,
|
||||
opened_by, opened_at, head_branch, base_branch, head_sha)
|
||||
VALUES ('ohm', 'rfc_branch', 'wiggleverse/rfc-0001-ohm', 7, 't', 'd',
|
||||
'open', 'alice', datetime('now'), 'feature-x', 'main', 'sha7')
|
||||
"""
|
||||
)
|
||||
client.cookies.clear()
|
||||
# Anonymous read on an open PR: should be 200. The endpoint may
|
||||
# surface a partial response (the FakeGitea won't have the head
|
||||
# branch's RFC.md, so branch_body falls back to empty) but the
|
||||
# auth gate must let the read through.
|
||||
r = client.get("/api/rfcs/ohm/prs/7")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["capabilities"]["is_anonymous"] is True
|
||||
assert body["capabilities"]["can_merge"] is False
|
||||
assert body["capabilities"]["can_post_review"] is False
|
||||
@@ -0,0 +1,390 @@
|
||||
"""End-to-end integration tests for v0.8.0's open beta-access request
|
||||
flow (§6.1 / §14.1, roadmap item #6).
|
||||
|
||||
The release replaces v0.3.0's `allowed_emails` allowlist as the
|
||||
admission gate. Any valid email can sign in via the v0.7.0 OTC flow;
|
||||
a fresh user lands in `permission_state='pending'` until an admin
|
||||
grants access. The first-OTC flow captures first name, last name,
|
||||
and a free-text "why I should be included in the beta" via a new
|
||||
`POST /api/auth/me/beta-request` endpoint.
|
||||
|
||||
The tests prove:
|
||||
|
||||
* A fresh OTC user lands `permission_state='pending'` with empty
|
||||
profile fields, and the verify-response carries `needs_profile=true`.
|
||||
* `POST /api/auth/me/beta-request` populates the three fields and
|
||||
leaves the row in `pending`.
|
||||
* A pending user is refused write endpoints (representative
|
||||
samples: propose RFC, post discussion thread). The refusal is
|
||||
403 (not 401 — they're authenticated, just not granted).
|
||||
* An admin-grant flow promotes pending → granted. v0.8.0 doesn't
|
||||
ship an admin UI for this (deferred to item #7 / v0.9.0), so
|
||||
the test flips the column directly via DB and asserts that
|
||||
`require_contributor` now admits the user.
|
||||
* A grandfathered user (existing row pre-migration, default
|
||||
`permission_state='granted'`) is unaffected — write endpoints
|
||||
accept them.
|
||||
* The `/auth/otc/request` endpoint accepts any email — the
|
||||
v0.7.0 allowlist gate is gone from this path. The `allowed_emails`
|
||||
table stays in the schema; the admin UI from v0.3.0 continues to
|
||||
manage it for the fast-path bypass deployments may use.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401 — fixtures land via import
|
||||
FakeGitea,
|
||||
app_with_fake_gitea,
|
||||
provision_user_row,
|
||||
sign_in_as,
|
||||
tmp_env,
|
||||
)
|
||||
|
||||
|
||||
def _reset_outbound():
|
||||
from app import email as email_mod
|
||||
email_mod.reset_sent_envelopes()
|
||||
|
||||
|
||||
def _outbound_otc_codes(to_address: str | None = None) -> list[str]:
|
||||
"""Pluck the code line from every OTC envelope in the test buffer."""
|
||||
from app import email as email_mod
|
||||
out = []
|
||||
for env in email_mod.sent_envelopes():
|
||||
if env.get("kind") != "otc":
|
||||
continue
|
||||
if to_address is not None and env["to"] != to_address:
|
||||
continue
|
||||
for line in env["body"].splitlines():
|
||||
tok = line.strip()
|
||||
if tok.isdigit() and len(tok) == 6:
|
||||
out.append(tok)
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fresh OTC sign-in lands pending with empty fields
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_fresh_otc_user_lands_pending_with_empty_profile(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
|
||||
# Request + verify the OTC.
|
||||
r = client.post("/auth/otc/request", json={"email": "newcomer@example.com"})
|
||||
assert r.status_code == 200, r.text
|
||||
code = _outbound_otc_codes("newcomer@example.com")[-1]
|
||||
|
||||
r = client.post("/auth/otc/verify", json={"email": "newcomer@example.com", "code": code})
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
# The verify response carries the new fields v0.8.0 added.
|
||||
assert body["needs_profile"] is True
|
||||
assert body["user"]["permission_state"] == "pending"
|
||||
|
||||
# The row reflects the same: pending state, no profile yet.
|
||||
row = db.conn().execute(
|
||||
"SELECT permission_state, first_name, last_name, beta_request_reason FROM users WHERE email = ? COLLATE NOCASE",
|
||||
("newcomer@example.com",),
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
assert row["permission_state"] == "pending"
|
||||
assert row["first_name"] is None
|
||||
assert row["last_name"] is None
|
||||
assert row["beta_request_reason"] is None
|
||||
|
||||
# /api/auth/me surfaces the same shape.
|
||||
me = client.get("/api/auth/me").json()
|
||||
assert me["authenticated"] is True
|
||||
assert me["user"]["permission_state"] == "pending"
|
||||
assert me["user"]["needs_profile"] is True
|
||||
assert me["user"]["first_name"] == ""
|
||||
assert me["user"]["last_name"] == ""
|
||||
assert me["user"]["beta_request_reason"] == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# beta-request endpoint captures the fields and leaves state pending
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_beta_request_populates_fields_keeps_state_pending(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
# Sign in the fresh user via the full OTC flow.
|
||||
client.post("/auth/otc/request", json={"email": "alice@example.com"})
|
||||
code = _outbound_otc_codes("alice@example.com")[-1]
|
||||
client.post("/auth/otc/verify", json={"email": "alice@example.com", "code": code})
|
||||
|
||||
# Submit the capture form.
|
||||
r = client.post(
|
||||
"/api/auth/me/beta-request",
|
||||
json={
|
||||
"first_name": "Alice",
|
||||
"last_name": "Liddell",
|
||||
"beta_request_reason": "I want to help write the RFCs.",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# The row reflects the captured fields; state stays pending.
|
||||
row = db.conn().execute(
|
||||
"SELECT permission_state, first_name, last_name, beta_request_reason FROM users WHERE email = ? COLLATE NOCASE",
|
||||
("alice@example.com",),
|
||||
).fetchone()
|
||||
assert row["permission_state"] == "pending"
|
||||
assert row["first_name"] == "Alice"
|
||||
assert row["last_name"] == "Liddell"
|
||||
assert row["beta_request_reason"] == "I want to help write the RFCs."
|
||||
|
||||
# /api/auth/me now reports needs_profile=false (fields are set).
|
||||
me = client.get("/api/auth/me").json()
|
||||
assert me["user"]["permission_state"] == "pending"
|
||||
assert me["user"]["needs_profile"] is False
|
||||
assert me["user"]["first_name"] == "Alice"
|
||||
|
||||
|
||||
def test_beta_request_refuses_anonymous(app_with_fake_gitea):
|
||||
"""The endpoint requires authentication — an anonymous caller can't
|
||||
file a request without first signing in via OTC."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
client.cookies.clear()
|
||||
r = client.post(
|
||||
"/api/auth/me/beta-request",
|
||||
json={"first_name": "A", "last_name": "B", "beta_request_reason": "Hi"},
|
||||
)
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_beta_request_refuses_granted_user(app_with_fake_gitea):
|
||||
"""A grandfathered (already granted) user has no business filing a
|
||||
beta request. The endpoint refuses with 409 so the client can
|
||||
distinguish the failure from "we don't know you" (401)."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="grandfathered", role="contributor")
|
||||
sign_in_as(
|
||||
client,
|
||||
user_id=1,
|
||||
gitea_login="grandfathered",
|
||||
display_name="Grandfathered",
|
||||
role="contributor",
|
||||
)
|
||||
r = client.post(
|
||||
"/api/auth/me/beta-request",
|
||||
json={"first_name": "G", "last_name": "F", "beta_request_reason": "x"},
|
||||
)
|
||||
assert r.status_code == 409
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pending user is refused write endpoints; admin grant promotes them
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_pending_user_is_refused_write_endpoints(app_with_fake_gitea):
|
||||
"""A pending user can read everything anonymous can read, but every
|
||||
write-shaped endpoint refuses with 403. The refusal shape mirrors
|
||||
the v0.6.0 / item #4 audit's anon-401 — both are "no contributor
|
||||
capability"; pending is the authenticated-but-ungranted variant.
|
||||
|
||||
Representative samples: propose RFC, post discussion thread.
|
||||
"""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
# Sign in via fresh OTC — lands pending.
|
||||
client.post("/auth/otc/request", json={"email": "pending@example.com"})
|
||||
code = _outbound_otc_codes("pending@example.com")[-1]
|
||||
client.post("/auth/otc/verify", json={"email": "pending@example.com", "code": code})
|
||||
|
||||
# Reads work — every anonymous surface stays reachable.
|
||||
assert client.get("/api/health").status_code == 200
|
||||
assert client.get("/api/rfcs").status_code == 200
|
||||
assert client.get("/api/philosophy").status_code == 200
|
||||
|
||||
# Propose — write-shaped, refused with 403.
|
||||
r = client.post(
|
||||
"/api/rfcs/propose",
|
||||
json={"title": "T", "slug": "t", "pitch": "p", "tags": []},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
# The error body mentions the review state so a UI surface can
|
||||
# render the right message — but the test asserts only on the
|
||||
# status code (the body shape is the FastAPI default detail).
|
||||
|
||||
|
||||
def test_admin_grant_promotes_pending_to_granted(app_with_fake_gitea):
|
||||
"""v0.8.0 doesn't ship an admin UI for this — it's deferred to
|
||||
item #7 / v0.9.0. For this release, an admin gesture is an
|
||||
`UPDATE users SET permission_state='granted' WHERE email=?`. The
|
||||
test flips the column directly via DB and asserts the
|
||||
`require_contributor` gate now admits the user.
|
||||
"""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
# Sign in a fresh OTC user — lands pending.
|
||||
client.post("/auth/otc/request", json={"email": "promoted@example.com"})
|
||||
code = _outbound_otc_codes("promoted@example.com")[-1]
|
||||
client.post("/auth/otc/verify", json={"email": "promoted@example.com", "code": code})
|
||||
|
||||
# Before the grant: propose refused with 403.
|
||||
r = client.post(
|
||||
"/api/rfcs/propose",
|
||||
json={"title": "T", "slug": "t-pre", "pitch": "p", "tags": []},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
# The admin gesture (v0.8.0 shape — direct UPDATE; v0.9.0 will
|
||||
# ship a UI). The test stamps `permission_decided_by` and
|
||||
# `permission_decided_at` as the v0.9.0 admin UI will, so the
|
||||
# column population exercises the schema slot. user_id=99 is
|
||||
# a placeholder admin row — provision it so the FK resolves.
|
||||
provision_user_row(user_id=99, login="adminuser", role="admin")
|
||||
db.conn().execute(
|
||||
"""
|
||||
UPDATE users
|
||||
SET permission_state = 'granted',
|
||||
permission_decided_by = 99,
|
||||
permission_decided_at = datetime('now')
|
||||
WHERE email = ?
|
||||
""",
|
||||
("promoted@example.com",),
|
||||
)
|
||||
|
||||
# The next request reads the fresh column from the DB. The
|
||||
# propose endpoint reaches the route body now (it then refuses
|
||||
# for a different reason — the slug 't-prop' will fail
|
||||
# the slug-format check or hit a mock-gitea path — but the
|
||||
# status code is _not_ 403/401, which is the v0.8.0 assertion).
|
||||
r = client.post(
|
||||
"/api/rfcs/propose",
|
||||
json={"title": "Title", "slug": "tprop", "pitch": "Pitch text.", "tags": []},
|
||||
)
|
||||
assert r.status_code != 403, r.text
|
||||
assert r.status_code != 401, r.text
|
||||
|
||||
|
||||
def test_grandfathered_user_is_unaffected_by_migration(app_with_fake_gitea):
|
||||
"""An existing `users` row at migration time has
|
||||
`permission_state='granted'` via the column default. The
|
||||
grandfathered user passes write endpoints without filing a
|
||||
beta request and without the admin UI. v0.6.0 (anon-write
|
||||
audit) is the v0.6.0 contract; v0.8.0 widens the gate but
|
||||
does not break this case.
|
||||
"""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=5, login="oldhand", role="contributor")
|
||||
# provision_user_row uses INSERT OR REPLACE INTO users with
|
||||
# the column list it knows; permission_state is not in that
|
||||
# list, so it picks up the column default ('granted') on
|
||||
# insert. Confirm directly.
|
||||
row = db.conn().execute(
|
||||
"SELECT permission_state FROM users WHERE id = 5"
|
||||
).fetchone()
|
||||
assert row["permission_state"] == "granted"
|
||||
|
||||
sign_in_as(
|
||||
client,
|
||||
user_id=5,
|
||||
gitea_login="oldhand",
|
||||
display_name="Old Hand",
|
||||
role="contributor",
|
||||
)
|
||||
|
||||
# Propose is write-shaped; the call should not refuse on
|
||||
# the permission_state gate. (Subsequent failure modes —
|
||||
# e.g. mock-gitea wiring — are not the v0.8.0 concern; this
|
||||
# test asserts on the gate, not the propose body's success.)
|
||||
r = client.post(
|
||||
"/api/rfcs/propose",
|
||||
json={"title": "Title", "slug": "gf-slug", "pitch": "Pitch.", "tags": []},
|
||||
)
|
||||
assert r.status_code != 403, r.text
|
||||
assert r.status_code != 401, r.text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /auth/otc/request accepts any email — the v0.7.0 allowlist gate is gone
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_otc_request_accepts_any_email_regardless_of_allowlist(app_with_fake_gitea):
|
||||
"""v0.7.0 silently dropped OTC requests for emails not on the
|
||||
`allowed_emails` table. v0.8.0 reverses this: the request
|
||||
endpoint sends a code to any valid email; admission gates at
|
||||
`permission_state` post-verify instead. The `allowed_emails`
|
||||
table stays in the schema as a fast-path bypass for
|
||||
deployments that want to pre-mark known-good emails (the v0.9.0
|
||||
admin user-management page will collapse the two surfaces).
|
||||
"""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
# Populate the allowlist with one specific email so the v0.7.0
|
||||
# gate would have engaged. v0.8.0 ignores it for the request
|
||||
# path.
|
||||
db.conn().execute("INSERT INTO allowed_emails (email) VALUES (?)", ("known@example.com",))
|
||||
|
||||
# An email NOT on the allowlist still gets a code under v0.8.0.
|
||||
r = client.post("/auth/otc/request", json={"email": "stranger@example.com"})
|
||||
assert r.status_code == 200
|
||||
codes = _outbound_otc_codes("stranger@example.com")
|
||||
assert len(codes) == 1, "OTC code must be sent regardless of allowlist state"
|
||||
|
||||
# The row is there and the user can complete sign-in (and will
|
||||
# land in 'pending' per the other tests).
|
||||
row = db.conn().execute(
|
||||
"SELECT 1 FROM otc_codes WHERE email = ?",
|
||||
("stranger@example.com",),
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
|
||||
|
||||
def test_allowlist_table_still_present_in_schema(app_with_fake_gitea):
|
||||
"""The schema migration leaves the `allowed_emails` table in
|
||||
place — the admin UI from v0.3.0 still manages it for the
|
||||
fast-path bypass deployments may use. This is a regression net
|
||||
for "did the v0.8.0 cleanup accidentally drop the table"."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
# The table accepts inserts (i.e. it exists) — no schema check
|
||||
# gymnastics needed.
|
||||
db.conn().execute("INSERT INTO allowed_emails (email) VALUES (?)", ("kept@example.com",))
|
||||
row = db.conn().execute(
|
||||
"SELECT email FROM allowed_emails WHERE email = ?",
|
||||
("kept@example.com",),
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
@@ -0,0 +1,236 @@
|
||||
"""v0.29.0 / roadmap #28 Parts 2 & 3 — create-RFC offers + contribute-to-
|
||||
pending requests.
|
||||
|
||||
Two layers, mirroring test_rfc_links_vertical.py:
|
||||
|
||||
* The PR-view scanner surfaces `rfc-pending` (Part 3) and `rfc-candidate`
|
||||
(Part 2) segments alongside Part 1's `rfc` links.
|
||||
* The contribute-request flow end-to-end: a non-owner asks, each owner
|
||||
gets an actionable §15 notification, accept fires #12's invite flow,
|
||||
decline notifies the requester.
|
||||
|
||||
Reuses the FakeGitea + seed/session helpers from the existing suites.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import db
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
FakeGitea,
|
||||
app_with_fake_gitea,
|
||||
provision_user_row,
|
||||
sign_in_as,
|
||||
tmp_env,
|
||||
)
|
||||
from test_rfc_view_vertical import SEED_BODY, seed_active_rfc
|
||||
from test_super_draft_vertical import seed_super_draft
|
||||
from test_rfc_links_vertical import _open_pr_on
|
||||
|
||||
|
||||
def _set_owner(slug: str, login: str) -> None:
|
||||
db.conn().execute(
|
||||
"UPDATE cached_rfcs SET owners_json = ? WHERE slug = ?",
|
||||
(json.dumps([login]), slug),
|
||||
)
|
||||
|
||||
|
||||
def _set_tags(slug: str, tags: list[str]) -> None:
|
||||
db.conn().execute(
|
||||
"UPDATE cached_rfcs SET tags_json = ? WHERE slug = ?",
|
||||
(json.dumps(tags), slug),
|
||||
)
|
||||
|
||||
|
||||
def _segs(segments, kind):
|
||||
return [s for s in segments if s["type"] == kind]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Part 2 + Part 3 — scanner surfaces on the PR view
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_pending_and_candidate_segments_on_pr(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
# Host active RFC (OHM — single word, contributes no keys itself)
|
||||
# carrying a multi-word tag with no defining RFC: the Part 2
|
||||
# candidate. And a pending super-draft owned by alice: the Part 3
|
||||
# contribute target.
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
_set_tags("ohm", ["memory model", "identity"])
|
||||
seed_super_draft(fake, slug="open-human-model", title="Open Human Model",
|
||||
pitch="A framework for representing humans.", proposed_by="alice")
|
||||
_set_owner("open-human-model", "alice")
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
|
||||
pr_number = _open_pr_on(
|
||||
client, fake, host_slug="ohm",
|
||||
description="This builds on the Open Human Model and the memory model.",
|
||||
)
|
||||
pr = client.get(f"/api/rfcs/ohm/prs/{pr_number}").json()
|
||||
segs = pr["description_segments"]
|
||||
|
||||
pending = _segs(segs, "rfc-pending")
|
||||
assert len(pending) == 1
|
||||
assert pending[0]["slug"] == "open-human-model"
|
||||
assert pending[0]["label"] == "Open Human Model"
|
||||
assert pending[0]["owner"] == "Alice" # display_name of the owner
|
||||
|
||||
candidate = _segs(segs, "rfc-candidate")
|
||||
assert len(candidate) == 1
|
||||
assert candidate[0]["term"] == "memory model"
|
||||
# "identity" is a single-word tag — deliberately NOT a candidate.
|
||||
assert all("identity" not in s.get("term", "") for s in candidate)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Part 3 — the contribute-request flow
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _seed_pending_owned_by_alice(fake):
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
provision_user_row(user_id=3, login="bob", role="contributor")
|
||||
seed_super_draft(fake, slug="open-human-model", title="Open Human Model",
|
||||
pitch="A framework.", proposed_by="alice")
|
||||
_set_owner("open-human-model", "alice")
|
||||
|
||||
|
||||
_REQUEST = {
|
||||
"matched_term": "Open Human Model",
|
||||
"who_i_am": "Bob, a researcher",
|
||||
"why": "I have relevant prior work to bring.",
|
||||
"use_case": "Building an identity tool.",
|
||||
}
|
||||
|
||||
|
||||
def test_request_accept_invites_and_notifies(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_pending_owned_by_alice(fake)
|
||||
|
||||
# Bob asks to contribute.
|
||||
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob",
|
||||
role="contributor", email="bob@test")
|
||||
r = client.post("/api/rfcs/open-human-model/contribution-requests", json=_REQUEST)
|
||||
assert r.status_code == 200, r.text
|
||||
request_id = r.json()["id"]
|
||||
assert r.json()["status"] == "pending"
|
||||
|
||||
# A second ask while pending is a 409, not a duplicate row.
|
||||
assert client.post("/api/rfcs/open-human-model/contribution-requests",
|
||||
json=_REQUEST).status_code == 409
|
||||
|
||||
# Alice (owner) sees the actionable notification with full detail.
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice",
|
||||
role="contributor", email="alice@test")
|
||||
inbox = client.get("/api/notifications").json()
|
||||
reqs = [i for i in inbox["items"]
|
||||
if i["event_kind"] == "contribution_request_on_pending_rfc"]
|
||||
assert len(reqs) == 1
|
||||
assert "wants to contribute" in reqs[0]["summary"]
|
||||
assert reqs[0]["extras"]["who_i_am"] == "Bob, a researcher"
|
||||
assert reqs[0]["extras"]["request_id"] == request_id
|
||||
|
||||
# Alice accepts → #12 invitation minted for bob's email.
|
||||
acc = client.post(f"/api/rfcs/open-human-model/contribution-requests/{request_id}/accept")
|
||||
assert acc.status_code == 200, acc.text
|
||||
assert acc.json()["status"] == "accepted"
|
||||
assert acc.json()["invitation_id"]
|
||||
|
||||
inv = db.conn().execute(
|
||||
"SELECT invitee_email, role_in_rfc, status FROM rfc_invitations "
|
||||
"WHERE rfc_slug = 'open-human-model'"
|
||||
).fetchone()
|
||||
assert inv["invitee_email"] == "bob@test"
|
||||
assert inv["role_in_rfc"] == "contributor"
|
||||
assert inv["status"] == "pending"
|
||||
|
||||
# The request is settled — re-accepting is a 409.
|
||||
assert client.post(
|
||||
f"/api/rfcs/open-human-model/contribution-requests/{request_id}/accept"
|
||||
).status_code == 409
|
||||
|
||||
# Bob gets the accepted echo in his inbox.
|
||||
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob",
|
||||
role="contributor", email="bob@test")
|
||||
bob_kinds = [i["event_kind"] for i in client.get("/api/notifications").json()["items"]]
|
||||
assert "contribution_request_accepted" in bob_kinds
|
||||
|
||||
|
||||
def test_decline_notifies_requester(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_pending_owned_by_alice(fake)
|
||||
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob",
|
||||
role="contributor", email="bob@test")
|
||||
request_id = client.post(
|
||||
"/api/rfcs/open-human-model/contribution-requests", json=_REQUEST
|
||||
).json()["id"]
|
||||
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice",
|
||||
role="contributor", email="alice@test")
|
||||
dec = client.post(f"/api/rfcs/open-human-model/contribution-requests/{request_id}/decline")
|
||||
assert dec.status_code == 200, dec.text
|
||||
assert dec.json()["status"] == "declined"
|
||||
|
||||
row = db.conn().execute(
|
||||
"SELECT status FROM contribution_requests WHERE id = ?", (request_id,)
|
||||
).fetchone()
|
||||
assert row["status"] == "declined"
|
||||
|
||||
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob",
|
||||
role="contributor", email="bob@test")
|
||||
bob_kinds = [i["event_kind"] for i in client.get("/api/notifications").json()["items"]]
|
||||
assert "contribution_request_declined" in bob_kinds
|
||||
|
||||
|
||||
def test_owner_cannot_request_own_rfc(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_pending_owned_by_alice(fake)
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice",
|
||||
role="contributor", email="alice@test")
|
||||
r = client.post("/api/rfcs/open-human-model/contribution-requests", json=_REQUEST)
|
||||
assert r.status_code == 409
|
||||
assert "own" in r.json()["detail"].lower()
|
||||
|
||||
|
||||
def test_request_on_active_rfc_rejected(app_with_fake_gitea):
|
||||
# The contribute offer only exists for pending super-drafts; an active
|
||||
# RFC uses the Part-1 link instead.
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=3, login="bob", role="contributor")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob",
|
||||
role="contributor", email="bob@test")
|
||||
r = client.post("/api/rfcs/ohm/contribution-requests", json=_REQUEST)
|
||||
assert r.status_code == 409
|
||||
|
||||
|
||||
def test_contribution_target_eligibility(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_pending_owned_by_alice(fake)
|
||||
|
||||
# Anonymous: not eligible, told to sign in.
|
||||
t = client.get("/api/rfcs/open-human-model/contribution-target").json()
|
||||
assert t["eligible"] is False
|
||||
assert "sign in" in (t["reason"] or "").lower()
|
||||
assert t["owner"] == "Alice"
|
||||
|
||||
# Bob: eligible until he has a pending ask, then not.
|
||||
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob",
|
||||
role="contributor", email="bob@test")
|
||||
assert client.get("/api/rfcs/open-human-model/contribution-target").json()["eligible"] is True
|
||||
client.post("/api/rfcs/open-human-model/contribution-requests", json=_REQUEST)
|
||||
after = client.get("/api/rfcs/open-human-model/contribution-target").json()
|
||||
assert after["already_requested"] is True
|
||||
assert after["eligible"] is False
|
||||
@@ -0,0 +1,205 @@
|
||||
"""End-to-end tests for v0.13.0 / roadmap item #11 — cookie / privacy consent.
|
||||
|
||||
Covers the §17 endpoints (`GET` / `PUT /api/users/me/cookie-consent`) and
|
||||
the §14.5 storage contract:
|
||||
|
||||
* GET on a fresh user returns no-choice-yet (recorded_at is None,
|
||||
essential=True, analytics=False, other=False).
|
||||
* PUT writes a row, stamps recorded_at, and the choice survives.
|
||||
* PUT with `analytics=true, other=false` round-trips faithfully.
|
||||
* `essential` is permanently true at the API surface — a PUT that
|
||||
requests essential=false is still persisted with essential=true.
|
||||
* The endpoint requires authentication (401 for anon).
|
||||
* A second PUT updates the existing row in place (single row per
|
||||
user, recorded_at re-stamps).
|
||||
* Choice persists across sign-out / sign-in.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
FakeGitea,
|
||||
app_with_fake_gitea,
|
||||
provision_user_row,
|
||||
sign_in_as,
|
||||
tmp_env,
|
||||
)
|
||||
|
||||
|
||||
def test_get_cookie_consent_fresh_user_has_no_choice(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
|
||||
r = client.get("/api/users/me/cookie-consent")
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["essential"] is True
|
||||
assert body["analytics"] is False
|
||||
assert body["other"] is False
|
||||
assert body["recorded_at"] is None
|
||||
|
||||
|
||||
def test_put_cookie_consent_records_choice(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
|
||||
r = client.put(
|
||||
"/api/users/me/cookie-consent",
|
||||
json={"essential": True, "analytics": True, "other": False},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["ok"] is True
|
||||
assert body["essential"] is True
|
||||
assert body["analytics"] is True
|
||||
assert body["other"] is False
|
||||
assert body["recorded_at"] is not None
|
||||
|
||||
# Round-trip the read endpoint.
|
||||
r = client.get("/api/users/me/cookie-consent")
|
||||
body = r.json()
|
||||
assert body["essential"] is True
|
||||
assert body["analytics"] is True
|
||||
assert body["other"] is False
|
||||
assert body["recorded_at"] is not None
|
||||
|
||||
|
||||
def test_put_cookie_consent_forces_essential_true(app_with_fake_gitea):
|
||||
"""§14.5: `essential` is permanently true at the API surface. A
|
||||
request that sets it to false is accepted (for symmetry with the
|
||||
other two flags) but persisted as true.
|
||||
"""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
|
||||
r = client.put(
|
||||
"/api/users/me/cookie-consent",
|
||||
json={"essential": False, "analytics": False, "other": False},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["essential"] is True
|
||||
|
||||
# Confirm at the schema layer too — the persisted row has essential=1.
|
||||
row = db.conn().execute(
|
||||
"SELECT essential FROM cookie_consent WHERE user_id = ?",
|
||||
(2,),
|
||||
).fetchone()
|
||||
assert row["essential"] == 1
|
||||
|
||||
|
||||
def test_cookie_consent_requires_auth(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/api/users/me/cookie-consent")
|
||||
assert r.status_code == 401, r.text
|
||||
r = client.put(
|
||||
"/api/users/me/cookie-consent",
|
||||
json={"essential": True, "analytics": True, "other": True},
|
||||
)
|
||||
assert r.status_code == 401, r.text
|
||||
|
||||
|
||||
def test_put_cookie_consent_upserts_in_place(app_with_fake_gitea):
|
||||
"""A second PUT updates the existing row rather than inserting a new
|
||||
one. Verifies the §14.5 single-row-per-user shape.
|
||||
"""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
|
||||
client.put(
|
||||
"/api/users/me/cookie-consent",
|
||||
json={"essential": True, "analytics": True, "other": False},
|
||||
)
|
||||
client.put(
|
||||
"/api/users/me/cookie-consent",
|
||||
json={"essential": True, "analytics": False, "other": True},
|
||||
)
|
||||
|
||||
rows = db.conn().execute(
|
||||
"SELECT analytics, other_cookies FROM cookie_consent WHERE user_id = ?",
|
||||
(2,),
|
||||
).fetchall()
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["analytics"] == 0
|
||||
assert rows[0]["other_cookies"] == 1
|
||||
|
||||
|
||||
def test_cookie_consent_persists_across_sign_out_in(app_with_fake_gitea):
|
||||
"""§14.5 precedence: the server row survives sign-out / sign-in.
|
||||
"""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
|
||||
client.put(
|
||||
"/api/users/me/cookie-consent",
|
||||
json={"essential": True, "analytics": True, "other": True},
|
||||
)
|
||||
|
||||
# Simulate sign-out by clearing the session cookie.
|
||||
client.cookies.clear()
|
||||
|
||||
# Anonymous viewer cannot read.
|
||||
r = client.get("/api/users/me/cookie-consent")
|
||||
assert r.status_code == 401
|
||||
|
||||
# Sign back in as Alice. The server row is still there.
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
r = client.get("/api/users/me/cookie-consent")
|
||||
body = r.json()
|
||||
assert body["analytics"] is True
|
||||
assert body["other"] is True
|
||||
assert body["recorded_at"] is not None
|
||||
|
||||
|
||||
def test_two_users_have_independent_rows(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
provision_user_row(user_id=3, login="bob", role="contributor")
|
||||
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
client.put(
|
||||
"/api/users/me/cookie-consent",
|
||||
json={"essential": True, "analytics": True, "other": False},
|
||||
)
|
||||
|
||||
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob", role="contributor")
|
||||
client.put(
|
||||
"/api/users/me/cookie-consent",
|
||||
json={"essential": True, "analytics": False, "other": False},
|
||||
)
|
||||
|
||||
# Each user reads their own row.
|
||||
r = client.get("/api/users/me/cookie-consent").json()
|
||||
assert r["analytics"] is False # Bob's
|
||||
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
r = client.get("/api/users/me/cookie-consent").json()
|
||||
assert r["analytics"] is True # Alice's
|
||||
@@ -0,0 +1,494 @@
|
||||
"""End-to-end integration tests for the v0.11.0 trust-device vertical
|
||||
(§6.2, roadmap item #9).
|
||||
|
||||
After a successful OTC or passcode sign-in with `trust_device=true`
|
||||
on the body, the server mints a fresh `device_trust` row and sets the
|
||||
`rfc_device_trust` cookie. On a subsequent visit, the cookie carries
|
||||
a session re-established by `POST /auth/device-trust/start`. The
|
||||
tests below prove:
|
||||
|
||||
* `trust_device=false` (default, including omitted) on OTC verify
|
||||
does NOT set the device-trust cookie and does NOT insert a row.
|
||||
* `trust_device=true` on OTC verify DOES set the cookie (HttpOnly +
|
||||
Secure + SameSite=Lax + 30-day Max-Age) and DOES insert a row.
|
||||
The row's hash is NOT the raw token; only the hash lives in the
|
||||
database.
|
||||
* Same shape for passcode verify.
|
||||
* On a returning visit with the cookie, `POST /auth/device-trust/start`
|
||||
re-establishes the session — `GET /api/auth/me` reads the right
|
||||
user without an OTC roundtrip.
|
||||
* `last_seen_at` refreshes on a successful lookup.
|
||||
* `POST /auth/device-trust/start` with no cookie returns 401.
|
||||
* `POST /auth/device-trust/start` with a forged / unknown cookie
|
||||
returns 401 + clears the cookie.
|
||||
* A revoked row refuses the cookie (401) and clears it.
|
||||
* An expired row refuses the cookie (401) and clears it.
|
||||
* `GET /api/auth/me/devices` lists the user's active rows.
|
||||
* `DELETE /api/auth/me/devices/{id}` revokes a single row.
|
||||
* `DELETE /api/auth/me/devices/{id}` for another user's row reads 404.
|
||||
* `DELETE /api/auth/me/devices` revokes every active row.
|
||||
* Constant-time path: bcrypt.checkpw guards lookup; the raw token
|
||||
is never written to logs or to the DB.
|
||||
|
||||
The fakes from `test_propose_vertical` give us a working app harness.
|
||||
The OTC envelope buffer from `test_otc_vertical` is reused for the
|
||||
OTC roundtrips this suite needs.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
FakeGitea,
|
||||
app_with_fake_gitea,
|
||||
provision_user_row,
|
||||
tmp_env,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers — mirror the OTC suite's outbound-buffer helpers.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
COOKIE_NAME = "rfc_device_trust"
|
||||
|
||||
# The device-trust cookie is set with Secure=True, which httpx (the
|
||||
# TestClient's underlying transport) will only return on an https
|
||||
# scheme. We use a `base_url="https://testserver"` so the cookie
|
||||
# roundtrips faithfully — that mirrors how production deployments
|
||||
# serve the framework (per the v0.11.0 upgrade-step requiring HTTPS).
|
||||
HTTPS_BASE = "https://testserver"
|
||||
|
||||
|
||||
def _reset_outbound():
|
||||
from app import email as email_mod
|
||||
email_mod.reset_sent_envelopes()
|
||||
|
||||
|
||||
def _outbound_otc_codes(to_address: str | None = None) -> list[str]:
|
||||
from app import email as email_mod
|
||||
out = []
|
||||
for env in email_mod.sent_envelopes():
|
||||
if env.get("kind") != "otc":
|
||||
continue
|
||||
if to_address is not None and env["to"] != to_address:
|
||||
continue
|
||||
for line in env["body"].splitlines():
|
||||
tok = line.strip()
|
||||
if tok.isdigit() and len(tok) == 6:
|
||||
out.append(tok)
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def _sign_in_via_otc(client, email: str, *, trust_device: bool = False) -> None:
|
||||
r = client.post("/auth/otc/request", json={"email": email})
|
||||
assert r.status_code == 200, r.text
|
||||
code = _outbound_otc_codes(email)[-1]
|
||||
body = {"email": email, "code": code, "trust_device": trust_device}
|
||||
r = client.post("/auth/otc/verify", json=body)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
def _device_rows_for_email(email: str) -> list[dict]:
|
||||
from app import db
|
||||
rows = db.conn().execute(
|
||||
"""
|
||||
SELECT dt.*
|
||||
FROM device_trust dt
|
||||
JOIN users u ON u.id = dt.user_id
|
||||
WHERE u.email = ? COLLATE NOCASE
|
||||
ORDER BY dt.id
|
||||
""",
|
||||
(email,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# trust_device flag controls cookie issuance
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_otc_verify_without_trust_device_does_not_issue_cookie(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app, base_url=HTTPS_BASE) as client:
|
||||
_reset_outbound()
|
||||
_sign_in_via_otc(client, "alice@example.com", trust_device=False)
|
||||
# No cookie set on the response.
|
||||
assert COOKIE_NAME not in {c.name for c in client.cookies.jar}
|
||||
# No row inserted.
|
||||
assert _device_rows_for_email("alice@example.com") == []
|
||||
|
||||
|
||||
def test_otc_verify_with_trust_device_issues_cookie_and_row(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app, base_url=HTTPS_BASE) as client:
|
||||
_reset_outbound()
|
||||
r = client.post("/auth/otc/request", json={"email": "alice@example.com"})
|
||||
assert r.status_code == 200, r.text
|
||||
code = _outbound_otc_codes("alice@example.com")[-1]
|
||||
r = client.post(
|
||||
"/auth/otc/verify",
|
||||
json={"email": "alice@example.com", "code": code, "trust_device": True},
|
||||
headers={"User-Agent": "Mozilla/5.0 (TestBrowser)"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# Cookie present on the response.
|
||||
set_cookie = r.headers.get("set-cookie", "")
|
||||
assert COOKIE_NAME in set_cookie
|
||||
# Cookie attribute set asserts the spec'd shape. Starlette emits
|
||||
# the attribute names case-insensitively (`samesite=lax`,
|
||||
# `httponly`); we normalize when asserting.
|
||||
lower = set_cookie.lower()
|
||||
assert "httponly" in lower
|
||||
assert "secure" in lower
|
||||
assert "samesite=lax" in lower
|
||||
assert "max-age=" in lower
|
||||
|
||||
# Row inserted; hash is not the raw token.
|
||||
rows = _device_rows_for_email("alice@example.com")
|
||||
assert len(rows) == 1
|
||||
row = rows[0]
|
||||
assert row["revoked_at"] is None
|
||||
assert row["user_agent"] == "Mozilla/5.0 (TestBrowser)"
|
||||
cookie_token = client.cookies.get(COOKIE_NAME)
|
||||
assert cookie_token
|
||||
assert cookie_token != row["device_token_hash"]
|
||||
# bcrypt hash shape (starts with $2)
|
||||
assert row["device_token_hash"].startswith("$2")
|
||||
|
||||
|
||||
def test_passcode_verify_with_trust_device_issues_cookie(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app, base_url=HTTPS_BASE) as client:
|
||||
_reset_outbound()
|
||||
_sign_in_via_otc(client, "alice@example.com")
|
||||
|
||||
# Set a passcode.
|
||||
r = client.post("/auth/passcode/set", json={"passcode": "secret123"})
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# Sign out so the passcode verify path is the active sign-in.
|
||||
client.cookies.clear()
|
||||
|
||||
# Passcode verify with trust_device=true issues a row.
|
||||
r = client.post(
|
||||
"/auth/passcode/verify",
|
||||
json={"email": "alice@example.com", "passcode": "secret123", "trust_device": True},
|
||||
headers={"User-Agent": "Test/Phone"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
set_cookie = r.headers.get("set-cookie", "")
|
||||
assert COOKIE_NAME in set_cookie
|
||||
|
||||
rows = _device_rows_for_email("alice@example.com")
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["user_agent"] == "Test/Phone"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /auth/device-trust/start
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_device_trust_start_with_no_cookie_returns_401(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app, base_url=HTTPS_BASE) as client:
|
||||
r = client.post("/auth/device-trust/start")
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_device_trust_start_with_valid_cookie_establishes_session(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app, base_url=HTTPS_BASE) as client:
|
||||
_reset_outbound()
|
||||
# Trust the device.
|
||||
r = client.post("/auth/otc/request", json={"email": "alice@example.com"})
|
||||
assert r.status_code == 200, r.text
|
||||
code = _outbound_otc_codes("alice@example.com")[-1]
|
||||
r = client.post(
|
||||
"/auth/otc/verify",
|
||||
json={"email": "alice@example.com", "code": code, "trust_device": True},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
trust_cookie = client.cookies.get(COOKIE_NAME)
|
||||
assert trust_cookie
|
||||
|
||||
# Clear the session cookie so only the device-trust cookie is in
|
||||
# play. We keep `rfc_device_trust` and drop `rfc_session`.
|
||||
for cookie in list(client.cookies.jar):
|
||||
if cookie.name != COOKIE_NAME:
|
||||
client.cookies.jar.clear(cookie.domain, cookie.path, cookie.name)
|
||||
|
||||
# The session cookie is gone — /api/auth/me reads anonymous.
|
||||
me = client.get("/api/auth/me").json()
|
||||
assert me["authenticated"] is False
|
||||
|
||||
# Hit the trust-start endpoint; the cookie re-establishes the session.
|
||||
r = client.post("/auth/device-trust/start")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["user"]["email"] == "alice@example.com"
|
||||
|
||||
# /api/auth/me now reads authenticated.
|
||||
me = client.get("/api/auth/me").json()
|
||||
assert me["authenticated"] is True
|
||||
assert me["user"]["email"] == "alice@example.com"
|
||||
|
||||
|
||||
def test_device_trust_start_refreshes_last_seen_at(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app, base_url=HTTPS_BASE) as client:
|
||||
_reset_outbound()
|
||||
r = client.post("/auth/otc/request", json={"email": "alice@example.com"})
|
||||
assert r.status_code == 200, r.text
|
||||
code = _outbound_otc_codes("alice@example.com")[-1]
|
||||
r = client.post(
|
||||
"/auth/otc/verify",
|
||||
json={"email": "alice@example.com", "code": code, "trust_device": True},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# Force the existing row's last_seen_at into the past so we can
|
||||
# assert the refresh moved it forward.
|
||||
db.conn().execute(
|
||||
"""
|
||||
UPDATE device_trust
|
||||
SET last_seen_at = datetime('now', '-7 days')
|
||||
"""
|
||||
)
|
||||
|
||||
# Hit the start endpoint.
|
||||
r = client.post("/auth/device-trust/start")
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# last_seen_at is now recent (within the last minute).
|
||||
row = db.conn().execute(
|
||||
"SELECT last_seen_at, datetime('now') >= datetime(last_seen_at, '-1 minute') AS fresh FROM device_trust LIMIT 1"
|
||||
).fetchone()
|
||||
assert row["fresh"] == 1
|
||||
|
||||
|
||||
def test_device_trust_start_with_revoked_row_refuses_and_clears(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app, base_url=HTTPS_BASE) as client:
|
||||
_reset_outbound()
|
||||
r = client.post("/auth/otc/request", json={"email": "alice@example.com"})
|
||||
assert r.status_code == 200, r.text
|
||||
code = _outbound_otc_codes("alice@example.com")[-1]
|
||||
r = client.post(
|
||||
"/auth/otc/verify",
|
||||
json={"email": "alice@example.com", "code": code, "trust_device": True},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# Revoke the row out-of-band.
|
||||
db.conn().execute(
|
||||
"UPDATE device_trust SET revoked_at = datetime('now')"
|
||||
)
|
||||
|
||||
# Now the start endpoint refuses + clears the cookie.
|
||||
r = client.post("/auth/device-trust/start")
|
||||
assert r.status_code == 401
|
||||
# The cookie is cleared via a Set-Cookie header with Max-Age=0
|
||||
# (Starlette's `delete_cookie` shape).
|
||||
set_cookie = r.headers.get("set-cookie", "")
|
||||
assert COOKIE_NAME in set_cookie
|
||||
assert "Max-Age=0" in set_cookie or 'expires=Thu, 01 Jan 1970' in set_cookie.lower().replace("expires=thu", "expires=Thu")
|
||||
|
||||
|
||||
def test_device_trust_start_with_expired_row_refuses_and_clears(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app, base_url=HTTPS_BASE) as client:
|
||||
_reset_outbound()
|
||||
r = client.post("/auth/otc/request", json={"email": "alice@example.com"})
|
||||
assert r.status_code == 200, r.text
|
||||
code = _outbound_otc_codes("alice@example.com")[-1]
|
||||
r = client.post(
|
||||
"/auth/otc/verify",
|
||||
json={"email": "alice@example.com", "code": code, "trust_device": True},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# Backdate the expiry into the past.
|
||||
db.conn().execute(
|
||||
"UPDATE device_trust SET expires_at = datetime('now', '-1 day')"
|
||||
)
|
||||
|
||||
r = client.post("/auth/device-trust/start")
|
||||
assert r.status_code == 401
|
||||
set_cookie = r.headers.get("set-cookie", "")
|
||||
assert COOKIE_NAME in set_cookie
|
||||
|
||||
|
||||
def test_device_trust_start_with_forged_cookie_refuses_and_clears(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app, base_url=HTTPS_BASE) as client:
|
||||
# No real row; just paste a cookie value.
|
||||
client.cookies.set(COOKIE_NAME, "definitely-not-a-real-token-value-xxx")
|
||||
r = client.post("/auth/device-trust/start")
|
||||
assert r.status_code == 401
|
||||
set_cookie = r.headers.get("set-cookie", "")
|
||||
assert COOKIE_NAME in set_cookie
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /api/auth/me/devices — list + revoke
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_list_devices_requires_session(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app, base_url=HTTPS_BASE) as client:
|
||||
r = client.get("/api/auth/me/devices")
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_list_devices_returns_active_rows_only(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app, base_url=HTTPS_BASE) as client:
|
||||
_reset_outbound()
|
||||
_sign_in_via_otc(client, "alice@example.com", trust_device=True)
|
||||
|
||||
# Add a second trusted device by re-running the verify flow.
|
||||
# OTC has a per-email cooldown, so drop the cooldown rather
|
||||
# than waiting it out.
|
||||
db.conn().execute("UPDATE otc_codes SET consumed_at = datetime('now', '-1 hour'), created_at = datetime('now', '-1 hour')")
|
||||
r = client.post("/auth/otc/request", json={"email": "alice@example.com"})
|
||||
assert r.status_code == 200, r.text
|
||||
code = _outbound_otc_codes("alice@example.com")[-1]
|
||||
r = client.post(
|
||||
"/auth/otc/verify",
|
||||
json={"email": "alice@example.com", "code": code, "trust_device": True},
|
||||
headers={"User-Agent": "Test/Tablet"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# Revoke one row directly.
|
||||
db.conn().execute(
|
||||
"UPDATE device_trust SET revoked_at = datetime('now') WHERE id = 1"
|
||||
)
|
||||
|
||||
# /api/auth/me/devices returns only the un-revoked one.
|
||||
r = client.get("/api/auth/me/devices")
|
||||
assert r.status_code == 200, r.text
|
||||
items = r.json()["items"]
|
||||
assert len(items) == 1
|
||||
assert items[0]["user_agent"] == "Test/Tablet"
|
||||
|
||||
|
||||
def test_revoke_single_device_kills_the_row(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app, base_url=HTTPS_BASE) as client:
|
||||
_reset_outbound()
|
||||
_sign_in_via_otc(client, "alice@example.com", trust_device=True)
|
||||
|
||||
r = client.get("/api/auth/me/devices")
|
||||
assert r.status_code == 200, r.text
|
||||
items = r.json()["items"]
|
||||
assert len(items) == 1
|
||||
device_id = items[0]["id"]
|
||||
|
||||
# Revoke it.
|
||||
r = client.delete(f"/api/auth/me/devices/{device_id}")
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# List is empty.
|
||||
r = client.get("/api/auth/me/devices")
|
||||
assert r.json()["items"] == []
|
||||
|
||||
# The row in the table has revoked_at populated.
|
||||
row = db.conn().execute(
|
||||
"SELECT revoked_at FROM device_trust WHERE id = ?", (device_id,)
|
||||
).fetchone()
|
||||
assert row["revoked_at"] is not None
|
||||
|
||||
|
||||
def test_revoke_other_users_device_reads_404(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app, base_url=HTTPS_BASE) as client:
|
||||
_reset_outbound()
|
||||
# Alice trusts a device.
|
||||
_sign_in_via_otc(client, "alice@example.com", trust_device=True)
|
||||
alice_device_id = client.get("/api/auth/me/devices").json()["items"][0]["id"]
|
||||
|
||||
# Bob signs in (without a trusted device of his own).
|
||||
client.cookies.clear()
|
||||
db.conn().execute("UPDATE otc_codes SET consumed_at = datetime('now', '-1 hour'), created_at = datetime('now', '-1 hour')")
|
||||
_sign_in_via_otc(client, "bob@example.com", trust_device=False)
|
||||
|
||||
# Bob tries to revoke Alice's row by id.
|
||||
r = client.delete(f"/api/auth/me/devices/{alice_device_id}")
|
||||
assert r.status_code == 404
|
||||
|
||||
# Alice's row is still active.
|
||||
row = db.conn().execute(
|
||||
"SELECT revoked_at FROM device_trust WHERE id = ?", (alice_device_id,)
|
||||
).fetchone()
|
||||
assert row["revoked_at"] is None
|
||||
|
||||
|
||||
def test_revoke_all_devices_kills_every_active_row(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app, base_url=HTTPS_BASE) as client:
|
||||
_reset_outbound()
|
||||
_sign_in_via_otc(client, "alice@example.com", trust_device=True)
|
||||
|
||||
# Add a second device.
|
||||
db.conn().execute("UPDATE otc_codes SET consumed_at = datetime('now', '-1 hour'), created_at = datetime('now', '-1 hour')")
|
||||
r = client.post("/auth/otc/request", json={"email": "alice@example.com"})
|
||||
assert r.status_code == 200, r.text
|
||||
code = _outbound_otc_codes("alice@example.com")[-1]
|
||||
r = client.post(
|
||||
"/auth/otc/verify",
|
||||
json={"email": "alice@example.com", "code": code, "trust_device": True},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# Two active rows.
|
||||
assert len(client.get("/api/auth/me/devices").json()["items"]) == 2
|
||||
|
||||
# Revoke all.
|
||||
r = client.delete("/api/auth/me/devices")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["revoked"] == 2
|
||||
|
||||
# List is empty.
|
||||
assert client.get("/api/auth/me/devices").json()["items"] == []
|
||||
@@ -0,0 +1,237 @@
|
||||
"""End-to-end integration tests for the v0.5.0 PR-less discussion
|
||||
surface — roadmap item #3, "discussion without PR; contribution requires
|
||||
PR."
|
||||
|
||||
The vertical: an active RFC exists; the discussion endpoints under
|
||||
`/api/rfcs/<slug>/discussion/...` open threads with
|
||||
`threads.branch_name IS NULL`, post messages into them, and surface
|
||||
them on subsequent reads. Branch-scoped threads (the §8.12 surface)
|
||||
remain segregated. Anonymous viewers can read; only signed-in
|
||||
contributors can write.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
# Reuse the harness from Slice 1 / Slice 2.
|
||||
from test_propose_vertical import ( # noqa: F401 — fixtures land via import
|
||||
FakeGitea,
|
||||
app_with_fake_gitea,
|
||||
provision_user_row,
|
||||
sign_in_as,
|
||||
tmp_env,
|
||||
)
|
||||
from test_rfc_view_vertical import seed_active_rfc, SEED_BODY
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_create_and_post_to_pr_less_discussion_thread(app_with_fake_gitea):
|
||||
"""The vertical: signed-in contributor opens a thread on the RFC's
|
||||
discussion surface, posts a message, and the thread + message
|
||||
surface on subsequent reads with branch_name IS NULL."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="alice", role="contributor")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
sign_in_as(client, user_id=1, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
|
||||
# Listing materializes the default whole-doc thread.
|
||||
r = client.get("/api/rfcs/ohm/discussion/threads")
|
||||
assert r.status_code == 200, r.text
|
||||
items = r.json()["items"]
|
||||
assert len(items) == 1
|
||||
default_thread_id = items[0]["id"]
|
||||
assert items[0]["anchor_kind"] == "whole-doc"
|
||||
assert items[0]["thread_kind"] == "chat"
|
||||
|
||||
# Open an additional discussion thread with a first message.
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/discussion/threads",
|
||||
json={"label": "Question about §3", "message": "Is consent baked into the trait model?"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
payload = r.json()
|
||||
thread_id = payload["thread_id"]
|
||||
message_id = payload["message_id"]
|
||||
assert thread_id is not None and message_id is not None
|
||||
|
||||
# Confirm the row carries branch_name IS NULL (the PR-less shape).
|
||||
row = db.conn().execute(
|
||||
"SELECT rfc_slug, branch_name, thread_kind, anchor_kind, created_by FROM threads WHERE id = ?",
|
||||
(thread_id,),
|
||||
).fetchone()
|
||||
assert row["rfc_slug"] == "ohm"
|
||||
assert row["branch_name"] is None
|
||||
assert row["thread_kind"] == "chat"
|
||||
assert row["anchor_kind"] == "whole-doc"
|
||||
assert row["created_by"] == 1
|
||||
|
||||
# The thread surfaces on the list endpoint alongside the default.
|
||||
r = client.get("/api/rfcs/ohm/discussion/threads")
|
||||
ids = [t["id"] for t in r.json()["items"]]
|
||||
assert default_thread_id in ids
|
||||
assert thread_id in ids
|
||||
|
||||
# Posting a reply on the new thread persists and returns the id.
|
||||
r = client.post(
|
||||
f"/api/rfcs/ohm/discussion/threads/{thread_id}/messages",
|
||||
json={"text": "Following up — see §3.2."},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
reply_id = r.json()["message_id"]
|
||||
|
||||
# The messages read endpoint returns both messages in order.
|
||||
r = client.get(f"/api/rfcs/ohm/discussion/threads/{thread_id}/messages")
|
||||
assert r.status_code == 200
|
||||
messages = r.json()["messages"]
|
||||
assert [m["id"] for m in messages] == [message_id, reply_id]
|
||||
assert messages[0]["author_login"] == "alice"
|
||||
assert messages[0]["text"].startswith("Is consent")
|
||||
|
||||
|
||||
def test_anonymous_can_read_but_cannot_post_discussion(app_with_fake_gitea):
|
||||
"""Per the v0.3.0 anonymous-read contract: reads on the discussion
|
||||
surface are open; write attempts return 401. v0.6.0 (item #4) will
|
||||
tighten the read gate — v0.5.0 holds the write line so there is no
|
||||
open window between releases."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
|
||||
# Seed the discussion thread + first message as Alice.
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/discussion/threads",
|
||||
json={"message": "First."},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
thread_id = r.json()["thread_id"]
|
||||
|
||||
# Drop the session — viewer is anonymous now.
|
||||
client.cookies.clear()
|
||||
|
||||
# Reads are open.
|
||||
r = client.get("/api/rfcs/ohm/discussion/threads")
|
||||
assert r.status_code == 200
|
||||
assert any(t["id"] == thread_id for t in r.json()["items"])
|
||||
|
||||
r = client.get(f"/api/rfcs/ohm/discussion/threads/{thread_id}/messages")
|
||||
assert r.status_code == 200
|
||||
assert len(r.json()["messages"]) >= 1
|
||||
|
||||
# Writes refuse 401.
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/discussion/threads",
|
||||
json={"message": "Drive-by."},
|
||||
)
|
||||
assert r.status_code == 401
|
||||
|
||||
r = client.post(
|
||||
f"/api/rfcs/ohm/discussion/threads/{thread_id}/messages",
|
||||
json={"text": "Drive-by reply."},
|
||||
)
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_discussion_threads_and_branch_threads_are_segregated(app_with_fake_gitea):
|
||||
"""A branch-scoped thread (the §8.12 surface, branch_name='main' or a
|
||||
feature branch) MUST NOT surface on the discussion endpoint, which
|
||||
is keyed on branch_name IS NULL. The two surfaces share a table; the
|
||||
null-filter is what segregates them."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=3, login="alice", role="contributor")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
sign_in_as(client, user_id=3, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
|
||||
# Manually materialize a branch-scoped thread on a feature branch.
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO threads
|
||||
(rfc_slug, branch_name, anchor_kind, thread_kind, label, created_by)
|
||||
VALUES ('ohm', 'alice-draft-aa00', 'whole-doc', 'chat', NULL, 3)
|
||||
"""
|
||||
)
|
||||
# And one on the discussion surface.
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/discussion/threads",
|
||||
json={"message": "Discussion-surface message."},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
discussion_thread_id = r.json()["thread_id"]
|
||||
|
||||
# The discussion list contains the null-branch thread (plus the
|
||||
# default whole-doc) and excludes the feature-branch thread.
|
||||
r = client.get("/api/rfcs/ohm/discussion/threads")
|
||||
assert r.status_code == 200
|
||||
ids = [t["id"] for t in r.json()["items"]]
|
||||
assert discussion_thread_id in ids
|
||||
# Feature-branch thread MUST NOT surface.
|
||||
branch_thread_row = db.conn().execute(
|
||||
"SELECT id FROM threads WHERE branch_name = 'alice-draft-aa00'"
|
||||
).fetchone()
|
||||
assert branch_thread_row is not None
|
||||
assert branch_thread_row["id"] not in ids
|
||||
|
||||
|
||||
def test_discussion_thread_resolve_permissions(app_with_fake_gitea):
|
||||
"""A thread's creator can resolve it; an unrelated contributor cannot;
|
||||
an admin / owner / RFC-owner can. Mirrors §8.12's resolution rule for
|
||||
branch-scoped threads."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=4, login="alice", role="contributor")
|
||||
provision_user_row(user_id=5, login="bob", role="contributor")
|
||||
provision_user_row(user_id=6, login="ben", role="owner")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
|
||||
sign_in_as(client, user_id=4, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/discussion/threads",
|
||||
json={"label": "Alice's thread", "message": "..."},
|
||||
)
|
||||
thread_id = r.json()["thread_id"]
|
||||
|
||||
# Unrelated contributor refused.
|
||||
sign_in_as(client, user_id=5, gitea_login="bob", display_name="Bob", role="contributor")
|
||||
r = client.post(f"/api/rfcs/ohm/discussion/threads/{thread_id}/resolve")
|
||||
assert r.status_code == 403
|
||||
|
||||
# Creator allowed.
|
||||
sign_in_as(client, user_id=4, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
r = client.post(f"/api/rfcs/ohm/discussion/threads/{thread_id}/resolve")
|
||||
assert r.status_code == 200
|
||||
|
||||
# Open another thread, resolve it as the owner.
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/discussion/threads",
|
||||
json={"label": "Another thread", "message": "..."},
|
||||
)
|
||||
thread_id2 = r.json()["thread_id"]
|
||||
sign_in_as(client, user_id=6, gitea_login="ben", display_name="Ben", role="owner")
|
||||
r = client.post(f"/api/rfcs/ohm/discussion/threads/{thread_id2}/resolve")
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_discussion_404_on_unknown_rfc(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/api/rfcs/nonexistent/discussion/threads")
|
||||
assert r.status_code == 404
|
||||
@@ -0,0 +1,379 @@
|
||||
"""v0.19.0 / roadmap item #30 — `/api/docs/sessions/*` endpoints.
|
||||
|
||||
The framework mediates reads against the public
|
||||
`wiggleverse/ohm-session-history` gitea repo so the rendered
|
||||
`/docs/sessions/*` surface inherits the same chrome as `/docs/user-guide`.
|
||||
This test suite covers the four endpoints + the in-process TTL cache,
|
||||
mocking the upstream HTTP via `httpx.MockTransport` (the same shape the
|
||||
rest of the test suite uses for Gitea).
|
||||
|
||||
The tests do NOT spin up the full FakeGitea — they only need to mock
|
||||
the gitea raw URL surface (and the contents API for the session-index
|
||||
endpoint). Each test owns its mock transport so we can dial in 200 /
|
||||
404 / 5xx / timeout responses per case.
|
||||
|
||||
Path-validation tests intentionally bypass the network — a malformed
|
||||
`nnnn` or `filename` MUST be rejected at the route layer before any
|
||||
upstream call is made.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import docs_sessions
|
||||
|
||||
# Reuse the proven app-construction fixtures from the proposal vertical
|
||||
# (same shape every test file in this repo uses).
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
app_with_fake_gitea,
|
||||
tmp_env,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test scaffolding
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _UpstreamHandler:
|
||||
"""Records every URL the docs_sessions module fetched and returns
|
||||
canned responses keyed by URL substring. Allows the test to assert
|
||||
on call count (for cache verification) without needing a full Gitea
|
||||
simulator.
|
||||
|
||||
`calls` tracks only URLs that hit the session-history host (the
|
||||
`OHM_SESSION_HISTORY_*` bases) so reconciler/Gitea-side calls — which
|
||||
also pass through this handler because `httpx.AsyncClient` is a
|
||||
shared attribute the gitea-side fixture also monkeypatches — don't
|
||||
inflate the count we use for cache-hit assertions.
|
||||
"""
|
||||
|
||||
_SESSION_HOST_MARKERS = ("ohm-session-history", "wiggleverse/ohm-session-history")
|
||||
|
||||
def __init__(self, responses: dict[str, tuple[int, str]]):
|
||||
self.responses = responses
|
||||
self.calls: list[str] = []
|
||||
|
||||
def __call__(self, request: httpx.Request) -> httpx.Response:
|
||||
url = str(request.url)
|
||||
if any(m in url for m in self._SESSION_HOST_MARKERS):
|
||||
self.calls.append(url)
|
||||
for key, (status, body) in self.responses.items():
|
||||
if key in url:
|
||||
return httpx.Response(status, text=body)
|
||||
# Default: 404. Lets tests skip declaring "the rest is 404".
|
||||
return httpx.Response(404, text="not found")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def patched_httpx(monkeypatch):
|
||||
"""Provide a hook the test can call to install a MockTransport.
|
||||
|
||||
Returns a closure: `install(handler)` patches
|
||||
`app.docs_sessions.httpx.AsyncClient` so every constructed client
|
||||
uses the handler's transport.
|
||||
|
||||
NB: the upstream `app_with_fake_gitea` fixture also patches
|
||||
`httpx.AsyncClient` (to route gitea calls to a FakeGitea handler),
|
||||
and because `httpx` is a single shared module, that patch mutates
|
||||
the *same* `AsyncClient` attribute we're about to overwrite. We
|
||||
therefore import the unpatched class directly from the
|
||||
`httpx._client` module so our install path can construct a fresh
|
||||
real client around our MockTransport without going through the
|
||||
FakeGitea wrapper.
|
||||
"""
|
||||
from httpx._client import AsyncClient as RealAsyncClient
|
||||
|
||||
def install(handler):
|
||||
def patched(*args, **kwargs):
|
||||
kwargs["transport"] = httpx.MockTransport(handler)
|
||||
return RealAsyncClient(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr("app.docs_sessions.httpx.AsyncClient", patched)
|
||||
return handler
|
||||
|
||||
yield install
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app(app_with_fake_gitea):
|
||||
"""Wrap the shared app fixture, resetting the docs-sessions cache so
|
||||
cross-test state can't leak. Returns just the FastAPI app — the
|
||||
fake-Gitea handle is irrelevant for the docs-sessions surface.
|
||||
"""
|
||||
docs_sessions.reset_cache()
|
||||
fastapi_app, _fake = app_with_fake_gitea
|
||||
return fastapi_app
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Manifest endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_manifest_happy_path(app, patched_httpx):
|
||||
manifest_body = json.dumps(
|
||||
{
|
||||
"0001": {"title": "Bootstrap"},
|
||||
"0014": {"title": "Wave 7 driver"},
|
||||
}
|
||||
)
|
||||
patched_httpx(
|
||||
_UpstreamHandler({"sessions.json": (200, manifest_body)})
|
||||
)
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/api/docs/sessions/manifest")
|
||||
assert r.status_code == 200, r.text
|
||||
payload = r.json()
|
||||
assert payload == {
|
||||
"0001": {"title": "Bootstrap"},
|
||||
"0014": {"title": "Wave 7 driver"},
|
||||
}
|
||||
|
||||
|
||||
def test_manifest_empty_state(app, patched_httpx):
|
||||
"""A 404 from gitea means the manifest hasn't been published yet.
|
||||
The endpoint returns HTTP 200 + `{}` so the frontend can render the
|
||||
no-sessions-yet state without an error banner.
|
||||
"""
|
||||
patched_httpx(_UpstreamHandler({"sessions.json": (404, "not found")}))
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/api/docs/sessions/manifest")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json() == {}
|
||||
|
||||
|
||||
def test_manifest_upstream_5xx_returns_502(app, patched_httpx):
|
||||
patched_httpx(_UpstreamHandler({"sessions.json": (500, "internal")}))
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/api/docs/sessions/manifest")
|
||||
assert r.status_code == 502, r.text
|
||||
body = r.json()
|
||||
assert body["detail"]["error"] == "session-history fetch failed"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# About endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_about_happy_path(app, patched_httpx):
|
||||
readme = "# OHM session history\n\nWelcome.\n"
|
||||
patched_httpx(_UpstreamHandler({"README.md": (200, readme)}))
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/api/docs/sessions/about")
|
||||
assert r.status_code == 200, r.text
|
||||
assert "text/markdown" in r.headers["content-type"]
|
||||
assert r.text == readme
|
||||
|
||||
|
||||
def test_about_404(app, patched_httpx):
|
||||
patched_httpx(_UpstreamHandler({"README.md": (404, "")}))
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/api/docs/sessions/about")
|
||||
assert r.status_code == 404, r.text
|
||||
|
||||
|
||||
def test_about_upstream_5xx_returns_502(app, patched_httpx):
|
||||
patched_httpx(_UpstreamHandler({"README.md": (503, "down")}))
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/api/docs/sessions/about")
|
||||
assert r.status_code == 502, r.text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Transcript endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_transcript_happy_path(app, patched_httpx):
|
||||
body = "# Session 0017.1 — Transcript\n\nbody.\n"
|
||||
fname = "SESSION-0017.1-TRANSCRIPT-2026-05-28T08-50--2026-05-28T11-20.md"
|
||||
patched_httpx(_UpstreamHandler({fname: (200, body)}))
|
||||
with TestClient(app) as client:
|
||||
r = client.get(f"/api/docs/sessions/0017/{fname}")
|
||||
assert r.status_code == 200, r.text
|
||||
assert "text/markdown" in r.headers["content-type"]
|
||||
assert r.text == body
|
||||
|
||||
|
||||
def test_transcript_404(app, patched_httpx):
|
||||
fname = "SESSION-9999.0-TRANSCRIPT-2026-01-01T00-00--2026-01-01T00-01.md"
|
||||
patched_httpx(_UpstreamHandler({})) # everything 404s
|
||||
with TestClient(app) as client:
|
||||
r = client.get(f"/api/docs/sessions/9999/{fname}")
|
||||
assert r.status_code == 404, r.text
|
||||
|
||||
|
||||
def test_transcript_rejects_invalid_session_dir(app, patched_httpx):
|
||||
"""`nnnn` must be exactly 4 digits. `abcd` fails before any
|
||||
network call.
|
||||
"""
|
||||
handler = _UpstreamHandler({})
|
||||
patched_httpx(handler)
|
||||
with TestClient(app) as client:
|
||||
r = client.get(
|
||||
"/api/docs/sessions/abcd/"
|
||||
"SESSION-0001.0-TRANSCRIPT-2026-01-01T00-00--2026-01-01T00-01.md"
|
||||
)
|
||||
assert r.status_code == 400, r.text
|
||||
assert handler.calls == [], "rejected path must not hit the network"
|
||||
|
||||
|
||||
def test_transcript_rejects_path_traversal(app, patched_httpx):
|
||||
"""A filename that doesn't match the SESSION-NNNN.M-TRANSCRIPT regex
|
||||
is rejected. `../etc/passwd` doesn't match; neither does the legacy
|
||||
flat-root `SESSION-A-TRANSCRIPT.md`.
|
||||
"""
|
||||
handler = _UpstreamHandler({})
|
||||
patched_httpx(handler)
|
||||
with TestClient(app) as client:
|
||||
# Path traversal — but FastAPI normalizes `..` in the path before
|
||||
# routing, so this resolves to /api/docs/sessions/0001/etc/passwd
|
||||
# which routes to the same handler with filename=etc/passwd, and
|
||||
# gets rejected as an invalid transcript filename. Even if the
|
||||
# normalization didn't apply (some intermediary), the regex
|
||||
# check rejects anything not matching the SESSION- prefix.
|
||||
r = client.get(
|
||||
"/api/docs/sessions/0001/etc%2Fpasswd"
|
||||
)
|
||||
# 400 (filename validation) or 404 (path didn't match the
|
||||
# route); both reject before any network call. Either is
|
||||
# acceptable — what matters is that we never fetched it.
|
||||
assert r.status_code in (400, 404), r.text
|
||||
assert handler.calls == [], "rejected path must not hit the network"
|
||||
|
||||
|
||||
def test_transcript_rejects_legacy_flat_filename(app, patched_httpx):
|
||||
"""Legacy `SESSION-A-TRANSCRIPT.md` (letter form) doesn't match the
|
||||
numeric regex — by design, since post-#23 transcripts live in
|
||||
`NNNN/` folders with numeric names. Reject 400.
|
||||
"""
|
||||
handler = _UpstreamHandler({})
|
||||
patched_httpx(handler)
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/api/docs/sessions/0001/SESSION-A-TRANSCRIPT.md")
|
||||
assert r.status_code == 400, r.text
|
||||
assert handler.calls == [], "rejected path must not hit the network"
|
||||
|
||||
|
||||
def test_transcript_upstream_5xx_returns_502(app, patched_httpx):
|
||||
fname = "SESSION-0001.0-TRANSCRIPT-2026-01-01T00-00--2026-01-01T00-01.md"
|
||||
patched_httpx(_UpstreamHandler({fname: (502, "bad gateway")}))
|
||||
with TestClient(app) as client:
|
||||
r = client.get(f"/api/docs/sessions/0001/{fname}")
|
||||
assert r.status_code == 502, r.text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session-index endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_session_index_happy_path(app, patched_httpx):
|
||||
"""The contents API returns a JSON list of file entries. The
|
||||
endpoint filters to entries that match the transcript regex and
|
||||
sorts them.
|
||||
"""
|
||||
# Two transcripts (driver + subagent) + a non-transcript sibling
|
||||
# that must be filtered out.
|
||||
listing = json.dumps(
|
||||
[
|
||||
{
|
||||
"name": "SESSION-0017.0-TRANSCRIPT-"
|
||||
"2026-05-28T08-30--2026-05-28T12-00.md",
|
||||
"type": "file",
|
||||
},
|
||||
{
|
||||
"name": "SESSION-0017.1-TRANSCRIPT-"
|
||||
"2026-05-28T08-50--2026-05-28T11-20.md",
|
||||
"type": "file",
|
||||
},
|
||||
{"name": "notes.md", "type": "file"}, # not a transcript
|
||||
{"name": "attached-dir", "type": "dir"}, # not a file
|
||||
]
|
||||
)
|
||||
patched_httpx(_UpstreamHandler({"/contents/0017": (200, listing)}))
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/api/docs/sessions/0017/index")
|
||||
assert r.status_code == 200, r.text
|
||||
files = r.json()["files"]
|
||||
assert files == [
|
||||
"SESSION-0017.0-TRANSCRIPT-2026-05-28T08-30--2026-05-28T12-00.md",
|
||||
"SESSION-0017.1-TRANSCRIPT-2026-05-28T08-50--2026-05-28T11-20.md",
|
||||
]
|
||||
|
||||
|
||||
def test_session_index_404(app, patched_httpx):
|
||||
patched_httpx(_UpstreamHandler({})) # everything 404s
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/api/docs/sessions/9999/index")
|
||||
assert r.status_code == 404, r.text
|
||||
|
||||
|
||||
def test_session_index_rejects_invalid_session_dir(app, patched_httpx):
|
||||
handler = _UpstreamHandler({})
|
||||
patched_httpx(handler)
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/api/docs/sessions/abc/index")
|
||||
assert r.status_code == 400, r.text
|
||||
assert handler.calls == [], "rejected path must not hit the network"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cache behavior
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_manifest_cache_hits_within_ttl(app, patched_httpx, monkeypatch):
|
||||
"""Two consecutive manifest calls within the TTL window should
|
||||
issue exactly one HTTP request to gitea.
|
||||
"""
|
||||
# Generous TTL so the test never races.
|
||||
monkeypatch.setenv("OHM_DOCS_SESSIONS_MANIFEST_TTL_SEC", "60")
|
||||
handler = _UpstreamHandler(
|
||||
{"sessions.json": (200, json.dumps({"0001": {"title": "x"}}))}
|
||||
)
|
||||
patched_httpx(handler)
|
||||
with TestClient(app) as client:
|
||||
r1 = client.get("/api/docs/sessions/manifest")
|
||||
r2 = client.get("/api/docs/sessions/manifest")
|
||||
assert r1.status_code == 200
|
||||
assert r2.status_code == 200
|
||||
assert len(handler.calls) == 1, (
|
||||
f"expected one upstream call, got {handler.calls}"
|
||||
)
|
||||
|
||||
|
||||
def test_transcript_cache_hits_within_ttl(app, patched_httpx, monkeypatch):
|
||||
monkeypatch.setenv("OHM_DOCS_SESSIONS_CONTENT_TTL_SEC", "300")
|
||||
fname = "SESSION-0001.0-TRANSCRIPT-2026-01-01T00-00--2026-01-01T00-01.md"
|
||||
handler = _UpstreamHandler({fname: (200, "# body\n")})
|
||||
patched_httpx(handler)
|
||||
with TestClient(app) as client:
|
||||
r1 = client.get(f"/api/docs/sessions/0001/{fname}")
|
||||
r2 = client.get(f"/api/docs/sessions/0001/{fname}")
|
||||
assert r1.status_code == 200
|
||||
assert r2.status_code == 200
|
||||
assert len(handler.calls) == 1
|
||||
|
||||
|
||||
def test_transcript_404_is_cached(app, patched_httpx, monkeypatch):
|
||||
"""Negative caching: a 404 result is cached at the content TTL so a
|
||||
deployment with no published transcripts doesn't hammer gitea on
|
||||
every navigation. Documented in `docs_sessions.fetch_transcript`.
|
||||
"""
|
||||
monkeypatch.setenv("OHM_DOCS_SESSIONS_CONTENT_TTL_SEC", "300")
|
||||
fname = "SESSION-9999.0-TRANSCRIPT-2026-01-01T00-00--2026-01-01T00-01.md"
|
||||
handler = _UpstreamHandler({}) # everything 404s
|
||||
patched_httpx(handler)
|
||||
with TestClient(app) as client:
|
||||
r1 = client.get(f"/api/docs/sessions/9999/{fname}")
|
||||
r2 = client.get(f"/api/docs/sessions/9999/{fname}")
|
||||
assert r1.status_code == 404
|
||||
assert r2.status_code == 404
|
||||
assert len(handler.calls) == 1, "negative caching should suppress the 2nd call"
|
||||
@@ -0,0 +1,469 @@
|
||||
"""v0.20.0 — `/api/docs/specs/*` endpoints.
|
||||
|
||||
Sibling of `test_docs_sessions_vertical.py`. The framework mediates
|
||||
reads of the configured framework-spec URLs (default: rfc-app's own
|
||||
SPEC.md + flotilla's SPEC.md on `git.wiggleverse.org`) so the
|
||||
`/docs/specs/*` surface inherits the same chrome as
|
||||
`/docs/user-guide` and `/docs/sessions/*`.
|
||||
|
||||
This file covers:
|
||||
|
||||
- The manifest endpoint with the framework default
|
||||
- The manifest endpoint with an overridden `OHM_DOCS_SPECS` JSON value
|
||||
- Slug validation at the route layer (rejects `..`, `/`, `~`,
|
||||
uppercase, whitespace, path traversal attempts)
|
||||
- Gitea 200 / 404 / 5xx response mapping
|
||||
- Negative caching (404 is cached, not re-fetched within TTL)
|
||||
- Malformed `OHM_DOCS_SPECS` → fallback to the default + a logged
|
||||
warning (asserted by caplog)
|
||||
- A manifest entry that fails per-entry validation (bad slug,
|
||||
missing field) is dropped, with the rest of the list retained
|
||||
|
||||
Mocking approach: same as docs_sessions — `httpx.MockTransport`
|
||||
substituted into `app.docs_specs.httpx.AsyncClient` via a fixture.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import docs_specs
|
||||
|
||||
# Reuse the proven app-construction fixtures from the proposal vertical
|
||||
# (same shape every test file in this repo uses).
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
app_with_fake_gitea,
|
||||
tmp_env,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test scaffolding
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _UpstreamHandler:
|
||||
"""Records every URL the docs_specs module fetched and returns
|
||||
canned responses keyed by URL substring. Lets the test assert on
|
||||
call count (for cache verification) without booting a full upstream
|
||||
simulator.
|
||||
|
||||
`calls` tracks only URLs that hit a host configured in the spec
|
||||
manifest under test — so unrelated httpx clients (gitea-side
|
||||
fixtures, etc.) don't inflate the count we use for cache-hit
|
||||
assertions. We marker-match on substrings the manifest carries.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
responses: dict[str, tuple[int, str]],
|
||||
host_markers: tuple[str, ...] = ("rfc-app", "flotilla", "specs.example"),
|
||||
):
|
||||
self.responses = responses
|
||||
self.host_markers = host_markers
|
||||
self.calls: list[str] = []
|
||||
|
||||
def __call__(self, request: httpx.Request) -> httpx.Response:
|
||||
url = str(request.url)
|
||||
if any(m in url for m in self.host_markers):
|
||||
self.calls.append(url)
|
||||
for key, (status, body) in self.responses.items():
|
||||
if key in url:
|
||||
return httpx.Response(status, text=body)
|
||||
# Default: 404. Lets tests skip declaring "the rest is 404".
|
||||
return httpx.Response(404, text="not found")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def patched_httpx(monkeypatch):
|
||||
"""Provide a hook the test can call to install a MockTransport.
|
||||
|
||||
Same shape as the docs_sessions fixture — `app_with_fake_gitea`
|
||||
monkeypatches `httpx.AsyncClient` for the gitea side, so we
|
||||
construct from the unpatched class directly to avoid the
|
||||
FakeGitea wrapper.
|
||||
"""
|
||||
from httpx._client import AsyncClient as RealAsyncClient
|
||||
|
||||
def install(handler):
|
||||
def patched(*args, **kwargs):
|
||||
kwargs["transport"] = httpx.MockTransport(handler)
|
||||
return RealAsyncClient(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr("app.docs_specs.httpx.AsyncClient", patched)
|
||||
return handler
|
||||
|
||||
yield install
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app(app_with_fake_gitea):
|
||||
"""Reset the docs-specs cache so cross-test state can't leak."""
|
||||
docs_specs.reset_cache()
|
||||
fastapi_app, _fake = app_with_fake_gitea
|
||||
return fastapi_app
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Manifest endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_manifest_default(app, monkeypatch):
|
||||
"""With `OHM_DOCS_SPECS` unset, the manifest endpoint returns the
|
||||
framework default (rfc-app + flotilla).
|
||||
"""
|
||||
monkeypatch.delenv("OHM_DOCS_SPECS", raising=False)
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/api/docs/specs/manifest")
|
||||
assert r.status_code == 200, r.text
|
||||
payload = r.json()
|
||||
assert "specs" in payload
|
||||
names = [s["name"] for s in payload["specs"]]
|
||||
assert names == ["rfc-app", "flotilla"]
|
||||
# The default URLs point at the OHM-canonical gitea raw paths.
|
||||
assert all("git.wiggleverse.org" in s["url"] for s in payload["specs"])
|
||||
|
||||
|
||||
def test_manifest_overridden(app, monkeypatch):
|
||||
"""A deployment overriding `OHM_DOCS_SPECS` gets its custom list.
|
||||
|
||||
The manifest is parsed per-request from the env var (no startup
|
||||
binding) so a runtime overlay change is visible without a
|
||||
restart — same shape as the docs_sessions env knobs.
|
||||
"""
|
||||
custom = json.dumps(
|
||||
[
|
||||
{
|
||||
"name": "custom-spec",
|
||||
"title": "Custom Spec",
|
||||
"url": "https://specs.example.org/CUSTOM.md",
|
||||
}
|
||||
]
|
||||
)
|
||||
monkeypatch.setenv("OHM_DOCS_SPECS", custom)
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/api/docs/specs/manifest")
|
||||
assert r.status_code == 200, r.text
|
||||
payload = r.json()
|
||||
assert payload == {
|
||||
"specs": [
|
||||
{
|
||||
"name": "custom-spec",
|
||||
"title": "Custom Spec",
|
||||
"url": "https://specs.example.org/CUSTOM.md",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def test_manifest_malformed_json_falls_back(app, monkeypatch, caplog):
|
||||
"""A non-JSON value in `OHM_DOCS_SPECS` logs a warning and the
|
||||
endpoint falls back to the framework default. Startup is
|
||||
unaffected — the deployment continues to render the spec surface
|
||||
rather than crashing on the typo.
|
||||
"""
|
||||
monkeypatch.setenv("OHM_DOCS_SPECS", "{not-json")
|
||||
with caplog.at_level(logging.WARNING, logger="app.docs_specs"):
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/api/docs/specs/manifest")
|
||||
assert r.status_code == 200, r.text
|
||||
payload = r.json()
|
||||
names = [s["name"] for s in payload["specs"]]
|
||||
assert names == ["rfc-app", "flotilla"]
|
||||
assert any(
|
||||
"OHM_DOCS_SPECS is not valid JSON" in rec.message
|
||||
for rec in caplog.records
|
||||
), f"expected a logged warning; got {[r.message for r in caplog.records]}"
|
||||
|
||||
|
||||
def test_manifest_non_list_falls_back(app, monkeypatch, caplog):
|
||||
"""`OHM_DOCS_SPECS` must be a JSON array. A JSON object (or any
|
||||
non-list value) falls back to the default + logs a warning.
|
||||
"""
|
||||
monkeypatch.setenv("OHM_DOCS_SPECS", json.dumps({"name": "not-a-list"}))
|
||||
with caplog.at_level(logging.WARNING, logger="app.docs_specs"):
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/api/docs/specs/manifest")
|
||||
assert r.status_code == 200, r.text
|
||||
names = [s["name"] for s in r.json()["specs"]]
|
||||
assert names == ["rfc-app", "flotilla"]
|
||||
assert any(
|
||||
"must be a JSON array" in rec.message for rec in caplog.records
|
||||
)
|
||||
|
||||
|
||||
def test_manifest_drops_invalid_entry_keeps_valid(app, monkeypatch, caplog):
|
||||
"""Per-entry validation: an entry with a bad slug or missing field
|
||||
is dropped; valid entries in the same list are retained.
|
||||
"""
|
||||
custom = json.dumps(
|
||||
[
|
||||
{"name": "Bad Slug", "title": "Bad", "url": "https://x"}, # uppercase + space
|
||||
{"name": "..", "title": "Traversal", "url": "https://x"}, # path traversal
|
||||
{"name": "missing-url", "title": "Missing URL"}, # no url
|
||||
{
|
||||
"name": "good-spec",
|
||||
"title": "Good",
|
||||
"url": "https://specs.example.org/GOOD.md",
|
||||
},
|
||||
]
|
||||
)
|
||||
monkeypatch.setenv("OHM_DOCS_SPECS", custom)
|
||||
with caplog.at_level(logging.WARNING, logger="app.docs_specs"):
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/api/docs/specs/manifest")
|
||||
assert r.status_code == 200, r.text
|
||||
names = [s["name"] for s in r.json()["specs"]]
|
||||
assert names == ["good-spec"]
|
||||
# Three drop warnings (one per bad entry).
|
||||
drops = [r for r in caplog.records if "failed validation" in r.message]
|
||||
assert len(drops) == 3
|
||||
|
||||
|
||||
def test_manifest_all_invalid_falls_back(app, monkeypatch, caplog):
|
||||
"""If every entry is dropped, the framework default applies (the
|
||||
surface never goes empty due to a bad overlay).
|
||||
"""
|
||||
monkeypatch.setenv(
|
||||
"OHM_DOCS_SPECS",
|
||||
json.dumps([{"name": "BAD"}, {"name": "..", "title": "x", "url": "y"}]),
|
||||
)
|
||||
with caplog.at_level(logging.WARNING, logger="app.docs_specs"):
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/api/docs/specs/manifest")
|
||||
assert r.status_code == 200, r.text
|
||||
names = [s["name"] for s in r.json()["specs"]]
|
||||
assert names == ["rfc-app", "flotilla"]
|
||||
assert any(
|
||||
"yielded no valid entries" in rec.message for rec in caplog.records
|
||||
)
|
||||
|
||||
|
||||
def test_manifest_drops_duplicate_names(app, monkeypatch, caplog):
|
||||
"""A duplicate `name` is dropped (the first occurrence wins). The
|
||||
route layer's `/api/docs/specs/{name}` path lookup is by name, so
|
||||
duplicates would otherwise be ambiguous.
|
||||
"""
|
||||
custom = json.dumps(
|
||||
[
|
||||
{"name": "x", "title": "First", "url": "https://specs.example/1"},
|
||||
{"name": "x", "title": "Second", "url": "https://specs.example/2"},
|
||||
]
|
||||
)
|
||||
monkeypatch.setenv("OHM_DOCS_SPECS", custom)
|
||||
with caplog.at_level(logging.WARNING, logger="app.docs_specs"):
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/api/docs/specs/manifest")
|
||||
payload = r.json()
|
||||
assert [s["title"] for s in payload["specs"]] == ["First"]
|
||||
assert any("duplicate name" in rec.message for rec in caplog.records)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Spec endpoint — happy + error paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_spec_happy_path(app, patched_httpx, monkeypatch):
|
||||
monkeypatch.setenv(
|
||||
"OHM_DOCS_SPECS",
|
||||
json.dumps(
|
||||
[
|
||||
{
|
||||
"name": "rfc-app",
|
||||
"title": "rfc-app SPEC",
|
||||
"url": "https://specs.example.org/rfc-app/SPEC.md",
|
||||
}
|
||||
]
|
||||
),
|
||||
)
|
||||
body = "# rfc-app SPEC\n\nSection 1...\n"
|
||||
patched_httpx(_UpstreamHandler({"rfc-app/SPEC.md": (200, body)}))
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/api/docs/specs/rfc-app")
|
||||
assert r.status_code == 200, r.text
|
||||
assert "text/markdown" in r.headers["content-type"]
|
||||
assert r.text == body
|
||||
|
||||
|
||||
def test_spec_upstream_404(app, patched_httpx, monkeypatch):
|
||||
monkeypatch.setenv(
|
||||
"OHM_DOCS_SPECS",
|
||||
json.dumps(
|
||||
[
|
||||
{
|
||||
"name": "missing-spec",
|
||||
"title": "Missing",
|
||||
"url": "https://specs.example.org/missing.md",
|
||||
}
|
||||
]
|
||||
),
|
||||
)
|
||||
patched_httpx(_UpstreamHandler({})) # everything 404s
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/api/docs/specs/missing-spec")
|
||||
assert r.status_code == 404, r.text
|
||||
|
||||
|
||||
def test_spec_upstream_5xx_returns_502(app, patched_httpx, monkeypatch):
|
||||
monkeypatch.setenv(
|
||||
"OHM_DOCS_SPECS",
|
||||
json.dumps(
|
||||
[
|
||||
{
|
||||
"name": "broken-spec",
|
||||
"title": "Broken",
|
||||
"url": "https://specs.example.org/broken.md",
|
||||
}
|
||||
]
|
||||
),
|
||||
)
|
||||
patched_httpx(_UpstreamHandler({"broken.md": (500, "internal")}))
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/api/docs/specs/broken-spec")
|
||||
assert r.status_code == 502, r.text
|
||||
body = r.json()
|
||||
assert body["detail"]["error"] == "specs fetch failed"
|
||||
|
||||
|
||||
def test_spec_unknown_name_returns_404(app, patched_httpx, monkeypatch):
|
||||
"""A name that doesn't appear in the manifest returns 404 without
|
||||
touching the network. The handler treats "no such configured spec"
|
||||
and "upstream 404" as the same outcome — both render the same
|
||||
"spec not found" empty state on the frontend.
|
||||
"""
|
||||
monkeypatch.setenv(
|
||||
"OHM_DOCS_SPECS",
|
||||
json.dumps(
|
||||
[
|
||||
{
|
||||
"name": "rfc-app",
|
||||
"title": "rfc-app",
|
||||
"url": "https://specs.example.org/x.md",
|
||||
}
|
||||
]
|
||||
),
|
||||
)
|
||||
handler = _UpstreamHandler({})
|
||||
patched_httpx(handler)
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/api/docs/specs/does-not-exist")
|
||||
assert r.status_code == 404, r.text
|
||||
assert handler.calls == [], "unknown-name lookup must not hit the network"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Spec endpoint — slug validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw_name",
|
||||
[
|
||||
"UPPER", # uppercase
|
||||
"spaces here", # whitespace (post-decoding)
|
||||
"with~tilde", # tilde
|
||||
"with.dot", # dot
|
||||
"with_under", # underscore (not allowed by [a-z0-9-]+)
|
||||
],
|
||||
)
|
||||
def test_spec_rejects_invalid_name(app, patched_httpx, raw_name):
|
||||
"""Names that don't match `^[a-z0-9-]+$` are rejected with 400 at
|
||||
the route layer before any network or cache work.
|
||||
|
||||
Note: `..` is intentionally not in this list because the URL-
|
||||
parsing layer collapses `/api/docs/specs/..` to `/api/docs/specs`
|
||||
before the handler is reached — the path-traversal protection is
|
||||
therefore framework-level (httpx/urllib's path normalizer) rather
|
||||
than route-layer. The slug-validation guard still rejects any
|
||||
`..` that *would* reach the handler (e.g. via an env-configured
|
||||
manifest entry); see `test_manifest_drops_invalid_entry_keeps_valid`
|
||||
for that path.
|
||||
"""
|
||||
handler = _UpstreamHandler({})
|
||||
patched_httpx(handler)
|
||||
with TestClient(app) as client:
|
||||
from urllib.parse import quote
|
||||
|
||||
r = client.get(f"/api/docs/specs/{quote(raw_name, safe='')}")
|
||||
assert r.status_code == 400, r.text
|
||||
assert handler.calls == [], "rejected name must not hit the network"
|
||||
|
||||
|
||||
def test_spec_rejects_slash_in_name(app, patched_httpx):
|
||||
"""A literal `/` in the path can't make it through the path
|
||||
parameter — FastAPI routes it as a separate segment. The check
|
||||
here is that the request never reaches an upstream fetch.
|
||||
"""
|
||||
handler = _UpstreamHandler({})
|
||||
patched_httpx(handler)
|
||||
with TestClient(app) as client:
|
||||
# `/api/docs/specs/sub/path` — the second segment makes this
|
||||
# not match the `/{name}` route at all; FastAPI returns 404.
|
||||
r = client.get("/api/docs/specs/sub/path")
|
||||
assert r.status_code == 404, r.text
|
||||
assert handler.calls == [], "non-matching path must not hit the network"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cache behavior
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_spec_cache_hits_within_ttl(app, patched_httpx, monkeypatch):
|
||||
monkeypatch.setenv("OHM_DOCS_SPECS_CONTENT_TTL_SEC", "300")
|
||||
monkeypatch.setenv(
|
||||
"OHM_DOCS_SPECS",
|
||||
json.dumps(
|
||||
[
|
||||
{
|
||||
"name": "rfc-app",
|
||||
"title": "rfc-app",
|
||||
"url": "https://specs.example.org/rfc-app/SPEC.md",
|
||||
}
|
||||
]
|
||||
),
|
||||
)
|
||||
handler = _UpstreamHandler({"rfc-app/SPEC.md": (200, "# body\n")})
|
||||
patched_httpx(handler)
|
||||
with TestClient(app) as client:
|
||||
r1 = client.get("/api/docs/specs/rfc-app")
|
||||
r2 = client.get("/api/docs/specs/rfc-app")
|
||||
assert r1.status_code == 200
|
||||
assert r2.status_code == 200
|
||||
assert len(handler.calls) == 1, (
|
||||
f"expected one upstream call, got {handler.calls}"
|
||||
)
|
||||
|
||||
|
||||
def test_spec_404_is_cached(app, patched_httpx, monkeypatch):
|
||||
"""Negative caching: a 404 result is cached at the content TTL so
|
||||
a deployment with a misconfigured spec URL doesn't hammer the
|
||||
upstream on every navigation.
|
||||
"""
|
||||
monkeypatch.setenv("OHM_DOCS_SPECS_CONTENT_TTL_SEC", "300")
|
||||
monkeypatch.setenv(
|
||||
"OHM_DOCS_SPECS",
|
||||
json.dumps(
|
||||
[
|
||||
{
|
||||
"name": "missing-spec",
|
||||
"title": "Missing",
|
||||
"url": "https://specs.example.org/missing.md",
|
||||
}
|
||||
]
|
||||
),
|
||||
)
|
||||
handler = _UpstreamHandler({}) # everything 404s
|
||||
patched_httpx(handler)
|
||||
with TestClient(app) as client:
|
||||
r1 = client.get("/api/docs/specs/missing-spec")
|
||||
r2 = client.get("/api/docs/specs/missing-spec")
|
||||
assert r1.status_code == 404
|
||||
assert r2.status_code == 404
|
||||
assert len(handler.calls) == 1, "negative caching should suppress the 2nd call"
|
||||
@@ -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,
|
||||
@@ -118,19 +119,25 @@ def test_full_user_lifecycle_propose_through_hygiene(app_with_fake_gitea):
|
||||
r = client.post(f"/api/rfcs/ohm/prs/{body_pr}/merge")
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# --- 7. Graduate the super-draft. ---
|
||||
# --- 7. Graduate the super-draft (in-place flip, §13). ---
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/graduate?_sync=1",
|
||||
json={"rfc_id": "RFC-0001", "repo_name": "rfc-0001-ohm",
|
||||
"owners": ["ben"]},
|
||||
json={"rfc_id": "RFC-0001", "owners": ["ben"]},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["succeeded"] is True
|
||||
d = client.get("/api/rfcs/ohm").json()
|
||||
assert d["state"] == "active"
|
||||
assert d["repo"] == "wiggleverse/rfc-0001-ohm"
|
||||
# Meta-only topology (§1): no per-RFC repo — the active RFC lives
|
||||
# in its meta entry, `repo` stays null.
|
||||
assert d["repo"] is None
|
||||
|
||||
# --- 8. Alice opens a PR on the now-active RFC's per-RFC repo. ---
|
||||
# --- 8. Alice opens a PR on the now-active RFC (meta 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={})
|
||||
@@ -194,8 +201,8 @@ def test_full_user_lifecycle_propose_through_hygiene(app_with_fake_gitea):
|
||||
)
|
||||
assert counters["deleted_post_merge"] >= 1, counters
|
||||
|
||||
# The branch is gone from FakeGitea + cached row flipped.
|
||||
assert active_branch not in fake.branches[("wiggleverse", "rfc-0001-ohm")]
|
||||
# The branch is gone from FakeGitea (meta repo) + cached row flipped.
|
||||
assert active_branch not in fake.branches[("wiggleverse", "meta")]
|
||||
cached = db.conn().execute(
|
||||
"SELECT state FROM cached_branches WHERE rfc_slug = 'ohm' AND branch_name = ?",
|
||||
(active_branch,),
|
||||
@@ -225,13 +232,17 @@ def test_bounce_webhook_refuses_unsigned_when_secret_configured(app_with_fake_gi
|
||||
|
||||
# With the right header, the call passes the guard. (No matching
|
||||
# user exists, so we get {matched: False} — that's the v1 contract.)
|
||||
# v0.18.0 Slice 5: the response now includes `correlated_id`
|
||||
# (the outbound_emails row id that matched the bounce's
|
||||
# `message_id`, if one was supplied). The body didn't pass a
|
||||
# message_id, so correlated_id is None.
|
||||
r = client.post(
|
||||
"/api/webhooks/email-bounce",
|
||||
json={"email": "stranger@example.com", "kind": "hard"},
|
||||
headers={"X-Webhook-Secret": "shhh"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json() == {"ok": True, "matched": False}
|
||||
assert r.json() == {"ok": True, "matched": False, "correlated_id": None}
|
||||
|
||||
|
||||
def test_bounce_webhook_open_when_secret_unset(app_with_fake_gitea):
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Unit tests for `app.email_envelope.build_envelope` (v0.18.0 Slice 1).
|
||||
|
||||
These tests don't spin up the FastAPI app or touch the DB — they
|
||||
exercise the helper directly. The integration tests in
|
||||
test_otc_vertical / test_admin_create_user_invite_vertical /
|
||||
test_notifications_vertical exercise the helper's *use* via the
|
||||
shared `_SENT` buffer (the send path appends the envelope dict
|
||||
before invoking the helper).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from email.utils import parsedate_to_datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from app.email_envelope import build_envelope
|
||||
|
||||
|
||||
def _base_kwargs(**overrides):
|
||||
base = dict(
|
||||
to_address="recipient@example.com",
|
||||
from_address="notifications@ohm.wiggleverse.org",
|
||||
from_name="OHM",
|
||||
subject="A test subject",
|
||||
body_plain="Hello, world.\n",
|
||||
)
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Always-present headers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_envelope_sets_from_to_subject():
|
||||
msg = build_envelope(**_base_kwargs())
|
||||
assert msg["To"] == "recipient@example.com"
|
||||
assert msg["Subject"] == "A test subject"
|
||||
# `From` is the display-form: "OHM <notifications@ohm.wiggleverse.org>".
|
||||
assert "OHM" in msg["From"]
|
||||
assert "<notifications@ohm.wiggleverse.org>" in msg["From"]
|
||||
|
||||
|
||||
def test_envelope_sets_date_header_parseable():
|
||||
msg = build_envelope(**_base_kwargs())
|
||||
raw = msg["Date"]
|
||||
assert raw, "Date header must be set"
|
||||
# parsedate_to_datetime raises ValueError on malformed input.
|
||||
dt = parsedate_to_datetime(raw)
|
||||
assert dt is not None
|
||||
|
||||
|
||||
def test_envelope_sets_message_id_with_from_domain_by_default():
|
||||
msg = build_envelope(**_base_kwargs())
|
||||
mid = msg["Message-ID"]
|
||||
assert mid, "Message-ID must be set"
|
||||
# Shape per RFC 5322 / make_msgid: <random@domain>
|
||||
assert mid.startswith("<") and mid.endswith(">")
|
||||
assert "@ohm.wiggleverse.org>" in mid
|
||||
|
||||
|
||||
def test_envelope_message_id_domain_override():
|
||||
msg = build_envelope(**_base_kwargs(msgid_domain="example.test"))
|
||||
assert "@example.test>" in msg["Message-ID"]
|
||||
|
||||
|
||||
def test_envelope_message_id_falls_back_to_localhost_if_from_has_no_at():
|
||||
# Defensive: a malformed from_address shouldn't crash the helper.
|
||||
msg = build_envelope(**_base_kwargs(from_address="bare-no-at-sign"))
|
||||
assert "@localhost>" in msg["Message-ID"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auto-Submitted (RFC 3834)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_envelope_sets_auto_submitted_for_transactional_default():
|
||||
msg = build_envelope(**_base_kwargs())
|
||||
assert msg["Auto-Submitted"] == "auto-generated"
|
||||
|
||||
|
||||
def test_envelope_omits_auto_submitted_when_transactional_is_false():
|
||||
msg = build_envelope(**_base_kwargs(is_transactional=False))
|
||||
assert msg["Auto-Submitted"] is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reply-To
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_envelope_sets_reply_to_when_provided():
|
||||
msg = build_envelope(**_base_kwargs(reply_to="ohm@wiggleverse.org"))
|
||||
assert msg["Reply-To"] == "ohm@wiggleverse.org"
|
||||
|
||||
|
||||
def test_envelope_omits_reply_to_when_absent():
|
||||
msg = build_envelope(**_base_kwargs())
|
||||
assert msg["Reply-To"] is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# List-Unsubscribe (the headers RFC 8058 / Gmail-Yahoo care about)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_envelope_no_list_unsubscribe_when_neither_given():
|
||||
"""OTC mail: the recipient explicitly requested the code; no
|
||||
unsubscribe semantics. The header MUST be absent (presence would
|
||||
imply OHM has the recipient on a list, which it doesn't)."""
|
||||
msg = build_envelope(**_base_kwargs())
|
||||
assert msg["List-Unsubscribe"] is None
|
||||
assert msg["List-Unsubscribe-Post"] is None
|
||||
|
||||
|
||||
def test_envelope_mailto_only_list_unsubscribe():
|
||||
"""Admin invite / per-RFC invite: `mailto:` form only, no URL.
|
||||
The recipient isn't a user yet, so there's no per-user opt-out
|
||||
URL to flip; the operator handles ad-hoc opt-outs manually."""
|
||||
msg = build_envelope(**_base_kwargs(
|
||||
unsubscribe_mailto="ohm@wiggleverse.org?subject=remove",
|
||||
))
|
||||
assert msg["List-Unsubscribe"] == "<mailto:ohm@wiggleverse.org?subject=remove>"
|
||||
# NO List-Unsubscribe-Post when only a mailto is present — the
|
||||
# one-click semantic requires a URL the MUA can POST to.
|
||||
assert msg["List-Unsubscribe-Post"] is None
|
||||
|
||||
|
||||
def test_envelope_full_one_click_list_unsubscribe():
|
||||
"""Watcher notification / bundle: `mailto:` + signed-URL +
|
||||
`List-Unsubscribe-Post: List-Unsubscribe=One-Click`. Gmail and
|
||||
Yahoo enforce this for bulk-adjacent mail per RFC 8058."""
|
||||
msg = build_envelope(**_base_kwargs(
|
||||
unsubscribe_mailto="ohm@wiggleverse.org?subject=remove",
|
||||
unsubscribe_url="https://ohm.wiggleverse.org/api/email/unsubscribe?t=abc",
|
||||
))
|
||||
lu = msg["List-Unsubscribe"]
|
||||
assert "<mailto:ohm@wiggleverse.org?subject=remove>" in lu
|
||||
assert "<https://ohm.wiggleverse.org/api/email/unsubscribe?t=abc>" in lu
|
||||
assert msg["List-Unsubscribe-Post"] == "List-Unsubscribe=One-Click"
|
||||
|
||||
|
||||
def test_envelope_url_only_list_unsubscribe_still_sets_post():
|
||||
msg = build_envelope(**_base_kwargs(
|
||||
unsubscribe_url="https://ohm.wiggleverse.org/api/email/unsubscribe?t=abc",
|
||||
))
|
||||
assert msg["List-Unsubscribe"] == "<https://ohm.wiggleverse.org/api/email/unsubscribe?t=abc>"
|
||||
assert msg["List-Unsubscribe-Post"] == "List-Unsubscribe=One-Click"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Body shape — plain-only vs multipart/alternative
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_envelope_plain_only_body_is_text_plain():
|
||||
msg = build_envelope(**_base_kwargs())
|
||||
# No HTML alternative -> single-part text/plain.
|
||||
assert msg.get_content_type() == "text/plain"
|
||||
assert msg.get_content().strip() == "Hello, world."
|
||||
|
||||
|
||||
def test_envelope_html_body_is_guarded_not_enabled():
|
||||
# I3 (security-audit-0026): the HTML/multipart-alternative path is
|
||||
# intentionally not enabled — passing body_html must fail loudly so
|
||||
# a future caller can't silently ship unescaped user HTML (the C1
|
||||
# stored-XSS class in the mail channel). When HTML mail is enabled
|
||||
# deliberately, this test flips to assert the multipart shape.
|
||||
with pytest.raises(NotImplementedError):
|
||||
build_envelope(**_base_kwargs(body_html="<p>Hello, <b>world</b>.</p>"))
|
||||
|
||||
|
||||
def test_envelope_html_none_is_plain_only():
|
||||
# The guard keys on `is not None`, so the default (None) stays the
|
||||
# live plain-text path — exercised here to lock the boundary.
|
||||
msg = build_envelope(**_base_kwargs(body_html=None))
|
||||
assert msg.get_content_type() == "text/plain"
|
||||
@@ -1,29 +1,29 @@
|
||||
"""End-to-end integration tests for the Slice 5 vertical (§13 in full).
|
||||
"""End-to-end integration tests for the §13 graduation flow under the
|
||||
meta-only topology (SPEC §1, ROADMAP #36).
|
||||
|
||||
Walks the §13.3 transactional sequence end-to-end against the in-process
|
||||
FakeGitea from test_propose_vertical.py:
|
||||
Graduation is an in-place state flip on the meta entry — no per-RFC repo
|
||||
is created, the body is kept, and there is no multi-step transaction or
|
||||
rollback (§13.3). These tests walk it against the in-process FakeGitea
|
||||
from test_propose_vertical.py:
|
||||
|
||||
* Seed an owned super-draft (skipping the propose+merge + §13.1 claim
|
||||
round-trips already proven by Slice 1 and exercised in
|
||||
test_claim_opens_meta_pr below for the §13.1 surface itself).
|
||||
* Seed an owned super-draft (the §13.1 claim flow is exercised
|
||||
separately in test_claim_opens_meta_pr).
|
||||
* GET /api/rfcs/<slug>/graduate/check returns per-field validity for
|
||||
the dialog.
|
||||
* GET /api/rfcs/<slug>/blocking-prs returns the §9.8 precondition list.
|
||||
* POST /api/rfcs/<slug>/graduate?_sync=1 runs the five-step sequence
|
||||
inline. On success: per-RFC repo exists with RFC.md / README.md /
|
||||
.rfc/metadata.yaml, meta-entry body is stripped, frontmatter is
|
||||
graduated, cached_rfcs.state is 'active'.
|
||||
* §9.8 precondition gate refuses the start when a body-edit PR is open.
|
||||
* Rollback on a mid-sequence failure unwinds repo creation cleanly.
|
||||
* §13.4 chat migration: whole-doc threads under (slug, 'main') survive
|
||||
graduation unchanged — the rfc_slug is the canonical key per §2.3,
|
||||
so no data movement is needed.
|
||||
* §9.8 pre-graduation history: the new RFC's /main response surfaces
|
||||
edit-branch threads under `pre_graduation_history`.
|
||||
the two-field dialog (integer id + owners; no repo name).
|
||||
* POST /api/rfcs/<slug>/graduate?_sync=1 opens + merges the flip PR
|
||||
inline. On success: NO per-RFC repo, the meta entry is `state:
|
||||
active` with the body KEPT and `repo` null, cached_rfcs.state flips
|
||||
to 'active'.
|
||||
* An open body-edit PR no longer blocks graduation (§9.8) — they
|
||||
coexist.
|
||||
* An open-PR failure leaves the entry a super-draft (nothing created);
|
||||
a merge failure cleans up the half-open PR/branch and leaves the
|
||||
entry a super-draft.
|
||||
* §13.4: chat threads + edit branches stay put — the slug is the
|
||||
canonical key per §2.3, so nothing moves at the flip.
|
||||
|
||||
The orchestrator's `?_sync=1` seam awaits the sequence inline so the
|
||||
test can assert post-conditions on the same event loop tick. Production
|
||||
clients use the spec-described SSE shape via `/graduate/progress`.
|
||||
The orchestrator's `?_sync=1` seam awaits the flip inline so the test can
|
||||
assert post-conditions on the same event loop tick.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -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,
|
||||
@@ -109,7 +110,9 @@ def seed_owned_super_draft(fake: FakeGitea, *, slug: str, title: str, pitch: str
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_graduate_check_validates_three_fields(app_with_fake_gitea):
|
||||
def test_graduate_check_validates_id_and_owners(app_with_fake_gitea):
|
||||
"""Two-field dialog under meta-only: integer id + owners. No repo
|
||||
name to validate (§13.2)."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
@@ -120,57 +123,46 @@ def test_graduate_check_validates_three_fields(app_with_fake_gitea):
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner")
|
||||
|
||||
# Happy: a fresh RFC-0001 + rfc-0001-ohm repo name.
|
||||
r = client.get("/api/rfcs/ohm/graduate/check",
|
||||
params={"id": "RFC-0001", "repo": "rfc-0001-ohm"})
|
||||
# Happy: a fresh RFC-0001.
|
||||
r = client.get("/api/rfcs/ohm/graduate/check", params={"id": "RFC-0001"})
|
||||
assert r.status_code == 200, r.text
|
||||
d = r.json()
|
||||
assert d["id"]["ok"] is True
|
||||
assert d["repo"]["ok"] is True
|
||||
assert d["owners"]["ok"] is True
|
||||
assert d["blocking_prs"]["ok"] is True
|
||||
assert d["can_submit"] is True
|
||||
# No repo field in the meta-only check response.
|
||||
assert "repo" not in d
|
||||
|
||||
# ID format error — non-numeric tail.
|
||||
r = client.get("/api/rfcs/ohm/graduate/check",
|
||||
params={"id": "RFC-abcd", "repo": "rfc-0001-ohm"})
|
||||
r = client.get("/api/rfcs/ohm/graduate/check", params={"id": "RFC-abcd"})
|
||||
d = r.json()
|
||||
assert d["id"]["ok"] is False
|
||||
assert d["can_submit"] is False
|
||||
|
||||
# Repo name pattern error — leading dot.
|
||||
r = client.get("/api/rfcs/ohm/graduate/check",
|
||||
params={"id": "RFC-0001", "repo": ".bad"})
|
||||
d = r.json()
|
||||
assert d["repo"]["ok"] is False
|
||||
|
||||
|
||||
def test_graduate_check_refuses_when_no_owners(app_with_fake_gitea):
|
||||
"""An unclaimed super-draft fails the owners precondition; can_submit
|
||||
flips false even with valid id+repo."""
|
||||
flips false even with a valid id."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
# No owners — simulates an unclaimed super-draft.
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH, owners=[])
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner")
|
||||
r = client.get("/api/rfcs/ohm/graduate/check",
|
||||
params={"id": "RFC-0001", "repo": "rfc-0001-ohm"})
|
||||
r = client.get("/api/rfcs/ohm/graduate/check", params={"id": "RFC-0001"})
|
||||
d = r.json()
|
||||
assert d["owners"]["ok"] is False
|
||||
assert "No owners" in d["owners"]["error"]
|
||||
assert d["can_submit"] is False
|
||||
|
||||
|
||||
def test_graduate_happy_path_runs_five_steps_and_flips_state(app_with_fake_gitea):
|
||||
"""The full §13.3 sequence: create repo, seed files, open PR, merge
|
||||
PR, refresh cache. End state: cached_rfcs.state='active', the meta
|
||||
entry's body is stripped, the per-RFC repo has RFC.md, the audit
|
||||
log carries graduate_start → graduate_complete bracketing the
|
||||
per-step rows."""
|
||||
def test_graduate_happy_path_flips_in_place_keeping_body(app_with_fake_gitea):
|
||||
"""The meta-only flip: open + merge a frontmatter PR. End state:
|
||||
cached_rfcs.state='active', the meta entry's body is KEPT, `repo` is
|
||||
null, NO per-RFC repo exists, and the audit log carries graduate_start
|
||||
→ graduate_pr_open → graduate_pr_merge → graduate_complete."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db, entry as entry_mod
|
||||
|
||||
@@ -185,60 +177,57 @@ def test_graduate_happy_path_runs_five_steps_and_flips_state(app_with_fake_gitea
|
||||
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/graduate?_sync=1",
|
||||
json={"rfc_id": "RFC-0042", "repo_name": "rfc-0042-ohm",
|
||||
"owners": ["ben"]},
|
||||
json={"rfc_id": "RFC-0042", "owners": ["ben"]},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
d = r.json()
|
||||
assert d["finished"] is True
|
||||
assert d["succeeded"] is True
|
||||
assert d["repo"] == "wiggleverse/rfc-0042-ohm"
|
||||
# No repo in the response, no per-RFC repo on Gitea.
|
||||
assert "repo" not in d
|
||||
assert ("wiggleverse", "rfc-0042-ohm") not in fake.repos
|
||||
assert not any(
|
||||
k[1].startswith("rfc-0042") for k in fake.repos
|
||||
), f"a per-RFC repo was created: {fake.repos}"
|
||||
|
||||
# 1. Per-RFC repo exists on Gitea.
|
||||
assert ("wiggleverse", "rfc-0042-ohm") in fake.repos
|
||||
# 2. Seed files landed on main.
|
||||
assert ("wiggleverse", "rfc-0042-ohm", "main", "RFC.md") in fake.files
|
||||
assert ("wiggleverse", "rfc-0042-ohm", "main", "README.md") in fake.files
|
||||
assert ("wiggleverse", "rfc-0042-ohm", "main", ".rfc/metadata.yaml") in fake.files
|
||||
rfc_md = fake.files[("wiggleverse", "rfc-0042-ohm", "main", "RFC.md")]["content"]
|
||||
assert "Open Human Model is a framework" in rfc_md
|
||||
# 3. Meta entry body is stripped + frontmatter graduated.
|
||||
# Meta entry on main: state flipped, body KEPT, repo null.
|
||||
meta_text = fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
|
||||
graduated = entry_mod.parse(meta_text)
|
||||
assert graduated.state == "active"
|
||||
assert graduated.id == "RFC-0042"
|
||||
assert graduated.repo == "wiggleverse/rfc-0042-ohm"
|
||||
assert graduated.repo is None
|
||||
assert graduated.graduated_by == "ben"
|
||||
assert graduated.graduated_at # non-empty ISO date
|
||||
assert graduated.body.strip() == ""
|
||||
# 5. cached_rfcs.state flipped to active via the inline refresh.
|
||||
assert "Open Human Model is a framework" in graduated.body
|
||||
|
||||
# cached_rfcs flipped to active via the inline refresh; body intact.
|
||||
cached = db.conn().execute(
|
||||
"SELECT state, rfc_id, repo, body FROM cached_rfcs WHERE slug = 'ohm'"
|
||||
).fetchone()
|
||||
assert cached["state"] == "active"
|
||||
assert cached["rfc_id"] == "RFC-0042"
|
||||
assert cached["repo"] == "wiggleverse/rfc-0042-ohm"
|
||||
# cached body now mirrors RFC.md from the per-RFC repo.
|
||||
assert cached["repo"] is None
|
||||
assert "Open Human Model is a framework" in cached["body"]
|
||||
|
||||
# Audit log: graduate_start, graduate_repo_create, graduate_repo_seed,
|
||||
# graduate_pr_open, graduate_pr_merge, graduate_complete, in order.
|
||||
kinds = [
|
||||
r["action_kind"]
|
||||
for r in db.conn().execute(
|
||||
row["action_kind"]
|
||||
for row in db.conn().execute(
|
||||
"SELECT action_kind FROM actions WHERE rfc_slug = 'ohm' ORDER BY id"
|
||||
)
|
||||
]
|
||||
for needed in ("graduate_start", "graduate_repo_create",
|
||||
"graduate_repo_seed", "graduate_pr_open",
|
||||
for needed in ("graduate_start", "graduate_pr_open",
|
||||
"graduate_pr_merge", "graduate_complete"):
|
||||
assert needed in kinds, f"missing audit row {needed}: {kinds}"
|
||||
# The retired per-repo steps must NOT appear.
|
||||
for gone in ("graduate_repo_create", "graduate_repo_seed",
|
||||
"graduate_repo_delete", "graduate_rollback"):
|
||||
assert gone not in kinds, f"retired audit row present: {gone}"
|
||||
|
||||
|
||||
def test_graduate_refuses_when_body_edit_pr_open(app_with_fake_gitea):
|
||||
"""§9.8: an open meta-repo body-edit PR against rfcs/<slug>.md blocks
|
||||
graduation before the bot starts the sequence — §13.3's rollback
|
||||
complexity does not grow."""
|
||||
def test_graduate_coexists_with_open_body_edit_pr(app_with_fake_gitea):
|
||||
"""§9.8 (meta-only): an open meta-repo body-edit PR no longer blocks
|
||||
graduation — the body is kept, so they coexist. /check stays
|
||||
submittable and the flip succeeds."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
@@ -248,10 +237,11 @@ 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"])
|
||||
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")
|
||||
|
||||
# Cut an edit branch and open a body-edit PR (full Slice 4 path).
|
||||
# Cut an edit branch and open a body-edit PR.
|
||||
branch = client.post("/api/rfcs/ohm/start-edit-branch", json={}).json()["branch_name"]
|
||||
view = client.get(f"/api/rfcs/ohm/branches/{branch}").json()
|
||||
thread_id = view["main_thread_id"]
|
||||
@@ -275,94 +265,31 @@ def test_graduate_refuses_when_body_edit_pr_open(app_with_fake_gitea):
|
||||
f"/api/rfcs/ohm/branches/{branch}/open-pr",
|
||||
json={"title": "Add harm", "description": "Adds harm dimension."},
|
||||
).json()["pr_number"]
|
||||
assert pr_number # PR is open
|
||||
|
||||
# /blocking-prs surfaces it.
|
||||
# /check stays submittable despite the open body-edit PR.
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner")
|
||||
r = client.get("/api/rfcs/ohm/blocking-prs")
|
||||
items = r.json()["items"]
|
||||
assert len(items) == 1
|
||||
assert items[0]["pr_number"] == pr_number
|
||||
d = client.get("/api/rfcs/ohm/graduate/check", params={"id": "RFC-0001"}).json()
|
||||
assert "blocking_prs" not in d
|
||||
assert d["can_submit"] is True
|
||||
|
||||
# /check refuses can_submit.
|
||||
r = client.get("/api/rfcs/ohm/graduate/check",
|
||||
params={"id": "RFC-0001", "repo": "rfc-0001-ohm"})
|
||||
d = r.json()
|
||||
assert d["blocking_prs"]["ok"] is False
|
||||
assert d["can_submit"] is False
|
||||
|
||||
# POST refuses with 409 — the bot never starts the sequence.
|
||||
# The flip succeeds — coexists with the open body-edit PR.
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/graduate?_sync=1",
|
||||
json={"rfc_id": "RFC-0001", "repo_name": "rfc-0001-ohm",
|
||||
"owners": ["ben"]},
|
||||
json={"rfc_id": "RFC-0001", "owners": ["ben"]},
|
||||
)
|
||||
assert r.status_code == 409
|
||||
assert "blocking graduation" in r.text or "block" in r.text
|
||||
|
||||
|
||||
def test_graduate_rollback_on_step_2_seed_failure(app_with_fake_gitea):
|
||||
"""Step 2 (seed files) fails partway → the orchestrator rolls back
|
||||
step 1 (delete the repo) and records the rollback in the audit log.
|
||||
The cached_rfcs row stays at 'super-draft'."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
from app.bot import Bot
|
||||
from app.gitea import Gitea, GiteaError
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
||||
pitch=PITCH, owners=["ben"])
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner")
|
||||
|
||||
# Monkey-patch the bot to fail on seed_graduated_rfc. The repo
|
||||
# has already been created in step 1; the rollback must delete it.
|
||||
orig_seed = Bot.seed_graduated_rfc
|
||||
async def boom(self, *args, **kwargs):
|
||||
raise GiteaError(500, "simulated seed failure for rollback test")
|
||||
Bot.seed_graduated_rfc = boom
|
||||
try:
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/graduate?_sync=1",
|
||||
json={"rfc_id": "RFC-0003", "repo_name": "rfc-0003-ohm",
|
||||
"owners": ["ben"]},
|
||||
)
|
||||
finally:
|
||||
Bot.seed_graduated_rfc = orig_seed
|
||||
assert r.status_code == 200, r.text
|
||||
d = r.json()
|
||||
assert d["finished"] is True
|
||||
assert d["succeeded"] is False
|
||||
|
||||
# Repo deleted as the rollback inverse.
|
||||
assert ("wiggleverse", "rfc-0003-ohm") not in fake.repos
|
||||
# Meta entry unchanged.
|
||||
assert r.json()["succeeded"] is True
|
||||
cached = db.conn().execute(
|
||||
"SELECT state, rfc_id FROM cached_rfcs WHERE slug = 'ohm'"
|
||||
"SELECT state FROM cached_rfcs WHERE slug = 'ohm'"
|
||||
).fetchone()
|
||||
assert cached["state"] == "super-draft"
|
||||
assert cached["rfc_id"] is None
|
||||
# Audit log carries the rollback row.
|
||||
kinds = [
|
||||
r["action_kind"]
|
||||
for r in db.conn().execute(
|
||||
"SELECT action_kind FROM actions WHERE rfc_slug = 'ohm' ORDER BY id"
|
||||
)
|
||||
]
|
||||
assert "graduate_start" in kinds
|
||||
assert "graduate_repo_create" in kinds
|
||||
assert "graduate_repo_delete" in kinds
|
||||
assert "graduate_rollback" in kinds
|
||||
assert "graduate_complete" not in kinds
|
||||
assert cached["state"] == "active"
|
||||
|
||||
|
||||
def test_graduate_rollback_on_step_3_pr_open_failure(app_with_fake_gitea):
|
||||
"""Step 3 (open PR) fails → the orchestrator rolls back steps 2 and
|
||||
1 (deleting the repo, which reclaims the seed commits at the same
|
||||
time). The meta-repo entry is untouched."""
|
||||
def test_graduate_open_pr_failure_leaves_super_draft(app_with_fake_gitea):
|
||||
"""An open-PR failure creates nothing — the entry stays a super-draft
|
||||
with its body intact and no graduation PR on the meta repo."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
from app.bot import Bot
|
||||
@@ -383,19 +310,92 @@ def test_graduate_rollback_on_step_3_pr_open_failure(app_with_fake_gitea):
|
||||
try:
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/graduate?_sync=1",
|
||||
json={"rfc_id": "RFC-0007", "repo_name": "rfc-0007-ohm",
|
||||
"owners": ["ben"]},
|
||||
json={"rfc_id": "RFC-0007", "owners": ["ben"]},
|
||||
)
|
||||
finally:
|
||||
Bot.open_graduation_pr = orig_open_pr
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["succeeded"] is False
|
||||
# Repo torn down.
|
||||
assert ("wiggleverse", "rfc-0007-ohm") not in fake.repos
|
||||
# Meta entry's body still has the pitch (not stripped).
|
||||
|
||||
# Entry untouched: still super-draft, body intact on main.
|
||||
cached = db.conn().execute(
|
||||
"SELECT state, rfc_id FROM cached_rfcs WHERE slug = 'ohm'"
|
||||
).fetchone()
|
||||
assert cached["state"] == "super-draft"
|
||||
assert cached["rfc_id"] is None
|
||||
meta_text = fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
|
||||
assert "Open Human Model is a framework" in meta_text
|
||||
|
||||
kinds = [
|
||||
row["action_kind"]
|
||||
for row in db.conn().execute(
|
||||
"SELECT action_kind FROM actions WHERE rfc_slug = 'ohm' ORDER BY id"
|
||||
)
|
||||
]
|
||||
assert "graduate_start" in kinds
|
||||
assert "graduate_failed" in kinds
|
||||
assert "graduate_complete" not in kinds
|
||||
|
||||
|
||||
def test_graduate_merge_failure_cleans_up_pr(app_with_fake_gitea):
|
||||
"""A merge failure leaves the flip PR open on its dash-suffixed
|
||||
branch; the orchestrator closes the PR and deletes the branch so
|
||||
failed attempts don't accumulate. The entry stays a super-draft —
|
||||
the flip PR's commit was on a branch, not on main."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
from app.bot import Bot
|
||||
from app.gitea import GiteaError
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
||||
pitch=PITCH, owners=["ben"])
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner")
|
||||
|
||||
orig_merge = Bot.merge_graduation_pr
|
||||
async def boom(self, *args, **kwargs):
|
||||
raise GiteaError(502, "simulated merge failure")
|
||||
Bot.merge_graduation_pr = boom
|
||||
try:
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/graduate?_sync=1",
|
||||
json={"rfc_id": "RFC-0009", "owners": ["ben"]},
|
||||
)
|
||||
finally:
|
||||
Bot.merge_graduation_pr = orig_merge
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["succeeded"] is False
|
||||
|
||||
# Entry stays super-draft on main (the flip never merged).
|
||||
cached = db.conn().execute(
|
||||
"SELECT state FROM cached_rfcs WHERE slug = 'ohm'"
|
||||
).fetchone()
|
||||
assert cached["state"] == "super-draft"
|
||||
meta_text = fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
|
||||
assert "state: super-draft" in meta_text
|
||||
|
||||
# The dash-suffixed graduation branch was cleaned up.
|
||||
grad_branches = [
|
||||
name for (o, repo), branches in fake.branches.items()
|
||||
if (o, repo) == ("wiggleverse", "meta")
|
||||
for name in branches
|
||||
if name.startswith("graduate-ohm-")
|
||||
]
|
||||
assert grad_branches == [], f"leftover graduation branch: {grad_branches}"
|
||||
|
||||
kinds = [
|
||||
row["action_kind"]
|
||||
for row in db.conn().execute(
|
||||
"SELECT action_kind FROM actions WHERE rfc_slug = 'ohm' ORDER BY id"
|
||||
)
|
||||
]
|
||||
assert "graduate_pr_open" in kinds
|
||||
assert "graduate_failed" in kinds
|
||||
assert "graduate_complete" not in kinds
|
||||
|
||||
|
||||
def test_graduate_refuses_concurrent_graduation(app_with_fake_gitea):
|
||||
"""A second graduation request for a slug already in-flight is refused."""
|
||||
@@ -410,17 +410,14 @@ def test_graduate_refuses_concurrent_graduation(app_with_fake_gitea):
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner")
|
||||
|
||||
# Seed a synthetic in-flight state so the registry refuses the second.
|
||||
st = api_graduation._new_active(
|
||||
"ohm", rfc_id="RFC-0001", repo_name="rfc-0001-ohm",
|
||||
repo_full="wiggleverse/rfc-0001-ohm", owners=["ben"], arbiters=["ben"],
|
||||
"ohm", rfc_id="RFC-0001", owners=["ben"], arbiters=["ben"],
|
||||
)
|
||||
st.finished = False
|
||||
try:
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/graduate?_sync=1",
|
||||
json={"rfc_id": "RFC-0001", "repo_name": "rfc-0001-ohm",
|
||||
"owners": ["ben"]},
|
||||
json={"rfc_id": "RFC-0001", "owners": ["ben"]},
|
||||
)
|
||||
assert r.status_code == 409
|
||||
finally:
|
||||
@@ -428,11 +425,9 @@ def test_graduate_refuses_concurrent_graduation(app_with_fake_gitea):
|
||||
|
||||
|
||||
def test_chat_threads_survive_graduation_without_data_movement(app_with_fake_gitea):
|
||||
"""§13.4: chat threads on the super-draft's canonical-body view
|
||||
(`branch_name='main'`) are interpreted as the new RFC's main-thread
|
||||
after graduation. The rows don't move — the rfc_slug is canonical
|
||||
per §2.3 — so the same thread surfaces from both before and after
|
||||
the graduation."""
|
||||
"""§13.4: chat threads on the entry's main view (`branch_name='main'`)
|
||||
stay put across the flip — the rfc_slug is canonical per §2.3 — so the
|
||||
same thread surfaces from /branches/main before and after graduation."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
@@ -444,9 +439,6 @@ def test_chat_threads_survive_graduation_without_data_movement(app_with_fake_git
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner")
|
||||
|
||||
# Materialize a whole-doc main thread + a message on it. This
|
||||
# mirrors what reading the canonical-body view would create
|
||||
# lazily (§8.12 / api_branches._ensure_branch_chat_thread).
|
||||
cur = db.conn().execute(
|
||||
"""
|
||||
INSERT INTO threads (rfc_slug, branch_name, anchor_kind, thread_kind, created_by)
|
||||
@@ -462,32 +454,26 @@ def test_chat_threads_survive_graduation_without_data_movement(app_with_fake_git
|
||||
(thread_id,),
|
||||
)
|
||||
|
||||
# Graduate.
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/graduate?_sync=1",
|
||||
json={"rfc_id": "RFC-0099", "repo_name": "rfc-0099-ohm",
|
||||
"owners": ["ben"]},
|
||||
json={"rfc_id": "RFC-0099", "owners": ["ben"]},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# The thread row's identity is unchanged.
|
||||
row = db.conn().execute(
|
||||
"SELECT id, branch_name FROM threads WHERE id = ?", (thread_id,),
|
||||
).fetchone()
|
||||
assert row["branch_name"] == "main"
|
||||
# The new RFC's main view surfaces the same thread id as its
|
||||
# whole-doc main thread (the entry is now active, the branch
|
||||
# 'main' now points at the per-RFC repo's main, but the
|
||||
# `(rfc_slug, branch_name)` key remains the canonical anchor).
|
||||
r = client.get("/api/rfcs/ohm/branches/main")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["main_thread_id"] == thread_id
|
||||
|
||||
|
||||
def test_pre_graduation_history_surfaces_edit_branch_threads(app_with_fake_gitea):
|
||||
"""§9.8: after graduation, threads on meta-repo edit branches stay
|
||||
attached to their original branch_name and surface from the new
|
||||
RFC's /main response under `pre_graduation_history`."""
|
||||
def test_edit_branch_surfaces_normally_after_graduation(app_with_fake_gitea):
|
||||
"""§13.4 (meta-only): after graduation an edit branch is a *current*
|
||||
branch of the now-active RFC — it surfaces in the normal `branches`
|
||||
list, and there is no separate `pre_graduation_history` set (that
|
||||
affordance is legacy per-repo only)."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
@@ -497,8 +483,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"])
|
||||
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",
|
||||
display_name="Alice", role="contributor")
|
||||
branch = client.post("/api/rfcs/ohm/start-edit-branch", json={}).json()["branch_name"]
|
||||
@@ -507,30 +493,25 @@ def test_pre_graduation_history_surfaces_edit_branch_threads(app_with_fake_gitea
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO thread_messages (thread_id, role, author_user_id, text)
|
||||
VALUES (?, 'user', 2, 'pre-graduation note on an edit branch')
|
||||
VALUES (?, 'user', 2, 'note on an edit branch')
|
||||
""",
|
||||
(thread_id,),
|
||||
)
|
||||
|
||||
# Ben graduates.
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner")
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/graduate?_sync=1",
|
||||
json={"rfc_id": "RFC-0100", "repo_name": "rfc-0100-ohm",
|
||||
"owners": ["ben"]},
|
||||
json={"rfc_id": "RFC-0100", "owners": ["ben"]},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# /main on the now-active RFC surfaces the pre-graduation history.
|
||||
r = client.get("/api/rfcs/ohm/main")
|
||||
d = r.json()
|
||||
d = client.get("/api/rfcs/ohm/main").json()
|
||||
assert d["state"] == "active"
|
||||
hist = d["pre_graduation_history"]
|
||||
assert len(hist) >= 1
|
||||
assert any(h["branch_name"] == branch for h in hist)
|
||||
target = next(h for h in hist if h["branch_name"] == branch)
|
||||
assert target["message_count"] >= 1
|
||||
# The edit branch is a current branch; no pre-graduation hop.
|
||||
assert d["pre_graduation_history"] == []
|
||||
assert any(b["name"] == branch for b in d["branches"]), \
|
||||
f"edit branch not in branches: {[b['name'] for b in d['branches']]}"
|
||||
|
||||
|
||||
def test_claim_opens_meta_pr(app_with_fake_gitea):
|
||||
@@ -554,12 +535,10 @@ def test_claim_opens_meta_pr(app_with_fake_gitea):
|
||||
d = r.json()
|
||||
assert d["branch_name"] == "claim/ohm"
|
||||
|
||||
# The PR body's diff carries Alice in owners.
|
||||
text = fake.files[("wiggleverse", "meta", "claim/ohm", "rfcs/ohm.md")]["content"]
|
||||
ent = entry_mod.parse(text)
|
||||
assert "alice" in ent.owners
|
||||
|
||||
# cached_prs records pr_kind='meta_claim' via refresh_meta_pulls.
|
||||
row = db.conn().execute(
|
||||
"SELECT pr_kind FROM cached_prs WHERE pr_number = ?", (d["pr_number"],),
|
||||
).fetchone()
|
||||
|
||||
@@ -339,10 +339,10 @@ def test_hygiene_action_kinds_fire_no_notifications(app_with_fake_gitea):
|
||||
|
||||
|
||||
def test_graduation_rollback_deletes_dash_suffixed_branch(app_with_fake_gitea):
|
||||
"""§19.2 candidate Slice 8 settles: when graduation rolls back
|
||||
after step 3 (open_pr), the `graduate-<slug>-<6hex>` branch is
|
||||
deleted alongside the PR close so failed-graduation branches
|
||||
don't accumulate on the meta repo across retries."""
|
||||
"""Meta-only (§13.3): when the flip's merge fails after the PR is
|
||||
open, the orchestrator closes the PR and deletes its
|
||||
`graduate-<slug>-<6hex>` branch so failed attempts don't accumulate
|
||||
on the meta repo across retries."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
from app.bot import Bot
|
||||
@@ -359,24 +359,23 @@ def test_graduation_rollback_deletes_dash_suffixed_branch(app_with_fake_gitea):
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner")
|
||||
|
||||
# Force a step-4 (merge_pr) failure so step 3 (open_pr) has
|
||||
# already landed and the rollback exercises the branch cleanup.
|
||||
# Force a merge_pr failure so the flip PR (open_pr) has already
|
||||
# landed and the cleanup exercises the branch deletion.
|
||||
orig_merge = Bot.merge_graduation_pr
|
||||
async def boom(self, *args, **kwargs):
|
||||
raise GiteaError(502, "simulated merge failure for rollback test")
|
||||
raise GiteaError(502, "simulated merge failure for cleanup test")
|
||||
Bot.merge_graduation_pr = boom
|
||||
try:
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/graduate?_sync=1",
|
||||
json={"rfc_id": "RFC-0099", "repo_name": "rfc-0099-ohm",
|
||||
"owners": ["ben"]},
|
||||
json={"rfc_id": "RFC-0099", "owners": ["ben"]},
|
||||
)
|
||||
finally:
|
||||
Bot.merge_graduation_pr = orig_merge
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["succeeded"] is False
|
||||
|
||||
# The dash-suffixed graduation branch was deleted on rollback.
|
||||
# The dash-suffixed graduation branch was deleted on cleanup.
|
||||
meta_branches = fake.branches[("wiggleverse", "meta")]
|
||||
graduation_branches = [n for n in meta_branches if n.startswith("graduate-ohm-")]
|
||||
assert graduation_branches == [], (
|
||||
|
||||
@@ -612,3 +612,127 @@ def test_explicit_watch_set_overrides_auto(app_with_fake_gitea):
|
||||
# the user put them.
|
||||
assert row["set_by"] == "explicit"
|
||||
assert row["state"] == "following"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# v0.18.0 — envelope headers + RFC 8058 one-click POST endpoint
|
||||
#
|
||||
# Watcher notifications are bulk-adjacent (a busy RFC can produce
|
||||
# dozens of structural events); per the proposal, they MUST carry
|
||||
# `Date`, `Message-ID`, `Auto-Submitted`, full `List-Unsubscribe`
|
||||
# (mailto + signed URL), AND `List-Unsubscribe-Post:
|
||||
# List-Unsubscribe=One-Click` per RFC 8058. Gmail and Yahoo
|
||||
# enforce this for senders at OHM's volume tier.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_notification_envelope_carries_full_one_click_headers(app_with_fake_gitea):
|
||||
"""A `proposal_merged` event lands a watcher notification email
|
||||
with the full one-click unsubscribe shape."""
|
||||
from fastapi.testclient import TestClient
|
||||
from email.utils import parsedate_to_datetime
|
||||
from app import db, email as email_mod
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor", email="alice@test")
|
||||
r = client.post("/api/rfcs/propose", json={"title": "OHM", "slug": "ohm", "pitch": PITCH, "tags": []})
|
||||
assert r.status_code == 200
|
||||
email_mod.reset_sent_envelopes()
|
||||
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner", email="ben@test")
|
||||
merge_r = client.post(f"/api/proposals/{r.json()['pr_number']}/merge")
|
||||
assert merge_r.status_code == 200, merge_r.text
|
||||
|
||||
envelopes = [e for e in email_mod.sent_envelopes() if e["to"] == "alice@test"]
|
||||
assert envelopes, "watcher notification did not fire"
|
||||
msg = envelopes[-1]["message"]
|
||||
# Always-present headers from the helper.
|
||||
assert parsedate_to_datetime(msg["Date"]) is not None
|
||||
assert msg["Message-ID"].startswith("<") and msg["Message-ID"].endswith(">")
|
||||
assert msg["Auto-Submitted"] == "auto-generated"
|
||||
# Full one-click unsubscribe.
|
||||
lu = msg["List-Unsubscribe"]
|
||||
assert lu is not None
|
||||
assert "<mailto:" in lu
|
||||
# URL part carries the signed token per make_unsubscribe_url.
|
||||
assert "/api/email/unsubscribe?t=" in lu
|
||||
assert msg["List-Unsubscribe-Post"] == "List-Unsubscribe=One-Click"
|
||||
|
||||
|
||||
def test_email_unsubscribe_post_one_click_flips_category_off(app_with_fake_gitea):
|
||||
"""RFC 8058: Gmail/Yahoo POST `List-Unsubscribe=One-Click` to the
|
||||
URL in the List-Unsubscribe header. The endpoint MUST accept POST
|
||||
+ the same token shape as the GET handler + return 200 + flip the
|
||||
flag."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db, email as email_mod
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
token = email_mod.make_unsubscribe_url(2, "personal-direct").split("t=", 1)[1]
|
||||
|
||||
r = client.post(
|
||||
f"/api/email/unsubscribe?t={token}",
|
||||
data={"List-Unsubscribe": "One-Click"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["ok"] is True
|
||||
assert body["category"] == "personal-direct"
|
||||
|
||||
row = db.conn().execute(
|
||||
"SELECT email_personal_direct FROM users WHERE id = 2"
|
||||
).fetchone()
|
||||
assert row["email_personal_direct"] == 0
|
||||
|
||||
|
||||
def test_email_unsubscribe_post_all_sets_global_opt_out(app_with_fake_gitea):
|
||||
"""The v0.18.0 `all` synthetic category (used by the bundle +
|
||||
digest paths) MUST set `email_opt_out_all = 1`."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db, email as email_mod
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
token = email_mod.make_unsubscribe_url(2, "all").split("t=", 1)[1]
|
||||
r = client.post(f"/api/email/unsubscribe?t={token}")
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"ok": True, "category": "all"}
|
||||
row = db.conn().execute(
|
||||
"SELECT email_opt_out_all FROM users WHERE id = 2"
|
||||
).fetchone()
|
||||
assert row["email_opt_out_all"] == 1
|
||||
|
||||
|
||||
def test_email_unsubscribe_get_all_sets_global_opt_out(app_with_fake_gitea):
|
||||
"""GET handler also accepts the `all` category and lands the
|
||||
global opt-out (so an MUA that doesn't honor RFC 8058 POST and
|
||||
just opens the URL in a browser still works)."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db, email as email_mod
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
token = email_mod.make_unsubscribe_url(2, "all").split("t=", 1)[1]
|
||||
r = client.get(f"/api/email/unsubscribe?t={token}")
|
||||
assert r.status_code == 200
|
||||
assert "Unsubscribed" in r.text
|
||||
row = db.conn().execute(
|
||||
"SELECT email_opt_out_all FROM users WHERE id = 2"
|
||||
).fetchone()
|
||||
assert row["email_opt_out_all"] == 1
|
||||
|
||||
|
||||
def test_email_unsubscribe_post_refuses_invalid_token(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
r = client.post("/api/email/unsubscribe?t=not-a-valid-token")
|
||||
assert r.status_code == 400
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
"""End-to-end integration tests for the v0.7.0 email/OTC sign-in
|
||||
vertical (§6.2).
|
||||
|
||||
The release replaces the Gitea OAuth gesture as the primary human
|
||||
sign-in path. The tests prove:
|
||||
|
||||
* `/auth/otc/request` is rate-limited per-email — back-to-back
|
||||
requests inside `OTC_REQUEST_COOLDOWN_SECONDS` are refused with
|
||||
429 (the loud-failure shape the spec calls out).
|
||||
* The happy path: request → code lands in the outbound buffer →
|
||||
verify with the code → session cookie surfaces an authenticated
|
||||
user via `/api/auth/me`.
|
||||
* Expired codes refuse with 400.
|
||||
* Already-consumed codes refuse with 400 on re-use.
|
||||
* Wrong codes refuse with 400.
|
||||
* Allowlist gate (v0.8.0 update): v0.7.0 silently dropped requests
|
||||
for emails not on `allowed_emails`. v0.8.0 (item #6) removed
|
||||
that gate from the request path; the admission gate is now
|
||||
`permission_state` on the freshly-provisioned `users` row,
|
||||
asserted in test_beta_access_vertical.py. The tests below
|
||||
confirm v0.8.0's open-request shape for both on-list and
|
||||
off-list emails.
|
||||
* Migration link: an existing OAuth-era user (with a `users.email`
|
||||
row) is linked by email on first OTC sign-in — `gitea_id` is
|
||||
preserved.
|
||||
* Provisioning path: an unrecognized email creates a fresh
|
||||
contributor row with `gitea_id = NULL`.
|
||||
|
||||
The Gitea bot user + token are still required at process construction
|
||||
(every test harness sets the same `GITEA_*` env vars); the OTC flow
|
||||
itself never reaches Gitea. The fakes from `test_propose_vertical`
|
||||
remain in scope so the rest of the app boots cleanly.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
FakeGitea,
|
||||
app_with_fake_gitea,
|
||||
provision_user_row,
|
||||
tmp_env,
|
||||
)
|
||||
|
||||
|
||||
def _reset_outbound():
|
||||
from app import email as email_mod
|
||||
email_mod.reset_sent_envelopes()
|
||||
|
||||
|
||||
def _outbound_otc_codes(to_address: str | None = None) -> list[str]:
|
||||
"""Pluck the `code` line out of every OTC email in the test buffer.
|
||||
|
||||
The OTC mailer stamps `kind='otc'` on the envelope so the §15.4
|
||||
notification mailer's envelopes (the unsubscribe-footer shape)
|
||||
don't accidentally satisfy the assertion. Each envelope's body
|
||||
carries the code on its own indented line; this helper extracts
|
||||
just that token so the test reads the same way the user would
|
||||
read the email.
|
||||
"""
|
||||
from app import email as email_mod
|
||||
out = []
|
||||
for env in email_mod.sent_envelopes():
|
||||
if env.get("kind") != "otc":
|
||||
continue
|
||||
if to_address is not None and env["to"] != to_address:
|
||||
continue
|
||||
for line in env["body"].splitlines():
|
||||
tok = line.strip()
|
||||
if tok.isdigit() and len(tok) == 6:
|
||||
out.append(tok)
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Happy path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_otc_request_then_verify_signs_in_a_fresh_user(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
|
||||
# Request: 202 + a single OTC envelope to the requested address.
|
||||
r = client.post("/auth/otc/request", json={"email": "newcomer@example.com"})
|
||||
assert r.status_code == 200, r.text
|
||||
codes = _outbound_otc_codes("newcomer@example.com")
|
||||
assert len(codes) == 1
|
||||
code = codes[0]
|
||||
|
||||
# Verify: 200 + session cookie + me-shape now reads authenticated.
|
||||
r = client.post("/auth/otc/verify", json={"email": "newcomer@example.com", "code": code})
|
||||
assert r.status_code == 200, r.text
|
||||
me = client.get("/api/auth/me").json()
|
||||
assert me["authenticated"] is True
|
||||
assert me["user"]["email"] == "newcomer@example.com"
|
||||
# Fresh provisioning: no gitea linker. The display name is the
|
||||
# local part of the email per §6.2.
|
||||
assert me["user"]["role"] == "contributor"
|
||||
assert me["user"]["display_name"] == "newcomer"
|
||||
|
||||
# The `users` row reflects the same: gitea_id NULL, email set.
|
||||
from app import db
|
||||
row = db.conn().execute(
|
||||
"SELECT gitea_id, email FROM users WHERE email = ? COLLATE NOCASE",
|
||||
("newcomer@example.com",),
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
assert row["gitea_id"] is None
|
||||
assert row["email"] == "newcomer@example.com"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Failure modes on verify
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_otc_verify_refuses_wrong_code(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
client.post("/auth/otc/request", json={"email": "alice@example.com"})
|
||||
r = client.post("/auth/otc/verify", json={"email": "alice@example.com", "code": "000000"})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_otc_verify_refuses_consumed_code(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
client.post("/auth/otc/request", json={"email": "alice@example.com"})
|
||||
code = _outbound_otc_codes("alice@example.com")[-1]
|
||||
# First verify succeeds.
|
||||
r1 = client.post("/auth/otc/verify", json={"email": "alice@example.com", "code": code})
|
||||
assert r1.status_code == 200
|
||||
# Drop the session cookie so the re-verify reads as fresh.
|
||||
client.cookies.clear()
|
||||
# Second verify with the same code is refused — `consumed_at`
|
||||
# stamped on the row blocks the replay.
|
||||
r2 = client.post("/auth/otc/verify", json={"email": "alice@example.com", "code": code})
|
||||
assert r2.status_code == 400
|
||||
|
||||
|
||||
def test_otc_verify_refuses_expired_code(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
client.post("/auth/otc/request", json={"email": "alice@example.com"})
|
||||
code = _outbound_otc_codes("alice@example.com")[-1]
|
||||
# Backdate the row's expires_at to the past. The TTL setting is
|
||||
# an env var (default 10 min); rather than waiting, the test
|
||||
# rewrites the row.
|
||||
db.conn().execute(
|
||||
"UPDATE otc_codes SET expires_at = datetime('now', '-1 minute') WHERE email = ?",
|
||||
("alice@example.com",),
|
||||
)
|
||||
r = client.post("/auth/otc/verify", json={"email": "alice@example.com", "code": code})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rate limiting
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_otc_request_rate_limited_per_email(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
r1 = client.post("/auth/otc/request", json={"email": "alice@example.com"})
|
||||
assert r1.status_code == 200
|
||||
# Cooldown defaults to 60s; the second back-to-back call is
|
||||
# refused with a loud 429.
|
||||
r2 = client.post("/auth/otc/request", json={"email": "alice@example.com"})
|
||||
assert r2.status_code == 429
|
||||
# The buffer still has exactly one envelope — the rate-limited
|
||||
# call didn't double-send.
|
||||
assert len(_outbound_otc_codes("alice@example.com")) == 1
|
||||
|
||||
|
||||
def test_otc_request_cooldown_is_per_email_not_global(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
r1 = client.post("/auth/otc/request", json={"email": "alice@example.com"})
|
||||
assert r1.status_code == 200
|
||||
# Different email, fresh cooldown.
|
||||
r2 = client.post("/auth/otc/request", json={"email": "bob@example.com"})
|
||||
assert r2.status_code == 200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Allowlist gate — v0.8.0 update
|
||||
#
|
||||
# v0.7.0 gated the OTC request endpoint on the `allowed_emails` table:
|
||||
# emails not on the list got a silent drop (still 202, but no code).
|
||||
# v0.8.0 (roadmap item #6) reverses this: the request endpoint
|
||||
# accepts any valid email and sends a code. The admission gate moves
|
||||
# to `permission_state` on the freshly-provisioned `users` row,
|
||||
# which the next-tier tests in test_beta_access_vertical.py cover.
|
||||
# The `allowed_emails` table stays in the schema as a fast-path
|
||||
# bypass for admin convenience.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_otc_request_admits_emails_regardless_of_allowlist_population(app_with_fake_gitea):
|
||||
"""v0.8.0: the OTC request path no longer consults `allowed_emails`.
|
||||
Whether the allowlist is empty or populated, every valid email
|
||||
receives a code; admission gates at `permission_state` post-verify.
|
||||
"""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
# Populate the allowlist with one specific email; the v0.7.0
|
||||
# gate would have engaged here.
|
||||
db.conn().execute("INSERT INTO allowed_emails (email) VALUES (?)", ("invited@example.com",))
|
||||
|
||||
# The not-on-list email still gets a code under v0.8.0.
|
||||
r = client.post("/auth/otc/request", json={"email": "stranger@example.com"})
|
||||
assert r.status_code == 200
|
||||
assert len(_outbound_otc_codes("stranger@example.com")) == 1
|
||||
|
||||
|
||||
def test_otc_request_admits_allowlisted_email(app_with_fake_gitea):
|
||||
"""v0.8.0: still works for emails that happen to be on the legacy
|
||||
allowlist — the table is no longer consulted at request time but
|
||||
populated rows are admitted alongside everyone else (since the
|
||||
gate is now open at the request surface)."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
db.conn().execute("INSERT INTO allowed_emails (email) VALUES (?)", ("invited@example.com",))
|
||||
r = client.post("/auth/otc/request", json={"email": "invited@example.com"})
|
||||
assert r.status_code == 200
|
||||
assert len(_outbound_otc_codes("invited@example.com")) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Migration path — link by email to an OAuth-era user
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_otc_links_to_existing_oauth_user_by_email(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
# Seed an OAuth-era row. `provision_user_row` writes
|
||||
# email=<login>@test, so we sign in via OTC with the matching
|
||||
# email and expect the same `users.id` to come back.
|
||||
provision_user_row(user_id=42, login="legacyuser", role="contributor")
|
||||
existing = db.conn().execute(
|
||||
"SELECT id, gitea_id FROM users WHERE id = ?", (42,)
|
||||
).fetchone()
|
||||
assert existing["gitea_id"] == 42 # OAuth linker is set.
|
||||
|
||||
r = client.post("/auth/otc/request", json={"email": "legacyuser@test"})
|
||||
assert r.status_code == 200
|
||||
code = _outbound_otc_codes("legacyuser@test")[-1]
|
||||
r = client.post("/auth/otc/verify", json={"email": "legacyuser@test", "code": code})
|
||||
assert r.status_code == 200
|
||||
|
||||
# /api/auth/me reports the linked user — same id, original role.
|
||||
me = client.get("/api/auth/me").json()
|
||||
assert me["authenticated"] is True
|
||||
assert me["user"]["id"] == 42
|
||||
assert me["user"]["role"] == "contributor"
|
||||
|
||||
# gitea_id is preserved on the linked row — the migration path
|
||||
# doesn't disturb the OAuth linker.
|
||||
row = db.conn().execute(
|
||||
"SELECT gitea_id FROM users WHERE id = ?", (42,)
|
||||
).fetchone()
|
||||
assert row["gitea_id"] == 42
|
||||
|
||||
|
||||
def test_otc_provisions_fresh_user_when_email_matches_no_one(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
r = client.post("/auth/otc/request", json={"email": "newperson@example.com"})
|
||||
assert r.status_code == 200
|
||||
code = _outbound_otc_codes("newperson@example.com")[-1]
|
||||
r = client.post("/auth/otc/verify", json={"email": "newperson@example.com", "code": code})
|
||||
assert r.status_code == 200
|
||||
|
||||
# A fresh row landed with NULL gitea_id (no OAuth linker).
|
||||
row = db.conn().execute(
|
||||
"SELECT id, gitea_id, gitea_login, role FROM users WHERE email = ? COLLATE NOCASE",
|
||||
("newperson@example.com",),
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
assert row["gitea_id"] is None
|
||||
assert row["gitea_login"] is None
|
||||
assert row["role"] == "contributor"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Re-request invalidates prior code
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_otc_re_request_invalidates_prior_unused_code(app_with_fake_gitea, monkeypatch):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
# Drop the cooldown so the second request lands instead of 429ing.
|
||||
monkeypatch.setenv("OTC_REQUEST_COOLDOWN_SECONDS", "0")
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
client.post("/auth/otc/request", json={"email": "alice@example.com"})
|
||||
first = _outbound_otc_codes("alice@example.com")[-1]
|
||||
client.post("/auth/otc/request", json={"email": "alice@example.com"})
|
||||
second = _outbound_otc_codes("alice@example.com")[-1]
|
||||
assert first != second
|
||||
|
||||
# The old code is invalidated — verify with `first` now refuses.
|
||||
r = client.post("/auth/otc/verify", json={"email": "alice@example.com", "code": first})
|
||||
assert r.status_code == 400
|
||||
|
||||
# The new code still works.
|
||||
r = client.post("/auth/otc/verify", json={"email": "alice@example.com", "code": second})
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# v0.18.0: envelope headers — Slice 2
|
||||
#
|
||||
# OTC mail goes through `build_envelope` and MUST land Date,
|
||||
# Message-ID, and Auto-Submitted but MUST NOT carry a
|
||||
# List-Unsubscribe header (the recipient explicitly requested the
|
||||
# code; advertising a list semantic would be wrong).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _last_otc_envelope():
|
||||
from app import email as email_mod
|
||||
otc = [e for e in email_mod.sent_envelopes() if e.get("kind") == "otc"]
|
||||
assert otc, "no OTC envelope in the buffer"
|
||||
return otc[-1]
|
||||
|
||||
|
||||
def test_otc_envelope_sets_date_messageid_autosubmitted(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from email.utils import parsedate_to_datetime
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
client.post("/auth/otc/request", json={"email": "headers@example.com"})
|
||||
msg = _last_otc_envelope()["message"]
|
||||
# Date is RFC 5322 parseable.
|
||||
assert parsedate_to_datetime(msg["Date"]) is not None
|
||||
# Message-ID is bracketed and carries the From-address @-domain.
|
||||
mid = msg["Message-ID"]
|
||||
assert mid.startswith("<") and mid.endswith(">")
|
||||
# Auto-Submitted prevents auto-responder loops.
|
||||
assert msg["Auto-Submitted"] == "auto-generated"
|
||||
|
||||
|
||||
def test_otc_envelope_has_no_list_unsubscribe(app_with_fake_gitea):
|
||||
"""The recipient explicitly typed their email and asked for a
|
||||
code; the framework MUST NOT advertise a list semantic on this
|
||||
mail. Per the v0.18.0 proposal's tradeoff discussion."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
client.post("/auth/otc/request", json={"email": "headers@example.com"})
|
||||
msg = _last_otc_envelope()["message"]
|
||||
assert msg["List-Unsubscribe"] is None
|
||||
assert msg["List-Unsubscribe-Post"] is None
|
||||
@@ -0,0 +1,368 @@
|
||||
"""End-to-end integration tests for the v0.18.0 Slice 4
|
||||
outbound_emails audit table + admin endpoint.
|
||||
|
||||
The release adds:
|
||||
* `backend/migrations/020_outbound_emails.sql` — the audit table.
|
||||
* `record_outbound()` in `email.py` — the write helper every send
|
||||
path calls before returning, capturing status='sent' / 'failed'
|
||||
/ 'deferred' (the dev-fallback path when SMTP_HOST is unset).
|
||||
* `GET /api/admin/outbound-emails` — admin-only listing, filterable
|
||||
by kind / status / to_address.
|
||||
|
||||
These tests prove:
|
||||
* Sending OTC / invite / notification mail writes one row per send
|
||||
(status='deferred' under tests since SMTP_HOST is unset).
|
||||
* The Message-ID on the row matches the envelope's Message-ID
|
||||
header (the seam Slice 5 uses for bounce correlation).
|
||||
* `kind` is populated per send path.
|
||||
* `GET /api/admin/outbound-emails` lists rows newest-first,
|
||||
accepts filters, refuses non-admins.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
FakeGitea,
|
||||
app_with_fake_gitea,
|
||||
provision_user_row,
|
||||
sign_in_as,
|
||||
tmp_env,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Write-on-send wiring
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_otc_send_writes_outbound_row_with_message_id(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db, email as email_mod
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
email_mod.reset_sent_envelopes()
|
||||
r = client.post("/auth/otc/request", json={"email": "newcomer@ex.co"})
|
||||
assert r.status_code == 200
|
||||
|
||||
# Audit row landed.
|
||||
rows = db.conn().execute(
|
||||
"SELECT id, to_address, kind, status, message_id, error "
|
||||
"FROM outbound_emails WHERE to_address = 'newcomer@ex.co'"
|
||||
).fetchall()
|
||||
assert len(rows) == 1
|
||||
row = rows[0]
|
||||
assert row["kind"] == "otc"
|
||||
# No SMTP_HOST in tests -> 'deferred', not 'sent'.
|
||||
assert row["status"] == "deferred"
|
||||
assert row["error"] is None
|
||||
# Message-ID matches the envelope's header (the seam Slice 5 uses).
|
||||
envelopes = [e for e in email_mod.sent_envelopes() if e.get("kind") == "otc"]
|
||||
assert envelopes
|
||||
envelope_mid = envelopes[-1]["message"]["Message-ID"]
|
||||
assert row["message_id"] == envelope_mid
|
||||
|
||||
|
||||
def test_invite_send_writes_outbound_row(app_with_fake_gitea):
|
||||
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=500, login="adminQ", role="admin")
|
||||
sign_in_as(
|
||||
client, user_id=500, gitea_login="adminQ",
|
||||
display_name="Admin Q", role="admin",
|
||||
email="adminq@test",
|
||||
)
|
||||
r = client.post(
|
||||
"/api/admin/users",
|
||||
json={
|
||||
"email": "invitee@ex.co",
|
||||
"first_name": "Inv", "last_name": "Itee",
|
||||
"role": "contributor", "custom_message": "",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
rows = db.conn().execute(
|
||||
"SELECT kind, status, message_id FROM outbound_emails "
|
||||
"WHERE to_address = 'invitee@ex.co'"
|
||||
).fetchall()
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["kind"] == "invite"
|
||||
assert rows[0]["status"] == "deferred"
|
||||
assert rows[0]["message_id"] is not None
|
||||
|
||||
|
||||
def test_notification_send_writes_outbound_row_with_notification_id(app_with_fake_gitea):
|
||||
"""Watcher notifications carry a `notification_id` FK so the
|
||||
admin can join through to the notifications table to see what
|
||||
triggered the send."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db, email as email_mod
|
||||
from test_notifications_vertical import PITCH
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
sign_in_as(
|
||||
client, user_id=2, gitea_login="alice",
|
||||
display_name="Alice", role="contributor", email="alice@test",
|
||||
)
|
||||
r = client.post("/api/rfcs/propose", json={
|
||||
"title": "OHM", "slug": "ohm", "pitch": PITCH, "tags": [],
|
||||
})
|
||||
email_mod.reset_sent_envelopes()
|
||||
# Wipe pre-merge audit rows so the assertion below is unambiguous.
|
||||
db.conn().execute("DELETE FROM outbound_emails")
|
||||
sign_in_as(
|
||||
client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner", email="ben@test",
|
||||
)
|
||||
merge_r = client.post(f"/api/proposals/{r.json()['pr_number']}/merge")
|
||||
assert merge_r.status_code == 200, merge_r.text
|
||||
|
||||
rows = db.conn().execute(
|
||||
"SELECT kind, status, notification_id, message_id "
|
||||
"FROM outbound_emails WHERE to_address = 'alice@test'"
|
||||
).fetchall()
|
||||
assert rows, "no outbound_emails row for alice@test"
|
||||
# At least one notification kind, with a populated FK.
|
||||
notif_rows = [r for r in rows if r["kind"] == "notification"]
|
||||
assert notif_rows
|
||||
for nr in notif_rows:
|
||||
assert nr["status"] == "deferred"
|
||||
assert nr["notification_id"] is not None
|
||||
assert nr["message_id"] is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Admin endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_admin_outbound_emails_lists_rows(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
# Generate a few rows.
|
||||
client.post("/auth/otc/request", json={"email": "one@ex.co"})
|
||||
|
||||
provision_user_row(user_id=600, login="adminR", role="admin")
|
||||
sign_in_as(
|
||||
client, user_id=600, gitea_login="adminR",
|
||||
display_name="Admin R", role="admin", email="adminr@test",
|
||||
)
|
||||
client.post("/api/admin/users", json={
|
||||
"email": "two@ex.co", "first_name": "T", "last_name": "Wo",
|
||||
"role": "contributor", "custom_message": "",
|
||||
})
|
||||
|
||||
r = client.get("/api/admin/outbound-emails")
|
||||
assert r.status_code == 200, r.text
|
||||
items = r.json()["items"]
|
||||
kinds = {it["kind"] for it in items}
|
||||
assert "otc" in kinds
|
||||
assert "invite" in kinds
|
||||
# Newest-first.
|
||||
ids = [it["id"] for it in items]
|
||||
assert ids == sorted(ids, reverse=True)
|
||||
|
||||
|
||||
def test_admin_outbound_emails_filters_by_kind(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
client.post("/auth/otc/request", json={"email": "filter1@ex.co"})
|
||||
|
||||
provision_user_row(user_id=601, login="adminS", role="admin")
|
||||
sign_in_as(
|
||||
client, user_id=601, gitea_login="adminS",
|
||||
display_name="Admin S", role="admin", email="admins@test",
|
||||
)
|
||||
client.post("/api/admin/users", json={
|
||||
"email": "filter2@ex.co", "first_name": "F", "last_name": "Two",
|
||||
"role": "contributor", "custom_message": "",
|
||||
})
|
||||
|
||||
r = client.get("/api/admin/outbound-emails?kind=otc")
|
||||
assert r.status_code == 200
|
||||
items = r.json()["items"]
|
||||
assert items
|
||||
assert all(it["kind"] == "otc" for it in items)
|
||||
|
||||
|
||||
def test_admin_outbound_emails_filters_by_to_address(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
client.post("/auth/otc/request", json={"email": "TARGET@ex.co"})
|
||||
client.post("/auth/otc/request", json={"email": "other@ex.co"})
|
||||
|
||||
provision_user_row(user_id=602, login="adminT", role="admin")
|
||||
sign_in_as(
|
||||
client, user_id=602, gitea_login="adminT",
|
||||
display_name="Admin T", role="admin", email="admint@test",
|
||||
)
|
||||
|
||||
# to_address filter is case-insensitive.
|
||||
r = client.get("/api/admin/outbound-emails?to_address=target@ex.co")
|
||||
assert r.status_code == 200
|
||||
items = r.json()["items"]
|
||||
assert items
|
||||
assert all(it["to_address"].lower() == "target@ex.co" for it in items)
|
||||
|
||||
|
||||
def test_admin_outbound_emails_refuses_non_admin(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=700, login="contribU", role="contributor")
|
||||
sign_in_as(
|
||||
client, user_id=700, gitea_login="contribU",
|
||||
display_name="Contrib U", role="contributor",
|
||||
)
|
||||
r = client.get("/api/admin/outbound-emails")
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# v0.18.0 Slice 5: bounce correlation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_bounce_with_message_id_marks_outbound_row_bounced(app_with_fake_gitea):
|
||||
"""When the bounce body includes the original `message_id`, the
|
||||
framework looks it up in outbound_emails and stamps
|
||||
status='bounced' on the matching row. The hard-bounce ->
|
||||
global-opt-out logic still fires."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db, email as email_mod
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=800, login="bouncey", role="contributor")
|
||||
db.conn().execute("UPDATE users SET email = 'bouncey@ex.co' WHERE id = 800")
|
||||
|
||||
# Send something to bouncey to land an outbound_emails row.
|
||||
email_mod.reset_sent_envelopes()
|
||||
client.post("/auth/otc/request", json={"email": "bouncey@ex.co"})
|
||||
row = db.conn().execute(
|
||||
"SELECT id, message_id, status FROM outbound_emails "
|
||||
"WHERE to_address = 'bouncey@ex.co'"
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
original_id = row["id"]
|
||||
message_id = row["message_id"]
|
||||
assert row["status"] == "deferred" # pre-bounce baseline
|
||||
|
||||
# Bounce comes in carrying that message_id.
|
||||
r = client.post(
|
||||
"/api/webhooks/email-bounce",
|
||||
json={
|
||||
"email": "bouncey@ex.co",
|
||||
"kind": "hard",
|
||||
"message_id": message_id,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["matched"] is True
|
||||
assert body["correlated_id"] == original_id
|
||||
|
||||
# Audit row stamped.
|
||||
post = db.conn().execute(
|
||||
"SELECT status, error FROM outbound_emails WHERE id = ?",
|
||||
(original_id,),
|
||||
).fetchone()
|
||||
assert post["status"] == "bounced"
|
||||
assert "bounce (hard)" in (post["error"] or "")
|
||||
|
||||
# Hard-bounce global opt-out still fires.
|
||||
urow = db.conn().execute(
|
||||
"SELECT email_opt_out_all FROM users WHERE id = 800"
|
||||
).fetchone()
|
||||
assert urow["email_opt_out_all"] == 1
|
||||
|
||||
|
||||
def test_bounce_with_unknown_message_id_does_not_crash(app_with_fake_gitea):
|
||||
"""A message_id the framework doesn't recognize logs but does
|
||||
NOT 5xx — bounce providers replay old bounces, and the
|
||||
framework can't refuse just because the row was pruned."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
r = client.post(
|
||||
"/api/webhooks/email-bounce",
|
||||
json={
|
||||
"email": "nobody@ex.co",
|
||||
"kind": "hard",
|
||||
"message_id": "<not-in-our-db@ex.co>",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["correlated_id"] is None
|
||||
|
||||
|
||||
def test_bounce_without_message_id_still_flips_opt_out(app_with_fake_gitea):
|
||||
"""Backward compat: providers that don't surface Message-ID
|
||||
still get the legacy v1 behavior — match by email + flip the
|
||||
global opt-out."""
|
||||
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=801, login="legacybounce", role="contributor")
|
||||
db.conn().execute("UPDATE users SET email = 'legacy@ex.co' WHERE id = 801")
|
||||
|
||||
r = client.post(
|
||||
"/api/webhooks/email-bounce",
|
||||
json={"email": "legacy@ex.co", "kind": "hard"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["matched"] is True
|
||||
assert body["correlated_id"] is None
|
||||
|
||||
urow = db.conn().execute(
|
||||
"SELECT email_opt_out_all FROM users WHERE id = 801"
|
||||
).fetchone()
|
||||
assert urow["email_opt_out_all"] == 1
|
||||
|
||||
|
||||
def test_bounced_rows_show_in_admin_endpoint(app_with_fake_gitea):
|
||||
"""The admin endpoint surfaces bounced rows alongside the rest;
|
||||
filtering by `status=bounced` isolates them."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db, email as email_mod
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=802, login="adminB", role="admin")
|
||||
sign_in_as(
|
||||
client, user_id=802, gitea_login="adminB",
|
||||
display_name="Admin B", role="admin", email="adminb@test",
|
||||
)
|
||||
email_mod.reset_sent_envelopes()
|
||||
client.post("/auth/otc/request", json={"email": "willbounce@ex.co"})
|
||||
row = db.conn().execute(
|
||||
"SELECT message_id FROM outbound_emails WHERE to_address = 'willbounce@ex.co'"
|
||||
).fetchone()
|
||||
client.post(
|
||||
"/api/webhooks/email-bounce",
|
||||
json={"email": "willbounce@ex.co", "kind": "hard", "message_id": row["message_id"]},
|
||||
)
|
||||
|
||||
r = client.get("/api/admin/outbound-emails?status=bounced")
|
||||
assert r.status_code == 200
|
||||
items = r.json()["items"]
|
||||
assert items
|
||||
assert all(it["status"] == "bounced" for it in items)
|
||||
assert any(it["to_address"] == "willbounce@ex.co" for it in items)
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -416,8 +438,24 @@ def tmp_env(monkeypatch):
|
||||
"SECRET_KEY": "test-secret-key-for-cookies",
|
||||
"DATABASE_PATH": str(db_path),
|
||||
"OWNER_GITEA_LOGIN": "ben",
|
||||
"GITEA_WEBHOOK_SECRET": "",
|
||||
# v0.18.0: `GITEA_WEBHOOK_SECRET` is now mandatory at startup
|
||||
# per the email + webhook hygiene proposal. Tests bind a fake
|
||||
# value so the framework boots; tests that want to exercise
|
||||
# the dev-bypass path monkeypatch `RFC_APP_INSECURE_WEBHOOKS=1`.
|
||||
"GITEA_WEBHOOK_SECRET": "test-webhook-secret-for-signature-verification",
|
||||
"ENABLED_MODELS": "claude",
|
||||
# v0.27.0 (audit 0026 M4): the session cookie now defaults to
|
||||
# Secure. The TestClient talks plain http://testserver, so a
|
||||
# Secure cookie is never sent back and every authenticated flow
|
||||
# would fail. Tests opt out explicitly, exactly as a dev box on
|
||||
# plain http does.
|
||||
"SESSION_COOKIE_SECURE": "false",
|
||||
# v0.27.0 (audit 0026 M5): the bounce webhook fails closed (503)
|
||||
# when its secret is unset. Tests exercise the legacy behavioral
|
||||
# path via the documented dev opt-in, mirroring the
|
||||
# RFC_APP_INSECURE_WEBHOOKS bypass above. Tests that assert the
|
||||
# fail-closed default delenv this key themselves.
|
||||
"RFC_APP_INSECURE_BOUNCE_WEBHOOK": "1",
|
||||
}
|
||||
for k, v in env.items():
|
||||
monkeypatch.setenv(k, v)
|
||||
@@ -496,6 +534,11 @@ def test_propose_to_super_draft_vertical(app_with_fake_gitea):
|
||||
proposal = r.json()
|
||||
assert proposal["entry"]["title"] == "Open Human Model"
|
||||
assert proposal["entry"]["state"] == "super-draft"
|
||||
# §9.2: the proposer is the implicit first owner at propose time.
|
||||
# The owners field is a single-element list containing exactly the
|
||||
# session user's gitea_login — no request-supplied owner field
|
||||
# exists or is honored.
|
||||
assert proposal["entry"]["owners"] == ["alice"]
|
||||
assert proposal["affordances"]["merge"] is True
|
||||
|
||||
# Owner merges. The catalog picks up the new super-draft.
|
||||
@@ -516,6 +559,10 @@ def test_propose_to_super_draft_vertical(app_with_fake_gitea):
|
||||
view = r.json()
|
||||
assert view["state"] == "super-draft"
|
||||
assert "shared definition" in view["body"]
|
||||
# §9.2: the auto-set proposer-owner survives the meta-repo round-trip
|
||||
# — it's in the file's frontmatter on main after merge, not just
|
||||
# in the pending-PR view above.
|
||||
assert view["owners"] == ["alice"]
|
||||
|
||||
# The pending-ideas list no longer carries the merged proposal.
|
||||
r = client.get("/api/proposals")
|
||||
@@ -530,6 +577,97 @@ def test_propose_to_super_draft_vertical(app_with_fake_gitea):
|
||||
assert ("merge_proposal", "ben") in kinds
|
||||
|
||||
|
||||
def test_merged_idea_pr_with_deleted_branch_clears_proposal(app_with_fake_gitea):
|
||||
"""Regression: a merged idea PR whose branch was deleted must not
|
||||
linger as a 'pending idea' ghost.
|
||||
|
||||
Found via the ROADMAP #35 operator authoring lane: merging an idea
|
||||
PR from the CLI with `--delete-branch` makes Gitea report the PR's
|
||||
`head.ref` as the synthetic `refs/pull/<N>/head` sentinel instead of
|
||||
`propose/<slug>`. `refresh_meta_pulls` derives the slug from the
|
||||
branch name, so the sentinel parsed to slug=None, the row was skipped,
|
||||
and `cached_prs.state` stayed frozen at 'open' — leaving the entry
|
||||
showing as BOTH a super-draft (cached_rfcs reconciled off the push)
|
||||
AND a pending idea (cached_prs never updated). The fix recovers the
|
||||
original branch name from the already-stored cached_prs row.
|
||||
|
||||
The web UX never tripped this because it leaves the branch in place
|
||||
(the repo's default_delete_branch_after_merge is false).
|
||||
|
||||
The bug only manifests on an out-of-band merge (the PR merged +
|
||||
branch deleted directly in Gitea, with the in-app merge endpoint never
|
||||
reconciling the row while the branch still existed) -- which is exactly
|
||||
what the #35 CLI lane does. An in-app merge reconciles cached_prs to
|
||||
'merged' before the branch is gone, so it never trips this; the test
|
||||
therefore drives the Gitea state directly to reproduce the CLI path.
|
||||
"""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db, cache, gitea as gitea_mod
|
||||
from app.config import load_config
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor", email="alice@test")
|
||||
r = client.post("/api/rfcs/propose", json={
|
||||
"title": "Informed Consent",
|
||||
"slug": "informed-consent",
|
||||
"pitch": "A first-class definition of consent in OHM.",
|
||||
"tags": [],
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
pr_number = r.json()["pr_number"]
|
||||
|
||||
# The proposal is cached as an open idea PR.
|
||||
items = client.get("/api/proposals").json()["items"]
|
||||
assert any(i["pr_number"] == pr_number for i in items)
|
||||
|
||||
# Out-of-band CLI merge (ROADMAP #35 lane): the PR is merged AND
|
||||
# its branch deleted directly in Gitea, WITHOUT the in-app merge
|
||||
# endpoint ever running. So cached_prs still says state='open' and
|
||||
# Gitea now reports the merged PR's head.ref as the sentinel. This
|
||||
# is the exact state `rfc-authoring.sh pr-merge --delete-branch`
|
||||
# leaves behind.
|
||||
for pr in fake.pulls[("wiggleverse", "meta")]:
|
||||
if pr["number"] == pr_number:
|
||||
# land the file on main (the push side already reconciles
|
||||
# cached_rfcs into a super-draft via the webhook/sweep)
|
||||
for (o, rp, br, p), data in list(fake.files.items()):
|
||||
if (o, rp, br) == ("wiggleverse", "meta", "propose/informed-consent"):
|
||||
fake.files[("wiggleverse", "meta", "main", p)] = dict(data)
|
||||
pr["state"] = "closed"
|
||||
pr["merged"] = True
|
||||
pr["merged_at"] = "2026-05-29T12:13:00Z"
|
||||
pr["closed_at"] = "2026-05-29T12:13:00Z"
|
||||
pr["merge_commit_sha"] = fake._next_sha()
|
||||
pr["head"]["ref"] = f"refs/pull/{pr_number}/head"
|
||||
fake.branches[("wiggleverse", "meta")].pop("propose/informed-consent", None)
|
||||
|
||||
# The reconcile sweep runs (a later webhook, or the 5-min safety net).
|
||||
import asyncio
|
||||
cfg = load_config()
|
||||
gclient = gitea_mod.Gitea(cfg)
|
||||
asyncio.run(cache.refresh_meta_repo(cfg, gclient))
|
||||
asyncio.run(cache.refresh_meta_pulls(cfg, gclient))
|
||||
|
||||
# The bug: this used to still list informed-consent (frozen 'open'
|
||||
# row, slug unparseable from the sentinel). The fix recovers the
|
||||
# stored branch name, so the row reconciles to merged and the ghost
|
||||
# is gone.
|
||||
assert client.get("/api/proposals").json()["items"] == []
|
||||
|
||||
# And the cached_prs row is correctly merged, not a frozen 'open'.
|
||||
row = db.conn().execute(
|
||||
"SELECT state FROM cached_prs WHERE pr_number = ?", (pr_number,)
|
||||
).fetchone()
|
||||
assert row["state"] == "merged", f"expected merged, got {row['state']}"
|
||||
|
||||
# The super-draft itself is unaffected — still in the catalog.
|
||||
items = client.get("/api/rfcs").json()["items"]
|
||||
assert any(i["slug"] == "informed-consent" and i["state"] == "super-draft" for i in items)
|
||||
|
||||
|
||||
def test_slug_uniqueness_enforced(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
app, _fake = app_with_fake_gitea
|
||||
@@ -568,6 +706,37 @@ def test_anonymous_cannot_propose(app_with_fake_gitea):
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_proposer_is_auto_owner_request_payload_ignored(app_with_fake_gitea):
|
||||
"""§9.2: the owners field on the new entry is always exactly
|
||||
`[session.gitea_login]`. The propose endpoint never accepts an owner
|
||||
from the client; a request payload that smuggles one in is ignored
|
||||
by the Pydantic body model and the auto-set value lands instead.
|
||||
"""
|
||||
from fastapi.testclient import TestClient
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=11, login="alice", role="contributor")
|
||||
sign_in_as(client, user_id=11, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
# Extra unknown fields like `owners` are dropped by the
|
||||
# ProposeBody model; the session user is the only source of truth.
|
||||
r = client.post("/api/rfcs/propose", json={
|
||||
"title": "Spoof attempt",
|
||||
"slug": "spoof-attempt",
|
||||
"pitch": "p",
|
||||
"tags": [],
|
||||
"owners": ["mallory", "eve"],
|
||||
"proposed_by": "mallory@test",
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
pr_number = r.json()["pr_number"]
|
||||
r = client.get(f"/api/proposals/{pr_number}")
|
||||
assert r.status_code == 200, r.text
|
||||
entry = r.json()["entry"]
|
||||
assert entry["owners"] == ["alice"]
|
||||
# proposed_by also comes from the session, never the body.
|
||||
assert entry["proposed_by"] in ("alice@test", "alice")
|
||||
|
||||
|
||||
def test_withdraw_by_proposer_works(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
app, _fake = app_with_fake_gitea
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
"""End-to-end vertical for roadmap #26 (rfc-app v0.22.0): the optional
|
||||
"What will you be using this for?" capture on the two propose surfaces.
|
||||
|
||||
Reuses the FakeGitea + session helpers from test_propose_vertical.py and
|
||||
the active-RFC seed from test_rfc_view_vertical.py. Proves:
|
||||
|
||||
(a) propose-RFC persists and returns `proposed_use_case` when supplied,
|
||||
and the value survives onto the merged super-draft's RFC view;
|
||||
(b) propose-RFC accepts a NULL / omitted use case ("left blank");
|
||||
(c) propose-PR persists and returns `proposed_use_case` when supplied,
|
||||
and accepts a NULL / omitted one.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
FakeGitea,
|
||||
app_with_fake_gitea,
|
||||
provision_user_row,
|
||||
sign_in_as,
|
||||
tmp_env,
|
||||
)
|
||||
from test_pr_flow_vertical import _cut_branch_and_accept_change
|
||||
from test_rfc_view_vertical import SEED_BODY, seed_active_rfc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# propose-RFC
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_propose_rfc_persists_and_returns_use_case(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor", email="alice@test")
|
||||
|
||||
r = client.post("/api/rfcs/propose", json={
|
||||
"title": "Open Human Model",
|
||||
"slug": "open-human-model",
|
||||
"pitch": "A shared definition of what we mean by *human*.",
|
||||
"tags": ["identity"],
|
||||
"proposed_use_case": "Wiring OHM into the OpenXML consent surface.",
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
pr_number = r.json()["pr_number"]
|
||||
|
||||
# The pending-idea list carries the use case.
|
||||
items = client.get("/api/proposals").json()["items"]
|
||||
assert items[0]["proposed_use_case"] == "Wiring OHM into the OpenXML consent surface."
|
||||
|
||||
# The pending-idea detail view carries it too.
|
||||
proposal = client.get(f"/api/proposals/{pr_number}").json()
|
||||
assert proposal["proposed_use_case"] == "Wiring OHM into the OpenXML consent surface."
|
||||
|
||||
# Merge as owner; the use case survives onto the RFC view (looked
|
||||
# up by slug from the canonical side table, since the idea PR
|
||||
# closes on merge).
|
||||
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner", email="ben@test")
|
||||
r = client.post(f"/api/proposals/{pr_number}/merge")
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
view = client.get("/api/rfcs/open-human-model").json()
|
||||
assert view["proposed_use_case"] == "Wiring OHM into the OpenXML consent surface."
|
||||
|
||||
|
||||
def test_propose_rfc_use_case_optional(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=3, login="carol", role="contributor")
|
||||
sign_in_as(client, user_id=3, gitea_login="carol", display_name="Carol", role="contributor")
|
||||
|
||||
# Omitted entirely.
|
||||
r = client.post("/api/rfcs/propose", json={
|
||||
"title": "No Use Case", "slug": "no-use-case", "pitch": "p", "tags": [],
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
pr_a = r.json()["pr_number"]
|
||||
|
||||
# Explicit null.
|
||||
r = client.post("/api/rfcs/propose", json={
|
||||
"title": "Null Use Case", "slug": "null-use-case", "pitch": "p",
|
||||
"tags": [], "proposed_use_case": None,
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
pr_b = r.json()["pr_number"]
|
||||
|
||||
# Blank/whitespace — treated as "left blank", no row written.
|
||||
r = client.post("/api/rfcs/propose", json={
|
||||
"title": "Blank Use Case", "slug": "blank-use-case", "pitch": "p",
|
||||
"tags": [], "proposed_use_case": " ",
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
pr_c = r.json()["pr_number"]
|
||||
|
||||
for pr in (pr_a, pr_b, pr_c):
|
||||
assert client.get(f"/api/proposals/{pr}").json()["proposed_use_case"] is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# propose-PR (against an active RFC)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_propose_pr_persists_and_returns_use_case(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
|
||||
branch, _ = _cut_branch_and_accept_change(
|
||||
client, fake, slug="ohm",
|
||||
original="Open Human Model is a framework for representing humans.",
|
||||
proposed="Open Human Model is a framework for representing humans across systems.",
|
||||
)
|
||||
r = client.post(
|
||||
f"/api/rfcs/ohm/branches/{branch}/open-pr",
|
||||
json={
|
||||
"title": "Tighten the opening",
|
||||
"description": "Scope to systems.",
|
||||
"proposed_use_case": "Building a cross-system consent registry.",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
pr_number = r.json()["pr_number"]
|
||||
|
||||
pr = client.get(f"/api/rfcs/ohm/prs/{pr_number}").json()
|
||||
assert pr["proposed_use_case"] == "Building a cross-system consent registry."
|
||||
|
||||
|
||||
def test_propose_pr_use_case_optional(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
|
||||
branch, _ = _cut_branch_and_accept_change(
|
||||
client, fake, slug="ohm",
|
||||
original="It defines consent, trait, and agency in compatible terms.",
|
||||
proposed="It defines consent, trait, harm, and agency in compatible terms.",
|
||||
)
|
||||
# No proposed_use_case key at all.
|
||||
r = client.post(
|
||||
f"/api/rfcs/ohm/branches/{branch}/open-pr",
|
||||
json={"title": "Add harm", "description": "Name harm explicitly."},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
pr_number = r.json()["pr_number"]
|
||||
|
||||
pr = client.get(f"/api/rfcs/ohm/prs/{pr_number}").json()
|
||||
assert pr["proposed_use_case"] is None
|
||||
@@ -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,273 @@
|
||||
"""Roadmap #28 Part 1 — auto-link RFC references in PR text + comments.
|
||||
|
||||
Two layers:
|
||||
|
||||
* Unit tests over the pure scanner (`rfc_links.segment_text` /
|
||||
`_keys_for` / `LinkIndex`) — the matching rules and their
|
||||
false-positive guards, no DB.
|
||||
* End-to-end tests that the PR description, PR review comments, and
|
||||
PR-less discussion comments all surface `*_segments` enriched against
|
||||
the live accepted-RFC corpus, with self-references suppressed.
|
||||
|
||||
Reuses the FakeGitea + session helpers from test_propose_vertical.py and
|
||||
the active-RFC seed from test_rfc_view_vertical.py.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from app import rfc_links
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
FakeGitea,
|
||||
app_with_fake_gitea,
|
||||
grant_rfc_collaborator,
|
||||
provision_user_row,
|
||||
sign_in_as,
|
||||
tmp_env,
|
||||
)
|
||||
from test_rfc_view_vertical import SEED_BODY, seed_active_rfc
|
||||
from test_pr_flow_vertical import _cut_branch_and_accept_change
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit — the pure scanner
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _idx(*terms):
|
||||
"""Build a LinkIndex from raw (key, slug, title) tuples (keys lower)."""
|
||||
return rfc_links.LinkIndex(list(terms))
|
||||
|
||||
|
||||
def test_empty_text_is_single_empty_segment():
|
||||
assert rfc_links.segment_text("", []) == [{"type": "text", "text": ""}]
|
||||
assert rfc_links.segment_text(None, []) == [{"type": "text", "text": ""}]
|
||||
|
||||
|
||||
def test_no_terms_returns_plain_text():
|
||||
out = rfc_links.segment_text("hello world", [])
|
||||
assert out == [{"type": "text", "text": "hello world"}]
|
||||
|
||||
|
||||
def test_multiword_title_links_and_preserves_casing():
|
||||
idx = _idx(("open human model", "open-human-model", "Open Human Model"))
|
||||
out = idx.segment("See the Open Human Model for details.")
|
||||
assert out == [
|
||||
{"type": "text", "text": "See the "},
|
||||
{"type": "rfc", "slug": "open-human-model", "label": "Open Human Model",
|
||||
"title": "Open Human Model"},
|
||||
{"type": "text", "text": " for details."},
|
||||
]
|
||||
|
||||
|
||||
def test_match_is_case_insensitive():
|
||||
idx = _idx(("open human model", "open-human-model", "Open Human Model"))
|
||||
out = idx.segment("see the OPEN HUMAN MODEL")
|
||||
assert out[-1] == {"type": "rfc", "slug": "open-human-model",
|
||||
"label": "OPEN HUMAN MODEL", "title": "Open Human Model"}
|
||||
|
||||
|
||||
def test_word_boundary_prevents_substring_match():
|
||||
# "harm" must not match inside "charming" / "harmless".
|
||||
idx = _idx(("rfc-0001", "open-human-model", "Open Human Model"))
|
||||
out = idx.segment("a charming rfc-00012 not real")
|
||||
# rfc-0001 is a prefix of rfc-00012 but the trailing '2' is a word char,
|
||||
# so no match — the whole string stays plain text.
|
||||
assert out == [{"type": "text", "text": "a charming rfc-00012 not real"}]
|
||||
|
||||
|
||||
def test_rfc_id_token_links():
|
||||
idx = _idx(("rfc-0001", "open-human-model", "Open Human Model"))
|
||||
out = idx.segment("as established in RFC-0001.")
|
||||
assert out[1] == {"type": "rfc", "slug": "open-human-model",
|
||||
"label": "RFC-0001", "title": "Open Human Model"}
|
||||
|
||||
|
||||
def test_longest_match_wins():
|
||||
# A bare "Open" term and the full title both present; the full title
|
||||
# (longer) must win at the position.
|
||||
idx = _idx(
|
||||
("open", "open", "Open"),
|
||||
("open human model", "open-human-model", "Open Human Model"),
|
||||
)
|
||||
out = idx.segment("the Open Human Model")
|
||||
assert out[-1]["slug"] == "open-human-model"
|
||||
assert out[-1]["label"] == "Open Human Model"
|
||||
|
||||
|
||||
def test_pending_term_emits_contribute_segment():
|
||||
# Part 3: a super-draft match is an `rfc-pending` segment carrying the
|
||||
# owner display name, not a plain link.
|
||||
idx = rfc_links.LinkIndex([
|
||||
rfc_links.Term(key="open human model", kind="pending",
|
||||
slug="open-human-model", title="Open Human Model", owner="Alice"),
|
||||
])
|
||||
out = idx.segment("see Open Human Model please")
|
||||
assert out[1] == {
|
||||
"type": "rfc-pending", "slug": "open-human-model",
|
||||
"label": "Open Human Model", "title": "Open Human Model", "owner": "Alice",
|
||||
}
|
||||
|
||||
|
||||
def test_candidate_term_emits_create_segment():
|
||||
# Part 2: a candidate term carries its canonical spelling for the
|
||||
# propose pre-fill; no slug (no RFC exists yet).
|
||||
idx = rfc_links.LinkIndex([
|
||||
rfc_links.Term(key="memory model", kind="candidate", term="Memory Model"),
|
||||
])
|
||||
out = idx.segment("the memory model is unspecified")
|
||||
assert out[1] == {"type": "rfc-candidate", "label": "memory model", "term": "Memory Model"}
|
||||
|
||||
|
||||
def test_kind_precedence_active_beats_pending_beats_candidate():
|
||||
# All three buckets contribute the same key; the highest-precedence
|
||||
# kind (active) must win at the position.
|
||||
key = "open human model"
|
||||
idx = rfc_links.LinkIndex([
|
||||
rfc_links.Term(key=key, kind="candidate", term="Open Human Model"),
|
||||
rfc_links.Term(key=key, kind="pending", slug="ohm-draft", title="Open Human Model", owner="A"),
|
||||
rfc_links.Term(key=key, kind="active", slug="open-human-model", title="Open Human Model"),
|
||||
])
|
||||
out = idx.segment("the Open Human Model")
|
||||
assert out[-1]["type"] == "rfc"
|
||||
assert out[-1]["slug"] == "open-human-model"
|
||||
|
||||
|
||||
def test_keys_for_gating():
|
||||
keys = lambda **kw: set(rfc_links._keys_for(**kw))
|
||||
# rfc_id always contributes.
|
||||
assert "rfc-0001" in keys(slug="x", title="X", rfc_id="RFC-0001")
|
||||
# multi-word title contributes; single common word does NOT.
|
||||
assert "open human model" in keys(slug="ohm", title="Open Human Model", rfc_id=None)
|
||||
assert keys(slug="human", title="Human", rfc_id=None) == set()
|
||||
# hyphenated slug contributes; single-token slug does NOT.
|
||||
assert "open-human-model" in keys(slug="open-human-model", title="X", rfc_id=None)
|
||||
assert "ohm" not in keys(slug="ohm", title="OHM", rfc_id=None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# End-to-end — enrichment surfaces on the read paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _open_pr_on(client, fake, *, host_slug: str, description: str):
|
||||
"""Seed branch + accepted change on host_slug and open a PR. Returns
|
||||
the pr_number."""
|
||||
# `original` must exist verbatim in SEED_BODY or the accept is "stale".
|
||||
branch, _ = _cut_branch_and_accept_change(
|
||||
client, fake, slug=host_slug,
|
||||
original="It defines consent, trait, and agency in compatible terms.",
|
||||
proposed="It defines consent, trait, harm, and agency in compatible terms.",
|
||||
)
|
||||
r = client.post(
|
||||
f"/api/rfcs/{host_slug}/branches/{branch}/open-pr",
|
||||
json={"title": "A change", "description": description},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["pr_number"]
|
||||
|
||||
|
||||
def _rfc_segments(segments):
|
||||
return [s for s in segments if s["type"] == "rfc"]
|
||||
|
||||
|
||||
def test_pr_description_autolinks_other_rfc(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
# Two accepted RFCs: a host for the PR + a referenceable target.
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
seed_active_rfc(fake, slug="open-human-model", title="Open Human Model", body=SEED_BODY)
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
|
||||
pr_number = _open_pr_on(
|
||||
client, fake, host_slug="ohm",
|
||||
description="This builds on the Open Human Model definition.",
|
||||
)
|
||||
r = client.get(f"/api/rfcs/ohm/prs/{pr_number}")
|
||||
assert r.status_code == 200, r.text
|
||||
pr = r.json()
|
||||
links = _rfc_segments(pr["description_segments"])
|
||||
assert len(links) == 1
|
||||
assert links[0]["slug"] == "open-human-model"
|
||||
assert links[0]["label"] == "Open Human Model"
|
||||
# The plain text is still present for non-segment callers (the bot
|
||||
# appends a §6.5 On-behalf-of trailer, so this is a containment check).
|
||||
assert "This builds on the Open Human Model definition." in pr["description"]
|
||||
|
||||
|
||||
def test_pr_review_comment_autolinked(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
seed_active_rfc(fake, slug="open-human-model", title="Open Human Model", body=SEED_BODY)
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
|
||||
pr_number = _open_pr_on(client, fake, host_slug="ohm", description="plain.")
|
||||
r = client.post(
|
||||
f"/api/rfcs/ohm/prs/{pr_number}/review",
|
||||
json={"text": "See Open Human Model and RFC-0001.", "anchor_payload": {}},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
pr = client.get(f"/api/rfcs/ohm/prs/{pr_number}").json()
|
||||
all_msgs = [m for msgs in pr["messages_by_thread"].values() for m in msgs]
|
||||
review_msgs = [m for m in all_msgs if "Open Human Model" in (m["text"] or "")]
|
||||
assert review_msgs, "review comment not found in payload"
|
||||
links = _rfc_segments(review_msgs[0]["text_segments"])
|
||||
# Both "Open Human Model" (title) and "RFC-0001" (id) point to the
|
||||
# one referenceable RFC.
|
||||
assert {s["slug"] for s in links} == {"open-human-model"}
|
||||
assert {s["label"] for s in links} == {"Open Human Model", "RFC-0001"}
|
||||
|
||||
|
||||
def test_discussion_comment_autolinked(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
seed_active_rfc(fake, slug="open-human-model", title="Open Human Model", body=SEED_BODY)
|
||||
# alice is the seeded owner of ohm (owners=["alice"]); grant the
|
||||
# per-RFC collaborator row explicitly so the #12 discuss gate passes.
|
||||
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")
|
||||
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/discussion/threads",
|
||||
json={"message": "Compare with the Open Human Model."},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
thread_id = r.json()["thread_id"]
|
||||
|
||||
r = client.get(f"/api/rfcs/ohm/discussion/threads/{thread_id}/messages")
|
||||
assert r.status_code == 200, r.text
|
||||
msgs = r.json()["messages"]
|
||||
assert msgs and "text_segments" in msgs[0]
|
||||
links = _rfc_segments(msgs[0]["text_segments"])
|
||||
assert len(links) == 1
|
||||
assert links[0]["slug"] == "open-human-model"
|
||||
|
||||
|
||||
def test_self_reference_not_linked(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
# The host RFC has a multi-word title, so absent exclude_slug it
|
||||
# WOULD self-link. exclude_slug must suppress it.
|
||||
seed_active_rfc(fake, slug="open-human-model", title="Open Human Model", body=SEED_BODY)
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
|
||||
pr_number = _open_pr_on(
|
||||
client, fake, host_slug="open-human-model",
|
||||
description="Refines the Open Human Model definition.",
|
||||
)
|
||||
pr = client.get(f"/api/rfcs/open-human-model/prs/{pr_number}").json()
|
||||
assert _rfc_segments(pr["description_segments"]) == []
|
||||
@@ -0,0 +1,140 @@
|
||||
"""End-to-end integration tests for the v0.23.0 sign-in state-resume
|
||||
vertical (§6.2, roadmap item #29).
|
||||
|
||||
New behavior: each authenticated user's last-viewed route + a small bag
|
||||
of light view state is tracked server-side, so the next sign-in can land
|
||||
them back where they left off rather than on the empty-state home view.
|
||||
|
||||
The tests below prove:
|
||||
|
||||
* `PUT /api/me/last-state` requires auth — an anonymous client gets
|
||||
401, and nothing is stored.
|
||||
* An authenticated PUT upserts the route, and the stored route is
|
||||
read back for that user off `GET /api/auth/me` (`last_route`).
|
||||
* A second PUT overwrites (upsert, one row per user) — the latest
|
||||
route wins.
|
||||
* `last_route_state` round-trips as decoded JSON on `/api/auth/me`.
|
||||
* Per-user isolation: user A's stored route is not visible to user B.
|
||||
* `resume_enabled = 0` disables resume: the PUT no-ops (does not
|
||||
rewrite the stored route) and `/api/auth/me` hands back a null
|
||||
`last_route` even though a stored row exists.
|
||||
|
||||
The fakes from `test_propose_vertical` give us a working app harness +
|
||||
the `sign_in_as` / `provision_user_row` seams.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
FakeGitea,
|
||||
app_with_fake_gitea,
|
||||
provision_user_row,
|
||||
sign_in_as,
|
||||
tmp_env,
|
||||
)
|
||||
|
||||
|
||||
def test_put_last_state_requires_auth(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
# Anonymous — no session cookie set.
|
||||
r = client.put("/api/me/last-state", json={"route": "/rfc/open-human-model"})
|
||||
assert r.status_code == 401
|
||||
# Nothing landed in the table.
|
||||
row = db.conn().execute("SELECT COUNT(*) AS n FROM user_session_state").fetchone()
|
||||
assert row["n"] == 0
|
||||
|
||||
|
||||
def test_put_last_state_upserts_and_reads_back(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor", email="alice@test")
|
||||
|
||||
# First POST stores a route + light state.
|
||||
r = client.put(
|
||||
"/api/me/last-state",
|
||||
json={"route": "/rfc/open-human-model", "state": {"tab": "discussion", "scroll": 420}},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["stored"] is True
|
||||
|
||||
# /api/auth/me hands the route + decoded state back.
|
||||
me = client.get("/api/auth/me").json()
|
||||
assert me["authenticated"] is True
|
||||
assert me["user"]["resume_enabled"] is True
|
||||
assert me["user"]["last_route"] == "/rfc/open-human-model"
|
||||
assert me["user"]["last_route_state"] == {"tab": "discussion", "scroll": 420}
|
||||
|
||||
# A later POST overwrites — one row per user, latest wins.
|
||||
r = client.put("/api/me/last-state", json={"route": "/proposals/7"})
|
||||
assert r.status_code == 200
|
||||
me = client.get("/api/auth/me").json()
|
||||
assert me["user"]["last_route"] == "/proposals/7"
|
||||
# state was omitted on the second POST → cleared to null.
|
||||
assert me["user"]["last_route_state"] is None
|
||||
|
||||
|
||||
def test_last_state_is_per_user(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
provision_user_row(user_id=3, login="bob", role="contributor")
|
||||
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor", email="alice@test")
|
||||
client.put("/api/me/last-state", json={"route": "/rfc/alice-route"})
|
||||
|
||||
# Switch to bob — he has no stored route yet.
|
||||
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob", role="contributor", email="bob@test")
|
||||
me = client.get("/api/auth/me").json()
|
||||
assert me["user"]["last_route"] is None
|
||||
|
||||
client.put("/api/me/last-state", json={"route": "/rfc/bob-route"})
|
||||
me = client.get("/api/auth/me").json()
|
||||
assert me["user"]["last_route"] == "/rfc/bob-route"
|
||||
|
||||
# Back to alice — her route is untouched by bob's write.
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor", email="alice@test")
|
||||
me = client.get("/api/auth/me").json()
|
||||
assert me["user"]["last_route"] == "/rfc/alice-route"
|
||||
|
||||
|
||||
def test_resume_disabled_no_ops_put_and_hides_route(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor", email="alice@test")
|
||||
|
||||
# Seed a stored row, then flip resume_enabled off directly (the
|
||||
# profile-settings toggle UI to do this from the client is a
|
||||
# follow-up; the column + behavior ship now).
|
||||
client.put("/api/me/last-state", json={"route": "/rfc/before-disable"})
|
||||
db.conn().execute(
|
||||
"UPDATE user_session_state SET resume_enabled = 0 WHERE user_id = ?",
|
||||
(2,),
|
||||
)
|
||||
|
||||
# /api/auth/me reports resume off and hands back a null route
|
||||
# even though a stored row exists.
|
||||
me = client.get("/api/auth/me").json()
|
||||
assert me["user"]["resume_enabled"] is False
|
||||
assert me["user"]["last_route"] is None
|
||||
|
||||
# A PUT while disabled no-ops: stored=False and the stored route
|
||||
# is NOT rewritten.
|
||||
r = client.put("/api/me/last-state", json={"route": "/rfc/after-disable"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["stored"] is False
|
||||
row = db.conn().execute(
|
||||
"SELECT last_route FROM user_session_state WHERE user_id = ?", (2,)
|
||||
).fetchone()
|
||||
assert row["last_route"] == "/rfc/before-disable"
|
||||
@@ -0,0 +1,264 @@
|
||||
"""Vertical + unit coverage for roadmap #27 (rfc-app v0.24.0): Claude
|
||||
Haiku tag suggestions on the propose-RFC modal.
|
||||
|
||||
Reuses the FakeGitea + session helpers from test_propose_vertical.py.
|
||||
The Anthropic call is never made for real — tests monkeypatch the
|
||||
`tag_suggest.haiku_provider` seam with a stub provider whose `send`
|
||||
returns canned text, so the HTTP contract is exercised without a key.
|
||||
|
||||
Proves:
|
||||
(a) the endpoint is contributor-gated (anon → 401);
|
||||
(b) a contributor gets suggestions, filtered to the corpus tag
|
||||
universe, with invented tags dropped;
|
||||
(c) no Anthropic key bound → empty list, not an error;
|
||||
(d) an empty corpus → empty list (model is never even called);
|
||||
(e) the per-user rate limit surfaces as a 429;
|
||||
plus unit coverage of the universe gather, the reply parser's tolerance,
|
||||
and the suggest() orchestration short-circuits.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
FakeGitea,
|
||||
app_with_fake_gitea,
|
||||
provision_user_row,
|
||||
sign_in_as,
|
||||
tmp_env,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class StubProvider:
|
||||
"""A BaseProvider stand-in whose send() returns a fixed string (or
|
||||
raises, to exercise the graceful-failure path)."""
|
||||
|
||||
def __init__(self, reply: str = "[]", *, raises: bool = False):
|
||||
self.reply = reply
|
||||
self.raises = raises
|
||||
self.calls: list[tuple[str, list]] = []
|
||||
|
||||
def send(self, system, history):
|
||||
self.calls.append((system, history))
|
||||
if self.raises:
|
||||
raise RuntimeError("boom")
|
||||
return self.reply
|
||||
|
||||
|
||||
def _seed_tags(slug: str, title: str, tags: list[str], state: str = "active") -> None:
|
||||
from app import db
|
||||
|
||||
db.conn().execute(
|
||||
"INSERT OR REPLACE INTO cached_rfcs (slug, title, state, tags_json) VALUES (?, ?, ?, ?)",
|
||||
(slug, title, state, json.dumps(tags)),
|
||||
)
|
||||
|
||||
|
||||
def _use_provider(monkeypatch, provider) -> None:
|
||||
monkeypatch.setattr("app.tag_suggest.haiku_provider", lambda config: provider)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_rate_limits():
|
||||
from app import tag_suggest
|
||||
|
||||
tag_suggest.reset_rate_limits()
|
||||
yield
|
||||
tag_suggest.reset_rate_limits()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoint (vertical)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_anonymous_cannot_suggest(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
r = client.post("/api/rfcs/suggest-tags", json={"title": "X"})
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_contributor_gets_filtered_suggestions(app_with_fake_gitea, monkeypatch):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
|
||||
_seed_tags("ohm", "OHM", ["identity", "schema", "consent"])
|
||||
_seed_tags("other", "Other", ["identity", "governance"])
|
||||
|
||||
# Model returns two real tags (one lowercased to test canonical
|
||||
# mapping is exact-set anyway), plus one invented tag that MUST
|
||||
# be dropped.
|
||||
reply = json.dumps([
|
||||
{"tag": "identity", "confidence": 0.9},
|
||||
{"tag": "consent", "confidence": 0.7},
|
||||
{"tag": "totally-invented", "confidence": 0.99},
|
||||
])
|
||||
stub = StubProvider(reply=reply)
|
||||
_use_provider(monkeypatch, stub)
|
||||
|
||||
r = client.post("/api/rfcs/suggest-tags", json={
|
||||
"title": "Consent and identity",
|
||||
"pitch": "We need a shared definition of consent tied to identity.",
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
tags = [s["tag"] for s in r.json()["suggestions"]]
|
||||
assert tags == ["identity", "consent"]
|
||||
# the model was actually invoked
|
||||
assert len(stub.calls) == 1
|
||||
# the universe (deduped distinct tags) was handed to the model
|
||||
user_msg = stub.calls[0][1][0]["content"]
|
||||
assert "identity" in user_msg and "governance" in user_msg
|
||||
|
||||
|
||||
def test_no_api_key_returns_empty(app_with_fake_gitea, monkeypatch):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
_seed_tags("ohm", "OHM", ["identity"])
|
||||
|
||||
# No key bound (test env has no ANTHROPIC_API_KEY) → provider None.
|
||||
# (Explicitly assert the seam returns None given the test config.)
|
||||
from app import tag_suggest
|
||||
assert tag_suggest.haiku_provider(app.state.config) is None
|
||||
|
||||
r = client.post("/api/rfcs/suggest-tags", json={"title": "X", "pitch": "y"})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["suggestions"] == []
|
||||
|
||||
|
||||
def test_empty_corpus_returns_empty_without_calling_model(app_with_fake_gitea, monkeypatch):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
|
||||
stub = StubProvider(reply=json.dumps([{"tag": "x", "confidence": 1}]))
|
||||
_use_provider(monkeypatch, stub)
|
||||
|
||||
# No cached_rfcs rows → empty universe → suggest() short-circuits.
|
||||
r = client.post("/api/rfcs/suggest-tags", json={"title": "X", "pitch": "y"})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["suggestions"] == []
|
||||
assert stub.calls == [] # model never invoked on an empty universe
|
||||
|
||||
|
||||
def test_rate_limit_surfaces_429(app_with_fake_gitea, monkeypatch):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
monkeypatch.setenv("TAG_SUGGEST_RATE_MAX", "2")
|
||||
monkeypatch.setenv("TAG_SUGGEST_RATE_WINDOW_SECONDS", "60")
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
_seed_tags("ohm", "OHM", ["identity"])
|
||||
_use_provider(monkeypatch, StubProvider(reply="[]"))
|
||||
|
||||
body = {"title": "X", "pitch": "y"}
|
||||
assert client.post("/api/rfcs/suggest-tags", json=body).status_code == 200
|
||||
assert client.post("/api/rfcs/suggest-tags", json=body).status_code == 200
|
||||
# Third call inside the window trips the limit.
|
||||
assert client.post("/api/rfcs/suggest-tags", json=body).status_code == 429
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Units
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_gather_tag_universe_dedupes_and_ranks(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import tag_suggest
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
# The db is initialized in the app's lifespan; enter the client
|
||||
# context so cached_rfcs exists before we seed it directly.
|
||||
with TestClient(app):
|
||||
_seed_tags("a", "A", ["identity", "schema"])
|
||||
_seed_tags("b", "B", ["identity", " schema ", "consent", ""]) # whitespace + empty
|
||||
_seed_tags("c", "C", ["identity"])
|
||||
|
||||
universe = tag_suggest.gather_tag_universe()
|
||||
# identity (3) > schema (2) > consent (1); whitespace trimmed/merged,
|
||||
# empties dropped.
|
||||
assert universe == ["identity", "schema", "consent"]
|
||||
|
||||
|
||||
def test_parse_reply_tolerates_junk():
|
||||
from app import tag_suggest
|
||||
|
||||
universe = ["identity", "schema", "consent"]
|
||||
|
||||
# Prose around the JSON, an invented tag, a bare string, a dup, a
|
||||
# missing confidence, and a garbage confidence.
|
||||
text = (
|
||||
"Sure! Here are the tags:\n"
|
||||
'[{"tag": "identity", "confidence": 0.9}, '
|
||||
'{"tag": "invented", "confidence": 1}, '
|
||||
'"schema", '
|
||||
'{"tag": "identity", "confidence": 0.5}, '
|
||||
'{"tag": "consent"}, '
|
||||
'{"tag": "consent", "confidence": "high"}]\n'
|
||||
"Hope that helps!"
|
||||
)
|
||||
out = tag_suggest.parse_reply(text, universe, max_suggestions=6)
|
||||
tags = [s["tag"] for s in out]
|
||||
assert tags == ["identity", "schema", "consent"] # invented dropped, deduped
|
||||
by_tag = {s["tag"]: s["confidence"] for s in out}
|
||||
assert by_tag["identity"] == 0.9
|
||||
assert by_tag["schema"] == 0.5 # bare string defaults to 0.5
|
||||
assert by_tag["consent"] == 0.5 # missing/garbage confidence → 0.5
|
||||
|
||||
|
||||
def test_parse_reply_empty_on_unparseable():
|
||||
from app import tag_suggest
|
||||
|
||||
assert tag_suggest.parse_reply("no json here", ["a"], 6) == []
|
||||
assert tag_suggest.parse_reply("", ["a"], 6) == []
|
||||
assert tag_suggest.parse_reply("[]", ["a"], 6) == []
|
||||
|
||||
|
||||
def test_parse_reply_respects_max():
|
||||
from app import tag_suggest
|
||||
|
||||
universe = ["a", "b", "c", "d", "e"]
|
||||
text = json.dumps([{"tag": t, "confidence": 0.5} for t in universe])
|
||||
out = tag_suggest.parse_reply(text, universe, max_suggestions=3)
|
||||
assert [s["tag"] for s in out] == ["a", "b", "c"]
|
||||
|
||||
|
||||
def test_suggest_short_circuits_empty_draft():
|
||||
from app import tag_suggest
|
||||
|
||||
stub = StubProvider(reply=json.dumps([{"tag": "a", "confidence": 1}]))
|
||||
draft = tag_suggest.Draft(title=" ", pitch="", use_case="")
|
||||
assert tag_suggest.suggest(stub, draft, ["a"]) == []
|
||||
assert stub.calls == [] # never called for an empty draft
|
||||
|
||||
|
||||
def test_suggest_returns_empty_on_provider_failure():
|
||||
from app import tag_suggest
|
||||
|
||||
stub = StubProvider(raises=True)
|
||||
draft = tag_suggest.Draft(title="Real title", pitch="a reason")
|
||||
assert tag_suggest.suggest(stub, draft, ["identity"]) == []
|
||||
@@ -0,0 +1,251 @@
|
||||
"""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 `turnstile._siteverify_post` with an async stub that
|
||||
returns the requested success shape. The stub does not touch the
|
||||
real CloudFlare endpoint and never sees a real secret.
|
||||
|
||||
I4 (security-audit-0026): the siteverify call is now awaited on an
|
||||
`httpx.AsyncClient`, isolated behind the `_siteverify_post` seam.
|
||||
Patching that narrow function (rather than the shared
|
||||
`httpx.AsyncClient`, which gitea/docs also construct) keeps app boot
|
||||
intact.
|
||||
"""
|
||||
captured = {}
|
||||
|
||||
async def fake_post(url, data):
|
||||
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, "_siteverify_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 siteverify call inside turnstile must not be made in this path —
|
||||
# patch the seam to a sentinel that explodes if it ever runs
|
||||
# (I4: the seam is now `_siteverify_post`, not module-level httpx.post).
|
||||
from app import turnstile as turnstile_mod
|
||||
|
||||
async def must_not_be_called(*a, **kw):
|
||||
raise AssertionError("siteverify should not run when no secret is configured")
|
||||
|
||||
monkeypatch.setattr(turnstile_mod, "_siteverify_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") == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# I4 (security-audit-0026): verify_token is a coroutine — calling it returns
|
||||
# an awaitable, not a VerifyOutcome. Locks the async contract so a revert to
|
||||
# the synchronous event-loop-blocking shape fails here, not just in the
|
||||
# integration paths.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_verify_token_is_async_and_soft_skips_without_secret(monkeypatch):
|
||||
import asyncio
|
||||
|
||||
from app import turnstile as turnstile_mod
|
||||
|
||||
monkeypatch.delenv("CLOUDFLARE_TURNSTILE_SECRET", raising=False)
|
||||
monkeypatch.delenv("TURNSTILE_REQUIRED", raising=False)
|
||||
|
||||
coro = turnstile_mod.verify_token("any-token")
|
||||
assert asyncio.iscoroutine(coro), "verify_token must be a coroutine (I4)"
|
||||
outcome = asyncio.run(coro)
|
||||
# No secret + not required → the gate stays open without any network call.
|
||||
assert outcome.ok is True
|
||||
assert outcome.reason == "skipped"
|
||||
@@ -0,0 +1,205 @@
|
||||
"""End-to-end integration tests for the Gitea webhook receiver
|
||||
(v0.18.0 Slice 3 — webhook tightening per the email + webhook
|
||||
hygiene proposal).
|
||||
|
||||
The release changes the receiver from "verifies the signature only
|
||||
when a secret is configured; silently accepts unsigned POSTs
|
||||
otherwise" to "requires the secret unless `RFC_APP_INSECURE_WEBHOOKS=1`
|
||||
is set as an explicit dev-bypass." The startup-time check lives in
|
||||
`config.load_config()`; the request-time check lives in
|
||||
`webhooks.receive`.
|
||||
|
||||
These tests prove:
|
||||
|
||||
* The framework refuses to start when `GITEA_WEBHOOK_SECRET` is
|
||||
empty and the dev-bypass is not set.
|
||||
* The dev-bypass (`RFC_APP_INSECURE_WEBHOOKS=1`) lets the
|
||||
framework boot with an empty secret AND lets webhook POSTs
|
||||
land without signature verification (a loud-warning log line
|
||||
surfaces, but the request is accepted).
|
||||
* Default path (secret bound): a POST with a valid signature
|
||||
lands; a POST with an invalid signature gets 401; a POST with
|
||||
no signature gets 401.
|
||||
* Unknown-repo POSTs surface in the log (the "stale Gitea hook"
|
||||
case the proposal targets).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
FakeGitea,
|
||||
app_with_fake_gitea,
|
||||
tmp_env,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Startup-time secret check (config.load_config)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_config_refuses_to_load_with_empty_secret_and_no_bypass(monkeypatch, tmp_path):
|
||||
"""The framework MUST refuse to start when `GITEA_WEBHOOK_SECRET`
|
||||
is empty unless `RFC_APP_INSECURE_WEBHOOKS=1` is set. This is
|
||||
the v0.18.0 startup-loud-failure shape — silent acceptance was
|
||||
the bug."""
|
||||
monkeypatch.setenv("GITEA_URL", "http://gitea.test")
|
||||
monkeypatch.setenv("GITEA_BOT_USER", "rfc-bot")
|
||||
monkeypatch.setenv("GITEA_BOT_TOKEN", "bot-token")
|
||||
monkeypatch.setenv("GITEA_ORG", "wiggleverse")
|
||||
monkeypatch.setenv("OAUTH_CLIENT_ID", "cid")
|
||||
monkeypatch.setenv("OAUTH_CLIENT_SECRET", "csec")
|
||||
monkeypatch.setenv("SECRET_KEY", "test-secret-key")
|
||||
monkeypatch.setenv("DATABASE_PATH", str(tmp_path / "test.db"))
|
||||
monkeypatch.setenv("GITEA_WEBHOOK_SECRET", "")
|
||||
monkeypatch.delenv("RFC_APP_INSECURE_WEBHOOKS", raising=False)
|
||||
|
||||
from app.config import load_config
|
||||
with pytest.raises(RuntimeError, match="GITEA_WEBHOOK_SECRET"):
|
||||
load_config()
|
||||
|
||||
|
||||
def test_config_loads_with_empty_secret_when_bypass_is_set(monkeypatch, tmp_path):
|
||||
"""The explicit `RFC_APP_INSECURE_WEBHOOKS=1` opt-in lets the
|
||||
framework boot with an empty webhook secret. This is the
|
||||
local-dev escape hatch."""
|
||||
monkeypatch.setenv("GITEA_URL", "http://gitea.test")
|
||||
monkeypatch.setenv("GITEA_BOT_USER", "rfc-bot")
|
||||
monkeypatch.setenv("GITEA_BOT_TOKEN", "bot-token")
|
||||
monkeypatch.setenv("GITEA_ORG", "wiggleverse")
|
||||
monkeypatch.setenv("OAUTH_CLIENT_ID", "cid")
|
||||
monkeypatch.setenv("OAUTH_CLIENT_SECRET", "csec")
|
||||
monkeypatch.setenv("SECRET_KEY", "test-secret-key")
|
||||
monkeypatch.setenv("DATABASE_PATH", str(tmp_path / "test.db"))
|
||||
monkeypatch.setenv("GITEA_WEBHOOK_SECRET", "")
|
||||
monkeypatch.setenv("RFC_APP_INSECURE_WEBHOOKS", "1")
|
||||
|
||||
from app.config import load_config
|
||||
cfg = load_config() # MUST NOT raise
|
||||
assert cfg.webhook_secret == ""
|
||||
|
||||
|
||||
def test_config_loads_with_secret_set(monkeypatch, tmp_path):
|
||||
"""Sanity: the happy path (secret bound, bypass not set) loads
|
||||
cleanly."""
|
||||
monkeypatch.setenv("GITEA_URL", "http://gitea.test")
|
||||
monkeypatch.setenv("GITEA_BOT_USER", "rfc-bot")
|
||||
monkeypatch.setenv("GITEA_BOT_TOKEN", "bot-token")
|
||||
monkeypatch.setenv("GITEA_ORG", "wiggleverse")
|
||||
monkeypatch.setenv("OAUTH_CLIENT_ID", "cid")
|
||||
monkeypatch.setenv("OAUTH_CLIENT_SECRET", "csec")
|
||||
monkeypatch.setenv("SECRET_KEY", "test-secret-key")
|
||||
monkeypatch.setenv("DATABASE_PATH", str(tmp_path / "test.db"))
|
||||
monkeypatch.setenv("GITEA_WEBHOOK_SECRET", "my-real-secret")
|
||||
monkeypatch.delenv("RFC_APP_INSECURE_WEBHOOKS", raising=False)
|
||||
|
||||
from app.config import load_config
|
||||
cfg = load_config()
|
||||
assert cfg.webhook_secret == "my-real-secret"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request-time signature verification (webhooks.receive)
|
||||
#
|
||||
# The default `app_with_fake_gitea` fixture binds
|
||||
# `GITEA_WEBHOOK_SECRET=test-webhook-secret-for-signature-verification`,
|
||||
# so these tests exercise the production path.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_SECRET = "test-webhook-secret-for-signature-verification"
|
||||
|
||||
|
||||
def _sign(body: bytes) -> str:
|
||||
return hmac.new(_SECRET.encode("utf-8"), body, hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def test_webhook_post_with_valid_signature_accepted(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
body = json.dumps({"repository": {"full_name": "wiggleverse/meta"}}).encode()
|
||||
sig = _sign(body)
|
||||
r = client.post(
|
||||
"/api/webhooks/gitea",
|
||||
content=body,
|
||||
headers={
|
||||
"X-Gitea-Event": "push",
|
||||
"X-Gitea-Signature": sig,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
def test_webhook_post_with_invalid_signature_refused_401(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
body = json.dumps({"repository": {"full_name": "wiggleverse/meta"}}).encode()
|
||||
r = client.post(
|
||||
"/api/webhooks/gitea",
|
||||
content=body,
|
||||
headers={
|
||||
"X-Gitea-Event": "push",
|
||||
"X-Gitea-Signature": "0" * 64, # wrong signature
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_webhook_post_with_missing_signature_refused_401(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
body = json.dumps({"repository": {"full_name": "wiggleverse/meta"}}).encode()
|
||||
r = client.post(
|
||||
"/api/webhooks/gitea",
|
||||
content=body,
|
||||
headers={
|
||||
"X-Gitea-Event": "push",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unknown-repo logging (the "stale hook on a fork" surface)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_webhook_unknown_repo_logs_at_info(app_with_fake_gitea, caplog):
|
||||
"""Per the proposal: a hook on a fork or a stale Gitea binding
|
||||
used to silently 200-OK. v0.18.0 surfaces it as an INFO log."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
body = json.dumps({"repository": {"full_name": "someone-else/unrelated"}}).encode()
|
||||
sig = _sign(body)
|
||||
with caplog.at_level(logging.INFO, logger="app.webhooks"):
|
||||
r = client.post(
|
||||
"/api/webhooks/gitea",
|
||||
content=body,
|
||||
headers={
|
||||
"X-Gitea-Event": "push",
|
||||
"X-Gitea-Signature": sig,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200 # the handler still 200s; surface is the log line
|
||||
assert any(
|
||||
"unknown repo" in rec.message and "someone-else/unrelated" in rec.message
|
||||
for rec in caplog.records
|
||||
), f"expected unknown-repo log line; got: {[r.message for r in caplog.records]}"
|
||||
@@ -1,7 +1,7 @@
|
||||
# RFC App — Deployment Reference & New-Session Prompt
|
||||
|
||||
Use this document as:
|
||||
1. A reference for the current `rfc.wiggleverse.org` deployment
|
||||
1. A reference for the current `ohm.wiggleverse.org` deployment
|
||||
2. A prompt to paste into a new Claude session to deploy a new version
|
||||
|
||||
---
|
||||
@@ -36,10 +36,10 @@ For reference, the separate Gitea VM is `wiggleverse` project / `gitea` VM / 34.
|
||||
|
||||
| Record | Type | Value | Proxy |
|
||||
|--------|------|-------|-------|
|
||||
| `rfc.wiggleverse.org` | A | 34.132.29.41 | DNS-only (gray cloud) |
|
||||
| `ohm.wiggleverse.org` | A | 34.132.29.41 | DNS-only (gray cloud) |
|
||||
| `_dmarc.wiggleverse.org` | TXT | `v=DMARC1; p=none; rua=mailto:ben@wiggleverse.org` | n/a |
|
||||
|
||||
> Note: `rfc.wiggleverse.org` uses **Let's Encrypt via certbot** directly on the VM. Keep the A record **DNS only (gray cloud)** — Cloudflare Flexible SSL would conflict with certbot.
|
||||
> Note: `ohm.wiggleverse.org` uses **Let's Encrypt via certbot** directly on the VM. Keep the A record **DNS only (gray cloud)** — Cloudflare Flexible SSL would conflict with certbot.
|
||||
|
||||
SPF (`v=spf1 include:_spf.google.com ~all`) and DKIM (`google._domainkey`) for `wiggleverse.org` are already in place via Workspace.
|
||||
|
||||
@@ -64,7 +64,7 @@ SPF (`v=spf1 include:_spf.google.com ~all`) and DKIM (`google._domainkey`) for `
|
||||
| `/opt/rfc-app/backend/.env` | All secrets and config (mode 0600) |
|
||||
| `/opt/rfc-app/backend/data/rfc-app.db` | SQLite database |
|
||||
| `/opt/rfc-app/frontend/dist/` | Built React SPA (served by nginx) |
|
||||
| `/etc/nginx/sites-available/rfc.wiggleverse.org` | nginx vhost config |
|
||||
| `/etc/nginx/sites-available/ohm.wiggleverse.org` | nginx vhost config |
|
||||
| `/etc/systemd/system/rfc-app.service` | systemd unit |
|
||||
|
||||
---
|
||||
@@ -79,7 +79,7 @@ SPF (`v=spf1 include:_spf.google.com ~all`) and DKIM (`google._domainkey`) for `
|
||||
|
||||
## Gitea Setup (one-time)
|
||||
|
||||
These are already done for `rfc.wiggleverse.org`. Document here for replication.
|
||||
These are already done for `ohm.wiggleverse.org`. Document here for replication.
|
||||
|
||||
### Bot service account
|
||||
|
||||
@@ -91,13 +91,13 @@ Created in Gitea as `rfc-bot`. Token scopes: `write:repository`, `write:user`, `
|
||||
|
||||
### Meta repo
|
||||
|
||||
`wiggleverse/meta` — seeded by `scripts/seed_meta_repo.py`. Contains `PHILOSOPHY.md`, `README.md`, `CONTRIBUTING.md`, and `rfcs/` directory. Gitea webhook registered to `https://rfc.wiggleverse.org/api/webhooks/gitea`.
|
||||
`wiggleverse/meta` — seeded by `scripts/seed_meta_repo.py`. Contains `PHILOSOPHY.md`, `README.md`, `CONTRIBUTING.md`, and `rfcs/` directory. Gitea webhook registered to `https://ohm.wiggleverse.org/api/webhooks/gitea`.
|
||||
|
||||
### OAuth2 app
|
||||
|
||||
Registered in Gitea Site Administration → Integrations → OAuth2 Applications:
|
||||
- Name: `RFC App`
|
||||
- Redirect URI: `https://rfc.wiggleverse.org/auth/callback`
|
||||
- Redirect URI: `https://ohm.wiggleverse.org/auth/callback`
|
||||
- Client ID and secret stored in `.env`
|
||||
|
||||
---
|
||||
@@ -133,7 +133,7 @@ OAUTH_CLIENT_ID=<from Gitea OAuth app>
|
||||
OAUTH_CLIENT_SECRET=<from Gitea OAuth app>
|
||||
|
||||
# App
|
||||
APP_URL=https://rfc.wiggleverse.org
|
||||
APP_URL=https://ohm.wiggleverse.org
|
||||
SECRET_KEY=<openssl rand -hex 32>
|
||||
DATABASE_PATH=/opt/rfc-app/backend/data/rfc-app.db
|
||||
OWNER_GITEA_LOGIN=ben.stull
|
||||
@@ -182,10 +182,16 @@ sudo systemctl restart rfc-app
|
||||
|
||||
For frontend changes, build on the VM directly (Node 20+ is already there):
|
||||
```bash
|
||||
cd /opt/rfc-app/frontend && sudo -u rfc-app npm install
|
||||
cd /opt/rfc-app/frontend && sudo -u rfc-app npm ci
|
||||
sudo -u rfc-app npm run build
|
||||
```
|
||||
|
||||
`npm ci` installs strictly from the committed `package-lock.json` and
|
||||
will not regenerate it. Using `npm install` here causes the VM's npm
|
||||
to rewrite the lockfile in place (notably stripping `libc` fields on
|
||||
optional rollup native packages), which then conflicts with `git
|
||||
checkout <tag>` on the next deploy.
|
||||
|
||||
The output lands in `/opt/rfc-app/frontend/dist/` owned by `rfc-app` — nginx serves it directly, no copy step needed.
|
||||
|
||||
(Building locally and `gcloud compute scp`-ing the dist also works. Plain `rsync -e ssh` from the Mac fails because OS Login uses short-lived SSH certs that only the gcloud wrapper can mint interactively.)
|
||||
@@ -197,7 +203,7 @@ Schema migrations run automatically on restart (append-only, safe to re-run).
|
||||
## First-Time Deployment (new server)
|
||||
|
||||
### 1. Add DNS record
|
||||
Add `rfc.wiggleverse.org` → 34.132.29.41 as an A record in Cloudflare, **DNS only (gray cloud)**. Do not proxy — certbot needs to reach the VM directly.
|
||||
Add `ohm.wiggleverse.org` → 34.132.29.41 as an A record in Cloudflare, **DNS only (gray cloud)**. Do not proxy — certbot needs to reach the VM directly.
|
||||
|
||||
### 2. Host prep
|
||||
```bash
|
||||
@@ -230,15 +236,15 @@ sudo -u rfc-app -H bash -c \
|
||||
|
||||
### 6. Build the frontend (on the VM)
|
||||
```bash
|
||||
cd /opt/rfc-app/frontend && sudo -u rfc-app npm install
|
||||
cd /opt/rfc-app/frontend && sudo -u rfc-app npm ci
|
||||
sudo -u rfc-app npm run build
|
||||
```
|
||||
|
||||
### 7. nginx
|
||||
```bash
|
||||
sudo cp /opt/rfc-app/deploy/nginx/rfc.wiggleverse.org.conf \
|
||||
/etc/nginx/sites-available/rfc.wiggleverse.org
|
||||
sudo ln -s /etc/nginx/sites-available/rfc.wiggleverse.org \
|
||||
sudo cp /opt/rfc-app/deploy/nginx/ohm.wiggleverse.org.conf \
|
||||
/etc/nginx/sites-available/ohm.wiggleverse.org
|
||||
sudo ln -s /etc/nginx/sites-available/ohm.wiggleverse.org \
|
||||
/etc/nginx/sites-enabled/
|
||||
sudo usermod -a -G rfc-app www-data
|
||||
sudo chmod -R g+rX /opt/rfc-app/frontend/dist
|
||||
@@ -247,7 +253,7 @@ sudo nginx -t && sudo systemctl reload nginx
|
||||
|
||||
### 8. Let's Encrypt
|
||||
```bash
|
||||
sudo certbot --nginx -d rfc.wiggleverse.org
|
||||
sudo certbot --nginx -d ohm.wiggleverse.org
|
||||
```
|
||||
|
||||
### 9. systemd
|
||||
@@ -259,7 +265,7 @@ sudo systemctl status rfc-app
|
||||
```
|
||||
|
||||
### 10. Smoke test
|
||||
Visit `https://rfc.wiggleverse.org`:
|
||||
Visit `https://ohm.wiggleverse.org`:
|
||||
1. Landing page renders with sign-in button
|
||||
2. Sign in with Gitea OAuth → catalog loads
|
||||
3. `+ Propose New RFC` opens the propose modal
|
||||
@@ -301,7 +307,7 @@ Paste the following into a new Claude session to continue development:
|
||||
|
||||
---
|
||||
|
||||
> I'm working on the **Wiggleverse RFC App** — a FastAPI + SQLite + React + Vite application deployed at `rfc.wiggleverse.org` on a GCP e2-small VM (`rfc-app` in the `wiggleverse-rfc` project; separate from the `gitea` VM in `wiggleverse` that runs Gitea at `git.wiggleverse.org`). The app is the primary interface for the Open Human Model (OHM) RFC working group.
|
||||
> I'm working on the **Wiggleverse RFC App** — a FastAPI + SQLite + React + Vite application deployed at `ohm.wiggleverse.org` on a GCP e2-small VM (`rfc-app` in the `wiggleverse-rfc` project; separate from the `gitea` VM in `wiggleverse` that runs Gitea at `git.wiggleverse.org`). The app is the primary interface for the Open Human Model (OHM) RFC working group.
|
||||
>
|
||||
> **Stack:**
|
||||
> - Backend: Python 3.11, FastAPI, uvicorn (single process), SQLite WAL mode
|
||||
|
||||
+18
-12
@@ -1,6 +1,6 @@
|
||||
# Runbook
|
||||
|
||||
Single-host deployment of the RFC app at `rfc.wiggleverse.org`, sharing
|
||||
Single-host deployment of the RFC app at `ohm.wiggleverse.org`, sharing
|
||||
infrastructure with `git.wiggleverse.org` (same Gitea instance, same nginx,
|
||||
same Let's Encrypt). The shape matches §4.2: one process, one SQLite file,
|
||||
no separate worker.
|
||||
@@ -18,7 +18,7 @@ recover from a partial install is safe.
|
||||
|
||||
- Ubuntu/Debian-style host with nginx and certbot already serving
|
||||
`git.wiggleverse.org` over HTTPS.
|
||||
- DNS: an `A` record for `rfc.wiggleverse.org` pointing at the same IP as
|
||||
- DNS: an `A` record for `ohm.wiggleverse.org` pointing at the same IP as
|
||||
`git.wiggleverse.org`.
|
||||
- Python 3.11+ available system-wide (the project has no `requires-python`
|
||||
pin; the current production VM runs 3.11 on Debian bookworm). Node 20+
|
||||
@@ -75,7 +75,7 @@ Invite → rfc-bot → Owner**.
|
||||
Integrations → OAuth2 Applications → Create Application**:
|
||||
|
||||
- Name: `RFC App`
|
||||
- Redirect URI: `https://rfc.wiggleverse.org/auth/callback`
|
||||
- Redirect URI: `https://ohm.wiggleverse.org/auth/callback`
|
||||
|
||||
Copy the client ID and client secret. They go into `.env`.
|
||||
|
||||
@@ -93,7 +93,7 @@ sudo -u rfc-app /opt/rfc-app/backend/.venv/bin/pip install \
|
||||
|
||||
```sh
|
||||
# On your laptop:
|
||||
cd frontend && npm install && npm run build
|
||||
cd frontend && npm ci && npm run build
|
||||
rsync -a dist/ ben.stull@<host>:/tmp/rfc-app-dist/
|
||||
# On the host:
|
||||
sudo -u rfc-app mkdir -p /opt/rfc-app/frontend/dist
|
||||
@@ -104,10 +104,16 @@ sudo chown -R rfc-app:rfc-app /opt/rfc-app/frontend/dist
|
||||
Or build on the host directly if Node is installed there:
|
||||
|
||||
```sh
|
||||
cd /opt/rfc-app/frontend && sudo -u rfc-app npm install
|
||||
cd /opt/rfc-app/frontend && sudo -u rfc-app npm ci
|
||||
sudo -u rfc-app npm run build
|
||||
```
|
||||
|
||||
`npm ci` installs strictly from the committed `package-lock.json` and
|
||||
refuses to mutate it. `npm install` was previously used here but can
|
||||
regenerate the lockfile in place (e.g. stripping `libc` fields from
|
||||
optional rollup native packages), which then collides with `git
|
||||
checkout <tag>` on the next deploy.
|
||||
|
||||
**1.3.3 Write `.env`.**
|
||||
|
||||
```sh
|
||||
@@ -128,7 +134,7 @@ META_REPO=meta
|
||||
OAUTH_CLIENT_ID=<from 1.2.3>
|
||||
OAUTH_CLIENT_SECRET=<from 1.2.3>
|
||||
|
||||
APP_URL=https://rfc.wiggleverse.org
|
||||
APP_URL=https://ohm.wiggleverse.org
|
||||
SECRET_KEY=<openssl rand -hex 32>
|
||||
OWNER_GITEA_LOGIN=ben.stull
|
||||
GITEA_WEBHOOK_SECRET=<openssl rand -hex 32>
|
||||
@@ -182,9 +188,9 @@ Re-running is safe; every step is upsert-shaped.
|
||||
**1.4.1 nginx vhost.**
|
||||
|
||||
```sh
|
||||
sudo cp /opt/rfc-app/deploy/nginx/rfc.wiggleverse.org.conf \
|
||||
/etc/nginx/sites-available/rfc.wiggleverse.org
|
||||
sudo ln -s /etc/nginx/sites-available/rfc.wiggleverse.org \
|
||||
sudo cp /opt/rfc-app/deploy/nginx/ohm.wiggleverse.org.conf \
|
||||
/etc/nginx/sites-available/ohm.wiggleverse.org
|
||||
sudo ln -s /etc/nginx/sites-available/ohm.wiggleverse.org \
|
||||
/etc/nginx/sites-enabled/
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
```
|
||||
@@ -200,7 +206,7 @@ sudo systemctl reload nginx
|
||||
**1.4.2 Let's Encrypt cert.**
|
||||
|
||||
```sh
|
||||
sudo certbot --nginx -d rfc.wiggleverse.org
|
||||
sudo certbot --nginx -d ohm.wiggleverse.org
|
||||
```
|
||||
|
||||
### 1.5 systemd
|
||||
@@ -226,7 +232,7 @@ RFC app started — meta repo wiggleverse/meta
|
||||
|
||||
### 1.6 Smoke test
|
||||
|
||||
In a browser at `https://rfc.wiggleverse.org`:
|
||||
In a browser at `https://ohm.wiggleverse.org`:
|
||||
|
||||
1. The landing page renders (§14.1 — title, pitch, three-item deck,
|
||||
sign-in affordance).
|
||||
@@ -384,7 +390,7 @@ say), restore from the most recent backup per §2.2.
|
||||
`rfc-app`.
|
||||
- **OAuth callback returns "Invalid state".** The redirect URI in Gitea
|
||||
must match `APP_URL/auth/callback` exactly. Confirm it's
|
||||
`https://rfc.wiggleverse.org/auth/callback`.
|
||||
`https://ohm.wiggleverse.org/auth/callback`.
|
||||
- **The catalog stays empty after a merge.** Check the webhook:
|
||||
`journalctl -u rfc-app | grep webhook`. Gitea's **Settings → Webhooks
|
||||
→ Recent Deliveries** on the meta repo shows the delivery status; the
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
# nginx vhost for the RFC app — single-process FastAPI behind nginx,
|
||||
# frontend served as static files from the Vite build output.
|
||||
#
|
||||
# Install:
|
||||
# sudo cp deploy/nginx/ohm.wiggleverse.org.conf \
|
||||
# /etc/nginx/sites-available/ohm.wiggleverse.org
|
||||
# sudo ln -s /etc/nginx/sites-available/ohm.wiggleverse.org \
|
||||
# /etc/nginx/sites-enabled/
|
||||
# sudo nginx -t && sudo systemctl reload nginx
|
||||
#
|
||||
# Then add the Let's Encrypt cert:
|
||||
# sudo certbot --nginx -d ohm.wiggleverse.org
|
||||
# Certbot will rewrite this file to add the 443 listener and certificate
|
||||
# directives; the rest of the config below stays as written.
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name ohm.wiggleverse.org;
|
||||
|
||||
# v0.25.0 security hardening (audit 0026 M2/L8)
|
||||
#
|
||||
# NOTE: certbot promotes THIS server block to the HTTPS listener
|
||||
# (`listen 443 ssl`) and adds a separate port-80 → 443 redirect
|
||||
# block (see the install comment above). These response headers
|
||||
# therefore ride into the HTTPS server block on the VM. They use
|
||||
# `add_header ... always` so they also apply to nginx-generated
|
||||
# error responses (4xx/5xx), not just 200s.
|
||||
#
|
||||
# `server_tokens off` (L8) — suppress the nginx version in the
|
||||
# Server header and on error pages so we don't advertise the
|
||||
# build to scanners.
|
||||
server_tokens off;
|
||||
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# Content-Security-Policy (M2). Tuned to what the SPA actually loads:
|
||||
# - default-src 'self': everything not called out below is same-origin.
|
||||
# - script-src 'self' + challenges.cloudflare.com: the only external
|
||||
# <script> tag the app injects is the CloudFlare Turnstile widget
|
||||
# (frontend/src/components/TurnstileWidget.jsx). Amplitude and
|
||||
# mermaid are BUNDLED (dynamic `import()` from node_modules, served
|
||||
# from 'self'), so they need no extra script origin — *.amplitude.com
|
||||
# is listed defensively in case a future SDK build script-injects.
|
||||
# script-src DELIBERATELY OMITS 'unsafe-inline' — no inline <script>
|
||||
# is used, so we keep XSS-via-inline-script blocked.
|
||||
# - style-src 'unsafe-inline' IS REQUIRED by the current build: the
|
||||
# JSX uses inline `style={...}` attributes throughout and mermaid
|
||||
# injects <style> blocks at render time. Removing it would break
|
||||
# layout; tightening this is a future build-side change (nonce/hash).
|
||||
# - img-src 'self' data: https: — markdown/RFC bodies may embed remote
|
||||
# images and data: URIs; svg/mermaid output uses data: too.
|
||||
# - font-src 'self' data: — bundled fonts plus data: webfonts.
|
||||
# - connect-src 'self' + *.amplitude.com + challenges.cloudflare.com:
|
||||
# the app's API/auth/SSE are same-origin (nginx proxy); Amplitude
|
||||
# Analytics + Session Replay (shipped at sampleRate 1) POST to
|
||||
# *.amplitude.com; Turnstile verifies via challenges.cloudflare.com.
|
||||
# - worker-src 'self' blob: — Amplitude Session Replay spins up a
|
||||
# Web Worker from a blob: URL for capture/compression; without
|
||||
# blob: here session replay breaks for every consenting user.
|
||||
# - frame-src challenges.cloudflare.com — the Turnstile challenge
|
||||
# renders in an iframe from that origin.
|
||||
# - frame-ancestors 'none' — clickjacking defense, pairs with
|
||||
# X-Frame-Options DENY for older agents.
|
||||
# - base-uri 'self'; object-src 'none' — lock down <base>/<object>.
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://challenges.cloudflare.com https://*.amplitude.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https://*.amplitude.com https://challenges.cloudflare.com; worker-src 'self' blob:; frame-src https://challenges.cloudflare.com; frame-ancestors 'none'; base-uri 'self'; object-src 'none'" always;
|
||||
|
||||
# Static SPA assets live in the Vite build output. The systemd unit
|
||||
# runs as user `rfc-app`; make sure nginx (usually `www-data`) can
|
||||
# read this path. Either group-add www-data into rfc-app's group, or
|
||||
# chmod o+r on the dist/ tree.
|
||||
root /opt/rfc-app/frontend/dist;
|
||||
index index.html;
|
||||
|
||||
# API routes are proxied to the FastAPI process. SSE chat streams
|
||||
# need proxy_buffering off so chunks reach the browser immediately;
|
||||
# the long read_timeout matches a slow LLM turn.
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_read_timeout 1h;
|
||||
}
|
||||
|
||||
location /auth/ {
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# SPA fallback — any non-asset path falls back to index.html so
|
||||
# React Router can take over.
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Cache the hashed JS/CSS bundles aggressively; Vite includes a
|
||||
# content-hash in the filename so updates bust the cache for free.
|
||||
location ~* \.(js|css|woff2?|ttf|otf|eot|png|jpg|jpeg|gif|svg|ico)$ {
|
||||
try_files $uri =404;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Reasonable upload cap. Adjust if RFC bodies grow large.
|
||||
client_max_body_size 4M;
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
# nginx vhost for the RFC app — single-process FastAPI behind nginx,
|
||||
# frontend served as static files from the Vite build output.
|
||||
#
|
||||
# Install:
|
||||
# sudo cp deploy/nginx/rfc.wiggleverse.org.conf \
|
||||
# /etc/nginx/sites-available/rfc.wiggleverse.org
|
||||
# sudo ln -s /etc/nginx/sites-available/rfc.wiggleverse.org \
|
||||
# /etc/nginx/sites-enabled/
|
||||
# sudo nginx -t && sudo systemctl reload nginx
|
||||
#
|
||||
# Then add the Let's Encrypt cert:
|
||||
# sudo certbot --nginx -d rfc.wiggleverse.org
|
||||
# Certbot will rewrite this file to add the 443 listener and certificate
|
||||
# directives; the rest of the config below stays as written.
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name rfc.wiggleverse.org;
|
||||
|
||||
# Static SPA assets live in the Vite build output. The systemd unit
|
||||
# runs as user `rfc-app`; make sure nginx (usually `www-data`) can
|
||||
# read this path. Either group-add www-data into rfc-app's group, or
|
||||
# chmod o+r on the dist/ tree.
|
||||
root /opt/rfc-app/frontend/dist;
|
||||
index index.html;
|
||||
|
||||
# API routes are proxied to the FastAPI process. SSE chat streams
|
||||
# need proxy_buffering off so chunks reach the browser immediately;
|
||||
# the long read_timeout matches a slow LLM turn.
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_read_timeout 1h;
|
||||
}
|
||||
|
||||
location /auth/ {
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# SPA fallback — any non-asset path falls back to index.html so
|
||||
# React Router can take over.
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Cache the hashed JS/CSS bundles aggressively; Vite includes a
|
||||
# content-hash in the filename so updates bust the cache for free.
|
||||
location ~* \.(js|css|woff2?|ttf|otf|eot|png|jpg|jpeg|gif|svg|ico)$ {
|
||||
try_files $uri =404;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Reasonable upload cap. Adjust if RFC bodies grow large.
|
||||
client_max_body_size 4M;
|
||||
}
|
||||
@@ -42,5 +42,32 @@ ProtectHome=true
|
||||
PrivateTmp=true
|
||||
ReadWritePaths=/opt/rfc-app/backend/data
|
||||
|
||||
# v0.25.0 security hardening (audit 0026 L4) — defense-in-depth.
|
||||
# The service binds 127.0.0.1:8000 and runs plain CPython
|
||||
# (FastAPI/uvicorn + sqlite + bcrypt + httpx), so it needs no
|
||||
# capabilities and no exotic syscalls.
|
||||
CapabilityBoundingSet=
|
||||
AmbientCapabilities=
|
||||
PrivateDevices=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectKernelLogs=true
|
||||
ProtectControlGroups=true
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
||||
RestrictNamespaces=true
|
||||
LockPersonality=true
|
||||
# MemoryDenyWriteExecute=true blocks W^X memory — safe for stock
|
||||
# CPython (no JIT) and the pure-Python/C-extension stack here, but
|
||||
# would break a JIT or a C-ext that mmaps W+X. Watch the first
|
||||
# restart's journal for a crash; if uvicorn fails to come up,
|
||||
# comment this one line out and reload.
|
||||
MemoryDenyWriteExecute=true
|
||||
RestrictRealtime=true
|
||||
RestrictSUIDSGID=true
|
||||
SystemCallFilter=@system-service
|
||||
SystemCallErrorNumber=EPERM
|
||||
SystemCallArchitectures=native
|
||||
UMask=0077
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
+31
-2
@@ -69,7 +69,7 @@ The shortest path from scratch:
|
||||
any deployment-identifying value; if a required variable is
|
||||
missing, the build fails loudly.
|
||||
|
||||
6. **Build and run.** `cd frontend && npm install && npm run
|
||||
6. **Build and run.** `cd frontend && npm ci && npm run
|
||||
build`, then start the backend per `deploy/RUNBOOK.md`. The
|
||||
first sign-in is the OWNER login from `backend/.env`.
|
||||
|
||||
@@ -166,7 +166,7 @@ The mechanics in practice:
|
||||
5. **Check out the framework at the target version.** `git
|
||||
fetch && git checkout <tag>`.
|
||||
|
||||
6. **Rebuild.** `npm install && npm run build` for the frontend;
|
||||
6. **Rebuild.** `npm ci && npm run build` for the frontend;
|
||||
restart the backend.
|
||||
|
||||
7. **Smoke-test.** Sign in, check the brand reflects your
|
||||
@@ -240,6 +240,35 @@ The deployment repo does **not** hold:
|
||||
edit a framework file, the right move is to file a change against
|
||||
the framework, get a release, and pin to it.
|
||||
|
||||
## Private-beta gate
|
||||
|
||||
From `0.3.0` onward, every deployment ships with an opt-in email
|
||||
allowlist. The default state is **off**: an empty `allowed_emails`
|
||||
table behaves exactly like 0.2.x — any successful Gitea OAuth
|
||||
provisions a user.
|
||||
|
||||
To run a closed beta:
|
||||
|
||||
1. Sign in once as the deployment operator so your `users` row exists
|
||||
(you will be grandfathered by `gitea_id` thereafter — adding the
|
||||
first allowlist row does **not** lock you out).
|
||||
2. Open `/admin/allowlist` and add the first invited email. As soon as
|
||||
any row exists, sign-in is restricted to listed emails plus
|
||||
grandfathered users.
|
||||
3. Optionally set `VITE_BETA_CONTACT` in `frontend/.env` (an email
|
||||
address, a URL, or a short instruction). It is shown to rejected
|
||||
sign-ins on the `/beta-pending` page so visitors know how to
|
||||
request an invitation.
|
||||
|
||||
To re-open the deployment, remove all rows from `allowed_emails` (the
|
||||
admin tab has a Remove button per row) — the gate flips off as soon
|
||||
as the last row is gone.
|
||||
|
||||
Anonymous viewers see the full app in read-only mode regardless of
|
||||
allowlist state. Write affordances (Propose, chat, Contribute, Open
|
||||
PR) are hidden behind a sign-in CTA, and the public read endpoints
|
||||
behave the same in both states.
|
||||
|
||||
## When something goes wrong
|
||||
|
||||
If a framework behavior is wrong for your deployment, file it as a
|
||||
|
||||
@@ -13,3 +13,75 @@
|
||||
# VITE_APP_NAME=Wiggleverse RFC
|
||||
# VITE_APP_NAME=Wiggleverse Open Human Model
|
||||
VITE_APP_NAME=
|
||||
|
||||
# Optional contact line shown on the /beta-pending page when a deployment
|
||||
# is in private-beta mode (i.e. the backend's `allowed_emails` table has
|
||||
# rows). Free-text — an email address, a URL, or a one-line instruction
|
||||
# tells visitors how to request an invitation. If unset, the page falls
|
||||
# back to a generic "contact the deployment operator" line.
|
||||
#
|
||||
# Examples:
|
||||
# VITE_BETA_CONTACT=ben@wiggleverse.org
|
||||
# VITE_BETA_CONTACT=DM @ben on Matrix
|
||||
VITE_BETA_CONTACT=
|
||||
|
||||
# Optional URL to the deployment's privacy policy (v0.13.0+, SPEC §14.5).
|
||||
# The framework ships a minimal default privacy policy at `/privacy`
|
||||
# that describes the framework's stance and lists the cookies the
|
||||
# framework sets. When this var is set to an http(s) URL, the page
|
||||
# renders the framework's stub above a link to the configured URL —
|
||||
# deployments use this to layer their own policy content on top
|
||||
# without forking the framework. Unset is OK; the stub is sufficient
|
||||
# for a deployment that has nothing specific to add.
|
||||
#
|
||||
# Examples:
|
||||
# VITE_PRIVACY_POLICY_URL=https://wiggleverse.org/privacy
|
||||
VITE_PRIVACY_POLICY_URL=
|
||||
|
||||
# Optional URL to the deployment's cookies policy (v0.13.0+, SPEC §14.6).
|
||||
# Same shape as VITE_PRIVACY_POLICY_URL. The framework's default
|
||||
# `/cookies` page lists exactly which cookies the framework sets
|
||||
# (rfc_session, the consent-choice localStorage entry); a deployment
|
||||
# that adds its own cookies (analytics SDK once #13 lands, third-party
|
||||
# embeds) points this var at a page that documents the full list.
|
||||
# Unset is OK; the stub is sufficient for a default-config deployment.
|
||||
#
|
||||
# Examples:
|
||||
# VITE_COOKIES_POLICY_URL=https://wiggleverse.org/cookies
|
||||
VITE_COOKIES_POLICY_URL=
|
||||
|
||||
# v0.12.0 / roadmap item #10: CloudFlare Turnstile site key (public).
|
||||
# Provision a Turnstile site at dash.cloudflare.com → Turnstile → Add
|
||||
# site. The site key (this var) is embedded into the frontend bundle at
|
||||
# build time and rendered by the Turnstile widget on the /login email-
|
||||
# entry step. The secret key (private) lives in the backend env as
|
||||
# CLOUDFLARE_TURNSTILE_SECRET — see backend/.env.example. Leave unset
|
||||
# in dev to skip the widget; the backend's TURNSTILE_REQUIRED policy
|
||||
# decides what happens to a tokenless request.
|
||||
#
|
||||
# Examples:
|
||||
# VITE_TURNSTILE_SITE_KEY=0x4AAAAAAA...
|
||||
VITE_TURNSTILE_SITE_KEY=
|
||||
|
||||
# v0.15.0 / roadmap item #13: Amplitude project API key (public).
|
||||
# Embedded in the frontend bundle at build time and used by the
|
||||
# analytics wrapper (`frontend/src/lib/analytics.js`) — which loads
|
||||
# `@amplitude/unified` (Analytics + Session Replay) when the user
|
||||
# has granted analytics consent (v0.13.0 cookie banner). Provision
|
||||
# an Amplitude project at app.amplitude.com → Projects → New, copy
|
||||
# the API key.
|
||||
#
|
||||
# Public by design: Amplitude browser keys are bundle-embedded
|
||||
# (visible in dev tools), same nature as VITE_TURNSTILE_SITE_KEY
|
||||
# (also public; the truly-secret half of that Turnstile pair is
|
||||
# CLOUDFLARE_TURNSTILE_SECRET on the backend). For deployments
|
||||
# behind flotilla, bind via `flotilla overlay set <deployment>
|
||||
# VITE_AMPLITUDE_API_KEY=<key>` — NOT `flotilla secret set`. The
|
||||
# vendor's installation wizard shows the key inline as a literal
|
||||
# string in the init call, confirming the public framing. Leave
|
||||
# unset in dev; the wrapper logs one console warning and no-ops
|
||||
# (the app continues to work).
|
||||
#
|
||||
# Examples:
|
||||
# VITE_AMPLITUDE_API_KEY=01234567890abcdef01234567890abcd
|
||||
VITE_AMPLITUDE_API_KEY=
|
||||
|
||||
Generated
+530
-10
@@ -1,13 +1,14 @@
|
||||
{
|
||||
"name": "rfc-app-frontend",
|
||||
"version": "0.2.1",
|
||||
"version": "0.24.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "rfc-app-frontend",
|
||||
"version": "0.2.1",
|
||||
"version": "0.24.0",
|
||||
"dependencies": {
|
||||
"@amplitude/unified": "^1.1.9",
|
||||
"@codemirror/commands": "^6.10.3",
|
||||
"@codemirror/lang-markdown": "^6.5.0",
|
||||
"@codemirror/language": "^6.12.3",
|
||||
@@ -17,6 +18,7 @@
|
||||
"@tiptap/pm": "^3.5.0",
|
||||
"@tiptap/react": "^3.5.0",
|
||||
"@tiptap/starter-kit": "^3.5.0",
|
||||
"dompurify": "^3.2.4",
|
||||
"marked": "^18.0.4",
|
||||
"mermaid": "^11.15.0",
|
||||
"react": "^19.2.6",
|
||||
@@ -30,6 +32,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 +620,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 +1019,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 +1514,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 +1773,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 +1816,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 +1858,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 +2479,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 +2512,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 +2551,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 +2582,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 +2909,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 +2971,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 +3017,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 +3051,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 +3327,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 +3355,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 +3411,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 +3524,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.2.3",
|
||||
"version": "0.31.4",
|
||||
"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",
|
||||
@@ -18,6 +19,7 @@
|
||||
"@tiptap/pm": "^3.5.0",
|
||||
"@tiptap/react": "^3.5.0",
|
||||
"@tiptap/starter-kit": "^3.5.0",
|
||||
"dompurify": "^3.2.4",
|
||||
"marked": "^18.0.4",
|
||||
"mermaid": "^11.15.0",
|
||||
"react": "^19.2.6",
|
||||
|
||||
+1480
-555
File diff suppressed because it is too large
Load Diff
+318
-44
@@ -1,17 +1,34 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Routes, Route, Link, useNavigate } from 'react-router-dom'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Routes, Route, Link, Navigate, useLocation, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { getMe, subscribeToNotifications } from './api'
|
||||
import { anonymize, EVENTS, identify, track } from './lib/analytics'
|
||||
import { useLastState } from './lib/useLastState'
|
||||
import Catalog from './components/Catalog.jsx'
|
||||
import Inbox from './components/Inbox.jsx'
|
||||
import RFCView from './components/RFCView.jsx'
|
||||
import PRView from './components/PRView.jsx'
|
||||
import ProposalView from './components/ProposalView.jsx'
|
||||
import ProposeModal from './components/ProposeModal.jsx'
|
||||
import ContributeRequestForm from './components/ContributeRequestForm.jsx'
|
||||
import Landing from './components/Landing.jsx'
|
||||
import Login from './components/Login.jsx'
|
||||
import BetaPending from './components/BetaPending.jsx'
|
||||
import Philosophy from './components/Philosophy.jsx'
|
||||
import DocsLayout from './components/DocsLayout.jsx'
|
||||
import DocsUserGuide from './components/DocsUserGuide.jsx'
|
||||
import DocsSessionsAbout from './components/DocsSessionsAbout.jsx'
|
||||
import DocsSessionIndex from './components/DocsSessionIndex.jsx'
|
||||
import DocsSessionTranscript from './components/DocsSessionTranscript.jsx'
|
||||
import DocsSpec from './components/DocsSpec.jsx'
|
||||
import DocsSpecsIndex from './components/DocsSpecsIndex.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'
|
||||
import Cookies from './pages/Cookies.jsx'
|
||||
import './App.css'
|
||||
|
||||
export default function App() {
|
||||
@@ -22,7 +39,98 @@ export default function App() {
|
||||
const [inboxOpen, setInboxOpen] = useState(false)
|
||||
const [unreadCount, setUnreadCount] = useState(0)
|
||||
const [inboxTick, setInboxTick] = useState(0)
|
||||
// §14.5: a tick that, when bumped, asks <CookieConsentBanner> to
|
||||
// re-open even if the user has already made a choice. The settings
|
||||
// "Privacy & cookies" tab dispatches a `rfc-app:cookie-consent-reopen`
|
||||
// event that bumps this.
|
||||
const [consentReopenTick, setConsentReopenTick] = useState(0)
|
||||
// v0.23.0 / item #29 — flips true once the #21-Part-C identify effect
|
||||
// has fired (or once we've confirmed there's no authenticated user to
|
||||
// identify). useLastState gates its resume redirect on this so the
|
||||
// redirect always happens AFTER identify, preserving identify-then-
|
||||
// track ordering.
|
||||
const [identifyReady, setIdentifyReady] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
// #28 Parts 2–3: the LinkedText create/contribute affordances route via
|
||||
// query params so they need no prop-threading from deep in a comment
|
||||
// list. `?propose=<term>` opens the propose modal pre-filled;
|
||||
// `?contribute=<slug>&term=<term>` opens the contribute-request form.
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const proposeParam = searchParams.get('propose')
|
||||
const contributeSlug = searchParams.get('contribute')
|
||||
const contributeTerm = searchParams.get('term')
|
||||
const clearParams = (...keys) => {
|
||||
const next = new URLSearchParams(searchParams)
|
||||
keys.forEach(k => next.delete(k))
|
||||
setSearchParams(next, { replace: true })
|
||||
}
|
||||
// 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 })
|
||||
// v0.23.0 / item #29 — identify has now fired for this sign-in;
|
||||
// release useLastState's resume redirect (it waits on this).
|
||||
setIdentifyReady(true)
|
||||
} 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])
|
||||
|
||||
// v0.23.0 / item #29 — once `me` has resolved, if there's no
|
||||
// authenticated user there is nothing to identify, so release the
|
||||
// resume gate immediately (anonymous boots have no resume to do, but
|
||||
// the hook still needs the gate resolved to be a clean no-op).
|
||||
useEffect(() => {
|
||||
if (me != null && !me.authenticated) setIdentifyReady(true)
|
||||
}, [me])
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => setConsentReopenTick(t => t + 1)
|
||||
window.addEventListener('rfc-app:cookie-consent-reopen', handler)
|
||||
return () => window.removeEventListener('rfc-app:cookie-consent-reopen', handler)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
getMe()
|
||||
@@ -31,6 +139,18 @@ export default function App() {
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
// v0.23.0 / item #29 — server-side sign-in state resume. The hook
|
||||
// debounce-posts the current route for authenticated users and, once
|
||||
// identify has fired, redirects a fresh sign-in (which hard-lands on
|
||||
// "/") to the user's stored last route. Anonymous users: no-op.
|
||||
useLastState({
|
||||
authenticated: !!me?.authenticated,
|
||||
pathname: location.pathname,
|
||||
identifyReady,
|
||||
lastRoute: me?.authenticated ? me.user?.last_route : null,
|
||||
navigate,
|
||||
})
|
||||
|
||||
// §15.3 — subscribe to the live SSE stream for authenticated viewers
|
||||
// so the badge counter and the toast surface stay in lockstep with
|
||||
// the inbox. Tabs that miss an event because they were closed pick
|
||||
@@ -68,17 +188,18 @@ export default function App() {
|
||||
return <div className="boot">Loading…</div>
|
||||
}
|
||||
|
||||
// §14.2: the philosophy route is reachable by anonymous visitors too.
|
||||
// Resolve it before the authentication gate so a signed-out reader
|
||||
// who follows the §14.1 landing link does not get bounced to sign-in.
|
||||
if (!me?.authenticated) {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/philosophy" element={<Philosophy authenticated={false} />} />
|
||||
<Route path="*" element={<Landing />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
// 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. 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">
|
||||
@@ -88,84 +209,196 @@ export default function App() {
|
||||
</div>
|
||||
<div className="header-right">
|
||||
{/* §14.3: the persistent About link. One word, no badge, no
|
||||
state — visible from every authenticated screen so a
|
||||
contributor mid-PR who wonders why a conversation is
|
||||
public can reach the answer in two clicks. */}
|
||||
state — visible from every screen so a viewer mid-PR who
|
||||
wonders why a conversation is public can reach the answer
|
||||
in two clicks. Anonymous viewers see it too. */}
|
||||
<Link to="/philosophy" className="header-about" title="Why this exists (§14)">
|
||||
About
|
||||
</Link>
|
||||
<Link to="/settings/notifications" className="header-settings" title="Notification settings (§15)">
|
||||
Settings
|
||||
<Link to="/docs" className="header-about" title="User guide">
|
||||
Docs
|
||||
</Link>
|
||||
{(me.user.role === 'owner' || me.user.role === 'admin') && (
|
||||
{viewer && (
|
||||
<Link to="/settings/notifications" className="header-settings" title="Notification settings (§15)">
|
||||
Settings
|
||||
</Link>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Link to="/admin" className="header-admin" title="Admin home base">
|
||||
Admin
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
className="inbox-trigger"
|
||||
onClick={() => setInboxOpen(o => !o)}
|
||||
title="Notifications inbox (§15.2)"
|
||||
>
|
||||
<span aria-hidden>📮</span>
|
||||
{unreadCount > 0 && (
|
||||
<span className="badge">{unreadCount > 99 ? '99+' : unreadCount}</span>
|
||||
)}
|
||||
</button>
|
||||
<span className="user-name">{me.user.display_name}</span>
|
||||
<span className={`user-role-badge role-${me.user.role}`}>{me.user.role}</span>
|
||||
<a className="btn-link" href="/auth/logout">Sign out</a>
|
||||
{viewer && (
|
||||
<button
|
||||
className="inbox-trigger"
|
||||
onClick={() => setInboxOpen(o => !o)}
|
||||
aria-label="Inbox"
|
||||
title="Inbox (§15.2)"
|
||||
>
|
||||
<svg
|
||||
width="18" height="18" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" strokeWidth="1.75"
|
||||
strokeLinecap="round" strokeLinejoin="round" aria-hidden
|
||||
>
|
||||
<path d="M4 5h16a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1Z" />
|
||||
<path d="m3.5 6.5 8.5 6 8.5-6" />
|
||||
</svg>
|
||||
{unreadCount > 0 && (
|
||||
<span className="badge">{unreadCount > 99 ? '99+' : unreadCount}</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{viewer ? (
|
||||
<>
|
||||
<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"
|
||||
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">
|
||||
Sign in <span className="beta-chip">Beta</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
{isPending && <PendingAccessBanner />}
|
||||
<div className="app-body">
|
||||
<Routes>
|
||||
<Route path="/philosophy" element={<PhilosophyWithSidebar viewer={me.user} />} />
|
||||
<Route path="/settings/notifications" element={<NotificationSettingsWithSidebar viewer={me.user} />} />
|
||||
<Route path="/admin/*" element={<AdminWithSidebar viewer={me.user} />} />
|
||||
<Route path="/welcome" element={<Landing />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<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} />} />
|
||||
{/* v0.19.0 / roadmap item #30 — /docs/* is a hub with sub-nav.
|
||||
The bare /docs path redirects to the user guide; sessions
|
||||
browser lives at /docs/sessions/*. See DocsLayout.jsx
|
||||
for the flyout shape and CHANGELOG v0.19.0 for the
|
||||
upgrade path. */}
|
||||
<Route path="/docs" element={<Navigate to="/docs/user-guide" replace />} />
|
||||
<Route path="/docs/*" element={<DocsWithSidebar viewer={viewer} />} />
|
||||
{/* §14.5 / §14.6: cookie-consent companions to /philosophy.
|
||||
Available to anonymous and authenticated viewers alike. */}
|
||||
<Route path="/privacy" element={<PolicyShell><Privacy /></PolicyShell>} />
|
||||
<Route path="/cookies" element={<PolicyShell><Cookies /></PolicyShell>} />
|
||||
{viewer && (
|
||||
<Route path="/settings/notifications" element={<NotificationSettingsWithSidebar viewer={viewer} />} />
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Route path="/admin/*" element={<AdminWithSidebar viewer={viewer} />} />
|
||||
)}
|
||||
<Route path="*" element={
|
||||
<>
|
||||
<Catalog
|
||||
viewer={viewer}
|
||||
onProposeRFC={() => setProposeOpen(true)}
|
||||
version={catalogVersion}
|
||||
/>
|
||||
<main className="main-pane">
|
||||
<Routes>
|
||||
<Route path="/" element={<Welcome viewer={me.user} />} />
|
||||
<Route path="/rfc/:slug" element={<RFCView viewer={me.user} />} />
|
||||
<Route path="/rfc/:slug/pr/:prNumber" element={<PRView viewer={me.user} />} />
|
||||
<Route path="/proposals/:prNumber" element={<ProposalView viewer={me.user} onChange={() => setCatalogVersion(v => v + 1)} />} />
|
||||
<Route path="/" element={<Welcome viewer={viewer} />} />
|
||||
<Route path="/rfc/:slug" element={<RFCView viewer={viewer} />} />
|
||||
<Route path="/rfc/:slug/pr/:prNumber" element={<PRView viewer={viewer} />} />
|
||||
<Route path="/proposals/:prNumber" element={<ProposalView viewer={viewer} onChange={() => setCatalogVersion(v => v + 1)} />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</>
|
||||
} />
|
||||
</Routes>
|
||||
</div>
|
||||
{proposeOpen && (
|
||||
{(proposeOpen || proposeParam != null) && viewer && (
|
||||
<ProposeModal
|
||||
onClose={() => setProposeOpen(false)}
|
||||
viewer={viewer}
|
||||
initialTitle={proposeParam || ''}
|
||||
onClose={() => { setProposeOpen(false); clearParams('propose') }}
|
||||
onSubmitted={({ pr_number }) => {
|
||||
setProposeOpen(false)
|
||||
clearParams('propose')
|
||||
setCatalogVersion(v => v + 1)
|
||||
navigate(`/proposals/${pr_number}`)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{inboxOpen && (
|
||||
{contributeSlug && viewer && (
|
||||
<ContributeRequestForm
|
||||
slug={contributeSlug}
|
||||
term={contributeTerm || ''}
|
||||
onClose={() => clearParams('contribute', 'term')}
|
||||
/>
|
||||
)}
|
||||
{inboxOpen && viewer && (
|
||||
<Inbox onClose={() => setInboxOpen(false)} lastChangeTick={inboxTick} />
|
||||
)}
|
||||
<ToastHost />
|
||||
<CookieConsentBanner viewer={viewer} forceOpen={consentReopenTick} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PhilosophyWithSidebar() {
|
||||
function PolicyShell({ children }) {
|
||||
// §14.5 / §14.6 policy pages reuse the chrome-pane shape so they
|
||||
// render full-width without the catalog rail. The components inside
|
||||
// carry their own back affordance per Philosophy.jsx's pattern.
|
||||
return <main className="chrome-pane">{children}</main>
|
||||
}
|
||||
|
||||
function PhilosophyWithSidebar({ viewer }) {
|
||||
// The chrome surfaces (§14.2 philosophy, §15 settings, §6/§17 admin)
|
||||
// all use the full app body — no catalog left pane, no propose modal.
|
||||
// The header carries the navigation back; the body is a single
|
||||
// reading surface.
|
||||
return (
|
||||
<main className="chrome-pane">
|
||||
<Philosophy authenticated={true} />
|
||||
<Philosophy authenticated={!!viewer} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function DocsWithSidebar({ viewer }) {
|
||||
// v0.19.0 / roadmap item #30 — the `/docs/*` surface is a flyout
|
||||
// shell with sub-routes. The shell (sidebar + content area) is the
|
||||
// DocsLayout outlet host; the sub-routes mount their respective
|
||||
// pages into the outlet. Bare `/docs/sessions` redirects to the
|
||||
// sessions about page so deep-linkers and the flyout's "Sessions"
|
||||
// header both land somewhere coherent.
|
||||
return (
|
||||
<main className="chrome-pane">
|
||||
<Routes>
|
||||
<Route element={<DocsLayout authenticated={!!viewer} />}>
|
||||
<Route index element={<Navigate to="user-guide" replace />} />
|
||||
<Route path="user-guide" element={<DocsUserGuide />} />
|
||||
<Route path="sessions" element={<Navigate to="about" replace />} />
|
||||
<Route path="sessions/about" element={<DocsSessionsAbout />} />
|
||||
<Route path="sessions/:nnnn" element={<DocsSessionIndex />} />
|
||||
<Route path="sessions/:nnnn/:filename" element={<DocsSessionTranscript />} />
|
||||
{/* v0.20.0 — /docs/specs/* surface (framework spec + flotilla spec
|
||||
at runtime via gitea raw). Bare /docs/specs lands on the
|
||||
client-side redirect to the first configured spec. */}
|
||||
<Route path="specs" element={<DocsSpecsIndex />} />
|
||||
<Route path="specs/:name" element={<DocsSpec />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -186,7 +419,48 @@ 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">
|
||||
<h1>Welcome.</h1>
|
||||
<p>
|
||||
The catalog on the left lists every super-draft and active RFC in the
|
||||
framework. Open one to read the canonical body and the public
|
||||
conversation behind each definition.
|
||||
</p>
|
||||
<p>
|
||||
Discussion and contribution are in private <strong>Beta</strong> —
|
||||
read freely, and <Link to="/login">sign in</Link> if your email has
|
||||
been invited.
|
||||
</p>
|
||||
<p>
|
||||
Wondering why a conversation is public, why graduation costs what it
|
||||
does, or why the model is in the chat? <Link to="/philosophy">Read the
|
||||
philosophy</Link>.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="welcome">
|
||||
<h1>Welcome, {viewer.display_name}.</h1>
|
||||
|
||||
+486
-8
@@ -25,6 +25,150 @@ export async function getMe() {
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
// ── v0.23.0: sign-in state resume (§6.2, roadmap item #29) ───────────────
|
||||
//
|
||||
// The route-change hook (useLastState) debounce-posts the user's current
|
||||
// route + a small bag of *light* view state here for authenticated users.
|
||||
// The next sign-in reads `last_route` off `/api/auth/me` and redirects.
|
||||
// Privacy: `state` carries ephemeral view state ONLY — never draft-buffer
|
||||
// contents (see SPEC §6.2). Best-effort: callers ignore failures (an
|
||||
// offline/401 POST must never disrupt navigation).
|
||||
export async function putLastState(route, state) {
|
||||
const body = { route }
|
||||
if (state != null) body.state = state
|
||||
const res = await fetch('/api/me/last-state', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
// ── v0.7.0: email + one-time-code sign-in (§6.2) ─────────────────────────
|
||||
//
|
||||
// The legacy /auth/login → /auth/callback OAuth flow remains during the
|
||||
// migration — the new UI just no longer points at it primarily. These
|
||||
// two helpers drive the Login.jsx surface.
|
||||
|
||||
export async function requestOtc(email, { turnstileToken } = {}) {
|
||||
// v0.12.0 / roadmap item #10: when the Turnstile widget has produced
|
||||
// a token, send it alongside the email so the backend can siteverify
|
||||
// before the OTC dispatch. The backend treats a missing token as
|
||||
// either soft-fail (no secret wired AND TURNSTILE_REQUIRED=false)
|
||||
// or hard-fail (verification required) — the frontend stays
|
||||
// uninvolved in the policy.
|
||||
const body = { email }
|
||||
if (turnstileToken) body.turnstile_token = turnstileToken
|
||||
const res = await fetch('/auth/otc/request', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function verifyOtc(email, code, { trustDevice = false } = {}) {
|
||||
// v0.11.0 — `trustDevice` is the "trust this device for 30 days"
|
||||
// checkbox on the Login.jsx OTC step. When true, the server mints
|
||||
// a fresh device-trust row and sets the long-lived cookie; on
|
||||
// subsequent visits, the cookie skips the OTC roundtrip via
|
||||
// `startDeviceTrust()`.
|
||||
const res = await fetch('/auth/otc/verify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, code, trust_device: !!trustDevice }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
// ── v0.8.0: open beta-access request flow (§6.1 / §14.1) ─────────────────
|
||||
//
|
||||
// On the first OTC sign-in, the user lands in `permission_state='pending'`
|
||||
// and `/api/auth/me` reports `needs_profile=true`. The Login.jsx surface
|
||||
// then prompts for first/last/why and POSTs them here. After this lands,
|
||||
// the user sees the /beta-pending page until an admin grants access.
|
||||
|
||||
export async function submitBetaRequest({ first_name, last_name, beta_request_reason }) {
|
||||
const res = await fetch('/api/auth/me/beta-request', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ first_name, last_name, beta_request_reason }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
// ── v0.10.0: user-set passcodes after OTC (§6.2, roadmap item #8) ─────────
|
||||
//
|
||||
// After a successful OTC sign-in, a contributor may set a passcode and
|
||||
// use email + passcode for subsequent sign-ins. OTC remains the
|
||||
// forgot-passcode fallback — 5 consecutive verify failures locks the
|
||||
// passcode path for 15 minutes (HTTP 423); the OTC path is unaffected.
|
||||
|
||||
export async function checkPasscode(email) {
|
||||
// Anonymous endpoint. Returns `{has_passcode: boolean}` so the
|
||||
// Login.jsx flow can decide whether to render a passcode input or
|
||||
// fall back to OTC. We URL-encode the email so addresses with '+'
|
||||
// round-trip cleanly.
|
||||
const params = new URLSearchParams({ email })
|
||||
const res = await fetch(`/auth/passcode/check?${params}`)
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function verifyPasscode(email, passcode, { trustDevice = false } = {}) {
|
||||
// v0.11.0 — same trust-device opt-in as `verifyOtc`.
|
||||
const res = await fetch('/auth/passcode/verify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, passcode, trust_device: !!trustDevice }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
// ── v0.11.0: trust device for 30 days (§6.2, roadmap item #9) ─────────────
|
||||
//
|
||||
// On a returning visit with a valid device-trust cookie, `startDeviceTrust`
|
||||
// re-establishes the session without an OTC / passcode roundtrip. The
|
||||
// cookie is HttpOnly so the client cannot read it; the call is a pure POST
|
||||
// that the browser attaches the cookie to automatically.
|
||||
//
|
||||
// `listMyDevices`, `revokeMyDevice`, and `revokeAllMyDevices` drive the
|
||||
// /settings/devices revoke-device UI. The signed-in user is the implicit
|
||||
// subject; the cookie carries the session.
|
||||
|
||||
export async function startDeviceTrust() {
|
||||
const res = await fetch('/auth/device-trust/start', { method: 'POST' })
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function listMyDevices() {
|
||||
return jsonOrThrow(await fetch('/api/auth/me/devices'))
|
||||
}
|
||||
|
||||
export async function revokeMyDevice(deviceId) {
|
||||
return jsonOrThrow(await fetch(`/api/auth/me/devices/${deviceId}`, { method: 'DELETE' }))
|
||||
}
|
||||
|
||||
export async function revokeAllMyDevices() {
|
||||
return jsonOrThrow(await fetch('/api/auth/me/devices', { method: 'DELETE' }))
|
||||
}
|
||||
|
||||
export async function setPasscode(passcode) {
|
||||
// Requires an active session — the server returns 401 if not signed
|
||||
// in. The signed-in user is the implicit subject; the body carries
|
||||
// only the new passcode.
|
||||
const res = await fetch('/auth/passcode/set', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ passcode }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function clearPasscode() {
|
||||
const res = await fetch('/auth/passcode', { method: 'DELETE' })
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function listRFCs() {
|
||||
return jsonOrThrow(await fetch('/api/rfcs'))
|
||||
}
|
||||
@@ -41,15 +185,81 @@ export async function getProposal(prNumber) {
|
||||
return jsonOrThrow(await fetch(`/api/proposals/${prNumber}`))
|
||||
}
|
||||
|
||||
export async function proposeRFC({ title, slug, pitch, tags }) {
|
||||
export async function proposeRFC({ title, slug, pitch, tags, proposedUseCase }) {
|
||||
const res = await fetch('/api/rfcs/propose', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title, slug, pitch, tags: tags || [] }),
|
||||
// #26: proposed_use_case is optional; send null when blank so the
|
||||
// backend treats it as "left blank".
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
slug,
|
||||
pitch,
|
||||
tags: tags || [],
|
||||
proposed_use_case: proposedUseCase || null,
|
||||
}),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
// Roadmap #27: Claude Haiku tag suggestions for the propose-RFC modal.
|
||||
// Returns a (possibly empty) array of { tag, confidence }. Deliberately
|
||||
// forgiving — any non-OK response (rate limit, transient error, no key
|
||||
// configured server-side) resolves to [] so the modal just shows nothing
|
||||
// rather than surfacing an error for what is a best-effort assist.
|
||||
export async function suggestTags({ title, pitch, useCase }) {
|
||||
try {
|
||||
const res = await fetch('/api/rfcs/suggest-tags', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: title || '',
|
||||
pitch: pitch || '',
|
||||
use_case: useCase || '',
|
||||
}),
|
||||
})
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return Array.isArray(data.suggestions) ? data.suggestions : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// Roadmap #28 Part 3: offer-to-contribute-to-a-pending-RFC.
|
||||
// `contributionTarget` feeds the contribute form (RFC title, owner
|
||||
// display, the viewer's eligibility); `requestContribution` submits the
|
||||
// ask; accept/decline are the owner's inbox actions.
|
||||
export async function contributionTarget(slug) {
|
||||
return jsonOrThrow(await fetch(`/api/rfcs/${slug}/contribution-target`))
|
||||
}
|
||||
|
||||
export async function requestContribution(slug, { matchedTerm, whoIAm, why, useCase }) {
|
||||
const res = await fetch(`/api/rfcs/${slug}/contribution-requests`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
matched_term: matchedTerm,
|
||||
who_i_am: whoIAm,
|
||||
why,
|
||||
use_case: useCase || null,
|
||||
}),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function acceptContributionRequest(slug, requestId) {
|
||||
return jsonOrThrow(await fetch(
|
||||
`/api/rfcs/${slug}/contribution-requests/${requestId}/accept`, { method: 'POST' },
|
||||
))
|
||||
}
|
||||
|
||||
export async function declineContributionRequest(slug, requestId) {
|
||||
return jsonOrThrow(await fetch(
|
||||
`/api/rfcs/${slug}/contribution-requests/${requestId}/decline`, { method: 'POST' },
|
||||
))
|
||||
}
|
||||
|
||||
export async function mergeProposal(prNumber) {
|
||||
const res = await fetch(`/api/proposals/${prNumber}/merge`, { method: 'POST' })
|
||||
return jsonOrThrow(res)
|
||||
@@ -197,6 +407,94 @@ 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
|
||||
// table the branch chat uses, with a null branch the schema already
|
||||
// supported. Contribution still requires a PR (api_prs / openPR), so
|
||||
// these endpoints are read+write for discussion only.
|
||||
|
||||
export async function listDiscussionThreads(slug) {
|
||||
return jsonOrThrow(await fetch(`/api/rfcs/${slug}/discussion/threads`))
|
||||
}
|
||||
|
||||
export async function createDiscussionThread(slug, { label = null, message = null } = {}) {
|
||||
const res = await fetch(`/api/rfcs/${slug}/discussion/threads`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ label, message }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function getDiscussionThreadMessages(slug, threadId) {
|
||||
return jsonOrThrow(await fetch(
|
||||
`/api/rfcs/${slug}/discussion/threads/${threadId}/messages`,
|
||||
))
|
||||
}
|
||||
|
||||
export async function postDiscussionMessage(slug, threadId, { text, quote = null }) {
|
||||
const res = await fetch(
|
||||
`/api/rfcs/${slug}/discussion/threads/${threadId}/messages`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text, quote }),
|
||||
},
|
||||
)
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function resolveDiscussionThread(slug, threadId) {
|
||||
const res = await fetch(
|
||||
`/api/rfcs/${slug}/discussion/threads/${threadId}/resolve`,
|
||||
{ method: 'POST' },
|
||||
)
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
// ── Slice 4: super-draft body editing (§9.5) ─────────────────────────────
|
||||
|
||||
export async function startEditBranch(slug, body = {}) {
|
||||
@@ -232,18 +530,19 @@ export async function listBlockingPRs(slug) {
|
||||
return jsonOrThrow(await fetch(`/api/rfcs/${slug}/blocking-prs`))
|
||||
}
|
||||
|
||||
export async function graduateCheck(slug, { id, repo }) {
|
||||
export async function graduateCheck(slug, { id }) {
|
||||
// Meta-only topology (§13.2): two fields — integer id + owners. No
|
||||
// repo name to validate.
|
||||
const params = new URLSearchParams()
|
||||
if (id != null) params.set('id', id)
|
||||
if (repo != null) params.set('repo', repo)
|
||||
return jsonOrThrow(await fetch(`/api/rfcs/${slug}/graduate/check?${params}`))
|
||||
}
|
||||
|
||||
export async function startGraduation(slug, { rfcId, repoName, owners }) {
|
||||
export async function startGraduation(slug, { rfcId, owners }) {
|
||||
const res = await fetch(`/api/rfcs/${slug}/graduate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ rfc_id: rfcId, repo_name: repoName, owners }),
|
||||
body: JSON.stringify({ rfc_id: rfcId, owners }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
@@ -279,13 +578,14 @@ export async function draftPRText(slug, branch) {
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function openPR(slug, branch, { title, description }) {
|
||||
export async function openPR(slug, branch, { title, description, proposedUseCase }) {
|
||||
const res = await fetch(
|
||||
`/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/open-pr`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title, description }),
|
||||
// #26: proposed_use_case is optional; null when blank.
|
||||
body: JSON.stringify({ title, description, proposed_use_case: proposedUseCase || null }),
|
||||
},
|
||||
)
|
||||
return jsonOrThrow(res)
|
||||
@@ -462,6 +762,23 @@ export async function setQuietHours({ start, end, timezone } = {}) {
|
||||
}))
|
||||
}
|
||||
|
||||
// v0.13.0 / roadmap item #11: cookie consent (SPEC §14.5).
|
||||
export async function getCookieConsent() {
|
||||
return jsonOrThrow(await fetch('/api/users/me/cookie-consent'))
|
||||
}
|
||||
|
||||
export async function setCookieConsent({ analytics, other } = {}) {
|
||||
return jsonOrThrow(await fetch('/api/users/me/cookie-consent', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
essential: true,
|
||||
analytics: !!analytics,
|
||||
other: !!other,
|
||||
}),
|
||||
}))
|
||||
}
|
||||
|
||||
export async function muteUser(userId) {
|
||||
return jsonOrThrow(await fetch(`/api/users/${userId}/notification-mute`, { method: 'POST' }))
|
||||
}
|
||||
@@ -486,6 +803,89 @@ export async function getPhilosophy() {
|
||||
return jsonOrThrow(await fetch('/api/philosophy'))
|
||||
}
|
||||
|
||||
export async function getDocs() {
|
||||
return jsonOrThrow(await fetch('/api/docs'))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// v0.19.0 / roadmap item #30 — /api/docs/sessions/* surface
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// The framework mediates reads against the public
|
||||
// `wiggleverse/ohm-session-history` gitea repo so the rendered
|
||||
// `/docs/sessions/*` surface inherits the same chrome as
|
||||
// `/docs/user-guide`. Three text-bearing endpoints return markdown
|
||||
// (Content-Type: text/markdown) and the manifest returns JSON. We
|
||||
// wrap each into a small helper.
|
||||
//
|
||||
// 404 from `getSessionAbout` / `getSessionTranscript` / `getSessionIndex`
|
||||
// throws an Error with `.status === 404` so the UI can render its own
|
||||
// empty-state. 502 (gitea unreachable) throws `.status === 502` so
|
||||
// the UI can offer a retry button.
|
||||
|
||||
export async function getSessionsManifest() {
|
||||
// Manifest 404 is mapped server-side to HTTP 200 + `{}` so this
|
||||
// helper never throws on the empty-state path.
|
||||
return jsonOrThrow(await fetch('/api/docs/sessions/manifest'))
|
||||
}
|
||||
|
||||
async function _textOrThrow(res) {
|
||||
if (!res.ok) {
|
||||
let detail = ''
|
||||
try {
|
||||
const body = await res.json()
|
||||
detail = body.detail || JSON.stringify(body)
|
||||
} catch {
|
||||
detail = await res.text()
|
||||
}
|
||||
const error = new Error(detail || `HTTP ${res.status}`)
|
||||
error.status = res.status
|
||||
throw error
|
||||
}
|
||||
return res.text()
|
||||
}
|
||||
|
||||
export async function getSessionsAbout() {
|
||||
return _textOrThrow(await fetch('/api/docs/sessions/about'))
|
||||
}
|
||||
|
||||
export async function getSessionTranscript(nnnn, filename) {
|
||||
return _textOrThrow(await fetch(
|
||||
`/api/docs/sessions/${encodeURIComponent(nnnn)}/${encodeURIComponent(filename)}`
|
||||
))
|
||||
}
|
||||
|
||||
export async function getSessionIndex(nnnn) {
|
||||
return jsonOrThrow(await fetch(
|
||||
`/api/docs/sessions/${encodeURIComponent(nnnn)}/index`
|
||||
))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// v0.20.0 — /api/docs/specs/* surface
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Sibling of the docs-sessions helpers above. The framework mediates
|
||||
// reads against the configured spec URLs (default: rfc-app's own
|
||||
// SPEC.md + flotilla's SPEC.md on `git.wiggleverse.org`) so the
|
||||
// `/docs/specs/*` route inherits the same chrome as `/docs/user-guide`
|
||||
// and `/docs/sessions/*`. The manifest endpoint always returns 200 +
|
||||
// {specs: [...]} — a malformed `OHM_DOCS_SPECS` env var falls back to
|
||||
// the framework default at parse time on the backend.
|
||||
//
|
||||
// 404 from `getSpec` throws `.status === 404`; 502 throws `.status === 502`,
|
||||
// matching the docs-sessions helper convention.
|
||||
|
||||
export async function getSpecsManifest() {
|
||||
return jsonOrThrow(await fetch('/api/docs/specs/manifest'))
|
||||
}
|
||||
|
||||
export async function getSpec(name) {
|
||||
return _textOrThrow(await fetch(
|
||||
`/api/docs/specs/${encodeURIComponent(name)}`
|
||||
))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Slice 7: admin neighborhood (§17 admin/* + user search for the §15.8 mute
|
||||
// typeahead).
|
||||
@@ -511,6 +911,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)
|
||||
@@ -534,6 +947,71 @@ export async function listGraduationQueue() {
|
||||
return jsonOrThrow(await fetch('/api/admin/graduation-queue'))
|
||||
}
|
||||
|
||||
export async function listAllowlist() {
|
||||
return jsonOrThrow(await fetch('/api/admin/allowlist'))
|
||||
}
|
||||
|
||||
export async function addAllowlistEmail(email, note) {
|
||||
return jsonOrThrow(await fetch('/api/admin/allowlist', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, note: note || null }),
|
||||
}))
|
||||
}
|
||||
|
||||
export async function removeAllowlistEmail(email) {
|
||||
return jsonOrThrow(await fetch(`/api/admin/allowlist/${encodeURIComponent(email)}`, {
|
||||
method: 'DELETE',
|
||||
}))
|
||||
}
|
||||
|
||||
// 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,26 @@ 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' },
|
||||
{ path: 'allowlist', label: 'Allowlist' },
|
||||
{ path: 'graduation', label: 'Graduation queue' },
|
||||
{ path: 'audit', label: 'Audit log' },
|
||||
{ path: 'permissions', label: 'Permission events' },
|
||||
@@ -54,6 +67,7 @@ export default function Admin({ viewer }) {
|
||||
<Routes>
|
||||
<Route index element={<UsersTab />} />
|
||||
<Route path="users" element={<UsersTab />} />
|
||||
<Route path="allowlist" element={<AllowlistTab />} />
|
||||
<Route path="graduation" element={<GraduationTab />} />
|
||||
<Route path="audit" element={<AuditTab />} />
|
||||
<Route path="permissions" element={<PermissionsTab />} />
|
||||
@@ -63,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)
|
||||
@@ -108,68 +140,564 @@ 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>
|
||||
<div className="admin-tab-heading">
|
||||
<h2>Users</h2>
|
||||
{/* 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>
|
||||
</div>
|
||||
<p className="muted">
|
||||
Role changes write to <code>permission_events</code>. The §6.2
|
||||
write-mute applies to contributors only — promote to admin to
|
||||
remove a user's ability to write without silencing them.
|
||||
The pending bucket is the beta-access review queue (§6.1 /
|
||||
v0.8.0). Grant or revoke writes to <code>permission_events</code>
|
||||
and stamps <code>permission_decided_by</code> +{' '}
|
||||
<code>permission_decided_at</code>. Role and write-mute controls
|
||||
retain their v0.7.0 semantics — promote to admin to remove a
|
||||
user's ability to write without silencing them.
|
||||
</p>
|
||||
</header>
|
||||
{error && <p className="settings-note warning">{error}</p>}
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Role</th>
|
||||
<th>Write-muted</th>
|
||||
<th>Last seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map(u => (
|
||||
<tr key={u.id}>
|
||||
<td>
|
||||
<div className="user-cell">
|
||||
<span className="user-handle">@{u.gitea_login}</span>
|
||||
<span className="muted">{u.display_name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<select
|
||||
value={u.role}
|
||||
onChange={e => changeRole(u.id, e.target.value)}
|
||||
disabled={!!busy[u.id]}
|
||||
>
|
||||
<option value="contributor">Contributor</option>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="owner">Owner</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
{u.role === 'contributor' ? (
|
||||
<label className="mute-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!u.muted}
|
||||
onChange={e => toggleMute(u.id, e.target.checked)}
|
||||
disabled={!!busy[u.id]}
|
||||
/>
|
||||
{u.muted ? 'Muted' : 'Active'}
|
||||
</label>
|
||||
) : (
|
||||
<span className="muted">N/A</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="muted">{u.last_seen_at}</td>
|
||||
{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
|
||||
// When there's no gitea_login the handle already IS the email, so the
|
||||
// subline would otherwise repeat it. Only append the email when it adds
|
||||
// something the handle doesn't already show.
|
||||
const showEmail = u.email && u.email !== handle
|
||||
return (
|
||||
<>
|
||||
<tr>
|
||||
<td>
|
||||
<div className="user-cell">
|
||||
<div className="user-cell-handle">
|
||||
<span className="user-handle">{handle}</span>
|
||||
{pendingInvite && (
|
||||
<span
|
||||
className="invite-badge"
|
||||
title={`Admin-created invite; expires ${pendingInvite.expires_at}`}
|
||||
>pending invite</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="muted">
|
||||
{fullName || u.display_name}
|
||||
{showEmail ? ` · ${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>
|
||||
<TimeCell value={u.created_at} />
|
||||
{/* An unclaimed admin invite has provably never authenticated, so
|
||||
last_seen_at is just the row-creation default (it equals
|
||||
created_at). Render the truth — "Never" — rather than a
|
||||
timestamp that reads like a real visit. */}
|
||||
<TimeCell
|
||||
value={pendingInvite ? null : u.last_seen_at}
|
||||
emptyLabel={pendingInvite ? 'Never' : '—'}
|
||||
/>
|
||||
</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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Render a "YYYY-MM-DD HH:MM:SS" timestamp as an intentional date-over-time
|
||||
// stack (date prominent, time quiet below) rather than letting a narrow
|
||||
// column wrap the value mid-string. Falls back to an em-dash when absent.
|
||||
function TimeCell({ value, emptyLabel = '—' }) {
|
||||
if (!value) return <td className="muted">{emptyLabel}</td>
|
||||
const [date, ...rest] = String(value).split(' ')
|
||||
const time = rest.join(' ')
|
||||
return (
|
||||
<td className="user-when">
|
||||
<span className="user-when-date">{date}</span>
|
||||
{time && <span className="user-when-time muted">{time}</span>}
|
||||
</td>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Private-beta allowlist (`migrations/011_allowlist.sql`) ────────────────
|
||||
|
||||
function AllowlistTab() {
|
||||
const [data, setData] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [draftEmail, setDraftEmail] = useState('')
|
||||
const [draftNote, setDraftNote] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
async function refresh() {
|
||||
setError(null)
|
||||
try {
|
||||
setData(await listAllowlist())
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { refresh() }, [])
|
||||
|
||||
async function handleAdd(event) {
|
||||
event.preventDefault()
|
||||
const email = draftEmail.trim()
|
||||
if (!email) return
|
||||
setBusy(true); setError(null)
|
||||
try {
|
||||
await addAllowlistEmail(email, draftNote.trim() || null)
|
||||
setDraftEmail(''); setDraftNote('')
|
||||
await refresh()
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemove(email) {
|
||||
if (!confirm(`Remove ${email} from the allowlist?`)) return
|
||||
setBusy(true); setError(null)
|
||||
try {
|
||||
await removeAllowlistEmail(email)
|
||||
await refresh()
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (data == null && !error) return <p className="muted">Loading allowlist…</p>
|
||||
|
||||
return (
|
||||
<div className="admin-tab">
|
||||
<header className="admin-tab-header">
|
||||
<h2>Allowlist</h2>
|
||||
<p className="muted">
|
||||
When this list has any rows, OAuth sign-in is restricted: only emails
|
||||
here (case-insensitive) may sign in. Already-provisioned users are
|
||||
grandfathered by their Gitea ID and never re-checked. An empty list
|
||||
turns the gate off entirely.
|
||||
</p>
|
||||
<p className="muted">
|
||||
Status:{' '}
|
||||
<strong>{data?.active ? 'Private beta — gate active' : 'Open — anyone can sign in'}</strong>
|
||||
</p>
|
||||
</header>
|
||||
{error && <p className="settings-note warning">{error}</p>}
|
||||
|
||||
<form className="allowlist-add" onSubmit={handleAdd}>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="email@example.com"
|
||||
value={draftEmail}
|
||||
onChange={e => setDraftEmail(e.target.value)}
|
||||
required
|
||||
disabled={busy}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Note (optional)"
|
||||
value={draftNote}
|
||||
onChange={e => setDraftNote(e.target.value)}
|
||||
maxLength={200}
|
||||
disabled={busy}
|
||||
/>
|
||||
<button type="submit" className="btn-primary" disabled={busy || !draftEmail.trim()}>
|
||||
Add to allowlist
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{data?.items?.length > 0 ? (
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Email</th>
|
||||
<th>Note</th>
|
||||
<th>Added by</th>
|
||||
<th>Added at</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.items.map(r => (
|
||||
<tr key={r.email}>
|
||||
<td><code>{r.email}</code></td>
|
||||
<td>{r.note || <span className="muted">—</span>}</td>
|
||||
<td>
|
||||
{r.added_by_login
|
||||
? <span>@{r.added_by_login}</span>
|
||||
: <span className="muted">—</span>}
|
||||
</td>
|
||||
<td className="muted">{r.created_at}</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-link-quiet"
|
||||
onClick={() => handleRemove(r.email)}
|
||||
disabled={busy}
|
||||
>Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<p className="muted">
|
||||
No allow-listed emails yet. Add the first one to put the deployment
|
||||
into private-beta mode.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// BetaPending.jsx — the "your request is in review" page (§6.1 / §14.1).
|
||||
//
|
||||
// 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({ 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>
|
||||
{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">
|
||||
Questions? Contact <strong>{contact}</strong>.
|
||||
</p>
|
||||
) : (
|
||||
<p className="beta-pending-contact">
|
||||
Questions? Contact the deployment operator.
|
||||
</p>
|
||||
)}
|
||||
<div className="beta-pending-actions">
|
||||
<Link className="btn-primary" to="/">Browse the catalog</Link>
|
||||
<Link className="btn-link-quiet" to="/philosophy">Read the philosophy →</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -22,7 +22,7 @@ const SORT_OPTIONS = [
|
||||
{ id: 'state', label: 'State' },
|
||||
]
|
||||
|
||||
export default function Catalog({ onProposeRFC, version }) {
|
||||
export default function Catalog({ viewer, onProposeRFC, version }) {
|
||||
const [rfcs, setRfcs] = useState([])
|
||||
const [proposals, setProposals] = useState([])
|
||||
const [search, setSearch] = useState('')
|
||||
@@ -93,7 +93,7 @@ export default function Catalog({ onProposeRFC, version }) {
|
||||
{filtered.length === 0 ? (
|
||||
<div style={{ padding: '24px 14px', color: '#999', fontSize: 13 }}>
|
||||
{rfcs.length === 0
|
||||
? 'No RFCs in the catalog yet. Propose one below.'
|
||||
? (viewer ? 'No RFCs in the catalog yet. Propose one below.' : 'No RFCs in the catalog yet.')
|
||||
: 'No matches.'}
|
||||
</div>
|
||||
) : (
|
||||
@@ -148,7 +148,13 @@ export default function Catalog({ onProposeRFC, version }) {
|
||||
</div>
|
||||
|
||||
<div className="catalog-footer">
|
||||
<button className="btn-propose" onClick={onProposeRFC}>+ Propose New RFC</button>
|
||||
{viewer ? (
|
||||
<button className="btn-propose" onClick={onProposeRFC}>+ Propose New RFC</button>
|
||||
) : (
|
||||
<a className="btn-propose" href="/auth/login" title="Private beta — only invited emails can propose">
|
||||
Sign in to propose <span className="beta-chip">Beta</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
// ContributeRequestForm.jsx — roadmap #28 Part 3.
|
||||
//
|
||||
// The "ask to contribute" popover, opened from an `rfc-pending` affordance
|
||||
// in LinkedText (App reads `?contribute=<slug>&term=<term>`). It loads the
|
||||
// contribution target (RFC title + owner display + the viewer's
|
||||
// eligibility), shows the framing line "<owner> is working on an RFC for
|
||||
// '<term>'", and collects the three #15/#26-vocabulary fields:
|
||||
//
|
||||
// * Who I am (required, free-text)
|
||||
// * Why I'm asking (required, free-text)
|
||||
// * What I'd use it for (optional, mirrors #26)
|
||||
//
|
||||
// Submitting POSTs the request, which lands in each owner's §15 inbox.
|
||||
// When the viewer isn't eligible (anonymous, already a collaborator, or
|
||||
// has a pending ask) the form shows the backend's reason instead.
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { contributionTarget, requestContribution } from '../api'
|
||||
|
||||
export default function ContributeRequestForm({ slug, term, onClose }) {
|
||||
const [target, setTarget] = useState(null)
|
||||
const [loadError, setLoadError] = useState(null)
|
||||
const [whoIAm, setWhoIAm] = useState('')
|
||||
const [why, setWhy] = useState('')
|
||||
const [useCase, setUseCase] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [done, setDone] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let live = true
|
||||
contributionTarget(slug)
|
||||
.then(t => { if (live) setTarget(t) })
|
||||
.catch(err => { if (live) setLoadError(err.message || 'Could not load this RFC.') })
|
||||
return () => { live = false }
|
||||
}, [slug])
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault()
|
||||
if (!whoIAm.trim() || !why.trim()) return
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
await requestContribution(slug, {
|
||||
matchedTerm: term || target?.title || slug,
|
||||
whoIAm: whoIAm.trim(),
|
||||
why: why.trim(),
|
||||
useCase: useCase.trim() || null,
|
||||
})
|
||||
setDone(true)
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not send your request.')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const owner = target?.owner || 'The owner'
|
||||
const label = term || target?.title || slug
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||
<div className="modal">
|
||||
<div className="modal-header">
|
||||
<h2>Ask to contribute</h2>
|
||||
<button className="modal-close" onClick={onClose}>×</button>
|
||||
</div>
|
||||
|
||||
{loadError && (
|
||||
<div className="modal-body"><p className="field-error">{loadError}</p></div>
|
||||
)}
|
||||
|
||||
{!loadError && done && (
|
||||
<>
|
||||
<div className="modal-body">
|
||||
<p>
|
||||
Your request has been sent to <strong>{owner}</strong>. You'll hear back
|
||||
in your inbox; if it's accepted you'll get an invitation by email to
|
||||
join the RFC.
|
||||
</p>
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn-primary" onClick={onClose}>Done</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!loadError && !done && target && !target.eligible && (
|
||||
<>
|
||||
<div className="modal-body">
|
||||
<p className="field-help" style={{ marginTop: 0 }}>
|
||||
{owner} is working on an RFC for <strong>'{label}'</strong>.
|
||||
</p>
|
||||
<p>{target.already_requested
|
||||
? "You've already asked to contribute to this RFC — the owner has your request."
|
||||
: (target.reason || 'You cannot ask to contribute to this RFC right now.')}</p>
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn-secondary" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!loadError && !done && target && target.eligible && (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="modal-body">
|
||||
<p className="field-help" style={{ marginTop: 0 }}>
|
||||
<strong>{owner}</strong> is working on an RFC for <strong>'{label}'</strong>.
|
||||
Tell them a little about why you'd like to contribute.
|
||||
</p>
|
||||
|
||||
<label htmlFor="contribute-who">Who I am</label>
|
||||
<textarea
|
||||
id="contribute-who"
|
||||
value={whoIAm}
|
||||
onChange={e => setWhoIAm(e.target.value)}
|
||||
placeholder="Your name and a sentence of context."
|
||||
rows={2}
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
|
||||
<label htmlFor="contribute-why">Why I'm asking to contribute</label>
|
||||
<textarea
|
||||
id="contribute-why"
|
||||
value={why}
|
||||
onChange={e => setWhy(e.target.value)}
|
||||
placeholder="What you'd bring, or what draws you to this RFC."
|
||||
rows={3}
|
||||
required
|
||||
/>
|
||||
|
||||
<label htmlFor="contribute-use-case">What I'd use the RFC for (optional)</label>
|
||||
<textarea
|
||||
id="contribute-use-case"
|
||||
value={useCase}
|
||||
onChange={e => setUseCase(e.target.value)}
|
||||
placeholder="The concrete thing you intend to build or do with it. Optional."
|
||||
rows={2}
|
||||
/>
|
||||
|
||||
{error && <p className="field-error">{error}</p>}
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn-secondary" onClick={onClose}>Cancel</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
disabled={!whoIAm.trim() || !why.trim() || submitting}
|
||||
>
|
||||
{submitting ? 'Sending…' : 'Send request'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{!loadError && !done && !target && (
|
||||
<div className="modal-body"><p className="field-help">Loading…</p></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user