Compare commits
141 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c2f566512a | |||
| 39ce54fbcc | |||
| 55d04ce4ca | |||
| bd6dc6524a | |||
| 17bdd5fd9a | |||
| 2b32e124ab | |||
| 98eea3e2d6 | |||
| 0c654b173d | |||
| f57d4080dc | |||
| 91b0fb358c | |||
| 868391870c | |||
| 74476423ba | |||
| 599e7018f6 | |||
| 4ffff6b677 | |||
| aaf7b09bbe | |||
| 4f72aa31e0 | |||
| 9ca07a3f81 | |||
| 867f2504d6 | |||
| 08bdea8539 | |||
| 0de91fe35c | |||
| 2f5d09aef5 | |||
| 87279fc545 | |||
| 9c0e3b60ac | |||
| 31d680be54 | |||
| f758fe072f | |||
| 33c67ccc09 | |||
| 508a8cb6d0 | |||
| fec51bdbb6 | |||
| 455ef33b29 | |||
| 9f548a340d | |||
| 569066ef48 | |||
| a117fbb521 | |||
| d5213fc2da | |||
| 380e1f9782 | |||
| db57caf8a1 | |||
| 999c4b65ef | |||
| 0252e40527 | |||
| 97ba3ae9b5 | |||
| 6c2bdb3c0a | |||
| 0f6b2b464b | |||
| f114af8ce0 | |||
| a2dc29af9c | |||
| 8004b2a123 | |||
| 69fd0cb2f0 | |||
| 87ddb845f4 | |||
| 76207bbb62 | |||
| 2fe2a719ac | |||
| fe47eefdd9 | |||
| e8ce3cd228 | |||
| 07e003e5fc | |||
| c386b05960 | |||
| 48fd6f9675 | |||
| cecc6c0b41 | |||
| f1b03dffef | |||
| 759a42e589 | |||
| 2746242022 | |||
| f7bd466f31 | |||
| 539d063c22 | |||
| 27a0a0443b | |||
| 7d05125381 | |||
| 8f21dc5f9c | |||
| 49b741243e | |||
| 2d9022b19e | |||
| 0bccae1260 | |||
| d8661d5025 | |||
| d73a9e2860 | |||
| 597f6bc92b | |||
| 3a3104f4c6 | |||
| dd72f913a3 | |||
| 1b011ed483 | |||
| 2cf7db4bff | |||
| 6f356d3598 | |||
| 7703fa233a | |||
| 1dab24eef0 | |||
| 503689bf1a | |||
| ad2ece18fa | |||
| 57b2fc5205 | |||
| 34a65e099e | |||
| 848de4cd8a | |||
| 49ba06e0c2 | |||
| 6f901e3e2c | |||
| 7aba89655d | |||
| 0062510a4e | |||
| 714c2aed86 | |||
| 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 |
@@ -0,0 +1,15 @@
|
||||
# Keep the preview build context lean + reproducible.
|
||||
.git
|
||||
.gitea
|
||||
**/__pycache__/
|
||||
**/*.pyc
|
||||
backend/.venv/
|
||||
backend/data/
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
e2e/
|
||||
mockups/
|
||||
docs/
|
||||
*.md
|
||||
!VERSION
|
||||
.pytest_cache/
|
||||
+2325
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,5 @@
|
||||
@~/.claude/wiggleverse.md
|
||||
|
||||
# Working in rfc-app
|
||||
|
||||
This is the framework — the software that hosts an RFC standardization
|
||||
|
||||
+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.
|
||||
@@ -39,23 +39,44 @@ thread. Every write affordance is replaced with a sign-in prompt.
|
||||
|
||||
## Signing in
|
||||
|
||||
While the framework is in private beta, only invited email addresses
|
||||
can complete sign-in. If your email is on the allowlist, the
|
||||
"Sign in" button in the header completes the flow and lands you on
|
||||
the catalog with full read and write access. If your email is not on
|
||||
the allowlist, you'll be sent to a short "pending" page explaining
|
||||
the gate.
|
||||
Anyone can start the sign-in flow with their own email address — there
|
||||
is no invite-only allowlist. Sign-in is passwordless:
|
||||
|
||||
Once you have an account, you're a **contributor** by default — the
|
||||
role that grants every write affordance the app exposes, scoped by
|
||||
the per-RFC and per-branch rules described below.
|
||||
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 four
|
||||
the bottom of the catalog opens a small modal that collects five
|
||||
things:
|
||||
|
||||
- **Title.** The word, concept, or topic this RFC would define.
|
||||
@@ -65,8 +86,13 @@ things:
|
||||
inline.
|
||||
- **Pitch.** One or two paragraphs answering *why this RFC is
|
||||
needed*. This becomes the body of the entry.
|
||||
- **Tags.** Optional. The AI suggests tags from the pitch; you can
|
||||
accept, dismiss, or type your own.
|
||||
- **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
|
||||
@@ -167,6 +193,51 @@ 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
|
||||
@@ -505,6 +576,14 @@ Each role is a strict superset of the one below it.
|
||||
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
|
||||
@@ -594,6 +673,20 @@ 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
|
||||
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
# rfc-app container image — the Cloud Run keystone for per-PR preview
|
||||
# environments (flotilla SPEC §15). NOT used by production: prod still deploys
|
||||
# via the pin-based on-VM gesture (flotilla §8). This image exists so flotilla
|
||||
# `preview up --pr=N` can build a PR's tree and run it as an ephemeral,
|
||||
# scale-to-zero Cloud Run service with a SEEDED SYNTHETIC database and ZERO real
|
||||
# secrets (test-secret env only — flotilla §15 / §3 invariant 1).
|
||||
#
|
||||
# Single container, single port: nginx serves the built SPA on $PORT (Cloud Run
|
||||
# injects it) and reverse-proxies /api/ + /auth/ to a single-process uvicorn on
|
||||
# 127.0.0.1:8000 — mirroring the prod nginx + systemd split
|
||||
# (deploy/nginx/ohm.wiggleverse.org.conf, deploy/systemd/rfc-app.service), so a
|
||||
# preview behaves like prod minus the secrets. Single uvicorn process + single
|
||||
# SQLite file, per §4.2 (never scale workers).
|
||||
#
|
||||
# Build context is the repo root: docker build -t <image> .
|
||||
|
||||
# ---- stage 1: build the Vite SPA -------------------------------------------
|
||||
FROM node:20-slim AS web
|
||||
WORKDIR /app/frontend
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY frontend/ ./
|
||||
# VITE_* values are baked into the bundle at build time (intentionally public —
|
||||
# §8 phase-5 note). VITE_APP_NAME is REQUIRED by vite.config.js (the framework
|
||||
# ships no default — each deployment names itself); previews self-name. Turnstile
|
||||
# uses Cloudflare's always-pass test SITE key so the widget renders + auto-passes.
|
||||
ARG VITE_APP_NAME="RFC App (preview)"
|
||||
ARG VITE_TURNSTILE_SITE_KEY=1x00000000000000000000AA
|
||||
ARG VITE_AMPLITUDE_API_KEY=
|
||||
RUN VITE_APP_NAME="$VITE_APP_NAME" \
|
||||
VITE_TURNSTILE_SITE_KEY="$VITE_TURNSTILE_SITE_KEY" \
|
||||
VITE_AMPLITUDE_API_KEY="$VITE_AMPLITUDE_API_KEY" \
|
||||
npm run build
|
||||
|
||||
# ---- stage 2: runtime (backend + nginx) ------------------------------------
|
||||
FROM python:3.12-slim AS runtime
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends nginx gettext-base sqlite3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /opt/rfc-app
|
||||
|
||||
# Backend deps first for layer caching.
|
||||
COPY backend/requirements.txt backend/requirements.txt
|
||||
RUN pip install --no-cache-dir -r backend/requirements.txt
|
||||
|
||||
COPY backend/ backend/
|
||||
COPY deploy/preview/ deploy/preview/
|
||||
# health.py reads VERSION at parents[2] (== /opt/rfc-app/VERSION).
|
||||
COPY VERSION ./
|
||||
COPY --from=web /app/frontend/dist/ frontend/dist/
|
||||
|
||||
RUN chmod +x deploy/preview/entrypoint.sh
|
||||
|
||||
# Cloud Run injects $PORT (default 8080); the entrypoint renders nginx against
|
||||
# it. The synthetic preview DB lives on the container's ephemeral filesystem and
|
||||
# dies with the instance (zero create/seed/drop lifecycle — §15).
|
||||
ENV PORT=8080 \
|
||||
DATABASE_PATH=/opt/rfc-app/backend/data/rfc-app.db
|
||||
EXPOSE 8080
|
||||
|
||||
ENTRYPOINT ["deploy/preview/entrypoint.sh"]
|
||||
@@ -0,0 +1,19 @@
|
||||
.PHONY: tier1-up tier1-down tier1-logs fe-unit e2e e2e-install
|
||||
|
||||
tier1-up:
|
||||
docker compose -f testing/docker-compose.yml up --build -d
|
||||
|
||||
tier1-down:
|
||||
docker compose -f testing/docker-compose.yml down -v
|
||||
|
||||
tier1-logs:
|
||||
docker compose -f testing/docker-compose.yml logs -f
|
||||
|
||||
fe-unit:
|
||||
cd frontend && npm run test:run
|
||||
|
||||
e2e-install:
|
||||
cd e2e && npm ci && npx playwright install chromium
|
||||
|
||||
e2e:
|
||||
cd e2e && BASE_URL=$${BASE_URL:-http://localhost:8080} MAILSINK_URL=$${MAILSINK_URL:-http://localhost:8025} npm run e2e
|
||||
+41
-3
@@ -9,10 +9,18 @@ GITEA_URL=http://localhost:3000
|
||||
GITEA_BOT_USER=rfc-bot
|
||||
GITEA_BOT_TOKEN=
|
||||
|
||||
# The Gitea org or user that owns the meta repo and every RFC repo
|
||||
# the bot will create on graduation.
|
||||
# The Gitea org or user that owns every RFC repo the bot will create on
|
||||
# graduation.
|
||||
GITEA_ORG=wiggleverse
|
||||
META_REPO=meta
|
||||
|
||||
# §22.2 — the project registry repo (REQUIRED). The framework reads
|
||||
# `projects.yaml` at its root to learn which projects exist. The repo name is
|
||||
# the deployment's choice; the app fails to start if this is unset.
|
||||
REGISTRY_REPO=registry
|
||||
# §22.13 — optional id for the bootstrap/default project. Reserved for the
|
||||
# Plan B re-stamp; leave unset in Plan A (the default project id stays
|
||||
# `default`). When set, it must match an `id` in projects.yaml.
|
||||
# DEFAULT_PROJECT_ID=ohm
|
||||
|
||||
# --- OAuth (Gitea) ---
|
||||
# In Gitea: Site Administration → Applications → Add OAuth2 Application.
|
||||
@@ -38,10 +46,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
|
||||
@@ -92,3 +112,21 @@ OTC_TTL_MINUTES=10
|
||||
# loud-failure shape so the abuse path is visible). Set to 0 to
|
||||
# disable the cooldown — useful for tests but never in production.
|
||||
OTC_REQUEST_COOLDOWN_SECONDS=60
|
||||
|
||||
# --- v0.12.0: CloudFlare Turnstile gate on OTC dispatch (§6.2, item #10) ---
|
||||
# Provision a Turnstile site at dash.cloudflare.com → Turnstile → Add
|
||||
# site. The site key (public) goes in `frontend/.env` as
|
||||
# VITE_TURNSTILE_SITE_KEY. The secret key (private) goes here and is
|
||||
# what the backend POSTs to /siteverify alongside the user's response
|
||||
# token. Leave both unset for dev/test paths; the gate stays open when
|
||||
# the secret is absent AND TURNSTILE_REQUIRED=false (the default).
|
||||
CLOUDFLARE_TURNSTILE_SECRET=
|
||||
|
||||
# When `true`, /auth/otc/request fails closed (HTTP 500 "auth
|
||||
# misconfigured") if CLOUDFLARE_TURNSTILE_SECRET is unset. When `false`
|
||||
# (the default), a missing secret skips verification — useful in dev
|
||||
# and during the pre-rollout window when the operator hasn't wired
|
||||
# the secret yet. Flip to `true` once the secret is wired so a future
|
||||
# config drift surfaces as a loud 500 rather than a silent abuse-
|
||||
# defense disablement.
|
||||
TURNSTILE_REQUIRED=false
|
||||
|
||||
+735
-26
@@ -15,18 +15,28 @@ 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_collections,
|
||||
api_contributions,
|
||||
api_deployment,
|
||||
api_discussion,
|
||||
api_graduation,
|
||||
api_invitations,
|
||||
api_notifications,
|
||||
api_prs,
|
||||
auth,
|
||||
collections as collections_mod,
|
||||
projects as projects_mod,
|
||||
db,
|
||||
device_trust as device_trust_mod,
|
||||
docs as docs_mod,
|
||||
docs_sessions,
|
||||
docs_specs,
|
||||
entry as entry_mod,
|
||||
cache,
|
||||
funder,
|
||||
@@ -34,6 +44,7 @@ from . import (
|
||||
notify,
|
||||
philosophy,
|
||||
providers as providers_mod,
|
||||
tag_suggest,
|
||||
)
|
||||
from .bot import Bot
|
||||
from .config import Config
|
||||
@@ -46,6 +57,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):
|
||||
@@ -57,6 +84,18 @@ 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.
|
||||
@@ -101,6 +140,20 @@ def make_router(
|
||||
# Contribution still requires a PR (api_prs above); this surface
|
||||
# is for discussion that does not yet warrant a branch.
|
||||
router.include_router(api_discussion.make_router())
|
||||
# v0.16.0 (roadmap item #12): owner-only invite for per-RFC
|
||||
# contribution + discussion. The RFC's owner can invite specific
|
||||
# users by email to either open PRs or join the discussion; non-
|
||||
# invited users keep read access but cannot write (v0.6.0
|
||||
# contract extended to per-RFC scope).
|
||||
router.include_router(api_invitations.make_router())
|
||||
# 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())
|
||||
# §22.9/§22.10 (M3): runtime deployment + per-project config (replaces
|
||||
# VITE_APP_NAME) + the old-URL 308 redirects.
|
||||
router.include_router(api_deployment.make_router(config))
|
||||
router.include_router(api_collections.make_router(config, gitea, bot))
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# §17: /api/health — unauthenticated post-flight probe.
|
||||
@@ -135,6 +188,177 @@ def make_router(
|
||||
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.
|
||||
# ---------------------------------------------------------------
|
||||
@@ -167,6 +391,28 @@ def make_router(
|
||||
)
|
||||
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": {
|
||||
@@ -183,6 +429,10 @@ def make_router(
|
||||
"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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -252,30 +502,153 @@ def make_router(
|
||||
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
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.get("/api/rfcs")
|
||||
async def list_rfcs(request: Request) -> dict[str, Any]:
|
||||
async def list_rfcs(request: Request, unreviewed: str | None = None) -> dict[str, Any]:
|
||||
"""§7's left pane data.
|
||||
|
||||
The chip-filter / sort / search combinatorics live on the
|
||||
client — the server returns the full set and lets the chips
|
||||
narrow it. The set is small (hundreds, not thousands) for the
|
||||
foreseeable future, so paginating here would buy nothing.
|
||||
|
||||
§22.4c: pass `?unreviewed=true` to narrow to active entries
|
||||
still awaiting owner review.
|
||||
"""
|
||||
viewer = auth.current_user(request)
|
||||
viewer_id = viewer.user_id if viewer else None
|
||||
# §22.5: a gated project's entries never surface in a non-member's
|
||||
# catalog. For the single public default project this is the full set.
|
||||
visible = auth.visible_project_ids(viewer)
|
||||
if not visible:
|
||||
return {"items": []}
|
||||
placeholders = ",".join("?" for _ in visible)
|
||||
params = list(visible)
|
||||
unreviewed_clause = ""
|
||||
if unreviewed is not None and unreviewed.lower() in ("1", "true", "yes"):
|
||||
unreviewed_clause = " AND unreviewed = 1 AND state = 'active'"
|
||||
rows = db.conn().execute(
|
||||
"""
|
||||
SELECT slug, title, state, rfc_id, repo,
|
||||
owners_json, arbiters_json, tags_json,
|
||||
last_main_commit_at, last_entry_commit_at, updated_at
|
||||
FROM cached_rfcs
|
||||
WHERE state IN ('super-draft', 'active')
|
||||
ORDER BY COALESCE(last_main_commit_at, last_entry_commit_at) DESC
|
||||
"""
|
||||
f"""
|
||||
SELECT r.slug, r.title, r.state, r.rfc_id, r.repo,
|
||||
r.owners_json, r.arbiters_json, r.tags_json,
|
||||
r.last_main_commit_at, r.last_entry_commit_at, r.updated_at
|
||||
FROM cached_rfcs r JOIN collections c ON c.id = r.collection_id
|
||||
WHERE r.state IN ('super-draft', 'active')
|
||||
AND c.project_id IN ({placeholders}){unreviewed_clause}
|
||||
ORDER BY COALESCE(r.last_main_commit_at, r.last_entry_commit_at) DESC
|
||||
""",
|
||||
params,
|
||||
).fetchall()
|
||||
|
||||
starred = set()
|
||||
@@ -307,27 +680,227 @@ def make_router(
|
||||
return {"items": items}
|
||||
|
||||
@router.get("/api/rfcs/{slug}")
|
||||
async def get_rfc(slug: str) -> dict[str, Any]:
|
||||
async def get_rfc(slug: str, request: Request) -> dict[str, Any]:
|
||||
row = db.conn().execute(
|
||||
"SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, "Not found")
|
||||
return _serialize_rfc(row)
|
||||
viewer = auth.current_user(request)
|
||||
# §22.5 visibility gate (subtractive, §22.7): a gated project's entries
|
||||
# 404 to non-members. Recover the project via the entry's collection.
|
||||
auth.require_project_readable(viewer, auth.project_of_rfc(slug))
|
||||
# §13.7: a retired entry is removed from every browsing surface. The
|
||||
# sole exception is a site owner, so the un-retire affordance has
|
||||
# somewhere to live; everyone else gets a plain 404.
|
||||
if row["state"] == "retired" and (viewer is None or viewer.role != "owner"):
|
||||
raise HTTPException(404, "Not found")
|
||||
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
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# §22.4 (Plan B): per-project RFC serving — the catalog + entry view
|
||||
# scoped to one project, identified by its own slug namespace
|
||||
# (project_id, slug). The unscoped /api/rfcs[/{slug}] above stay as the
|
||||
# default-project compat path; the frontend reads these scoped routes so a
|
||||
# second project's corpus renders under /p/<id>/.
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
def _require_collection_in_project(collection_id: str, project_id: str) -> None:
|
||||
# §22 S2: a collection-scoped route 404s when the collection does not
|
||||
# belong to the project in the path (shape matches an unknown id).
|
||||
if collections_mod.project_of_collection(collection_id) != project_id:
|
||||
raise HTTPException(404, "Not found")
|
||||
|
||||
def _list_rfcs_for_collection(
|
||||
collection_id: str, viewer, unreviewed: str | None
|
||||
) -> dict[str, Any]:
|
||||
viewer_id = viewer.user_id if viewer else None
|
||||
unreviewed_clause = ""
|
||||
if unreviewed is not None and unreviewed.lower() in ("1", "true", "yes"):
|
||||
unreviewed_clause = " AND unreviewed = 1 AND state = 'active'"
|
||||
rows = db.conn().execute(
|
||||
f"""
|
||||
SELECT slug, title, state, rfc_id, repo,
|
||||
owners_json, arbiters_json, tags_json,
|
||||
last_main_commit_at, last_entry_commit_at, updated_at
|
||||
FROM cached_rfcs
|
||||
WHERE state IN ('super-draft', 'active')
|
||||
AND collection_id = ?{unreviewed_clause}
|
||||
ORDER BY COALESCE(last_main_commit_at, last_entry_commit_at) DESC
|
||||
""",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
starred = set()
|
||||
if viewer_id is not None:
|
||||
starred = {
|
||||
r["rfc_slug"]
|
||||
for r in db.conn().execute(
|
||||
"SELECT rfc_slug FROM stars WHERE user_id = ? AND collection_id = ?",
|
||||
(viewer_id, collection_id),
|
||||
)
|
||||
}
|
||||
items = [
|
||||
{
|
||||
"slug": r["slug"],
|
||||
"title": r["title"],
|
||||
"state": r["state"],
|
||||
"id": r["rfc_id"],
|
||||
"repo": r["repo"],
|
||||
"owners": json.loads(r["owners_json"] or "[]"),
|
||||
"arbiters": json.loads(r["arbiters_json"] or "[]"),
|
||||
"tags": json.loads(r["tags_json"] or "[]"),
|
||||
"last_active_at": r["last_main_commit_at"] or r["last_entry_commit_at"] or r["updated_at"],
|
||||
"starred_by_me": r["slug"] in starred,
|
||||
"has_open_prs": False,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
return {"items": items}
|
||||
|
||||
def _get_rfc_for_collection(collection_id: str, slug: str, viewer) -> dict[str, Any]:
|
||||
row = db.conn().execute(
|
||||
"SELECT * FROM cached_rfcs WHERE collection_id = ? AND slug = ?",
|
||||
(collection_id, slug),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, "Not found")
|
||||
if row["state"] == "retired" and (viewer is None or viewer.role != "owner"):
|
||||
raise HTTPException(404, "Not found")
|
||||
payload = _serialize_rfc(row)
|
||||
uc = db.conn().execute(
|
||||
"""
|
||||
SELECT use_case FROM proposed_use_cases
|
||||
WHERE scope = 'rfc' AND rfc_slug = ? AND collection_id = ?
|
||||
ORDER BY id DESC LIMIT 1
|
||||
""",
|
||||
(slug, collection_id),
|
||||
).fetchone()
|
||||
payload["proposed_use_case"] = uc["use_case"] if uc else None
|
||||
return payload
|
||||
|
||||
@router.get("/api/projects/{project_id}/rfcs")
|
||||
async def list_project_rfcs(
|
||||
project_id: str, request: Request, unreviewed: str | None = None
|
||||
) -> dict[str, Any]:
|
||||
viewer = auth.current_user(request)
|
||||
# §22.5 read gate: a gated project's catalog 404s to a non-member.
|
||||
auth.require_project_readable(viewer, project_id)
|
||||
# §22 S1: the project-scoped route serves the default collection.
|
||||
collection_id = collections_mod.default_collection_id(project_id)
|
||||
return _list_rfcs_for_collection(collection_id, viewer, unreviewed)
|
||||
|
||||
@router.get("/api/projects/{project_id}/rfcs/{slug}")
|
||||
async def get_project_rfc(project_id: str, slug: str, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.current_user(request)
|
||||
auth.require_project_readable(viewer, project_id)
|
||||
collection_id = collections_mod.default_collection_id(project_id)
|
||||
return _get_rfc_for_collection(collection_id, slug, viewer)
|
||||
|
||||
# §22 S2: collection-scoped serve + propose. The catalog/entry views read
|
||||
# these under /p/<project>/c/<collection>/; the project-scoped routes above
|
||||
# stay as the default-collection compat surface.
|
||||
@router.get("/api/projects/{project_id}/collections/{collection_id}/rfcs")
|
||||
async def list_collection_rfcs(
|
||||
project_id: str, collection_id: str, request: Request,
|
||||
unreviewed: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
viewer = auth.current_user(request)
|
||||
auth.require_project_readable(viewer, project_id)
|
||||
_require_collection_in_project(collection_id, project_id)
|
||||
# §22.5 (S3): a hidden/gated collection 404s to a non-scope-role viewer.
|
||||
auth.require_collection_readable(viewer, collection_id)
|
||||
return _list_rfcs_for_collection(collection_id, viewer, unreviewed)
|
||||
|
||||
@router.get("/api/projects/{project_id}/collections/{collection_id}/rfcs/{slug}")
|
||||
async def get_collection_rfc(
|
||||
project_id: str, collection_id: str, slug: str, request: Request
|
||||
) -> dict[str, Any]:
|
||||
viewer = auth.current_user(request)
|
||||
auth.require_project_readable(viewer, project_id)
|
||||
_require_collection_in_project(collection_id, project_id)
|
||||
auth.require_collection_readable(viewer, collection_id)
|
||||
return _get_rfc_for_collection(collection_id, slug, viewer)
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# §22.4c: mark-reviewed — clear an active entry's `unreviewed` flag
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.post("/api/projects/{project_id}/rfcs/{slug}/mark-reviewed")
|
||||
async def mark_reviewed(project_id: str, slug: str, request: Request) -> dict[str, Any]:
|
||||
"""§22.4c — clear an active entry's `unreviewed` flag. Authority is the
|
||||
§B.2 collection Owner (a collection/project/global Owner or deployment
|
||||
owner/admin reaching the entry's collection)."""
|
||||
viewer = auth.require_user(request)
|
||||
auth.require_project_readable(viewer, project_id)
|
||||
collection_id = collections_mod.default_collection_id(project_id)
|
||||
if not auth.is_collection_superuser(viewer, collection_id):
|
||||
raise HTTPException(403, "Only a collection owner can mark an entry reviewed")
|
||||
row = db.conn().execute(
|
||||
"SELECT state, unreviewed FROM cached_rfcs WHERE slug = ? AND collection_id = ?",
|
||||
(slug, collection_id),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, "Not found")
|
||||
if row["state"] != "active" or not row["unreviewed"]:
|
||||
raise HTTPException(409, "Entry is not an unreviewed active entry")
|
||||
try:
|
||||
await bot.mark_entry_reviewed(
|
||||
viewer.as_actor(),
|
||||
org=config.gitea_org,
|
||||
meta_repo=(projects_mod.default_content_repo(config) or ""),
|
||||
slug=slug,
|
||||
reviewed_by=viewer.gitea_login,
|
||||
reviewed_at=entry_mod.today(),
|
||||
)
|
||||
except GiteaError as e:
|
||||
raise HTTPException(502, f"Gitea: {e.detail}")
|
||||
await cache.refresh_meta_repo(config, gitea)
|
||||
return {"ok": True}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# §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]:
|
||||
async def list_proposals(request: Request) -> dict[str, Any]:
|
||||
# §22.5: idea PRs in a gated project never surface to non-members.
|
||||
visible = auth.visible_project_ids(auth.current_user(request))
|
||||
if not visible:
|
||||
return {"items": []}
|
||||
placeholders = ",".join("?" for _ in visible)
|
||||
rows = db.conn().execute(
|
||||
"""
|
||||
f"""
|
||||
SELECT rfc_slug, pr_number, title, description, opened_by, opened_at, state
|
||||
FROM cached_prs
|
||||
WHERE pr_kind = 'idea' AND state = 'open'
|
||||
AND project_id IN ({placeholders})
|
||||
ORDER BY opened_at DESC
|
||||
"""
|
||||
""",
|
||||
visible,
|
||||
).fetchall()
|
||||
return {
|
||||
"items": [
|
||||
@@ -338,6 +911,36 @@ 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
|
||||
]
|
||||
}
|
||||
|
||||
@router.get("/api/projects/{project_id}/proposals")
|
||||
async def list_project_proposals(project_id: str, request: Request) -> dict[str, Any]:
|
||||
# §22.4/§22.5: the pending idea-PRs scoped to one project.
|
||||
viewer = auth.current_user(request)
|
||||
auth.require_project_readable(viewer, project_id)
|
||||
rows = db.conn().execute(
|
||||
"""
|
||||
SELECT rfc_slug, pr_number, title, description, opened_by, opened_at, state
|
||||
FROM cached_prs
|
||||
WHERE pr_kind = 'idea' AND state = 'open' AND project_id = ?
|
||||
ORDER BY opened_at DESC
|
||||
""",
|
||||
(project_id,),
|
||||
).fetchall()
|
||||
return {
|
||||
"items": [
|
||||
{
|
||||
"slug": r["rfc_slug"],
|
||||
"pr_number": r["pr_number"],
|
||||
"title": r["title"],
|
||||
"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
|
||||
]
|
||||
@@ -361,10 +964,12 @@ def make_router(
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, "Not a proposal PR")
|
||||
# §22.5 visibility gate (subtractive): gated → 404 to non-members.
|
||||
auth.require_project_readable(auth.current_user(request), row["project_id"])
|
||||
# Read the proposed entry file from the head branch.
|
||||
slug = row["rfc_slug"]
|
||||
head = row["head_branch"]
|
||||
result = await gitea.read_file(config.gitea_org, config.meta_repo, f"rfcs/{slug}.md", ref=head)
|
||||
result = await gitea.read_file(config.gitea_org, (projects_mod.default_content_repo(config) or ""), f"rfcs/{slug}.md", ref=head)
|
||||
entry_payload: dict[str, Any] | None = None
|
||||
if result:
|
||||
text, _sha = result
|
||||
@@ -386,15 +991,28 @@ def make_router(
|
||||
"opened_at": row["opened_at"],
|
||||
"entry": entry_payload,
|
||||
"affordances": affordances,
|
||||
"proposed_use_case": _proposal_use_case(pr_number),
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# §9.1: propose a new RFC
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.post("/api/rfcs/propose")
|
||||
async def propose_rfc(payload: ProposeBody, request: Request) -> dict[str, Any]:
|
||||
user = auth.require_contributor(request)
|
||||
async def _propose_into_project(project_id: str, payload: ProposeBody, user) -> dict[str, Any]:
|
||||
# Default-collection wrapper (§22 S1/S2): resolve the project's default
|
||||
# collection and delegate. Keeps the project-scoped propose routes intact.
|
||||
return await _propose_into_collection(
|
||||
project_id, collections_mod.default_collection_id(project_id), payload, user
|
||||
)
|
||||
|
||||
async def _propose_into_collection(
|
||||
project_id: str, collection_id: str, payload: ProposeBody, user
|
||||
) -> dict[str, Any]:
|
||||
# §B.2 (S3): proposing a new entry requires contribute standing in the
|
||||
# *target collection* — the four-layer scope-role union, with the
|
||||
# grandfathered implicit-public baseline on the default collection.
|
||||
if not auth.can_contribute_in_collection(user, collection_id):
|
||||
raise HTTPException(403, "You do not have contribute access to this collection")
|
||||
slug = payload.slug.strip().lower()
|
||||
if not entry_mod.is_valid_slug(slug):
|
||||
raise HTTPException(422, "Slug must be lowercase letters, digits, and dashes")
|
||||
@@ -404,21 +1022,29 @@ def make_router(
|
||||
# on every keystroke, since a concurrent submission could land
|
||||
# between dialog-open and submit.
|
||||
clash = db.conn().execute(
|
||||
"SELECT 1 FROM cached_rfcs WHERE slug = ?", (slug,)
|
||||
"SELECT 1 FROM cached_rfcs WHERE slug = ? AND collection_id = ?", (slug, collection_id)
|
||||
).fetchone()
|
||||
if clash:
|
||||
raise HTTPException(409, f"Slug `{slug}` is already taken")
|
||||
idea_clash = db.conn().execute(
|
||||
"SELECT 1 FROM cached_prs WHERE pr_kind = 'idea' AND state = 'open' AND rfc_slug = ?",
|
||||
(slug,),
|
||||
"SELECT 1 FROM cached_prs WHERE pr_kind = 'idea' AND state = 'open' "
|
||||
"AND rfc_slug = ? AND project_id = ?",
|
||||
(slug, project_id),
|
||||
).fetchone()
|
||||
if idea_clash:
|
||||
raise HTTPException(409, f"Slug `{slug}` is already reserved by an open proposal")
|
||||
|
||||
# §22.4b: the target collection's landing state (the per-corpus field
|
||||
# moved down to the collection in migration 029).
|
||||
landing_state = (
|
||||
"active" if collections_mod.collection_initial_state(collection_id) == "active"
|
||||
else "super-draft"
|
||||
)
|
||||
|
||||
entry = entry_mod.Entry(
|
||||
slug=slug,
|
||||
title=payload.title.strip(),
|
||||
state="super-draft",
|
||||
state=landing_state,
|
||||
id=None,
|
||||
repo=None,
|
||||
proposed_by=user.email or user.gitea_login,
|
||||
@@ -433,6 +1059,7 @@ def make_router(
|
||||
arbiters=[],
|
||||
tags=[t.strip() for t in payload.tags if t.strip()],
|
||||
body=payload.pitch.strip() + "\n",
|
||||
unreviewed=(landing_state == "active"),
|
||||
)
|
||||
contents = entry_mod.serialize(entry)
|
||||
pr_title = f"Propose: {entry.title}"
|
||||
@@ -443,15 +1070,19 @@ def make_router(
|
||||
f"**Topic:** {entry.title}\n\n"
|
||||
f"{payload.pitch.strip()}"
|
||||
)
|
||||
# §22 S2: write the entry under the target collection's <subfolder>/rfcs.
|
||||
subfolder = collections_mod.subfolder_of(collection_id)
|
||||
rfcs_dir = f"{subfolder}/rfcs" if subfolder else "rfcs"
|
||||
try:
|
||||
pr = await bot.open_idea_pr(
|
||||
user.as_actor(),
|
||||
org=config.gitea_org,
|
||||
meta_repo=config.meta_repo,
|
||||
meta_repo=(projects_mod.content_repo(project_id) or ""),
|
||||
slug=slug,
|
||||
file_contents=contents,
|
||||
pr_title=pr_title,
|
||||
pr_description=pr_description,
|
||||
rfcs_dir=rfcs_dir,
|
||||
)
|
||||
except GiteaError as e:
|
||||
raise HTTPException(502, f"Gitea: {e.detail}")
|
||||
@@ -462,8 +1093,84 @@ 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, collection_id)
|
||||
VALUES ('rfc', ?, ?, ?, ?)
|
||||
ON CONFLICT(collection_id, scope, pr_number) DO UPDATE SET use_case = excluded.use_case
|
||||
""",
|
||||
(slug, pr["number"], use_case, collection_id),
|
||||
)
|
||||
db.conn().execute(
|
||||
"UPDATE cached_prs SET proposed_use_case = ? WHERE pr_kind = 'idea' AND pr_number = ? AND project_id = ?",
|
||||
(use_case, pr["number"], project_id),
|
||||
)
|
||||
|
||||
return {"pr_number": pr["number"], "slug": slug}
|
||||
|
||||
@router.post("/api/rfcs/propose")
|
||||
async def propose_rfc(payload: ProposeBody, request: Request) -> dict[str, Any]:
|
||||
# Default-project compat path (pre-multi-project clients).
|
||||
user = auth.require_contributor(request)
|
||||
return await _propose_into_project(projects_mod.resolved_default_id(config), payload, user)
|
||||
|
||||
@router.post("/api/projects/{project_id}/rfcs/propose")
|
||||
async def propose_project_rfc(
|
||||
project_id: str, payload: ProposeBody, request: Request
|
||||
) -> dict[str, Any]:
|
||||
# §22.4: propose a new entry into a specific project (read-gated first
|
||||
# so a gated project 404s a non-member before the contribute check).
|
||||
user = auth.require_contributor(request)
|
||||
auth.require_project_readable(user, project_id)
|
||||
return await _propose_into_project(project_id, payload, user)
|
||||
|
||||
@router.post("/api/projects/{project_id}/collections/{collection_id}/rfcs/propose")
|
||||
async def propose_collection_rfc(
|
||||
project_id: str, collection_id: str, payload: ProposeBody, request: Request
|
||||
) -> dict[str, Any]:
|
||||
# §22 S2: propose a new entry into a specific collection of a project.
|
||||
user = auth.require_contributor(request)
|
||||
auth.require_project_readable(user, project_id)
|
||||
_require_collection_in_project(collection_id, project_id)
|
||||
# §22.5 (S3): a hidden/gated collection 404s a non-scope-role viewer
|
||||
# before the contribute check (existence is not revealed).
|
||||
auth.require_collection_readable(user, collection_id)
|
||||
return await _propose_into_collection(project_id, collection_id, payload, user)
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# §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
|
||||
# ---------------------------------------------------------------
|
||||
@@ -476,7 +1183,7 @@ def make_router(
|
||||
await bot.merge_idea_pr(
|
||||
user.as_actor(),
|
||||
org=config.gitea_org,
|
||||
meta_repo=config.meta_repo,
|
||||
meta_repo=(projects_mod.default_content_repo(config) or ""),
|
||||
pr_number=pr_number,
|
||||
slug=row["rfc_slug"],
|
||||
)
|
||||
@@ -496,7 +1203,7 @@ def make_router(
|
||||
await bot.decline_idea_pr(
|
||||
user.as_actor(),
|
||||
org=config.gitea_org,
|
||||
meta_repo=config.meta_repo,
|
||||
meta_repo=(projects_mod.default_content_repo(config) or ""),
|
||||
pr_number=pr_number,
|
||||
slug=row["rfc_slug"],
|
||||
comment=body.comment,
|
||||
@@ -520,7 +1227,7 @@ def make_router(
|
||||
await bot.withdraw_idea_pr(
|
||||
user.as_actor(),
|
||||
org=config.gitea_org,
|
||||
meta_repo=config.meta_repo,
|
||||
meta_repo=(projects_mod.default_content_repo(config) or ""),
|
||||
pr_number=pr_number,
|
||||
slug=row["rfc_slug"],
|
||||
)
|
||||
@@ -576,6 +1283,8 @@ def make_router(
|
||||
).fetchone()
|
||||
if rfc is None:
|
||||
raise HTTPException(404, "RFC not found")
|
||||
# §22.5 visibility gate (subtractive): gated → 404 to non-members.
|
||||
auth.require_project_readable(user, auth.project_of_rfc(slug))
|
||||
# §6.7: refuse consent from a user with no registered credentials
|
||||
# — a consent without a universe would be inert and the surface
|
||||
# should fail loudly rather than silently.
|
||||
|
||||
+426
-1
@@ -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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -65,6 +68,32 @@ class AllowlistAddBody(BaseModel):
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -90,6 +119,17 @@ def make_router(config: Config) -> APIRouter:
|
||||
|
||||
`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(
|
||||
@@ -115,6 +155,62 @@ def make_router(config: Config) -> APIRouter:
|
||||
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": [
|
||||
{
|
||||
@@ -133,6 +229,217 @@ def make_router(config: Config) -> APIRouter:
|
||||
"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
|
||||
]
|
||||
@@ -365,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,
|
||||
@@ -455,6 +829,57 @@ def make_router(config: Config) -> APIRouter:
|
||||
items_blocked.append(payload)
|
||||
return {"ready": items_ready, "blocked": items_blocked}
|
||||
|
||||
# ----- §13.7: retired (soft-deleted) entries — site owners only -----
|
||||
|
||||
@router.get("/api/admin/retired-rfcs")
|
||||
async def retired_rfcs(request: Request) -> dict[str, Any]:
|
||||
# Retired entries are hidden from every browsing surface (§13.7);
|
||||
# this owner-gated list is how a site owner discovers them to
|
||||
# un-retire. Admins do not get this surface — un-retire authority is
|
||||
# site-owner-only, so neither is the list that feeds it.
|
||||
viewer = auth.require_admin(request)
|
||||
if viewer.role != "owner":
|
||||
raise HTTPException(403, "Site owner role required")
|
||||
rows = db.conn().execute(
|
||||
"""
|
||||
SELECT slug, title, rfc_id, owners_json, tags_json,
|
||||
proposed_at, updated_at
|
||||
FROM cached_rfcs
|
||||
WHERE state = 'retired'
|
||||
ORDER BY updated_at DESC
|
||||
"""
|
||||
).fetchall()
|
||||
items = []
|
||||
for r in rows:
|
||||
# The state it would return to on un-retire, mirroring
|
||||
# api_graduation._prior_state_before_retire's audit lookup.
|
||||
prior = db.conn().execute(
|
||||
"""
|
||||
SELECT details FROM actions
|
||||
WHERE rfc_slug = ? AND action_kind = 'retire'
|
||||
ORDER BY id DESC LIMIT 1
|
||||
""",
|
||||
(r["slug"],),
|
||||
).fetchone()
|
||||
restored_state = None
|
||||
if prior and prior["details"]:
|
||||
try:
|
||||
restored_state = json.loads(prior["details"]).get("prior_state")
|
||||
except (ValueError, TypeError):
|
||||
restored_state = None
|
||||
if restored_state not in ("super-draft", "active"):
|
||||
restored_state = "active" if r["rfc_id"] else "super-draft"
|
||||
items.append({
|
||||
"slug": r["slug"],
|
||||
"title": r["title"],
|
||||
"id": r["rfc_id"],
|
||||
"owners": json.loads(r["owners_json"] or "[]"),
|
||||
"tags": json.loads(r["tags_json"] or "[]"),
|
||||
"retired_at": r["updated_at"],
|
||||
"restores_to": restored_state,
|
||||
})
|
||||
return {"items": items}
|
||||
|
||||
# ----- User search (typeahead for §15.8 mute add) -----
|
||||
|
||||
@router.get("/api/users/search")
|
||||
|
||||
+141
-80
@@ -29,7 +29,7 @@ from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
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, projects as projects_mod
|
||||
from .bot import Bot
|
||||
from .config import Config
|
||||
from .gitea import Gitea, GiteaError
|
||||
@@ -120,7 +120,9 @@ def make_router(
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
@router.get("/api/rfcs/{slug}/models")
|
||||
async def list_models_for_rfc(slug: str) -> dict[str, Any]:
|
||||
async def list_models_for_rfc(slug: str, request: Request) -> dict[str, Any]:
|
||||
# §22.5 visibility gate (subtractive): gated → 404 to non-members.
|
||||
_require_rfc(slug, auth.current_user(request))
|
||||
resolved = models_resolver.resolve_models_for_rfc(slug, providers)
|
||||
return {
|
||||
"models": [
|
||||
@@ -140,7 +142,7 @@ def make_router(
|
||||
@router.get("/api/rfcs/{slug}/main")
|
||||
async def get_rfc_main(slug: str, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.current_user(request)
|
||||
rfc = _require_rfc(slug)
|
||||
rfc = _require_rfc(slug, viewer)
|
||||
if rfc["state"] not in ("active", "super-draft"):
|
||||
raise HTTPException(409, f"RFC is {rfc['state']}")
|
||||
|
||||
@@ -150,7 +152,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 +182,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 +206,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 +282,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)
|
||||
rfc = _require_active_rfc(slug)
|
||||
# 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, viewer)
|
||||
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 +341,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,7 +357,15 @@ 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)
|
||||
rfc = _require_super_draft(slug)
|
||||
# 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, viewer)
|
||||
owner, repo = _repo_for(rfc)
|
||||
new_branch = (body.branch_name or "").strip()
|
||||
if not new_branch:
|
||||
@@ -376,7 +410,7 @@ def make_router(
|
||||
@router.post("/api/rfcs/{slug}/metadata")
|
||||
async def edit_metadata(slug: str, body: MetadataEditBody, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
rfc = _require_super_draft(slug)
|
||||
rfc = _require_super_draft(slug, viewer)
|
||||
# Permission: super-draft owners/arbiters per §6.3, plus app-wide
|
||||
# admins/owners per §6.1. Until claim, that collapses to admin/owner.
|
||||
if not _can_edit_metadata(rfc, viewer):
|
||||
@@ -449,7 +483,7 @@ def make_router(
|
||||
request: Request,
|
||||
) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
rfc = _require_rfc_with_repo(slug)
|
||||
rfc = _require_rfc_with_repo(slug, viewer)
|
||||
_require_can_contribute(slug, branch, viewer)
|
||||
row = _require_pending_change(slug, branch, change_id)
|
||||
if row["kind"] != "ai":
|
||||
@@ -536,7 +570,7 @@ def make_router(
|
||||
@router.post("/api/rfcs/{slug}/branches/{branch:path}/changes/{change_id}/decline")
|
||||
async def decline_change(slug: str, branch: str, change_id: int, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
_require_rfc_with_repo(slug)
|
||||
_require_rfc_with_repo(slug, viewer)
|
||||
_require_can_contribute(slug, branch, viewer)
|
||||
row = _require_pending_change(slug, branch, change_id)
|
||||
if row["kind"] != "ai":
|
||||
@@ -554,7 +588,7 @@ def make_router(
|
||||
@router.post("/api/rfcs/{slug}/branches/{branch:path}/changes/{change_id}/reask")
|
||||
async def reask_change(slug: str, branch: str, change_id: int, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
rfc = _require_rfc_with_repo(slug)
|
||||
rfc = _require_rfc_with_repo(slug, viewer)
|
||||
_require_can_contribute(slug, branch, viewer)
|
||||
row = _require_change(slug, branch, change_id)
|
||||
if row["kind"] != "ai":
|
||||
@@ -621,7 +655,7 @@ def make_router(
|
||||
@router.post("/api/rfcs/{slug}/branches/{branch:path}/manual-flush")
|
||||
async def manual_flush(slug: str, branch: str, body: ManualFlushBody, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
rfc = _require_rfc_with_repo(slug)
|
||||
rfc = _require_rfc_with_repo(slug, viewer)
|
||||
_require_can_contribute(slug, branch, viewer)
|
||||
owner, repo = _repo_for(rfc, branch)
|
||||
path = _file_path_for(rfc, branch)
|
||||
@@ -698,7 +732,7 @@ def make_router(
|
||||
@router.post("/api/rfcs/{slug}/branches/{branch:path}/visibility")
|
||||
async def set_branch_visibility(slug: str, branch: str, body: VisibilityBody, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
rfc = _require_rfc_with_repo(slug)
|
||||
rfc = _require_rfc_with_repo(slug, viewer)
|
||||
creator = _branch_creator(slug, branch)
|
||||
_require_branch_owner(rfc, viewer, creator)
|
||||
current = _branch_vis(slug, branch)
|
||||
@@ -708,7 +742,7 @@ def make_router(
|
||||
"""
|
||||
INSERT INTO branch_visibility (rfc_slug, branch_name, read_public, contribute_mode)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(rfc_slug, branch_name) DO UPDATE SET
|
||||
ON CONFLICT(collection_id, rfc_slug, branch_name) DO UPDATE SET
|
||||
read_public = excluded.read_public,
|
||||
contribute_mode = excluded.contribute_mode
|
||||
""",
|
||||
@@ -719,7 +753,7 @@ def make_router(
|
||||
@router.post("/api/rfcs/{slug}/branches/{branch:path}/grants")
|
||||
async def add_branch_grant(slug: str, branch: str, body: GrantBody, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
rfc = _require_rfc_with_repo(slug)
|
||||
rfc = _require_rfc_with_repo(slug, viewer)
|
||||
creator = _branch_creator(slug, branch)
|
||||
_require_branch_owner(rfc, viewer, creator)
|
||||
grantee = db.conn().execute(
|
||||
@@ -740,7 +774,7 @@ def make_router(
|
||||
@router.delete("/api/rfcs/{slug}/branches/{branch:path}/grants/{grantee_login}")
|
||||
async def revoke_branch_grant(slug: str, branch: str, grantee_login: str, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
rfc = _require_rfc_with_repo(slug)
|
||||
rfc = _require_rfc_with_repo(slug, viewer)
|
||||
creator = _branch_creator(slug, branch)
|
||||
_require_branch_owner(rfc, viewer, creator)
|
||||
grantee = db.conn().execute(
|
||||
@@ -760,7 +794,7 @@ def make_router(
|
||||
@router.get("/api/rfcs/{slug}/branches/{branch:path}/threads")
|
||||
async def list_branch_threads(slug: str, branch: str, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.current_user(request)
|
||||
_require_rfc_with_repo(slug)
|
||||
_require_rfc_with_repo(slug, viewer)
|
||||
if not _can_read_branch(slug, branch, viewer):
|
||||
raise HTTPException(403, "Branch is private")
|
||||
rows = db.conn().execute(
|
||||
@@ -778,7 +812,7 @@ def make_router(
|
||||
@router.post("/api/rfcs/{slug}/branches/{branch:path}/threads")
|
||||
async def create_branch_thread(slug: str, branch: str, body: ThreadCreateBody, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
_require_rfc_with_repo(slug)
|
||||
_require_rfc_with_repo(slug, viewer)
|
||||
if body.thread_kind == "flag" and not body.label:
|
||||
raise HTTPException(422, "Flag threads require a label")
|
||||
cur = db.conn().execute(
|
||||
@@ -808,7 +842,7 @@ def make_router(
|
||||
@router.get("/api/rfcs/{slug}/branches/{branch:path}/threads/{thread_id}/messages")
|
||||
async def get_thread_messages(slug: str, branch: str, thread_id: int, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.current_user(request)
|
||||
_require_rfc_with_repo(slug)
|
||||
_require_rfc_with_repo(slug, viewer)
|
||||
if not _can_read_branch(slug, branch, viewer):
|
||||
raise HTTPException(403, "Branch is private")
|
||||
thread = _require_thread(slug, branch, thread_id)
|
||||
@@ -833,7 +867,7 @@ def make_router(
|
||||
slug: str, branch: str, thread_id: int, body: ThreadMessageBody, request: Request
|
||||
) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
_require_rfc_with_repo(slug)
|
||||
_require_rfc_with_repo(slug, viewer)
|
||||
_require_thread(slug, branch, thread_id)
|
||||
if not _can_read_branch(slug, branch, viewer):
|
||||
raise HTTPException(403, "Branch is private")
|
||||
@@ -854,7 +888,7 @@ def make_router(
|
||||
to this (slug, branch) on or before the new cursor is marked read.
|
||||
"""
|
||||
viewer = auth.require_user(request)
|
||||
_require_rfc_with_repo(slug)
|
||||
_require_rfc_with_repo(slug, viewer)
|
||||
if not _can_read_branch(slug, branch, viewer):
|
||||
raise HTTPException(403, "Branch is private")
|
||||
last_seen = int(body.get("last_seen_message_id") or 0) or None
|
||||
@@ -862,7 +896,7 @@ def make_router(
|
||||
"""
|
||||
INSERT INTO branch_chat_seen (user_id, rfc_slug, branch_name, last_seen_message_id, seen_at)
|
||||
VALUES (?, ?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(user_id, rfc_slug, branch_name) DO UPDATE SET
|
||||
ON CONFLICT(collection_id, user_id, rfc_slug, branch_name) DO UPDATE SET
|
||||
last_seen_message_id = excluded.last_seen_message_id,
|
||||
seen_at = excluded.seen_at
|
||||
""",
|
||||
@@ -877,7 +911,7 @@ def make_router(
|
||||
@router.post("/api/rfcs/{slug}/branches/{branch:path}/threads/{thread_id}/resolve")
|
||||
async def resolve_thread(slug: str, branch: str, thread_id: int, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
rfc = _require_rfc_with_repo(slug)
|
||||
rfc = _require_rfc_with_repo(slug, viewer)
|
||||
thread = _require_thread(slug, branch, thread_id)
|
||||
creator = _branch_creator(slug, branch)
|
||||
if not _can_resolve_thread(rfc, thread, creator, viewer):
|
||||
@@ -900,7 +934,7 @@ def make_router(
|
||||
slug: str, branch: str, thread_id: int, body: ChatTurnBody, request: Request
|
||||
):
|
||||
viewer = auth.require_contributor(request)
|
||||
rfc = _require_rfc_with_repo(slug)
|
||||
rfc = _require_rfc_with_repo(slug, viewer)
|
||||
thread = _require_thread(slug, branch, thread_id)
|
||||
if not _can_read_branch(slug, branch, viewer):
|
||||
raise HTTPException(403, "Branch is private")
|
||||
@@ -986,7 +1020,7 @@ def make_router(
|
||||
@router.get("/api/rfcs/{slug}/branches/{branch:path}")
|
||||
async def get_branch_view(slug: str, branch: str, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.current_user(request)
|
||||
rfc = _require_rfc_with_repo(slug)
|
||||
rfc = _require_rfc_with_repo(slug, viewer)
|
||||
if not _can_read_branch(slug, branch, viewer):
|
||||
raise HTTPException(403, "Branch is private")
|
||||
|
||||
@@ -1057,31 +1091,34 @@ def make_router(
|
||||
# Permission + state helpers (closures, share `config` etc.)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _require_rfc(slug: str):
|
||||
row = db.conn().execute("SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
|
||||
def _require_rfc(slug: str, viewer):
|
||||
row = db.conn().execute("SELECT *, (SELECT c.project_id FROM collections c WHERE c.id = cached_rfcs.collection_id) AS project_id FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, "RFC not found")
|
||||
# §22.5 visibility gate (subtractive, §22.7): a gated project's entries
|
||||
# 404 to non-members — indistinguishable from an unknown slug.
|
||||
auth.require_project_readable(viewer, row["project_id"])
|
||||
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."""
|
||||
row = _require_rfc(slug)
|
||||
def _require_rfc_with_repo(slug: str, viewer):
|
||||
"""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, viewer)
|
||||
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):
|
||||
row = _require_rfc_with_repo(slug)
|
||||
def _require_active_rfc(slug: str, viewer):
|
||||
row = _require_rfc_with_repo(slug, viewer)
|
||||
if row["state"] != "active":
|
||||
raise HTTPException(409, f"RFC is {row['state']}, not active")
|
||||
return row
|
||||
|
||||
def _require_super_draft(slug: str):
|
||||
row = _require_rfc(slug)
|
||||
def _require_super_draft(slug: str, viewer):
|
||||
row = _require_rfc(slug, viewer)
|
||||
if row["state"] != "super-draft":
|
||||
raise HTTPException(409, f"RFC is {row['state']}, not super-draft")
|
||||
return row
|
||||
@@ -1089,28 +1126,34 @@ 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)
|
||||
|
||||
def _repo_for(rfc, branch: str = "main") -> tuple[str, str]:
|
||||
if _is_meta_target(rfc, branch):
|
||||
return config.gitea_org, config.meta_repo
|
||||
return config.gitea_org, (projects_mod.default_content_repo(config) or "")
|
||||
owner, repo = rfc["repo"].split("/", 1)
|
||||
return owner, repo
|
||||
|
||||
@@ -1144,7 +1187,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:
|
||||
@@ -1221,6 +1264,12 @@ def make_router(
|
||||
return row["on_behalf_of"] if row else None
|
||||
|
||||
def _can_read_branch(slug: str, branch: str, viewer) -> bool:
|
||||
# §22.5 visibility gate first (subtractive, §B.2): in a hidden/gated
|
||||
# collection nothing — not even main or a read_public branch — is
|
||||
# readable by a non-scope-role viewer.
|
||||
cid = auth.collection_of_rfc(slug)
|
||||
if not auth.can_read_collection(viewer, cid):
|
||||
return False
|
||||
if branch == "main":
|
||||
return True
|
||||
vis = _branch_vis(slug, branch)
|
||||
@@ -1228,7 +1277,7 @@ def make_router(
|
||||
return True
|
||||
if viewer is None:
|
||||
return False
|
||||
if viewer.role in ("owner", "admin"):
|
||||
if auth.is_collection_superuser(viewer, cid):
|
||||
return True
|
||||
creator = _branch_creator(slug, branch)
|
||||
if creator and viewer.gitea_login == creator:
|
||||
@@ -1253,14 +1302,20 @@ 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"):
|
||||
cid = auth.collection_of_rfc(slug)
|
||||
# §22.5 visibility gate (subtractive): no contribute in an unreadable
|
||||
# collection.
|
||||
if not auth.can_read_collection(viewer, cid):
|
||||
return False
|
||||
if auth.is_collection_superuser(viewer, cid):
|
||||
return True
|
||||
owners = json.loads(rfc["owners_json"] or "[]")
|
||||
arbiters = json.loads(rfc["arbiters_json"] or "[]")
|
||||
@@ -1271,7 +1326,10 @@ def make_router(
|
||||
return True
|
||||
vis = _branch_vis(slug, branch)
|
||||
if vis["contribute_mode"] == "any-contributor":
|
||||
return True
|
||||
# "any contributor" means anyone with collection-level write standing
|
||||
# (§B.2) — the grandfathered baseline on the public default
|
||||
# collection, or an explicit scope grant reaching the collection.
|
||||
return auth.can_contribute_in_collection(viewer, cid)
|
||||
if vis["contribute_mode"] == "specific":
|
||||
row = db.conn().execute(
|
||||
"""
|
||||
@@ -1285,14 +1343,16 @@ 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):
|
||||
raise HTTPException(403, "You do not have contribute access to this branch")
|
||||
|
||||
def _require_branch_owner(rfc, viewer, creator: str | None) -> None:
|
||||
if viewer.role in ("owner", "admin"):
|
||||
# §22.6: a project_admin is the per-RFC owner/arbiter authority lifted
|
||||
# to project scope, so it (and a deployment owner/admin) clears here.
|
||||
if auth.is_collection_superuser(viewer, rfc["collection_id"]):
|
||||
return
|
||||
owners = json.loads(rfc["owners_json"] or "[]")
|
||||
arbiters = json.loads(rfc["arbiters_json"] or "[]")
|
||||
@@ -1303,11 +1363,12 @@ def make_router(
|
||||
raise HTTPException(403, "Only the branch creator, an RFC owner/arbiter, or an admin/owner may change branch settings")
|
||||
|
||||
def _can_edit_metadata(rfc, viewer) -> bool:
|
||||
"""§9.5: super-draft owners/arbiters per §6.3 plus app admins/owners.
|
||||
Until §13.1's claim runs, the super-draft has no owners, so the set
|
||||
collapses to app admins/owners only — sensible because admin oversight
|
||||
is the only path to canonicalizing edits on an unclaimed entry."""
|
||||
if viewer.role in ("owner", "admin"):
|
||||
"""§9.5: super-draft owners/arbiters per §6.3 plus project_admin /
|
||||
app admins/owners (§22.6). Until §13.1's claim runs, the super-draft
|
||||
has no owners, so the set collapses to the superuser tier only —
|
||||
sensible because admin oversight is the only path to canonicalizing
|
||||
edits on an unclaimed entry."""
|
||||
if auth.is_collection_superuser(viewer, rfc["collection_id"]):
|
||||
return True
|
||||
owners = json.loads(rfc["owners_json"] or "[]")
|
||||
arbiters = json.loads(rfc["arbiters_json"] or "[]")
|
||||
@@ -1320,7 +1381,7 @@ def make_router(
|
||||
"can_read": _can_read_branch(slug, branch, viewer),
|
||||
"can_contribute": _can_contribute(rfc, slug, branch, viewer) if viewer else False,
|
||||
"can_change_branch_settings": viewer is not None and (
|
||||
viewer.role in ("owner", "admin")
|
||||
auth.is_collection_superuser(viewer, rfc["collection_id"])
|
||||
or (creator is not None and viewer.gitea_login == creator)
|
||||
or viewer.gitea_login in (owners + arbiters)
|
||||
),
|
||||
@@ -1366,7 +1427,7 @@ def make_router(
|
||||
def _can_resolve_thread(rfc, thread, creator: str | None, viewer) -> bool:
|
||||
if viewer is None:
|
||||
return False
|
||||
if viewer.role in ("owner", "admin"):
|
||||
if auth.is_collection_superuser(viewer, rfc["collection_id"]):
|
||||
return True
|
||||
owners = json.loads(rfc["owners_json"] or "[]")
|
||||
arbiters = json.loads(rfc["arbiters_json"] or "[]")
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
"""§22 S2 — collection directory + create-collection.
|
||||
|
||||
GET /api/projects/:id/collections — list the project's visible collections.
|
||||
GET /api/projects/:id/collections/:cid — one collection's settings.
|
||||
POST /api/projects/:id/collections — create a collection. Authorized by a
|
||||
deployment owner/admin (S2; scoped
|
||||
{owner, contributor} roles at the
|
||||
collection axis land in S3). The bot
|
||||
commits a `.collection.yaml` to the
|
||||
content repo, then the registry mirror
|
||||
upserts the collections row — §22.2
|
||||
keeps the registry the source of truth.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
|
||||
from . import (
|
||||
auth,
|
||||
collections as collections_mod,
|
||||
projects as projects_mod,
|
||||
registry as registry_mod,
|
||||
)
|
||||
from .bot import Bot
|
||||
from .config import Config
|
||||
from .gitea import Gitea, GiteaError
|
||||
|
||||
_SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$")
|
||||
|
||||
|
||||
class CreateCollectionBody(BaseModel):
|
||||
collection_id: str
|
||||
type: str
|
||||
name: str | None = None
|
||||
visibility: str | None = None
|
||||
initial_state: str | None = None
|
||||
|
||||
|
||||
def make_router(config: Config, gitea: Gitea, bot: Bot) -> APIRouter:
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/api/projects/{project_id}/collections")
|
||||
async def list_cols(project_id: str, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.current_user(request)
|
||||
# §22.5 read gate: a gated project 404s a non-member.
|
||||
auth.require_project_readable(viewer, project_id)
|
||||
# §22.5 (S3): the directory is viewer-aware — a hidden/gated collection
|
||||
# is listed only for a scope-role holder who can read it; `unlisted` is
|
||||
# omitted from enumeration for everyone (link-only).
|
||||
items = [
|
||||
c
|
||||
for c in collections_mod.list_collections(project_id, include_unlisted=True)
|
||||
if c["visibility"] != "unlisted" and auth.can_read_collection(viewer, c["id"])
|
||||
]
|
||||
return {"items": items}
|
||||
|
||||
@router.get("/api/projects/{project_id}/collections/{collection_id}")
|
||||
async def get_col(project_id: str, collection_id: str, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.current_user(request)
|
||||
auth.require_project_readable(viewer, project_id)
|
||||
col = collections_mod.get_collection(collection_id)
|
||||
if col is None or col["project_id"] != project_id:
|
||||
raise HTTPException(404, "Not found")
|
||||
# §22.5 (S3): a hidden/gated collection 404s a non-scope-role viewer.
|
||||
auth.require_collection_readable(viewer, collection_id)
|
||||
return col
|
||||
|
||||
@router.post("/api/projects/{project_id}/collections")
|
||||
async def create_col(
|
||||
project_id: str, body: CreateCollectionBody, request: Request
|
||||
) -> dict[str, Any]:
|
||||
# §B.1 (S3) authority: a deployment owner/admin or a project/global-scope
|
||||
# grant holder (Owner or RFC Contributor) may create a collection. The
|
||||
# read gate runs first so a gated project 404s a non-member.
|
||||
user = auth.require_contributor(request)
|
||||
auth.require_project_readable(user, project_id)
|
||||
if not auth.can_create_collection(user, project_id):
|
||||
raise HTTPException(403, "You may not create collections in this project")
|
||||
cid = body.collection_id.strip().lower()
|
||||
if not _SLUG_RE.match(cid) or cid == "default":
|
||||
raise HTTPException(422, "collection id must be a slug and not 'default'")
|
||||
if body.type not in registry_mod.VALID_TYPES:
|
||||
raise HTTPException(422, f"invalid type {body.type!r}")
|
||||
if body.visibility is not None:
|
||||
if body.visibility not in registry_mod.VALID_VISIBILITY:
|
||||
raise HTTPException(422, f"invalid visibility {body.visibility!r}")
|
||||
# §22.5 (S3) strictness: a collection may be set only as strict or
|
||||
# stricter than its project — never more public.
|
||||
pvis = auth.project_visibility(project_id)
|
||||
if auth.visibility_rank(body.visibility) < auth.visibility_rank(pvis):
|
||||
raise HTTPException(
|
||||
422,
|
||||
f"collection visibility {body.visibility!r} is looser than "
|
||||
f"the project's {pvis!r}; a collection may only narrow it",
|
||||
)
|
||||
if body.initial_state is not None and body.initial_state not in registry_mod.VALID_INITIAL_STATE:
|
||||
raise HTTPException(422, f"invalid initial_state {body.initial_state!r}")
|
||||
if collections_mod.get_collection(cid) is not None:
|
||||
raise HTTPException(409, f"collection `{cid}` already exists")
|
||||
content_repo = projects_mod.content_repo(project_id)
|
||||
if not content_repo:
|
||||
raise HTTPException(409, "project has no content repo")
|
||||
|
||||
manifest: dict[str, Any] = {"type": body.type}
|
||||
if body.name:
|
||||
manifest["name"] = body.name
|
||||
if body.visibility:
|
||||
manifest["visibility"] = body.visibility
|
||||
if body.initial_state:
|
||||
manifest["initial_state"] = body.initial_state
|
||||
manifest_yaml = yaml.safe_dump(manifest, sort_keys=False)
|
||||
|
||||
try:
|
||||
await bot.create_collection(
|
||||
user.as_actor(),
|
||||
org=config.gitea_org,
|
||||
content_repo=content_repo,
|
||||
collection_id=cid,
|
||||
manifest_yaml=manifest_yaml,
|
||||
)
|
||||
except GiteaError as e:
|
||||
raise HTTPException(502, f"Gitea: {e.detail}")
|
||||
|
||||
# §22.2: re-read the registry so the new manifest becomes a row.
|
||||
await registry_mod.refresh_registry(config, gitea)
|
||||
col = collections_mod.get_collection(cid)
|
||||
if col is None:
|
||||
raise HTTPException(500, "collection committed but not mirrored")
|
||||
return col
|
||||
|
||||
return router
|
||||
@@ -0,0 +1,316 @@
|
||||
"""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, viewer):
|
||||
"""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). The §22.5
|
||||
visibility gate is subtractive: a gated project's entries 404 to
|
||||
non-members (§22.7)."""
|
||||
row = db.conn().execute(
|
||||
"SELECT slug, title, state, owners_json, proposed_by, (SELECT c.project_id FROM collections c WHERE c.id = cached_rfcs.collection_id) AS project_id FROM cached_rfcs WHERE slug = ?",
|
||||
(slug,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, "RFC not found")
|
||||
auth.require_project_readable(viewer, row["project_id"])
|
||||
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 auth.is_collection_superuser(viewer, auth.collection_of_rfc(slug)):
|
||||
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, (SELECT c.project_id FROM collections c WHERE c.id = cached_rfcs.collection_id) AS project_id FROM cached_rfcs WHERE slug = ?",
|
||||
(slug,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, "RFC not found")
|
||||
viewer = auth.current_user(request)
|
||||
# §22.5 visibility gate (subtractive): gated → 404 to non-members.
|
||||
auth.require_project_readable(viewer, row["project_id"])
|
||||
|
||||
from . import rfc_links # local import: avoid a module import cycle
|
||||
|
||||
owner = rfc_links._owner_display(db.conn(), row["owners_json"], row["proposed_by"])
|
||||
|
||||
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, viewer)
|
||||
|
||||
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, viewer)
|
||||
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, viewer)
|
||||
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,123 @@
|
||||
"""§22.9 runtime deployment/project config (replaces VITE_APP_NAME) + §22.10
|
||||
old-URL 308 redirects.
|
||||
|
||||
GET /api/deployment — the deployment name/tagline + the projects the caller can
|
||||
see (§22.5: gated filtered by membership, unlisted omitted from enumeration),
|
||||
plus the corpus-served `default_project_id` the M3-frontend guard keys on.
|
||||
GET /api/projects/:id — one project's runtime config + optional theme overlay,
|
||||
gated behind the §22.5 read gate (404 for a non-member of a gated project).
|
||||
GET /rfc/{slug}, /proposals/{n} — §22.10 server-side 308s onto the new
|
||||
`/p/<default>/…` routes (the SPA no longer owns these paths; nginx proxies them
|
||||
to the backend instead of serving index.html).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
from . import auth, collections as collections_mod, db, projects as projects_mod
|
||||
from .config import Config
|
||||
|
||||
|
||||
def make_router(config: Config) -> APIRouter:
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/api/deployment")
|
||||
async def get_deployment(request: Request) -> dict[str, Any]:
|
||||
viewer = auth.current_user(request)
|
||||
dep = db.conn().execute(
|
||||
"SELECT name, tagline FROM deployment WHERE id = 1"
|
||||
).fetchone()
|
||||
# §22.5: enumerate only public + (member-)gated; unlisted is never listed.
|
||||
visible = set(auth.visible_project_ids(viewer))
|
||||
# §22 three-tier: `type` is a per-corpus field on the (default) collection
|
||||
# now; surface the default collection's type for each project.
|
||||
rows = db.conn().execute(
|
||||
"SELECT id, name, visibility FROM projects "
|
||||
"WHERE visibility != 'unlisted' ORDER BY name"
|
||||
).fetchall()
|
||||
projects = [
|
||||
{
|
||||
"id": r["id"],
|
||||
"name": r["name"],
|
||||
"type": collections_mod.collection_type(
|
||||
collections_mod.default_collection_id(r["id"])
|
||||
),
|
||||
"visibility": r["visibility"],
|
||||
}
|
||||
for r in rows
|
||||
if r["id"] in visible
|
||||
]
|
||||
return {
|
||||
"name": (dep["name"] if dep else "") or "",
|
||||
"tagline": (dep["tagline"] if dep else "") or "",
|
||||
# §22.10 / M3-frontend guard contract: which project the backend
|
||||
# serves the corpus for (the default, until Plan B serves per
|
||||
# project). The frontend renders corpus routes only for this id and
|
||||
# shows a "content not yet served" placeholder for any other.
|
||||
"default_project_id": projects_mod.resolved_default_id(config),
|
||||
"projects": projects,
|
||||
}
|
||||
|
||||
@router.get("/api/projects/{project_id}")
|
||||
async def get_project(project_id: str, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.current_user(request)
|
||||
# §22.5 read gate: a gated project 404s to a non-member (shape matches
|
||||
# an unknown id). unlisted is readable by direct id.
|
||||
auth.require_project_readable(viewer, project_id)
|
||||
row = db.conn().execute(
|
||||
"SELECT id, name, visibility, config_json FROM projects WHERE id = ?",
|
||||
(project_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
try:
|
||||
cfg = json.loads(row["config_json"] or "{}")
|
||||
except (ValueError, TypeError):
|
||||
cfg = {}
|
||||
dep = db.conn().execute("SELECT tagline FROM deployment WHERE id = 1").fetchone()
|
||||
# §22 three-tier: type + initial_state moved down to the (default)
|
||||
# collection in migration 029.
|
||||
cid = collections_mod.default_collection_id(row["id"])
|
||||
return {
|
||||
"id": row["id"],
|
||||
"name": row["name"],
|
||||
"tagline": (dep["tagline"] if dep else "") or "",
|
||||
"type": collections_mod.collection_type(cid),
|
||||
"visibility": row["visibility"],
|
||||
"initial_state": collections_mod.collection_initial_state(cid),
|
||||
"theme": cfg.get("theme") or {},
|
||||
}
|
||||
|
||||
# §22.10 / §5 — server-side 308s off the old corpus-root URLs onto the
|
||||
# `/p/<default>/…` routes. 308 (not 301/302) preserves method + body and
|
||||
# is permanent, so external "RFC-0001" links and bookmarks land correctly.
|
||||
# nginx routes /rfc/ and /proposals/ to the backend so these are reached
|
||||
# before the SPA's index.html fallback.
|
||||
# §22 three-tier (S1): the canonical entry route now carries the collection
|
||||
# segment /p/<project>/c/<collection>/…. The legacy roots redirect through
|
||||
# the default project's default collection.
|
||||
@router.get("/rfc/{slug}")
|
||||
async def redirect_old_rfc(slug: str) -> RedirectResponse:
|
||||
default_id = projects_mod.resolved_default_id(config)
|
||||
cid = collections_mod.default_collection_id(default_id)
|
||||
return RedirectResponse(url=f"/p/{default_id}/c/{cid}/e/{slug}", status_code=308)
|
||||
|
||||
@router.get("/rfc/{slug}/pr/{pr_number}")
|
||||
async def redirect_old_rfc_pr(slug: str, pr_number: int) -> RedirectResponse:
|
||||
default_id = projects_mod.resolved_default_id(config)
|
||||
cid = collections_mod.default_collection_id(default_id)
|
||||
return RedirectResponse(
|
||||
url=f"/p/{default_id}/c/{cid}/e/{slug}/pr/{pr_number}", status_code=308
|
||||
)
|
||||
|
||||
@router.get("/proposals/{pr_number}")
|
||||
async def redirect_old_proposal(pr_number: int) -> RedirectResponse:
|
||||
default_id = projects_mod.resolved_default_id(config)
|
||||
cid = collections_mod.default_collection_id(default_id)
|
||||
return RedirectResponse(url=f"/p/{default_id}/c/{cid}/proposals/{pr_number}", status_code=308)
|
||||
|
||||
return router
|
||||
@@ -40,7 +40,7 @@ from typing import Any
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from . import auth, chat as chat_layer, db
|
||||
from . import auth, chat as chat_layer, db, rfc_links
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -84,7 +84,7 @@ def make_router() -> APIRouter:
|
||||
@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)
|
||||
_require_rfc_readable(slug, viewer)
|
||||
# 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
|
||||
@@ -115,7 +115,18 @@ def make_router() -> APIRouter:
|
||||
slug: str, body: DiscussionThreadCreateBody, request: Request
|
||||
) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
_require_rfc_readable(slug)
|
||||
_require_rfc_readable(slug, viewer)
|
||||
# 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
|
||||
@@ -144,8 +155,8 @@ def make_router() -> APIRouter:
|
||||
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)
|
||||
viewer = auth.current_user(request)
|
||||
_require_rfc_readable(slug, viewer)
|
||||
thread = _require_discussion_thread(slug, thread_id)
|
||||
rows = db.conn().execute(
|
||||
"""
|
||||
@@ -160,9 +171,17 @@ def make_router() -> APIRouter:
|
||||
""",
|
||||
(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": [_serialize_message(r) for r in rows],
|
||||
"messages": messages,
|
||||
}
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
@@ -174,7 +193,13 @@ def make_router() -> APIRouter:
|
||||
slug: str, thread_id: int, body: DiscussionMessageBody, request: Request
|
||||
) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
_require_rfc_readable(slug)
|
||||
_require_rfc_readable(slug, viewer)
|
||||
# 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,
|
||||
@@ -193,7 +218,7 @@ def make_router() -> APIRouter:
|
||||
slug: str, thread_id: int, request: Request
|
||||
) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
rfc = _require_rfc_readable(slug)
|
||||
rfc = _require_rfc_readable(slug, viewer)
|
||||
thread = _require_discussion_thread(slug, thread_id)
|
||||
if not _can_resolve(rfc, thread, viewer):
|
||||
raise HTTPException(
|
||||
@@ -220,17 +245,24 @@ def make_router() -> APIRouter:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
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."""
|
||||
def _require_rfc_readable(slug: str, viewer):
|
||||
"""Per the v0.3.0 anonymous-read contract: any cached RFC in a *readable*
|
||||
project is readable by anyone. The §22.5 visibility gate is subtractive on
|
||||
top (§22.7): a gated project's entries 404 to non-members. 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,)
|
||||
"SELECT *, (SELECT c.project_id FROM collections c WHERE c.id = cached_rfcs.collection_id) AS project_id FROM cached_rfcs WHERE slug = ?", (slug,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, "RFC not found")
|
||||
auth.require_project_readable(viewer, row["project_id"])
|
||||
if row["state"] == "withdrawn":
|
||||
raise HTTPException(409, "RFC is withdrawn")
|
||||
# §13.7: a retired entry is soft-deleted — refuse reads of every shape
|
||||
# (a 404, not a 409: the entry is not surfaced anywhere a browser looks).
|
||||
if row["state"] == "retired":
|
||||
raise HTTPException(404, "RFC not found")
|
||||
return row
|
||||
|
||||
|
||||
@@ -280,7 +312,7 @@ def _ensure_discussion_thread(slug: str, viewer) -> int:
|
||||
def _can_resolve(rfc, thread, viewer) -> bool:
|
||||
if viewer is None:
|
||||
return False
|
||||
if viewer.role in ("owner", "admin"):
|
||||
if auth.is_collection_superuser(viewer, rfc["collection_id"]):
|
||||
return True
|
||||
owners = json.loads(rfc["owners_json"] or "[]")
|
||||
arbiters = json.loads(rfc["arbiters_json"] or "[]")
|
||||
|
||||
+378
-388
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,596 @@
|
||||
"""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, viewer)
|
||||
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, viewer)
|
||||
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, viewer)
|
||||
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, viewer):
|
||||
"""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. The §22.5
|
||||
visibility gate is subtractive: a gated project's entries 404 to
|
||||
non-members (§22.7)."""
|
||||
row = db.conn().execute(
|
||||
"SELECT slug, title, state, (SELECT c.project_id FROM collections c WHERE c.id = cached_rfcs.collection_id) AS project_id FROM cached_rfcs WHERE slug = ?", (slug,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, "RFC not found")
|
||||
auth.require_project_readable(viewer, row["project_id"])
|
||||
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
|
||||
@@ -73,6 +73,13 @@ 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):
|
||||
@@ -206,14 +213,16 @@ def make_router(config: Config) -> APIRouter:
|
||||
@router.post("/api/rfcs/{slug}/watch")
|
||||
async def set_watch(slug: str, body: WatchBody, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_user(request)
|
||||
rfc = db.conn().execute("SELECT slug FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
|
||||
rfc = db.conn().execute("SELECT (SELECT c.project_id FROM collections c WHERE c.id = cached_rfcs.collection_id) AS project_id FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
|
||||
if rfc is None:
|
||||
raise HTTPException(404, "RFC not found")
|
||||
# §22.5 visibility gate (subtractive): gated → 404 to non-members.
|
||||
auth.require_project_readable(viewer, rfc["project_id"])
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO watches (user_id, rfc_slug, state, set_by, set_at, last_participation_at)
|
||||
VALUES (?, ?, ?, 'explicit', datetime('now'), datetime('now'))
|
||||
ON CONFLICT(user_id, rfc_slug) DO UPDATE SET
|
||||
ON CONFLICT(collection_id, user_id, rfc_slug) DO UPDATE SET
|
||||
state = excluded.state,
|
||||
set_by = 'explicit',
|
||||
set_at = excluded.set_at
|
||||
@@ -443,6 +452,40 @@ def make_router(config: Config) -> APIRouter:
|
||||
|
||||
# ----- 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:
|
||||
@@ -453,20 +496,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]:
|
||||
@@ -485,21 +559,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
|
||||
|
||||
|
||||
+99
-30
@@ -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, projects as projects_mod, 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):
|
||||
@@ -80,7 +85,7 @@ def make_router(
|
||||
@router.post("/api/rfcs/{slug}/branches/{branch:path}/pr-draft")
|
||||
async def draft_pr_text(slug: str, branch: str, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
rfc = _require_active_rfc(slug)
|
||||
rfc = _require_active_rfc(slug, viewer)
|
||||
owner, repo = _owner_repo(rfc)
|
||||
path = _file_path_for(rfc)
|
||||
if not _branch_has_commits_ahead(slug, branch):
|
||||
@@ -112,7 +117,18 @@ 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)
|
||||
rfc = _require_active_rfc(slug)
|
||||
# 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, viewer)
|
||||
if branch == "main":
|
||||
raise HTTPException(409, "PRs open from non-main branches")
|
||||
owner, repo = _owner_repo(rfc)
|
||||
@@ -137,7 +153,7 @@ def make_router(
|
||||
"""
|
||||
INSERT INTO branch_visibility (rfc_slug, branch_name, read_public, contribute_mode)
|
||||
VALUES (?, ?, 1, 'just-me')
|
||||
ON CONFLICT(rfc_slug, branch_name) DO UPDATE SET read_public = 1
|
||||
ON CONFLICT(collection_id, rfc_slug, branch_name) DO UPDATE SET read_public = 1
|
||||
""",
|
||||
(slug, branch),
|
||||
)
|
||||
@@ -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(collection_id, 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}
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
@@ -171,12 +207,18 @@ def make_router(
|
||||
@router.get("/api/rfcs/{slug}/prs/{pr_number}")
|
||||
async def get_pr(slug: str, pr_number: int, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.current_user(request)
|
||||
rfc = _require_active_rfc(slug)
|
||||
rfc = _require_active_rfc(slug, viewer)
|
||||
pr_row = _require_pr(slug, pr_number)
|
||||
owner, repo = _owner_repo(rfc)
|
||||
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"],
|
||||
@@ -322,7 +373,7 @@ def make_router(
|
||||
@router.post("/api/rfcs/{slug}/prs/{pr_number}/seen")
|
||||
async def advance_seen(slug: str, pr_number: int, body: PRSeenBody, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
_require_active_rfc(slug)
|
||||
_require_active_rfc(slug, viewer)
|
||||
_require_pr(slug, pr_number)
|
||||
# Take the max of stored and incoming for both cursors so a
|
||||
# stale tab firing a seen-cursor advance after a fresher tab
|
||||
@@ -347,7 +398,7 @@ def make_router(
|
||||
INSERT INTO pr_seen
|
||||
(user_id, rfc_slug, pr_number, last_seen_commit_sha, last_seen_message_id, seen_at)
|
||||
VALUES (?, ?, ?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(user_id, rfc_slug, pr_number) DO UPDATE SET
|
||||
ON CONFLICT(collection_id, user_id, rfc_slug, pr_number) DO UPDATE SET
|
||||
last_seen_commit_sha = excluded.last_seen_commit_sha,
|
||||
last_seen_message_id = excluded.last_seen_message_id,
|
||||
seen_at = excluded.seen_at
|
||||
@@ -369,7 +420,7 @@ def make_router(
|
||||
@router.post("/api/rfcs/{slug}/prs/{pr_number}/review")
|
||||
async def post_review_thread(slug: str, pr_number: int, body: PRReviewBody, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
_require_active_rfc(slug)
|
||||
_require_active_rfc(slug, viewer)
|
||||
pr_row = _require_pr(slug, pr_number)
|
||||
head_branch = pr_row["head_branch"]
|
||||
cur = db.conn().execute(
|
||||
@@ -396,7 +447,7 @@ def make_router(
|
||||
@router.post("/api/rfcs/{slug}/prs/{pr_number}/merge")
|
||||
async def merge_pr(slug: str, pr_number: int, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
rfc = _require_active_rfc(slug)
|
||||
rfc = _require_active_rfc(slug, viewer)
|
||||
pr_row = _require_pr(slug, pr_number)
|
||||
if not _can_merge(rfc, viewer):
|
||||
raise HTTPException(403, "Only arbiters, RFC owners, and app admins/owners may merge")
|
||||
@@ -428,7 +479,7 @@ def make_router(
|
||||
@router.post("/api/rfcs/{slug}/prs/{pr_number}/withdraw")
|
||||
async def withdraw_pr(slug: str, pr_number: int, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
rfc = _require_active_rfc(slug)
|
||||
rfc = _require_active_rfc(slug, viewer)
|
||||
pr_row = _require_pr(slug, pr_number)
|
||||
if not _can_withdraw(rfc, pr_row, viewer):
|
||||
raise HTTPException(403, "Only the contributor or an RFC owner/arbiter (or app admin/owner) may withdraw")
|
||||
@@ -459,7 +510,7 @@ def make_router(
|
||||
slug: str, pr_number: int, body: PRDescriptionBody, request: Request
|
||||
) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
rfc = _require_active_rfc(slug)
|
||||
rfc = _require_active_rfc(slug, viewer)
|
||||
pr_row = _require_pr(slug, pr_number)
|
||||
if not _can_edit_pr_text(rfc, pr_row, viewer):
|
||||
raise HTTPException(403, "Only the contributor or an RFC owner/arbiter (or admin/owner) may edit")
|
||||
@@ -484,7 +535,7 @@ def make_router(
|
||||
@router.post("/api/rfcs/{slug}/prs/{pr_number}/resolution-branch")
|
||||
async def start_resolution_branch(slug: str, pr_number: int, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
rfc = _require_active_rfc(slug)
|
||||
rfc = _require_active_rfc(slug, viewer)
|
||||
pr_row = _require_pr(slug, pr_number)
|
||||
if pr_row["state"] != "open":
|
||||
raise HTTPException(409, f"PR is {pr_row['state']}, not open")
|
||||
@@ -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,
|
||||
)
|
||||
@@ -610,42 +661,49 @@ def make_router(
|
||||
# Helpers (closures over config/gitea/etc.)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _require_rfc(slug: str):
|
||||
row = db.conn().execute("SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
|
||||
def _require_rfc(slug: str, viewer):
|
||||
row = db.conn().execute("SELECT *, (SELECT c.project_id FROM collections c WHERE c.id = cached_rfcs.collection_id) AS project_id FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, "RFC not found")
|
||||
# §22.5 visibility gate (subtractive, §22.7) — even §11.3 "PRs always
|
||||
# public" yields to a gated project: non-members get 404.
|
||||
auth.require_project_readable(viewer, row["project_id"])
|
||||
return row
|
||||
|
||||
def _require_active_rfc(slug: str):
|
||||
def _require_active_rfc(slug: str, viewer):
|
||||
"""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."""
|
||||
row = _require_rfc(slug)
|
||||
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, viewer)
|
||||
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):
|
||||
return config.gitea_org, config.meta_repo
|
||||
if _is_meta_resident(rfc):
|
||||
return config.gitea_org, (projects_mod.default_content_repo(config) or "")
|
||||
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 +767,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)
|
||||
@@ -725,10 +783,10 @@ def make_router(
|
||||
|
||||
|
||||
def _can_merge(rfc, viewer) -> bool:
|
||||
"""§6.1 admin/owner OR §6.3 RFC owners/arbiters."""
|
||||
"""§6.1 admin/owner or §22.6 project_admin OR §6.3 RFC owners/arbiters."""
|
||||
if viewer is None:
|
||||
return False
|
||||
if viewer.role in ("owner", "admin"):
|
||||
if auth.is_collection_superuser(viewer, rfc["collection_id"]):
|
||||
return True
|
||||
owners = json.loads(rfc["owners_json"] or "[]")
|
||||
arbiters = json.loads(rfc["arbiters_json"] or "[]")
|
||||
@@ -751,6 +809,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",
|
||||
|
||||
@@ -16,9 +16,11 @@ from typing import Any
|
||||
import httpx
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from . import collections as collections_mod
|
||||
from . import db
|
||||
from .bot import Actor
|
||||
from .config import Config
|
||||
from .projects import DEFAULT_PROJECT_ID
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -290,5 +292,575 @@ def require_admin(request: Request) -> SessionUser:
|
||||
return user
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# §22.6 / §22.7 — project-scoped authorization (the multi-project middle tier).
|
||||
#
|
||||
# A deployment hosts N projects (§22). Authorization for an action on an RFC is
|
||||
# the *most-permissive union* of three tiers — the actor's deployment role
|
||||
# (§6.1), their project role (§22.6), and their per-RFC authority (§6.3/§12) —
|
||||
# with the §22.5 visibility gate and the §6.2 write-mute *subtractive* on top
|
||||
# (§22.7). The per-RFC capability helpers below (`can_discuss_rfc`,
|
||||
# `can_contribute_to_rfc`, `can_invite_to_rfc`) compose all three tiers, so the
|
||||
# ~20 endpoint call sites inherit multi-project behavior unchanged.
|
||||
#
|
||||
# Through Slice M2 the only project is the migration-seeded `default` one (the
|
||||
# N=1 case, §22.13); a project is resolved from an RFC slug via
|
||||
# `cached_rfcs.project_id` (unique per slug while N=1). M3's registry mirror
|
||||
# lets a deployment declare a second project; these gates already hold then.
|
||||
#
|
||||
# OPERATOR DECISIONS (M2):
|
||||
# * implicit-on-public — on a `public` project a granted deployment
|
||||
# `contributor` keeps the pre-multi-project write *baseline* (propose
|
||||
# freely; an owned RFC's discuss/contribute is still gated by the v0.16.0
|
||||
# per-RFC invite). No project_members row is needed and no backfill runs,
|
||||
# so the N=1 case stays whole. Explicit project_members rows and
|
||||
# gated/unlisted visibility are where the new tier actually bites.
|
||||
# * preserve curation — the implicit-public baseline does NOT override per-RFC
|
||||
# owner curation; only an *explicit* project_contributor/project_admin grant
|
||||
# (or a deployment owner/admin) bypasses it. So §22.7's "project_contributor
|
||||
# ⊇ rfc_collaborators(contributor)" holds for explicit grants, while a plain
|
||||
# granted contributor on public behaves exactly as it did before M2.
|
||||
# ===========================================================================
|
||||
|
||||
_DEPLOYMENT_SUPERUSER_ROLES = ("owner", "admin")
|
||||
|
||||
|
||||
def project_visibility(project_id: str) -> str:
|
||||
"""The project's §22.5 visibility ('gated' | 'public' | 'unlisted'). A
|
||||
missing row reads as 'gated' — the safe default: an unknown project is
|
||||
invisible rather than open."""
|
||||
row = db.conn().execute(
|
||||
"SELECT visibility FROM projects WHERE id = ?", (project_id,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return "gated"
|
||||
return row["visibility"] or "gated"
|
||||
|
||||
|
||||
def _is_default_project(project_id: str) -> bool:
|
||||
"""True iff `project_id` owns the migration-seeded `default` collection — the
|
||||
deployment's primary project (§22.13), whatever its configured id. Only there
|
||||
do M2's role rows (which migrated to collection scope `default`) stand in for
|
||||
project-level authority."""
|
||||
return collections_mod.project_of_collection(collections_mod.DEFAULT_COLLECTION_ID) == project_id
|
||||
|
||||
|
||||
def project_member_role(user: SessionUser | None, project_id: str) -> str | None:
|
||||
"""The user's *project-grain* §22.6 role at this project, or None — the
|
||||
most-permissive of a **global** grant (inherits down to every project) and a
|
||||
**project**-scope grant. Mapped back to the legacy
|
||||
`project_admin`/`project_contributor` strings the project-grain authz speaks.
|
||||
|
||||
Back-compat: on the deployment's *default* project only, M2's rows live at
|
||||
collection scope `default` (§B.3 migration), so a `default` collection-scope
|
||||
grant there is read as project-level too. A collection grant on any other
|
||||
project is NOT project authority — that is the four-layer collection resolver
|
||||
(`effective_scope_role`). Does not fold in the deployment tier
|
||||
(`is_project_superuser` adds it) or the implicit-on-public baseline."""
|
||||
if user is None:
|
||||
return None
|
||||
clauses = ["scope_type = 'global'", "(scope_type = 'project' AND scope_id = ?)"]
|
||||
params: list = [user.user_id, project_id]
|
||||
if _is_default_project(project_id):
|
||||
clauses.append("(scope_type = 'collection' AND scope_id = ?)")
|
||||
params.append(collections_mod.DEFAULT_COLLECTION_ID)
|
||||
row = db.conn().execute(
|
||||
"SELECT role FROM memberships WHERE user_id = ? AND (" + " OR ".join(clauses) + ") "
|
||||
"ORDER BY CASE role WHEN 'owner' THEN 0 ELSE 1 END LIMIT 1",
|
||||
params,
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return "project_admin" if row["role"] == "owner" else "project_contributor"
|
||||
|
||||
|
||||
def project_of_rfc(rfc_slug: str) -> str:
|
||||
"""The project an RFC belongs to, via its collection
|
||||
(`cached_rfcs.collection_id` -> `collections.project_id`, §22 three-tier).
|
||||
Falls back to the default project when the slug isn't cached — the same N=1
|
||||
default migration 026 backfills."""
|
||||
row = db.conn().execute(
|
||||
"SELECT c.project_id AS project_id "
|
||||
"FROM cached_rfcs r JOIN collections c ON c.id = r.collection_id "
|
||||
"WHERE r.slug = ?",
|
||||
(rfc_slug,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return DEFAULT_PROJECT_ID
|
||||
return row["project_id"] or DEFAULT_PROJECT_ID
|
||||
|
||||
|
||||
def collection_of_rfc(rfc_slug: str) -> str:
|
||||
"""The collection an RFC belongs to (`cached_rfcs.collection_id`). Falls back
|
||||
to the default collection when the slug isn't cached. Mirrors
|
||||
`project_of_rfc`'s first-match semantics; a slug shared across collections is
|
||||
a known routing ambiguity (the RFC-grain helpers take a bare slug) resolved
|
||||
by the collection-qualified routes in later slices."""
|
||||
row = db.conn().execute(
|
||||
"SELECT collection_id FROM cached_rfcs WHERE slug = ?", (rfc_slug,)
|
||||
).fetchone()
|
||||
if row is None or not row["collection_id"]:
|
||||
return collections_mod.DEFAULT_COLLECTION_ID
|
||||
return row["collection_id"]
|
||||
|
||||
|
||||
def is_project_superuser(user: SessionUser | None, project_id: str) -> bool:
|
||||
"""Maximal authority within a project: a deployment owner/admin (superuser
|
||||
in every project, §22.7) or an explicit `project_admin` (§22.6). Both
|
||||
subsume the per-RFC owners/arbiters tier."""
|
||||
if user is None:
|
||||
return False
|
||||
if user.role in _DEPLOYMENT_SUPERUSER_ROLES:
|
||||
return True
|
||||
return project_member_role(user, project_id) == "project_admin"
|
||||
|
||||
|
||||
def can_read_project(user: SessionUser | None, project_id: str) -> bool:
|
||||
"""The §22.5 visibility gate at the project grain. `public`/`unlisted` are
|
||||
readable by anyone (anonymous included — `unlisted` is link-only but the link
|
||||
still reads); `gated` is readable only by a deployment owner/admin or a
|
||||
holder of any scope grant reaching the project — a global grant, a project
|
||||
grant, or membership at *any* collection within it (seeing a collection
|
||||
implies seeing its project). Used as the subtractive read gate (a gated
|
||||
project's entries 404 to non-members)."""
|
||||
vis = project_visibility(project_id)
|
||||
if vis in ("public", "unlisted"):
|
||||
return True
|
||||
# gated — scope-role holders + superusers only, subject to the §6 floor.
|
||||
if user is None or user.permission_state != "granted":
|
||||
return False
|
||||
if user.role in _DEPLOYMENT_SUPERUSER_ROLES:
|
||||
return True
|
||||
row = db.conn().execute(
|
||||
"SELECT 1 FROM memberships m WHERE m.user_id = ? AND ("
|
||||
" m.scope_type = 'global'"
|
||||
" OR (m.scope_type = 'project' AND m.scope_id = ?)"
|
||||
" OR (m.scope_type = 'collection' AND m.scope_id IN "
|
||||
" (SELECT id FROM collections WHERE project_id = ?))) LIMIT 1",
|
||||
(user.user_id, project_id, project_id),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
def require_project_readable(user: SessionUser | None, project_id: str) -> None:
|
||||
"""Raise 404 when the project is not readable by this viewer (§22.5: a
|
||||
gated project is invisible to non-members — indistinguishable from absent,
|
||||
so the shape matches an unknown slug)."""
|
||||
if not can_read_project(user, project_id):
|
||||
raise HTTPException(status_code=404, detail="RFC not found")
|
||||
|
||||
|
||||
def _has_write_baseline(user: SessionUser | None, project_id: str) -> bool:
|
||||
"""The implicit-on-public write baseline: a granted deployment
|
||||
`contributor` on a `public` project carries the pre-multi-project
|
||||
write standing (still subject to per-RFC curation). Deployment
|
||||
owner/admin are handled by `is_project_superuser`; on gated/unlisted a
|
||||
contributor has no baseline and needs an explicit project role."""
|
||||
if user is None or user.permission_state != "granted":
|
||||
return False
|
||||
return user.role == "contributor" and project_visibility(project_id) == "public"
|
||||
|
||||
|
||||
def can_contribute_in_project(user: SessionUser | None, project_id: str) -> bool:
|
||||
"""May the user contribute *new* content to the project (propose an entry)
|
||||
— the project-level (not RFC-specific) contribute standing. The union of
|
||||
the override grants (superuser / explicit project_contributor) and the
|
||||
implicit-public baseline, subject to the visibility gate."""
|
||||
if user is None or user.permission_state != "granted":
|
||||
return False
|
||||
if not can_read_project(user, project_id):
|
||||
return False
|
||||
if is_project_superuser(user, project_id):
|
||||
return True
|
||||
if project_member_role(user, project_id) == "project_contributor":
|
||||
return True
|
||||
return _has_write_baseline(user, project_id)
|
||||
|
||||
|
||||
def can_discuss_in_project(user: SessionUser | None, project_id: str) -> bool:
|
||||
"""May the user participate in discussion in the project at all — the
|
||||
project-level discuss standing (project_viewer ⊇ discussant, §22.7).
|
||||
A superset of `can_contribute_in_project` (a contributor can discuss)."""
|
||||
if user is None or user.permission_state != "granted":
|
||||
return False
|
||||
if not can_read_project(user, project_id):
|
||||
return False
|
||||
if is_project_superuser(user, project_id):
|
||||
return True
|
||||
if project_member_role(user, project_id) in ("project_viewer", "project_contributor"):
|
||||
return True
|
||||
return _has_write_baseline(user, project_id)
|
||||
|
||||
|
||||
def visible_project_ids(user: SessionUser | None) -> list[str]:
|
||||
"""Project ids whose entries may surface in a listing for this viewer — the
|
||||
§22.5 read gate applied to the catalog/idea lists. (The directory's
|
||||
`unlisted`-omission and the per-project routing are M3 concerns; for the M2
|
||||
listing filter we include every project the viewer can read.)"""
|
||||
rows = db.conn().execute("SELECT id FROM projects").fetchall()
|
||||
return [r["id"] for r in rows if can_read_project(user, r["id"])]
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# §22 three-tier — S3. The four-layer scope-role resolver (§B.2) and the
|
||||
# collection-grain visibility gate.
|
||||
#
|
||||
# A grant attaches the unified role {owner, contributor} at a scope: global,
|
||||
# project, or collection (§B.1). Grants inherit downward, are additive, and
|
||||
# admit no negative override (§B.2). Effective authority over a *collection* is
|
||||
# the most-permissive union of the layers reaching it:
|
||||
#
|
||||
# global (users.role owner/admin ∪ memberships scope_type='global')
|
||||
# ∪ project (memberships scope_type='project' at the collection's project)
|
||||
# ∪ collection (memberships scope_type='collection' at the collection)
|
||||
#
|
||||
# minus the §22.5 visibility gate and §6.2 write-mute (subtractive, as today).
|
||||
# Per-entry authority (owners / arbiters / rfc_collaborators) is a distinct,
|
||||
# finer layer the RFC-grain helpers union in beneath collection.
|
||||
#
|
||||
# OPERATOR DECISIONS (S3, session 0076):
|
||||
# * scope-role-primary — a plain granted account (users.role 'contributor', no
|
||||
# membership) is a granted *account*, not a write-everywhere global role.
|
||||
# Write standing comes from an explicit scope grant; the lone exception is the
|
||||
# grandfathered implicit-public baseline below.
|
||||
# * grandfathered baseline — the migration-seeded `default` collection keeps the
|
||||
# pre-three-tier implicit-on-public write baseline (a granted deployment
|
||||
# contributor may propose while it is public), so the N=1 deployment (§22.13)
|
||||
# loses no capability. Every *explicitly-created* collection requires an
|
||||
# explicit scope grant to write.
|
||||
# * hidden-from-public — a `gated` collection is invisible to the public (404,
|
||||
# omitted from the directory) yet visible to any scope-role holder reaching it
|
||||
# (collection/project/global). A collection's visibility may be set only as
|
||||
# strict or stricter than its project's (the rank ordering below).
|
||||
# ===========================================================================
|
||||
|
||||
# The global scope is a single tier per deployment; its grant rows use this
|
||||
# sentinel scope_id (migration 030).
|
||||
GLOBAL_SCOPE_ID = "*"
|
||||
|
||||
# §22.5 visibility strictness on the public-exposure axis: `public` is least
|
||||
# strict, `gated` most strict. A collection may narrow its project's visibility
|
||||
# but never widen it (`rank(collection) >= rank(project)`).
|
||||
_VISIBILITY_RANK = {"public": 0, "unlisted": 1, "gated": 2}
|
||||
|
||||
|
||||
def visibility_rank(visibility: str | None) -> int:
|
||||
"""The strictness rank of a §22.5 visibility (higher = stricter). An unknown
|
||||
value reads as the strictest (`gated`) — the safe default."""
|
||||
return _VISIBILITY_RANK.get(visibility or "", _VISIBILITY_RANK["gated"])
|
||||
|
||||
|
||||
def effective_scope_role(user: SessionUser | None, collection_id: str) -> str | None:
|
||||
"""The most-permissive unified role ({'owner','contributor'}) the user holds
|
||||
over `collection_id`, folding §B.2's global → project → collection layers.
|
||||
Returns None when no scope grant reaches the collection. 'owner' outranks
|
||||
'contributor'; there is no negative override (a parent grant is never
|
||||
subtracted by a child). Subject to the §6 admission floor."""
|
||||
if user is None or user.permission_state != "granted":
|
||||
return None
|
||||
# Global tier — a deployment owner/admin is a global Owner (§B.1).
|
||||
if user.role in _DEPLOYMENT_SUPERUSER_ROLES:
|
||||
return "owner"
|
||||
pid = collections_mod.project_of_collection(collection_id)
|
||||
row = db.conn().execute(
|
||||
"SELECT role FROM memberships "
|
||||
"WHERE user_id = ? AND ("
|
||||
" scope_type = 'global'"
|
||||
" OR (scope_type = 'project' AND scope_id = ?)"
|
||||
" OR (scope_type = 'collection' AND scope_id = ?)) "
|
||||
"ORDER BY CASE role WHEN 'owner' THEN 0 ELSE 1 END LIMIT 1",
|
||||
(user.user_id, pid, collection_id),
|
||||
).fetchone()
|
||||
return row["role"] if row else None
|
||||
|
||||
|
||||
def collection_visibility(collection_id: str) -> str:
|
||||
"""The collection's own §22.5 visibility. A missing row reads as 'gated' —
|
||||
an unknown collection is invisible rather than open."""
|
||||
row = db.conn().execute(
|
||||
"SELECT visibility FROM collections WHERE id = ?", (collection_id,)
|
||||
).fetchone()
|
||||
if row is None or not row["visibility"]:
|
||||
return "gated"
|
||||
return row["visibility"]
|
||||
|
||||
|
||||
def effective_collection_visibility(collection_id: str) -> str:
|
||||
"""The stricter of the collection's own visibility and its project's (§22.5
|
||||
'both gates'). A collection is constrained to be ≥ its project in strictness,
|
||||
but we max() defensively so a misconfigured looser collection can never widen
|
||||
its project's gate."""
|
||||
cvis = collection_visibility(collection_id)
|
||||
pid = collections_mod.project_of_collection(collection_id)
|
||||
pvis = project_visibility(pid) if pid else "gated"
|
||||
return cvis if visibility_rank(cvis) >= visibility_rank(pvis) else pvis
|
||||
|
||||
|
||||
def can_read_collection(user: SessionUser | None, collection_id: str) -> bool:
|
||||
"""§22.5 read/existence gate at the collection grain. `public`/`unlisted`
|
||||
read by anyone (anonymous included — `unlisted` is link-only but the link
|
||||
reads); `gated` ("hidden from public existence") reads only for a scope-role
|
||||
holder over the collection (collection/project/global) or a deployment
|
||||
owner/admin. The subtractive read gate — a gated collection 404s a
|
||||
non-holder, indistinguishable from absent."""
|
||||
vis = effective_collection_visibility(collection_id)
|
||||
if vis in ("public", "unlisted"):
|
||||
return True
|
||||
if user is None or user.permission_state != "granted":
|
||||
return False
|
||||
return effective_scope_role(user, collection_id) is not None
|
||||
|
||||
|
||||
def require_collection_readable(user: SessionUser | None, collection_id: str) -> None:
|
||||
"""Raise 404 when the collection is not readable by this viewer (§22.5: a
|
||||
hidden/gated collection is invisible to non-holders — the shape matches an
|
||||
unknown collection)."""
|
||||
if not can_read_collection(user, collection_id):
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
|
||||
|
||||
def is_collection_superuser(user: SessionUser | None, collection_id: str) -> bool:
|
||||
"""Maximal authority over a collection: an effective scope role of 'owner'
|
||||
reaching it (a collection Owner, a project Owner of its project, a global
|
||||
Owner, or a deployment owner/admin). Subsumes the per-entry owners/arbiters
|
||||
tier within the collection."""
|
||||
return effective_scope_role(user, collection_id) == "owner"
|
||||
|
||||
|
||||
def _has_collection_write_baseline(user: SessionUser | None, collection_id: str) -> bool:
|
||||
"""The grandfathered implicit-on-public write baseline, narrowed to the
|
||||
migration-seeded `default` collection (§22.13 N=1 case). A granted deployment
|
||||
`contributor` keeps its pre-three-tier write standing on the default
|
||||
collection while its effective visibility is public; every explicitly-created
|
||||
collection requires an explicit scope grant (S3 operator decision)."""
|
||||
if user is None or user.permission_state != "granted":
|
||||
return False
|
||||
if collection_id != collections_mod.DEFAULT_COLLECTION_ID:
|
||||
return False
|
||||
return user.role == "contributor" and effective_collection_visibility(collection_id) == "public"
|
||||
|
||||
|
||||
def can_contribute_in_collection(user: SessionUser | None, collection_id: str) -> bool:
|
||||
"""May the user contribute *new* content to the collection (propose an entry)
|
||||
— the collection-level contribute standing. The union of the scope-role grant
|
||||
(owner/contributor reaching the collection) and the grandfathered default
|
||||
baseline, subject to the visibility read gate."""
|
||||
if user is None or user.permission_state != "granted":
|
||||
return False
|
||||
if not can_read_collection(user, collection_id):
|
||||
return False
|
||||
if effective_scope_role(user, collection_id) is not None:
|
||||
return True
|
||||
return _has_collection_write_baseline(user, collection_id)
|
||||
|
||||
|
||||
def can_discuss_in_collection(user: SessionUser | None, collection_id: str) -> bool:
|
||||
"""May the user participate in discussion in the collection — the
|
||||
collection-level discuss standing. A superset of contribute for this pass
|
||||
(the read-only viewer tier is deferred, §B.3), so it mirrors
|
||||
`can_contribute_in_collection`."""
|
||||
return can_contribute_in_collection(user, collection_id)
|
||||
|
||||
|
||||
def can_create_collection(user: SessionUser | None, project_id: str) -> bool:
|
||||
"""May the user create a new collection in this project (§B.1)? Creating a
|
||||
collection is a *project-level* action: a deployment owner/admin, or any
|
||||
holder of a project-scope or global-scope grant (Owner OR RFC Contributor —
|
||||
'anyone at the project level with permission to create a collection'). A
|
||||
*collection*-scope grant cannot create sibling collections."""
|
||||
if user is None or user.permission_state != "granted":
|
||||
return False
|
||||
if user.role in _DEPLOYMENT_SUPERUSER_ROLES:
|
||||
return True
|
||||
row = db.conn().execute(
|
||||
"SELECT 1 FROM memberships "
|
||||
"WHERE user_id = ? AND ("
|
||||
" scope_type = 'global'"
|
||||
" OR (scope_type = 'project' AND scope_id = ?)) LIMIT 1",
|
||||
(user.user_id, project_id),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
# 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
|
||||
cid = collection_of_rfc(rfc_slug)
|
||||
# §22.5 visibility gate is subtractive (§22.7) — no capability in a
|
||||
# collection the viewer cannot even read.
|
||||
if not can_read_collection(user, cid):
|
||||
return False
|
||||
# §B.2 union, scope-role grants first — these bypass per-RFC curation (a
|
||||
# collection/project/global Owner or RFC Contributor ⊇ discussant).
|
||||
if effective_scope_role(user, cid) is not None:
|
||||
return True
|
||||
# per-RFC authority (union term).
|
||||
owners = _rfc_owners_set(rfc_slug)
|
||||
if user.gitea_login in owners:
|
||||
return True
|
||||
if is_rfc_collaborator(user, rfc_slug, role_in_rfc=None):
|
||||
return True
|
||||
# grandfathered implicit-public baseline (curation preserved): on the default
|
||||
# collection a granted deployment contributor may discuss only while the RFC
|
||||
# is unclaimed. The first §13.1 claim engages the per-RFC gate, mirroring the
|
||||
# pre-multi-project v0.16.0 contract.
|
||||
if not owners and _has_collection_write_baseline(user, cid):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
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
|
||||
cid = collection_of_rfc(rfc_slug)
|
||||
if not can_read_collection(user, cid):
|
||||
return False
|
||||
# §B.2 union, scope-role grants first (a collection/project/global RFC
|
||||
# Contributor ⊇ rfc_collaborators(contributor); an Owner ⊇ all).
|
||||
if effective_scope_role(user, cid) is not None:
|
||||
return True
|
||||
# per-RFC authority (union term). A 'discussant' row is NOT sufficient —
|
||||
# PRs are the higher-privilege surface.
|
||||
owners = _rfc_owners_set(rfc_slug)
|
||||
if user.gitea_login in owners:
|
||||
return True
|
||||
if is_rfc_collaborator(user, rfc_slug, role_in_rfc="contributor"):
|
||||
return True
|
||||
# grandfathered implicit-public baseline (curation preserved): until an owner
|
||||
# exists, a granted deployment contributor on the public default collection
|
||||
# may contribute.
|
||||
if not owners and _has_collection_write_baseline(user, cid):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
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
|
||||
cid = collection_of_rfc(rfc_slug)
|
||||
if not can_read_collection(user, cid):
|
||||
return False
|
||||
# An Owner reaching the collection (collection/project/global Owner, or a
|
||||
# deployment owner/admin) may invite; otherwise only the RFC's frontmatter
|
||||
# owner. Per-RFC collaborators and the baseline do not get the invite power.
|
||||
if is_collection_superuser(user, cid):
|
||||
return True
|
||||
return is_rfc_owner(user, rfc_slug)
|
||||
|
||||
|
||||
def new_state() -> str:
|
||||
return secrets.token_urlsafe(16)
|
||||
|
||||
+200
-134
@@ -27,7 +27,7 @@ import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
from . import db, notify
|
||||
from . import db, entry as entry_mod, notify
|
||||
from .gitea import Gitea, GiteaError
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -163,6 +163,41 @@ class Bot:
|
||||
def __init__(self, gitea: Gitea):
|
||||
self._gitea = gitea
|
||||
|
||||
# ----- Content repo: collection structure (§22 S2) -----
|
||||
|
||||
async def create_collection(
|
||||
self,
|
||||
actor: Actor,
|
||||
*,
|
||||
org: str,
|
||||
content_repo: str,
|
||||
collection_id: str,
|
||||
manifest_yaml: str,
|
||||
) -> dict:
|
||||
"""§22 S2: commit `<collection_id>/.collection.yaml` to the content
|
||||
repo's main. A structural admin action — committed straight to main (no
|
||||
PR), like the registry config it feeds; the registry mirror then upserts
|
||||
the collections row (§22.2 keeps the registry the source of truth). Logs
|
||||
an audit row for the §6.5 trail."""
|
||||
path = f"{collection_id}/.collection.yaml"
|
||||
created = await self._gitea.create_file(
|
||||
org,
|
||||
content_repo,
|
||||
path,
|
||||
content=manifest_yaml,
|
||||
message=_stamp_single(f"chore: create collection {collection_id}", actor),
|
||||
branch="main",
|
||||
author_name=actor.display_name,
|
||||
author_email=actor.email or f"{actor.gitea_login}@users.noreply",
|
||||
)
|
||||
_log(
|
||||
actor,
|
||||
"create_collection",
|
||||
bot_commit_sha=created.get("commit", {}).get("sha"),
|
||||
details={"collection_id": collection_id, "repo": content_repo},
|
||||
)
|
||||
return created
|
||||
|
||||
# ----- Meta repo: idea PRs (§9.1 / §9.2) -----
|
||||
|
||||
async def open_idea_pr(
|
||||
@@ -175,12 +210,16 @@ class Bot:
|
||||
file_contents: str,
|
||||
pr_title: str,
|
||||
pr_description: str,
|
||||
rfcs_dir: str = "rfcs",
|
||||
) -> dict:
|
||||
"""Per §9.1: open a meta-repo PR adding one file under rfcs/.
|
||||
"""Per §9.1: open a meta-repo PR adding one file under `<rfcs_dir>/`.
|
||||
|
||||
One file per PR keeps idea submissions atomic and conflict-free.
|
||||
The PR title and the file-add commit subject share §9.2's fixed
|
||||
pattern; callers compose `pr_title` as `Propose: <Title>`.
|
||||
pattern; callers compose `pr_title` as `Propose: <Title>`. §22 S2:
|
||||
`rfcs_dir` carries the target collection's `<subfolder>/rfcs` so a
|
||||
propose into a named collection writes under its subfolder; it
|
||||
defaults to `rfcs` (the default collection / shipped behaviour).
|
||||
"""
|
||||
branch = f"propose/{slug}"
|
||||
await self._gitea.create_branch(org, meta_repo, branch, from_branch="main")
|
||||
@@ -189,7 +228,7 @@ class Bot:
|
||||
created = await self._gitea.create_file(
|
||||
org,
|
||||
meta_repo,
|
||||
f"rfcs/{slug}.md",
|
||||
f"{rfcs_dir}/{slug}.md",
|
||||
content=file_contents,
|
||||
message=commit_message,
|
||||
branch=branch,
|
||||
@@ -695,111 +734,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,
|
||||
@@ -810,12 +745,15 @@ class Bot:
|
||||
slug: str,
|
||||
new_file_contents: str,
|
||||
prior_sha: str,
|
||||
rfc_id: str,
|
||||
repo_full: str,
|
||||
rfc_id: str | None,
|
||||
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
|
||||
"""§13.3 (meta-only): open a PR against the meta repo that flips the
|
||||
entry's frontmatter to `state: active` with the graduation stamps
|
||||
and — **optionally** — the integer `id`, **keeping the body
|
||||
unchanged** (§1 meta-only topology; no repo is created and no body
|
||||
is stripped). When `rfc_id` is None the entry graduates without a
|
||||
number (id stays null, slug is canonical per §2.3, §13.2). Branch
|
||||
name uses the `graduate-<slug>-<6hex>` shape — dash-separated like
|
||||
the other meta-repo branches per the §19.2 path-routing candidate.
|
||||
"""
|
||||
@@ -824,7 +762,7 @@ class Bot:
|
||||
branch = f"graduate-{slug}-{secrets.token_hex(3)}"
|
||||
await self._gitea.create_branch(org, meta_repo, branch, from_branch="main")
|
||||
ae = actor.email or f"{actor.gitea_login}@users.noreply"
|
||||
commit_subject = f"Graduate {slug} → {rfc_id}"
|
||||
commit_subject = f"Graduate {slug} → {rfc_id}" if rfc_id else f"Graduate {slug} (no number)"
|
||||
commit_message = _stamp_single(commit_subject, actor)
|
||||
result = await self._gitea.update_file(
|
||||
org, meta_repo, f"rfcs/{slug}.md",
|
||||
@@ -839,16 +777,17 @@ class Bot:
|
||||
or result.get("content", {}).get("sha")
|
||||
or ""
|
||||
)
|
||||
pr_title = f"Graduate {slug} → {rfc_id}"
|
||||
pr_title = f"Graduate {slug} → {rfc_id}" if rfc_id else f"Graduate {slug} (no number)"
|
||||
owners_str = ", ".join(owners) if owners else "(none)"
|
||||
id_line = f"- ID: `{rfc_id}`\n" if rfc_id else "- ID: (none — identified by slug)\n"
|
||||
pr_body_text = (
|
||||
f"Graduates super-draft `{slug}` to active.\n\n"
|
||||
f"- ID: `{rfc_id}`\n"
|
||||
f"- Repo: `{repo_full}`\n"
|
||||
f"{id_line}"
|
||||
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 +801,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
|
||||
|
||||
@@ -875,7 +814,7 @@ class Bot:
|
||||
pr_number: int,
|
||||
head_branch: str,
|
||||
slug: str,
|
||||
rfc_id: str,
|
||||
rfc_id: str | None,
|
||||
) -> None:
|
||||
"""§13.3 step 4: auto-merge the graduation PR with the admin as
|
||||
merge actor. Distinct action_kind so the audit log carries the
|
||||
@@ -891,7 +830,7 @@ class Bot:
|
||||
reports the transient state (belt-and-suspenders against the same
|
||||
race appearing in a different shape).
|
||||
"""
|
||||
subject = f"Graduate {slug} → {rfc_id}"
|
||||
subject = f"Graduate {slug} → {rfc_id}" if rfc_id else f"Graduate {slug} (no number)"
|
||||
body = _trailer(actor)
|
||||
|
||||
try:
|
||||
@@ -912,27 +851,106 @@ class Bot:
|
||||
details={"rfc_id": rfc_id},
|
||||
)
|
||||
|
||||
# ----- §13.3 rollback inverses -----
|
||||
# ----- §13.7 retire / un-retire: open + merge a state-flip PR -----
|
||||
|
||||
async def delete_rfc_repo(
|
||||
async def open_retire_flip_pr(
|
||||
self,
|
||||
actor: Actor,
|
||||
*,
|
||||
org: str,
|
||||
repo_name: str,
|
||||
meta_repo: 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)
|
||||
new_file_contents: str,
|
||||
prior_sha: str,
|
||||
verb: str,
|
||||
target_state: str,
|
||||
) -> dict:
|
||||
"""§13.7: open a PR flipping `rfcs/<slug>.md` to `state:
|
||||
<target_state>` — for retire (`verb='retire'`, target `retired`)
|
||||
or un-retire (`verb='unretire'`, target the restored prior state).
|
||||
Only the frontmatter `state` changes; the body and every other
|
||||
field (including the integer `id`) are kept, so an un-retire
|
||||
restores the entry exactly. Branch shape mirrors graduation's
|
||||
`<verb>-<slug>-<6hex>`.
|
||||
"""
|
||||
import secrets
|
||||
|
||||
branch = f"{verb}-{slug}-{secrets.token_hex(3)}"
|
||||
await self._gitea.create_branch(org, meta_repo, branch, from_branch="main")
|
||||
ae = actor.email or f"{actor.gitea_login}@users.noreply"
|
||||
verb_title = "Retire" if verb == "retire" else "Un-retire"
|
||||
commit_subject = f"{verb_title} {slug}"
|
||||
commit_message = _stamp_single(commit_subject, actor)
|
||||
result = await self._gitea.update_file(
|
||||
org, meta_repo, f"rfcs/{slug}.md",
|
||||
content=new_file_contents,
|
||||
sha=prior_sha,
|
||||
message=commit_message,
|
||||
branch=branch,
|
||||
author_name=actor.display_name, author_email=ae,
|
||||
)
|
||||
commit_sha = (
|
||||
result.get("commit", {}).get("sha")
|
||||
or result.get("content", {}).get("sha")
|
||||
or ""
|
||||
)
|
||||
pr_title = f"{verb_title} {slug}"
|
||||
pr_body_text = (
|
||||
f"{verb_title}s `{slug}` (state → `{target_state}`).\n\n"
|
||||
f"This is an in-place frontmatter flip per SPEC §13.7 — the\n"
|
||||
f"entry `rfcs/{slug}.md` keeps its body and every other field;\n"
|
||||
f"only `state` changes."
|
||||
)
|
||||
_subject, pr_body = _stamp("", pr_body_text, actor)
|
||||
pr = await self._gitea.create_pull(
|
||||
org, meta_repo,
|
||||
title=pr_title, body=pr_body, head=branch, base="main",
|
||||
)
|
||||
_log(
|
||||
actor,
|
||||
"graduate_repo_delete",
|
||||
f"{verb}_pr_open",
|
||||
rfc_slug=slug,
|
||||
details={"repo": f"{org}/{repo_name}", "reason": reason},
|
||||
branch_name=branch,
|
||||
pr_number=pr["number"],
|
||||
bot_commit_sha=commit_sha,
|
||||
details={"pr_title": pr_title, "target_state": target_state},
|
||||
)
|
||||
return pr
|
||||
|
||||
async def merge_retire_flip_pr(
|
||||
self,
|
||||
actor: Actor,
|
||||
*,
|
||||
org: str,
|
||||
meta_repo: str,
|
||||
pr_number: int,
|
||||
head_branch: str,
|
||||
slug: str,
|
||||
verb: str,
|
||||
) -> None:
|
||||
"""§13.7: auto-merge the retire / un-retire flip PR (same
|
||||
mergeable-wait + retry shape as `merge_graduation_pr`)."""
|
||||
verb_title = "Retire" if verb == "retire" else "Un-retire"
|
||||
subject = f"{verb_title} {slug}"
|
||||
body = _trailer(actor)
|
||||
try:
|
||||
await self._gitea.wait_for_mergeable(org, meta_repo, pr_number)
|
||||
except TimeoutError as e:
|
||||
raise GiteaError(409, str(e)) from e
|
||||
await _merge_with_retry(
|
||||
self._gitea, org, meta_repo, pr_number,
|
||||
merge_message_title=subject, merge_message_body=body,
|
||||
)
|
||||
_log(
|
||||
actor,
|
||||
f"{verb}_pr_merge",
|
||||
rfc_slug=slug,
|
||||
branch_name=head_branch,
|
||||
pr_number=pr_number,
|
||||
details={},
|
||||
)
|
||||
|
||||
# ----- §13.3 (meta-only): cleanup of an unmerged flip PR -----
|
||||
|
||||
async def close_graduation_pr(
|
||||
self,
|
||||
@@ -1090,6 +1108,54 @@ class Bot:
|
||||
)
|
||||
return pr
|
||||
|
||||
# ----- §22.4c: mark-reviewed (direct main write) -----
|
||||
|
||||
async def mark_entry_reviewed(
|
||||
self,
|
||||
actor: Actor,
|
||||
*,
|
||||
org: str,
|
||||
meta_repo: str,
|
||||
slug: str,
|
||||
reviewed_by: str,
|
||||
reviewed_at: str,
|
||||
) -> None:
|
||||
"""Clear §22.4c unreviewed on an active entry by rewriting its
|
||||
frontmatter on main. Stamps the commit with the §6.5 On-behalf-of
|
||||
trailer and writes an actions-log row, mirroring the graduation
|
||||
stamp's bot-write shape."""
|
||||
path = f"rfcs/{slug}.md"
|
||||
result = await self._gitea.read_file(org, meta_repo, path, ref="main")
|
||||
if result is None:
|
||||
raise GiteaError(404, f"{path} not found")
|
||||
text, sha = result
|
||||
e = entry_mod.parse(text)
|
||||
e.unreviewed = False
|
||||
e.reviewed_at = reviewed_at
|
||||
e.reviewed_by = reviewed_by
|
||||
commit_message = _stamp_single(f"Mark {slug} reviewed", actor)
|
||||
result = await self._gitea.update_file(
|
||||
org, meta_repo, path,
|
||||
content=entry_mod.serialize(e),
|
||||
sha=sha,
|
||||
message=commit_message,
|
||||
branch="main",
|
||||
author_name=actor.display_name,
|
||||
author_email=actor.email or f"{actor.gitea_login}@users.noreply",
|
||||
)
|
||||
commit_sha = (
|
||||
result.get("commit", {}).get("sha")
|
||||
or result.get("content", {}).get("sha")
|
||||
or ""
|
||||
)
|
||||
_log(
|
||||
actor,
|
||||
"mark_reviewed",
|
||||
rfc_slug=slug,
|
||||
bot_commit_sha=commit_sha,
|
||||
details={"reviewed_by": reviewed_by, "reviewed_at": reviewed_at},
|
||||
)
|
||||
|
||||
# ----- Per-RFC repo: seeding (test/dev fixtures, future graduation) -----
|
||||
|
||||
async def ensure_rfc_repo_seed(
|
||||
|
||||
+139
-34
@@ -27,7 +27,7 @@ import asyncio
|
||||
import json
|
||||
import logging
|
||||
|
||||
from . import db, entry as entry_mod
|
||||
from . import db, entry as entry_mod, projects as projects_mod, registry as registry_mod
|
||||
from .config import Config
|
||||
from .gitea import Gitea, GiteaError
|
||||
|
||||
@@ -35,16 +35,46 @@ log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def refresh_meta_repo(config: Config, gitea: Gitea) -> None:
|
||||
"""Re-read rfcs/ on the meta repo and reconcile cached_rfcs.
|
||||
"""Re-read rfcs/ on every project's content repo and reconcile cached_rfcs.
|
||||
|
||||
Idempotent. Safe to call on every meta-repo webhook and on every
|
||||
reconciler sweep.
|
||||
§22 (Plan B): a deployment has N projects (§22.1), each with its own
|
||||
content_repo (§22.3). Mirror each into cached_rfcs stamped with that
|
||||
project's id, so a second project's corpus renders under /p/<id>/. Idempotent;
|
||||
safe on every content-repo webhook and reconciler sweep.
|
||||
"""
|
||||
org, repo = config.gitea_org, config.meta_repo
|
||||
org = config.gitea_org
|
||||
rows = db.conn().execute(
|
||||
"SELECT id, content_repo FROM projects WHERE content_repo IS NOT NULL AND content_repo != ''"
|
||||
).fetchall()
|
||||
if not rows:
|
||||
log.warning("refresh_meta_repo: no projects with a content_repo yet; skipping")
|
||||
return
|
||||
for prow in rows:
|
||||
await _refresh_project_corpus(org, prow["id"], prow["content_repo"], gitea)
|
||||
|
||||
|
||||
async def _refresh_project_corpus(org: str, project_id: str, repo: str, gitea: Gitea) -> None:
|
||||
# §22 S2: the corpus grain is the collection. Mirror every collection of the
|
||||
# project from its `<subfolder>/rfcs/` directory, keying cached_rfcs by the
|
||||
# collection id. The default collection (subfolder '') reads `rfcs/` — the
|
||||
# shipped path, unchanged. include_unlisted: the mirror serves every
|
||||
# collection's content regardless of enumeration visibility.
|
||||
from . import collections as collections_mod
|
||||
for col in collections_mod.list_collections(project_id, include_unlisted=True):
|
||||
await _refresh_collection_corpus(
|
||||
org, project_id, repo, col["id"], col["subfolder"] or "", gitea
|
||||
)
|
||||
|
||||
|
||||
async def _refresh_collection_corpus(
|
||||
org: str, project_id: str, repo: str, collection_id: str, subfolder: str, gitea: Gitea
|
||||
) -> None:
|
||||
rfcs_dir = f"{subfolder}/rfcs" if subfolder else "rfcs"
|
||||
try:
|
||||
files = await gitea.list_dir(org, repo, "rfcs", ref="main")
|
||||
files = await gitea.list_dir(org, repo, rfcs_dir, ref="main")
|
||||
except GiteaError as e:
|
||||
log.warning("refresh_meta_repo: cannot list rfcs/: %s", e)
|
||||
log.warning("refresh_meta_repo: %s/%s: cannot list %s: %s",
|
||||
project_id, collection_id, rfcs_dir, e)
|
||||
return
|
||||
|
||||
seen_slugs: set[str] = set()
|
||||
@@ -58,24 +88,31 @@ async def refresh_meta_repo(config: Config, gitea: Gitea) -> None:
|
||||
try:
|
||||
entry = entry_mod.parse(text)
|
||||
except Exception as parse_err:
|
||||
log.warning("refresh_meta_repo: skipping %s: %s", f["path"], parse_err)
|
||||
log.warning("refresh_meta_repo: %s/%s: skipping %s: %s",
|
||||
project_id, collection_id, f["path"], parse_err)
|
||||
continue
|
||||
if not entry.slug:
|
||||
log.warning("refresh_meta_repo: skipping %s: missing slug", f["path"])
|
||||
log.warning("refresh_meta_repo: %s/%s: skipping %s: missing slug",
|
||||
project_id, collection_id, f["path"])
|
||||
continue
|
||||
seen_slugs.add(entry.slug)
|
||||
_upsert_cached_rfc(entry, body_sha=sha)
|
||||
_upsert_cached_rfc(entry, body_sha=sha, collection_id=collection_id)
|
||||
|
||||
# Mark entries removed from the meta repo as withdrawn-without-trace.
|
||||
# In practice the spec keeps withdrawn entries in rfcs/ as historical
|
||||
# record (§3), so this branch fires only for entries deleted out of
|
||||
# band. We leave the row but flag it for reconciler attention.
|
||||
existing = {row["slug"] for row in db.conn().execute("SELECT slug FROM cached_rfcs")}
|
||||
# Entries removed from a collection's rfcs/ — the spec keeps withdrawn entries
|
||||
# as historical record (§3), so this fires only for out-of-band deletes;
|
||||
# leave the row, scoped to this collection, for reconciler attention.
|
||||
existing = {
|
||||
row["slug"]
|
||||
for row in db.conn().execute(
|
||||
"SELECT slug FROM cached_rfcs WHERE collection_id = ?", (collection_id,)
|
||||
)
|
||||
}
|
||||
for missing in existing - seen_slugs:
|
||||
log.info("refresh_meta_repo: %s no longer in rfcs/ — leaving cache row in place", missing)
|
||||
log.info("refresh_meta_repo: %s/%s/%s no longer present — leaving cache row",
|
||||
project_id, collection_id, missing)
|
||||
|
||||
|
||||
def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str) -> None:
|
||||
def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str, collection_id: str = "default") -> None:
|
||||
# §6.6: models_json stays NULL when the frontmatter key is absent
|
||||
# (inherit operator universe) and '[]' for the explicit opt-out.
|
||||
models_json = json.dumps(entry.models) if entry.models is not None else None
|
||||
@@ -87,9 +124,11 @@ def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str) -> None:
|
||||
INSERT INTO cached_rfcs
|
||||
(slug, title, state, rfc_id, repo, proposed_by, proposed_at,
|
||||
graduated_at, graduated_by, owners_json, arbiters_json, tags_json,
|
||||
models_json, funder_login, body, body_sha, last_entry_commit_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
|
||||
ON CONFLICT(slug) DO UPDATE SET
|
||||
models_json, funder_login, body, body_sha,
|
||||
unreviewed, reviewed_at, reviewed_by, collection_id,
|
||||
last_entry_commit_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
|
||||
ON CONFLICT(collection_id, slug) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
state = excluded.state,
|
||||
rfc_id = excluded.rfc_id,
|
||||
@@ -105,6 +144,9 @@ def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str) -> None:
|
||||
funder_login = excluded.funder_login,
|
||||
body = excluded.body,
|
||||
body_sha = excluded.body_sha,
|
||||
unreviewed = excluded.unreviewed,
|
||||
reviewed_at = excluded.reviewed_at,
|
||||
reviewed_by = excluded.reviewed_by,
|
||||
last_entry_commit_at = datetime('now'),
|
||||
updated_at = datetime('now')
|
||||
""",
|
||||
@@ -125,6 +167,10 @@ def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str) -> None:
|
||||
funder_login,
|
||||
entry.body,
|
||||
body_sha,
|
||||
1 if entry.unreviewed else 0,
|
||||
entry.reviewed_at,
|
||||
entry.reviewed_by,
|
||||
collection_id,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -184,7 +230,7 @@ async def refresh_rfc_repo(config: Config, gitea: Gitea, slug: str) -> None:
|
||||
"""
|
||||
INSERT INTO cached_branches (rfc_slug, branch_name, head_sha, state, last_commit_at)
|
||||
VALUES (?, ?, ?, 'open', ?)
|
||||
ON CONFLICT(rfc_slug, branch_name) DO UPDATE SET
|
||||
ON CONFLICT(collection_id, rfc_slug, branch_name) DO UPDATE SET
|
||||
head_sha = excluded.head_sha,
|
||||
state = CASE WHEN cached_branches.state = 'closed' THEN 'closed' ELSE 'open' END,
|
||||
last_commit_at = excluded.last_commit_at
|
||||
@@ -219,6 +265,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(
|
||||
@@ -307,7 +366,11 @@ async def refresh_meta_branches(config: Config, gitea: Gitea) -> None:
|
||||
structurally `edit/<slug>/<auto-name>` per §9.5, with dashes in place
|
||||
of slashes per the §19.2 path-routing candidate.
|
||||
"""
|
||||
org, repo = config.gitea_org, config.meta_repo
|
||||
org = config.gitea_org
|
||||
repo = projects_mod.default_content_repo(config)
|
||||
if not repo:
|
||||
log.warning("refresh_meta_branches: default project has no content_repo yet; skipping")
|
||||
return
|
||||
try:
|
||||
branches = await gitea.list_branches(org, repo)
|
||||
except GiteaError as e:
|
||||
@@ -329,16 +392,20 @@ 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(
|
||||
"""
|
||||
INSERT INTO cached_branches (rfc_slug, branch_name, head_sha, state, last_commit_at)
|
||||
VALUES (?, ?, ?, 'open', ?)
|
||||
ON CONFLICT(rfc_slug, branch_name) DO UPDATE SET
|
||||
ON CONFLICT(collection_id, rfc_slug, branch_name) DO UPDATE SET
|
||||
head_sha = excluded.head_sha,
|
||||
state = CASE WHEN cached_branches.state = 'closed' THEN 'closed' ELSE 'open' END,
|
||||
last_commit_at = excluded.last_commit_at
|
||||
@@ -352,14 +419,15 @@ 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(
|
||||
"""
|
||||
INSERT INTO cached_branches (rfc_slug, branch_name, head_sha, state, last_commit_at)
|
||||
VALUES (?, 'main', ?, 'open', ?)
|
||||
ON CONFLICT(rfc_slug, branch_name) DO UPDATE SET
|
||||
ON CONFLICT(collection_id, rfc_slug, branch_name) DO UPDATE SET
|
||||
head_sha = excluded.head_sha,
|
||||
last_commit_at = excluded.last_commit_at
|
||||
""",
|
||||
@@ -374,7 +442,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'
|
||||
"""
|
||||
@@ -418,19 +487,50 @@ async def refresh_meta_pulls(config: Config, gitea: Gitea) -> None:
|
||||
`On-behalf-of:` trailer from the PR body, then to the raw Gitea
|
||||
login as last resort.
|
||||
"""
|
||||
org, repo = config.gitea_org, config.meta_repo
|
||||
org = config.gitea_org
|
||||
bot_login = config.gitea_bot_user
|
||||
rows = db.conn().execute(
|
||||
"SELECT id, content_repo FROM projects WHERE content_repo IS NOT NULL AND content_repo != ''"
|
||||
).fetchall()
|
||||
if not rows:
|
||||
log.warning("refresh_meta_pulls: no projects with a content_repo yet; skipping")
|
||||
return
|
||||
for prow in rows:
|
||||
await _refresh_project_pulls(org, prow["id"], prow["content_repo"], gitea, bot_login)
|
||||
|
||||
|
||||
async def _refresh_project_pulls(
|
||||
org: str, project_id: str, repo: str, gitea: Gitea, bot_login: str
|
||||
) -> None:
|
||||
repo_full = f"{org}/{repo}"
|
||||
try:
|
||||
open_pulls = await gitea.list_pulls(org, repo, state="open")
|
||||
closed_pulls = await gitea.list_pulls(org, repo, state="closed")
|
||||
except GiteaError as e:
|
||||
log.warning("refresh_meta_pulls: %s", e)
|
||||
log.warning("refresh_meta_pulls: project %s: %s", project_id, e)
|
||||
return
|
||||
|
||||
bot_login = config.gitea_bot_user
|
||||
|
||||
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
|
||||
@@ -464,8 +564,8 @@ async def refresh_meta_pulls(config: Config, gitea: Gitea) -> None:
|
||||
INSERT INTO cached_prs
|
||||
(rfc_slug, pr_kind, repo, pr_number, title, description, state,
|
||||
opened_by, opened_at, merged_at, closed_at,
|
||||
head_branch, base_branch, head_sha, merge_commit_sha)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
head_branch, base_branch, head_sha, merge_commit_sha, project_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(repo, pr_number) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
description = excluded.description,
|
||||
@@ -492,6 +592,7 @@ async def refresh_meta_pulls(config: Config, gitea: Gitea) -> None:
|
||||
(pull.get("base") or {}).get("ref") or "main",
|
||||
(pull.get("head") or {}).get("sha"),
|
||||
merge_commit_sha,
|
||||
project_id,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -625,6 +726,10 @@ class Reconciler:
|
||||
async def sweep(self) -> None:
|
||||
log.info("reconciler: starting sweep")
|
||||
try:
|
||||
try:
|
||||
await registry_mod.refresh_registry(self._config, self._gitea)
|
||||
except Exception:
|
||||
log.exception("reconciler: registry refresh failed; keeping last-good projects")
|
||||
await refresh_meta_repo(self._config, self._gitea)
|
||||
await refresh_meta_branches(self._config, self._gitea)
|
||||
await refresh_meta_pulls(self._config, self._gitea)
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"""§22 collection grain — resolution helpers beneath the project tier.
|
||||
|
||||
In S1 each project has exactly one collection (the default). These helpers
|
||||
recover the collection for a project and read the per-corpus fields (`type`,
|
||||
`initial_state`) that moved down from `projects` in migration 029. Project-grain
|
||||
authz (auth.py) recovers a row's project by joining `collections` on
|
||||
`collection_id`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from . import db
|
||||
|
||||
DEFAULT_COLLECTION_ID = "default"
|
||||
|
||||
|
||||
def default_collection_id(project_id: str) -> str:
|
||||
"""The id of a project's default (S1: sole) collection. Falls back to the
|
||||
literal 'default' when the project has no collection row yet."""
|
||||
row = db.conn().execute(
|
||||
"SELECT id FROM collections WHERE project_id = ? ORDER BY created_at, id LIMIT 1",
|
||||
(project_id,),
|
||||
).fetchone()
|
||||
return row["id"] if row else DEFAULT_COLLECTION_ID
|
||||
|
||||
|
||||
def project_of_collection(collection_id: str) -> str | None:
|
||||
"""The project a collection belongs to, or None if unknown."""
|
||||
row = db.conn().execute(
|
||||
"SELECT project_id FROM collections WHERE id = ?", (collection_id,)
|
||||
).fetchone()
|
||||
return row["project_id"] if row else None
|
||||
|
||||
|
||||
def collection_initial_state(collection_id: str) -> str:
|
||||
"""§22.4b landing state for new entries in a collection. 'super-draft'
|
||||
default for an unknown/unset row (today's safe flow)."""
|
||||
row = db.conn().execute(
|
||||
"SELECT initial_state FROM collections WHERE id = ?", (collection_id,)
|
||||
).fetchone()
|
||||
if row is None or not row["initial_state"]:
|
||||
return "super-draft"
|
||||
return row["initial_state"]
|
||||
|
||||
|
||||
def collection_type(collection_id: str) -> str:
|
||||
"""The collection's immutable §22.4a type. 'document' default for unknown."""
|
||||
row = db.conn().execute(
|
||||
"SELECT type FROM collections WHERE id = ?", (collection_id,)
|
||||
).fetchone()
|
||||
return row["type"] if row and row["type"] else "document"
|
||||
|
||||
|
||||
def subfolder_of(collection_id: str) -> str:
|
||||
"""The content-repo subfolder a collection lives under (§22.3). Empty string
|
||||
for the default collection (entries at the repo root `rfcs/`)."""
|
||||
row = db.conn().execute(
|
||||
"SELECT subfolder FROM collections WHERE id = ?", (collection_id,)
|
||||
).fetchone()
|
||||
return (row["subfolder"] if row else "") or ""
|
||||
|
||||
|
||||
def get_collection(collection_id: str) -> dict | None:
|
||||
"""The full collection row as a dict, or None if unknown."""
|
||||
row = db.conn().execute(
|
||||
"SELECT id, project_id, type, subfolder, initial_state, visibility, name "
|
||||
"FROM collections WHERE id = ?",
|
||||
(collection_id,),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def list_collections(project_id: str, include_unlisted: bool = False) -> list[dict]:
|
||||
"""Collections in a project, the default first then by name (§22.5). `unlisted`
|
||||
is omitted from enumeration unless include_unlisted (a direct-id read or the
|
||||
corpus mirror, which serves every collection)."""
|
||||
rows = db.conn().execute(
|
||||
"SELECT id, project_id, type, subfolder, initial_state, visibility, name "
|
||||
"FROM collections WHERE project_id = ? ORDER BY (id != 'default'), name, id",
|
||||
(project_id,),
|
||||
).fetchall()
|
||||
out: list[dict] = []
|
||||
for r in rows:
|
||||
if not include_unlisted and r["visibility"] == "unlisted":
|
||||
continue
|
||||
out.append(dict(r))
|
||||
return out
|
||||
+21
-5
@@ -32,7 +32,7 @@ class Config:
|
||||
gitea_bot_user: str
|
||||
gitea_bot_token: str
|
||||
gitea_org: str
|
||||
meta_repo: str
|
||||
registry_repo: str
|
||||
oauth_client_id: str
|
||||
oauth_client_secret: str
|
||||
app_url: str
|
||||
@@ -44,14 +44,15 @@ class Config:
|
||||
anthropic_api_key: str = ""
|
||||
google_api_key: str = ""
|
||||
openai_api_key: str = ""
|
||||
default_project_id: str = ""
|
||||
|
||||
@property
|
||||
def redirect_uri(self) -> str:
|
||||
return f"{self.app_url}/auth/callback"
|
||||
|
||||
@property
|
||||
def meta_repo_full(self) -> str:
|
||||
return f"{self.gitea_org}/{self.meta_repo}"
|
||||
def registry_repo_full(self) -> str:
|
||||
return f"{self.gitea_org}/{self.registry_repo}"
|
||||
|
||||
|
||||
def load_config() -> Config:
|
||||
@@ -60,21 +61,36 @@ 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"),
|
||||
gitea_bot_token=_required("GITEA_BOT_TOKEN"),
|
||||
gitea_org=_required("GITEA_ORG"),
|
||||
meta_repo=_optional("META_REPO", "meta"),
|
||||
registry_repo=_required("REGISTRY_REPO"),
|
||||
oauth_client_id=_required("OAUTH_CLIENT_ID"),
|
||||
oauth_client_secret=_required("OAUTH_CLIENT_SECRET"),
|
||||
app_url=_optional("APP_URL", "http://localhost:8000").rstrip("/"),
|
||||
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"),
|
||||
openai_api_key=_optional("OPENAI_API_KEY"),
|
||||
default_project_id=_optional("DEFAULT_PROJECT_ID"),
|
||||
)
|
||||
|
||||
+23
-1
@@ -48,7 +48,29 @@ def run_migrations(config: Config) -> None:
|
||||
if version in applied:
|
||||
continue
|
||||
sql = path.read_text()
|
||||
conn.executescript("BEGIN; " + sql + "; COMMIT;")
|
||||
if "-- migrate:no-foreign-keys" in sql:
|
||||
# SQLite table rebuilds (changing a PRIMARY KEY / UNIQUE, e.g.
|
||||
# folding project_id into a composite key) follow the official
|
||||
# 12-step ALTER procedure, which requires FK enforcement OFF —
|
||||
# and `PRAGMA foreign_keys` is a no-op *inside* a transaction, so
|
||||
# it must be toggled here, around the script. The connection is
|
||||
# in autocommit mode (isolation_level=None), so the PRAGMA takes
|
||||
# effect immediately. We re-enable and run foreign_key_check
|
||||
# after, failing the migration loudly if the rebuild left any
|
||||
# dangling reference.
|
||||
conn.execute("PRAGMA foreign_keys = OFF")
|
||||
try:
|
||||
conn.executescript("BEGIN; " + sql + "; COMMIT;")
|
||||
violations = conn.execute("PRAGMA foreign_key_check").fetchall()
|
||||
if violations:
|
||||
raise RuntimeError(
|
||||
f"migration {version} left foreign-key violations: "
|
||||
f"{[tuple(v) for v in violations]}"
|
||||
)
|
||||
finally:
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
else:
|
||||
conn.executescript("BEGIN; " + sql + "; COMMIT;")
|
||||
conn.execute("INSERT INTO schema_migrations (version) VALUES (?)", (version,))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -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,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}"}
|
||||
+165
-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})
|
||||
@@ -250,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(
|
||||
@@ -305,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:
|
||||
@@ -331,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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -440,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"
|
||||
)
|
||||
@@ -23,10 +23,10 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import smtplib
|
||||
from email.message import EmailMessage
|
||||
from email.utils import formataddr
|
||||
|
||||
from .email import EmailConfig, _SENT
|
||||
from .email import EmailConfig, _SENT, record_outbound
|
||||
from .email_envelope import build_envelope
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -44,31 +44,48 @@ def send_otc_email(to_address: str, code: str) -> bool:
|
||||
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:
|
||||
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:
|
||||
@@ -78,9 +95,18 @@ def send_otc_email(to_address: str, code: str) -> bool:
|
||||
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:
|
||||
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
|
||||
|
||||
|
||||
|
||||
+20
-1
@@ -30,7 +30,7 @@ _ABSENT = object()
|
||||
class Entry:
|
||||
slug: str
|
||||
title: str
|
||||
state: str = "super-draft" # super-draft | active | withdrawn
|
||||
state: str = "super-draft" # super-draft | active | withdrawn | retired (§3, §13.7)
|
||||
id: str | None = None # 'RFC-NNNN' or None
|
||||
repo: str | None = None
|
||||
proposed_by: str = ""
|
||||
@@ -50,6 +50,13 @@ class Entry:
|
||||
# operator credentials per §18 are used. The binding is inert until
|
||||
# the named user has a funder_consents row (the hybrid two-key rule).
|
||||
funder: str | None = None
|
||||
# §22.4c: an `active` entry that landed without a human review gate
|
||||
# carries unreviewed=True until an owner clears it. Orthogonal to
|
||||
# `state`; only meaningful for active entries. reviewed_at/reviewed_by
|
||||
# are the provenance of the clear, paralleling graduated_at/by.
|
||||
unreviewed: bool = False
|
||||
reviewed_at: str | None = None
|
||||
reviewed_by: str | None = None
|
||||
body: str = ""
|
||||
|
||||
|
||||
@@ -66,6 +73,7 @@ def parse(text: str) -> Entry:
|
||||
models = [str(m) for m in raw_models]
|
||||
raw_funder = fm.get("funder")
|
||||
funder = str(raw_funder).strip() if raw_funder else None
|
||||
unreviewed = bool(fm.get("unreviewed") or False)
|
||||
return Entry(
|
||||
slug=str(fm.get("slug") or ""),
|
||||
title=str(fm.get("title") or ""),
|
||||
@@ -81,6 +89,9 @@ def parse(text: str) -> Entry:
|
||||
tags=list(fm.get("tags") or []),
|
||||
models=models,
|
||||
funder=funder,
|
||||
unreviewed=unreviewed,
|
||||
reviewed_at=fm.get("reviewed_at") or None,
|
||||
reviewed_by=fm.get("reviewed_by") or None,
|
||||
body=body,
|
||||
)
|
||||
|
||||
@@ -110,6 +121,14 @@ def serialize(entry: Entry) -> str:
|
||||
# second meaning here as with `models:`; one set of semantics.
|
||||
if entry.funder:
|
||||
fm["funder"] = entry.funder
|
||||
# §22.4c: emit unreviewed only when True (a super-draft / reviewed
|
||||
# active entry leaves the key absent → frontmatter stays minimal).
|
||||
if entry.unreviewed:
|
||||
fm["unreviewed"] = True
|
||||
if entry.reviewed_at:
|
||||
fm["reviewed_at"] = entry.reviewed_at
|
||||
if entry.reviewed_by:
|
||||
fm["reviewed_by"] = entry.reviewed_by
|
||||
yaml_text = yaml.safe_dump(fm, sort_keys=False, default_flow_style=False).rstrip()
|
||||
body = entry.body.lstrip("\n")
|
||||
if body:
|
||||
|
||||
@@ -220,7 +220,7 @@ def add_consent(user_id: int, slug: str) -> None:
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO funder_consents (user_id, rfc_slug) VALUES (?, ?)
|
||||
ON CONFLICT(user_id, rfc_slug) DO NOTHING
|
||||
ON CONFLICT(collection_id, user_id, rfc_slug) DO NOTHING
|
||||
""",
|
||||
(user_id, slug),
|
||||
)
|
||||
|
||||
+17
-8
@@ -30,7 +30,7 @@ import logging
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from . import db
|
||||
from . import db, projects as projects_mod
|
||||
from .bot import Bot
|
||||
from .config import Config
|
||||
|
||||
@@ -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,20 @@ 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":
|
||||
owner, repo = config.gitea_org, config.meta_repo
|
||||
elif rfc["state"] == "active" and rfc["repo"] and "/" in rfc["repo"]:
|
||||
if not rfc["repo"]:
|
||||
repo = projects_mod.default_content_repo(config)
|
||||
if not repo:
|
||||
log.warning(
|
||||
"hygiene: default project has no content_repo; skipping branch delete for %s/%s",
|
||||
slug, branch,
|
||||
)
|
||||
return False
|
||||
owner = config.gitea_org
|
||||
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
|
||||
]
|
||||
+342
-10
@@ -7,11 +7,12 @@ 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
|
||||
|
||||
@@ -20,12 +21,18 @@ from . import (
|
||||
auth,
|
||||
cache,
|
||||
db,
|
||||
device_trust as device_trust_mod,
|
||||
digest,
|
||||
email_otc,
|
||||
hygiene,
|
||||
invites as invites_mod,
|
||||
otc,
|
||||
passcode as passcode_mod,
|
||||
projects,
|
||||
providers as providers_mod,
|
||||
ratelimit,
|
||||
registry as registry_mod,
|
||||
turnstile,
|
||||
webhooks,
|
||||
)
|
||||
from .bot import Bot
|
||||
@@ -38,11 +45,25 @@ 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):
|
||||
@@ -52,6 +73,27 @@ class PasscodeSetBody(BaseModel):
|
||||
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
|
||||
@@ -60,6 +102,27 @@ async def lifespan(app: FastAPI):
|
||||
db.run_migrations(config)
|
||||
db.init(config)
|
||||
gitea = Gitea(config)
|
||||
# §22.2: mirror the registry before anything reads projects/content_repo.
|
||||
# First boot has no last-good rows, so a missing/invalid registry is fatal
|
||||
# (loud-fail per separation-of-concerns); the reconciler sweep keeps it
|
||||
# fresh thereafter and tolerates a later bad PR.
|
||||
try:
|
||||
await registry_mod.refresh_registry(config, gitea)
|
||||
except Exception as e:
|
||||
raise RuntimeError(
|
||||
f"registry mirror failed at startup ({config.registry_repo_full}/projects.yaml): {e}"
|
||||
) from e
|
||||
# §22.13 step 1: re-stamp the M1 bootstrap 'default' project id to the
|
||||
# deployment's configured id (DEFAULT_PROJECT_ID) once the registry row
|
||||
# exists, so the original corpus lands at a meaningful /p/<id>/ and
|
||||
# 'default' is never a public URL. Idempotent no-op once done.
|
||||
projects.restamp_default_project(config)
|
||||
if projects.default_content_repo(config) is None:
|
||||
raise RuntimeError(
|
||||
f"registry does not describe the default project "
|
||||
f"{projects.resolved_default_id(config)!r} (no content_repo). "
|
||||
f"Add it to {config.registry_repo_full}/projects.yaml."
|
||||
)
|
||||
bot = Bot(gitea)
|
||||
reconciler = cache.Reconciler(config, gitea)
|
||||
digest_sched = digest.DigestScheduler()
|
||||
@@ -88,7 +151,7 @@ async def lifespan(app: FastAPI):
|
||||
reconciler.start()
|
||||
digest_sched.start()
|
||||
hygiene_sched.start()
|
||||
log.info("RFC app started — meta repo %s/%s", config.gitea_org, config.meta_repo)
|
||||
log.info("RFC app started — registry %s", config.registry_repo_full)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
@@ -104,12 +167,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
|
||||
|
||||
@@ -117,6 +188,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()
|
||||
|
||||
@@ -164,7 +278,31 @@ def _oauth_router(config) -> APIRouter:
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.post("/auth/otc/request")
|
||||
async def otc_request(body: OtcRequestBody):
|
||||
async def otc_request(body: OtcRequestBody, request: Request):
|
||||
# v0.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
|
||||
@@ -177,10 +315,26 @@ def _oauth_router(config) -> APIRouter:
|
||||
return {"ok": True}
|
||||
|
||||
@router.post("/auth/otc/verify")
|
||||
async def otc_verify(body: OtcVerifyBody, request: Request):
|
||||
async def otc_verify(body: OtcVerifyBody, request: Request, response: Response):
|
||||
# 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
|
||||
@@ -202,6 +356,18 @@ def _oauth_router(config) -> APIRouter:
|
||||
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": {
|
||||
@@ -225,12 +391,17 @@ def _oauth_router(config) -> APIRouter:
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.get("/auth/passcode/check")
|
||||
async def passcode_check(email: str = ""):
|
||||
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."""
|
||||
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}
|
||||
|
||||
@@ -254,12 +425,21 @@ def _oauth_router(config) -> APIRouter:
|
||||
return {"ok": True}
|
||||
|
||||
@router.post("/auth/passcode/verify")
|
||||
async def passcode_verify(body: PasscodeVerifyBody, request: Request):
|
||||
async def passcode_verify(body: PasscodeVerifyBody, request: Request, response: Response):
|
||||
"""Sign in with email + passcode. Returns the standard session
|
||||
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)."""
|
||||
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(
|
||||
@@ -271,7 +451,12 @@ def _oauth_router(config) -> APIRouter:
|
||||
)
|
||||
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": {
|
||||
@@ -282,4 +467,151 @@ def _oauth_router(config) -> APIRouter:
|
||||
},
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# v0.17.0: admin-create user + invite claim (§6.1, roadmap item #16).
|
||||
#
|
||||
# The admin-create surface lives at POST /api/admin/users (see
|
||||
# api_admin.py); this endpoint is the corresponding claim path the
|
||||
# invitee hits when they click the link in their invite email.
|
||||
# The frontend route `/invites/claim?token=…` reads the token from
|
||||
# the URL and POSTs it here.
|
||||
#
|
||||
# The claim itself is the first-sign-in for the invitee: clicking
|
||||
# the unique token in the email is proof of email control per the
|
||||
# roadmap, so this endpoint skips the OTC step entirely on first
|
||||
# sign-in. The session cookie lands; the response tells the
|
||||
# frontend whether to route to passcode-set (if v0.10.0 passcode
|
||||
# flow is in play and the user has not yet set a passcode) or to
|
||||
# home.
|
||||
#
|
||||
# The endpoint is anonymous-reachable: the entire point is to
|
||||
# establish the session, so we do not gate it on `require_user`.
|
||||
# The trust-device opt-in mirrors the v0.11.0 OTC/passcode verify
|
||||
# contract (the body's `trust_device` flag, when true, mints a
|
||||
# fresh device-trust row on the same response so the invitee
|
||||
# lands trusted on their first device).
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.post("/api/invites/claim")
|
||||
async def invites_claim(body: InviteClaimBody, request: Request, response: Response):
|
||||
result = invites_mod.claim(body.token)
|
||||
if result.reason == "expired":
|
||||
# The token's TTL window passed without a claim. HTTP 410
|
||||
# (Gone) so the frontend can render a "this invite has
|
||||
# expired — please contact the admin for a fresh one"
|
||||
# message distinct from the generic invalid-token shape.
|
||||
raise HTTPException(410, "This invite has expired")
|
||||
if result.reason == "claimed":
|
||||
# The token was already consumed. HTTP 410 for the same
|
||||
# reason — the row is dead either way.
|
||||
raise HTTPException(410, "This invite has already been claimed")
|
||||
if not result.ok or result.user is None:
|
||||
# 'unknown' / 'invalid' — the token does not match any
|
||||
# active invite row. HTTP 400 so it reads distinct from
|
||||
# the dead-token shape above.
|
||||
raise HTTPException(400, "Invalid invite token")
|
||||
|
||||
# Establish the session. From here on the invitee is signed
|
||||
# in as the pre-provisioned user row carrying their
|
||||
# pre-assigned role.
|
||||
auth.store_session(request, result.user)
|
||||
|
||||
# v0.11.0 — opt-in device trust on the claim response. Same
|
||||
# contract as OTC/passcode verify: when the body's flag is
|
||||
# true, the server mints a fresh device-trust row and sets
|
||||
# the long-lived cookie, so the invitee skips the email step
|
||||
# on subsequent visits to the same browser.
|
||||
if body.trust_device:
|
||||
ua = request.headers.get("user-agent", "")
|
||||
outcome = device_trust_mod.issue(result.user.user_id, ua)
|
||||
_set_device_trust_cookie(response, outcome.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
|
||||
|
||||
@@ -270,6 +270,86 @@ def fan_out_new_beta_request(
|
||||
)
|
||||
|
||||
|
||||
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,
|
||||
@@ -769,6 +849,16 @@ def render_summary(event_kind: str, actor_display: str | None, rfc_title: str |
|
||||
return f"{actor} began graduating {title}."
|
||||
if event_kind == "pr_conflict_with_main":
|
||||
return f"{actor} started a resolution branch on {title}."
|
||||
if event_kind == "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
|
||||
@@ -884,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:
|
||||
|
||||
@@ -85,6 +85,15 @@ def _cooldown_seconds() -> int:
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -206,6 +215,61 @@ class VerifyOutcome:
|
||||
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:
|
||||
@@ -214,6 +278,13 @@ def verify_code(email: str, code: str) -> VerifyOutcome:
|
||||
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
|
||||
@@ -237,6 +308,9 @@ def verify_code(email: str, code: str) -> VerifyOutcome:
|
||||
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:
|
||||
@@ -255,6 +329,8 @@ def verify_code(email: str, code: str) -> VerifyOutcome:
|
||||
"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")
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Project registry — the §22 multi-project layer.
|
||||
|
||||
A deployment hosts one or more projects (§22.1). The git registry mirror
|
||||
that lets a deployment declare projects lands in M3 and drives this module.
|
||||
`seed_default_project` (the §22.13 META_REPO backfill) is retired in M3;
|
||||
the registry mirror (`registry.refresh_registry`) is authoritative.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from . import db
|
||||
from .config import Config
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_PROJECT_ID = "default"
|
||||
|
||||
|
||||
def resolved_default_id(config: Config) -> str:
|
||||
"""The id of the deployment's bootstrap/default project. Plan A: always
|
||||
'default' (the re-stamp to a config slug rides Plan B). The config knob is
|
||||
read here so Plan B can flip the resolution without touching call sites."""
|
||||
return config.default_project_id.strip() or DEFAULT_PROJECT_ID
|
||||
|
||||
|
||||
def restamp_default_project(config: Config) -> None:
|
||||
"""§22.13 step 1 — one-time rename of the M1 bootstrap project id
|
||||
(DEFAULT_PROJECT_ID = 'default') to the deployment's configured default id
|
||||
(the DEFAULT_PROJECT_ID env var, e.g. 'ohm'), so the deployment's original
|
||||
corpus lands at a meaningful `/p/<id>/` and `default` is never a public URL.
|
||||
|
||||
Renames `project_id` across every project-scoped table (discovered by
|
||||
column, so it stays correct as the schema grows), then drops the stale
|
||||
bootstrap `projects` row (its data has moved to the configured row, which
|
||||
the registry mirror already created). Idempotent and a no-op when the
|
||||
configured id is still 'default' or no bootstrap rows remain. Runs at
|
||||
startup after the registry mirror, with FK enforcement off for the rename
|
||||
(the composite FKs are kept consistent because parent and child rows are
|
||||
renamed together) and a foreign_key_check backstop before commit.
|
||||
"""
|
||||
target = resolved_default_id(config)
|
||||
if target == DEFAULT_PROJECT_ID:
|
||||
return
|
||||
conn = db.conn()
|
||||
# §22 three-tier: the entry-corpus tables key on collection_id now; detect a
|
||||
# lingering bootstrap project by the project-grain `collections.project_id`
|
||||
# (the PRAGMA scan below still renames every project_id column dynamically).
|
||||
has_rows = conn.execute(
|
||||
"SELECT 1 FROM collections WHERE project_id = ? LIMIT 1", (DEFAULT_PROJECT_ID,)
|
||||
).fetchone()
|
||||
stale_proj = conn.execute(
|
||||
"SELECT 1 FROM projects WHERE id = ? LIMIT 1", (DEFAULT_PROJECT_ID,)
|
||||
).fetchone()
|
||||
if not has_rows and not stale_proj:
|
||||
return
|
||||
if conn.execute("SELECT 1 FROM projects WHERE id = ? LIMIT 1", (target,)).fetchone() is None:
|
||||
log.warning("restamp: target project %r not in registry yet; skipping", target)
|
||||
return
|
||||
|
||||
tables = [r["name"] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")]
|
||||
pid_tables = [
|
||||
t for t in tables
|
||||
if any(c["name"] == "project_id" for c in conn.execute(f"PRAGMA table_info({t})"))
|
||||
]
|
||||
conn.execute("PRAGMA foreign_keys = OFF")
|
||||
try:
|
||||
conn.execute("BEGIN")
|
||||
for t in pid_tables:
|
||||
conn.execute(
|
||||
f"UPDATE {t} SET project_id = ? WHERE project_id = ?",
|
||||
(target, DEFAULT_PROJECT_ID),
|
||||
)
|
||||
# The bootstrap row's data has moved to the configured (registry) row.
|
||||
conn.execute("DELETE FROM projects WHERE id = ?", (DEFAULT_PROJECT_ID,))
|
||||
violations = conn.execute("PRAGMA foreign_key_check").fetchall()
|
||||
if violations:
|
||||
conn.execute("ROLLBACK")
|
||||
raise RuntimeError(
|
||||
f"restamp left foreign-key violations: {[tuple(v) for v in violations]}"
|
||||
)
|
||||
conn.execute("COMMIT")
|
||||
except Exception:
|
||||
try:
|
||||
conn.execute("ROLLBACK")
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
finally:
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
log.info("restamp: renamed bootstrap project %r -> %r across %d tables",
|
||||
DEFAULT_PROJECT_ID, target, len(pid_tables))
|
||||
|
||||
|
||||
def default_content_repo(config: Config) -> str | None:
|
||||
"""The content repo the single-corpus mirror reads, from the default
|
||||
project's row (filled by the registry mirror). Replaces the retired
|
||||
META_REPO. None until the registry mirror has run."""
|
||||
row = db.conn().execute(
|
||||
"SELECT content_repo FROM projects WHERE id = ?",
|
||||
(resolved_default_id(config),),
|
||||
).fetchone()
|
||||
return row["content_repo"] if row and row["content_repo"] else None
|
||||
|
||||
|
||||
def content_repo(project_id: str) -> str | None:
|
||||
"""The content repo for a specific project (§22.3). None if unknown/unset.
|
||||
The per-project successor to `default_content_repo` for the write path."""
|
||||
row = db.conn().execute(
|
||||
"SELECT content_repo FROM projects WHERE id = ?", (project_id,)
|
||||
).fetchone()
|
||||
return row["content_repo"] if row and row["content_repo"] else None
|
||||
|
||||
|
||||
def project_initial_state(project_id: str) -> str:
|
||||
"""§22.4b landing state for new entries in a project's default collection
|
||||
(the per-corpus field moved down to the collection in migration 029).
|
||||
Defaults to 'super-draft' for an unknown/unset row (today's-flow default)."""
|
||||
from . import collections as collections_mod
|
||||
return collections_mod.collection_initial_state(
|
||||
collections_mod.default_collection_id(project_id)
|
||||
)
|
||||
@@ -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,338 @@
|
||||
"""§22.2 project registry mirror — the config-side analogue of
|
||||
cache.refresh_meta_repo.
|
||||
|
||||
A deployment declares its projects in a `projects.yaml` at the root of
|
||||
REGISTRY_REPO. This module mirrors that file into the `projects` cache table
|
||||
and the `deployment` singleton. Per §22.2, `projects` rows flow from the
|
||||
registry only — never from user actions. The mirror runs on the registry-repo
|
||||
webhook and on every reconciler sweep (Option A wiring, Task 5).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import yaml
|
||||
|
||||
from . import db
|
||||
from .config import Config
|
||||
from .gitea import Gitea
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
VALID_TYPES = {"document", "specification", "bdd"}
|
||||
VALID_VISIBILITY = {"gated", "public", "unlisted"}
|
||||
VALID_INITIAL_STATE = {"super-draft", "active"}
|
||||
# §22.4b: per-type default landing state.
|
||||
_TYPE_DEFAULT_INITIAL_STATE = {
|
||||
"document": "super-draft",
|
||||
"specification": "super-draft",
|
||||
"bdd": "active",
|
||||
}
|
||||
_SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$")
|
||||
|
||||
|
||||
class RegistryError(Exception):
|
||||
"""A registry document that fails validation, or a missing registry file.
|
||||
|
||||
Raised by parse_registry/refresh_registry. The caller decides severity:
|
||||
fatal at startup (no last-good to serve), tolerated on a running deployment
|
||||
(keep the last-good projects rows). See Task 5 wiring.
|
||||
"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProjectEntry:
|
||||
id: str
|
||||
name: str
|
||||
type: str
|
||||
content_repo: str
|
||||
visibility: str
|
||||
initial_state: str
|
||||
config: dict = field(default_factory=dict) # theme, enabled_models
|
||||
|
||||
|
||||
@dataclass
|
||||
class CollectionEntry:
|
||||
"""A named collection declared by a `.collection.yaml` manifest inside a
|
||||
project's content repo (S2). `visibility=None` means "inherit the project's
|
||||
visibility"."""
|
||||
type: str
|
||||
visibility: str | None
|
||||
initial_state: str
|
||||
name: str | None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RegistryDoc:
|
||||
deployment_name: str
|
||||
deployment_tagline: str
|
||||
projects: list[ProjectEntry]
|
||||
|
||||
|
||||
def parse_registry(text: str) -> RegistryDoc:
|
||||
"""Parse + validate projects.yaml. Pure (no I/O). Raises RegistryError."""
|
||||
raw = yaml.safe_load(text) or {}
|
||||
dep = raw.get("deployment") or {}
|
||||
projects_raw = raw.get("projects") or []
|
||||
if not isinstance(projects_raw, list) or not projects_raw:
|
||||
raise RegistryError("registry must declare at least one project")
|
||||
seen: set[str] = set()
|
||||
entries: list[ProjectEntry] = []
|
||||
for p in projects_raw:
|
||||
if not isinstance(p, dict):
|
||||
raise RegistryError(f"each project entry must be a mapping, got {type(p).__name__}")
|
||||
pid = str(p.get("id") or "").strip()
|
||||
if not _SLUG_RE.match(pid):
|
||||
raise RegistryError(f"project id {pid!r} is not a valid slug")
|
||||
if pid in seen:
|
||||
raise RegistryError(f"duplicate project id {pid!r}")
|
||||
seen.add(pid)
|
||||
name = str(p.get("name") or "").strip()
|
||||
if not name:
|
||||
raise RegistryError(f"project {pid!r} missing name")
|
||||
ptype = str(p.get("type") or "").strip()
|
||||
if ptype not in VALID_TYPES:
|
||||
raise RegistryError(f"project {pid!r} has invalid type {ptype!r}")
|
||||
content_repo = str(p.get("content_repo") or "").strip()
|
||||
if not content_repo:
|
||||
raise RegistryError(f"project {pid!r} missing content_repo")
|
||||
vis = str(p.get("visibility") or "gated").strip()
|
||||
if vis not in VALID_VISIBILITY:
|
||||
raise RegistryError(f"project {pid!r} has invalid visibility {vis!r}")
|
||||
initial_state = str(
|
||||
p.get("initial_state") or _TYPE_DEFAULT_INITIAL_STATE[ptype]
|
||||
).strip()
|
||||
if initial_state not in VALID_INITIAL_STATE:
|
||||
raise RegistryError(
|
||||
f"project {pid!r} has invalid initial_state {initial_state!r}"
|
||||
)
|
||||
cfg: dict = {}
|
||||
if p.get("theme") is not None:
|
||||
cfg["theme"] = p["theme"]
|
||||
if p.get("enabled_models") is not None:
|
||||
cfg["enabled_models"] = [str(m) for m in p["enabled_models"]]
|
||||
entries.append(
|
||||
ProjectEntry(pid, name, ptype, content_repo, vis, initial_state, cfg)
|
||||
)
|
||||
return RegistryDoc(
|
||||
deployment_name=str(dep.get("name") or "").strip(),
|
||||
deployment_tagline=str(dep.get("tagline") or "").strip(),
|
||||
projects=entries,
|
||||
)
|
||||
|
||||
|
||||
def parse_collection_manifest(text: str) -> CollectionEntry:
|
||||
"""Parse + validate a `.collection.yaml`. Pure (no I/O). Raises RegistryError.
|
||||
|
||||
`type` is required and immutable (§22.4a, enforced at upsert). `visibility`
|
||||
is optional — omitted means inherit the project's. `initial_state` defaults
|
||||
per type (§22.4b)."""
|
||||
raw = yaml.safe_load(text) or {}
|
||||
if not isinstance(raw, dict):
|
||||
raise RegistryError("collection manifest must be a mapping")
|
||||
ctype = str(raw.get("type") or "").strip()
|
||||
if ctype not in VALID_TYPES:
|
||||
raise RegistryError(f"collection has invalid type {ctype!r}")
|
||||
vis = raw.get("visibility")
|
||||
if vis is not None:
|
||||
vis = str(vis).strip()
|
||||
if vis not in VALID_VISIBILITY:
|
||||
raise RegistryError(f"collection has invalid visibility {vis!r}")
|
||||
initial_state = str(
|
||||
raw.get("initial_state") or _TYPE_DEFAULT_INITIAL_STATE[ctype]
|
||||
).strip()
|
||||
if initial_state not in VALID_INITIAL_STATE:
|
||||
raise RegistryError(f"collection has invalid initial_state {initial_state!r}")
|
||||
name = raw.get("name")
|
||||
name = str(name).strip() if name else None
|
||||
return CollectionEntry(ctype, vis, initial_state, name)
|
||||
|
||||
|
||||
def _default_collection_id(project_id: str, default_id: str) -> str:
|
||||
"""The id of a project's default collection. The deployment's primary
|
||||
project (== `default_id`, the §22.13 resolved default) gets the stable
|
||||
literal `'default'` — matching migration 029's seed so the upsert *merges*
|
||||
onto the migration-seeded row rather than duplicating it (critical on a
|
||||
fresh deploy where the bootstrap `default` project is later restamped to the
|
||||
configured id). Any additional project keys its default collection by its own
|
||||
id, keeping the collection PK globally unique (pre-S5 multi-project)."""
|
||||
return "default" if project_id == default_id else project_id
|
||||
|
||||
|
||||
def apply_registry(doc: RegistryDoc, registry_sha: str, default_id: str) -> None:
|
||||
"""Upsert the parsed registry into projects + their default collections +
|
||||
the deployment singleton. Idempotent.
|
||||
|
||||
§22 three-tier (S1): a project carries the grouping-tier fields (name,
|
||||
content_repo, visibility, config); the per-corpus fields (`type`,
|
||||
`initial_state`) live on the project's default collection. §22.4a: `type` is
|
||||
immutable — a change against an existing collection is rejected (skip the
|
||||
type change + log), never applied. Projects absent from the registry are
|
||||
left in place (archival is out of scope for M3; they stop refreshing).
|
||||
"""
|
||||
with db.tx() as conn:
|
||||
for e in doc.projects:
|
||||
cid = _default_collection_id(e.id, default_id)
|
||||
existing = conn.execute(
|
||||
"SELECT type FROM collections WHERE id = ?", (cid,)
|
||||
).fetchone()
|
||||
type_locked = existing is not None and existing["type"] != e.type
|
||||
if type_locked:
|
||||
log.error(
|
||||
"registry: refusing immutable type change on collection %s (%s -> %s)",
|
||||
cid, existing["type"], e.type,
|
||||
)
|
||||
# The project (grouping tier) always refreshes.
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO projects
|
||||
(id, name, content_repo, visibility, config_json, registry_sha, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
content_repo = excluded.content_repo,
|
||||
visibility = excluded.visibility,
|
||||
config_json = excluded.config_json,
|
||||
registry_sha = excluded.registry_sha,
|
||||
updated_at = datetime('now')
|
||||
""",
|
||||
(e.id, e.name, e.content_repo, e.visibility, json.dumps(e.config), registry_sha),
|
||||
)
|
||||
# The default collection (corpus tier). On an immutable-type
|
||||
# conflict, keep the stored type but still refresh the rest.
|
||||
effective_type = existing["type"] if type_locked else e.type
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO collections
|
||||
(id, project_id, type, subfolder, initial_state, visibility, name, registry_sha, updated_at)
|
||||
VALUES (?, ?, ?, '', ?, ?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
project_id = excluded.project_id,
|
||||
type = excluded.type,
|
||||
initial_state = excluded.initial_state,
|
||||
visibility = excluded.visibility,
|
||||
name = excluded.name,
|
||||
registry_sha = excluded.registry_sha,
|
||||
updated_at = datetime('now')
|
||||
""",
|
||||
(cid, e.id, effective_type, e.initial_state, e.visibility, e.name, registry_sha),
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE deployment
|
||||
SET name = ?, tagline = ?, registry_sha = ?, updated_at = datetime('now')
|
||||
WHERE id = 1
|
||||
""",
|
||||
(doc.deployment_name, doc.deployment_tagline, registry_sha),
|
||||
)
|
||||
|
||||
|
||||
def _strictest_visibility(a: str, b: str) -> str:
|
||||
"""The stricter of two §22.5 visibilities on the public-exposure axis
|
||||
(`public` < `unlisted` < `gated`). Used to enforce that a collection is set
|
||||
only as strict or stricter than its project (S3 operator decision)."""
|
||||
rank = {"public": 0, "unlisted": 1, "gated": 2}
|
||||
return a if rank.get(a, 2) >= rank.get(b, 2) else b
|
||||
|
||||
|
||||
def _upsert_named_collection(
|
||||
proj: ProjectEntry, subdir: str, ce: CollectionEntry, sha: str
|
||||
) -> None:
|
||||
"""Upsert one named collection (S2). Type is immutable (§22.4a): a type
|
||||
change against an existing row is refused (logged, not applied). A None
|
||||
manifest visibility inherits the project's visibility; a manifest that tries
|
||||
to be *looser* than its project is clamped to the project's (S3 strictness:
|
||||
a collection may narrow but never widen its project's visibility)."""
|
||||
requested = ce.visibility or proj.visibility
|
||||
visibility = _strictest_visibility(requested, proj.visibility)
|
||||
if visibility != requested:
|
||||
log.warning(
|
||||
"registry: collection %s visibility %r looser than project %s %r — "
|
||||
"clamped to %r (S3 strictness)",
|
||||
subdir, requested, proj.id, proj.visibility, visibility,
|
||||
)
|
||||
with db.tx() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT type FROM collections WHERE id = ?", (subdir,)
|
||||
).fetchone()
|
||||
if existing is not None and existing["type"] != ce.type:
|
||||
log.error(
|
||||
"registry: refusing immutable type change on collection %s (%s -> %s)",
|
||||
subdir, existing["type"], ce.type,
|
||||
)
|
||||
return
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO collections
|
||||
(id, project_id, type, subfolder, initial_state, visibility, name, registry_sha, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
project_id = excluded.project_id,
|
||||
initial_state = excluded.initial_state,
|
||||
visibility = excluded.visibility,
|
||||
name = excluded.name,
|
||||
registry_sha = excluded.registry_sha,
|
||||
updated_at = datetime('now')
|
||||
""",
|
||||
(subdir, proj.id, ce.type, subdir, ce.initial_state, visibility, ce.name, sha),
|
||||
)
|
||||
|
||||
|
||||
async def _mirror_named_collections(config: Config, gitea: Gitea, doc: RegistryDoc, sha: str) -> None:
|
||||
"""§22 S2: named collections are declared by `.collection.yaml` manifests
|
||||
inside each project's content repo (the default collection comes from
|
||||
projects.yaml). Walk each content repo root; a subdir carrying a manifest
|
||||
becomes a collection keyed by the subdir name. Tolerant: a transport or
|
||||
parse failure on one project/collection logs and is skipped, never aborts
|
||||
the wider mirror (keep last-good)."""
|
||||
for proj in doc.projects:
|
||||
try:
|
||||
items = await gitea.list_dir(config.gitea_org, proj.content_repo, "", ref="main")
|
||||
except Exception as e: # noqa: BLE001 — GiteaError/transport: tolerate
|
||||
log.warning("registry: cannot list %s root: %s", proj.content_repo, e)
|
||||
continue
|
||||
for it in items:
|
||||
if it.get("type") != "dir":
|
||||
continue
|
||||
subdir = it["name"]
|
||||
manifest = await gitea.get_contents(
|
||||
config.gitea_org, proj.content_repo, f"{subdir}/.collection.yaml", ref="main"
|
||||
)
|
||||
if not manifest or manifest.get("type") != "file":
|
||||
continue
|
||||
mtext = base64.b64decode(manifest["content"]).decode("utf-8")
|
||||
try:
|
||||
ce = parse_collection_manifest(mtext)
|
||||
except RegistryError as e:
|
||||
log.error("registry: bad manifest %s/%s: %s", proj.content_repo, subdir, e)
|
||||
continue
|
||||
_upsert_named_collection(proj, subdir, ce, sha)
|
||||
|
||||
|
||||
async def refresh_registry(config: Config, gitea: Gitea) -> None:
|
||||
"""Mirror REGISTRY_REPO/projects.yaml into projects + deployment.
|
||||
|
||||
Idempotent. Raises RegistryError on a missing/invalid file and GiteaError
|
||||
on transport failure; the caller chooses fatal-vs-tolerated.
|
||||
"""
|
||||
item = await gitea.get_contents(
|
||||
config.gitea_org, config.registry_repo, "projects.yaml", ref="main"
|
||||
)
|
||||
if not item or item.get("type") != "file":
|
||||
raise RegistryError(
|
||||
f"{config.gitea_org}/{config.registry_repo}/projects.yaml not found"
|
||||
)
|
||||
text = base64.b64decode(item["content"]).decode("utf-8")
|
||||
# Prefer the file's last commit sha for provenance (production Gitea
|
||||
# includes it on the contents response); fall back to the blob sha.
|
||||
sha = item.get("last_commit_sha") or item.get("sha") or ""
|
||||
doc = parse_registry(text)
|
||||
from . import projects as projects_mod
|
||||
apply_registry(doc, sha, projects_mod.resolved_default_id(config))
|
||||
# §22 S2: discover + upsert named collections from each content repo.
|
||||
await _mirror_named_collections(config, gitea, doc, sha)
|
||||
log.info("registry: mirrored %d project(s) at %s", len(doc.projects), sha)
|
||||
@@ -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")
|
||||
+44
-4
@@ -12,10 +12,11 @@ import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException, Request
|
||||
|
||||
from . import cache, db
|
||||
from . import cache, db, projects as projects_mod, registry as registry_mod
|
||||
from .config import Config
|
||||
from .gitea import Gitea
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -58,9 +79,22 @@ def make_router(config: Config, gitea: Gitea) -> APIRouter:
|
||||
except Exception:
|
||||
payload = {}
|
||||
repo_full = (payload.get("repository") or {}).get("full_name") or ""
|
||||
meta_full = f"{config.gitea_org}/{config.meta_repo}"
|
||||
registry_full = f"{config.gitea_org}/{config.registry_repo}"
|
||||
content_repo = projects_mod.default_content_repo(config)
|
||||
if not content_repo:
|
||||
log.warning("webhook: default project content_repo is unknown; corpus refresh skipped")
|
||||
content_full = f"{config.gitea_org}/{content_repo}" if content_repo else None
|
||||
try:
|
||||
if repo_full == meta_full or not repo_full:
|
||||
if repo_full == registry_full:
|
||||
# §22.2: a registry-repo push re-mirrors the projects table.
|
||||
# Tolerate a malformed projects.yaml (keep last-good rows); let a
|
||||
# transport error bubble to the outer 500 so an unreachable Gitea
|
||||
# on a registry push is loud rather than silently dropped.
|
||||
try:
|
||||
await registry_mod.refresh_registry(config, gitea)
|
||||
except registry_mod.RegistryError:
|
||||
log.exception("registry webhook: invalid projects.yaml; keeping last-good")
|
||||
elif content_full and (repo_full == content_full or not repo_full):
|
||||
await cache.refresh_meta_repo(config, gitea)
|
||||
await cache.refresh_meta_branches(config, gitea)
|
||||
await cache.refresh_meta_pulls(config, gitea)
|
||||
@@ -68,6 +102,12 @@ 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:
|
||||
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,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';
|
||||
@@ -0,0 +1,61 @@
|
||||
-- §3 / §13.7: add the `retired` soft-delete state to cached_rfcs.
|
||||
--
|
||||
-- SQLite cannot ALTER a CHECK constraint in place, so we rebuild the table
|
||||
-- with the expanded constraint and copy the rows across. cached_rfcs is a
|
||||
-- §4 cache (reconstructible from Gitea by the reconciler), and nothing
|
||||
-- holds a foreign key into it, so the rebuild is safe; we preserve the
|
||||
-- existing rows anyway to avoid a needless full re-read on upgrade.
|
||||
--
|
||||
-- The rebuilt table must carry EVERY column cached_rfcs has accumulated,
|
||||
-- including the ones added by later migrations via ALTER TABLE ADD COLUMN:
|
||||
-- 009_per_rfc_models -> models_json
|
||||
-- 010_funder -> funder_login
|
||||
-- 021_proposed_use_case -> proposed_use_case
|
||||
-- They are appended last (matching the live column order) and copied
|
||||
-- across explicitly so nothing is dropped.
|
||||
--
|
||||
-- The migration runner wraps this file in a single BEGIN/COMMIT, so the
|
||||
-- swap is atomic.
|
||||
|
||||
CREATE TABLE cached_rfcs_new (
|
||||
slug TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
state TEXT NOT NULL CHECK (state IN ('super-draft', 'active', 'withdrawn', 'retired')),
|
||||
rfc_id TEXT, -- 'RFC-NNNN' or NULL (NULL is also valid for an active RFC graduated without a number, §13.2)
|
||||
repo TEXT, -- 'org/repo' or NULL; always NULL under the meta-only topology (§1)
|
||||
proposed_by TEXT,
|
||||
proposed_at TEXT,
|
||||
graduated_at TEXT,
|
||||
graduated_by TEXT,
|
||||
owners_json TEXT NOT NULL DEFAULT '[]',
|
||||
arbiters_json TEXT NOT NULL DEFAULT '[]',
|
||||
tags_json TEXT NOT NULL DEFAULT '[]',
|
||||
body TEXT,
|
||||
body_sha TEXT,
|
||||
last_main_commit_at TEXT,
|
||||
last_entry_commit_at TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
models_json TEXT, -- 009_per_rfc_models
|
||||
funder_login TEXT, -- 010_funder
|
||||
proposed_use_case TEXT -- 021_proposed_use_case
|
||||
);
|
||||
|
||||
INSERT INTO cached_rfcs_new
|
||||
(slug, title, state, rfc_id, repo, proposed_by, proposed_at,
|
||||
graduated_at, graduated_by, owners_json, arbiters_json, tags_json,
|
||||
body, body_sha, last_main_commit_at, last_entry_commit_at, updated_at,
|
||||
models_json, funder_login, proposed_use_case)
|
||||
SELECT
|
||||
slug, title, state, rfc_id, repo, proposed_by, proposed_at,
|
||||
graduated_at, graduated_by, owners_json, arbiters_json, tags_json,
|
||||
body, body_sha, last_main_commit_at, last_entry_commit_at, updated_at,
|
||||
models_json, funder_login, proposed_use_case
|
||||
FROM cached_rfcs;
|
||||
|
||||
DROP TABLE cached_rfcs;
|
||||
ALTER TABLE cached_rfcs_new RENAME TO cached_rfcs;
|
||||
|
||||
CREATE INDEX idx_cached_rfcs_state ON cached_rfcs (state);
|
||||
CREATE INDEX idx_cached_rfcs_last_active ON cached_rfcs (
|
||||
COALESCE(last_main_commit_at, last_entry_commit_at) DESC
|
||||
);
|
||||
@@ -0,0 +1,105 @@
|
||||
-- §22 (multi-project deployments) — Slice M1: the project spine.
|
||||
--
|
||||
-- A deployment now hosts one or more *projects*, each a corpus with its own
|
||||
-- content repo, slug/RFC-NNNN namespace, catalog, roster, and branding. The
|
||||
-- pre-multi-project single-corpus deployment is the N=1 case: this migration
|
||||
-- generates one 'default' project and stamps every existing RFC-scoped row
|
||||
-- to it, so the app keeps running exactly as before with the spine
|
||||
-- underneath (see docs/design/multi-project-spec.md §22.13).
|
||||
--
|
||||
-- STRATEGY — additive, no table rebuilds. Every slug-bearing table gets a
|
||||
-- `project_id TEXT NOT NULL DEFAULT 'default'` column. The constant default
|
||||
-- means every existing INSERT in the codebase that does not yet mention
|
||||
-- project_id keeps working and lands rows in the default project; no query
|
||||
-- breaks because, with a single project, slugs remain globally unique. The
|
||||
-- column carries no inline REFERENCES clause: SQLite's ALTER TABLE ADD
|
||||
-- COLUMN forbids a FK column with a non-NULL default. project_id referential
|
||||
-- integrity is therefore enforced at the app layer for now; the FK lands
|
||||
-- with the table rebuilds below.
|
||||
--
|
||||
-- ============================================================================
|
||||
-- DEFERRED to the slice that activates project #2 (M3/M4). Until a second
|
||||
-- project exists these are correct as-is; the moment two projects can share a
|
||||
-- slug or a Gitea PR number they become cross-project collision bugs and MUST
|
||||
-- be rebuilt (SQLite needs a create-copy-drop-rename per table) to fold
|
||||
-- project_id into the key, and to add the project_id FK:
|
||||
-- * cached_rfcs PRIMARY KEY (slug) -> (project_id, slug)
|
||||
-- * cached_branches UNIQUE (rfc_slug, branch_name) -> +project_id
|
||||
-- * branch_visibility UNIQUE (rfc_slug, branch_name) -> +project_id
|
||||
-- * branch_contribute_grants UNIQUE (rfc_slug, branch_name, grantee_user_id) -> +project_id
|
||||
-- * stars UNIQUE (user_id, rfc_slug) -> +project_id
|
||||
-- * watches UNIQUE (user_id, rfc_slug) -> +project_id
|
||||
-- * pr_seen UNIQUE (user_id, rfc_slug, pr_number) -> +project_id
|
||||
-- * branch_chat_seen UNIQUE (user_id, rfc_slug, branch_name) -> +project_id
|
||||
-- * funder_consents PRIMARY KEY (user_id, rfc_slug) -> +project_id
|
||||
-- * rfc_collaborators UNIQUE INDEX (rfc_slug, user_id) -> +project_id
|
||||
-- * contribution_requests UNIQUE INDEX (rfc_slug, requester_user_id) WHERE pending -> +project_id
|
||||
-- * proposed_use_cases UNIQUE (scope, pr_number) -> +project_id
|
||||
-- (PR numbers are per-content-repo = per-project)
|
||||
-- cached_prs UNIQUE (repo, pr_number) is already globally unique (repo is the
|
||||
-- full 'org/repo' string, distinct per project) and needs no rebuild.
|
||||
-- ============================================================================
|
||||
|
||||
-- The project registry cache (mirrored from the git registry by the M3
|
||||
-- reconciler; rows are never written from user actions). content_repo is
|
||||
-- NULL until the mirror — or the M1 startup backfill (§22.13 step 1) — sets
|
||||
-- it from the deployment's configured repo.
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
content_repo TEXT,
|
||||
visibility TEXT NOT NULL DEFAULT 'gated'
|
||||
CHECK (visibility IN ('gated', 'public', 'unlisted')),
|
||||
config_json TEXT,
|
||||
registry_sha TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- The default project. visibility='public' preserves the pre-multi-project
|
||||
-- open-by-default posture (§22.5, §22.13). name is a placeholder the startup
|
||||
-- backfill / registry overwrites with the deployment's display name.
|
||||
INSERT OR IGNORE INTO projects (id, name, visibility)
|
||||
VALUES ('default', 'default', 'public');
|
||||
|
||||
-- Per-(user, project) membership and the §22.6 middle-tier role. This is the
|
||||
-- new tier between the §6.1 deployment role (users.role, now deployment-scope
|
||||
-- only) and the §6.3 per-RFC authority.
|
||||
CREATE TABLE IF NOT EXISTS project_members (
|
||||
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL DEFAULT 'project_viewer'
|
||||
CHECK (role IN ('project_admin', 'project_contributor', 'project_viewer')),
|
||||
granted_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
granted_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (project_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_project_members_user ON project_members(user_id);
|
||||
|
||||
-- The project_id spine across every slug-bearing table. Backfills existing
|
||||
-- rows to 'default' via the constant column default.
|
||||
ALTER TABLE cached_rfcs ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
|
||||
ALTER TABLE cached_branches ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
|
||||
ALTER TABLE cached_prs ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
|
||||
ALTER TABLE branch_visibility ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
|
||||
ALTER TABLE branch_contribute_grants ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
|
||||
ALTER TABLE stars ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
|
||||
ALTER TABLE threads ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
|
||||
ALTER TABLE changes ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
|
||||
ALTER TABLE pr_seen ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
|
||||
ALTER TABLE branch_chat_seen ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
|
||||
ALTER TABLE watches ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
|
||||
ALTER TABLE notifications ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
|
||||
ALTER TABLE actions ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
|
||||
ALTER TABLE pr_resolution_branches ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
|
||||
ALTER TABLE funder_consents ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
|
||||
ALTER TABLE rfc_invitations ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
|
||||
ALTER TABLE rfc_collaborators ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
|
||||
ALTER TABLE proposed_use_cases ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
|
||||
ALTER TABLE contribution_requests ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
|
||||
|
||||
-- The catalog/directory lookup the M3 surfaces will make (RFCs in a project).
|
||||
-- The per-table composite-key rebuilds in the DEFERRED block above will add
|
||||
-- their own (project_id, …) indexes when they land.
|
||||
CREATE INDEX IF NOT EXISTS idx_cached_rfcs_project ON cached_rfcs(project_id);
|
||||
@@ -0,0 +1,31 @@
|
||||
-- §22 M3 (Plan A) — additive registry/runtime-config schema.
|
||||
--
|
||||
-- This is the additive half of M3's backend. It adds the project `type` and
|
||||
-- `initial_state` columns (mirrored from the registry), the deployment
|
||||
-- singleton (deployment name/tagline mirrored from the registry), and the
|
||||
-- §22.4c review columns on cached_rfcs. NO table rebuilds: the §22.13 PK
|
||||
-- rebuilds and the default->slug re-stamp ride a later migration (Plan B),
|
||||
-- just before a second project can collide (see migration 026's DEFERRED
|
||||
-- block and docs/superpowers/specs/2026-06-03-m3-backend-design.md §1/§6).
|
||||
|
||||
ALTER TABLE projects ADD COLUMN type TEXT NOT NULL DEFAULT 'document'
|
||||
CHECK (type IN ('document', 'specification', 'bdd'));
|
||||
ALTER TABLE projects ADD COLUMN initial_state TEXT NOT NULL DEFAULT 'super-draft'
|
||||
CHECK (initial_state IN ('super-draft', 'active'));
|
||||
|
||||
-- Deployment-level identity (name, tagline) mirrored from the registry's
|
||||
-- `deployment:` block. A singleton: the CHECK pins it to one row.
|
||||
CREATE TABLE IF NOT EXISTS deployment (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
name TEXT,
|
||||
tagline TEXT,
|
||||
registry_sha TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
INSERT OR IGNORE INTO deployment (id) VALUES (1);
|
||||
|
||||
-- §22.4c review flag + provenance. unreviewed is git-truth (mirrored from
|
||||
-- entry frontmatter); it survives a cache rebuild like `state` does.
|
||||
ALTER TABLE cached_rfcs ADD COLUMN unreviewed INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE cached_rfcs ADD COLUMN reviewed_at TEXT;
|
||||
ALTER TABLE cached_rfcs ADD COLUMN reviewed_by TEXT;
|
||||
@@ -0,0 +1,276 @@
|
||||
-- migrate:no-foreign-keys
|
||||
--
|
||||
-- §22.13 / §22.4 — fold project_id into the slug-keyed PRIMARY KEY / UNIQUE
|
||||
-- constraints that migration 026 deliberately left global, so a *second*
|
||||
-- project can hold an entry with the same slug as the first. M1 (026) added
|
||||
-- project_id additively (no rebuild); this is the rebuild that activates
|
||||
-- project #2, enumerated in 026's header.
|
||||
--
|
||||
-- SQLite can't ALTER a PK/UNIQUE in place, so each table is rebuilt by the
|
||||
-- official procedure: create `<t>__new` with the new constraint, copy, DROP
|
||||
-- the live table, rename `<t>__new` -> `<t>`, recreate its indexes. FK
|
||||
-- enforcement is OFF for the whole file (the `migrate:no-foreign-keys` marker
|
||||
-- above tells the runner to toggle it and run foreign_key_check after).
|
||||
--
|
||||
-- DROP-the-live-table (rather than rename-live-to-__old) is deliberate: SQLite
|
||||
-- rewrites child FK references when you RENAME a *referenced* table, so we drop
|
||||
-- the old cached_rfcs (allowed with FK off) and rename the temp in. cached_rfcs
|
||||
-- is rebuilt FIRST so rfc_collaborators / contribution_requests can re-point
|
||||
-- their FK at its new composite (project_id, slug) key.
|
||||
|
||||
-- ── cached_rfcs: PRIMARY KEY (slug) -> (project_id, slug) ──────────────────
|
||||
CREATE TABLE cached_rfcs__new (
|
||||
slug TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
state TEXT NOT NULL CHECK (state IN ('super-draft', 'active', 'withdrawn', 'retired')),
|
||||
rfc_id TEXT,
|
||||
repo TEXT,
|
||||
proposed_by TEXT,
|
||||
proposed_at TEXT,
|
||||
graduated_at TEXT,
|
||||
graduated_by TEXT,
|
||||
owners_json TEXT NOT NULL DEFAULT '[]',
|
||||
arbiters_json TEXT NOT NULL DEFAULT '[]',
|
||||
tags_json TEXT NOT NULL DEFAULT '[]',
|
||||
body TEXT,
|
||||
body_sha TEXT,
|
||||
last_main_commit_at TEXT,
|
||||
last_entry_commit_at TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
models_json TEXT,
|
||||
funder_login TEXT,
|
||||
proposed_use_case TEXT,
|
||||
project_id TEXT NOT NULL DEFAULT 'default',
|
||||
unreviewed INTEGER NOT NULL DEFAULT 0,
|
||||
reviewed_at TEXT,
|
||||
reviewed_by TEXT,
|
||||
PRIMARY KEY (project_id, slug)
|
||||
);
|
||||
INSERT INTO cached_rfcs__new SELECT * FROM cached_rfcs;
|
||||
DROP TABLE cached_rfcs;
|
||||
ALTER TABLE cached_rfcs__new RENAME TO cached_rfcs;
|
||||
CREATE INDEX idx_cached_rfcs_state ON cached_rfcs (state);
|
||||
CREATE INDEX idx_cached_rfcs_last_active ON cached_rfcs (
|
||||
COALESCE(last_main_commit_at, last_entry_commit_at) DESC
|
||||
);
|
||||
CREATE INDEX idx_cached_rfcs_project ON cached_rfcs(project_id);
|
||||
|
||||
-- ── rfc_invitations: FK rfc_slug -> cached_rfcs(slug) becomes composite ────
|
||||
-- (no key change of its own, but its single-column FK to cached_rfcs is now a
|
||||
-- mismatch against the composite PK, so it must be rebuilt too). Rebuilt after
|
||||
-- cached_rfcs (its FK target) and before the two tables that FK rfc_invitations.
|
||||
CREATE TABLE rfc_invitations__new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
rfc_slug TEXT NOT NULL,
|
||||
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,
|
||||
project_id TEXT NOT NULL DEFAULT 'default',
|
||||
FOREIGN KEY (project_id, rfc_slug) REFERENCES cached_rfcs(project_id, slug) ON DELETE CASCADE
|
||||
);
|
||||
INSERT INTO rfc_invitations__new SELECT * FROM rfc_invitations;
|
||||
DROP TABLE rfc_invitations;
|
||||
ALTER TABLE rfc_invitations__new RENAME TO rfc_invitations;
|
||||
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);
|
||||
|
||||
-- ── cached_branches: UNIQUE (rfc_slug, branch_name) -> +project_id ─────────
|
||||
CREATE TABLE cached_branches__new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
rfc_slug TEXT NOT NULL,
|
||||
branch_name TEXT NOT NULL,
|
||||
head_sha TEXT,
|
||||
state TEXT NOT NULL DEFAULT 'open' CHECK (state IN ('open', 'closed', 'deleted')),
|
||||
pinned INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
last_commit_at TEXT,
|
||||
closed_at TEXT,
|
||||
project_id TEXT NOT NULL DEFAULT 'default',
|
||||
UNIQUE (project_id, rfc_slug, branch_name)
|
||||
);
|
||||
INSERT INTO cached_branches__new SELECT * FROM cached_branches;
|
||||
DROP TABLE cached_branches;
|
||||
ALTER TABLE cached_branches__new RENAME TO cached_branches;
|
||||
CREATE INDEX idx_cached_branches_rfc ON cached_branches (rfc_slug, state);
|
||||
|
||||
-- ── branch_visibility: UNIQUE (rfc_slug, branch_name) -> +project_id ───────
|
||||
CREATE TABLE branch_visibility__new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
rfc_slug TEXT NOT NULL,
|
||||
branch_name TEXT NOT NULL,
|
||||
read_public INTEGER NOT NULL DEFAULT 1,
|
||||
contribute_mode TEXT NOT NULL DEFAULT 'just-me' CHECK (contribute_mode IN ('just-me', 'specific', 'any-contributor')),
|
||||
project_id TEXT NOT NULL DEFAULT 'default',
|
||||
UNIQUE (project_id, rfc_slug, branch_name)
|
||||
);
|
||||
INSERT INTO branch_visibility__new SELECT * FROM branch_visibility;
|
||||
DROP TABLE branch_visibility;
|
||||
ALTER TABLE branch_visibility__new RENAME TO branch_visibility;
|
||||
|
||||
-- ── branch_contribute_grants: UNIQUE (rfc_slug, branch_name, grantee) -> +pid
|
||||
CREATE TABLE branch_contribute_grants__new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
rfc_slug TEXT NOT NULL,
|
||||
branch_name TEXT NOT NULL,
|
||||
grantee_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
granted_by INTEGER NOT NULL REFERENCES users(id) ON DELETE SET NULL,
|
||||
granted_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
project_id TEXT NOT NULL DEFAULT 'default',
|
||||
UNIQUE (project_id, rfc_slug, branch_name, grantee_user_id)
|
||||
);
|
||||
INSERT INTO branch_contribute_grants__new SELECT * FROM branch_contribute_grants;
|
||||
DROP TABLE branch_contribute_grants;
|
||||
ALTER TABLE branch_contribute_grants__new RENAME TO branch_contribute_grants;
|
||||
CREATE INDEX idx_grants_lookup ON branch_contribute_grants (rfc_slug, branch_name);
|
||||
CREATE INDEX idx_grants_grantee ON branch_contribute_grants (grantee_user_id);
|
||||
|
||||
-- ── stars: UNIQUE (user_id, rfc_slug) -> +project_id ───────────────────────
|
||||
CREATE TABLE stars__new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
rfc_slug TEXT NOT NULL,
|
||||
starred_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
project_id TEXT NOT NULL DEFAULT 'default',
|
||||
UNIQUE (project_id, user_id, rfc_slug)
|
||||
);
|
||||
INSERT INTO stars__new SELECT * FROM stars;
|
||||
DROP TABLE stars;
|
||||
ALTER TABLE stars__new RENAME TO stars;
|
||||
CREATE INDEX idx_stars_user ON stars (user_id);
|
||||
CREATE INDEX idx_stars_rfc ON stars (rfc_slug);
|
||||
|
||||
-- ── watches: UNIQUE (user_id, rfc_slug) -> +project_id ─────────────────────
|
||||
CREATE TABLE watches__new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
rfc_slug TEXT NOT NULL,
|
||||
state TEXT NOT NULL CHECK (state IN ('watching', 'following', 'muted')),
|
||||
set_by TEXT NOT NULL CHECK (set_by IN ('auto', 'explicit')),
|
||||
set_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
last_participation_at TEXT,
|
||||
project_id TEXT NOT NULL DEFAULT 'default',
|
||||
UNIQUE (project_id, user_id, rfc_slug)
|
||||
);
|
||||
INSERT INTO watches__new SELECT * FROM watches;
|
||||
DROP TABLE watches;
|
||||
ALTER TABLE watches__new RENAME TO watches;
|
||||
CREATE INDEX idx_watches_user ON watches (user_id);
|
||||
CREATE INDEX idx_watches_rfc ON watches (rfc_slug);
|
||||
CREATE INDEX idx_watches_decay ON watches (state, last_participation_at);
|
||||
|
||||
-- ── pr_seen: UNIQUE (user_id, rfc_slug, pr_number) -> +project_id ──────────
|
||||
CREATE TABLE pr_seen__new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
rfc_slug TEXT NOT NULL,
|
||||
pr_number INTEGER NOT NULL,
|
||||
last_seen_commit_sha TEXT,
|
||||
last_seen_message_id INTEGER REFERENCES thread_messages(id) ON DELETE SET NULL,
|
||||
seen_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
project_id TEXT NOT NULL DEFAULT 'default',
|
||||
UNIQUE (project_id, user_id, rfc_slug, pr_number)
|
||||
);
|
||||
INSERT INTO pr_seen__new SELECT * FROM pr_seen;
|
||||
DROP TABLE pr_seen;
|
||||
ALTER TABLE pr_seen__new RENAME TO pr_seen;
|
||||
|
||||
-- ── branch_chat_seen: UNIQUE (user_id, rfc_slug, branch_name) -> +project_id
|
||||
CREATE TABLE branch_chat_seen__new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
rfc_slug TEXT NOT NULL,
|
||||
branch_name TEXT NOT NULL,
|
||||
last_seen_message_id INTEGER REFERENCES thread_messages(id) ON DELETE SET NULL,
|
||||
seen_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
project_id TEXT NOT NULL DEFAULT 'default',
|
||||
UNIQUE (project_id, user_id, rfc_slug, branch_name)
|
||||
);
|
||||
INSERT INTO branch_chat_seen__new SELECT * FROM branch_chat_seen;
|
||||
DROP TABLE branch_chat_seen;
|
||||
ALTER TABLE branch_chat_seen__new RENAME TO branch_chat_seen;
|
||||
|
||||
-- ── funder_consents: PRIMARY KEY (user_id, rfc_slug) -> +project_id ────────
|
||||
CREATE TABLE funder_consents__new (
|
||||
user_id INTEGER NOT NULL,
|
||||
rfc_slug TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
project_id TEXT NOT NULL DEFAULT 'default',
|
||||
PRIMARY KEY (project_id, user_id, rfc_slug),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
INSERT INTO funder_consents__new SELECT * FROM funder_consents;
|
||||
DROP TABLE funder_consents;
|
||||
ALTER TABLE funder_consents__new RENAME TO funder_consents;
|
||||
CREATE INDEX idx_funder_consents_slug ON funder_consents (rfc_slug);
|
||||
|
||||
-- ── rfc_collaborators: UNIQUE idx (rfc_slug, user_id) -> +project_id;
|
||||
-- FK rfc_slug -> cached_rfcs(slug) becomes composite (project_id, rfc_slug)
|
||||
CREATE TABLE rfc_collaborators__new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
rfc_slug TEXT NOT NULL,
|
||||
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')),
|
||||
project_id TEXT NOT NULL DEFAULT 'default',
|
||||
FOREIGN KEY (project_id, rfc_slug) REFERENCES cached_rfcs(project_id, slug) ON DELETE CASCADE
|
||||
);
|
||||
INSERT INTO rfc_collaborators__new SELECT * FROM rfc_collaborators;
|
||||
DROP TABLE rfc_collaborators;
|
||||
ALTER TABLE rfc_collaborators__new RENAME TO rfc_collaborators;
|
||||
CREATE UNIQUE INDEX idx_rfc_collaborators_unique ON rfc_collaborators (project_id, rfc_slug, user_id);
|
||||
CREATE INDEX idx_rfc_collaborators_user ON rfc_collaborators (user_id);
|
||||
|
||||
-- ── contribution_requests: UNIQUE idx (rfc_slug, requester) WHERE pending
|
||||
-- -> +project_id; FK rfc_slug -> cached_rfcs(slug) becomes composite.
|
||||
CREATE TABLE contribution_requests__new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
rfc_slug TEXT NOT NULL,
|
||||
requester_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
matched_term TEXT NOT NULL,
|
||||
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,
|
||||
invitation_id INTEGER REFERENCES rfc_invitations(id) ON DELETE SET NULL,
|
||||
notification_id INTEGER REFERENCES notifications(id) ON DELETE SET NULL,
|
||||
project_id TEXT NOT NULL DEFAULT 'default',
|
||||
FOREIGN KEY (project_id, rfc_slug) REFERENCES cached_rfcs(project_id, slug) ON DELETE CASCADE
|
||||
);
|
||||
INSERT INTO contribution_requests__new SELECT * FROM contribution_requests;
|
||||
DROP TABLE contribution_requests;
|
||||
ALTER TABLE contribution_requests__new RENAME TO contribution_requests;
|
||||
CREATE INDEX idx_contribution_requests_rfc ON contribution_requests(rfc_slug, status);
|
||||
CREATE INDEX idx_contribution_requests_requester ON contribution_requests(requester_user_id, status);
|
||||
CREATE UNIQUE INDEX idx_contribution_requests_one_open
|
||||
ON contribution_requests(project_id, rfc_slug, requester_user_id)
|
||||
WHERE status = 'pending';
|
||||
|
||||
-- ── proposed_use_cases: UNIQUE (scope, pr_number) -> +project_id ───────────
|
||||
CREATE TABLE proposed_use_cases__new (
|
||||
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')),
|
||||
project_id TEXT NOT NULL DEFAULT 'default',
|
||||
UNIQUE (project_id, scope, pr_number)
|
||||
);
|
||||
INSERT INTO proposed_use_cases__new SELECT * FROM proposed_use_cases;
|
||||
DROP TABLE proposed_use_cases;
|
||||
ALTER TABLE proposed_use_cases__new RENAME TO proposed_use_cases;
|
||||
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,427 @@
|
||||
-- migrate:no-foreign-keys
|
||||
--
|
||||
-- §22 three-tier refactor — S1. Insert a *collection* grain beneath project.
|
||||
--
|
||||
-- (1) a `collections` table beneath `projects`;
|
||||
-- (2) move the per-corpus fields (type, initial_state) down from `projects`
|
||||
-- (projects keeps id, name, content_repo, visibility, config_json, …);
|
||||
-- (3) one default collection per project (id='default' for the standard
|
||||
-- single-project deployment, subfolder = repo root), inheriting the
|
||||
-- project's type / initial_state / visibility;
|
||||
-- (4) re-key the 13 entry-corpus tables (project_id, slug) -> (collection_id,
|
||||
-- slug) via the migration-028 rebuild pattern, mapping each row to its
|
||||
-- project's default collection by JOIN;
|
||||
-- (5) generalise project_members -> memberships(scope_type ∈ {project,
|
||||
-- collection}, scope_id, …), collapsing the role enum to {owner,
|
||||
-- contributor} (§B.3).
|
||||
--
|
||||
-- SQLite can't ALTER a PK/UNIQUE in place, so each keyed table is rebuilt by the
|
||||
-- official create-copy-drop-rename procedure. FK enforcement is OFF for the file
|
||||
-- (the `migrate:no-foreign-keys` marker tells the runner to toggle it and run
|
||||
-- foreign_key_check after). cached_rfcs is rebuilt FIRST so the child tables can
|
||||
-- re-point their composite FK at its new (collection_id, slug) key.
|
||||
--
|
||||
-- The tables 026 tagged with project_id but 028 did NOT key (threads, changes,
|
||||
-- notifications, actions, pr_resolution_branches, cached_prs) keep project_id —
|
||||
-- they carry a project-grain tag, untouched in S1. See
|
||||
-- docs/design/2026-06-05-three-tier-projects-collections.md §A.6 / Part E.
|
||||
|
||||
-- ── collections: the new typed-corpus grain beneath projects ───────────────
|
||||
CREATE TABLE collections (
|
||||
id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
type TEXT NOT NULL DEFAULT 'document'
|
||||
CHECK (type IN ('document', 'specification', 'bdd')),
|
||||
subfolder TEXT NOT NULL DEFAULT '',
|
||||
initial_state TEXT NOT NULL DEFAULT 'super-draft'
|
||||
CHECK (initial_state IN ('super-draft', 'active')),
|
||||
visibility TEXT NOT NULL DEFAULT 'gated'
|
||||
CHECK (visibility IN ('gated', 'public', 'unlisted')),
|
||||
name TEXT,
|
||||
registry_sha TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
CREATE INDEX idx_collections_project ON collections(project_id);
|
||||
|
||||
-- One default collection per project. id='default' for the standard
|
||||
-- single-project deployment (a stable literal across deploy histories); the
|
||||
-- project_id is used as a unique fallback id only if a non-standard
|
||||
-- multi-project deployment migrates (pre-S5; avoids a PK collision).
|
||||
INSERT INTO collections (id, project_id, type, subfolder, initial_state, visibility, name)
|
||||
SELECT
|
||||
CASE WHEN (SELECT COUNT(*) FROM projects) <= 1 THEN 'default' ELSE p.id END,
|
||||
p.id, p.type, '', p.initial_state, p.visibility, p.name
|
||||
FROM projects p;
|
||||
|
||||
-- ── projects: rebuild to DROP the per-corpus fields (type, initial_state) ───
|
||||
CREATE TABLE projects__new (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
content_repo TEXT,
|
||||
visibility TEXT NOT NULL DEFAULT 'gated'
|
||||
CHECK (visibility IN ('gated', 'public', 'unlisted')),
|
||||
config_json TEXT,
|
||||
registry_sha TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
INSERT INTO projects__new (id, name, content_repo, visibility, config_json, registry_sha, created_at, updated_at)
|
||||
SELECT id, name, content_repo, visibility, config_json, registry_sha, created_at, updated_at FROM projects;
|
||||
DROP TABLE projects;
|
||||
ALTER TABLE projects__new RENAME TO projects;
|
||||
|
||||
-- ── cached_rfcs: PRIMARY KEY (project_id, slug) -> (collection_id, slug) ────
|
||||
CREATE TABLE cached_rfcs__new (
|
||||
slug TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
state TEXT NOT NULL CHECK (state IN ('super-draft', 'active', 'withdrawn', 'retired')),
|
||||
rfc_id TEXT,
|
||||
repo TEXT,
|
||||
proposed_by TEXT,
|
||||
proposed_at TEXT,
|
||||
graduated_at TEXT,
|
||||
graduated_by TEXT,
|
||||
owners_json TEXT NOT NULL DEFAULT '[]',
|
||||
arbiters_json TEXT NOT NULL DEFAULT '[]',
|
||||
tags_json TEXT NOT NULL DEFAULT '[]',
|
||||
body TEXT,
|
||||
body_sha TEXT,
|
||||
last_main_commit_at TEXT,
|
||||
last_entry_commit_at TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
models_json TEXT,
|
||||
funder_login TEXT,
|
||||
proposed_use_case TEXT,
|
||||
collection_id TEXT NOT NULL DEFAULT 'default' REFERENCES collections(id),
|
||||
unreviewed INTEGER NOT NULL DEFAULT 0,
|
||||
reviewed_at TEXT,
|
||||
reviewed_by TEXT,
|
||||
PRIMARY KEY (collection_id, slug)
|
||||
);
|
||||
INSERT INTO cached_rfcs__new
|
||||
(slug, title, state, rfc_id, repo, proposed_by, proposed_at, graduated_at,
|
||||
graduated_by, owners_json, arbiters_json, tags_json, body, body_sha,
|
||||
last_main_commit_at, last_entry_commit_at, updated_at, models_json,
|
||||
funder_login, proposed_use_case, collection_id, unreviewed, reviewed_at, reviewed_by)
|
||||
SELECT
|
||||
r.slug, r.title, r.state, r.rfc_id, r.repo, r.proposed_by, r.proposed_at, r.graduated_at,
|
||||
r.graduated_by, r.owners_json, r.arbiters_json, r.tags_json, r.body, r.body_sha,
|
||||
r.last_main_commit_at, r.last_entry_commit_at, r.updated_at, r.models_json,
|
||||
r.funder_login, r.proposed_use_case,
|
||||
(SELECT c.id FROM collections c WHERE c.project_id = r.project_id LIMIT 1),
|
||||
r.unreviewed, r.reviewed_at, r.reviewed_by
|
||||
FROM cached_rfcs r;
|
||||
DROP TABLE cached_rfcs;
|
||||
ALTER TABLE cached_rfcs__new RENAME TO cached_rfcs;
|
||||
CREATE INDEX idx_cached_rfcs_state ON cached_rfcs (state);
|
||||
CREATE INDEX idx_cached_rfcs_last_active ON cached_rfcs (
|
||||
COALESCE(last_main_commit_at, last_entry_commit_at) DESC
|
||||
);
|
||||
CREATE INDEX idx_cached_rfcs_collection ON cached_rfcs(collection_id);
|
||||
|
||||
-- ── rfc_invitations: single-col FK -> composite (collection_id, rfc_slug) ───
|
||||
CREATE TABLE rfc_invitations__new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
rfc_slug TEXT NOT NULL,
|
||||
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,
|
||||
collection_id TEXT NOT NULL DEFAULT 'default',
|
||||
FOREIGN KEY (collection_id, rfc_slug) REFERENCES cached_rfcs(collection_id, slug) ON DELETE CASCADE
|
||||
);
|
||||
INSERT INTO rfc_invitations__new
|
||||
(id, rfc_slug, inviter_user_id, invitee_email, role_in_rfc, status, token,
|
||||
expires_at, created_at, accepted_at, accepted_by_user_id, collection_id)
|
||||
SELECT
|
||||
i.id, i.rfc_slug, i.inviter_user_id, i.invitee_email, i.role_in_rfc, i.status, i.token,
|
||||
i.expires_at, i.created_at, i.accepted_at, i.accepted_by_user_id,
|
||||
(SELECT c.id FROM collections c WHERE c.project_id = i.project_id LIMIT 1)
|
||||
FROM rfc_invitations i;
|
||||
DROP TABLE rfc_invitations;
|
||||
ALTER TABLE rfc_invitations__new RENAME TO rfc_invitations;
|
||||
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);
|
||||
|
||||
-- ── cached_branches: UNIQUE (project_id, rfc_slug, branch_name) -> collection
|
||||
CREATE TABLE cached_branches__new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
rfc_slug TEXT NOT NULL,
|
||||
branch_name TEXT NOT NULL,
|
||||
head_sha TEXT,
|
||||
state TEXT NOT NULL DEFAULT 'open' CHECK (state IN ('open', 'closed', 'deleted')),
|
||||
pinned INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
last_commit_at TEXT,
|
||||
closed_at TEXT,
|
||||
collection_id TEXT NOT NULL DEFAULT 'default',
|
||||
UNIQUE (collection_id, rfc_slug, branch_name)
|
||||
);
|
||||
INSERT INTO cached_branches__new
|
||||
(id, rfc_slug, branch_name, head_sha, state, pinned, created_at, last_commit_at, closed_at, collection_id)
|
||||
SELECT
|
||||
b.id, b.rfc_slug, b.branch_name, b.head_sha, b.state, b.pinned, b.created_at, b.last_commit_at, b.closed_at,
|
||||
(SELECT c.id FROM collections c WHERE c.project_id = b.project_id LIMIT 1)
|
||||
FROM cached_branches b;
|
||||
DROP TABLE cached_branches;
|
||||
ALTER TABLE cached_branches__new RENAME TO cached_branches;
|
||||
CREATE INDEX idx_cached_branches_rfc ON cached_branches (rfc_slug, state);
|
||||
|
||||
-- ── branch_visibility: UNIQUE (project_id, rfc_slug, branch_name) -> collection
|
||||
CREATE TABLE branch_visibility__new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
rfc_slug TEXT NOT NULL,
|
||||
branch_name TEXT NOT NULL,
|
||||
read_public INTEGER NOT NULL DEFAULT 1,
|
||||
contribute_mode TEXT NOT NULL DEFAULT 'just-me' CHECK (contribute_mode IN ('just-me', 'specific', 'any-contributor')),
|
||||
collection_id TEXT NOT NULL DEFAULT 'default',
|
||||
UNIQUE (collection_id, rfc_slug, branch_name)
|
||||
);
|
||||
INSERT INTO branch_visibility__new
|
||||
(id, rfc_slug, branch_name, read_public, contribute_mode, collection_id)
|
||||
SELECT
|
||||
v.id, v.rfc_slug, v.branch_name, v.read_public, v.contribute_mode,
|
||||
(SELECT c.id FROM collections c WHERE c.project_id = v.project_id LIMIT 1)
|
||||
FROM branch_visibility v;
|
||||
DROP TABLE branch_visibility;
|
||||
ALTER TABLE branch_visibility__new RENAME TO branch_visibility;
|
||||
|
||||
-- ── branch_contribute_grants: UNIQUE (..., grantee) -> +collection_id ───────
|
||||
CREATE TABLE branch_contribute_grants__new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
rfc_slug TEXT NOT NULL,
|
||||
branch_name TEXT NOT NULL,
|
||||
grantee_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
granted_by INTEGER NOT NULL REFERENCES users(id) ON DELETE SET NULL,
|
||||
granted_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
collection_id TEXT NOT NULL DEFAULT 'default',
|
||||
UNIQUE (collection_id, rfc_slug, branch_name, grantee_user_id)
|
||||
);
|
||||
INSERT INTO branch_contribute_grants__new
|
||||
(id, rfc_slug, branch_name, grantee_user_id, granted_by, granted_at, collection_id)
|
||||
SELECT
|
||||
g.id, g.rfc_slug, g.branch_name, g.grantee_user_id, g.granted_by, g.granted_at,
|
||||
(SELECT c.id FROM collections c WHERE c.project_id = g.project_id LIMIT 1)
|
||||
FROM branch_contribute_grants g;
|
||||
DROP TABLE branch_contribute_grants;
|
||||
ALTER TABLE branch_contribute_grants__new RENAME TO branch_contribute_grants;
|
||||
CREATE INDEX idx_grants_lookup ON branch_contribute_grants (rfc_slug, branch_name);
|
||||
CREATE INDEX idx_grants_grantee ON branch_contribute_grants (grantee_user_id);
|
||||
|
||||
-- ── stars: UNIQUE (project_id, user_id, rfc_slug) -> collection_id ──────────
|
||||
CREATE TABLE stars__new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
rfc_slug TEXT NOT NULL,
|
||||
starred_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
collection_id TEXT NOT NULL DEFAULT 'default',
|
||||
UNIQUE (collection_id, user_id, rfc_slug)
|
||||
);
|
||||
INSERT INTO stars__new (id, user_id, rfc_slug, starred_at, collection_id)
|
||||
SELECT s.id, s.user_id, s.rfc_slug, s.starred_at,
|
||||
(SELECT c.id FROM collections c WHERE c.project_id = s.project_id LIMIT 1)
|
||||
FROM stars s;
|
||||
DROP TABLE stars;
|
||||
ALTER TABLE stars__new RENAME TO stars;
|
||||
CREATE INDEX idx_stars_user ON stars (user_id);
|
||||
CREATE INDEX idx_stars_rfc ON stars (rfc_slug);
|
||||
|
||||
-- ── watches: UNIQUE (project_id, user_id, rfc_slug) -> collection_id ────────
|
||||
CREATE TABLE watches__new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
rfc_slug TEXT NOT NULL,
|
||||
state TEXT NOT NULL CHECK (state IN ('watching', 'following', 'muted')),
|
||||
set_by TEXT NOT NULL CHECK (set_by IN ('auto', 'explicit')),
|
||||
set_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
last_participation_at TEXT,
|
||||
collection_id TEXT NOT NULL DEFAULT 'default',
|
||||
UNIQUE (collection_id, user_id, rfc_slug)
|
||||
);
|
||||
INSERT INTO watches__new
|
||||
(id, user_id, rfc_slug, state, set_by, set_at, last_participation_at, collection_id)
|
||||
SELECT
|
||||
w.id, w.user_id, w.rfc_slug, w.state, w.set_by, w.set_at, w.last_participation_at,
|
||||
(SELECT c.id FROM collections c WHERE c.project_id = w.project_id LIMIT 1)
|
||||
FROM watches w;
|
||||
DROP TABLE watches;
|
||||
ALTER TABLE watches__new RENAME TO watches;
|
||||
CREATE INDEX idx_watches_user ON watches (user_id);
|
||||
CREATE INDEX idx_watches_rfc ON watches (rfc_slug);
|
||||
CREATE INDEX idx_watches_decay ON watches (state, last_participation_at);
|
||||
|
||||
-- ── pr_seen: UNIQUE (project_id, user_id, rfc_slug, pr_number) -> collection ─
|
||||
CREATE TABLE pr_seen__new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
rfc_slug TEXT NOT NULL,
|
||||
pr_number INTEGER NOT NULL,
|
||||
last_seen_commit_sha TEXT,
|
||||
last_seen_message_id INTEGER REFERENCES thread_messages(id) ON DELETE SET NULL,
|
||||
seen_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
collection_id TEXT NOT NULL DEFAULT 'default',
|
||||
UNIQUE (collection_id, user_id, rfc_slug, pr_number)
|
||||
);
|
||||
INSERT INTO pr_seen__new
|
||||
(id, user_id, rfc_slug, pr_number, last_seen_commit_sha, last_seen_message_id, seen_at, collection_id)
|
||||
SELECT
|
||||
p.id, p.user_id, p.rfc_slug, p.pr_number, p.last_seen_commit_sha, p.last_seen_message_id, p.seen_at,
|
||||
(SELECT c.id FROM collections c WHERE c.project_id = p.project_id LIMIT 1)
|
||||
FROM pr_seen p;
|
||||
DROP TABLE pr_seen;
|
||||
ALTER TABLE pr_seen__new RENAME TO pr_seen;
|
||||
|
||||
-- ── branch_chat_seen: UNIQUE (project_id, user_id, rfc_slug, branch) -> coll ─
|
||||
CREATE TABLE branch_chat_seen__new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
rfc_slug TEXT NOT NULL,
|
||||
branch_name TEXT NOT NULL,
|
||||
last_seen_message_id INTEGER REFERENCES thread_messages(id) ON DELETE SET NULL,
|
||||
seen_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
collection_id TEXT NOT NULL DEFAULT 'default',
|
||||
UNIQUE (collection_id, user_id, rfc_slug, branch_name)
|
||||
);
|
||||
INSERT INTO branch_chat_seen__new
|
||||
(id, user_id, rfc_slug, branch_name, last_seen_message_id, seen_at, collection_id)
|
||||
SELECT
|
||||
s.id, s.user_id, s.rfc_slug, s.branch_name, s.last_seen_message_id, s.seen_at,
|
||||
(SELECT c.id FROM collections c WHERE c.project_id = s.project_id LIMIT 1)
|
||||
FROM branch_chat_seen s;
|
||||
DROP TABLE branch_chat_seen;
|
||||
ALTER TABLE branch_chat_seen__new RENAME TO branch_chat_seen;
|
||||
|
||||
-- ── funder_consents: PRIMARY KEY (project_id, user_id, rfc_slug) -> collection
|
||||
CREATE TABLE funder_consents__new (
|
||||
user_id INTEGER NOT NULL,
|
||||
rfc_slug TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
collection_id TEXT NOT NULL DEFAULT 'default',
|
||||
PRIMARY KEY (collection_id, user_id, rfc_slug),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
INSERT INTO funder_consents__new (user_id, rfc_slug, created_at, collection_id)
|
||||
SELECT f.user_id, f.rfc_slug, f.created_at,
|
||||
(SELECT c.id FROM collections c WHERE c.project_id = f.project_id LIMIT 1)
|
||||
FROM funder_consents f;
|
||||
DROP TABLE funder_consents;
|
||||
ALTER TABLE funder_consents__new RENAME TO funder_consents;
|
||||
CREATE INDEX idx_funder_consents_slug ON funder_consents (rfc_slug);
|
||||
|
||||
-- ── rfc_collaborators: UNIQUE idx + composite FK -> collection_id ───────────
|
||||
CREATE TABLE rfc_collaborators__new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
rfc_slug TEXT NOT NULL,
|
||||
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')),
|
||||
collection_id TEXT NOT NULL DEFAULT 'default',
|
||||
FOREIGN KEY (collection_id, rfc_slug) REFERENCES cached_rfcs(collection_id, slug) ON DELETE CASCADE
|
||||
);
|
||||
INSERT INTO rfc_collaborators__new
|
||||
(id, rfc_slug, user_id, role_in_rfc, invitation_id, created_at, collection_id)
|
||||
SELECT
|
||||
rc.id, rc.rfc_slug, rc.user_id, rc.role_in_rfc, rc.invitation_id, rc.created_at,
|
||||
(SELECT c.id FROM collections c WHERE c.project_id = rc.project_id LIMIT 1)
|
||||
FROM rfc_collaborators rc;
|
||||
DROP TABLE rfc_collaborators;
|
||||
ALTER TABLE rfc_collaborators__new RENAME TO rfc_collaborators;
|
||||
CREATE UNIQUE INDEX idx_rfc_collaborators_unique ON rfc_collaborators (collection_id, rfc_slug, user_id);
|
||||
CREATE INDEX idx_rfc_collaborators_user ON rfc_collaborators (user_id);
|
||||
|
||||
-- ── contribution_requests: UNIQUE idx (pending) + composite FK -> collection ─
|
||||
CREATE TABLE contribution_requests__new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
rfc_slug TEXT NOT NULL,
|
||||
requester_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
matched_term TEXT NOT NULL,
|
||||
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,
|
||||
invitation_id INTEGER REFERENCES rfc_invitations(id) ON DELETE SET NULL,
|
||||
notification_id INTEGER REFERENCES notifications(id) ON DELETE SET NULL,
|
||||
collection_id TEXT NOT NULL DEFAULT 'default',
|
||||
FOREIGN KEY (collection_id, rfc_slug) REFERENCES cached_rfcs(collection_id, slug) ON DELETE CASCADE
|
||||
);
|
||||
INSERT INTO contribution_requests__new
|
||||
(id, rfc_slug, requester_user_id, matched_term, who_i_am, why, use_case, status,
|
||||
created_at, decided_at, decided_by_user_id, invitation_id, notification_id, collection_id)
|
||||
SELECT
|
||||
cr.id, cr.rfc_slug, cr.requester_user_id, cr.matched_term, cr.who_i_am, cr.why, cr.use_case, cr.status,
|
||||
cr.created_at, cr.decided_at, cr.decided_by_user_id, cr.invitation_id, cr.notification_id,
|
||||
(SELECT c.id FROM collections c WHERE c.project_id = cr.project_id LIMIT 1)
|
||||
FROM contribution_requests cr;
|
||||
DROP TABLE contribution_requests;
|
||||
ALTER TABLE contribution_requests__new RENAME TO contribution_requests;
|
||||
CREATE INDEX idx_contribution_requests_rfc ON contribution_requests(rfc_slug, status);
|
||||
CREATE INDEX idx_contribution_requests_requester ON contribution_requests(requester_user_id, status);
|
||||
CREATE UNIQUE INDEX idx_contribution_requests_one_open
|
||||
ON contribution_requests(collection_id, rfc_slug, requester_user_id)
|
||||
WHERE status = 'pending';
|
||||
|
||||
-- ── proposed_use_cases: UNIQUE (project_id, scope, pr_number) -> collection ──
|
||||
CREATE TABLE proposed_use_cases__new (
|
||||
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')),
|
||||
collection_id TEXT NOT NULL DEFAULT 'default',
|
||||
UNIQUE (collection_id, scope, pr_number)
|
||||
);
|
||||
INSERT INTO proposed_use_cases__new
|
||||
(id, scope, rfc_slug, pr_number, use_case, created_at, collection_id)
|
||||
SELECT
|
||||
u.id, u.scope, u.rfc_slug, u.pr_number, u.use_case, u.created_at,
|
||||
(SELECT c.id FROM collections c WHERE c.project_id = u.project_id LIMIT 1)
|
||||
FROM proposed_use_cases u;
|
||||
DROP TABLE proposed_use_cases;
|
||||
ALTER TABLE proposed_use_cases__new RENAME TO proposed_use_cases;
|
||||
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);
|
||||
|
||||
-- ── project_members -> memberships(scope_type, scope_id, …); roles collapsed ─
|
||||
CREATE TABLE memberships (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
scope_type TEXT NOT NULL CHECK (scope_type IN ('project', 'collection')),
|
||||
scope_id TEXT NOT NULL,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL CHECK (role IN ('owner', 'contributor')),
|
||||
granted_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
granted_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE (scope_type, scope_id, user_id)
|
||||
);
|
||||
CREATE INDEX idx_memberships_user ON memberships(user_id);
|
||||
CREATE INDEX idx_memberships_scope ON memberships(scope_type, scope_id);
|
||||
|
||||
-- M2 project_members rows attached at what is now the *collection*; collapse the
|
||||
-- role enum (project_admin -> owner, project_contributor -> contributor;
|
||||
-- project_viewer dropped this pass, §B.3) and migrate onto the default
|
||||
-- collection of each project.
|
||||
INSERT INTO memberships (scope_type, scope_id, user_id, role, granted_by, granted_at)
|
||||
SELECT 'collection',
|
||||
(SELECT c.id FROM collections c WHERE c.project_id = pm.project_id LIMIT 1),
|
||||
pm.user_id,
|
||||
CASE pm.role WHEN 'project_admin' THEN 'owner'
|
||||
WHEN 'project_contributor' THEN 'contributor'
|
||||
ELSE 'contributor' END,
|
||||
pm.granted_by, pm.granted_at
|
||||
FROM project_members pm
|
||||
WHERE pm.role IN ('project_admin', 'project_contributor');
|
||||
DROP TABLE project_members;
|
||||
@@ -0,0 +1,34 @@
|
||||
-- migrate:no-foreign-keys
|
||||
--
|
||||
-- §22 three-tier — S3. Admit a *global*-scope grant to the memberships table.
|
||||
--
|
||||
-- §B.2's resolver folds four layers (global → project → collection → per-entry).
|
||||
-- Migration 029 created `memberships` with scope_type ∈ {project, collection}
|
||||
-- only; the global tier was left to S3. A global grant is how a "global RFC
|
||||
-- Contributor" (a contributor who may propose in every collection of every
|
||||
-- project, distinct from a deployment owner/admin) is represented — see
|
||||
-- docs/design/2026-06-05-three-tier-projects-collections.md §B.2/§B.3 and the
|
||||
-- C.1 "cleo" scenario.
|
||||
--
|
||||
-- SQLite can't ALTER a CHECK constraint in place, so the table is rebuilt by the
|
||||
-- create-copy-drop-rename procedure (the 028/029 pattern). The global scope uses
|
||||
-- a stable sentinel scope_id of '*' (one global tier per deployment); the
|
||||
-- UNIQUE(scope_type, scope_id, user_id) then admits exactly one global grant per
|
||||
-- user, mirroring the project/collection rows.
|
||||
|
||||
CREATE TABLE memberships__new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
scope_type TEXT NOT NULL CHECK (scope_type IN ('global', 'project', 'collection')),
|
||||
scope_id TEXT NOT NULL,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL CHECK (role IN ('owner', 'contributor')),
|
||||
granted_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
granted_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE (scope_type, scope_id, user_id)
|
||||
);
|
||||
INSERT INTO memberships__new (id, scope_type, scope_id, user_id, role, granted_by, granted_at)
|
||||
SELECT id, scope_type, scope_id, user_id, role, granted_by, granted_at FROM memberships;
|
||||
DROP TABLE memberships;
|
||||
ALTER TABLE memberships__new RENAME TO memberships;
|
||||
CREATE INDEX idx_memberships_user ON memberships(user_id);
|
||||
CREATE INDEX idx_memberships_scope ON memberships(scope_type, scope_id);
|
||||
@@ -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,139 @@
|
||||
"""§22.9/§22.5 — GET /api/deployment + GET /api/projects/:id with visibility."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
app_with_fake_gitea, tmp_env, provision_user_row, sign_in_as,
|
||||
)
|
||||
|
||||
|
||||
def _add_project(pid, name, vis, typ="document"):
|
||||
# §22 three-tier: a project (grouping tier) + its default collection (the
|
||||
# per-corpus type/initial_state moved down in migration 029). The default
|
||||
# collection keys by the project id so it is globally unique in tests.
|
||||
from app import db
|
||||
db.conn().execute(
|
||||
"INSERT OR REPLACE INTO projects (id, name, content_repo, visibility) VALUES (?, ?, ?, ?)",
|
||||
(pid, name, pid, vis),
|
||||
)
|
||||
db.conn().execute(
|
||||
"INSERT OR REPLACE INTO collections (id, project_id, type, subfolder, initial_state, visibility, name) "
|
||||
"VALUES (?, ?, ?, '', 'super-draft', ?, ?)",
|
||||
(pid, pid, typ, vis, name),
|
||||
)
|
||||
|
||||
|
||||
def test_deployment_lists_public_omits_gated_and_unlisted_for_anon(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_add_project("pub", "Public", "public")
|
||||
_add_project("gat", "Gated", "gated")
|
||||
_add_project("unl", "Unlisted", "unlisted")
|
||||
r = client.get("/api/deployment")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["name"] == "Test Deployment"
|
||||
ids = {p["id"] for p in body["projects"]}
|
||||
assert "pub" in ids and "default" in ids # both public
|
||||
assert "gat" not in ids # gated, anon not a member
|
||||
assert "unl" not in ids # unlisted never enumerated
|
||||
|
||||
|
||||
def test_projects_id_404_for_gated_non_member(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_add_project("gat", "Gated", "gated")
|
||||
assert client.get("/api/projects/gat").status_code == 404
|
||||
|
||||
|
||||
def test_projects_id_returns_config_for_public(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
from app import db
|
||||
db.conn().execute(
|
||||
"UPDATE projects SET config_json = ? WHERE id = 'default'",
|
||||
('{"theme": {"accent": "#5b5bd6"}}',),
|
||||
)
|
||||
r = client.get("/api/projects/default")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["id"] == "default"
|
||||
assert body["type"] == "document"
|
||||
assert body["visibility"] == "public"
|
||||
assert body["theme"] == {"accent": "#5b5bd6"}
|
||||
|
||||
|
||||
def test_projects_id_unlisted_readable_by_direct_id(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_add_project("unl", "Unlisted", "unlisted")
|
||||
assert client.get("/api/projects/unl").status_code == 200
|
||||
|
||||
|
||||
def test_projects_id_unknown_returns_404_even_for_superuser(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
|
||||
assert client.get("/api/projects/does-not-exist").status_code == 404
|
||||
|
||||
|
||||
def test_projects_id_unknown_returns_404_for_anon(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
assert client.get("/api/projects/nope").status_code == 404
|
||||
|
||||
|
||||
def test_deployment_includes_default_project_id(app_with_fake_gitea):
|
||||
# §22.10 / M3-frontend guard contract: the frontend learns which project
|
||||
# is the corpus-served (default) one from the deployment payload.
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
body = client.get("/api/deployment").json()
|
||||
assert body["default_project_id"] == "default"
|
||||
|
||||
|
||||
def test_rfc_root_url_redirects_308_to_project_scoped(app_with_fake_gitea):
|
||||
# §22.10 / §5: old corpus-root /rfc/<slug> → 308 /p/<default>/e/<slug>.
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/rfc/human", follow_redirects=False)
|
||||
assert r.status_code == 308
|
||||
assert r.headers["location"] == "/p/default/c/default/e/human"
|
||||
|
||||
|
||||
def test_rfc_pr_url_redirects_308_to_project_scoped(app_with_fake_gitea):
|
||||
# The old per-RFC PR deep link is preserved too.
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/rfc/human/pr/7", follow_redirects=False)
|
||||
assert r.status_code == 308
|
||||
assert r.headers["location"] == "/p/default/c/default/e/human/pr/7"
|
||||
|
||||
|
||||
def test_proposals_root_url_redirects_308_to_project_scoped(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/proposals/42", follow_redirects=False)
|
||||
assert r.status_code == 308
|
||||
assert r.headers["location"] == "/p/default/c/default/proposals/42"
|
||||
|
||||
|
||||
def test_gated_project_visible_and_readable_to_member(app_with_fake_gitea):
|
||||
from app import db
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_add_project("teamx", "Team X", "gated")
|
||||
provision_user_row(user_id=5, login="mia", role="contributor")
|
||||
db.conn().execute(
|
||||
"INSERT INTO memberships (scope_type, scope_id, user_id, role) VALUES ('collection', 'teamx', 5, 'contributor')"
|
||||
)
|
||||
sign_in_as(client, user_id=5, gitea_login="mia", display_name="Mia", role="contributor")
|
||||
# member sees the gated project in the deployment directory
|
||||
ids = {p["id"] for p in client.get("/api/deployment").json()["projects"]}
|
||||
assert "teamx" in ids
|
||||
# member can read it directly
|
||||
r = client.get("/api/projects/teamx")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["id"] == "teamx"
|
||||
@@ -0,0 +1,24 @@
|
||||
"""§22.4c — _upsert_cached_rfc mirrors the review fields into cached_rfcs."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from test_propose_vertical import app_with_fake_gitea, tmp_env # noqa: F401
|
||||
|
||||
|
||||
def test_upsert_writes_review_fields(app_with_fake_gitea):
|
||||
from app import cache, db, entry as entry_mod
|
||||
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
e = entry_mod.Entry(
|
||||
slug="rev", title="Rev", state="active",
|
||||
unreviewed=True, reviewed_at="2026-06-03", reviewed_by="ben",
|
||||
)
|
||||
cache._upsert_cached_rfc(e, body_sha="sha-rev")
|
||||
row = db.conn().execute(
|
||||
"SELECT unreviewed, reviewed_at, reviewed_by FROM cached_rfcs WHERE slug = 'rev'"
|
||||
).fetchone()
|
||||
assert row["unreviewed"] == 1
|
||||
assert row["reviewed_at"] == "2026-06-03"
|
||||
assert row["reviewed_by"] == "ben"
|
||||
@@ -0,0 +1,83 @@
|
||||
"""§22 S2 — create-collection vertical: a deployment owner/admin POSTs, the bot
|
||||
commits a `.collection.yaml`, and the registry mirror upserts the collections
|
||||
row (registry stays the source of truth)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import db
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
app_with_fake_gitea, tmp_env, provision_user_row, sign_in_as,
|
||||
)
|
||||
|
||||
|
||||
def test_create_collection_commits_manifest_and_mirrors(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben",
|
||||
role="owner", email="ben@test")
|
||||
r = client.post("/api/projects/default/collections",
|
||||
json={"collection_id": "features", "type": "bdd", "name": "Features"})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["type"] == "bdd"
|
||||
|
||||
# The bot committed the manifest to the content repo's main.
|
||||
f = fake.files.get(("wiggleverse", "meta", "main", "features/.collection.yaml"))
|
||||
assert f is not None
|
||||
assert "type: bdd" in f["content"]
|
||||
|
||||
# The registry refresh mirrored it into a collections row.
|
||||
row = db.conn().execute(
|
||||
"SELECT type, project_id, subfolder FROM collections WHERE id='features'"
|
||||
).fetchone()
|
||||
assert (row["type"], row["project_id"], row["subfolder"]) == ("bdd", "default", "features")
|
||||
|
||||
# It is now navigable via the directory + scoped serve.
|
||||
items = client.get("/api/projects/default/collections").json()["items"]
|
||||
assert any(c["id"] == "features" for c in items)
|
||||
assert client.get("/api/projects/default/collections/features/rfcs").status_code == 200
|
||||
|
||||
|
||||
def test_create_collection_requires_admin(app_with_fake_gitea):
|
||||
app, _ = 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/projects/default/collections",
|
||||
json={"collection_id": "x", "type": "bdd"})
|
||||
assert r.status_code in (401, 403)
|
||||
|
||||
|
||||
def test_create_collection_anonymous_rejected(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
r = client.post("/api/projects/default/collections",
|
||||
json={"collection_id": "x", "type": "bdd"})
|
||||
assert r.status_code in (401, 403)
|
||||
|
||||
|
||||
def test_create_collection_rejects_duplicate(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben",
|
||||
role="owner", email="ben@test")
|
||||
ok = client.post("/api/projects/default/collections",
|
||||
json={"collection_id": "features", "type": "bdd"})
|
||||
assert ok.status_code == 200, ok.text
|
||||
dup = client.post("/api/projects/default/collections",
|
||||
json={"collection_id": "features", "type": "bdd"})
|
||||
assert dup.status_code == 409
|
||||
|
||||
|
||||
def test_create_collection_rejects_reserved_default_id(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben",
|
||||
role="owner", email="ben@test")
|
||||
r = client.post("/api/projects/default/collections",
|
||||
json={"collection_id": "default", "type": "bdd"})
|
||||
assert r.status_code == 422
|
||||
@@ -0,0 +1,64 @@
|
||||
"""§22 S2 — collection read helpers: list_collections / get_collection /
|
||||
subfolder_of."""
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from app import collections as collections_mod, db
|
||||
from app.config import Config
|
||||
|
||||
|
||||
def _db() -> Config:
|
||||
cfg = Config(
|
||||
gitea_url="x", gitea_bot_user="x", gitea_bot_token="x", gitea_org="x",
|
||||
registry_repo="registry", oauth_client_id="x",
|
||||
oauth_client_secret="x", app_url="x", secret_key="x",
|
||||
database_path=Path(tempfile.mkdtemp(prefix="colhelp-")) / "t.db",
|
||||
owner_gitea_login="x", webhook_secret="x",
|
||||
)
|
||||
db.run_migrations(cfg)
|
||||
if db._CONN is not None:
|
||||
db._CONN.close()
|
||||
db._CONN = None
|
||||
db.init(cfg)
|
||||
return cfg
|
||||
|
||||
|
||||
def _seed(project_id="ohm"):
|
||||
db.conn().execute(
|
||||
"INSERT OR REPLACE INTO projects (id, name, content_repo, visibility, updated_at) "
|
||||
"VALUES (?, 'Ohm', 'ohm-rfc', 'public', datetime('now'))", (project_id,))
|
||||
for cid, sub, vis, name in [
|
||||
("default", "", "public", "Model"),
|
||||
("features", "features", "public", "Features"),
|
||||
("secret", "secret", "unlisted", "Secret"),
|
||||
]:
|
||||
db.conn().execute(
|
||||
"INSERT OR REPLACE INTO collections (id, project_id, type, subfolder, initial_state, "
|
||||
"visibility, name, created_at, updated_at) VALUES (?,?, 'document', ?, "
|
||||
"'super-draft', ?, ?, datetime('now'), datetime('now'))",
|
||||
(cid, project_id, sub, vis, name))
|
||||
|
||||
|
||||
def test_list_collections_excludes_unlisted():
|
||||
_db()
|
||||
_seed()
|
||||
ids = [c["id"] for c in collections_mod.list_collections("ohm", include_unlisted=False)]
|
||||
assert ids == ["default", "features"] # default first, then by name; 'secret' omitted
|
||||
|
||||
|
||||
def test_list_collections_include_unlisted():
|
||||
_db()
|
||||
_seed()
|
||||
ids = {c["id"] for c in collections_mod.list_collections("ohm", include_unlisted=True)}
|
||||
assert ids == {"default", "features", "secret"}
|
||||
|
||||
|
||||
def test_get_collection_and_subfolder():
|
||||
_db()
|
||||
_seed()
|
||||
assert collections_mod.get_collection("features")["name"] == "Features"
|
||||
assert collections_mod.subfolder_of("features") == "features"
|
||||
assert collections_mod.subfolder_of("default") == ""
|
||||
assert collections_mod.get_collection("nope") is None
|
||||
@@ -0,0 +1,146 @@
|
||||
"""§22 S2 — the registry mirror reads `.collection.yaml` manifests inside each
|
||||
project's content repo and upserts a named collection per manifest. The default
|
||||
collection still flows from projects.yaml (test_registry.py)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app import db, registry
|
||||
from app.config import Config
|
||||
|
||||
|
||||
def _db() -> Config:
|
||||
cfg = Config(
|
||||
gitea_url="x", gitea_bot_user="x", gitea_bot_token="x", gitea_org="wiggleverse",
|
||||
registry_repo="registry", oauth_client_id="x",
|
||||
oauth_client_secret="x", app_url="x", secret_key="x",
|
||||
database_path=Path(tempfile.mkdtemp(prefix="colreg-")) / "t.db",
|
||||
owner_gitea_login="x", webhook_secret="x",
|
||||
)
|
||||
db.run_migrations(cfg)
|
||||
if db._CONN is not None:
|
||||
db._CONN.close()
|
||||
db._CONN = None
|
||||
db.init(cfg)
|
||||
return cfg
|
||||
|
||||
|
||||
# --- pure parser --------------------------------------------------------------
|
||||
|
||||
|
||||
def test_parse_collection_manifest_minimal():
|
||||
doc = registry.parse_collection_manifest("type: bdd\n")
|
||||
assert doc.type == "bdd"
|
||||
# §22.4b: bdd defaults to 'active'; visibility inherits (None == inherit).
|
||||
assert doc.initial_state == "active"
|
||||
assert doc.visibility is None
|
||||
assert doc.name is None
|
||||
|
||||
|
||||
def test_parse_collection_manifest_full():
|
||||
doc = registry.parse_collection_manifest(
|
||||
"type: document\nvisibility: public\ninitial_state: active\nname: Model\n"
|
||||
)
|
||||
assert (doc.type, doc.visibility, doc.initial_state, doc.name) == (
|
||||
"document", "public", "active", "Model",
|
||||
)
|
||||
|
||||
|
||||
def test_parse_collection_manifest_rejects_bad_type():
|
||||
with pytest.raises(registry.RegistryError):
|
||||
registry.parse_collection_manifest("type: nonsense\n")
|
||||
|
||||
|
||||
def test_parse_collection_manifest_rejects_bad_visibility():
|
||||
with pytest.raises(registry.RegistryError):
|
||||
registry.parse_collection_manifest("type: bdd\nvisibility: nope\n")
|
||||
|
||||
|
||||
# --- mirror discovery ---------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeGitea:
|
||||
"""Minimal Gitea stub: projects.yaml in the registry repo + a content repo
|
||||
whose root holds a `features/` subdir carrying a `.collection.yaml`."""
|
||||
|
||||
def __init__(self, projects_yaml: str, repo_tree: dict[str, dict[str, str]]):
|
||||
self._projects_yaml = projects_yaml
|
||||
self._repo_tree = repo_tree # {repo: {path: text}}
|
||||
|
||||
async def get_contents(self, org, repo, path, ref="main"):
|
||||
if path == "projects.yaml":
|
||||
return {"type": "file",
|
||||
"content": base64.b64encode(self._projects_yaml.encode()).decode(),
|
||||
"sha": "regsha-test"}
|
||||
text = self._repo_tree.get(repo, {}).get(path)
|
||||
if text is None:
|
||||
return None
|
||||
return {"type": "file",
|
||||
"content": base64.b64encode(text.encode()).decode(), "sha": "c0ffee"}
|
||||
|
||||
async def list_dir(self, org, repo, path, ref="main"):
|
||||
# Root listing: surface each top-level segment as a 'dir' entry.
|
||||
prefix = (path.rstrip("/") + "/") if path else ""
|
||||
dirs = set()
|
||||
for p in self._repo_tree.get(repo, {}):
|
||||
if not p.startswith(prefix):
|
||||
continue
|
||||
rest = p[len(prefix):]
|
||||
if "/" in rest:
|
||||
dirs.add(rest.split("/", 1)[0])
|
||||
return [{"type": "dir", "name": n, "path": prefix + n} for n in sorted(dirs)]
|
||||
|
||||
|
||||
_PROJECTS = (
|
||||
"deployment:\n name: Ohm\n tagline: t\n"
|
||||
"projects:\n - id: ohm\n name: Ohm\n type: document\n"
|
||||
" content_repo: ohm-rfc\n visibility: public\n"
|
||||
)
|
||||
|
||||
|
||||
def test_refresh_registry_mirrors_named_collection():
|
||||
cfg = _db()
|
||||
gitea = _FakeGitea(
|
||||
projects_yaml=_PROJECTS,
|
||||
repo_tree={"ohm-rfc": {"features/.collection.yaml": "type: bdd\nname: Features\n"}},
|
||||
)
|
||||
asyncio.run(registry.refresh_registry(cfg, gitea))
|
||||
row = db.conn().execute(
|
||||
"SELECT type, subfolder, name, project_id, visibility FROM collections WHERE id='features'"
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
assert (row["type"], row["subfolder"], row["project_id"]) == ("bdd", "features", "ohm")
|
||||
assert row["name"] == "Features"
|
||||
# visibility inherits the project's (public) when the manifest omits it.
|
||||
assert row["visibility"] == "public"
|
||||
|
||||
|
||||
def test_refresh_registry_leaves_default_collection_intact():
|
||||
cfg = _db()
|
||||
gitea = _FakeGitea(
|
||||
projects_yaml=_PROJECTS,
|
||||
repo_tree={"ohm-rfc": {"features/.collection.yaml": "type: bdd\n"}},
|
||||
)
|
||||
asyncio.run(registry.refresh_registry(cfg, gitea))
|
||||
# The default collection (from projects.yaml) and the named one coexist.
|
||||
ids = {r["id"] for r in db.conn().execute("SELECT id FROM collections")}
|
||||
assert {"default", "features"} <= ids
|
||||
|
||||
|
||||
def test_refresh_registry_immutable_type_on_named_collection():
|
||||
cfg = _db()
|
||||
gitea = _FakeGitea(
|
||||
projects_yaml=_PROJECTS,
|
||||
repo_tree={"ohm-rfc": {"features/.collection.yaml": "type: bdd\n"}},
|
||||
)
|
||||
asyncio.run(registry.refresh_registry(cfg, gitea))
|
||||
# A later manifest that flips the type is refused (§22.4a immutable type).
|
||||
gitea._repo_tree["ohm-rfc"]["features/.collection.yaml"] = "type: document\n"
|
||||
asyncio.run(registry.refresh_registry(cfg, gitea))
|
||||
t = db.conn().execute("SELECT type FROM collections WHERE id='features'").fetchone()["type"]
|
||||
assert t == "bdd"
|
||||
@@ -0,0 +1,164 @@
|
||||
"""§22 S2 — collection-grained corpus mirror + collection-scoped serve/propose.
|
||||
|
||||
The mirror test drives cache.refresh_meta_repo against an in-memory content repo
|
||||
holding entries under both the default `rfcs/` and a named collection's
|
||||
`features/rfcs/`, and asserts cached_rfcs is keyed by the right collection_id."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from app import cache, db
|
||||
from app.config import Config
|
||||
|
||||
|
||||
def _db() -> Config:
|
||||
cfg = Config(
|
||||
gitea_url="x", gitea_bot_user="x", gitea_bot_token="x", gitea_org="wiggleverse",
|
||||
registry_repo="registry", oauth_client_id="x",
|
||||
oauth_client_secret="x", app_url="x", secret_key="x",
|
||||
database_path=Path(tempfile.mkdtemp(prefix="colserve-")) / "t.db",
|
||||
owner_gitea_login="x", webhook_secret="x",
|
||||
)
|
||||
db.run_migrations(cfg)
|
||||
if db._CONN is not None:
|
||||
db._CONN.close()
|
||||
db._CONN = None
|
||||
db.init(cfg)
|
||||
return cfg
|
||||
|
||||
|
||||
class _CorpusGitea:
|
||||
"""A content repo modelled as a flat {path: text} map, listing files under a
|
||||
directory prefix and reading them back."""
|
||||
|
||||
def __init__(self, tree: dict[str, str]):
|
||||
self._tree = tree
|
||||
|
||||
async def list_dir(self, org, repo, path, ref="main"):
|
||||
out = []
|
||||
prefix = (path.rstrip("/") + "/") if path else ""
|
||||
for p in self._tree:
|
||||
if p.startswith(prefix) and "/" not in p[len(prefix):]:
|
||||
out.append({"type": "file", "name": p.split("/")[-1], "path": p})
|
||||
return out
|
||||
|
||||
async def read_file(self, org, repo, path, ref="main"):
|
||||
t = self._tree.get(path)
|
||||
return (t, "sha-" + path) if t is not None else None
|
||||
|
||||
|
||||
def _entry_md(slug, title):
|
||||
return f"---\nslug: {slug}\ntitle: {title}\nstate: active\n---\nbody\n"
|
||||
|
||||
|
||||
def _seed_project_with_two_collections():
|
||||
db.conn().execute(
|
||||
"INSERT OR REPLACE INTO projects (id, name, content_repo, visibility, updated_at) "
|
||||
"VALUES ('ohm','Ohm','ohm-rfc','public', datetime('now'))")
|
||||
for cid, sub in [("default", ""), ("features", "features")]:
|
||||
db.conn().execute(
|
||||
"INSERT OR REPLACE INTO collections (id, project_id, type, subfolder, initial_state, "
|
||||
"visibility, created_at, updated_at) VALUES (?, 'ohm','document',?, "
|
||||
"'super-draft','public', datetime('now'), datetime('now'))", (cid, sub))
|
||||
|
||||
|
||||
def test_mirror_keys_entries_by_collection():
|
||||
cfg = _db()
|
||||
_seed_project_with_two_collections()
|
||||
gitea = _CorpusGitea({
|
||||
"rfcs/a.md": _entry_md("a", "Default A"),
|
||||
"features/rfcs/b.md": _entry_md("b", "Feature B"),
|
||||
})
|
||||
asyncio.run(cache.refresh_meta_repo(cfg, gitea))
|
||||
got = {(r["collection_id"], r["slug"]) for r in
|
||||
db.conn().execute("SELECT collection_id, slug FROM cached_rfcs")}
|
||||
assert got == {("default", "a"), ("features", "b")}
|
||||
|
||||
|
||||
# --- collection-scoped serve + propose (full app) -----------------------------
|
||||
|
||||
from fastapi.testclient import TestClient # noqa: E402
|
||||
from test_propose_vertical import ( # noqa: E402,F401
|
||||
app_with_fake_gitea, tmp_env, provision_user_row, sign_in_as,
|
||||
)
|
||||
|
||||
|
||||
def _add_features_collection(content_repo="meta"):
|
||||
"""Add a named 'features' collection (subfolder 'features') under the seeded
|
||||
default project, plus a single entry under features/rfcs/ in the db cache."""
|
||||
db.conn().execute(
|
||||
"INSERT OR REPLACE INTO collections (id, project_id, type, subfolder, "
|
||||
"initial_state, visibility, name, created_at, updated_at) VALUES "
|
||||
"('features','default','document','features','super-draft','public','Features', "
|
||||
"datetime('now'), datetime('now'))")
|
||||
|
||||
|
||||
def test_scoped_list_returns_only_that_collection(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_add_features_collection()
|
||||
# Seed one entry under each collection's rfcs dir + mirror them in.
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/a.md")] = {
|
||||
"content": _entry_md("a", "Default A"), "sha": "sa"}
|
||||
fake.files[("wiggleverse", "meta", "main", "features/rfcs/b.md")] = {
|
||||
"content": _entry_md("b", "Feature B"), "sha": "sb"}
|
||||
from app import cache as cache_mod, gitea as gitea_mod
|
||||
from app.config import load_config
|
||||
cfg = load_config()
|
||||
asyncio.run(cache_mod.refresh_meta_repo(cfg, gitea_mod.Gitea(cfg)))
|
||||
|
||||
r = client.get("/api/projects/default/collections/features/rfcs")
|
||||
assert r.status_code == 200, r.text
|
||||
assert [i["slug"] for i in r.json()["items"]] == ["b"]
|
||||
# The default collection still serves only its own entry.
|
||||
r2 = client.get("/api/projects/default/collections/default/rfcs")
|
||||
assert [i["slug"] for i in r2.json()["items"]] == ["a"]
|
||||
|
||||
|
||||
def test_scoped_propose_writes_into_collection_subfolder(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_add_features_collection()
|
||||
provision_user_row(user_id=3, login="alice", role="contributor")
|
||||
# §22 S3: an explicitly-created collection requires an explicit scope
|
||||
# grant to write (the grandfathered baseline covers only `default`).
|
||||
db.conn().execute(
|
||||
"INSERT OR REPLACE INTO memberships (scope_type, scope_id, user_id, role) "
|
||||
"VALUES ('collection', 'features', 3, 'contributor')")
|
||||
sign_in_as(client, user_id=3, gitea_login="alice", display_name="Alice",
|
||||
role="contributor", email="alice@test")
|
||||
r = client.post(
|
||||
"/api/projects/default/collections/features/rfcs/propose",
|
||||
json={"title": "New B", "slug": "newb", "pitch": "x", "tags": []})
|
||||
assert r.status_code == 200, r.text
|
||||
# The bot wrote the entry under features/rfcs/, not rfcs/.
|
||||
keys = {(k[1], k[3]) for k in fake.files
|
||||
if k[1] == "meta" and k[3].endswith("newb.md")}
|
||||
assert ("meta", "features/rfcs/newb.md") in keys
|
||||
|
||||
|
||||
def test_scoped_routes_404_for_collection_outside_project(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/api/projects/default/collections/nope/rfcs")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_s2_anonymous_empty_public_collection(app_with_fake_gitea):
|
||||
"""C3.6 (@S2): a public collection with no entries; an anonymous visitor
|
||||
lands on its catalog → an empty catalog (200, no items), and the propose
|
||||
action is not available to them (the propose route rejects anonymous)."""
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_add_features_collection() # public, no entries
|
||||
# Anonymous (no session cookie) reads the empty catalog — 200, [].
|
||||
r = client.get("/api/projects/default/collections/features/rfcs")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["items"] == []
|
||||
# No propose action for an anonymous visitor.
|
||||
r2 = client.post(
|
||||
"/api/projects/default/collections/features/rfcs/propose",
|
||||
json={"title": "X", "slug": "x", "pitch": "p", "tags": []})
|
||||
assert r2.status_code == 401
|
||||
@@ -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,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,384 @@
|
||||
"""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, app_with_fake_gitea): # noqa: F811
|
||||
"""Provide a hook the test can call to install a MockTransport.
|
||||
|
||||
Returns a closure: `install(handler)` patches `httpx.AsyncClient`
|
||||
(via `app.docs_sessions.httpx.AsyncClient`) so every constructed
|
||||
client uses the handler's transport.
|
||||
|
||||
M3 note: lifespan now calls `refresh_registry` which hits FakeGitea
|
||||
via the gitea transport. Since `httpx` is a module singleton, installing
|
||||
the docs transport would clobber the FakeGitea mock already installed
|
||||
by `app_with_fake_gitea`. We use a COMPOSITE handler: Gitea API
|
||||
requests (to `http://gitea.test/`) are delegated to FakeGitea; all
|
||||
other requests go to the test-specific handler.
|
||||
"""
|
||||
from httpx._client import AsyncClient as RealAsyncClient
|
||||
|
||||
_fake = app_with_fake_gitea[1]
|
||||
|
||||
def install(handler):
|
||||
def composite(request: httpx.Request) -> httpx.Response:
|
||||
if "gitea.test" in str(request.url):
|
||||
return _fake.handle(request)
|
||||
return handler(request)
|
||||
|
||||
def patched(*args, **kwargs):
|
||||
kwargs["transport"] = httpx.MockTransport(composite)
|
||||
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,478 @@
|
||||
"""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, app_with_fake_gitea): # noqa: F811
|
||||
"""Provide a hook the test can call to install a MockTransport.
|
||||
|
||||
M3 note: lifespan now calls `refresh_registry` which hits FakeGitea
|
||||
via the gitea transport. Since `httpx` is a module singleton, installing
|
||||
the docs transport would clobber the FakeGitea mock already installed
|
||||
by `app_with_fake_gitea`. We use a COMPOSITE handler: Gitea API
|
||||
requests (to `http://gitea.test/`) are delegated to FakeGitea; all
|
||||
other requests go to the test-specific handler.
|
||||
"""
|
||||
from httpx._client import AsyncClient as RealAsyncClient
|
||||
|
||||
_fake = app_with_fake_gitea[1]
|
||||
|
||||
def install(handler):
|
||||
def composite(request: httpx.Request) -> httpx.Response:
|
||||
if "gitea.test" in str(request.url):
|
||||
return _fake.handle(request)
|
||||
return handler(request)
|
||||
|
||||
def patched(*args, **kwargs):
|
||||
kwargs["transport"] = httpx.MockTransport(composite)
|
||||
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"
|
||||
@@ -0,0 +1,42 @@
|
||||
"""§22.4c — the unreviewed/reviewed_at/reviewed_by entry frontmatter fields."""
|
||||
from __future__ import annotations
|
||||
|
||||
from app import entry as entry_mod
|
||||
|
||||
|
||||
def test_parse_defaults_unreviewed_false_when_absent():
|
||||
text = "---\nslug: ohm\ntitle: OHM\nstate: active\n---\n\nBody.\n"
|
||||
e = entry_mod.parse(text)
|
||||
assert e.unreviewed is False
|
||||
assert e.reviewed_at is None
|
||||
assert e.reviewed_by is None
|
||||
|
||||
|
||||
def test_parse_reads_review_fields():
|
||||
text = (
|
||||
"---\nslug: ohm\ntitle: OHM\nstate: active\n"
|
||||
"unreviewed: true\nreviewed_at: '2026-06-03'\nreviewed_by: ben\n---\n\nBody.\n"
|
||||
)
|
||||
e = entry_mod.parse(text)
|
||||
assert e.unreviewed is True
|
||||
assert e.reviewed_at == "2026-06-03"
|
||||
assert e.reviewed_by == "ben"
|
||||
|
||||
|
||||
def test_serialize_emits_review_fields_only_when_meaningful():
|
||||
e = entry_mod.Entry(slug="a", title="A", state="super-draft")
|
||||
assert "unreviewed" not in entry_mod.serialize(e)
|
||||
assert "reviewed_at" not in entry_mod.serialize(e)
|
||||
e2 = entry_mod.Entry(slug="b", title="B", state="active", unreviewed=True)
|
||||
assert "unreviewed: true" in entry_mod.serialize(e2)
|
||||
|
||||
|
||||
def test_round_trip_preserves_review_fields():
|
||||
e = entry_mod.Entry(
|
||||
slug="b", title="B", state="active",
|
||||
unreviewed=False, reviewed_at="2026-06-03", reviewed_by="ben",
|
||||
)
|
||||
back = entry_mod.parse(entry_mod.serialize(e))
|
||||
assert back.reviewed_at == "2026-06-03"
|
||||
assert back.reviewed_by == "ben"
|
||||
assert back.unreviewed is False
|
||||
@@ -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,139 @@ 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_graduate_check_accepts_blank_id(app_with_fake_gitea):
|
||||
"""§13.2 (optional number): a blank id is VALID — it means "graduate
|
||||
without a number." `can_submit` stays true (owners are set), and an
|
||||
absent `id` param behaves the same as an explicit empty string."""
|
||||
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")
|
||||
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")
|
||||
|
||||
# Explicit empty id.
|
||||
d = client.get("/api/rfcs/ohm/graduate/check", params={"id": ""}).json()
|
||||
assert d["id"]["ok"] is True
|
||||
assert d["id"]["error"] is None
|
||||
assert d["can_submit"] is True
|
||||
|
||||
# No id param at all — same default-accepted shape.
|
||||
d = client.get("/api/rfcs/ohm/graduate/check").json()
|
||||
assert d["id"]["ok"] is True
|
||||
assert d["can_submit"] is True
|
||||
|
||||
# A malformed (non-blank) id is still rejected.
|
||||
d = client.get("/api/rfcs/ohm/graduate/check", params={"id": "RFC-xx"}).json()
|
||||
assert d["id"]["ok"] is False
|
||||
assert d["can_submit"] is False
|
||||
|
||||
|
||||
def test_graduate_without_number_flips_to_active_null_id_by_slug(app_with_fake_gitea):
|
||||
"""§13.2/§13.3 (optional number): graduating with a blank id flips the
|
||||
entry to `active` with `id: null`. The slug is the canonical identifier;
|
||||
the catalog shows the entry as active with no number, and the audit row
|
||||
records rfc_id null."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db, entry as entry_mod
|
||||
|
||||
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="Open Human Model",
|
||||
pitch=PITCH, owners=["ben"], arbiters=["ben"])
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner", email="ben@test")
|
||||
|
||||
# Blank rfc_id → graduate without a number.
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/graduate?_sync=1",
|
||||
json={"rfc_id": "", "owners": ["ben"]},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
d = r.json()
|
||||
assert d["succeeded"] is True
|
||||
assert d["rfc_id"] is None
|
||||
|
||||
# Meta entry: active, id null, body kept, graduation stamped.
|
||||
meta_text = fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
|
||||
graduated = entry_mod.parse(meta_text)
|
||||
assert graduated.state == "active"
|
||||
assert graduated.id is None
|
||||
assert graduated.graduated_by == "ben"
|
||||
assert graduated.graduated_at
|
||||
assert "Open Human Model is a framework" in graduated.body
|
||||
|
||||
# Cache flipped to active with a null rfc_id.
|
||||
cached = db.conn().execute(
|
||||
"SELECT state, rfc_id FROM cached_rfcs WHERE slug = 'ohm'"
|
||||
).fetchone()
|
||||
assert cached["state"] == "active"
|
||||
assert cached["rfc_id"] is None
|
||||
|
||||
# Catalog: present as active, identified by slug (no number).
|
||||
items = client.get("/api/rfcs").json()["items"]
|
||||
ohm = next(i for i in items if i["slug"] == "ohm")
|
||||
assert ohm["state"] == "active"
|
||||
assert ohm["id"] is None
|
||||
|
||||
# Audit: graduate_complete with rfc_id null.
|
||||
complete = db.conn().execute(
|
||||
"""
|
||||
SELECT details FROM actions
|
||||
WHERE rfc_slug = 'ohm' AND action_kind = 'graduate_complete'
|
||||
ORDER BY id DESC LIMIT 1
|
||||
"""
|
||||
).fetchone()
|
||||
assert complete is not None
|
||||
assert _json.loads(complete["details"])["rfc_id"] is None
|
||||
|
||||
|
||||
def test_graduate_with_number_unchanged_when_id_absent_field(app_with_fake_gitea):
|
||||
"""Omitting the rfc_id field entirely is treated the same as blank —
|
||||
graduates without a number — so older clients that drop the field don't
|
||||
break, and the supplied-number path stays exactly as before."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import entry as entry_mod
|
||||
|
||||
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")
|
||||
|
||||
r = client.post("/api/rfcs/ohm/graduate?_sync=1", json={"owners": ["ben"]})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["rfc_id"] is None
|
||||
graduated = entry_mod.parse(
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
|
||||
)
|
||||
assert graduated.state == "active"
|
||||
assert graduated.id is None
|
||||
|
||||
|
||||
def test_claim_opens_meta_pr(app_with_fake_gitea):
|
||||
@@ -554,12 +649,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 == [], (
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"""§22.4b — a project with initial_state='active' lands new entries active +
|
||||
unreviewed; the default 'super-draft' project is unchanged."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
app_with_fake_gitea, tmp_env, provision_user_row, sign_in_as,
|
||||
)
|
||||
|
||||
|
||||
def _propose(client):
|
||||
return client.post("/api/rfcs/propose", json={
|
||||
"title": "Active Lander", "slug": "active-lander",
|
||||
"pitch": "Lands active.", "tags": [],
|
||||
})
|
||||
|
||||
|
||||
def test_super_draft_default_unchanged(app_with_fake_gitea):
|
||||
from app import entry as entry_mod
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
|
||||
assert _propose(client).status_code == 200
|
||||
f = fake.files[("wiggleverse", "meta", "propose/active-lander", "rfcs/active-lander.md")]
|
||||
e = entry_mod.parse(f["content"])
|
||||
assert e.state == "super-draft"
|
||||
assert e.unreviewed is False
|
||||
|
||||
|
||||
def test_active_initial_state_lands_active_unreviewed(app_with_fake_gitea):
|
||||
from app import db, entry as entry_mod
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
db.conn().execute("UPDATE collections SET initial_state='active' WHERE project_id='default'")
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
|
||||
assert _propose(client).status_code == 200
|
||||
f = fake.files[("wiggleverse", "meta", "propose/active-lander", "rfcs/active-lander.md")]
|
||||
e = entry_mod.parse(f["content"])
|
||||
assert e.state == "active"
|
||||
assert e.unreviewed is True
|
||||
@@ -0,0 +1,65 @@
|
||||
"""§22.4c — owner/admin mark-reviewed clears the flag; catalog unreviewed filter."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
app_with_fake_gitea, tmp_env, provision_user_row, sign_in_as,
|
||||
)
|
||||
|
||||
|
||||
def _seed_unreviewed_active(fake, slug="feat"):
|
||||
"""Put an active+unreviewed entry on the meta repo main + cache."""
|
||||
from app import cache, entry as entry_mod
|
||||
body = entry_mod.serialize(entry_mod.Entry(
|
||||
slug=slug, title="Feat", state="active", unreviewed=True,
|
||||
owners=["ben"], proposed_by="ben",
|
||||
))
|
||||
fake.files[("wiggleverse", "meta", "main", f"rfcs/{slug}.md")] = {"content": body, "sha": "s1"}
|
||||
cache._upsert_cached_rfc(entry_mod.parse(body), body_sha="s1")
|
||||
|
||||
|
||||
def test_catalog_unreviewed_filter(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_unreviewed_active(fake, "feat")
|
||||
from app import cache, entry as entry_mod
|
||||
ok = entry_mod.serialize(entry_mod.Entry(slug="ok", title="OK", state="active", owners=["ben"]))
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/ok.md")] = {"content": ok, "sha": "s2"}
|
||||
cache._upsert_cached_rfc(entry_mod.parse(ok), body_sha="s2")
|
||||
r = client.get("/api/rfcs", params={"unreviewed": "true"})
|
||||
slugs = {i["slug"] for i in r.json()["items"]}
|
||||
assert slugs == {"feat"}
|
||||
|
||||
|
||||
def test_mark_reviewed_clears_flag(app_with_fake_gitea):
|
||||
from app import db
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_unreviewed_active(fake, "feat")
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
|
||||
r = client.post("/api/projects/default/rfcs/feat/mark-reviewed")
|
||||
assert r.status_code == 200
|
||||
row = db.conn().execute(
|
||||
"SELECT unreviewed, reviewed_at, reviewed_by FROM cached_rfcs WHERE slug='feat'"
|
||||
).fetchone()
|
||||
assert row["unreviewed"] == 0
|
||||
assert row["reviewed_by"] == "ben"
|
||||
assert row["reviewed_at"] # provenance stamped
|
||||
# git-side: the entry file on main was rewritten with the cleared flag.
|
||||
from app import entry as entry_mod
|
||||
written = fake.files[("wiggleverse", "meta", "main", "rfcs/feat.md")]["content"]
|
||||
e = entry_mod.parse(written)
|
||||
assert e.unreviewed is False
|
||||
assert e.reviewed_by == "ben"
|
||||
|
||||
|
||||
def test_mark_reviewed_forbidden_for_non_superuser(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_unreviewed_active(fake, "feat")
|
||||
provision_user_row(user_id=2, login="carol", role="contributor")
|
||||
sign_in_as(client, user_id=2, gitea_login="carol", display_name="Carol", role="contributor")
|
||||
r = client.post("/api/projects/default/rfcs/feat/mark-reviewed")
|
||||
assert r.status_code == 403
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Migration 027 — additive §22 M3 schema (projects.type/initial_state, the
|
||||
deployment singleton, cached_rfcs review columns). No table rebuilds in Plan A."""
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from app import db
|
||||
from app.config import Config
|
||||
|
||||
|
||||
def _fresh_config() -> Config:
|
||||
tmp = Path(tempfile.mkdtemp(prefix="mig027-")) / "t.db"
|
||||
return Config(
|
||||
gitea_url="x", gitea_bot_user="x", gitea_bot_token="x", gitea_org="x",
|
||||
registry_repo="registry",
|
||||
oauth_client_id="x", oauth_client_secret="x", app_url="x",
|
||||
secret_key="x", database_path=tmp, owner_gitea_login="x",
|
||||
webhook_secret="x",
|
||||
)
|
||||
|
||||
|
||||
def test_027_adds_project_type_and_initial_state():
|
||||
# 027 added type/initial_state to `projects`; migration 029 (three-tier)
|
||||
# moved those per-corpus fields *down* onto `collections`. After the full
|
||||
# migration chain they live on the collection, not the project.
|
||||
cfg = _fresh_config()
|
||||
db.run_migrations(cfg)
|
||||
conn = db.connect(cfg.database_path)
|
||||
proj_cols = {r["name"] for r in conn.execute("PRAGMA table_info(projects)")}
|
||||
assert "type" not in proj_cols and "initial_state" not in proj_cols
|
||||
coll_cols = {r["name"]: r for r in conn.execute("PRAGMA table_info(collections)")}
|
||||
assert "type" in coll_cols and coll_cols["type"]["dflt_value"] == "'document'"
|
||||
assert "initial_state" in coll_cols and coll_cols["initial_state"]["dflt_value"] == "'super-draft'"
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_027_creates_deployment_singleton():
|
||||
cfg = _fresh_config()
|
||||
db.run_migrations(cfg)
|
||||
conn = db.connect(cfg.database_path)
|
||||
rows = list(conn.execute("SELECT id FROM deployment"))
|
||||
assert [r["id"] for r in rows] == [1]
|
||||
try:
|
||||
conn.execute("INSERT INTO deployment (id) VALUES (2)")
|
||||
raised = False
|
||||
except Exception:
|
||||
raised = True
|
||||
assert raised
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_027_adds_review_columns_to_cached_rfcs():
|
||||
cfg = _fresh_config()
|
||||
db.run_migrations(cfg)
|
||||
conn = db.connect(cfg.database_path)
|
||||
cols = {r["name"] for r in conn.execute("PRAGMA table_info(cached_rfcs)")}
|
||||
assert {"unreviewed", "reviewed_at", "reviewed_by"} <= cols
|
||||
conn.close()
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Migration 029 — the collection grain beneath projects (§22 three-tier S1).
|
||||
|
||||
Proves: a `collections` table exists with one default collection per project
|
||||
(id='default', subfolder=repo root); the per-corpus fields (type, initial_state)
|
||||
moved off `projects`; the 13 entry-corpus tables re-key (project_id,slug) ->
|
||||
(collection_id,slug) with the composite PK/FK enforced; and project_members
|
||||
generalises into memberships(scope_type, …) with the role enum collapsed.
|
||||
Template: test_migration_028_project_scoped_keys.py.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app import db
|
||||
|
||||
|
||||
class _Cfg:
|
||||
def __init__(self, path):
|
||||
self.database_path = path
|
||||
|
||||
|
||||
def _fresh_db():
|
||||
d = tempfile.mkdtemp()
|
||||
path = Path(d) / "t.db"
|
||||
db.run_migrations(_Cfg(str(path)))
|
||||
return db.connect(str(path))
|
||||
|
||||
|
||||
def test_collections_table_exists_with_default_per_project():
|
||||
conn = _fresh_db()
|
||||
cols = {r["name"] for r in conn.execute("PRAGMA table_info(collections)")}
|
||||
assert {"id", "project_id", "type", "subfolder",
|
||||
"initial_state", "visibility", "name", "registry_sha"} <= cols
|
||||
# one default collection seeded for the bootstrap 'default' project (026)
|
||||
row = conn.execute(
|
||||
"SELECT id, project_id, subfolder FROM collections WHERE project_id='default'"
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
assert row["id"] == "default"
|
||||
assert row["subfolder"] == "" # repo root
|
||||
|
||||
|
||||
def test_per_corpus_fields_moved_off_projects():
|
||||
conn = _fresh_db()
|
||||
proj_cols = {r["name"] for r in conn.execute("PRAGMA table_info(projects)")}
|
||||
assert "type" not in proj_cols
|
||||
assert "initial_state" not in proj_cols
|
||||
# projects keeps the grouping-tier fields
|
||||
assert {"id", "name", "content_repo", "visibility"} <= proj_cols
|
||||
|
||||
|
||||
def test_entry_tables_rekeyed_to_collection_id():
|
||||
conn = _fresh_db()
|
||||
for t in ("cached_rfcs", "cached_branches", "stars", "watches",
|
||||
"rfc_collaborators", "contribution_requests", "proposed_use_cases",
|
||||
"branch_visibility", "branch_contribute_grants", "pr_seen",
|
||||
"branch_chat_seen", "funder_consents", "rfc_invitations"):
|
||||
cols = {r["name"] for r in conn.execute(f"PRAGMA table_info({t})")}
|
||||
assert "collection_id" in cols, f"{t} missing collection_id"
|
||||
assert "project_id" not in cols, f"{t} still has project_id"
|
||||
|
||||
|
||||
def test_cached_rfcs_pk_is_collection_slug():
|
||||
conn = _fresh_db()
|
||||
# a second collection under the default project
|
||||
conn.execute(
|
||||
"INSERT INTO collections (id, project_id, type, subfolder, initial_state, visibility, name) "
|
||||
"VALUES ('c2','default','document','specs','active','public','Specs')"
|
||||
)
|
||||
conn.execute("INSERT INTO cached_rfcs (slug, title, state, collection_id) VALUES ('intro','A','active','default')")
|
||||
conn.execute("INSERT INTO cached_rfcs (slug, title, state, collection_id) VALUES ('intro','B','active','c2')")
|
||||
n = conn.execute("SELECT COUNT(*) c FROM cached_rfcs WHERE slug='intro'").fetchone()["c"]
|
||||
assert n == 2
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
conn.execute("INSERT INTO cached_rfcs (slug, title, state, collection_id) VALUES ('intro','dup','active','default')")
|
||||
|
||||
|
||||
def test_cached_rfcs_collection_fk_enforced():
|
||||
conn = _fresh_db()
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
conn.execute("INSERT INTO cached_rfcs (slug, title, state, collection_id) VALUES ('x','X','active','nope')")
|
||||
|
||||
|
||||
def test_collaborator_fk_is_composite_on_collection():
|
||||
conn = _fresh_db()
|
||||
conn.execute(
|
||||
"INSERT INTO collections (id, project_id, type, subfolder, initial_state, visibility, name) "
|
||||
"VALUES ('c2','default','document','specs','active','public','Specs')"
|
||||
)
|
||||
conn.execute("INSERT INTO cached_rfcs (slug, title, state, collection_id) VALUES ('intro','A','active','c2')")
|
||||
conn.execute("INSERT INTO users (id, gitea_login, display_name, role) VALUES (1,'a','A','contributor')")
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
conn.execute(
|
||||
"INSERT INTO rfc_collaborators (rfc_slug, user_id, role_in_rfc, collection_id) "
|
||||
"VALUES ('intro',1,'contributor','c2')"
|
||||
)
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
# same slug, a collection with no such entry — composite FK rejects
|
||||
conn.execute(
|
||||
"INSERT INTO rfc_collaborators (rfc_slug, user_id, role_in_rfc, collection_id) "
|
||||
"VALUES ('intro',1,'contributor','default')"
|
||||
)
|
||||
|
||||
|
||||
def test_stars_unique_now_scoped_by_collection():
|
||||
conn = _fresh_db()
|
||||
conn.execute(
|
||||
"INSERT INTO collections (id, project_id, type, subfolder, initial_state, visibility, name) "
|
||||
"VALUES ('c2','default','document','specs','active','public','Specs')"
|
||||
)
|
||||
conn.execute("INSERT INTO users (id, gitea_login, display_name, role) VALUES (1,'a','A','contributor')")
|
||||
conn.execute("INSERT INTO cached_rfcs (slug, title, state, collection_id) VALUES ('intro','A','active','default')")
|
||||
conn.execute("INSERT INTO cached_rfcs (slug, title, state, collection_id) VALUES ('intro','B','active','c2')")
|
||||
conn.execute("INSERT INTO stars (user_id, rfc_slug, collection_id) VALUES (1,'intro','default')")
|
||||
conn.execute("INSERT INTO stars (user_id, rfc_slug, collection_id) VALUES (1,'intro','c2')")
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
conn.execute("INSERT INTO stars (user_id, rfc_slug, collection_id) VALUES (1,'intro','default')")
|
||||
|
||||
|
||||
def test_memberships_table_replaces_project_members():
|
||||
conn = _fresh_db()
|
||||
cols = {r["name"] for r in conn.execute("PRAGMA table_info(memberships)")}
|
||||
assert {"scope_type", "scope_id", "user_id", "role", "granted_by", "granted_at"} <= cols
|
||||
# project_members is gone
|
||||
assert conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='project_members'"
|
||||
).fetchone() is None
|
||||
conn.execute("INSERT INTO users (id, gitea_login, display_name, role) VALUES (9,'x','X','contributor')")
|
||||
conn.execute("INSERT INTO memberships (scope_type, scope_id, user_id, role) VALUES ('project','default',9,'owner')")
|
||||
# scope_type and role are CHECK-constrained
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
conn.execute("INSERT INTO memberships (scope_type, scope_id, user_id, role) VALUES ('bogus','default',9,'owner')")
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
conn.execute("INSERT INTO memberships (scope_type, scope_id, user_id, role) VALUES ('project','default',9,'viewer')")
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Migration 030 — the global-scope grant (§22 three-tier S3).
|
||||
|
||||
Proves: `memberships.scope_type` now admits 'global' alongside 'project' and
|
||||
'collection' (§B.2's four-layer resolver), existing rows survive the rebuild,
|
||||
and the UNIQUE(scope_type, scope_id, user_id) shape is preserved.
|
||||
Template: test_migration_029_collections.py.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app import db
|
||||
|
||||
|
||||
class _Cfg:
|
||||
def __init__(self, path):
|
||||
self.database_path = path
|
||||
|
||||
|
||||
def _fresh_db():
|
||||
d = tempfile.mkdtemp()
|
||||
path = Path(d) / "t.db"
|
||||
db.run_migrations(_Cfg(str(path)))
|
||||
return db.connect(str(path))
|
||||
|
||||
|
||||
def _add_user(conn, uid, login):
|
||||
conn.execute(
|
||||
"INSERT INTO users (id, gitea_id, gitea_login, display_name, role) "
|
||||
"VALUES (?, ?, ?, ?, 'contributor')",
|
||||
(uid, uid, login, login.capitalize()),
|
||||
)
|
||||
|
||||
|
||||
def test_global_scope_type_is_accepted():
|
||||
conn = _fresh_db()
|
||||
_add_user(conn, 1, "cleo")
|
||||
# global grant — the new tier — is accepted.
|
||||
conn.execute(
|
||||
"INSERT INTO memberships (scope_type, scope_id, user_id, role) "
|
||||
"VALUES ('global', '*', 1, 'contributor')"
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT scope_type, scope_id, role FROM memberships WHERE user_id = 1"
|
||||
).fetchone()
|
||||
assert row["scope_type"] == "global"
|
||||
assert row["scope_id"] == "*"
|
||||
assert row["role"] == "contributor"
|
||||
|
||||
|
||||
def test_project_and_collection_scopes_still_accepted():
|
||||
conn = _fresh_db()
|
||||
_add_user(conn, 1, "ben")
|
||||
conn.execute(
|
||||
"INSERT INTO memberships (scope_type, scope_id, user_id, role) "
|
||||
"VALUES ('project', 'default', 1, 'owner')"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO memberships (scope_type, scope_id, user_id, role) "
|
||||
"VALUES ('collection', 'default', 1, 'contributor')"
|
||||
)
|
||||
n = conn.execute("SELECT COUNT(*) AS n FROM memberships WHERE user_id = 1").fetchone()["n"]
|
||||
assert n == 2
|
||||
|
||||
|
||||
def test_unknown_scope_type_still_rejected():
|
||||
conn = _fresh_db()
|
||||
_add_user(conn, 1, "x")
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
conn.execute(
|
||||
"INSERT INTO memberships (scope_type, scope_id, user_id, role) "
|
||||
"VALUES ('deployment', '*', 1, 'owner')"
|
||||
)
|
||||
|
||||
|
||||
def test_one_global_grant_per_user():
|
||||
conn = _fresh_db()
|
||||
_add_user(conn, 1, "cleo")
|
||||
conn.execute(
|
||||
"INSERT INTO memberships (scope_type, scope_id, user_id, role) "
|
||||
"VALUES ('global', '*', 1, 'contributor')"
|
||||
)
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
conn.execute(
|
||||
"INSERT INTO memberships (scope_type, scope_id, user_id, role) "
|
||||
"VALUES ('global', '*', 1, 'owner')"
|
||||
)
|
||||
@@ -0,0 +1,358 @@
|
||||
"""Slice M2 — project-scoped authorization + the §22.7 resolver.
|
||||
|
||||
M1 laid the schema spine (the `projects` / `project_members` tables and the
|
||||
`project_id` column on every slug-bearing table). M2 builds the resolver on top:
|
||||
the most-permissive union of the deployment role (§6.1), the project role
|
||||
(§22.6), and the per-RFC authority (§6.3/§12), with the §22.5 visibility gate
|
||||
subtractive on top (§22.7).
|
||||
|
||||
Two operator decisions are pinned here as executable expectations:
|
||||
|
||||
* implicit-on-public — a granted deployment `contributor` keeps its
|
||||
pre-multi-project write *baseline* on a `public` project (propose freely;
|
||||
an owned RFC's discuss/contribute still needs the per-RFC invite) with no
|
||||
project_members row. So the single public default project behaves exactly
|
||||
as it did before M2 (verified across the rest of the suite, and the
|
||||
`*_public_*` tests below).
|
||||
* preserve curation — the implicit-public baseline does NOT override per-RFC
|
||||
owner curation; only an *explicit* project_contributor/admin grant (or a
|
||||
deployment owner/admin) bypasses it.
|
||||
|
||||
Everything is exercised on the single `default` project by flipping its
|
||||
visibility and granting/revoking `project_members` roles — the M2 slice is
|
||||
verifiable without a second project (which arrives with M3's registry mirror).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401 — fixtures land via import
|
||||
app_with_fake_gitea,
|
||||
provision_user_row,
|
||||
sign_in_as,
|
||||
tmp_env,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _su(user_id: int, login: str, role: str, *, state: str = "granted"):
|
||||
"""A SessionUser handle for direct resolver calls (the endpoint tests use
|
||||
sign_in_as instead)."""
|
||||
from app import auth
|
||||
|
||||
return auth.SessionUser(
|
||||
user_id=user_id,
|
||||
gitea_id=user_id,
|
||||
gitea_login=login,
|
||||
display_name=login.capitalize(),
|
||||
email=f"{login}@test",
|
||||
avatar_url="",
|
||||
role=role,
|
||||
permission_state=state,
|
||||
)
|
||||
|
||||
|
||||
def _set_visibility(project_id: str, visibility: str) -> None:
|
||||
from app import db
|
||||
|
||||
db.conn().execute(
|
||||
"UPDATE projects SET visibility = ? WHERE id = ?", (visibility, project_id)
|
||||
)
|
||||
|
||||
|
||||
# §22 three-tier (§B.3): M2's three project roles collapse to {owner,
|
||||
# contributor} in the unified `memberships` table at the project's default
|
||||
# collection. The read-only `viewer` tier is deferred (folded into contributor
|
||||
# for this pass), so the legacy role names map: admin→owner, contributor and
|
||||
# viewer→contributor.
|
||||
_ROLE_MAP = {
|
||||
"project_admin": "owner",
|
||||
"project_contributor": "contributor",
|
||||
"project_viewer": "contributor",
|
||||
}
|
||||
|
||||
|
||||
def _add_member(project_id: str, user_id: int, role: str) -> None:
|
||||
from app import collections as collections_mod, db
|
||||
|
||||
cid = collections_mod.default_collection_id(project_id)
|
||||
db.conn().execute(
|
||||
"INSERT OR REPLACE INTO memberships (scope_type, scope_id, user_id, role) "
|
||||
"VALUES ('collection', ?, ?, ?)",
|
||||
(cid, user_id, _ROLE_MAP[role]),
|
||||
)
|
||||
|
||||
|
||||
def _remove_member(project_id: str, user_id: int) -> None:
|
||||
from app import collections as collections_mod, db
|
||||
|
||||
cid = collections_mod.default_collection_id(project_id)
|
||||
db.conn().execute(
|
||||
"DELETE FROM memberships WHERE scope_type = 'collection' AND scope_id = ? AND user_id = ?",
|
||||
(cid, user_id),
|
||||
)
|
||||
|
||||
|
||||
def _seed_rfc(slug: str, *, state: str = "active", owners=None, project_id: str = "default") -> None:
|
||||
"""A minimal cached_rfcs row — enough for the authz gates (state, owners,
|
||||
collection grain). The entry lands in the project's default collection
|
||||
(id == project_id for the single 'default' project under test)."""
|
||||
import json
|
||||
|
||||
from app import collections as collections_mod, db
|
||||
|
||||
cid = collections_mod.default_collection_id(project_id)
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO cached_rfcs
|
||||
(slug, title, state, owners_json, arbiters_json, tags_json, collection_id)
|
||||
VALUES (?, ?, ?, ?, '[]', '[]', ?)
|
||||
""",
|
||||
(slug, slug.capitalize(), state, json.dumps(owners or []), cid),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. The §22.7 resolver — tier composition
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_resolver_public_project_tiers(app_with_fake_gitea):
|
||||
from app import auth
|
||||
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
provision_user_row(user_id=1, login="alice", role="contributor")
|
||||
provision_user_row(user_id=2, login="ben", role="owner")
|
||||
contributor = _su(1, "alice", "contributor")
|
||||
owner = _su(2, "ben", "owner")
|
||||
|
||||
# default is public — read open to everyone incl. anonymous.
|
||||
assert auth.can_read_project(None, "default") is True
|
||||
assert auth.can_read_project(contributor, "default") is True
|
||||
|
||||
# implicit-on-public: a granted deployment contributor carries the
|
||||
# write baseline without a project_members row.
|
||||
assert auth.can_contribute_in_project(contributor, "default") is True
|
||||
assert auth.can_discuss_in_project(contributor, "default") is True
|
||||
|
||||
# ... but it is NOT project_admin (curation/override authority).
|
||||
assert auth.is_project_superuser(contributor, "default") is False
|
||||
# a deployment owner/admin is a superuser in every project.
|
||||
assert auth.is_project_superuser(owner, "default") is True
|
||||
|
||||
# a pending contributor has no write standing (the §6 admission floor).
|
||||
pending = _su(1, "alice", "contributor", state="pending")
|
||||
assert auth.can_contribute_in_project(pending, "default") is False
|
||||
|
||||
|
||||
def test_resolver_gated_project_requires_membership(app_with_fake_gitea):
|
||||
from app import auth
|
||||
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
provision_user_row(user_id=1, login="alice", role="contributor")
|
||||
provision_user_row(user_id=2, login="ben", role="owner")
|
||||
_set_visibility("default", "gated")
|
||||
contributor = _su(1, "alice", "contributor")
|
||||
owner = _su(2, "ben", "owner")
|
||||
|
||||
# gated: no membership → invisible and no standing.
|
||||
assert auth.can_read_project(None, "default") is False
|
||||
assert auth.can_read_project(contributor, "default") is False
|
||||
assert auth.can_contribute_in_project(contributor, "default") is False
|
||||
|
||||
# deployment owner is a superuser regardless of membership.
|
||||
assert auth.can_read_project(owner, "default") is True
|
||||
assert auth.is_project_superuser(owner, "default") is True
|
||||
|
||||
# §22 three-tier (§B.3): the read-only viewer tier is deferred — the
|
||||
# smallest grant is `contributor`, which grants read + discuss +
|
||||
# contribute across the subtree.
|
||||
_add_member("default", 1, "project_contributor")
|
||||
assert auth.can_read_project(contributor, "default") is True
|
||||
assert auth.can_discuss_in_project(contributor, "default") is True
|
||||
assert auth.can_contribute_in_project(contributor, "default") is True
|
||||
assert auth.is_project_superuser(contributor, "default") is False
|
||||
|
||||
# project_admin → owner → superuser within the project.
|
||||
_add_member("default", 1, "project_admin")
|
||||
assert auth.is_project_superuser(contributor, "default") is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Public default unchanged — the regression floor (curation preserved)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_public_per_rfc_curation_preserved(app_with_fake_gitea):
|
||||
"""On the public default project a granted contributor still cannot
|
||||
discuss an *owned* RFC without a per-RFC invite (the v0.16.0 contract);
|
||||
but an unclaimed (no-owners) entry stays open. This is the implicit-public
|
||||
baseline with curation preserved."""
|
||||
from app import auth
|
||||
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
provision_user_row(user_id=1, login="alice", role="contributor")
|
||||
provision_user_row(user_id=2, login="bob", role="contributor")
|
||||
bob = _su(2, "bob", "contributor")
|
||||
|
||||
_seed_rfc("owned", state="active", owners=["alice"])
|
||||
assert auth.can_discuss_rfc(bob, "owned") is False
|
||||
assert auth.can_contribute_to_rfc(bob, "owned") is False
|
||||
|
||||
_seed_rfc("draft", state="super-draft", owners=[])
|
||||
assert auth.can_discuss_rfc(bob, "draft") is True
|
||||
assert auth.can_contribute_to_rfc(bob, "draft") is True
|
||||
|
||||
|
||||
def test_public_read_open_write_gated_endpoints(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="bob", role="contributor")
|
||||
_seed_rfc("ohm", state="active", owners=["alice"])
|
||||
|
||||
# anonymous can read the entry on a public project.
|
||||
r = client.get("/api/rfcs/ohm")
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# anonymous cannot open a discussion thread (401, the §6 write floor).
|
||||
r = client.post("/api/rfcs/ohm/discussion/threads", json={"message": "hi"})
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. The §22.5 visibility gate — 404 to non-members on read
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_gated_project_404s_non_members(app_with_fake_gitea):
|
||||
app, _ = 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")
|
||||
provision_user_row(user_id=3, login="ben", role="owner")
|
||||
_seed_rfc("sekret", state="active", owners=["alice"])
|
||||
_set_visibility("default", "gated")
|
||||
|
||||
# anonymous → 404 (indistinguishable from an unknown slug).
|
||||
assert client.get("/api/rfcs/sekret").status_code == 404
|
||||
|
||||
# signed-in non-member → 404 on the entry and its discussion.
|
||||
sign_in_as(client, user_id=2, gitea_login="bob", display_name="Bob", role="contributor")
|
||||
assert client.get("/api/rfcs/sekret").status_code == 404
|
||||
assert client.get("/api/rfcs/sekret/discussion/threads").status_code == 404
|
||||
# the gated entry never surfaces in the non-member's catalog.
|
||||
assert client.get("/api/rfcs").json()["items"] == []
|
||||
|
||||
# a project_viewer member can read again.
|
||||
_add_member("default", 2, "project_viewer")
|
||||
assert client.get("/api/rfcs/sekret").status_code == 200
|
||||
assert [i["slug"] for i in client.get("/api/rfcs").json()["items"]] == ["sekret"]
|
||||
|
||||
# a deployment owner can always read.
|
||||
sign_in_as(client, user_id=3, gitea_login="ben", display_name="Ben", role="owner")
|
||||
assert client.get("/api/rfcs/sekret").status_code == 200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. The contribution gate on a gated project (401/403)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_gated_propose_requires_project_contributor(app_with_fake_gitea):
|
||||
"""Propose checks project-level contribute standing before any Gitea
|
||||
work, so the gate is observable as a 403 (gated, non-member) without
|
||||
seeding the success path."""
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="bob", role="contributor")
|
||||
_set_visibility("default", "gated")
|
||||
sign_in_as(client, user_id=2, gitea_login="bob", display_name="Bob", role="contributor")
|
||||
|
||||
body = {"slug": "newidea", "title": "New Idea", "pitch": "A pitch.", "tags": []}
|
||||
assert client.post("/api/rfcs/propose", json=body).status_code == 403
|
||||
|
||||
# grant project_contributor — the project gate now passes (the request
|
||||
# proceeds past the gate; we assert only that it is no longer 403).
|
||||
_add_member("default", 2, "project_contributor")
|
||||
assert client.post("/api/rfcs/propose", json=body).status_code != 403
|
||||
|
||||
|
||||
def test_gated_member_can_discuss_and_contribute(app_with_fake_gitea):
|
||||
from app import auth
|
||||
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="bob", role="contributor")
|
||||
_seed_rfc("spec", state="super-draft", owners=[])
|
||||
_set_visibility("default", "gated")
|
||||
bob = _su(2, "bob", "contributor")
|
||||
|
||||
# non-member: discussion thread create → 404 (visibility, before the
|
||||
# write gate even applies).
|
||||
sign_in_as(client, user_id=2, gitea_login="bob", display_name="Bob", role="contributor")
|
||||
assert client.post("/api/rfcs/spec/discussion/threads", json={"message": "q"}).status_code == 404
|
||||
|
||||
# §22 three-tier (§B.3): a `contributor` member can both discuss and
|
||||
# contribute (the viewer-only read tier is deferred this pass).
|
||||
_add_member("default", 2, "project_contributor")
|
||||
r = client.post("/api/rfcs/spec/discussion/threads", json={"message": "q"})
|
||||
assert r.status_code == 200, r.text
|
||||
assert auth.can_contribute_to_rfc(bob, "spec") is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Union of tiers + subtractive visibility gate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_per_rfc_authority_unions_then_yields_to_visibility(app_with_fake_gitea):
|
||||
from app import auth
|
||||
from test_propose_vertical import grant_rfc_collaborator
|
||||
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
provision_user_row(user_id=2, login="bob", role="contributor")
|
||||
_seed_rfc("owned", state="active", owners=["alice"])
|
||||
bob = _su(2, "bob", "contributor")
|
||||
|
||||
# public + per-RFC collaborator(contributor) → contribute (union term).
|
||||
grant_rfc_collaborator(user_id=2, rfc_slug="owned", role_in_rfc="contributor")
|
||||
assert auth.can_contribute_to_rfc(bob, "owned") is True
|
||||
|
||||
# flip to gated: the §22.5 gate is subtractive — the per-RFC grant no
|
||||
# longer suffices without project membership.
|
||||
_set_visibility("default", "gated")
|
||||
assert auth.can_contribute_to_rfc(bob, "owned") is False
|
||||
|
||||
# restore read via project membership → the per-RFC union applies again.
|
||||
_add_member("default", 2, "project_viewer")
|
||||
assert auth.can_contribute_to_rfc(bob, "owned") is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. Revocation takes effect on the next call
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_revoking_membership_revokes_access(app_with_fake_gitea):
|
||||
from app import auth
|
||||
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
provision_user_row(user_id=2, login="bob", role="contributor")
|
||||
_set_visibility("default", "gated")
|
||||
bob = _su(2, "bob", "contributor")
|
||||
|
||||
_add_member("default", 2, "project_contributor")
|
||||
assert auth.can_contribute_in_project(bob, "default") is True
|
||||
|
||||
_remove_member("default", 2)
|
||||
assert auth.can_read_project(bob, "default") is False
|
||||
assert auth.can_contribute_in_project(bob, "default") is False
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Slice M1+M3 — the §22 multi-project spine.
|
||||
|
||||
Migration 026 introduces the `projects` and `project_members` tables, seeds
|
||||
the single `default` project (the N=1 case, §22.13), and threads a
|
||||
`project_id` column onto every slug-bearing table, backfilled to `default`.
|
||||
M3 retires the META_REPO startup backfill; content_repo now comes from the
|
||||
registry mirror (projects.yaml in REGISTRY_REPO). These tests prove the
|
||||
spine lands without disturbing the single-project app.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
app_with_fake_gitea,
|
||||
tmp_env,
|
||||
)
|
||||
|
||||
# §22 three-tier (migration 029): the entry-corpus grain is the collection, so
|
||||
# the 13 tables migration 028 keyed by project_id re-key to collection_id. The
|
||||
# remaining tables 026 tagged keep their denormalised project_id (project grain).
|
||||
COLLECTION_TABLES = [
|
||||
"cached_rfcs", "cached_branches", "branch_visibility",
|
||||
"branch_contribute_grants", "stars", "pr_seen", "branch_chat_seen",
|
||||
"watches", "funder_consents", "rfc_invitations", "rfc_collaborators",
|
||||
"proposed_use_cases", "contribution_requests",
|
||||
]
|
||||
PROJECT_TAG_TABLES = [
|
||||
"cached_prs", "threads", "changes", "notifications", "actions",
|
||||
"pr_resolution_branches",
|
||||
]
|
||||
|
||||
|
||||
def test_default_project_seeded_and_backfilled(app_with_fake_gitea):
|
||||
from app import db
|
||||
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
rows = list(db.conn().execute(
|
||||
"SELECT id, name, visibility, content_repo FROM projects"
|
||||
))
|
||||
assert len(rows) == 1
|
||||
row = rows[0]
|
||||
assert row["id"] == "default"
|
||||
# public preserves the pre-multi-project open-by-default posture.
|
||||
assert row["visibility"] == "public"
|
||||
# M3: content_repo now comes from the registry mirror (projects.yaml),
|
||||
# not the retired META_REPO startup backfill.
|
||||
assert row["content_repo"] == "meta"
|
||||
|
||||
|
||||
def test_grain_columns_on_every_slug_table(app_with_fake_gitea):
|
||||
from app import db
|
||||
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
# The entry-corpus tables key on collection_id (NOT NULL, 'default').
|
||||
for table in COLLECTION_TABLES:
|
||||
cols = {r["name"]: r for r in db.conn().execute(f"PRAGMA table_info({table})")}
|
||||
assert "collection_id" in cols, f"{table} missing collection_id"
|
||||
assert "project_id" not in cols, f"{table} should no longer have project_id"
|
||||
col = cols["collection_id"]
|
||||
assert col["notnull"] == 1, f"{table}.collection_id should be NOT NULL"
|
||||
assert col["dflt_value"] == "'default'", f"{table}.collection_id default"
|
||||
# The project-tag tables keep their denormalised project_id.
|
||||
for table in PROJECT_TAG_TABLES:
|
||||
cols = {r["name"] for r in db.conn().execute(f"PRAGMA table_info({table})")}
|
||||
assert "project_id" in cols, f"{table} missing project_id tag"
|
||||
|
||||
|
||||
def test_existing_row_backfills_to_default(app_with_fake_gitea):
|
||||
"""A row inserted the old way (no collection grain) lands in the default
|
||||
collection — the trick that keeps every pre-three-tier INSERT working."""
|
||||
from app import db
|
||||
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
db.conn().execute(
|
||||
"INSERT INTO cached_rfcs (slug, title, state) VALUES (?, ?, ?)",
|
||||
("human", "Human", "active"),
|
||||
)
|
||||
got = db.conn().execute(
|
||||
"SELECT collection_id FROM cached_rfcs WHERE slug = 'human'"
|
||||
).fetchone()["collection_id"]
|
||||
assert got == "default"
|
||||
|
||||
|
||||
def test_memberships_table_shape(app_with_fake_gitea):
|
||||
from app import db
|
||||
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
# §22 three-tier: project_members generalised into memberships.
|
||||
assert db.conn().execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='project_members'"
|
||||
).fetchone() is None
|
||||
cols = {r["name"] for r in db.conn().execute("PRAGMA table_info(memberships)")}
|
||||
assert {"scope_type", "scope_id", "user_id", "role", "granted_by", "granted_at"} <= cols
|
||||
db.conn().execute(
|
||||
"INSERT INTO users (id, display_name, role) VALUES (1, 'Ben', 'owner')"
|
||||
)
|
||||
db.conn().execute(
|
||||
"INSERT INTO memberships (scope_type, scope_id, user_id, role) "
|
||||
"VALUES ('collection', 'default', 1, 'owner')"
|
||||
)
|
||||
import sqlite3
|
||||
try:
|
||||
db.conn().execute(
|
||||
"INSERT INTO memberships (scope_type, scope_id, user_id, role) "
|
||||
"VALUES ('collection', 'default', 1, 'nonsense')"
|
||||
)
|
||||
assert False, "CHECK should reject an unknown role"
|
||||
except sqlite3.IntegrityError:
|
||||
pass
|
||||
|
||||
|
||||
def test_registry_mirror_is_idempotent(app_with_fake_gitea):
|
||||
"""Re-running the registry mirror is safe — it upserts (overwrites) the
|
||||
projects row from projects.yaml each time without raising. M3 retirement
|
||||
of seed_default_project: the registry mirror is now the sole authority."""
|
||||
import asyncio
|
||||
from app import db, registry as registry_mod
|
||||
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
before = db.conn().execute("SELECT COUNT(*) AS n FROM projects").fetchone()["n"]
|
||||
cfg = app.state.config
|
||||
gitea = app.state.gitea
|
||||
asyncio.run(registry_mod.refresh_registry(cfg, gitea))
|
||||
after = db.conn().execute("SELECT COUNT(*) AS n FROM projects").fetchone()["n"]
|
||||
assert after == before
|
||||
row = db.conn().execute(
|
||||
"SELECT content_repo FROM projects WHERE id='default'"
|
||||
).fetchone()
|
||||
assert row["content_repo"] == "meta"
|
||||
|
||||
|
||||
def test_registry_repo_config_wired(app_with_fake_gitea, monkeypatch):
|
||||
from app.config import load_config
|
||||
|
||||
monkeypatch.setenv("REGISTRY_REPO", "wiggleverse-registry")
|
||||
assert load_config().registry_repo == "wiggleverse-registry"
|
||||
# M3: REGISTRY_REPO is now required — absent raises RuntimeError.
|
||||
monkeypatch.delenv("REGISTRY_REPO", raising=False)
|
||||
import pytest
|
||||
with pytest.raises(RuntimeError, match="REGISTRY_REPO"):
|
||||
load_config()
|
||||
@@ -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
|
||||
|
||||
@@ -347,3 +347,53 @@ def test_otc_re_request_invalidates_prior_unused_code(app_with_fake_gitea, monke
|
||||
# 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)
|
||||
@@ -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")
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""§22.4 (Plan B write): proposing a new entry into a *specific* project lands
|
||||
it in that project's content repo and surfaces under that project's proposals,
|
||||
isolated from the default project."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
app_with_fake_gitea, tmp_env, provision_user_row, sign_in_as,
|
||||
)
|
||||
|
||||
|
||||
def _register_ecomm(fake):
|
||||
from app import db
|
||||
db.conn().execute(
|
||||
"INSERT OR IGNORE INTO projects (id, name, content_repo, visibility) "
|
||||
"VALUES ('ecomm', 'Ecomm', 'ecomm-content', 'public')"
|
||||
)
|
||||
db.conn().execute(
|
||||
"INSERT OR IGNORE INTO collections (id, project_id, type, subfolder, initial_state, visibility, name) "
|
||||
"VALUES ('ecomm', 'ecomm', 'document', '', 'super-draft', 'public', 'Ecomm')"
|
||||
)
|
||||
fake._seed_repo("wiggleverse", "ecomm-content")
|
||||
|
||||
|
||||
def test_propose_into_second_project_lands_scoped(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_register_ecomm(fake)
|
||||
provision_user_row(user_id=3, login="alice", role="contributor")
|
||||
# §22 S3: the grandfathered implicit-public baseline covers only the N=1
|
||||
# `default` collection; a second project requires an explicit scope grant
|
||||
# to write. Grant alice contributor at the ecomm project.
|
||||
from app import db
|
||||
db.conn().execute(
|
||||
"INSERT OR REPLACE INTO memberships (scope_type, scope_id, user_id, role) "
|
||||
"VALUES ('project', 'ecomm', 3, 'contributor')")
|
||||
sign_in_as(client, user_id=3, gitea_login="alice", display_name="Alice",
|
||||
role="contributor", email="alice@test")
|
||||
r = client.post("/api/projects/ecomm/rfcs/propose", json={
|
||||
"title": "Cart", "slug": "cart", "pitch": "why a cart", "tags": [],
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# The idea PR shows under ecomm's proposals, not the default's.
|
||||
e = {i["slug"] for i in client.get("/api/projects/ecomm/proposals").json()["items"]}
|
||||
d = {i["slug"] for i in client.get("/api/projects/default/proposals").json()["items"]}
|
||||
assert "cart" in e
|
||||
assert "cart" not in d
|
||||
|
||||
# It landed in ecomm's content repo, not the default 'meta' repo.
|
||||
assert ("wiggleverse", "ecomm-content") in {
|
||||
(o, rp) for (o, rp) in fake.branches if rp == "ecomm-content"
|
||||
}
|
||||
assert any(
|
||||
br.startswith("propose/cart")
|
||||
for br in fake.branches.get(("wiggleverse", "ecomm-content"), {})
|
||||
)
|
||||
|
||||
|
||||
def test_propose_into_gated_project_404s_for_non_member(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
from app import db
|
||||
with TestClient(app) as client:
|
||||
db.conn().execute(
|
||||
"INSERT OR IGNORE INTO projects (id, name, content_repo, visibility) "
|
||||
"VALUES ('secret', 'Secret', 'secret-content', 'gated')"
|
||||
)
|
||||
db.conn().execute(
|
||||
"INSERT OR IGNORE INTO collections (id, project_id, type, subfolder, initial_state, visibility, name) "
|
||||
"VALUES ('secret', 'secret', 'document', '', 'super-draft', 'gated', 'Secret')"
|
||||
)
|
||||
provision_user_row(user_id=4, login="bob", role="contributor")
|
||||
sign_in_as(client, user_id=4, gitea_login="bob", display_name="Bob",
|
||||
role="contributor", email="bob@test")
|
||||
r = client.post("/api/projects/secret/rfcs/propose", json={
|
||||
"title": "X", "slug": "x", "pitch": "p", "tags": [],
|
||||
})
|
||||
assert r.status_code == 404
|
||||
@@ -0,0 +1,74 @@
|
||||
"""§22.4 (Plan B) — per-project RFC serving. A second project's corpus renders
|
||||
under its own slug namespace via /api/projects/{pid}/rfcs[/{slug}], isolated
|
||||
from the default project and gated by §22.5 visibility."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
app_with_fake_gitea, tmp_env, provision_user_row, sign_in_as,
|
||||
)
|
||||
|
||||
|
||||
def _add_project(pid, name, vis="public"):
|
||||
# §22 three-tier: a project + its default collection (keyed by the project
|
||||
# id in tests, so default_collection_id(pid) == pid).
|
||||
from app import db
|
||||
db.conn().execute(
|
||||
"INSERT OR IGNORE INTO projects (id, name, content_repo, visibility) VALUES (?, ?, ?, ?)",
|
||||
(pid, name, pid + "-content", vis),
|
||||
)
|
||||
db.conn().execute(
|
||||
"INSERT OR IGNORE INTO collections (id, project_id, type, subfolder, initial_state, visibility, name) "
|
||||
"VALUES (?, ?, 'document', '', 'super-draft', ?, ?)",
|
||||
(pid, pid, vis, name),
|
||||
)
|
||||
|
||||
|
||||
def _add_rfc(slug, title, pid, state="active"):
|
||||
from app import db
|
||||
# entries key by the project's default collection (id == pid in these tests)
|
||||
db.conn().execute(
|
||||
"INSERT INTO cached_rfcs (slug, title, state, collection_id) VALUES (?, ?, ?, ?)",
|
||||
(slug, title, state, pid),
|
||||
)
|
||||
|
||||
|
||||
def test_catalog_scoped_to_one_project(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_add_project("ecomm", "Ecomm")
|
||||
_add_rfc("intro", "Default Intro", "default")
|
||||
_add_rfc("intro", "Ecomm Intro", "ecomm")
|
||||
_add_rfc("only-ecomm", "Ecomm Only", "ecomm")
|
||||
|
||||
d = client.get("/api/projects/default/rfcs").json()["items"]
|
||||
e = client.get("/api/projects/ecomm/rfcs").json()["items"]
|
||||
d_slugs = {i["slug"] for i in d}
|
||||
e_slugs = {i["slug"] for i in e}
|
||||
assert "intro" in d_slugs and "only-ecomm" not in d_slugs
|
||||
assert {"intro", "only-ecomm"} <= e_slugs
|
||||
|
||||
|
||||
def test_entry_is_isolated_by_project(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_add_project("ecomm", "Ecomm")
|
||||
_add_rfc("intro", "Default Intro", "default")
|
||||
_add_rfc("intro", "Ecomm Intro", "ecomm")
|
||||
_add_rfc("only-ecomm", "Ecomm Only", "ecomm")
|
||||
|
||||
assert client.get("/api/projects/default/rfcs/intro").json()["title"] == "Default Intro"
|
||||
assert client.get("/api/projects/ecomm/rfcs/intro").json()["title"] == "Ecomm Intro"
|
||||
# a slug that exists only in ecomm 404s under default
|
||||
assert client.get("/api/projects/ecomm/rfcs/only-ecomm").status_code == 200
|
||||
assert client.get("/api/projects/default/rfcs/only-ecomm").status_code == 404
|
||||
|
||||
|
||||
def test_gated_project_catalog_404s_for_anon(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_add_project("secret", "Secret", vis="gated")
|
||||
_add_rfc("hush", "Hush", "secret")
|
||||
assert client.get("/api/projects/secret/rfcs").status_code == 404
|
||||
assert client.get("/api/projects/secret/rfcs/hush").status_code == 404
|
||||
@@ -55,6 +55,24 @@ class FakeGitea:
|
||||
self._pr_counter = 0
|
||||
self._commit_counter = 0
|
||||
self._seed_repo("wiggleverse", "meta")
|
||||
# §22 M3: the deployment's project registry. Startup refresh_registry
|
||||
# reads projects.yaml here; the single 'default' project's content_repo
|
||||
# points back at the seeded meta repo so the corpus mirror is unchanged.
|
||||
self._seed_repo("wiggleverse", "registry")
|
||||
self.files[("wiggleverse", "registry", "main", "projects.yaml")] = {
|
||||
"content": (
|
||||
"deployment:\n"
|
||||
" name: Test Deployment\n"
|
||||
" tagline: A test deployment\n"
|
||||
"projects:\n"
|
||||
" - id: default\n"
|
||||
" name: Test Deployment\n"
|
||||
" type: document\n"
|
||||
" content_repo: meta\n"
|
||||
" visibility: public\n"
|
||||
),
|
||||
"sha": "regsha0001",
|
||||
}
|
||||
|
||||
def _seed_repo(self, owner, repo):
|
||||
self.branches[(owner, repo)] = {"main": {"sha": "initial", "ts": "2026-05-23T00:00:00Z"}}
|
||||
@@ -72,6 +90,28 @@ class FakeGitea:
|
||||
self._commit_counter += 1
|
||||
return f"sha{self._commit_counter:04d}"
|
||||
|
||||
def _dir_listing(self, owner, repo, ref, dirpath):
|
||||
"""Children directly under `dirpath` on (owner, repo, ref): files as
|
||||
`type: file` and immediate subdirectories as `type: dir` (the shape real
|
||||
Gitea returns for a contents listing)."""
|
||||
prefix = (dirpath.rstrip("/") + "/") if dirpath else ""
|
||||
files: dict[str, dict] = {}
|
||||
dirs: set[str] = set()
|
||||
for (o, r, br, p), data in self.files.items():
|
||||
if (o, r, br) != (owner, repo, ref) or not p.startswith(prefix):
|
||||
continue
|
||||
rest = p[len(prefix):]
|
||||
if "/" in rest:
|
||||
dirs.add(rest.split("/", 1)[0])
|
||||
elif rest:
|
||||
files[p] = data
|
||||
children = [{"name": n, "path": prefix + n, "type": "dir"} for n in sorted(dirs)]
|
||||
children += [
|
||||
{"name": p.rsplit("/", 1)[-1], "path": p, "type": "file", "sha": d["sha"]}
|
||||
for p, d in sorted(files.items())
|
||||
]
|
||||
return children
|
||||
|
||||
def _enrich_pr(self, owner: str, repo: str, pr: dict) -> dict:
|
||||
"""Return the PR with mergeability fields filled in.
|
||||
|
||||
@@ -209,6 +249,16 @@ class FakeGitea:
|
||||
}
|
||||
return httpx.Response(201, json={"name": new})
|
||||
|
||||
# GET /repos/{owner}/{repo}/contents (root listing, empty path). §22 S2:
|
||||
# the registry mirror walks the content-repo root for collection
|
||||
# subfolders, so the simulator models a root directory listing that
|
||||
# surfaces both file and `dir` children.
|
||||
m_root = re.fullmatch(r"/repos/([^/]+)/([^/]+)/contents/?", path)
|
||||
if method == "GET" and m_root:
|
||||
owner, repo = m_root.groups()
|
||||
ref = request.url.params.get("ref", "main")
|
||||
return httpx.Response(200, json=self._dir_listing(owner, repo, ref, ""))
|
||||
|
||||
# GET /repos/{owner}/{repo}/contents/{path}?ref=...
|
||||
m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/contents/(.+)", path)
|
||||
if method == "GET" and m:
|
||||
@@ -224,17 +274,8 @@ class FakeGitea:
|
||||
"sha": f["sha"],
|
||||
"content": base64.b64encode(f["content"].encode()).decode(),
|
||||
})
|
||||
# Directory listing
|
||||
prefix = fpath.rstrip("/") + "/"
|
||||
children = []
|
||||
for (o, r, br, p), data in self.files.items():
|
||||
if (o, r, br) == (owner, repo, ref) and p.startswith(prefix) and "/" not in p[len(prefix):]:
|
||||
children.append({
|
||||
"name": p.rsplit("/", 1)[-1],
|
||||
"path": p,
|
||||
"type": "file",
|
||||
"sha": data["sha"],
|
||||
})
|
||||
# Directory listing — both file and subdir children.
|
||||
children = self._dir_listing(owner, repo, ref, fpath)
|
||||
if children:
|
||||
return httpx.Response(200, json=children)
|
||||
return httpx.Response(404, json={"message": "not found"})
|
||||
@@ -395,6 +436,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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -409,15 +472,31 @@ def tmp_env(monkeypatch):
|
||||
"GITEA_BOT_USER": "rfc-bot",
|
||||
"GITEA_BOT_TOKEN": "bot-token",
|
||||
"GITEA_ORG": "wiggleverse",
|
||||
"META_REPO": "meta",
|
||||
"REGISTRY_REPO": "registry",
|
||||
"OAUTH_CLIENT_ID": "cid",
|
||||
"OAUTH_CLIENT_SECRET": "csec",
|
||||
"APP_URL": "http://localhost:8000",
|
||||
"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)
|
||||
@@ -539,6 +618,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
|
||||
|
||||
@@ -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,106 @@
|
||||
"""§22.2 registry parse + apply: validation, type-immutability, upsert."""
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app import db, registry
|
||||
from app.config import Config
|
||||
|
||||
|
||||
def _db():
|
||||
cfg = Config(
|
||||
gitea_url="x", gitea_bot_user="x", gitea_bot_token="x", gitea_org="x",
|
||||
registry_repo="registry", oauth_client_id="x",
|
||||
oauth_client_secret="x", app_url="x", secret_key="x",
|
||||
database_path=Path(tempfile.mkdtemp(prefix="reg-")) / "t.db",
|
||||
owner_gitea_login="x", webhook_secret="x",
|
||||
)
|
||||
db.run_migrations(cfg)
|
||||
if db._CONN is not None:
|
||||
db._CONN.close()
|
||||
db._CONN = None
|
||||
db.init(cfg)
|
||||
return cfg
|
||||
|
||||
|
||||
VALID = """
|
||||
deployment:
|
||||
name: Open Human Model
|
||||
tagline: A model of human flourishing
|
||||
projects:
|
||||
- id: default
|
||||
name: Open Human Model
|
||||
type: document
|
||||
content_repo: meta
|
||||
visibility: public
|
||||
"""
|
||||
|
||||
|
||||
def test_parse_valid_registry():
|
||||
doc = registry.parse_registry(VALID)
|
||||
assert doc.deployment_name == "Open Human Model"
|
||||
assert doc.deployment_tagline == "A model of human flourishing"
|
||||
assert len(doc.projects) == 1
|
||||
p = doc.projects[0]
|
||||
assert (p.id, p.type, p.content_repo, p.visibility) == ("default", "document", "meta", "public")
|
||||
assert p.initial_state == "super-draft"
|
||||
|
||||
|
||||
def test_parse_initial_state_defaults_per_type():
|
||||
doc = registry.parse_registry(
|
||||
"projects:\n - {id: a, name: A, type: bdd, content_repo: a}\n"
|
||||
)
|
||||
assert doc.projects[0].initial_state == "active" # bdd default
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad,msg", [
|
||||
("projects: []\n", "at least one"),
|
||||
("projects:\n - just-a-string\n", "must be a mapping"),
|
||||
("projects:\n - {id: 'Bad Slug', name: A, type: document, content_repo: a}\n", "valid slug"),
|
||||
("projects:\n - {id: a, name: A, type: nope, content_repo: a}\n", "invalid type"),
|
||||
("projects:\n - {id: a, name: A, type: document}\n", "content_repo"),
|
||||
("projects:\n - {id: a, name: A, type: document, content_repo: a, visibility: x}\n", "visibility"),
|
||||
("projects:\n - {id: a, name: A, type: document, content_repo: a}\n - {id: a, name: B, type: document, content_repo: b}\n", "duplicate"),
|
||||
])
|
||||
def test_parse_rejects_invalid(bad, msg):
|
||||
with pytest.raises(registry.RegistryError) as e:
|
||||
registry.parse_registry(bad)
|
||||
assert msg in str(e.value)
|
||||
|
||||
|
||||
def test_apply_upserts_projects_and_deployment():
|
||||
_db()
|
||||
doc = registry.parse_registry(VALID)
|
||||
registry.apply_registry(doc, registry_sha="regsha1", default_id="default")
|
||||
# §22 three-tier: the project carries the grouping-tier fields; the
|
||||
# per-corpus type/initial_state live on its default collection.
|
||||
prow = db.conn().execute(
|
||||
"SELECT name, content_repo, visibility, registry_sha FROM projects WHERE id='default'"
|
||||
).fetchone()
|
||||
assert prow["name"] == "Open Human Model"
|
||||
assert prow["content_repo"] == "meta"
|
||||
assert prow["registry_sha"] == "regsha1"
|
||||
crow = db.conn().execute(
|
||||
"SELECT type, initial_state FROM collections WHERE id='default'"
|
||||
).fetchone()
|
||||
assert crow["type"] == "document"
|
||||
assert crow["initial_state"] == "super-draft"
|
||||
drow = db.conn().execute("SELECT name, tagline FROM deployment WHERE id=1").fetchone()
|
||||
assert drow["name"] == "Open Human Model"
|
||||
assert drow["tagline"] == "A model of human flourishing"
|
||||
|
||||
|
||||
def test_apply_rejects_type_change_on_existing_project():
|
||||
_db()
|
||||
registry.apply_registry(registry.parse_registry(VALID), "s1", default_id="default")
|
||||
changed = VALID.replace("type: document", "type: specification")
|
||||
registry.apply_registry(registry.parse_registry(changed), "s2", default_id="default") # skipped
|
||||
# §22.4a immutable type — now enforced on the collection.
|
||||
t = db.conn().execute("SELECT type FROM collections WHERE id='default'").fetchone()["type"]
|
||||
assert t == "document" # immutable — unchanged
|
||||
# The deployment row IS still advanced even though the type change was skipped.
|
||||
drow = db.conn().execute("SELECT registry_sha FROM deployment WHERE id=1").fetchone()
|
||||
assert drow["registry_sha"] == "s2"
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Startup mirrors the registry; the registry webhook re-mirrors it."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from test_propose_vertical import app_with_fake_gitea, tmp_env # noqa: F401
|
||||
|
||||
|
||||
def test_startup_mirrors_registry_into_projects_and_deployment(app_with_fake_gitea):
|
||||
from app import db
|
||||
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app):
|
||||
prow = db.conn().execute(
|
||||
"SELECT content_repo FROM projects WHERE id='default'"
|
||||
).fetchone()
|
||||
assert prow["content_repo"] == "meta" # from the registry, not META_REPO
|
||||
# §22 three-tier: type now lives on the default collection.
|
||||
crow = db.conn().execute(
|
||||
"SELECT type FROM collections WHERE id='default'"
|
||||
).fetchone()
|
||||
assert crow["type"] == "document"
|
||||
drow = db.conn().execute("SELECT name FROM deployment WHERE id=1").fetchone()
|
||||
assert drow["name"] # deployment name mirrored from the registry
|
||||
|
||||
|
||||
def test_registry_webhook_remirrors(app_with_fake_gitea):
|
||||
import hashlib
|
||||
import hmac
|
||||
import json as _json
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
from app import db
|
||||
new_yaml = (
|
||||
"deployment:\n name: OHM\n tagline: Edited tagline\n"
|
||||
"projects:\n - id: default\n name: OHM\n type: document\n"
|
||||
" content_repo: meta\n visibility: public\n"
|
||||
)
|
||||
fake.files[("wiggleverse", "registry", "main", "projects.yaml")] = {
|
||||
"content": new_yaml, "sha": "regsha2",
|
||||
}
|
||||
body = _json.dumps({"repository": {"full_name": "wiggleverse/registry"}}).encode()
|
||||
secret = "test-webhook-secret-for-signature-verification"
|
||||
sig = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
|
||||
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
|
||||
tagline = db.conn().execute("SELECT tagline FROM deployment WHERE id=1").fetchone()["tagline"]
|
||||
assert tagline == "Edited tagline"
|
||||
@@ -0,0 +1,69 @@
|
||||
"""§22.13 step 1 — the bootstrap-id re-stamp: 'default' → the configured
|
||||
DEFAULT_PROJECT_ID. §22 three-tier (S1): the entry-corpus tables key on
|
||||
collection_id now, so the re-stamp renames the *project grain* — the
|
||||
`collections.project_id` link and the denormalised project_id tags — while the
|
||||
entries stay in their collection. The stale 'default' projects row is dropped,
|
||||
the composite FKs stay intact, and it is idempotent."""
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import app.db as db
|
||||
from app import projects
|
||||
|
||||
|
||||
class _Cfg:
|
||||
def __init__(self, path, default_id):
|
||||
self.database_path = path
|
||||
self.default_project_id = default_id
|
||||
|
||||
|
||||
def _setup(monkeypatch, default_id="ohm"):
|
||||
path = str(Path(tempfile.mkdtemp()) / "t.db")
|
||||
cfg = _Cfg(path, default_id)
|
||||
db.run_migrations(cfg) # seeds the bootstrap 'default' project + its default collection
|
||||
monkeypatch.setattr(db, "_CONN", db.connect(path))
|
||||
conn = db.conn()
|
||||
# A registry-mirrored 'ohm' project coexists with the bootstrap pre-restamp.
|
||||
conn.execute("INSERT OR IGNORE INTO projects (id,name,content_repo,visibility) "
|
||||
"VALUES ('ohm','Open Human Model','ohm-content','public')")
|
||||
conn.execute("INSERT INTO users (id,gitea_login,display_name,role) VALUES (1,'a','A','contributor')")
|
||||
# Entry data lives in the default collection (id='default'); the entry grain
|
||||
# is the collection and does not move on a re-stamp.
|
||||
conn.execute("INSERT INTO cached_rfcs (slug,title,state,collection_id) VALUES ('human','Human','active','default')")
|
||||
conn.execute("INSERT INTO rfc_collaborators (rfc_slug,user_id,role_in_rfc,collection_id) "
|
||||
"VALUES ('human',1,'contributor','default')")
|
||||
conn.execute("INSERT INTO stars (user_id,rfc_slug,collection_id) VALUES (1,'human','default')")
|
||||
return cfg, conn
|
||||
|
||||
|
||||
def test_restamp_moves_project_grain_and_drops_bootstrap_row(monkeypatch):
|
||||
cfg, conn = _setup(monkeypatch, default_id="ohm")
|
||||
projects.restamp_default_project(cfg)
|
||||
# The project grain (the collection's parent link) re-stamps to 'ohm'.
|
||||
assert conn.execute("SELECT COUNT(*) c FROM collections WHERE project_id='default'").fetchone()["c"] == 0
|
||||
assert conn.execute("SELECT project_id FROM collections WHERE id='default'").fetchone()["project_id"] == "ohm"
|
||||
# Entries stay in their collection — the collection_id is unchanged.
|
||||
assert conn.execute("SELECT collection_id FROM cached_rfcs WHERE slug='human'").fetchone()["collection_id"] == "default"
|
||||
assert conn.execute("SELECT collection_id FROM rfc_collaborators WHERE rfc_slug='human'").fetchone()["collection_id"] == "default"
|
||||
# stale bootstrap projects row removed; 'ohm' remains
|
||||
assert conn.execute("SELECT 1 FROM projects WHERE id='default'").fetchone() is None
|
||||
assert conn.execute("SELECT 1 FROM projects WHERE id='ohm'").fetchone() is not None
|
||||
# FK integrity intact after the rename
|
||||
assert conn.execute("PRAGMA foreign_key_check").fetchall() == []
|
||||
|
||||
|
||||
def test_restamp_is_idempotent(monkeypatch):
|
||||
cfg, conn = _setup(monkeypatch, default_id="ohm")
|
||||
projects.restamp_default_project(cfg)
|
||||
projects.restamp_default_project(cfg) # second call: no bootstrap rows left → no-op
|
||||
assert conn.execute("SELECT project_id FROM collections WHERE id='default'").fetchone()["project_id"] == "ohm"
|
||||
assert conn.execute("SELECT COUNT(*) c FROM cached_rfcs WHERE collection_id='default'").fetchone()["c"] == 1
|
||||
|
||||
|
||||
def test_restamp_noop_when_default_id_unchanged(monkeypatch):
|
||||
cfg, conn = _setup(monkeypatch, default_id="") # resolves to 'default'
|
||||
projects.restamp_default_project(cfg)
|
||||
# nothing renamed; the default collection still belongs to the bootstrap project
|
||||
assert conn.execute("SELECT project_id FROM collections WHERE id='default'").fetchone()["project_id"] == "default"
|
||||
@@ -0,0 +1,257 @@
|
||||
"""End-to-end integration tests for the §13.7 retire (soft-delete) flow.
|
||||
|
||||
Retire is an in-place frontmatter flip on the meta entry (state →
|
||||
`retired`) committed via an auto-merged PR — the same machinery as
|
||||
graduation, reused. The distinguishing rules under test:
|
||||
|
||||
* Authority (§3.1): RFC owners (frontmatter) and site `owner`-role
|
||||
holders may retire; app admins may NOT. Un-retire is site-owners-only.
|
||||
* Visibility (§13.7): a retired entry drops out of the catalog
|
||||
(`GET /api/rfcs`), and `GET /api/rfcs/<slug>` 404s for everyone except
|
||||
a site owner (so the un-retire affordance has a surface).
|
||||
* Reversibility: a site owner can un-retire, restoring the prior state
|
||||
(and keeping the integer id intact); an RFC owner cannot.
|
||||
|
||||
These walk against the in-process FakeGitea from test_propose_vertical.py,
|
||||
reusing the super-draft seed + sync graduation seam from the graduation
|
||||
suite.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json as _json
|
||||
|
||||
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_super_draft_vertical import seed_super_draft # noqa: F401
|
||||
from test_graduation_vertical import PITCH, seed_owned_super_draft # noqa: F401
|
||||
|
||||
|
||||
def _catalog_slugs(client) -> set[str]:
|
||||
return {i["slug"] for i in client.get("/api/rfcs").json()["items"]}
|
||||
|
||||
|
||||
def test_rfc_owner_can_retire_and_entry_leaves_every_surface(app_with_fake_gitea):
|
||||
"""An RFC owner (frontmatter, role contributor) retires their own
|
||||
super-draft. The entry flips to `retired`, drops out of the catalog,
|
||||
and `GET /api/rfcs/<slug>` 404s for them (they are not a site owner)."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db, entry as entry_mod
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="carol", role="contributor")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
||||
pitch=PITCH, owners=["carol"], arbiters=["carol"])
|
||||
sign_in_as(client, user_id=2, gitea_login="carol",
|
||||
display_name="Carol", role="contributor")
|
||||
|
||||
assert "ohm" in _catalog_slugs(client)
|
||||
|
||||
r = client.post("/api/rfcs/ohm/retire")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["state"] == "retired"
|
||||
|
||||
# Meta entry on main: state retired, body + fields kept.
|
||||
meta = entry_mod.parse(
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
|
||||
)
|
||||
assert meta.state == "retired"
|
||||
assert "carol" in meta.owners
|
||||
|
||||
# Cache flipped; gone from the catalog.
|
||||
cached = db.conn().execute(
|
||||
"SELECT state FROM cached_rfcs WHERE slug = 'ohm'"
|
||||
).fetchone()
|
||||
assert cached["state"] == "retired"
|
||||
assert "ohm" not in _catalog_slugs(client)
|
||||
|
||||
# The RFC owner is NOT a site owner → 404 on the entry read.
|
||||
assert client.get("/api/rfcs/ohm").status_code == 404
|
||||
|
||||
# Audit row records the prior state for un-retire.
|
||||
row = db.conn().execute(
|
||||
"SELECT details FROM actions WHERE rfc_slug='ohm' AND action_kind='retire' ORDER BY id DESC LIMIT 1"
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
assert _json.loads(row["details"])["prior_state"] == "super-draft"
|
||||
|
||||
|
||||
def test_site_owner_sees_retired_entry_but_admin_and_others_404(app_with_fake_gitea):
|
||||
"""`GET /api/rfcs/<slug>` for a retired entry: site owner gets 200
|
||||
(so the un-retire UI has a surface); an admin, a non-owner contributor,
|
||||
and an anonymous viewer all get 404."""
|
||||
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")
|
||||
provision_user_row(user_id=2, login="carol", role="contributor")
|
||||
provision_user_row(user_id=3, login="dave", role="admin")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
||||
pitch=PITCH, owners=["carol"])
|
||||
|
||||
sign_in_as(client, user_id=2, gitea_login="carol",
|
||||
display_name="Carol", role="contributor")
|
||||
assert client.post("/api/rfcs/ohm/retire").status_code == 200
|
||||
|
||||
# Site owner: 200.
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner")
|
||||
r = client.get("/api/rfcs/ohm")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["state"] == "retired"
|
||||
|
||||
# Admin (not site owner): 404.
|
||||
sign_in_as(client, user_id=3, gitea_login="dave",
|
||||
display_name="Dave", role="admin")
|
||||
assert client.get("/api/rfcs/ohm").status_code == 404
|
||||
|
||||
# Non-owner contributor: 404.
|
||||
provision_user_row(user_id=4, login="erin", role="contributor")
|
||||
sign_in_as(client, user_id=4, gitea_login="erin",
|
||||
display_name="Erin", role="contributor")
|
||||
assert client.get("/api/rfcs/ohm").status_code == 404
|
||||
|
||||
|
||||
def test_admin_cannot_retire(app_with_fake_gitea):
|
||||
"""§3.1: retire authority excludes app admins. An admin who is not an
|
||||
RFC owner gets 403 — the one lifecycle action where admin authority
|
||||
does not apply."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=3, login="dave", role="admin")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
||||
pitch=PITCH, owners=["carol"])
|
||||
sign_in_as(client, user_id=3, gitea_login="dave",
|
||||
display_name="Dave", role="admin")
|
||||
r = client.post("/api/rfcs/ohm/retire")
|
||||
assert r.status_code == 403, r.text
|
||||
|
||||
|
||||
def test_non_owner_contributor_cannot_retire(app_with_fake_gitea):
|
||||
"""A signed-in contributor who is not an RFC owner gets 403."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=4, login="erin", role="contributor")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
||||
pitch=PITCH, owners=["carol"])
|
||||
sign_in_as(client, user_id=4, gitea_login="erin",
|
||||
display_name="Erin", role="contributor")
|
||||
assert client.post("/api/rfcs/ohm/retire").status_code == 403
|
||||
|
||||
|
||||
def test_site_owner_can_retire_active_and_unretire_restores_active_with_id(app_with_fake_gitea):
|
||||
"""Round-trip on an active RFC: graduate (with a number) → retire →
|
||||
un-retire. The integer id survives, and un-retire restores `active`."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db, entry as entry_mod
|
||||
|
||||
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"], arbiters=["ben"])
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner", email="ben@test")
|
||||
|
||||
# Graduate with a number.
|
||||
assert client.post("/api/rfcs/ohm/graduate?_sync=1",
|
||||
json={"rfc_id": "RFC-0042", "owners": ["ben"]}).status_code == 200
|
||||
|
||||
# Retire (site owner).
|
||||
assert client.post("/api/rfcs/ohm/retire").json()["state"] == "retired"
|
||||
assert "ohm" not in _catalog_slugs(client)
|
||||
|
||||
# Un-retire (site owner) restores active, id intact.
|
||||
r = client.post("/api/rfcs/ohm/unretire")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["state"] == "active"
|
||||
|
||||
meta = entry_mod.parse(
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
|
||||
)
|
||||
assert meta.state == "active"
|
||||
assert meta.id == "RFC-0042"
|
||||
|
||||
cached = db.conn().execute(
|
||||
"SELECT state, rfc_id FROM cached_rfcs WHERE slug = 'ohm'"
|
||||
).fetchone()
|
||||
assert cached["state"] == "active"
|
||||
assert cached["rfc_id"] == "RFC-0042"
|
||||
assert "ohm" in _catalog_slugs(client)
|
||||
|
||||
|
||||
def test_rfc_owner_cannot_unretire(app_with_fake_gitea):
|
||||
"""Un-retire is site-owner-only: an RFC owner who could retire cannot
|
||||
bring it back (the soft-delete is recoverable only by the operator)."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="carol", role="contributor")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
||||
pitch=PITCH, owners=["carol"])
|
||||
sign_in_as(client, user_id=2, gitea_login="carol",
|
||||
display_name="Carol", role="contributor")
|
||||
assert client.post("/api/rfcs/ohm/retire").status_code == 200
|
||||
# Same RFC owner tries to un-retire → 403.
|
||||
assert client.post("/api/rfcs/ohm/unretire").status_code == 403
|
||||
|
||||
|
||||
def test_admin_retired_list_is_site_owner_only(app_with_fake_gitea):
|
||||
"""GET /api/admin/retired-rfcs lists retired entries for a site owner;
|
||||
an admin (who lacks un-retire authority) gets 403."""
|
||||
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")
|
||||
provision_user_row(user_id=2, login="carol", role="contributor")
|
||||
provision_user_row(user_id=3, login="dave", role="admin")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
||||
pitch=PITCH, owners=["carol"])
|
||||
sign_in_as(client, user_id=2, gitea_login="carol",
|
||||
display_name="Carol", role="contributor")
|
||||
assert client.post("/api/rfcs/ohm/retire").status_code == 200
|
||||
|
||||
# Admin: 403.
|
||||
sign_in_as(client, user_id=3, gitea_login="dave",
|
||||
display_name="Dave", role="admin")
|
||||
assert client.get("/api/admin/retired-rfcs").status_code == 403
|
||||
|
||||
# Site owner: 200, sees ohm with its restore target.
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner")
|
||||
r = client.get("/api/admin/retired-rfcs")
|
||||
assert r.status_code == 200, r.text
|
||||
items = r.json()["items"]
|
||||
ohm = next(i for i in items if i["slug"] == "ohm")
|
||||
assert ohm["restores_to"] == "super-draft"
|
||||
|
||||
|
||||
def test_retired_entry_refuses_discussion_reads(app_with_fake_gitea):
|
||||
"""A retired entry refuses content reads of every shape (§13.7) — the
|
||||
discussion/branch surfaces 404/409 rather than serve a soft-deleted RFC."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="carol", role="contributor")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
||||
pitch=PITCH, owners=["carol"])
|
||||
sign_in_as(client, user_id=2, gitea_login="carol",
|
||||
display_name="Carol", role="contributor")
|
||||
assert client.post("/api/rfcs/ohm/retire").status_code == 200
|
||||
|
||||
# The /main branch surface no longer serves it.
|
||||
assert client.get("/api/rfcs/ohm/main").status_code in (404, 409)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user