Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d581010063 | |||
| 732b23b156 | |||
| 1558cc3a8b | |||
| 8a94e26f75 | |||
| 3c9109c392 | |||
| 019c8a9185 | |||
| 79a447c77b | |||
| fe044ed3db | |||
| bd3ef269d4 |
+252
@@ -23,6 +23,258 @@ skip versions are the composition of each intervening adjacent
|
||||
release's steps in order — no A-to-B path is pre-computed beyond
|
||||
that.
|
||||
|
||||
## 0.30.2 — 2026-05-29
|
||||
|
||||
**Patch — header nav label: the persistent chrome link reverts from
|
||||
"Philosophy" back to "About." Display text only — the route
|
||||
(`/philosophy`), the `header-about` class, and the page itself are
|
||||
unchanged. A plain frontend rebuild applies it; no schema, API, config,
|
||||
overlay, or secret change.**
|
||||
|
||||
The §14.3 persistent link was relabeled "About" → "Philosophy" in
|
||||
v0.21.0. This restores "About" as the neutral, framework-native label
|
||||
(the CSS class `header-about` and the surrounding comment already call
|
||||
it "the About link"). A deployment that wants a more pointed framing
|
||||
can title its own About page in the `PHILOSOPHY.md` content the
|
||||
`PHILOSOPHY_PATH` override serves.
|
||||
|
||||
Upgrade steps: none. **SHOULD** deploy as a normal code deploy.
|
||||
|
||||
## 0.30.1 — 2026-05-29
|
||||
|
||||
**Patch — bug fix: a merged idea-PR whose branch was deleted no longer
|
||||
lingers as a phantom "pending idea." No schema, API, config, overlay,
|
||||
or secret change — a plain code deploy applies it, and the running
|
||||
reconciler clears any existing ghost on the next sweep (≤5 min) once
|
||||
deployed. Shipped from driver session 0040.0.**
|
||||
|
||||
`refresh_meta_pulls` (and `refresh_rfc_repo`) recover a PR's slug/kind
|
||||
by parsing its Gitea `head.ref`. When a PR is merged **and its branch
|
||||
deleted**, Gitea stops reporting the real branch name and returns the
|
||||
synthetic `refs/pull/<N>/head` sentinel instead. The slug then parsed
|
||||
to `None`, the reconcile loop skipped the row, and `cached_prs.state`
|
||||
stayed frozen at `open` forever — so the entry showed as **both** a
|
||||
super-draft (the `cached_rfcs` push-event reconcile succeeded) **and** a
|
||||
pending idea (the `cached_prs` PR-close reconcile never landed). The fix
|
||||
recovers the original branch name from the already-stored `cached_prs`
|
||||
row (which retains the real `head_branch` from when the PR was open;
|
||||
migration 002) whenever Gitea reports an empty or `refs/pull/` sentinel
|
||||
ref. Regression test added in `test_propose_vertical.py`
|
||||
(`test_merged_idea_pr_with_deleted_branch_clears_proposal`).
|
||||
|
||||
Surfaced through the ROADMAP #35 operator authoring lane, which merges
|
||||
idea PRs from the CLI with branch-deletion enabled — a path the web UX
|
||||
never exercises (it leaves branches in place, so
|
||||
`default_delete_branch_after_merge` stays false). The framework should
|
||||
not depend on branches outliving their merge, hence the framework-level
|
||||
fix rather than a tooling workaround.
|
||||
|
||||
Upgrade steps: none. **SHOULD** deploy as a normal code deploy; the
|
||||
periodic reconciler self-heals any existing phantom on its next sweep.
|
||||
|
||||
## 0.30.0 — 2026-05-29
|
||||
|
||||
**Minor — documentation: the user guide (`DOCS.md`, served at
|
||||
`/docs/user-guide` via `/api/docs`) brought back in sync with the
|
||||
shipped app. No code, schema, API, config, overlay, or secret change —
|
||||
a plain code deploy serves the updated guide. Shipped from driver
|
||||
session 0037.0.**
|
||||
|
||||
The guide had drifted since it was first written: it still described
|
||||
the pre-OTC email *allowlist* sign-in, listed four propose-RFC fields,
|
||||
and predated several shipped surfaces. Updated to match v0.7.0–v0.29.0:
|
||||
|
||||
- **Signing in** rewritten for the email + one-time-code flow (v0.7.0),
|
||||
optional passcode (v0.10.0), trust-this-device for 30 days (v0.11.0),
|
||||
optional Cloudflare Turnstile (v0.12.0), and the beta-access request →
|
||||
`pending` → admin-`granted` gate (v0.8.0 / #6), plus the admin-create
|
||||
+ invite-claim path (v0.17.0 / #16). The vestigial allowlist is no
|
||||
longer described as the gate.
|
||||
- **Proposing a new RFC** now lists five fields, adding the optional
|
||||
"What will you be using this RFC for?" use-case field (#26) and noting
|
||||
the AI tag-suggestion disclosure (#27).
|
||||
- **Roles & permissions** documents the `pending` state.
|
||||
- New **Invitations, cross-references, and contribution requests**
|
||||
section covers owner invitations (#12) and the RFC auto-link /
|
||||
create-RFC / ask-to-contribute affordances (#28).
|
||||
- New **Privacy and cookies** section covers the consent banner and
|
||||
consent-gated analytics (#11 / #13).
|
||||
|
||||
Upgrade steps: none — documentation-only; the change is the `DOCS.md`
|
||||
file served verbatim by `/api/docs`. A plain code deploy at this tag
|
||||
serves it. A deployment that overrides the guide via `DOCS_PATH`
|
||||
supplies its own copy and is unaffected.
|
||||
|
||||
## 0.29.0 — 2026-05-28
|
||||
|
||||
**Minor — roadmap #28 Parts 2 + 3: offer-to-create-an-RFC for strong-
|
||||
candidate terms, and offer-to-contribute-to-a-pending-RFC. One auto-
|
||||
applied migration (024, additive: a new `contribution_requests` table).
|
||||
No config/overlay/secret change; no nginx/systemd change. A plain code
|
||||
deploy + the auto-migration picks it up. Shipped from driver session
|
||||
0033.0.**
|
||||
|
||||
Both parts extend the v0.26.0 (#28 Part 1) read-time scanner
|
||||
(`backend/app/rfc_links.py`) and its renderer
|
||||
(`frontend/src/components/LinkedText.jsx`). The scanner now sorts each
|
||||
matched term into one of three buckets — active link (Part 1, unchanged),
|
||||
pending-RFC contribute offer (Part 3), create-RFC offer (Part 2) — in one
|
||||
pass, with precedence active > pending > candidate at any position. The
|
||||
backend still emits only structured segments (never HTML), so the surface
|
||||
stays XSS-safe by construction.
|
||||
|
||||
- **Part 2 — create-RFC offers.** A *strong-candidate* term — a
|
||||
**multi-word tag** from the #27 tag taxonomy that has no defining RFC
|
||||
(no active or super-draft RFC whose slug/title is that term) — renders,
|
||||
for a viewer with create rights (`permission_state='granted'`), as an
|
||||
inline "+ create RFC" affordance. Clicking opens the propose-RFC modal
|
||||
with the term pre-filled as the title (`ProposeModal` gained an
|
||||
`initialTitle`; the affordance routes via `?propose=<term>`, read in
|
||||
`App.jsx`). The heuristic is deliberately conservative — multi-word is
|
||||
the same false-positive guard the title rule uses, so a single common
|
||||
tag word (`identity`) is never offered. Broader candidate detection
|
||||
(capitalized phrases mined from text, terms repeated across recent PRs,
|
||||
or the #27 Haiku `ANTHROPIC_API_KEY` pathway) is a sanctioned but
|
||||
deferred extension.
|
||||
- **Part 3 — contribute-to-pending offers.** A term matching a *pending*
|
||||
RFC — a super-draft (`state='super-draft'`: accepted as an idea, owned,
|
||||
with a contribution surface, not yet graduated) — renders, for a
|
||||
signed-in non-owner, as an inline "ask to contribute" affordance
|
||||
carrying the owner's display name ("<owner> is working on an RFC for
|
||||
'<term>'"). It opens a contribute-request form (`?contribute=<slug>`)
|
||||
with three fields — **who I am** (required), **why I'm asking**
|
||||
(required), **what I'd use it for** (optional, mirroring #26). Submitting
|
||||
lands a `contribution_requests` row and one actionable §15 inbox
|
||||
notification per owner (new kind `contribution_request_on_pending_rfc`,
|
||||
category `personal-direct` — so it reuses the existing
|
||||
`email_personal_direct` preference, no new toggle). In the inbox the
|
||||
owner sees the requester's who/why/use-case inline with **Accept** /
|
||||
**Decline**. Accept fires #12's owner-invite flow with the requester as
|
||||
the invitee (a `contributor` `rfc_invitations` row + the existing invite
|
||||
email) and echoes a notification back to the requester; Decline closes
|
||||
the request and notifies the requester. Pre-merge idea PRs (not yet in
|
||||
`cached_rfcs`, no contribution surface) are deliberately out of scope —
|
||||
a documented future extension.
|
||||
|
||||
New endpoints (all under the existing `/api` router):
|
||||
`GET /api/rfcs/{slug}/contribution-target`,
|
||||
`POST /api/rfcs/{slug}/contribution-requests`,
|
||||
`POST /api/rfcs/{slug}/contribution-requests/{id}/accept`,
|
||||
`POST /api/rfcs/{slug}/contribution-requests/{id}/decline`.
|
||||
|
||||
The owner-invite issue path was refactored into one reusable chokepoint,
|
||||
`api_invitations.issue_invitation(...)`, shared by the manual invite
|
||||
endpoint and Part 3's accept path so the dup-guard, token mint, insert,
|
||||
and transactional email stay identical.
|
||||
|
||||
Upgrade steps:
|
||||
|
||||
1. Deployments **MUST** apply the auto-run migration `024` (additive: the
|
||||
new `contribution_requests` table; no existing table or row is
|
||||
touched). The standard deploy path runs pending migrations on start —
|
||||
no manual step beyond deploying the new code.
|
||||
2. No config, overlay, or secret change is required. The Part 2 candidate
|
||||
affordance reuses #27's tag taxonomy; it surfaces only when the corpus
|
||||
carries multi-word tags without a defining RFC, and the create
|
||||
affordance renders only for beta-granted viewers. Part 3's email reuse
|
||||
sends through the existing invitation SMTP path — no new key.
|
||||
|
||||
## 0.28.0 — 2026-05-28
|
||||
|
||||
**Minor — security-hardening follow-up (Session-0026 audit, informational
|
||||
findings I3 + I4). No operator action required: no migration, no schema
|
||||
change, no config/overlay change, no API/behavior change for any caller.
|
||||
A plain code deploy picks it up. Shipped from driver session 0032.0.**
|
||||
|
||||
Two informational findings from the Session-0026 audit, both
|
||||
framework-internal defense-in-depth:
|
||||
|
||||
- **I3 — dead HTML-email branch guarded.** `email_envelope.build_envelope`
|
||||
accepted a `body_html=` argument that built a `multipart/alternative`
|
||||
body, but no send path ever passed it — every rfc-app mail is plain
|
||||
text. An unused branch that would emit HTML built from (potentially
|
||||
unescaped) user content is the C1 stored-XSS class waiting in the mail
|
||||
channel. The branch is now a loud guard: passing `body_html` raises
|
||||
`NotImplementedError`. The argument is kept in the signature for
|
||||
documented future symmetry; enabling HTML mail becomes a deliberate
|
||||
change that MUST HTML-escape user content at the call site and remove
|
||||
the guard in the same commit.
|
||||
- **I4 — Turnstile siteverify no longer blocks the event loop.**
|
||||
`turnstile.verify_token` was a synchronous function issuing a blocking
|
||||
`httpx.post` from inside the async `/auth/otc/request` handler, so a
|
||||
slow CloudFlare response stalled the single worker for up to the 10s
|
||||
timeout. It is now `async` and awaits the call on an
|
||||
`httpx.AsyncClient` (matching the codebase's existing async-httpx
|
||||
pattern), isolated behind a narrow `_siteverify_post` seam. The sole
|
||||
caller (`main.py`) now `await`s it.
|
||||
|
||||
Upgrade steps: **none.** Both changes are internal. The `verify_token`
|
||||
signature changed from sync to `async` (callers must `await`), but the
|
||||
only caller is in-tree (`main.py`) and is updated in this release; no
|
||||
deployment-facing surface, config key, or migration is affected.
|
||||
|
||||
## 0.27.0 — 2026-05-28
|
||||
|
||||
**Minor — security-hardening release (Session-0026 audit remediation).
|
||||
One auto-applied migration (023); one behavior change that re-prompts
|
||||
device-trust; deployments MUST re-apply the nginx + systemd files.**
|
||||
This is the work cut as the "v0.25.0 security-hardening" branch; it
|
||||
reversioned to 0.27.0 because v0.26.0 (#28) took the next slot while it
|
||||
was in flight. Shipped from driver session 0030.0.
|
||||
|
||||
- **C1 (Critical) — stored-XSS closed.** Every markdown→HTML sink now
|
||||
routes through one chokepoint, `frontend/src/lib/sanitizeHtml.js`
|
||||
(DOMPurify), before any `innerHTML` / `dangerouslySetInnerHTML` write:
|
||||
`MarkdownPreview`, both `ProposalView` sinks (entry body +
|
||||
`proposed_use_case`), and `Editor`. A hook adds
|
||||
`rel="noopener noreferrer"` to `target=_blank` links. `marked` no
|
||||
longer passes raw HTML / `javascript:` URIs to the DOM, so a
|
||||
contributor can no longer plant a payload that runs in an admin/owner
|
||||
session during review.
|
||||
- **H1 — OTC verify is rate-limited.** New `backend/app/ratelimit.py`
|
||||
(per-IP token buckets) gates `/auth/otc/verify`, `/auth/otc/request`,
|
||||
and the passcode check/verify paths; a per-account OTC-verify lockout
|
||||
(migration `023_otc_verify_lockout.sql`) mirrors the passcode lockout.
|
||||
- **M1 — device-trust lookup no longer table-scans.** The device-trust
|
||||
cookie value is now `"<row_id>.<raw_token>"`; `device_trust.lookup`
|
||||
reads the one indexed row and bcrypt-checks only it, instead of
|
||||
bcrypt-checking every row in the table on each unauthenticated
|
||||
`/auth/device-trust/start`.
|
||||
- **M2 — HTTP security headers** (CSP, HSTS, X-Frame-Options,
|
||||
X-Content-Type-Options, Referrer-Policy) added to the nginx server
|
||||
block. **L8/I1**: `server_tokens off` + legacy TLS1.0/1.1 removed.
|
||||
- **M4 — session cookie `Secure` by default** (`SESSION_COOKIE_SECURE`,
|
||||
defaults on; a dev box on plain http sets it `false`).
|
||||
- **M5 — bounce webhook fails closed.** An unset
|
||||
`WEBHOOK_EMAIL_BOUNCE_SECRET` now **disables** `/api/webhooks/email-bounce`
|
||||
(503) instead of leaving it open; a dev opts back in with
|
||||
`RFC_APP_INSECURE_BOUNCE_WEBHOOK=1`.
|
||||
- **L2/L3** per-IP cooldown + check-endpoint throttle. **L4** systemd
|
||||
sandbox knobs (`CapabilityBoundingSet=`, `ProtectKernel*`,
|
||||
`RestrictAddressFamilies`, `SystemCallFilter`, …).
|
||||
|
||||
Upgrade steps:
|
||||
|
||||
1. **Migration** — none manual; `023_otc_verify_lockout.sql` auto-applies
|
||||
at startup via `db.run_migrations`.
|
||||
2. **Device trust (MUST expect re-prompt)** — the cookie format changed,
|
||||
so existing "trusted device" cookies no longer match; affected users
|
||||
are re-prompted for device verification once. No data migration; old
|
||||
rows are simply never matched and age out.
|
||||
3. **nginx + systemd (MUST apply out-of-band)** — the deploy gesture does
|
||||
**not** install `deploy/nginx/ohm.wiggleverse.org.conf` or
|
||||
`deploy/systemd/rfc-app.service`. After deploying the code, copy both
|
||||
to their system locations, then `nginx -t && systemctl reload nginx`
|
||||
and `systemctl daemon-reload && systemctl restart <unit>`. (M2 headers
|
||||
and L4 sandboxing do not take effect until this is done.)
|
||||
4. **Bounce webhook (SHOULD)** — bind `WEBHOOK_EMAIL_BOUNCE_SECRET` (or
|
||||
set `RFC_APP_INSECURE_BOUNCE_WEBHOOK=1` for dev). Unset → the endpoint
|
||||
returns 503 (closed). No legitimate bounce source is wired today, so
|
||||
503 is the safe default.
|
||||
5. **Session cookie (SHOULD, dev only)** — a deployment served over plain
|
||||
http MUST set `SESSION_COOKIE_SECURE=false` or the session cookie
|
||||
won't be sent. Production over HTTPS leaves it unset (Secure on).
|
||||
|
||||
## 0.26.0 — 2026-05-28
|
||||
|
||||
**Minor — no schema migration, no new secret, no config, no upgrade
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -21,6 +21,7 @@ from pydantic import BaseModel, Field
|
||||
from . import (
|
||||
api_admin,
|
||||
api_branches,
|
||||
api_contributions,
|
||||
api_discussion,
|
||||
api_graduation,
|
||||
api_invitations,
|
||||
@@ -141,6 +142,10 @@ def make_router(
|
||||
# invited users keep read access but cannot write (v0.6.0
|
||||
# contract extended to per-RFC scope).
|
||||
router.include_router(api_invitations.make_router())
|
||||
# v0.29.0 (roadmap item #28 Part 3): offer-to-contribute-to-a-pending
|
||||
# (super-draft) RFC. Reuses the #12 invite flow (api_invitations above)
|
||||
# on accept; lands the request + owner notifications via §15 notify.
|
||||
router.include_router(api_contributions.make_router())
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# §17: /api/health — unauthenticated post-flight probe.
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
"""v0.29.0 / roadmap #28 Part 3 — offer-to-contribute-to-a-pending-RFC.
|
||||
|
||||
When the #28 scanner (see ``rfc_links.py``) matches a term in submitted
|
||||
PR/comment text to a **pending** RFC — a super-draft
|
||||
(``cached_rfcs.state='super-draft'``: accepted as an idea, owned, with a
|
||||
contribution surface, but not yet graduated to an active RFC) — the
|
||||
reader is offered an "ask to contribute" popover. This module is the
|
||||
backend for that flow:
|
||||
|
||||
* ``GET /api/rfcs/{slug}/contribution-target`` — what the
|
||||
contribute form needs (RFC title, owner display, the viewer's
|
||||
eligibility + whether they already have a pending ask).
|
||||
* ``POST /api/rfcs/{slug}/contribution-requests`` — submit the ask
|
||||
(who-I-am / why / optional use-case); lands a row + one §15
|
||||
notification per owner.
|
||||
* ``POST /api/rfcs/{slug}/contribution-requests/{id}/accept`` — owner:
|
||||
accept, which fires #12's owner-invite flow with the requester as the
|
||||
invitee (opening the RFC's discussion/contribution surface), then
|
||||
echoes a notification back to the requester.
|
||||
* ``POST /api/rfcs/{slug}/contribution-requests/{id}/decline`` — owner:
|
||||
decline; the request closes and the requester is notified.
|
||||
|
||||
"Pending" is scoped to a super-draft because that is the state with an
|
||||
owner to route to, a contribution surface to open, and a row in
|
||||
``cached_rfcs`` for the ``rfc_invitations`` FK the accept path reuses.
|
||||
Pre-merge idea PRs are deliberately out of scope (no contribution
|
||||
surface yet) — a documented future extension, mirroring the
|
||||
conservative scoping in ``rfc_links.py``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from . import api_invitations, auth, db, notify
|
||||
|
||||
# Field caps — generous for free text, bounded so a request row (and the
|
||||
# notification payload that carries it) can't be used to store unbounded
|
||||
# blobs. Mirrors the order-of-magnitude of the propose/tag-suggest caps.
|
||||
_WHO_MAX = 2000
|
||||
_WHY_MAX = 4000
|
||||
_USE_CASE_MAX = 4000
|
||||
_TERM_MAX = 200
|
||||
|
||||
|
||||
class ContributionRequestBody(BaseModel):
|
||||
# The term in the PR/comment text that surfaced the offer (the
|
||||
# super-draft's title/slug). Carried for the owner's context line.
|
||||
matched_term: str = Field(min_length=1, max_length=_TERM_MAX)
|
||||
who_i_am: str = Field(min_length=1, max_length=_WHO_MAX)
|
||||
why: str = Field(min_length=1, max_length=_WHY_MAX)
|
||||
use_case: str | None = Field(default=None, max_length=_USE_CASE_MAX)
|
||||
|
||||
|
||||
def _require_super_draft(slug: str):
|
||||
"""The contribute surface only operates on a *pending* RFC. 404 on
|
||||
unknown; 409 on a state that isn't a super-draft (active RFCs use the
|
||||
Part-1 link, not a contribute offer; withdrawn is closed)."""
|
||||
row = db.conn().execute(
|
||||
"SELECT slug, title, state, owners_json, proposed_by FROM cached_rfcs WHERE slug = ?",
|
||||
(slug,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, "RFC not found")
|
||||
if row["state"] != "super-draft":
|
||||
raise HTTPException(409, "RFC is not a pending super-draft")
|
||||
return row
|
||||
|
||||
|
||||
def _require_request(slug: str, request_id: int):
|
||||
row = db.conn().execute(
|
||||
"""
|
||||
SELECT id, rfc_slug, requester_user_id, matched_term, who_i_am, why,
|
||||
use_case, status
|
||||
FROM contribution_requests WHERE id = ? AND rfc_slug = ?
|
||||
""",
|
||||
(request_id, slug),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, "Contribution request not found")
|
||||
return row
|
||||
|
||||
|
||||
def _viewer_relationship(viewer, slug: str) -> str | None:
|
||||
"""Why this viewer can't *request* to contribute — or None if they can.
|
||||
Owners/admins already have the RFC; existing collaborators are already
|
||||
in. Both get a clear 409 rather than a useless self-request."""
|
||||
if auth.is_rfc_owner(viewer, slug) or viewer.role in ("owner", "admin"):
|
||||
return "You already own or administer this RFC."
|
||||
if auth.is_rfc_collaborator(viewer, slug):
|
||||
return "You're already a collaborator on this RFC."
|
||||
return None
|
||||
|
||||
|
||||
def make_router() -> APIRouter:
|
||||
router = APIRouter()
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# GET — what the contribute form needs to render + gate itself.
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.get("/api/rfcs/{slug}/contribution-target")
|
||||
async def contribution_target(slug: str, request: Request) -> dict[str, Any]:
|
||||
row = db.conn().execute(
|
||||
"SELECT slug, title, state, owners_json, proposed_by FROM cached_rfcs WHERE slug = ?",
|
||||
(slug,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, "RFC not found")
|
||||
|
||||
from . import rfc_links # local import: avoid a module import cycle
|
||||
|
||||
owner = rfc_links._owner_display(db.conn(), row["owners_json"], row["proposed_by"])
|
||||
viewer = auth.current_user(request)
|
||||
|
||||
eligible = True
|
||||
reason: str | None = None
|
||||
already_requested = False
|
||||
if row["state"] != "super-draft":
|
||||
eligible, reason = False, "This RFC is no longer pending."
|
||||
elif viewer is None:
|
||||
eligible, reason = False, "Sign in to ask to contribute."
|
||||
elif viewer.permission_state != "granted":
|
||||
eligible, reason = False, "Your beta access request is in review."
|
||||
else:
|
||||
reason = _viewer_relationship(viewer, slug)
|
||||
if reason is not None:
|
||||
eligible = False
|
||||
else:
|
||||
already_requested = bool(
|
||||
db.conn().execute(
|
||||
"""
|
||||
SELECT 1 FROM contribution_requests
|
||||
WHERE rfc_slug = ? AND requester_user_id = ? AND status = 'pending'
|
||||
LIMIT 1
|
||||
""",
|
||||
(slug, viewer.user_id),
|
||||
).fetchone()
|
||||
)
|
||||
|
||||
return {
|
||||
"slug": row["slug"],
|
||||
"title": row["title"],
|
||||
"owner": owner,
|
||||
"eligible": eligible and not already_requested,
|
||||
"reason": reason,
|
||||
"already_requested": already_requested,
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# POST — submit a contribute request.
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.post("/api/rfcs/{slug}/contribution-requests")
|
||||
async def create_contribution_request(
|
||||
slug: str, body: ContributionRequestBody, request: Request
|
||||
) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
_require_super_draft(slug)
|
||||
|
||||
reason = _viewer_relationship(viewer, slug)
|
||||
if reason is not None:
|
||||
raise HTTPException(409, reason)
|
||||
|
||||
who_i_am = body.who_i_am.strip()
|
||||
why = body.why.strip()
|
||||
use_case = (body.use_case or "").strip() or None
|
||||
matched_term = body.matched_term.strip()
|
||||
if not who_i_am or not why:
|
||||
raise HTTPException(422, "Both 'who I am' and 'why' are required.")
|
||||
|
||||
try:
|
||||
cur = db.conn().execute(
|
||||
"""
|
||||
INSERT INTO contribution_requests
|
||||
(rfc_slug, requester_user_id, matched_term, who_i_am, why, use_case)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(slug, viewer.user_id, matched_term, who_i_am, why, use_case),
|
||||
)
|
||||
except sqlite3.IntegrityError:
|
||||
# The partial unique index — one open request per (RFC, user).
|
||||
raise HTTPException(409, "You already have a pending request to contribute to this RFC.")
|
||||
request_id = cur.lastrowid
|
||||
|
||||
# One actionable notification per owner; stamp the first onto the
|
||||
# row as the inbox-action handle (any owner can act on the request).
|
||||
notif_ids = notify.fan_out_contribution_request(
|
||||
rfc_slug=slug,
|
||||
requester_user_id=viewer.user_id,
|
||||
request_id=request_id,
|
||||
matched_term=matched_term,
|
||||
who_i_am=who_i_am,
|
||||
why=why,
|
||||
use_case=use_case,
|
||||
)
|
||||
if notif_ids:
|
||||
db.conn().execute(
|
||||
"UPDATE contribution_requests SET notification_id = ? WHERE id = ?",
|
||||
(notif_ids[0], request_id),
|
||||
)
|
||||
|
||||
return {"id": request_id, "rfc_slug": slug, "status": "pending"}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# POST — owner accepts → fire #12's invite flow.
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.post("/api/rfcs/{slug}/contribution-requests/{request_id}/accept")
|
||||
async def accept_contribution_request(
|
||||
slug: str, request_id: int, request: Request
|
||||
) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
rfc = _require_super_draft(slug)
|
||||
if not auth.can_invite_to_rfc(viewer, slug):
|
||||
raise HTTPException(403, "Only the RFC's owner can act on contribution requests")
|
||||
|
||||
req = _require_request(slug, request_id)
|
||||
if req["status"] != "pending":
|
||||
raise HTTPException(409, f"This request was already {req['status']}.")
|
||||
|
||||
requester = db.conn().execute(
|
||||
"SELECT id, email FROM users WHERE id = ?", (req["requester_user_id"],)
|
||||
).fetchone()
|
||||
if requester is None or not (requester["email"] or "").strip():
|
||||
raise HTTPException(422, "The requester has no email address on file to invite.")
|
||||
|
||||
# Fire #12's owner-invite flow with the requester as the invitee.
|
||||
# If a pending contributor invitation already exists (the owner
|
||||
# invited them out-of-band first), reuse it rather than failing.
|
||||
try:
|
||||
invitation = api_invitations.issue_invitation(
|
||||
slug=slug,
|
||||
inviter_user_id=viewer.user_id,
|
||||
inviter_display=viewer.display_name or viewer.gitea_login or "An RFC owner",
|
||||
invitee_email=requester["email"],
|
||||
role_in_rfc="contributor",
|
||||
rfc_title=rfc["title"],
|
||||
)
|
||||
invitation_id = invitation["id"]
|
||||
except HTTPException as exc:
|
||||
if exc.status_code != 409:
|
||||
raise
|
||||
existing = db.conn().execute(
|
||||
"""
|
||||
SELECT id FROM rfc_invitations
|
||||
WHERE rfc_slug = ? AND invitee_email = ? COLLATE NOCASE
|
||||
AND role_in_rfc = 'contributor' AND status = 'pending'
|
||||
ORDER BY id DESC LIMIT 1
|
||||
""",
|
||||
(slug, requester["email"].strip()),
|
||||
).fetchone()
|
||||
invitation_id = existing["id"] if existing else None
|
||||
|
||||
db.conn().execute(
|
||||
"""
|
||||
UPDATE contribution_requests
|
||||
SET status = 'accepted', decided_at = datetime('now'),
|
||||
decided_by_user_id = ?, invitation_id = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(viewer.user_id, invitation_id, request_id),
|
||||
)
|
||||
notify.notify_contribution_decided(
|
||||
rfc_slug=slug,
|
||||
requester_user_id=req["requester_user_id"],
|
||||
decider_user_id=viewer.user_id,
|
||||
request_id=request_id,
|
||||
accepted=True,
|
||||
)
|
||||
return {"ok": True, "status": "accepted", "invitation_id": invitation_id}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# POST — owner declines.
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.post("/api/rfcs/{slug}/contribution-requests/{request_id}/decline")
|
||||
async def decline_contribution_request(
|
||||
slug: str, request_id: int, request: Request
|
||||
) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
_require_super_draft(slug)
|
||||
if not auth.can_invite_to_rfc(viewer, slug):
|
||||
raise HTTPException(403, "Only the RFC's owner can act on contribution requests")
|
||||
|
||||
req = _require_request(slug, request_id)
|
||||
if req["status"] != "pending":
|
||||
raise HTTPException(409, f"This request was already {req['status']}.")
|
||||
|
||||
db.conn().execute(
|
||||
"""
|
||||
UPDATE contribution_requests
|
||||
SET status = 'declined', decided_at = datetime('now'),
|
||||
decided_by_user_id = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(viewer.user_id, request_id),
|
||||
)
|
||||
notify.notify_contribution_decided(
|
||||
rfc_slug=slug,
|
||||
requester_user_id=req["requester_user_id"],
|
||||
decider_user_id=viewer.user_id,
|
||||
request_id=request_id,
|
||||
accepted=False,
|
||||
)
|
||||
return {"ok": True, "status": "declined"}
|
||||
|
||||
return router
|
||||
@@ -133,69 +133,15 @@ def make_router() -> APIRouter:
|
||||
"Only the RFC's owner can invite collaborators",
|
||||
)
|
||||
|
||||
invitee_email = body.invitee_email.strip()
|
||||
role_in_rfc = body.role_in_rfc
|
||||
|
||||
# Refuse re-inviting an email that already has a pending
|
||||
# invitation on this RFC at the same role. Different-role
|
||||
# re-invite is allowed (upgrade discussant → contributor)
|
||||
# — the new row supersedes the old in the UI listing's
|
||||
# natural ordering, and acceptance of either picks up the
|
||||
# corresponding role.
|
||||
existing = db.conn().execute(
|
||||
"""
|
||||
SELECT id FROM rfc_invitations
|
||||
WHERE rfc_slug = ? AND invitee_email = ? COLLATE NOCASE
|
||||
AND role_in_rfc = ? AND status = 'pending'
|
||||
LIMIT 1
|
||||
""",
|
||||
(slug, invitee_email, role_in_rfc),
|
||||
).fetchone()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
409,
|
||||
f"{invitee_email} already has a pending {role_in_rfc} invitation for this RFC",
|
||||
)
|
||||
|
||||
token = _mint_token()
|
||||
cur = db.conn().execute(
|
||||
"""
|
||||
INSERT INTO rfc_invitations
|
||||
(rfc_slug, inviter_user_id, invitee_email, role_in_rfc,
|
||||
token, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, datetime('now', ?))
|
||||
""",
|
||||
(
|
||||
slug,
|
||||
viewer.user_id,
|
||||
invitee_email,
|
||||
role_in_rfc,
|
||||
token,
|
||||
f"+{INVITATION_TTL_DAYS} days",
|
||||
),
|
||||
)
|
||||
invitation_id = cur.lastrowid
|
||||
|
||||
# Send the email — synchronous. A send failure logs and
|
||||
# returns; the row stays so the owner can recover via the
|
||||
# listing (which carries the token for an out-of-band share).
|
||||
_send_invitation_email(
|
||||
to_address=invitee_email,
|
||||
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"],
|
||||
role_in_rfc=role_in_rfc,
|
||||
token=token,
|
||||
)
|
||||
|
||||
return {
|
||||
"id": invitation_id,
|
||||
"rfc_slug": slug,
|
||||
"invitee_email": invitee_email,
|
||||
"role_in_rfc": role_in_rfc,
|
||||
"status": "pending",
|
||||
"token": token,
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# GET /api/rfcs/<slug>/invitations
|
||||
# The owner's listing of every invitation on the RFC, regardless
|
||||
@@ -473,6 +419,78 @@ def _effective_status(row) -> str:
|
||||
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."""
|
||||
|
||||
@@ -557,7 +557,26 @@ 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):
|
||||
|
||||
@@ -219,6 +219,19 @@ async def refresh_rfc_repo(config: Config, gitea: Gitea, slug: str) -> None:
|
||||
open_pulls, closed_pulls = [], []
|
||||
for pull in open_pulls + closed_pulls:
|
||||
head_branch = pull.get("head", {}).get("ref", "")
|
||||
# Same deleted-branch recovery as refresh_meta_pulls: a merged-and-
|
||||
# deleted PR's `head.ref` collapses to `refs/pull/<N>/head`. Here
|
||||
# the slug is known (param), so state still updates correctly and
|
||||
# no ghost forms — but blindly storing the sentinel would clobber
|
||||
# the real branch name api_prs.py relies on as a fallback ref when
|
||||
# the merge commit is gone. Recover it from the stored row.
|
||||
if not head_branch or head_branch.startswith("refs/pull/"):
|
||||
prior = db.conn().execute(
|
||||
"SELECT head_branch FROM cached_prs WHERE repo = ? AND pr_number = ?",
|
||||
(repo_full, pull["number"]),
|
||||
).fetchone()
|
||||
if prior and prior["head_branch"]:
|
||||
head_branch = prior["head_branch"]
|
||||
state = _state_from_pull(pull)
|
||||
gitea_opener = (pull.get("user") or {}).get("login") or ""
|
||||
opened_by = _resolve_actor(
|
||||
@@ -431,6 +444,25 @@ async def refresh_meta_pulls(config: Config, gitea: Gitea) -> None:
|
||||
|
||||
for pull in open_pulls + closed_pulls:
|
||||
head_branch = pull.get("head", {}).get("ref", "")
|
||||
# A merged-and-deleted PR's branch is no longer reported by Gitea
|
||||
# as its real name — the `head.ref` collapses to the synthetic
|
||||
# `refs/pull/<N>/head` sentinel (or empty). The slug + kind both
|
||||
# derive from the branch name, so a deleted branch would parse to
|
||||
# slug=None and the row would be skipped forever, freezing the
|
||||
# cached_prs row at its last-seen `state='open'` — a permanent
|
||||
# ghost "pending idea" for an entry that has actually merged
|
||||
# (caught when the operator authoring lane in ROADMAP #35 merged
|
||||
# an idea PR with the branch deleted; the web UX leaves branches
|
||||
# in place so it never tripped this). Recover the original branch
|
||||
# from the row we already stored when the PR was open — that row
|
||||
# retains the real `head_branch` (migration 002).
|
||||
if not head_branch or head_branch.startswith("refs/pull/"):
|
||||
prior = db.conn().execute(
|
||||
"SELECT head_branch FROM cached_prs WHERE repo = ? AND pr_number = ?",
|
||||
(repo_full, pull["number"]),
|
||||
).fetchone()
|
||||
if prior and prior["head_branch"]:
|
||||
head_branch = prior["head_branch"]
|
||||
slug = _slug_from_head_branch(head_branch)
|
||||
if slug is None:
|
||||
continue
|
||||
|
||||
+32
-21
@@ -97,6 +97,13 @@ class IssueOutcome:
|
||||
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)
|
||||
@@ -173,33 +180,37 @@ def lookup(raw_token: str) -> LookupOutcome:
|
||||
if not raw:
|
||||
return LookupOutcome(ok=False, user=None, reason="invalid")
|
||||
|
||||
# The unique index on `device_token_hash` would let us SELECT by
|
||||
# hash if bcrypt were a stable hash, but bcrypt incorporates a
|
||||
# per-row salt — equal tokens produce different hashes. We walk
|
||||
# the candidate set instead. In practice the set is small (a
|
||||
# human has a handful of trusted devices) and bcrypt is cheap on
|
||||
# the order of milliseconds; the walk is bounded by the user's
|
||||
# active device count.
|
||||
# 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.
|
||||
#
|
||||
# We don't pre-filter by `revoked_at IS NULL` here so that a
|
||||
# token presented for a recently-revoked row produces a
|
||||
# 'revoked' outcome (the endpoint surfaces a different shape).
|
||||
# Same for expired: we let the walk hit and classify after.
|
||||
rows = db.conn().execute(
|
||||
# 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
|
||||
ORDER BY id DESC
|
||||
WHERE id = ?
|
||||
""",
|
||||
).fetchall()
|
||||
(int(selector),),
|
||||
).fetchone()
|
||||
|
||||
matched = None
|
||||
for row in rows:
|
||||
if _check(raw, row["device_token_hash"]):
|
||||
matched = row
|
||||
break
|
||||
|
||||
if matched is None:
|
||||
# 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:
|
||||
|
||||
@@ -60,12 +60,17 @@ def build_envelope(
|
||||
`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`, if supplied, lands as the
|
||||
second part of a `multipart/alternative` body — mail clients
|
||||
that prefer HTML render it; clients that don't fall back to the
|
||||
plain part. The text/plain part comes first per RFC 2046, so a
|
||||
plain-text client that picks the first body gets the readable
|
||||
text.
|
||||
`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
|
||||
@@ -131,13 +136,20 @@ def build_envelope(
|
||||
# idempotent and not require auth. See
|
||||
# `api_notifications.py` for the receiver.
|
||||
msg["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click"
|
||||
if body_html:
|
||||
# multipart/alternative: text/plain first, text/html second.
|
||||
# `set_content` sets the first part (and the message's main
|
||||
# body); `add_alternative` adds the second part and
|
||||
# restructures the message as multipart/alternative.
|
||||
msg.set_content(body_plain)
|
||||
msg.add_alternative(body_html, subtype="html")
|
||||
else:
|
||||
msg.set_content(body_plain)
|
||||
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
|
||||
|
||||
+61
-19
@@ -7,6 +7,7 @@ no need for a separate worker.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
@@ -28,6 +29,7 @@ from . import (
|
||||
otc,
|
||||
passcode as passcode_mod,
|
||||
providers as providers_mod,
|
||||
ratelimit,
|
||||
turnstile,
|
||||
webhooks,
|
||||
)
|
||||
@@ -142,12 +144,20 @@ def create_app() -> FastAPI:
|
||||
# eagerly via load_config(). Everything else waits for lifespan.
|
||||
config = load_config()
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
# v0.25.0 (audit 0026 M4): the session cookie is the primary 30-day
|
||||
# auth credential and must carry `Secure` in production so it never
|
||||
# travels cleartext. Default to Secure; a dev box serving over plain
|
||||
# http opts out with SESSION_COOKIE_SECURE=false. Production (OHM is
|
||||
# HTTPS-only with an HTTP->HTTPS 301) leaves this unset → Secure on.
|
||||
session_secure = os.environ.get("SESSION_COOKIE_SECURE", "true").strip().lower() not in (
|
||||
"0", "false", "no", "off",
|
||||
)
|
||||
app.add_middleware(
|
||||
SessionMiddleware,
|
||||
secret_key=config.secret_key,
|
||||
session_cookie="rfc_session",
|
||||
max_age=60 * 60 * 24 * 30,
|
||||
https_only=False,
|
||||
https_only=session_secure,
|
||||
)
|
||||
return app
|
||||
|
||||
@@ -155,24 +165,25 @@ def create_app() -> FastAPI:
|
||||
app = create_app()
|
||||
|
||||
|
||||
def _set_device_trust_cookie(response: Response, raw_token: str) -> None:
|
||||
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=/. The
|
||||
cookie value is the raw token; server-side storage is the hash.
|
||||
The cookie is "essential" per the v0.13.0 cookie-consent contract
|
||||
(it is part of authentication), so we set it regardless of the
|
||||
user's analytics / other-cookies choice.
|
||||
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
|
||||
SessionMiddleware in `create_app` keeps `https_only=False` for
|
||||
dev parity, but the device-trust cookie holds a 30-day credential
|
||||
and must not travel cleartext — production deployments serve over
|
||||
HTTPS, so Secure on the device-trust cookie is non-negotiable.
|
||||
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=raw_token,
|
||||
value=cookie_value,
|
||||
max_age=device_trust_mod.COOKIE_MAX_AGE_SECONDS,
|
||||
path="/",
|
||||
secure=True,
|
||||
@@ -245,6 +256,10 @@ def _oauth_router(config) -> APIRouter:
|
||||
|
||||
@router.post("/auth/otc/request")
|
||||
async def otc_request(body: OtcRequestBody, request: Request):
|
||||
# v0.25.0 (audit 0026 H1/L2): per-IP brake at the cheapest point,
|
||||
# before the Turnstile network call or any bcrypt/SMTP work.
|
||||
if not ratelimit.otc_request_limiter.allow(ratelimit.client_key(request)):
|
||||
raise HTTPException(429, "Too many requests; please wait a few minutes")
|
||||
# v0.12.0 / roadmap item #10: gate the request on a successful
|
||||
# Turnstile siteverify before the bcrypt hash + SMTP send. The
|
||||
# check runs first so a failed challenge spends no rate budget
|
||||
@@ -252,7 +267,7 @@ def _oauth_router(config) -> APIRouter:
|
||||
# secret AND TURNSTILE_REQUIRED=false (the default), the gate
|
||||
# opens — see `backend/app/turnstile.py` for the full matrix.
|
||||
client_ip = request.client.host if request.client else None
|
||||
ts = turnstile.verify_token(body.turnstile_token, client_ip=client_ip)
|
||||
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
|
||||
@@ -278,9 +293,25 @@ def _oauth_router(config) -> APIRouter:
|
||||
|
||||
@router.post("/auth/otc/verify")
|
||||
async def otc_verify(body: OtcVerifyBody, request: Request, response: Response):
|
||||
# v0.25.0 (audit 0026 H1): per-IP brake against fan-out guessing,
|
||||
# plus the per-email lockout enforced inside otc.verify_code.
|
||||
ip = ratelimit.client_key(request)
|
||||
if not ratelimit.verify_limiter.allow(ip):
|
||||
raise HTTPException(429, "Too many attempts; please wait a few minutes")
|
||||
result = otc.verify_code(body.email, body.code)
|
||||
if result.reason == "locked":
|
||||
raise HTTPException(
|
||||
423,
|
||||
{
|
||||
"detail": "Too many failed attempts; wait a few minutes or request a new code",
|
||||
"locked_until": result.locked_until,
|
||||
},
|
||||
)
|
||||
if not result.ok or result.user is None:
|
||||
raise HTTPException(400, "Invalid or expired code")
|
||||
# Legit sign-in: clear this IP's window so a user who fat-fingered
|
||||
# a couple of codes isn't left throttled.
|
||||
ratelimit.verify_limiter.reset(ip)
|
||||
auth.store_session(request, result.user)
|
||||
# v0.8.0: surface `needs_profile` so the Login.jsx surface can
|
||||
# decide whether to advance to the first/last/why capture step
|
||||
@@ -313,7 +344,7 @@ def _oauth_router(config) -> APIRouter:
|
||||
if body.trust_device:
|
||||
ua = request.headers.get("user-agent", "")
|
||||
outcome = device_trust_mod.issue(result.user.user_id, ua)
|
||||
_set_device_trust_cookie(response, outcome.raw_token)
|
||||
_set_device_trust_cookie(response, outcome.cookie_value)
|
||||
return {
|
||||
"ok": True,
|
||||
"user": {
|
||||
@@ -337,12 +368,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}
|
||||
|
||||
@@ -376,6 +412,11 @@ def _oauth_router(config) -> APIRouter:
|
||||
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(
|
||||
@@ -387,11 +428,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.raw_token)
|
||||
_set_device_trust_cookie(response, outcome.cookie_value)
|
||||
return {
|
||||
"ok": True,
|
||||
"user": {
|
||||
@@ -459,7 +501,7 @@ def _oauth_router(config) -> APIRouter:
|
||||
if body.trust_device:
|
||||
ua = request.headers.get("user-agent", "")
|
||||
outcome = device_trust_mod.issue(result.user.user_id, ua)
|
||||
_set_device_trust_cookie(response, outcome.raw_token)
|
||||
_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
|
||||
|
||||
@@ -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,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"
|
||||
+239
-64
@@ -1,43 +1,95 @@
|
||||
"""Roadmap #28 Part 1 — auto-link RFC references in submitted prose.
|
||||
"""Roadmap #28 — scan submitted prose for RFC-shaped references.
|
||||
|
||||
Scans plain-text PR descriptions and comment bodies for references to
|
||||
existing **accepted** (state='active') RFCs and returns a structured list
|
||||
The scanner splits a plain-text PR description / comment body into a list
|
||||
of *segments* the frontend renders: plain-text runs interleaved with
|
||||
``{"type": "rfc", ...}`` link segments. The backend never emits HTML —
|
||||
the frontend maps link segments onto React anchors — so the surface is
|
||||
XSS-safe by construction and independent of any HTML-sanitization layer.
|
||||
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.
|
||||
|
||||
**Read-time enrichment, not submit-time persistence.** The roadmap row
|
||||
phrases the scan as happening "at submit/post time"; this module instead
|
||||
enriches on read. The intent the roadmap actually names — "not as live
|
||||
compose preview" — is honored (drafts are never scanned, only submitted
|
||||
content on the read paths). Read-time was chosen for three reasons:
|
||||
Three buckets, one scan (Parts 1–3):
|
||||
|
||||
1. Correctness — links track the *live* active-RFC set. A newly-accepted
|
||||
RFC starts linking in older comments; a withdrawn RFC stops linking
|
||||
everywhere. Submit-time freezing would drift stale.
|
||||
2. Zero migration — no derived data to store. (A concurrent session
|
||||
already holds migration 023; staying migration-free keeps this slice
|
||||
conflict-free as well as simpler.)
|
||||
3. Cost — the active-RFC corpus is small and cache-resident, so building
|
||||
the term index and scanning a ≤20k-char body per read is cheap.
|
||||
* ``{"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.
|
||||
|
||||
**Matching is conservative by design.** Only references that are unlikely
|
||||
to be coincidental link:
|
||||
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 or slugs (e.g. a hypothetical RFC titled
|
||||
"Human") are deliberately NOT auto-linked — they would turn every prose
|
||||
"human" into a link. Surfacing those is the job of the roadmap's
|
||||
"curated canonical-terms list", an explicit per-deployment opt-in left as
|
||||
a future extension rather than guessed at here.
|
||||
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
|
||||
|
||||
from typing import Any, Iterable
|
||||
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:
|
||||
@@ -46,18 +98,37 @@ def _is_word_char(c: str) -> bool:
|
||||
return c.isalnum() or c in ("-", "_")
|
||||
|
||||
|
||||
def segment_text(text: str | None, terms: list[tuple[str, str, str]]) -> list[dict[str, Any]]:
|
||||
"""Split ``text`` into text / rfc-link segments against ``terms``.
|
||||
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}
|
||||
|
||||
``terms`` is a list of ``(key_lower, slug, title)`` tuples; callers
|
||||
pass it pre-sorted longest-first so the longest match wins at any
|
||||
position (so "Open Human Model" wins over a bare "Open"). Matching is
|
||||
case-insensitive and respects word boundaries on both ends. The
|
||||
returned ``label`` preserves the source casing.
|
||||
|
||||
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 ""}]
|
||||
|
||||
@@ -67,28 +138,23 @@ def segment_text(text: str | None, terms: list[tuple[str, str, str]]) -> list[di
|
||||
n = len(text)
|
||||
i = 0
|
||||
while i < n:
|
||||
match: tuple[str, str, str, int] | None = None
|
||||
for key, slug, title in terms:
|
||||
klen = len(key)
|
||||
if klen == 0 or not low.startswith(key, i):
|
||||
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 = (key, slug, title, klen)
|
||||
match = (term, klen)
|
||||
break
|
||||
if match is not None:
|
||||
_key, slug, title, klen = match
|
||||
term, klen = match
|
||||
if buf:
|
||||
out.append({"type": "text", "text": "".join(buf)})
|
||||
buf = []
|
||||
out.append({
|
||||
"type": "rfc",
|
||||
"slug": slug,
|
||||
"label": text[i:i + klen],
|
||||
"title": title,
|
||||
})
|
||||
out.append(_emit(term, text[i:i + klen]))
|
||||
i += klen
|
||||
else:
|
||||
buf.append(text[i])
|
||||
@@ -99,8 +165,8 @@ def segment_text(text: str | None, terms: list[tuple[str, str, str]]) -> list[di
|
||||
|
||||
|
||||
def _keys_for(slug: str, title: str, rfc_id: str | None) -> Iterable[str]:
|
||||
"""The match keys an active RFC contributes. See the module docstring
|
||||
for why each gate exists (conservative, false-positive-averse)."""
|
||||
"""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:
|
||||
@@ -117,13 +183,23 @@ def _keys_for(slug: str, title: str, rfc_id: str | None) -> Iterable[str]:
|
||||
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: list[tuple[str, str, str]]):
|
||||
# Longest key first so the longest reference wins at each position.
|
||||
self._terms = sorted(terms, key=lambda t: len(t[0]), reverse=True)
|
||||
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)
|
||||
@@ -132,27 +208,126 @@ class LinkIndex:
|
||||
return segment_text(text, self._terms)
|
||||
|
||||
|
||||
def build_index(conn, *, exclude_slug: str | None = None) -> LinkIndex:
|
||||
"""Build a :class:`LinkIndex` from the accepted (active) RFC corpus.
|
||||
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"
|
||||
|
||||
``exclude_slug`` drops the RFC the surrounding surface is itself scoped
|
||||
to, so an RFC's own title/id/slug don't self-link inside its own PR or
|
||||
discussion. ``ORDER BY slug`` makes key de-duplication deterministic
|
||||
when two RFCs would contribute the same key (first slug wins)."""
|
||||
rows = conn.execute(
|
||||
"SELECT slug, title, rfc_id FROM cached_rfcs WHERE state = 'active' ORDER BY slug"
|
||||
).fetchall()
|
||||
terms: list[tuple[str, str, str]] = []
|
||||
|
||||
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):
|
||||
if key in seen:
|
||||
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
|
||||
seen.add(key)
|
||||
terms.append((key, slug, title))
|
||||
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)
|
||||
|
||||
@@ -73,6 +73,19 @@ 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.
|
||||
@@ -98,7 +111,7 @@ class VerifyOutcome:
|
||||
reason: str
|
||||
|
||||
|
||||
def verify_token(token: str | None, *, client_ip: str | None = None) -> VerifyOutcome:
|
||||
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
|
||||
@@ -108,9 +121,14 @@ def verify_token(token: str | None, *, client_ip: str | None = None) -> VerifyOu
|
||||
* 'misconfigured' → 500 "auth misconfigured"
|
||||
* 'missing-token' / 'failed' / 'network' → 400 "verification failed"
|
||||
|
||||
Tests monkeypatch `httpx.post` (or set `TURNSTILE_SITEVERIFY_URL`
|
||||
+ a MockTransport client) to avoid touching the real CloudFlare
|
||||
endpoint. No real keys are ever embedded in tests.
|
||||
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()
|
||||
@@ -133,7 +151,7 @@ def verify_token(token: str | None, *, client_ip: str | None = None) -> VerifyOu
|
||||
data["remoteip"] = client_ip
|
||||
|
||||
try:
|
||||
response = httpx.post(_siteverify_url(), data=data, timeout=10.0)
|
||||
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)
|
||||
|
||||
@@ -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,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,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
|
||||
@@ -11,6 +11,8 @@ from __future__ import annotations
|
||||
|
||||
from email.utils import parsedate_to_datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from app.email_envelope import build_envelope
|
||||
|
||||
|
||||
@@ -160,14 +162,18 @@ def test_envelope_plain_only_body_is_text_plain():
|
||||
assert msg.get_content().strip() == "Hello, world."
|
||||
|
||||
|
||||
def test_envelope_with_html_is_multipart_alternative():
|
||||
msg = build_envelope(**_base_kwargs(body_html="<p>Hello, <b>world</b>.</p>"))
|
||||
assert msg.get_content_type() == "multipart/alternative"
|
||||
# Two parts: text/plain first (so plain-text clients picking the
|
||||
# first part get the readable text), text/html second.
|
||||
parts = list(msg.iter_parts())
|
||||
assert len(parts) == 2
|
||||
assert parts[0].get_content_type() == "text/plain"
|
||||
assert parts[1].get_content_type() == "text/html"
|
||||
assert "Hello, world." in parts[0].get_content()
|
||||
assert "<b>world</b>" in parts[1].get_content()
|
||||
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"
|
||||
|
||||
@@ -444,6 +444,18 @@ def tmp_env(monkeypatch):
|
||||
# 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)
|
||||
@@ -565,6 +577,97 @@ def test_propose_to_super_draft_vertical(app_with_fake_gitea):
|
||||
assert ("merge_proposal", "ben") in kinds
|
||||
|
||||
|
||||
def test_merged_idea_pr_with_deleted_branch_clears_proposal(app_with_fake_gitea):
|
||||
"""Regression: a merged idea PR whose branch was deleted must not
|
||||
linger as a 'pending idea' ghost.
|
||||
|
||||
Found via the ROADMAP #35 operator authoring lane: merging an idea
|
||||
PR from the CLI with `--delete-branch` makes Gitea report the PR's
|
||||
`head.ref` as the synthetic `refs/pull/<N>/head` sentinel instead of
|
||||
`propose/<slug>`. `refresh_meta_pulls` derives the slug from the
|
||||
branch name, so the sentinel parsed to slug=None, the row was skipped,
|
||||
and `cached_prs.state` stayed frozen at 'open' — leaving the entry
|
||||
showing as BOTH a super-draft (cached_rfcs reconciled off the push)
|
||||
AND a pending idea (cached_prs never updated). The fix recovers the
|
||||
original branch name from the already-stored cached_prs row.
|
||||
|
||||
The web UX never tripped this because it leaves the branch in place
|
||||
(the repo's default_delete_branch_after_merge is false).
|
||||
|
||||
The bug only manifests on an out-of-band merge (the PR merged +
|
||||
branch deleted directly in Gitea, with the in-app merge endpoint never
|
||||
reconciling the row while the branch still existed) -- which is exactly
|
||||
what the #35 CLI lane does. An in-app merge reconciles cached_prs to
|
||||
'merged' before the branch is gone, so it never trips this; the test
|
||||
therefore drives the Gitea state directly to reproduce the CLI path.
|
||||
"""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db, cache, gitea as gitea_mod
|
||||
from app.config import load_config
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor", email="alice@test")
|
||||
r = client.post("/api/rfcs/propose", json={
|
||||
"title": "Informed Consent",
|
||||
"slug": "informed-consent",
|
||||
"pitch": "A first-class definition of consent in OHM.",
|
||||
"tags": [],
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
pr_number = r.json()["pr_number"]
|
||||
|
||||
# The proposal is cached as an open idea PR.
|
||||
items = client.get("/api/proposals").json()["items"]
|
||||
assert any(i["pr_number"] == pr_number for i in items)
|
||||
|
||||
# Out-of-band CLI merge (ROADMAP #35 lane): the PR is merged AND
|
||||
# its branch deleted directly in Gitea, WITHOUT the in-app merge
|
||||
# endpoint ever running. So cached_prs still says state='open' and
|
||||
# Gitea now reports the merged PR's head.ref as the sentinel. This
|
||||
# is the exact state `rfc-authoring.sh pr-merge --delete-branch`
|
||||
# leaves behind.
|
||||
for pr in fake.pulls[("wiggleverse", "meta")]:
|
||||
if pr["number"] == pr_number:
|
||||
# land the file on main (the push side already reconciles
|
||||
# cached_rfcs into a super-draft via the webhook/sweep)
|
||||
for (o, rp, br, p), data in list(fake.files.items()):
|
||||
if (o, rp, br) == ("wiggleverse", "meta", "propose/informed-consent"):
|
||||
fake.files[("wiggleverse", "meta", "main", p)] = dict(data)
|
||||
pr["state"] = "closed"
|
||||
pr["merged"] = True
|
||||
pr["merged_at"] = "2026-05-29T12:13:00Z"
|
||||
pr["closed_at"] = "2026-05-29T12:13:00Z"
|
||||
pr["merge_commit_sha"] = fake._next_sha()
|
||||
pr["head"]["ref"] = f"refs/pull/{pr_number}/head"
|
||||
fake.branches[("wiggleverse", "meta")].pop("propose/informed-consent", None)
|
||||
|
||||
# The reconcile sweep runs (a later webhook, or the 5-min safety net).
|
||||
import asyncio
|
||||
cfg = load_config()
|
||||
gclient = gitea_mod.Gitea(cfg)
|
||||
asyncio.run(cache.refresh_meta_repo(cfg, gclient))
|
||||
asyncio.run(cache.refresh_meta_pulls(cfg, gclient))
|
||||
|
||||
# The bug: this used to still list informed-consent (frozen 'open'
|
||||
# row, slug unparseable from the sentinel). The fix recovers the
|
||||
# stored branch name, so the row reconciles to merged and the ghost
|
||||
# is gone.
|
||||
assert client.get("/api/proposals").json()["items"] == []
|
||||
|
||||
# And the cached_prs row is correctly merged, not a frozen 'open'.
|
||||
row = db.conn().execute(
|
||||
"SELECT state FROM cached_prs WHERE pr_number = ?", (pr_number,)
|
||||
).fetchone()
|
||||
assert row["state"] == "merged", f"expected merged, got {row['state']}"
|
||||
|
||||
# The super-draft itself is unaffected — still in the catalog.
|
||||
items = client.get("/api/rfcs").json()["items"]
|
||||
assert any(i["slug"] == "informed-consent" and i["state"] == "super-draft" for i in items)
|
||||
|
||||
|
||||
def test_slug_uniqueness_enforced(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
app, _fake = app_with_fake_gitea
|
||||
|
||||
@@ -94,6 +94,44 @@ def test_longest_match_wins():
|
||||
assert out[-1]["label"] == "Open Human Model"
|
||||
|
||||
|
||||
def test_pending_term_emits_contribute_segment():
|
||||
# Part 3: a super-draft match is an `rfc-pending` segment carrying the
|
||||
# owner display name, not a plain link.
|
||||
idx = rfc_links.LinkIndex([
|
||||
rfc_links.Term(key="open human model", kind="pending",
|
||||
slug="open-human-model", title="Open Human Model", owner="Alice"),
|
||||
])
|
||||
out = idx.segment("see Open Human Model please")
|
||||
assert out[1] == {
|
||||
"type": "rfc-pending", "slug": "open-human-model",
|
||||
"label": "Open Human Model", "title": "Open Human Model", "owner": "Alice",
|
||||
}
|
||||
|
||||
|
||||
def test_candidate_term_emits_create_segment():
|
||||
# Part 2: a candidate term carries its canonical spelling for the
|
||||
# propose pre-fill; no slug (no RFC exists yet).
|
||||
idx = rfc_links.LinkIndex([
|
||||
rfc_links.Term(key="memory model", kind="candidate", term="Memory Model"),
|
||||
])
|
||||
out = idx.segment("the memory model is unspecified")
|
||||
assert out[1] == {"type": "rfc-candidate", "label": "memory model", "term": "Memory Model"}
|
||||
|
||||
|
||||
def test_kind_precedence_active_beats_pending_beats_candidate():
|
||||
# All three buckets contribute the same key; the highest-precedence
|
||||
# kind (active) must win at the position.
|
||||
key = "open human model"
|
||||
idx = rfc_links.LinkIndex([
|
||||
rfc_links.Term(key=key, kind="candidate", term="Open Human Model"),
|
||||
rfc_links.Term(key=key, kind="pending", slug="ohm-draft", title="Open Human Model", owner="A"),
|
||||
rfc_links.Term(key=key, kind="active", slug="open-human-model", title="Open Human Model"),
|
||||
])
|
||||
out = idx.segment("the Open Human Model")
|
||||
assert out[-1]["type"] == "rfc"
|
||||
assert out[-1]["slug"] == "open-human-model"
|
||||
|
||||
|
||||
def test_keys_for_gating():
|
||||
keys = lambda **kw: set(rfc_links._keys_for(**kw))
|
||||
# rfc_id always contributes.
|
||||
|
||||
@@ -55,13 +55,19 @@ def _outbound_otc_envelopes(to_address: str | None = None) -> list[dict]:
|
||||
|
||||
|
||||
def _patch_siteverify(monkeypatch, *, success: bool, error_codes: list[str] | None = None):
|
||||
"""Replace `httpx.post` inside `app.turnstile` with a stub that
|
||||
"""Replace `turnstile._siteverify_post` with an async stub that
|
||||
returns the requested success shape. The stub does not touch the
|
||||
real CloudFlare endpoint and never sees a real secret.
|
||||
|
||||
I4 (security-audit-0026): the siteverify call is now awaited on an
|
||||
`httpx.AsyncClient`, isolated behind the `_siteverify_post` seam.
|
||||
Patching that narrow function (rather than the shared
|
||||
`httpx.AsyncClient`, which gitea/docs also construct) keeps app boot
|
||||
intact.
|
||||
"""
|
||||
captured = {}
|
||||
|
||||
def fake_post(url, *, data=None, timeout=None, **kwargs):
|
||||
async def fake_post(url, data):
|
||||
captured["url"] = url
|
||||
captured["data"] = data
|
||||
body = {"success": bool(success)}
|
||||
@@ -70,7 +76,7 @@ def _patch_siteverify(monkeypatch, *, success: bool, error_codes: list[str] | No
|
||||
return SimpleNamespace(json=lambda: body)
|
||||
|
||||
from app import turnstile as turnstile_mod
|
||||
monkeypatch.setattr(turnstile_mod.httpx, "post", fake_post)
|
||||
monkeypatch.setattr(turnstile_mod, "_siteverify_post", fake_post)
|
||||
return captured
|
||||
|
||||
|
||||
@@ -170,14 +176,15 @@ def test_otc_request_admits_when_secret_unset_and_not_required(app_with_fake_git
|
||||
|
||||
monkeypatch.delenv("CLOUDFLARE_TURNSTILE_SECRET", raising=False)
|
||||
monkeypatch.delenv("TURNSTILE_REQUIRED", raising=False)
|
||||
# The httpx.post inside turnstile must not be called in this path —
|
||||
# patch it to a sentinel that explodes if it ever runs.
|
||||
# The siteverify call inside turnstile must not be made in this path —
|
||||
# patch the seam to a sentinel that explodes if it ever runs
|
||||
# (I4: the seam is now `_siteverify_post`, not module-level httpx.post).
|
||||
from app import turnstile as turnstile_mod
|
||||
|
||||
def must_not_be_called(*a, **kw):
|
||||
async def must_not_be_called(*a, **kw):
|
||||
raise AssertionError("siteverify should not run when no secret is configured")
|
||||
|
||||
monkeypatch.setattr(turnstile_mod.httpx, "post", must_not_be_called)
|
||||
monkeypatch.setattr(turnstile_mod, "_siteverify_post", must_not_be_called)
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
@@ -218,3 +225,27 @@ def test_otc_request_refuses_when_required_but_secret_unset(app_with_fake_gitea,
|
||||
)
|
||||
assert r.status_code == 500, r.text
|
||||
assert _outbound_otc_envelopes("alice@example.com") == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# I4 (security-audit-0026): verify_token is a coroutine — calling it returns
|
||||
# an awaitable, not a VerifyOutcome. Locks the async contract so a revert to
|
||||
# the synchronous event-loop-blocking shape fails here, not just in the
|
||||
# integration paths.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_verify_token_is_async_and_soft_skips_without_secret(monkeypatch):
|
||||
import asyncio
|
||||
|
||||
from app import turnstile as turnstile_mod
|
||||
|
||||
monkeypatch.delenv("CLOUDFLARE_TURNSTILE_SECRET", raising=False)
|
||||
monkeypatch.delenv("TURNSTILE_REQUIRED", raising=False)
|
||||
|
||||
coro = turnstile_mod.verify_token("any-token")
|
||||
assert asyncio.iscoroutine(coro), "verify_token must be a coroutine (I4)"
|
||||
outcome = asyncio.run(coro)
|
||||
# No secret + not required → the gate stays open without any network call.
|
||||
assert outcome.ok is True
|
||||
assert outcome.reason == "skipped"
|
||||
|
||||
@@ -18,6 +18,56 @@ server {
|
||||
listen [::]:80;
|
||||
server_name ohm.wiggleverse.org;
|
||||
|
||||
# v0.25.0 security hardening (audit 0026 M2/L8)
|
||||
#
|
||||
# NOTE: certbot promotes THIS server block to the HTTPS listener
|
||||
# (`listen 443 ssl`) and adds a separate port-80 → 443 redirect
|
||||
# block (see the install comment above). These response headers
|
||||
# therefore ride into the HTTPS server block on the VM. They use
|
||||
# `add_header ... always` so they also apply to nginx-generated
|
||||
# error responses (4xx/5xx), not just 200s.
|
||||
#
|
||||
# `server_tokens off` (L8) — suppress the nginx version in the
|
||||
# Server header and on error pages so we don't advertise the
|
||||
# build to scanners.
|
||||
server_tokens off;
|
||||
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# Content-Security-Policy (M2). Tuned to what the SPA actually loads:
|
||||
# - default-src 'self': everything not called out below is same-origin.
|
||||
# - script-src 'self' + challenges.cloudflare.com: the only external
|
||||
# <script> tag the app injects is the CloudFlare Turnstile widget
|
||||
# (frontend/src/components/TurnstileWidget.jsx). Amplitude and
|
||||
# mermaid are BUNDLED (dynamic `import()` from node_modules, served
|
||||
# from 'self'), so they need no extra script origin — *.amplitude.com
|
||||
# is listed defensively in case a future SDK build script-injects.
|
||||
# script-src DELIBERATELY OMITS 'unsafe-inline' — no inline <script>
|
||||
# is used, so we keep XSS-via-inline-script blocked.
|
||||
# - style-src 'unsafe-inline' IS REQUIRED by the current build: the
|
||||
# JSX uses inline `style={...}` attributes throughout and mermaid
|
||||
# injects <style> blocks at render time. Removing it would break
|
||||
# layout; tightening this is a future build-side change (nonce/hash).
|
||||
# - img-src 'self' data: https: — markdown/RFC bodies may embed remote
|
||||
# images and data: URIs; svg/mermaid output uses data: too.
|
||||
# - font-src 'self' data: — bundled fonts plus data: webfonts.
|
||||
# - connect-src 'self' + *.amplitude.com + challenges.cloudflare.com:
|
||||
# the app's API/auth/SSE are same-origin (nginx proxy); Amplitude
|
||||
# Analytics + Session Replay (shipped at sampleRate 1) POST to
|
||||
# *.amplitude.com; Turnstile verifies via challenges.cloudflare.com.
|
||||
# - worker-src 'self' blob: — Amplitude Session Replay spins up a
|
||||
# Web Worker from a blob: URL for capture/compression; without
|
||||
# blob: here session replay breaks for every consenting user.
|
||||
# - frame-src challenges.cloudflare.com — the Turnstile challenge
|
||||
# renders in an iframe from that origin.
|
||||
# - frame-ancestors 'none' — clickjacking defense, pairs with
|
||||
# X-Frame-Options DENY for older agents.
|
||||
# - base-uri 'self'; object-src 'none' — lock down <base>/<object>.
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://challenges.cloudflare.com https://*.amplitude.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https://*.amplitude.com https://challenges.cloudflare.com; worker-src 'self' blob:; frame-src https://challenges.cloudflare.com; frame-ancestors 'none'; base-uri 'self'; object-src 'none'" always;
|
||||
|
||||
# Static SPA assets live in the Vite build output. The systemd unit
|
||||
# runs as user `rfc-app`; make sure nginx (usually `www-data`) can
|
||||
# read this path. Either group-add www-data into rfc-app's group, or
|
||||
|
||||
@@ -42,5 +42,32 @@ ProtectHome=true
|
||||
PrivateTmp=true
|
||||
ReadWritePaths=/opt/rfc-app/backend/data
|
||||
|
||||
# v0.25.0 security hardening (audit 0026 L4) — defense-in-depth.
|
||||
# The service binds 127.0.0.1:8000 and runs plain CPython
|
||||
# (FastAPI/uvicorn + sqlite + bcrypt + httpx), so it needs no
|
||||
# capabilities and no exotic syscalls.
|
||||
CapabilityBoundingSet=
|
||||
AmbientCapabilities=
|
||||
PrivateDevices=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectKernelLogs=true
|
||||
ProtectControlGroups=true
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
||||
RestrictNamespaces=true
|
||||
LockPersonality=true
|
||||
# MemoryDenyWriteExecute=true blocks W^X memory — safe for stock
|
||||
# CPython (no JIT) and the pure-Python/C-extension stack here, but
|
||||
# would break a JIT or a C-ext that mmaps W+X. Watch the first
|
||||
# restart's journal for a crash; if uvicorn fails to come up,
|
||||
# comment this one line out and reload.
|
||||
MemoryDenyWriteExecute=true
|
||||
RestrictRealtime=true
|
||||
RestrictSUIDSGID=true
|
||||
SystemCallFilter=@system-service
|
||||
SystemCallErrorNumber=EPERM
|
||||
SystemCallArchitectures=native
|
||||
UMask=0077
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
Generated
+3
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "rfc-app-frontend",
|
||||
"version": "0.21.0",
|
||||
"version": "0.24.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "rfc-app-frontend",
|
||||
"version": "0.21.0",
|
||||
"version": "0.24.0",
|
||||
"dependencies": {
|
||||
"@amplitude/unified": "^1.1.9",
|
||||
"@codemirror/commands": "^6.10.3",
|
||||
@@ -18,6 +18,7 @@
|
||||
"@tiptap/pm": "^3.5.0",
|
||||
"@tiptap/react": "^3.5.0",
|
||||
"@tiptap/starter-kit": "^3.5.0",
|
||||
"dompurify": "^3.2.4",
|
||||
"marked": "^18.0.4",
|
||||
"mermaid": "^11.15.0",
|
||||
"react": "^19.2.6",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "rfc-app-frontend",
|
||||
"private": true,
|
||||
"version": "0.26.0",
|
||||
"version": "0.30.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -19,6 +19,7 @@
|
||||
"@tiptap/pm": "^3.5.0",
|
||||
"@tiptap/react": "^3.5.0",
|
||||
"@tiptap/starter-kit": "^3.5.0",
|
||||
"dompurify": "^3.2.4",
|
||||
"marked": "^18.0.4",
|
||||
"mermaid": "^11.15.0",
|
||||
"react": "^19.2.6",
|
||||
|
||||
@@ -1365,6 +1365,30 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
.rfc-autolink:hover { text-decoration-style: solid; }
|
||||
|
||||
/* #28 Parts 2–3: a matched term that isn't a live link but carries an
|
||||
offer (contribute to a pending RFC / create a new one). The term reads
|
||||
as enriched (dotted underline, no link colour); the offer is a small
|
||||
trailing chip so the prose stays readable. */
|
||||
.rfc-pending, .rfc-candidate {
|
||||
text-decoration: underline;
|
||||
text-decoration-style: dotted;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
.rfc-offer {
|
||||
margin-left: 4px;
|
||||
padding: 0 5px;
|
||||
font-size: 0.74em;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
border-radius: 6px;
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
border: 1px solid var(--color-border, #ccc);
|
||||
color: var(--color-link);
|
||||
}
|
||||
.rfc-offer:hover { background: var(--color-surface-alt, rgba(0,0,0,0.04)); }
|
||||
.rfc-offer-create { border-style: dashed; }
|
||||
.pr-header-edit { display: flex; flex-direction: column; gap: 8px; }
|
||||
.pr-header-right {
|
||||
display: flex; flex-direction: column; align-items: flex-end; gap: 8px;
|
||||
|
||||
+27
-4
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Routes, Route, Link, Navigate, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { Routes, Route, Link, Navigate, useLocation, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { getMe, subscribeToNotifications } from './api'
|
||||
import { anonymize, EVENTS, identify, track } from './lib/analytics'
|
||||
import { useLastState } from './lib/useLastState'
|
||||
@@ -9,6 +9,7 @@ import RFCView from './components/RFCView.jsx'
|
||||
import PRView from './components/PRView.jsx'
|
||||
import ProposalView from './components/ProposalView.jsx'
|
||||
import ProposeModal from './components/ProposeModal.jsx'
|
||||
import ContributeRequestForm from './components/ContributeRequestForm.jsx'
|
||||
import Landing from './components/Landing.jsx'
|
||||
import Login from './components/Login.jsx'
|
||||
import BetaPending from './components/BetaPending.jsx'
|
||||
@@ -51,6 +52,19 @@ export default function App() {
|
||||
const [identifyReady, setIdentifyReady] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
// #28 Parts 2–3: the LinkedText create/contribute affordances route via
|
||||
// query params so they need no prop-threading from deep in a comment
|
||||
// list. `?propose=<term>` opens the propose modal pre-filled;
|
||||
// `?contribute=<slug>&term=<term>` opens the contribute-request form.
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const proposeParam = searchParams.get('propose')
|
||||
const contributeSlug = searchParams.get('contribute')
|
||||
const contributeTerm = searchParams.get('term')
|
||||
const clearParams = (...keys) => {
|
||||
const next = new URLSearchParams(searchParams)
|
||||
keys.forEach(k => next.delete(k))
|
||||
setSearchParams(next, { replace: true })
|
||||
}
|
||||
// v0.15.0 — Page Viewed event taxonomy. We fire on every
|
||||
// route change; the analytics wrapper itself decides whether
|
||||
// anything ships out (consent + key check). The first fire is
|
||||
@@ -199,7 +213,7 @@ export default function App() {
|
||||
wonders why a conversation is public can reach the answer
|
||||
in two clicks. Anonymous viewers see it too. */}
|
||||
<Link to="/philosophy" className="header-about" title="Why this exists (§14)">
|
||||
Philosophy
|
||||
About
|
||||
</Link>
|
||||
<Link to="/docs" className="header-about" title="User guide">
|
||||
Docs
|
||||
@@ -313,17 +327,26 @@ export default function App() {
|
||||
} />
|
||||
</Routes>
|
||||
</div>
|
||||
{proposeOpen && viewer && (
|
||||
{(proposeOpen || proposeParam != null) && viewer && (
|
||||
<ProposeModal
|
||||
viewer={viewer}
|
||||
onClose={() => setProposeOpen(false)}
|
||||
initialTitle={proposeParam || ''}
|
||||
onClose={() => { setProposeOpen(false); clearParams('propose') }}
|
||||
onSubmitted={({ pr_number }) => {
|
||||
setProposeOpen(false)
|
||||
clearParams('propose')
|
||||
setCatalogVersion(v => v + 1)
|
||||
navigate(`/proposals/${pr_number}`)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{contributeSlug && viewer && (
|
||||
<ContributeRequestForm
|
||||
slug={contributeSlug}
|
||||
term={contributeTerm || ''}
|
||||
onClose={() => clearParams('contribute', 'term')}
|
||||
/>
|
||||
)}
|
||||
{inboxOpen && viewer && (
|
||||
<Inbox onClose={() => setInboxOpen(false)} lastChangeTick={inboxTick} />
|
||||
)}
|
||||
|
||||
@@ -226,6 +226,40 @@ export async function suggestTags({ title, pitch, useCase }) {
|
||||
}
|
||||
}
|
||||
|
||||
// Roadmap #28 Part 3: offer-to-contribute-to-a-pending-RFC.
|
||||
// `contributionTarget` feeds the contribute form (RFC title, owner
|
||||
// display, the viewer's eligibility); `requestContribution` submits the
|
||||
// ask; accept/decline are the owner's inbox actions.
|
||||
export async function contributionTarget(slug) {
|
||||
return jsonOrThrow(await fetch(`/api/rfcs/${slug}/contribution-target`))
|
||||
}
|
||||
|
||||
export async function requestContribution(slug, { matchedTerm, whoIAm, why, useCase }) {
|
||||
const res = await fetch(`/api/rfcs/${slug}/contribution-requests`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
matched_term: matchedTerm,
|
||||
who_i_am: whoIAm,
|
||||
why,
|
||||
use_case: useCase || null,
|
||||
}),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function acceptContributionRequest(slug, requestId) {
|
||||
return jsonOrThrow(await fetch(
|
||||
`/api/rfcs/${slug}/contribution-requests/${requestId}/accept`, { method: 'POST' },
|
||||
))
|
||||
}
|
||||
|
||||
export async function declineContributionRequest(slug, requestId) {
|
||||
return jsonOrThrow(await fetch(
|
||||
`/api/rfcs/${slug}/contribution-requests/${requestId}/decline`, { method: 'POST' },
|
||||
))
|
||||
}
|
||||
|
||||
export async function mergeProposal(prNumber) {
|
||||
const res = await fetch(`/api/proposals/${prNumber}/merge`, { method: 'POST' })
|
||||
return jsonOrThrow(res)
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
// ContributeRequestForm.jsx — roadmap #28 Part 3.
|
||||
//
|
||||
// The "ask to contribute" popover, opened from an `rfc-pending` affordance
|
||||
// in LinkedText (App reads `?contribute=<slug>&term=<term>`). It loads the
|
||||
// contribution target (RFC title + owner display + the viewer's
|
||||
// eligibility), shows the framing line "<owner> is working on an RFC for
|
||||
// '<term>'", and collects the three #15/#26-vocabulary fields:
|
||||
//
|
||||
// * Who I am (required, free-text)
|
||||
// * Why I'm asking (required, free-text)
|
||||
// * What I'd use it for (optional, mirrors #26)
|
||||
//
|
||||
// Submitting POSTs the request, which lands in each owner's §15 inbox.
|
||||
// When the viewer isn't eligible (anonymous, already a collaborator, or
|
||||
// has a pending ask) the form shows the backend's reason instead.
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { contributionTarget, requestContribution } from '../api'
|
||||
|
||||
export default function ContributeRequestForm({ slug, term, onClose }) {
|
||||
const [target, setTarget] = useState(null)
|
||||
const [loadError, setLoadError] = useState(null)
|
||||
const [whoIAm, setWhoIAm] = useState('')
|
||||
const [why, setWhy] = useState('')
|
||||
const [useCase, setUseCase] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [done, setDone] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let live = true
|
||||
contributionTarget(slug)
|
||||
.then(t => { if (live) setTarget(t) })
|
||||
.catch(err => { if (live) setLoadError(err.message || 'Could not load this RFC.') })
|
||||
return () => { live = false }
|
||||
}, [slug])
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault()
|
||||
if (!whoIAm.trim() || !why.trim()) return
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
await requestContribution(slug, {
|
||||
matchedTerm: term || target?.title || slug,
|
||||
whoIAm: whoIAm.trim(),
|
||||
why: why.trim(),
|
||||
useCase: useCase.trim() || null,
|
||||
})
|
||||
setDone(true)
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not send your request.')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const owner = target?.owner || 'The owner'
|
||||
const label = term || target?.title || slug
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||
<div className="modal">
|
||||
<div className="modal-header">
|
||||
<h2>Ask to contribute</h2>
|
||||
<button className="modal-close" onClick={onClose}>×</button>
|
||||
</div>
|
||||
|
||||
{loadError && (
|
||||
<div className="modal-body"><p className="field-error">{loadError}</p></div>
|
||||
)}
|
||||
|
||||
{!loadError && done && (
|
||||
<>
|
||||
<div className="modal-body">
|
||||
<p>
|
||||
Your request has been sent to <strong>{owner}</strong>. You'll hear back
|
||||
in your inbox; if it's accepted you'll get an invitation by email to
|
||||
join the RFC.
|
||||
</p>
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn-primary" onClick={onClose}>Done</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!loadError && !done && target && !target.eligible && (
|
||||
<>
|
||||
<div className="modal-body">
|
||||
<p className="field-help" style={{ marginTop: 0 }}>
|
||||
{owner} is working on an RFC for <strong>'{label}'</strong>.
|
||||
</p>
|
||||
<p>{target.already_requested
|
||||
? "You've already asked to contribute to this RFC — the owner has your request."
|
||||
: (target.reason || 'You cannot ask to contribute to this RFC right now.')}</p>
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn-secondary" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!loadError && !done && target && target.eligible && (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="modal-body">
|
||||
<p className="field-help" style={{ marginTop: 0 }}>
|
||||
<strong>{owner}</strong> is working on an RFC for <strong>'{label}'</strong>.
|
||||
Tell them a little about why you'd like to contribute.
|
||||
</p>
|
||||
|
||||
<label htmlFor="contribute-who">Who I am</label>
|
||||
<textarea
|
||||
id="contribute-who"
|
||||
value={whoIAm}
|
||||
onChange={e => setWhoIAm(e.target.value)}
|
||||
placeholder="Your name and a sentence of context."
|
||||
rows={2}
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
|
||||
<label htmlFor="contribute-why">Why I'm asking to contribute</label>
|
||||
<textarea
|
||||
id="contribute-why"
|
||||
value={why}
|
||||
onChange={e => setWhy(e.target.value)}
|
||||
placeholder="What you'd bring, or what draws you to this RFC."
|
||||
rows={3}
|
||||
required
|
||||
/>
|
||||
|
||||
<label htmlFor="contribute-use-case">What I'd use the RFC for (optional)</label>
|
||||
<textarea
|
||||
id="contribute-use-case"
|
||||
value={useCase}
|
||||
onChange={e => setUseCase(e.target.value)}
|
||||
placeholder="The concrete thing you intend to build or do with it. Optional."
|
||||
rows={2}
|
||||
/>
|
||||
|
||||
{error && <p className="field-error">{error}</p>}
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn-secondary" onClick={onClose}>Cancel</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
disabled={!whoIAm.trim() || !why.trim() || submitting}
|
||||
>
|
||||
{submitting ? 'Sending…' : 'Send request'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{!loadError && !done && !target && (
|
||||
<div className="modal-body"><p className="field-help">Loading…</p></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -17,7 +17,7 @@
|
||||
import { useEditor, EditorContent, Extension } from '@tiptap/react'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import { useEffect, useRef, useCallback } from 'react'
|
||||
import { marked } from 'marked'
|
||||
import { renderMarkdown } from '../lib/sanitizeHtml'
|
||||
import { Plugin, PluginKey } from 'prosemirror-state'
|
||||
import { Decoration, DecorationSet } from 'prosemirror-view'
|
||||
|
||||
@@ -122,7 +122,7 @@ export default function Editor({
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor || content == null) return
|
||||
const html = marked.parse(content)
|
||||
const html = renderMarkdown(content)
|
||||
editor.commands.setContent(html, false)
|
||||
}, [content, editor])
|
||||
|
||||
|
||||
@@ -126,3 +126,13 @@
|
||||
line-height: var(--leading-normal);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* #28 Part 3 — actionable contribute-request row: the requester's
|
||||
who/why/use-case detail plus an Accept/Decline pair. */
|
||||
.inbox-row-action { display: flex; flex-direction: column; gap: 8px; padding: 12px; }
|
||||
.inbox-row-action .inbox-row-main { display: flex; align-items: center; gap: 8px; }
|
||||
.inbox-request-detail { margin-left: 18px; font-size: var(--text-sm); }
|
||||
.inbox-request-detail p { margin: 2px 0; color: var(--color-text-muted); }
|
||||
.inbox-request-detail strong { color: var(--color-text); }
|
||||
.inbox-request-actions { display: flex; gap: 8px; margin-left: 18px; }
|
||||
.inbox-request-outcome { margin: 0 0 0 18px; }
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import {
|
||||
acceptContributionRequest,
|
||||
declineContributionRequest,
|
||||
listNotifications,
|
||||
markNotificationRead,
|
||||
markNotificationsReadByFilter,
|
||||
@@ -164,7 +166,71 @@ export default function Inbox({ onClose, lastChangeTick }) {
|
||||
)
|
||||
}
|
||||
|
||||
// #28 Part 3: the contribute-request row is the first actionable inbox
|
||||
// kind — it renders the requester's who/why/use-case inline and an
|
||||
// Accept/Decline pair that fire the owner's decision (accept reuses #12's
|
||||
// invite flow on the backend).
|
||||
function ContributionRequestRow({ item, onMarkRead }) {
|
||||
const unread = !item.read_at
|
||||
const x = item.extras || {}
|
||||
const [outcome, setOutcome] = useState(null) // 'accepted' | 'declined'
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
async function act(accept) {
|
||||
if (busy || outcome) return
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
if (accept) await acceptContributionRequest(item.rfc_slug, x.request_id)
|
||||
else await declineContributionRequest(item.rfc_slug, x.request_id)
|
||||
setOutcome(accept ? 'accepted' : 'declined')
|
||||
await onMarkRead(item)
|
||||
} catch (err) {
|
||||
setError(err.message || 'Action failed.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<li className={`inbox-row inbox-row-action ${unread ? 'unread' : 'read'}`}>
|
||||
<div className="inbox-row-main">
|
||||
<span className="inbox-unread-dot" aria-hidden />
|
||||
<span className={`inbox-cat cat-${item.category || 'unknown'}`}>{item.category || '·'}</span>
|
||||
<span className="inbox-summary">{item.summary}</span>
|
||||
<span className="inbox-when">{formatWhen(item.created_at)}</span>
|
||||
</div>
|
||||
<div className="inbox-request-detail">
|
||||
{x.who_i_am && <p><strong>Who:</strong> {x.who_i_am}</p>}
|
||||
{x.why && <p><strong>Why:</strong> {x.why}</p>}
|
||||
{x.use_case && <p><strong>Use case:</strong> {x.use_case}</p>}
|
||||
</div>
|
||||
{error && <p className="field-error">{error}</p>}
|
||||
{outcome ? (
|
||||
<p className="inbox-request-outcome muted">
|
||||
{outcome === 'accepted'
|
||||
? 'Accepted — an invitation has been sent.'
|
||||
: 'Declined.'}
|
||||
</p>
|
||||
) : (
|
||||
<div className="inbox-request-actions">
|
||||
<button type="button" className="btn-primary" disabled={busy || !x.request_id} onClick={() => act(true)}>
|
||||
Accept
|
||||
</button>
|
||||
<button type="button" className="btn-secondary" disabled={busy || !x.request_id} onClick={() => act(false)}>
|
||||
Decline
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function InboxRow({ item, onClick, onMarkRead, onClose }) {
|
||||
if (item.event_kind === 'contribution_request_on_pending_rfc') {
|
||||
return <ContributionRequestRow item={item} onMarkRead={onMarkRead} />
|
||||
}
|
||||
const unread = !item.read_at
|
||||
const target = deepLink(item)
|
||||
const handle = async () => {
|
||||
|
||||
@@ -1,21 +1,40 @@
|
||||
// LinkedText.jsx — roadmap #28 Part 1.
|
||||
// LinkedText.jsx — roadmap #28 (Parts 1–3).
|
||||
//
|
||||
// Renders a backend-provided list of text/rfc-link segments (see
|
||||
// backend/app/rfc_links.py). RFC references in PR descriptions and
|
||||
// comments arrive pre-scanned as structured segments — this component
|
||||
// maps them onto plain text runs and anchor elements. It never renders
|
||||
// Renders a backend-provided list of text/link segments (see
|
||||
// backend/app/rfc_links.py). References in PR descriptions and comments
|
||||
// arrive pre-scanned as structured segments — this component maps them
|
||||
// onto plain text runs, anchors, and inline affordances. It never renders
|
||||
// HTML from the server (no dangerouslySetInnerHTML), so the surface is
|
||||
// XSS-safe regardless of what a comment author typed.
|
||||
//
|
||||
// Segment types:
|
||||
// * `rfc` — Part 1: a link to an accepted (active) RFC.
|
||||
// * `rfc-pending` — Part 3: the term names a pending (super-draft)
|
||||
// RFC; a signed-in viewer who isn't its owner gets
|
||||
// an inline "ask to contribute" affordance routing
|
||||
// to the contribute form (App reads `?contribute=`).
|
||||
// * `rfc-candidate` — Part 2: a strong-candidate term with no RFC yet;
|
||||
// a viewer with create rights (`canCreate`) gets a
|
||||
// "create RFC" affordance routing to the propose
|
||||
// flow pre-filled (App reads `?propose=`).
|
||||
//
|
||||
// Affordances degrade to plain text when the viewer lacks the relevant
|
||||
// right, so the visible prose is identical for everyone — only the
|
||||
// offered actions differ.
|
||||
//
|
||||
// `segments` is the enriched array; `text` is the raw fallback used when
|
||||
// the field is absent (an older cached response, or a caller that didn't
|
||||
// pass segments). Either way the visible text is identical — only the
|
||||
// links differ.
|
||||
// pass segments).
|
||||
|
||||
export default function LinkedText({ segments, text }) {
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
export default function LinkedText({ segments, text, viewer, canCreate }) {
|
||||
if (!Array.isArray(segments) || segments.length === 0) {
|
||||
return <>{text ?? ''}</>
|
||||
}
|
||||
// A signed-in, beta-granted viewer can ask to contribute; the backend
|
||||
// re-checks ownership/collaborator status and rejects self-requests.
|
||||
const canContribute = !!viewer && viewer.permission_state === 'granted'
|
||||
return (
|
||||
<>
|
||||
{segments.map((seg, i) => {
|
||||
@@ -31,6 +50,40 @@ export default function LinkedText({ segments, text }) {
|
||||
</a>
|
||||
)
|
||||
}
|
||||
if (seg.type === 'rfc-pending') {
|
||||
const who = seg.owner || 'Someone'
|
||||
return (
|
||||
<span key={i} className="rfc-pending">
|
||||
{seg.label}
|
||||
{canContribute && (
|
||||
<Link
|
||||
className="rfc-offer rfc-offer-contribute"
|
||||
to={`?contribute=${encodeURIComponent(seg.slug)}&term=${encodeURIComponent(seg.label)}`}
|
||||
title={`${who} is working on an RFC for '${seg.label}' — ask to contribute`}
|
||||
>
|
||||
ask to contribute
|
||||
</Link>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (seg.type === 'rfc-candidate') {
|
||||
const term = seg.term || seg.label
|
||||
return (
|
||||
<span key={i} className="rfc-candidate">
|
||||
{seg.label}
|
||||
{canCreate && (
|
||||
<Link
|
||||
className="rfc-offer rfc-offer-create"
|
||||
to={`?propose=${encodeURIComponent(term)}`}
|
||||
title={`Create RFC for '${term}'`}
|
||||
>
|
||||
+ create RFC
|
||||
</Link>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return <span key={i}>{seg.text}</span>
|
||||
})}
|
||||
</>
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import { Marked } from 'marked'
|
||||
import { sanitizeHtml } from '../lib/sanitizeHtml'
|
||||
import { decorateAcceptedChanges } from './trackedOverlay.js'
|
||||
import ChangeTooltip from './ChangeTooltip.jsx'
|
||||
|
||||
@@ -98,7 +99,7 @@ export default function MarkdownPreview({
|
||||
// synchronously with the body itself — no flash of un-decorated text.
|
||||
useEffect(() => {
|
||||
if (!hostRef.current) return
|
||||
const html = previewMarked.parse(content || '')
|
||||
const html = sanitizeHtml(previewMarked.parse(content || ''))
|
||||
hostRef.current.innerHTML = html
|
||||
const token = ++renderTokenRef.current
|
||||
// Reset memo so the new block set re-renders from scratch.
|
||||
|
||||
@@ -220,7 +220,7 @@ export default function PRView({ viewer }) {
|
||||
<h1 className="pr-title">{pr.title}</h1>
|
||||
{pr.description && (
|
||||
<p className="pr-description">
|
||||
<LinkedText segments={pr.description_segments} text={pr.description} />
|
||||
<LinkedText segments={pr.description_segments} text={pr.description} viewer={viewer} canCreate={viewer?.permission_state === 'granted'} />
|
||||
</p>
|
||||
)}
|
||||
{/* #26: the optional ground-truth use case for this change,
|
||||
@@ -444,7 +444,7 @@ function PRConversation({ threads, messagesByThread, threadsByKind, seenMsgId })
|
||||
</div>
|
||||
{m.quote && <pre className="chat-msg-quote">{m.quote}</pre>}
|
||||
<div className="chat-msg-body">
|
||||
<LinkedText segments={m.text_segments} text={m.text} />
|
||||
<LinkedText segments={m.text_segments} text={m.text} viewer={viewer} canCreate={viewer?.permission_state === 'granted'} />
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { marked } from 'marked'
|
||||
import { renderMarkdown } from '../lib/sanitizeHtml'
|
||||
import { getProposal, mergeProposal, declineProposal, withdrawProposal } from '../api'
|
||||
|
||||
export default function ProposalView({ viewer, onChange }) {
|
||||
@@ -161,7 +161,7 @@ export default function ProposalView({ viewer, onChange }) {
|
||||
</h3>
|
||||
<div
|
||||
className="entry-body"
|
||||
dangerouslySetInnerHTML={{ __html: marked.parse(data.entry?.body || '') }}
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(data.entry?.body || '') }}
|
||||
/>
|
||||
|
||||
{/* #26: the optional ground-truth use case the proposer supplied. */}
|
||||
@@ -169,7 +169,7 @@ export default function ProposalView({ viewer, onChange }) {
|
||||
Intended use case
|
||||
</h3>
|
||||
{data.proposed_use_case
|
||||
? <div className="entry-body" dangerouslySetInnerHTML={{ __html: marked.parse(data.proposed_use_case) }} />
|
||||
? <div className="entry-body" dangerouslySetInnerHTML={{ __html: renderMarkdown(data.proposed_use_case) }} />
|
||||
: <p style={{ color: '#999', fontStyle: 'italic' }}>Left blank by the proposer.</p>}
|
||||
</article>
|
||||
)
|
||||
|
||||
@@ -28,8 +28,11 @@ function slugify(title) {
|
||||
.replace(/^-+|-+$/g, '')
|
||||
}
|
||||
|
||||
export default function ProposeModal({ viewer, onClose, onSubmitted }) {
|
||||
const [title, setTitle] = useState('')
|
||||
export default function ProposeModal({ viewer, onClose, onSubmitted, initialTitle = '' }) {
|
||||
// #28 Part 2: a "create RFC for '<term>'" affordance pre-fills the title
|
||||
// (App passes the `?propose=<term>` value here); the slug derives from it
|
||||
// via the same effect that drives manual typing.
|
||||
const [title, setTitle] = useState(initialTitle)
|
||||
const [slug, setSlug] = useState('')
|
||||
const [slugEdited, setSlugEdited] = useState(false)
|
||||
const [pitch, setPitch] = useState('')
|
||||
|
||||
@@ -191,7 +191,7 @@ export default function RFCDiscussionPanel({ slug, viewer }) {
|
||||
</div>
|
||||
)}
|
||||
{activeMessages.map(msg => (
|
||||
<DiscussionMessage key={msg.id} message={msg} />
|
||||
<DiscussionMessage key={msg.id} message={msg} viewer={viewer} />
|
||||
))}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
@@ -258,7 +258,7 @@ export default function RFCDiscussionPanel({ slug, viewer }) {
|
||||
)
|
||||
}
|
||||
|
||||
function DiscussionMessage({ message }) {
|
||||
function DiscussionMessage({ message, viewer }) {
|
||||
const isSystem = message.role === 'system'
|
||||
if (isSystem) {
|
||||
return (
|
||||
@@ -281,7 +281,7 @@ function DiscussionMessage({ message }) {
|
||||
<div className="discussion-message-quote">"{message.quote}"</div>
|
||||
)}
|
||||
<div className="discussion-message-body">
|
||||
<LinkedText segments={message.text_segments} text={message.text} />
|
||||
<LinkedText segments={message.text_segments} text={message.text} viewer={viewer} canCreate={viewer?.permission_state === 'granted'} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// sanitizeHtml.js — the single chokepoint for turning user-authored
|
||||
// markdown into DOM-bound HTML.
|
||||
//
|
||||
// Security audit 0026 (finding C1, Critical): every `marked.parse(...)`
|
||||
// result that reaches an `innerHTML` / `dangerouslySetInnerHTML` sink was
|
||||
// previously written raw. `marked` passes through embedded HTML and
|
||||
// `javascript:`/event-handler attributes verbatim, so any user-authored
|
||||
// document (RFC body, proposal body, proposed_use_case, transcript) was a
|
||||
// stored-XSS vector — a contributor's payload executed in the session of
|
||||
// whoever viewed it, including an admin/owner during review.
|
||||
//
|
||||
// Fix: route EVERY markdown render through `renderMarkdown` (or, for
|
||||
// already-rendered HTML, `sanitizeHtml`). DOMPurify's defaults already
|
||||
// strip <script>, on* event handlers, and javascript:/unsafe-data: URIs;
|
||||
// we add a hook so any link opening a new tab carries rel="noopener
|
||||
// noreferrer". The html profile keeps the standard markdown tag set plus
|
||||
// class + data-* attributes (the latter is what MarkdownPreview's mermaid
|
||||
// placeholder relies on); mermaid renders its SVG into the DOM *after*
|
||||
// sanitization and is itself locked down with securityLevel:'strict'.
|
||||
|
||||
import DOMPurify from 'dompurify'
|
||||
import { marked } from 'marked'
|
||||
|
||||
let _hookInstalled = false
|
||||
function ensureHook() {
|
||||
if (_hookInstalled) return
|
||||
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
||||
if (node.tagName === 'A' && node.getAttribute('target') === '_blank') {
|
||||
node.setAttribute('rel', 'noopener noreferrer')
|
||||
}
|
||||
})
|
||||
_hookInstalled = true
|
||||
}
|
||||
|
||||
// Sanitize an already-rendered HTML string. Use when the HTML did not come
|
||||
// from `marked` (rare) or when a caller parses markdown with a bespoke
|
||||
// `Marked` instance and only needs the sanitize step.
|
||||
export function sanitizeHtml(html) {
|
||||
ensureHook()
|
||||
return DOMPurify.sanitize(html || '', { USE_PROFILES: { html: true } })
|
||||
}
|
||||
|
||||
// Parse markdown with the shared `marked` and sanitize the result. This is
|
||||
// the drop-in replacement for `marked.parse(src)` at any HTML sink.
|
||||
export function renderMarkdown(src) {
|
||||
return sanitizeHtml(marked.parse(src || ''))
|
||||
}
|
||||
Reference in New Issue
Block a user