Compare commits

..

1 Commits

Author SHA1 Message Date
Ben Stull 1185397a0e Release 0.5.0: PR-less per-RFC discussion (contribution still requires PR)
Roadmap item #3. An RFC's main view now carries a discussion surface
distinct from PR comments and from branch chat. The substrate is the
existing threads/thread_messages tables — rows with branch_name IS NULL
scope to the RFC's main view; the schema already permitted that shape,
v0.5.0 is the first build to write it. Five new endpoints under
/api/rfcs/<slug>/discussion/..., a new RFCDiscussionPanel right-column
component used when branchParam === main, SPEC §10.10 settling
discussion-vs-contribution, and §17 listing the new routes. Notification
routing reuses the existing chat_message_in_participated_thread /
chat_reply_to_my_message event kinds with branch_name=null on the
fan-out row; a distinct event_kind is a §19.2 candidate. Anonymous
viewers can read; writes require contributor — v0.6.0's item #4 will
harden adjacent gates. No schema migration; minor bump, no operator
action required beyond rebuild and restart.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 23:00:25 -07:00
35 changed files with 61 additions and 4777 deletions
-567
View File
@@ -23,528 +23,6 @@ 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.8.0 — 2026-05-28
**Minor — schema migration required; admission semantics shift.**
This release replaces the v0.3.0 / v0.7.0 `allowed_emails` admission
gate with an admin-grant flow (roadmap item #6, SPEC §6.1 / §6.2 /
§14.1 / §17). Anyone with a valid email can sign in via the v0.7.0
OTC flow; the OTC request endpoint no longer consults the
allowlist. A fresh user lands in `permission_state='pending'` until
an admin grants access. The first-OTC sign-in captures first name,
last name, and a free-text "why I should be included in the beta"
via a new `POST /api/auth/me/beta-request` endpoint; the captured
fields populate the same `users` row alongside the OAuth-era
columns.
A pending user has the same read access an anonymous viewer has —
the catalog, RFC bodies, the philosophy page, and every public
conversation are reachable. Every write-shaped endpoint
(`auth.require_contributor` floor) refuses pending users with 403.
The frontend renders a thin "Your beta access request is in
review" banner on every page and re-purposes the v0.3.0
`/beta-pending` page as the post-capture landing surface.
Grandfathered behavior: every `users` row at migration time
carries `permission_state='granted'` via the column default, so
existing contributors are unaffected. The OAuth fallback at
`/auth/callback` still consults the v0.3.0 allowlist (legacy
path); the OTC flow does not.
The `allowed_emails` table stays in the schema as a fast-path
bypass — the v0.3.0 admin UI continues to manage it, but the OTC
request handler no longer reads it. v0.9.0 (roadmap item #7) is
expected to ship the admin user-management page that replaces the
allowlist surface entirely; until then, admin grants are done by
direct DB `UPDATE`.
### Upgrade steps (from 0.13.0)
1. **MUST** restart the backend so migration `014_beta_access.sql`
runs. The migration adds `permission_state` (default `'granted'`,
so existing rows pass through unaffected), `first_name`,
`last_name`, `beta_request_reason`, `permission_decided_by`,
and `permission_decided_at` to the `users` table, plus an index
on `permission_state` for the pending queue. The migration is
ALTER-TABLE-based (no table rebuild) — every foreign key and
existing row passes through untouched.
2. **MUST** rebuild the frontend. The `Login.jsx` surface now
runs a conditional third step (the capture form) on fresh OTC
sign-ins; `BetaPending.jsx` carries the new "your request is
in review" copy; `App.jsx` renders a thin pending-access
banner. The build embeds the new `/api/auth/me/beta-request`
client call.
3. **SHOULD** announce the new admission flow to existing users.
Wording suggestion: "We've replaced our email-allowlist gate
with an admin-review flow. Existing users are unaffected;
new visitors sign in with their email, tell us a bit about
themselves, and an admin reviews their request before
discussion and contribution unlock." Existing sessions
remain valid.
4. **SHOULD** plan the admin grant mechanism. v0.8.0 does not
ship a UI for the grant — v0.9.0 (roadmap item #7) will. For
the v0.8.0 window, an admin grants access via direct DB
gesture:
```sql
UPDATE users
SET permission_state = 'granted',
permission_decided_by = <admin_user_id>,
permission_decided_at = datetime('now')
WHERE email = '<approved>';
```
The pending queue lives in `SELECT * FROM users WHERE
permission_state = 'pending' ORDER BY created_at`.
5. **MUST** decide whether to drain the `allowed_emails` table.
The OTC request handler no longer consults it; populated
rows are inert at the request surface. Three operator
choices, all valid:
* **Leave as-is** (the framework's default behavior — the
v0.3.0 admin UI continues to work, the rows stay as a
fast-path bypass record). Recommended if you anticipate
v0.9.0's user-management page folding the allowlist UI
into its surface.
* **Drain via the existing admin UI** (`/admin/allowlist`)
— one row at a time, no data loss elsewhere.
* **Bulk-drain via DB** — `DELETE FROM allowed_emails;`
drops every row; the table stays.
6. **MAY** announce write access individually to grandfathered
users you want to keep at `'granted'`. The default-`'granted'`
migration means no action is required for them; this step
exists only if you want to send a "you're still in" message.
### Added
- **`backend/migrations/014_beta_access.sql`** — adds
`permission_state` (CHECK in `('pending', 'granted', 'revoked')`,
default `'granted'`), `first_name`, `last_name`,
`beta_request_reason`, `permission_decided_by` (FK to users,
ON DELETE SET NULL), `permission_decided_at` to the `users`
table. Plus `idx_users_permission_state` for the pending
queue.
- **`POST /api/auth/me/beta-request`** — body
`{first_name, last_name, beta_request_reason}` (all required;
bounds 120 / 120 / 4000). Writes the fields to the signed-in
user's row and leaves `permission_state='pending'`. Refuses
HTTP 409 for already-granted / revoked users; refuses HTTP
401 for anonymous callers.
- **`needs_profile` flag** on the `/auth/otc/verify` response.
`true` iff the user is `permission_state='pending'` AND
carries no profile fields yet (a fresh OTC sign-in). The
Login.jsx surface uses the flag to gate the capture step.
- **`permission_state` field** on the `/api/auth/me` response,
plus `first_name`, `last_name`, `beta_request_reason`, and
the same `needs_profile` flag.
- **First-OTC profile capture step** in `Login.jsx`. Third
step in the sign-in surface, gated by the verify response's
`needs_profile` flag.
- **`/beta-pending` repurpose** in `BetaPending.jsx`. The
page now reads as "your request is in review" when the
viewer is pending; the v0.3.0 "private beta" framing
remains as the anonymous-viewer fallback.
- **Thin pending-access banner** at the top of every page
for `permission_state='pending'` viewers (other than
`/beta-pending` itself).
- **SPEC `§6` opening / `§6.1` / `§6.2` / `§14.1` / `§17` /
`§19.2`** corrections per §19.3 rule-2 — the admission
shift, the orthogonality of permission_state vs role / muted
/ notification-mutes, the new endpoints, and the
newly-surfaced §19.2 candidates (admin user-management page,
allowlist deprecation, admin notification on new request).
- **`backend/tests/test_beta_access_vertical.py`** — 9 new
tests covering: a fresh OTC user lands pending with empty
profile; the capture endpoint populates the fields and
keeps state pending; the capture endpoint refuses
anonymous / granted / revoked callers; a pending user is
refused write endpoints; an admin grant promotes pending →
granted; a grandfathered user is unaffected by the
migration; the OTC request endpoint accepts any email
regardless of allowlist state; the `allowed_emails` table
is still present in the schema.
### Changed
- **`backend/app/auth.py#require_contributor`** widens its
gate to refuse `permission_state != 'granted'` with HTTP
403. The §6.1 contributor capabilities (propose, branch,
PR, chat, claim) all funnel through this dependency, so
the widening covers them transitively. `SessionUser` now
carries `permission_state` (default `'granted'` for the
dataclass-default fallback path).
- **`backend/app/otc.py#request_code`** drops the allowlist
check from the OTC request flow. The `RequestOutcome`
shape loses the `'allowlist'` reason (replaced by
`'sent'` / `'cooldown'` / `'invalid'`).
- **`backend/app/otc.py#provision_or_link_user`** sets
`permission_state='pending'` explicitly on a fresh row.
Grandfathered (link-by-email) users pass through with
their existing column value.
- **`backend/app/auth.py#provision_user`** (OAuth fallback)
now sets `permission_state='granted'` explicitly on a
fresh row. The OAuth callback still consults the
`is_allowed_sign_in` allowlist check (the legacy fallback
path retains its v0.3.0 admission shape during the OAuth
migration window).
- **`backend/tests/test_otc_vertical.py`** — the
`test_otc_request_silently_drops_when_email_not_on_allowlist`
test (asserted the v0.7.0 allowlist gate) is replaced by
`test_otc_request_admits_emails_regardless_of_allowlist_population`
which asserts the v0.8.0 open-request contract. The
on-list test stays as a regression net for the
rate-limit / outbound-buffer plumbing.
- **`SPEC.md`** §6 opening, §6.1, §6.2, §14.1, §17, §19.2
per §19.3 rule-2.
- **`VERSION`** → `0.8.0`. `frontend/package.json#version` and
the lockfile mirror.
### Deferred to later releases
- **Admin user-management page** at `/admin/users` (item #7,
v0.9.0) — replaces the manual DB `UPDATE` gesture.
- **Allowlist UI deprecation** (also v0.9.0) — once the
admin user-management page lands, the `/admin/allowlist`
surface and the `allowed_emails` table both retire.
- **Admin email notification on new beta request** (item #7
again, v0.9.0).
- **Revoke gesture in the UI** — the `permission_state='revoked'`
state is wired in the schema and the auth gate; v0.9.0 ships
the admin UI that flips the column.
## 0.13.0 — 2026-05-28
**Minor — schema migration required; new optional env vars.** This
release ships the cookie / privacy consent surface (roadmap item #11,
SPEC §14.5 / §14.6). Every viewer — authenticated and anonymous alike —
now sees a non-modal bottom-of-page banner on first visit asking which
categories of cookies they allow (essential / essential + analytics /
essential + analytics + other). The choice persists in `localStorage`
for anonymous viewers and in a new `cookie_consent` table for
authenticated viewers, with server-side overriding local on sign-in.
The framework also ships default `/privacy` and `/cookies` policy pages
that deployments can layer their own policy URL on top of via two new
optional env vars. No analytics SDK ships in this release — the
consent infrastructure is wired so item #13 (v0.15.0) can read from
`frontend/src/lib/consent.js` when the SDK lands.
### Added
- **Cookie consent banner** (`frontend/src/components/CookieConsentBanner.jsx`).
Non-modal, bottom of viewport. Three single-select choices with
inline descriptions. Visible until the user makes a choice; hides
thereafter. Reachable for revision via the settings surface.
- **Consent helper** (`frontend/src/lib/consent.js`). Exports
`getConsent()`, `hasChosen()`, `onConsentChange(cb)`, `setConsent()`,
`hydrateFromServer()`, `clearLocal()`. Cross-tab sync via the
`storage` event. Item #13's analytics SDK reads consent here before
importing.
- **Privacy and cookies policy pages**
(`frontend/src/pages/Privacy.jsx`, `frontend/src/pages/Cookies.jsx`).
Default minimal policies that describe the framework's stance and
list the cookies the framework sets. Deployments override via the
two new env vars below; the framework's stub always renders above
the link so the framework-level contract stays visible.
- **"Privacy & cookies" tab** in `/settings/notifications` showing
the current consent choice, the recorded-at stamp, and a "Change"
button that re-opens the banner via a custom DOM event.
- **`§17` endpoints** —
- `GET /api/users/me/cookie-consent` — read the current consent
record.
- `PUT /api/users/me/cookie-consent` — write a new consent record.
Upserts a single row per user, stamps `recorded_at` to now,
accepts `essential` for symmetry but always persists it as true.
- **Schema migration** `013_cookie_consent.sql` — new
`cookie_consent` table keyed by `user_id`, three flags
(`essential`, `analytics`, `other_cookies`), and `recorded_at`.
(Renumbered from `012_*` during driver integration because v0.7.0
also added a `012_otc.sql` migration that landed in the integration
order before this one.)
- **SPEC `§14.5` Cookie / privacy consent** — settles the banner
shape, the three-category single-select, the storage shape (local
for anon, server row for authenticated), the precedence rule on
sign-in, and the `consent.js` helper surface for downstream
callers including item #13.
- **SPEC `§14.6` Privacy and cookies policy pages** — settles the
`/privacy` and `/cookies` routes, the framework's stub content, and
the `VITE_PRIVACY_POLICY_URL` / `VITE_COOKIES_POLICY_URL` override
shape.
- **SPEC `§5`** — names the `cookie_consent` table in the canonical
app-tables list.
- **SPEC `§17`** — lists the two new cookie-consent endpoints.
- **SPEC `§19.2`** — surfaces four candidates: policy content via
content-repo file vs env var, GPC / DNT headers, multi-language
consent text, and the item #13 analytics-SDK gating dependency.
### Changed
- **`frontend/.env.example`** — documents the two new optional env
vars `VITE_PRIVACY_POLICY_URL` and `VITE_COOKIES_POLICY_URL`. Unset
is supported; defaults render the framework's stub.
- **`backend/app/api_notifications.py`** — module docstring grew two
endpoint lines; the new endpoints sit alongside the existing
`/api/users/me/*` neighbors.
- **`frontend/src/App.jsx`** — registers `/privacy` and `/cookies`
routes (anonymous-reachable), wires `<CookieConsentBanner>` into
the global chrome, and listens for a `rfc-app:cookie-consent-reopen`
custom event to re-open the banner from the settings surface.
### Upgrade steps (from 0.7.0)
- You **MUST** rebuild the frontend and restart the backend after
upgrading. `frontend/package.json#version` and `VERSION` both move
to `0.13.0` and the build embeds the new env-var contract.
- You **MUST** apply schema migration `013_cookie_consent.sql`. The
migration creates a single new table keyed by `user_id` with three
flag columns and a `recorded_at` stamp. The framework runs
migrations automatically at process start; no manual step is
required beyond restarting the backend so the migration runner
picks the file up.
- You **MAY** set `VITE_PRIVACY_POLICY_URL` to an http(s) URL that
points at your deployment's full privacy policy. The framework's
`/privacy` page renders its built-in stub above a link to the
configured URL. Unset is supported — the stub is sufficient for a
default-config deployment.
- You **MAY** set `VITE_COOKIES_POLICY_URL` to an http(s) URL that
points at your deployment's full cookies policy. Same shape as the
privacy URL.
- You **MAY** announce the new consent banner to your users. Existing
authenticated users will see the banner on their next visit
(because their `cookie_consent` row does not yet exist); their
current sessions remain valid.
## 0.7.0 — 2026-05-28
**Minor — schema migration required; new auth path is additive.**
This release lands email + one-time-code sign-in (roadmap item #5,
SPEC §6.2) as the primary human-auth gesture. Users sign in by
typing their email, receiving a six-digit code via email, and
entering it. The Gitea OAuth callback (`/auth/callback`) remains
functional during migration — the new UI no longer points at it
primarily, but a "Sign in with Gitea (fallback)" link survives on
the new login surface so users with active OAuth sessions or older
invite paths still have a way in. A future release retires the
OAuth path entirely once every active user has signed in at least
once via OTC.
The migration path for existing users: first OTC sign-in matches by
`users.email` (case-insensitive) to the OAuth-era row and reuses
that row's `id` and `gitea_id`. New users provisioned via OTC carry
`gitea_id = NULL` and `gitea_login = NULL`. The `gitea_id` linker
remains the canonical handle for grandfathered users; `email`
becomes the identity key for everything provisioned after v0.7.0.
The Gitea **bot** user + token are still required (server-side git
operations — repo reads, PR creation — still flow through it). Only
the operator-facing sign-in surface moves.
### Upgrade steps (from 0.6.0)
1. **MUST** restart the backend so migration `012_otc.sql` runs.
The migration rebuilds the `users` table (SQLite cannot ALTER
COLUMN); existing rows pass through unchanged, but the new
schema relaxes `gitea_id` / `gitea_login` to nullable (with
partial unique indexes that ignore NULL) and adds a partial
unique index on `email`. A new `otc_codes` table is created.
2. **MUST** confirm the SMTP overlay is set (`SMTP_HOST`,
`SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`, `SMTP_STARTTLS`,
`EMAIL_FROM`, `EMAIL_FROM_NAME`). The OHM overlay already
carries these as of v0.5.0; deployments without them fall back
to logging the code to stdout (dev-only path — production
visitors will not receive their codes).
3. **SHOULD** announce the new email-based sign-in to existing
users. Wording suggestion: "You can now sign in by entering
your email and a one-time code we'll send you. Your old
account is linked automatically the first time you sign in."
4. **MAY** keep the existing OAuth callback as a fallback path.
The new login UI surfaces a small "Sign in with Gitea
(fallback)" link beneath the primary email/code form; a
deployment that prefers to hide it can override the Login
component in a future framework release that exposes the link
behind a feature flag. For v0.7.0, the link is hard-coded.
### New environment variables (all optional with defaults)
- `OTC_TTL_MINUTES` (default `10`) — how long a one-time code is
valid after issuance. Re-requesting invalidates the prior code
immediately regardless of TTL.
- `OTC_REQUEST_COOLDOWN_SECONDS` (default `60`) — per-email cooldown
between successive `/auth/otc/request` calls. The endpoint returns
HTTP 429 when the cooldown blocks a request (the loud-failure
shape; the abuse path is visible rather than swallowed).
No new secrets are required. The existing `SECRET_KEY` continues to
sign session cookies; OTC codes are bcrypt-hashed at rest using a
per-row salt the library generates.
### Added
- **`POST /auth/otc/request`** — body `{email}`. Generates a six-digit
code, stores its bcrypt hash with an expiry, and dispatches a plain
text email via the existing SMTP layer. Returns HTTP 200 (`{ok:true}`)
uniformly so allowlist state is not leaked. Returns HTTP 429 when
the per-email cooldown blocks the request.
- **`POST /auth/otc/verify`** — body `{email, code}`. Validates the
bcrypt hash against the most-recent unconsumed non-expired row, marks
the row consumed, provisions or links the `users` row by email, and
stores the session cookie. Returns HTTP 200 on success, HTTP 400 on
any failure (expired, consumed, wrong, unknown).
- **`backend/migrations/012_otc.sql`** — creates `otc_codes` and
rebuilds `users` with nullable `gitea_id` / `gitea_login` plus a
partial unique index on `email`.
- **`backend/app/otc.py`** — the OTC request/verify state machine and
the `provision_or_link_user` linker.
- **`backend/app/email_otc.py`** — outbound OTC mail composition. Reuses
the SMTP plumbing from `email.py` (`EmailConfig.from_env()`) and the
test buffer (`_SENT`) but writes its own envelope (no unsubscribe
footer, no quiet-hours hold — OTC mail carries a credential and
ignores notification preferences).
- **`frontend/src/components/Login.jsx`** — two-step sign-in surface
at `/login`. Step 1: enter email → request code. Step 2: enter
six-digit code → verify. Cmd/Ctrl+Enter on the code field submits.
The previous header "Sign in" link and the `Welcome` component's
inline link now route to `/login` instead of jumping straight to
the Gitea OAuth dance.
- **SPEC `§6.1` / `§6.2` / `§14.1` / `§17` / `§19.2`** corrections per
§19.3 rule-2 — see below.
- **`backend/tests/test_otc_vertical.py`** — 11 new tests covering
the happy path, expired/consumed/wrong codes, the per-email rate
limit (and its per-email isolation), the allowlist gate, the
migration link to OAuth-era users, fresh provisioning, and the
prior-code-invalidation behavior on re-request.
### Changed
- **`backend/app/auth.py#current_user`** — coerces NULL `gitea_id` and
NULL `gitea_login` to `0` / `""` so the `SessionUser` shape stays
stable for OTC-only users. The DB remains the source of truth for
"is this user OAuth-linked" (`gitea_id IS NOT NULL`).
- **`backend/requirements.txt`** — adds `bcrypt>=4.2` for OTC code
hashing. Pure-Python wheels are available on every platform the
deployment matrix targets; `pip install -r backend/requirements.txt`
picks it up.
- **`frontend/src/App.jsx`** — adds the `/login` route and replaces
the header "Sign in" `<a href="/auth/login">` with `<Link to="/login">`.
The `Welcome` component's inline sign-in link follows suit.
- **`frontend/src/components/Landing.jsx`** — the `/welcome` page's
primary action moves from "Sign in with Gitea" to "Sign in" pointing
at `/login`.
### Deferred to later releases
Per the v0.7.0 scope discipline (the foundation for items #6, #8, #9,
#10), several adjacent capabilities are intentionally not in this
release and surface as §19.2 candidates:
- **First-OTC profile capture** (first name, last name, "why") — item
#6, expected v0.8.0.
- **Open beta-access request flow** replacing the allowlist gate —
also item #6, v0.8.0.
- **Passcodes** (a long-term reauth token alternative) — item #8,
expected v0.10.0.
- **Device-trust 30-day skip** — item #9, expected v0.11.0.
- **Cloudflare Turnstile** on `/auth/otc/request` — item #10,
expected v0.12.0.
- **Removing the Gitea OAuth `/auth/callback` route entirely** — a
later release after every active user has signed in via OTC.
## 0.6.0 — 2026-05-28
**Minor — no operator action required.** A sweep-the-edges hardening
release (roadmap item #4, "anon discuss + contribute off-limits") that
audits every write-shaped backend endpoint and asserts each one
enforces an explicit `auth.require_contributor` (or stricter) gate
before doing any state-changing work. v0.3.0 hid write affordances
behind a sign-in CTA on the frontend; v0.5.0 added the PR-less
discussion surface with its own write gate; v0.6.0 sweeps the rest
and adds a regression test net so future endpoints can't quietly
ship without a gate. No schema migration, no env-var changes, no
new dependencies. The only behavioural change is one tightening: the
`GET /api/rfcs/<slug>/graduate/progress` SSE now requires
`auth.require_user` since it surfaces operator-visible step detail
(repo name, PR number, rollback steps) not part of the v0.3.0
anonymous-read contract for catalog/RFC bodies.
### Changed
- **`backend/app/api_graduation.py`** — `GET /graduate/progress`
now calls `auth.require_user(request)` as its first line.
Anonymous callers receive 401 instead of being able to subscribe
to a graduation's SSE. The floor is `require_user` (not
`require_contributor`) so a write-muted operator can still observe
the progress of a graduation they kicked off before being muted.
- **SPEC `§6.1`** (`SPEC.md`) — the Anonymous role's bullet now
documents the v0.6.0 audit: every write-shaped endpoint in §17
enforces an explicit gate; anonymous writes refuse 401. The list
of audited write families is recorded in-line.
- **SPEC `§10.10`** — the discussion-vs-contribution section now
records that the v0.5.0 write gates have a regression test net
(`test_anon_offlimits_vertical.py`) added in v0.6.0.
- **SPEC `§17`** — the `GET /api/rfcs/<slug>/graduate/progress`
bullet now documents the `require_user` gate added in v0.6.0,
with the rationale.
### Added (tests)
- **`backend/tests/test_anon_offlimits_vertical.py`** — twelve new
tests asserting that every write-shaped endpoint surveyed in the
v0.6.0 audit refuses anonymous callers with 401, and that the five
anonymous-read surfaces (health, philosophy, auth/me, catalog,
RFC view, discussion threads, proposals) stay reachable. Sixty-six
assertions in total, covering: propose; proposal merge / decline /
withdraw; branch promote-to-branch / start-edit-branch / metadata /
manual-flush / visibility / grants (POST + DELETE) / threads (POST)
/ messages (POST) / resolve / chat-seen / changes (accept / decline
/ reask) / chat-stream; super-draft start-edit-branch + metadata;
PR pr-draft / open / seen / review / merge / withdraw /
description / resolution-branch; discussion thread create + message
post + resolve; admin role / mute / allowlist (POST + DELETE) plus
the admin reads; notification preferences / quiet-hours / watch /
mark-read / user-mute (POST + DELETE); funder credentials (POST +
DELETE) + consent (POST + DELETE); graduation kickoff + claim +
progress SSE; PR review page anonymous-readable.
### Anonymous-writeable allowlist
Two endpoints are intentionally anonymous-by-design. They are not
audit findings; they are documented here so the contract is
explicit:
- `GET /auth/login` and `GET /auth/callback` — the OAuth
round-trip. Anonymous-by-design because they ARE the sign-in
entrypoint.
- `POST /api/webhooks/gitea` and `POST /api/webhooks/email-bounce`
— anonymous in the session sense but authenticated by HMAC shared
secret (`GITEA_WEBHOOK_SECRET` and `WEBHOOK_EMAIL_BOUNCE_SECRET`
respectively). The webhook receiver is the wrong place for an
authenticated session; the shared-secret shape is correct.
### §19.2 candidates surfaced
- None unique to this release. The two pre-existing candidates the
audit touched — anonymous-read polish for the discussion surface
(carried from v0.5.0) and the operator-visibility floor on
graduation progress — were settled here as `require_user` on
`/graduate/progress` rather than deferred.
### Upgrade steps (from 0.5.0)
1. The deployment **MUST** rebuild the frontend (`npm install &&
npm run build`) so the frontend bundle's reported version matches
the backend's. No new env vars; existing `frontend/.env` is
sufficient.
2. The deployment **MUST** restart the backend so the new
`require_user` gate on `/graduate/progress` is enforced. No
schema migration runs.
3. The deployment **MAY** announce the audit completion to
operators: every write-shaped backend endpoint now enforces an
explicit gate, and the `test_anon_offlimits_vertical.py` test
net asserts the contract on every CI run. The audited surfaces
are listed in the `Added (tests)` section above and in `SPEC.md`
§6.1.
4. The deployment **MUST NOT** assume any new envelope behaviour:
v0.6.0 is purely a hardening release. No schema, no env, no
dependency changes; the operator's role is reduced to rebuild +
restart.
## 0.5.0 — 2026-05-27
**Minor — no operator action required.** This release wires the
@@ -642,51 +120,6 @@ No schema migration is required.
v0.5.0's write paths already refuse anonymous posts, so no
pre-emptive operator action is needed.
## 0.4.0 — 2026-05-27
**Minor — no operator action required beyond rebuild + restart.** The
proposer of a new RFC is now the implicit first owner of its super-
draft entry, set automatically at propose time from the session user.
The §13.1 claim flow remains available for *additional* owners. No
schema changes, no env-var changes, no API-shape changes; only newly
proposed RFCs receive the auto-owner — existing super-drafts whose
`owners:` is empty are unaffected and can still be claimed via §13.1
as before. This is a §19.3 rule-2 spec correction: `SPEC.md` §9.1,
§9.2, and §13.1 are updated to reflect the new shape.
### Changed
- **`POST /api/rfcs/propose`** (`backend/app/api.py`) now sets
`Entry.owners = [user.gitea_login]` when constructing the new
super-draft entry, instead of `owners=[]`. The session's
`gitea_login` is the canonical source; the endpoint never accepted
an owner field from the request payload and still doesn't.
- **`SPEC.md` §9.1** narrowed: the "no proposed-owner or working-
group fields" sentence becomes a proposer-owner-auto / working-
group-deferred split, with a §19.3 rule-2 note.
- **`SPEC.md` §9.2** frontmatter shape: `owners: []` → `owners:
[<proposer.gitea_login>]`, with a §19.3 rule-2 note.
- **`SPEC.md` §13.1** reframed: claim flow is now a graduation-time
broadening for additional owners, not a precondition for the
proposer's own RFC. The §13.1 / §13.2 / §13.3 graduation pipeline
itself is unchanged — the "at least one owner" precondition for
graduation still holds and is now satisfied by default.
### Upgrade steps (from 0.3.0)
1. The deployment **MUST** rebuild and restart per the routine
deploy steps. No `.env` changes, no schema/migration changes, no
API-shape changes.
2. Operators **SHOULD** note that newly proposed RFCs after the
upgrade carry the proposer in `owners:` automatically. Existing
super-drafts with empty `owners:` are not migrated; they remain
claimable via the §13.1 flow exactly as before. No deployment-
side data action is required.
3. Deployments **MAY** communicate the UX shift to active proposers
(the "Claim ownership" affordance no longer applies to your own
newly proposed RFC), but the affordance simply hides on RFCs the
viewer already owns, so no operator-side action is required.
## 0.3.0 — 2026-05-27
**Minor — operator action required if a deployment wants to enable the
+20 -442
View File
@@ -332,13 +332,6 @@ and exact columns are illustrative; the implementing session can adjust.
- `actions` — append-only audit log for every state transition, every
graduation, every grant change. Includes the acting user, the bot
commit hash if any, and the on-behalf-of trailer applied.
- `cookie_consent` — per-user record of the §14.5 cookie consent
choice. One row per user. Columns: `user_id` (PK, FK users), three
flags (`essential`, `analytics`, `other_cookies`), and
`recorded_at`. `essential` is permanently 1; `recorded_at` is set
on first write and updated on every change. Absence of a row means
"no choice yet" — the banner shows. Anonymous viewers persist their
choice in `localStorage` only, with no corresponding row here.
**Super-draft scoping.** For rows in `threads` and `changes` where the
entry referenced by `rfc_slug` is in state `super-draft`, `branch_name`
@@ -356,60 +349,17 @@ merge with no data movement.
Authorization is owned by the app. Gitea sees only the bot account.
Authentication, as of v0.7.0, is by email + one-time-code: a visitor
enters their email address, receives a six-digit code via SMTP, and
exchanges the code for a session. The Gitea OAuth callback that
v0.1 used as the human sign-in path remains as a migration fallback
during the v0.7.0 window — `users.gitea_id` is preserved on existing
rows so a grandfathered user signing in via either path resolves to
the same row — but the primary surface points at OTC. `users.email`
is the identity key for everything provisioned after v0.7.0;
`users.gitea_id` is the grandfathering linker (nullable, partial-
unique). The Gitea bot user + token are still required for server-
side git operations (repo reads, PR creation); only the operator-
facing sign-in surface moved.
Admission, as of v0.8.0, is by admin grant. v0.7.0 carried the
v0.3.0 `allowed_emails` table forward as the admission gate at the
OTC request surface — emails not on the list got a silent drop.
v0.8.0 (roadmap item #6) reverses that: any valid email receives an
OTC, the fresh `users` row lands in `permission_state='pending'`,
and an admin grant flips the column to `'granted'` before write
endpoints accept the user. The capture-fields step (first name,
last name, free-text "why I should be included in the beta") feeds
the admin's triage queue. The `allowed_emails` table stays in the
schema as a fast-path bypass — the v0.3.0 admin UI still manages
it — but the OTC request path no longer consults it. v0.9.0's
admin user-management page replaces the allowlist UI and ships the
pending-queue triage surface.
### 6.1 Four roles, each a strict superset of the one below
1. **Anonymous.** Can read public RFCs (the meta repo's main branch,
every RFC repo's main branch), read any branch whose `read_public`
is true, read any PR. Cannot chat, propose, create branches, or
open PRs. v0.6.0 (roadmap item #4) closed the audit: every
write-shaped endpoint surveyed in §17 enforces an explicit
`auth.require_contributor` (or stricter) gate before doing any
state-changing work; anonymous writes refuse 401. The explicit
audit covers propose, branch create, branch threads, PR-less
discussion threads + messages, PR open / merge / withdraw,
funder credentials + consent, admin allowlist add, graduation
kickoff + claim. Anonymous reads on every catalog and RFC-body
surface remain open per the v0.3.0 contract.
2. **Contributor.** Default role for any authenticated account. A
first OTC sign-in by a previously unknown email provisions a row
at this role; v0.8.0 replaced the v0.3.0 / v0.7.0 allowlist gate
with an admin-grant flow (roadmap item #6, see opening of §6).
The contributor capabilities below — propose, branch, PR, chat,
claim — are gated by `users.permission_state='granted'` as well
as by the role. A pending contributor (the post-OTC waiting
state) has the same read access as anonymous and zero write
capability until an admin grants. Everything anonymous can do,
plus: propose new RFCs (open a PR against the meta repo), create
branches on any RFC repo, open PRs from branches they have
contribute access to, chat on anything they can read, claim
ownership of unclaimed super-drafts.
open PRs.
2. **Contributor.** Default role for any authenticated account.
Everything anonymous can do, plus: propose new RFCs (open a PR
against the meta repo), create branches on any RFC repo, open PRs
from branches they have contribute access to, chat on anything
they can read, claim ownership of unclaimed super-drafts.
3. **Admin.** Everything contributor can do, plus: act on any RFC
(merge PRs on behalf of arbiters, graduate super-drafts, set
branch visibility on anyone's behalf, downgrade or restore
@@ -427,14 +377,6 @@ subject to the standard 30/90 hygiene rules (§12). Restoring is the
reverse action. Every mute and restore is logged in
`permission_events`.
The write-mute is keyed on `users.id` and is auth-path-agnostic: a
contributor muted under the v0.1 OAuth-era flow stays muted after
the v0.7.0 email/OTC migration, since the same row is reused via the
email-match linker. Identity in this section means the `users.id`
column; the v0.7.0 identity-key shift (`gitea_id``email`) is
about which column carries the unique constraint for new
provisioning, not about which column the permission gates read.
This write-mute is structurally distinct from the two notification
mutes introduced in §15.8 — the per-RFC notification mute (the
`muted` state on the `watches` row, §15.6) and the per-user
@@ -448,38 +390,6 @@ triage what they can't act on, and the restore lands cleanly); a
self-DND'd contributor's own gestures continue to fire signals to
others normally.
v0.8.0 adds a fourth structurally-distinct field on the same row:
`users.permission_state` (the admission gate the v0.8.0 release
ships, see opening of §6 and §6.1). The four — role, muted,
permission_state, the notification mutes — are orthogonal and the
gate semantics compose:
* `role` answers "what scope of action is this user authorized to
perform if they're admitted at all?" (anonymous / contributor /
admin / owner).
* `muted` answers "is this contributor write-restricted by an
admin gesture against their existing grant?" (a sanctions
primitive — owner/admin imposed).
* `permission_state` answers "is this user admitted to the beta
at all?" (the v0.8.0 admin-grant gate — 'pending' / 'granted' /
'revoked'). The default for grandfathered rows at migration time
is `'granted'`; OTC freshly provisions `'pending'`.
* The notification mutes answer "does this user want to receive
signals about a particular RFC or from a particular other
user?" (self-imposed preference).
The four never gate each other. A pending user with `role=owner`
(impossible by construction in v0.8.0 — fresh OTC always provisions
role=contributor — but the orthogonality holds at the column level)
would still refuse write endpoints because the admission gate
runs first; a granted contributor whose row is also muted refuses
writes via the mute gate; a granted contributor with notification
mutes set still passes the contributor gate and writes normally.
v0.6.0's anon-write audit (item #4) is the structural floor for all
four — every write-shaped endpoint funnels through
`auth.require_contributor`, which checks all three of {authenticated,
not muted, permission_state='granted'} in order.
### 6.3 Per-RFC delegated authority
An RFC's `owners:` and `arbiters:` (from the meta-repo entry's
@@ -1215,20 +1125,12 @@ The modal collects four fields:
type their own.
No proposer name or email — the logged-in identity is canonical and
need not be retyped. No proposer-owner field either: the proposer is
implicitly the first owner of their own RFC, set automatically at
submit-time from the session user (see §9.2); the post-merge claim
flow (§13.1) remains for *other* contributors to add themselves as
owners on an RFC they didn't propose. No working-group field —
need not be retyped. No proposed-owner or working-group fields —
ownership flows through the post-merge claim flow (§13.1), and
arbiters are admin work, not the proposer's call. AI's drafting role
is intentionally narrow: tag suggestions only. The proposer is
making a specific claim about a specific word, and having AI propose
the claim for them would undercut the gesture. (§19.3 rule-2
correction: 0.4.0 narrowed this paragraph; the prior version asserted
"no proposed-owner field" without qualification, but running code
revealed that forcing a separate claim-flow gesture for the
proposer's own RFC was UX friction with no benefit — the proposer
already self-identified by proposing.)
the claim for them would undercut the gesture.
Primary action: **"Open proposal PR"** — naming the actual Git
artifact produced, consistent with §10.1's *Open PR* and §13's
@@ -1244,13 +1146,7 @@ is populated from the modal and session:
- `state: super-draft`, `id: null`, `repo: null`,
`graduated_at: null`, `graduated_by: null` — fixed at creation.
- `proposed_by: <session email>`, `proposed_at: <today>` — auto.
- `owners: [<proposer.gitea_login>]` — the proposer is the first
owner at propose time, set automatically from the session user.
The §13.1 claim flow remains available for *additional* owners on
the same entry. (§19.3 rule-2 correction: 0.4.0 changed this from
`owners: []`; the prior shape required a separate claim-flow
gesture for the proposer's own RFC, which running code surfaced
as needless UX friction.)
- `owners: []` — empty; the claim flow (§13.1) fills this.
- `arbiters: []` — empty; arbiters are admin work, not the
proposer's call.
@@ -1759,12 +1655,8 @@ them was the failure mode of generic-PR-comments-as-only-conversation.
Reads on the discussion surface follow §14 / the v0.3.0 anonymous-read
contract: anyone can see the conversation. Writes require contributor
role per §6.1: v0.5.0 implemented the gate on the three discussion
write paths (POST threads, POST messages, POST resolve); v0.6.0 (item
#4) audited the adjacent surfaces and added the matching test net
(`test_anon_offlimits_vertical.py`) so a regression on any write
endpoint is caught immediately. The gates use `auth.require_contributor`
as the canonical helper. The notification routing reuses the
role per §6.1 (v0.5.0 implements the gate; v0.6.0 — item #4 — hardens
adjacent surfaces to match). The notification routing reuses the
existing `chat_message_in_participated_thread` /
`chat_reply_to_my_message` event kinds with `branch_name=null` on the
fan-out row; the §15.7 reconciler and §15 inbox prose render
@@ -1865,20 +1757,11 @@ PR do not block graduation; they remain on the meta repo subject to
### 13.1 Claim ownership (prerequisite)
The proposer is already the first owner of any super-draft they
proposed (per §9.2; auto-set at propose time). The claim flow exists
for *additional* contributors to become owners on an existing super-
draft — typically a draft whose proposer has stepped away, or a draft
graduating with a working group. Any signed-in contributor can click
"Claim ownership," which opens a PR against the meta repo adding
their username to the `owners:` field of the entry. Owners and admins
can merge. (A self-merge window for un-acted claims is not enabled in
v1; configurable later if needed.) Multiple claims simply append.
(§19.3 rule-2 correction: 0.4.0 narrowed this section's framing; the
prior version implied owners always started empty and the claim flow
was a hard prerequisite to graduation, but with the proposer now
auto-set as first owner, the claim flow is a graduation-time
broadening rather than a precondition for the proposer's own RFC.)
If a super-draft has no owner, any signed-in contributor can click
"Claim ownership," which opens a PR against the meta repo adding their
username to the `owners:` field of the entry. Owners and admins can
merge. (A self-merge window for un-acted claims is not enabled in v1;
configurable later if needed.) Multiple claims simply append.
### 13.2 The Graduate dialog
@@ -2028,31 +1911,14 @@ and its public face.
The app's root URL, accessed by an unauthenticated visitor, renders a
landing page consisting of the title, the subtitle, and the short-form
deck from the top of `PHILOSOPHY.md` (see §2). Beneath the deck, a
single primary action: "Sign in" → the email + one-time-code surface
at `/login` (per §6.2). Beneath that, a secondary link: "Read the
full philosophy" → `/philosophy`. The v0.1 landing said "Sign in
with Gitea"; v0.7.0's email/OTC surface replaced that as the primary
gesture, with a small "Sign in with Gitea (fallback)" link surviving
on `/login` itself for the migration window.
single primary action: "Sign in with Gitea." Beneath that, a secondary
link: "Read the full philosophy" → `/philosophy`.
This is the front door. It sets expectation before the user encounters
the mechanics, so the mechanics (super-drafts, graduation, public
arguments, AI participation in chat) read as load-bearing rather than
novel.
v0.8.0 (roadmap item #6) added a third sign-in step the surface
runs conditionally — on the first OTC sign-in by a previously
unknown email, the verify response carries `needs_profile=true`
and the surface prompts for first name, last name, and a free-text
"why I should be included in the beta" before bouncing the user to
`/beta-pending`. The page displays a "your request is in review"
message keyed on `users.permission_state='pending'` (repurposed
from the v0.3.0 post-OAuth-rejection surface). Anonymous viewers
and pending viewers see the same read surfaces; only the write
affordances differ. A persistent thin "Your beta access is in
review" banner shows on every page (other than `/beta-pending`
itself) until an admin grants access.
### 14.2 The `/philosophy` route
Authenticated and anonymous visitors alike can reach `/philosophy`,
@@ -2084,92 +1950,6 @@ The visual design of the landing page and the `/philosophy` route —
typography, layout, illustrations if any — is deferred. The structural
decisions above are the binding part.
### 14.5 Cookie / privacy consent (v0.13.0)
The framework ships a non-modal cookie consent banner reachable by
every viewer — authenticated and anonymous alike. The banner appears
at the bottom of the viewport on first load and stays visible until
the user makes a choice, after which it hides and the choice is
persisted. The `/settings/notifications` page carries a "Privacy &
cookies" tab that surfaces the current choice and re-opens the banner
on demand.
The choice has three categories, presented as a single-select:
- **Essential only** — the framework's strictly-necessary cookies
(sign-in session, signed payloads, the consent-choice record
itself). Always on; the user cannot switch this off because the
app cannot function without it.
- **Essential + analytics** — adds the optional analytics layer
gated by this choice. As of v0.13.0 no analytics SDK ships in the
framework; roadmap item #13 (v0.15.0) lands one behind this gate.
Off by default — the user has to opt in.
- **Essential + analytics + other** — adds third-party embeds or
social widgets a deployment may configure. The framework ships no
such cookies by default; this category exists so deployments that
add them have a categorized opt-in to wire them behind.
Storage shape:
- **Anonymous viewer** — choice persists in `localStorage` only
(`rfc-app.cookie-consent.v1`). The same browser carries the choice
forward; a different browser, or cleared storage, re-prompts.
- **Authenticated viewer** — choice persists in the `cookie_consent`
row keyed by `user_id`. On sign-in, the server row (if present)
overrides the local snapshot; if the server has no row, the local
choice is uploaded.
The `essential` flag is permanently true at the API surface. The
endpoint accepts it for symmetry but never persists a false value.
A deployment that wants strictly-necessary cookies to be optional
must change the framework contract, not flip a flag.
The framework exports a small JavaScript helper (`frontend/src/lib/
consent.js`) for downstream surfaces:
- `getConsent()` — current snapshot.
- `hasChosen()` — true once the user has made a choice.
- `onConsentChange(cb)` — subscribe to updates.
- `setConsent({analytics, other})` — record a new choice locally
(the banner / settings surface handles server persistence on top).
Roadmap item #13's analytics SDK (v0.15.0) will read from this helper:
read consent, then conditionally `import()` the SDK module. The gate
is wired before the SDK lands so the contract is already in place.
### 14.6 Privacy and cookies policy pages
The framework ships two policy routes:
- `/privacy` — a minimal default privacy policy that describes the
framework's stance (what is stored, why, how to revoke consent,
how to reach the deployment operator). The page is reachable by
anonymous and authenticated viewers alike.
- `/cookies` — the framework's cookies policy, listing exactly which
cookies the framework sets, by category. Self-documenting: a future
framework release that adds or removes a cookie updates this page
as part of the change.
Each page links to the other and to the §14.5 banner. The consent
banner links to both.
Deployments override the policy content via two optional build-time
env vars documented in `frontend/.env.example`:
- `VITE_PRIVACY_POLICY_URL` — an http(s) URL the `/privacy` page
links to as the "full deployment policy". The framework's stub
always renders above the link so the framework-level contract is
always visible; the link layers deployment-specific content on
top.
- `VITE_COOKIES_POLICY_URL` — same shape for `/cookies`.
Both are optional. Unset is the supported default; the stub pages are
sufficient for a default-config deployment that has nothing
deployment-specific to add. The framework chose the env-var path over
a content-repo file because it composes with the existing build-time
config layer; the content-repo-file alternative is the §19.2
candidate.
---
## 15. Notifications
@@ -2686,48 +2466,6 @@ The follow-up session will refine this. A minimal starting set:
returned `version` matches the tag the operator just deployed,
catching the failure mode where a restart did not pick up the
new code.
- `POST /auth/otc/request` — unauthenticated. Body carries `email`.
Generates a six-digit code, stores its bcrypt hash with an expiry
(`OTC_TTL_MINUTES`, default 10), and dispatches a plain-text email
via the SMTP layer. Returns HTTP 200 (`{ok:true}`) uniformly.
Returns HTTP 429 when the per-email cooldown
(`OTC_REQUEST_COOLDOWN_SECONDS`, default 60) blocks back-to-back
requests — the loud-failure shape for the abuse path. A re-request
invalidates the prior unused code for the same email so only one
code is outstanding at a time. v0.7.0 also dropped requests
silently if the email wasn't on the `allowed_emails` list (the
v0.3.0 admission gate); v0.8.0 (item #6) removed that check —
admission moved to `permission_state` on the freshly-provisioned
`users` row, asserted at the contributor gate. Per §19.2's
expected next session, this endpoint is the lead-up to the
Cloudflare-Turnstile abuse-mitigation overlay.
- `POST /auth/otc/verify` — unauthenticated. Body carries `email` and
`code`. Validates the bcrypt hash against the most-recent unconsumed
non-expired row for the email, marks the row consumed, provisions
or links the `users` row by email (per §6.2's migration path —
match by `users.email` case-insensitive, otherwise insert a fresh
contributor row with `gitea_id = NULL` and
`permission_state='pending'`), and stores the session cookie.
Returns HTTP 200 on success; the response body carries
`{ok, user, needs_profile}` where `needs_profile=true` iff the
user is `permission_state='pending'` AND the row has no
first_name / last_name / beta_request_reason yet (a fresh OTC
sign-in). The `needs_profile` flag drives the Login.jsx surface's
step-3 capture form. HTTP 400 on any failure (expired, consumed,
wrong, unknown). The failure modes collapse to a single generic
message so a probing client cannot distinguish "you got the
wrong code" from "we don't know this email" — the operator logs
carry the distinction.
- `POST /api/auth/me/beta-request` — authenticated. Body carries
`first_name`, `last_name`, `beta_request_reason` (all required;
bounded at 120 / 120 / 4000 chars). Writes the fields to the
signed-in user's row and leaves `permission_state='pending'`.
Idempotent for the same already-pending user (a re-submit
updates the row so the admin sees the latest text). Refuses
with HTTP 409 if the user is already `'granted'` or `'revoked'`.
v0.8.0 — the first-OTC profile-capture endpoint (roadmap item
#6). v0.9.0's admin user-management page consumes this column
set to render the request queue.
- `GET /api/rfcs` — list entries with state, id, title, slug, repo,
owners, last_active_at, has_open_prs, starred-by-me. Supports
search, sort, filter chips, and the `unclaimed` predicate.
@@ -2783,13 +2521,7 @@ The follow-up session will refine this. A minimal starting set:
trailing `rollback` step's events if any earlier step fails. The
Graduate dialog opens this stream on confirm and renders the step
stack from the events. The stream closes on success or on
rollback completion. Requires `auth.require_user` per v0.6.0
(item #4): the step detail (repo name, PR number, rollback steps)
is operator-visible state and isn't part of the v0.3.0
anonymous-read contract for catalog and RFC bodies. The floor is
`require_user` (not `require_contributor`) so a write-muted
operator can still observe a graduation they kicked off before
being muted.
rollback completion.
- `GET /api/rfcs/<slug>/blocking-prs` — list open meta-repo PRs
against `rfcs/<slug>.md` per §13.2's precondition popover. Returns
PR number, title, author, last-activity timestamp, and the
@@ -2961,16 +2693,6 @@ The follow-up session will refine this. A minimal starting set:
short confirmation page.
- `POST /api/webhooks/email-bounce` — bounce and complaint receiver
per §15.4; sets the recipient's global email opt-out.
- `GET /api/users/me/cookie-consent` — read the signed-in user's
cookie consent record per §14.5. Returns `{essential, analytics,
other, recorded_at}`. `recorded_at: null` means "no choice yet"
and the banner should be shown; the framework treats absence of a
row as equivalent to that. `essential` is permanently true.
- `PUT /api/users/me/cookie-consent` — write the signed-in user's
cookie consent record per §14.5. Body: `{essential, analytics,
other}`. The `essential` flag is accepted for symmetry but always
persisted as true. Upserts (a single row per user) and stamps
`recorded_at` to now.
Plus all the chat / streaming / model-picker endpoints, scoped to
per-RFC and per-branch threads.
@@ -3703,150 +3425,6 @@ the new §15 (Notifications, in full), and §17 (the notification
endpoints — list, mark-read, stream, watch mutation, preferences,
quiet-hours, per-user mute, unsubscribe, bounce webhook).
First-OTC profile capture (formerly a v0.7.0 candidate) is settled
and folded into §6.1 (the contributor role now requires
`permission_state='granted'`), §6.2 (the orthogonality of
permission_state vs role / muted / notification-mutes), §14.1
(the landing page's v0.8.0 first-OTC capture step), and §17
(the `POST /api/auth/me/beta-request` endpoint and the verify
endpoint's new `needs_profile` flag). The structural decision
landed as: capture is a third step on the `/login` surface
gated by `verify_response.needs_profile=true`; pending users
land on `/beta-pending` after submitting and see a thin banner
on every other page until an admin grants. v0.8.0 (roadmap item
#6) shipped the work.
Candidates surfaced during v0.8.0 (open beta-access request flow,
§6.1 / §14.1, item #6):
- **Admin user-management page** (`/admin/users`). *Surfaced by
v0.8.0 — the release ships the pending-state column but no
admin UI to triage it.* For v0.8.0, the admin gesture is an
out-of-band `UPDATE users SET permission_state='granted' WHERE
email=?`. v0.9.0 (roadmap item #7) is expected to ship the
triage queue: a list of `permission_state='pending'` rows
sorted by `created_at`, each showing the captured first /
last / why fields, with Grant and Revoke buttons that stamp
`permission_decided_by` and `permission_decided_at` (schema
slots already in place per `migrations/014_beta_access.sql`).
The page composes naturally with the existing `/admin/allowlist`
surface — both are admission-control gestures — so v0.9.0 may
fold the allowlist UI into this page (see next candidate).
Decision points: do grants / revokes also fire email
notifications to the user (probably yes — the notifications
layer from v0.6.0 has the personal-direct channel for it); is
there a "decline with reason" gesture that surfaces in the
user's view (probably yes — symmetric with §9.3's
proposal-decline shape); does the page support bulk grants
(probably no for v0.9.0 — the queue volume is operator-scale,
not user-scale). Earns its session as the v0.9.0 design pass.
- **Allowlist deprecation.** *Surfaced by v0.8.0 — the
`allowed_emails` table stays in the schema but the OTC
request path no longer consults it.* v0.8.0 left the table
and the `/admin/allowlist` UI in place as a fast-path bypass
for deployments that want to pre-mark known-good emails (the
v0.8.0 contract is that those emails still go through the
pending-grant flow; the table itself is no longer a gate). A
future release retires both — probably v0.9.0 alongside the
admin user-management page, since the two surfaces are
functionally redundant once the pending queue lands.
Decision points: drop the table outright (a schema migration)
or leave it as a non-functional surface and remove only the
UI (a frontend-only change); how to handle existing
`allowed_emails` rows at the cutover (probably: walk them
into the pending queue with `permission_state='granted'` for
any matching `users` row, leave unmatched rows as a no-op
since v0.8.0 doesn't consult them anymore). Earns its
session as a sub-topic of the v0.9.0 admin user-management
pass.
- **Admin notification on new beta request.** *Surfaced by
v0.8.0 — the capture endpoint writes to the row but does
not signal admins.* v0.9.0 candidate (item #7 again): when a
`POST /api/auth/me/beta-request` lands, fire an `admin-actionable`
notification (per §15.4's category set) to every owner / admin
so the queue doesn't go stale. The §15 infrastructure already
supports the category; the open question is whether the
notification is per-request (one email per submission) or
digested (a daily summary). Earns its session alongside the
admin user-management page.
- **Removing the Gitea OAuth fallback.** *Surfaced by v0.7.0.*
v0.7.0 keeps `/auth/callback` functional and links to it as a
"Sign in with Gitea (fallback)" affordance on the new `/login`
surface, so users with active OAuth sessions or older invite
paths still have a way in during the migration window. A later
release retires the route entirely. Decision points: how do we
know "every active user has signed in via OTC at least once"
(probably: a `users.otc_first_signed_in_at` timestamp added in
v0.7.x and a query that confirms 100% population), how do we
handle users who never come back (probably: silently leave them
with stale rows; OAuth callback returning 404 is a sufficient
message), and whether the `/auth/login` and `/auth/callback`
routes get a tombstone redirect to `/login` or just 404. Earns
its session once the OTC adoption curve flattens.
- **Device trust (30-day skip).** *Surfaced by v0.7.0 — the
signed-in cookie already lasts 30 days via SessionMiddleware,
but every sign-in still requires a fresh OTC.* The roadmap
item-#9 candidate adds a "trust this device" affordance on the
verify step that issues a longer-lived rotating token, so
returning visitors on the same device skip the OTC step. The
shape question is whether the trust is a signed cookie distinct
from the session, a row in a `device_trust` table keyed by a
random device-id, or a property of the session itself; and
whether the trust survives password-equivalent events (none
exist yet — passcodes are item #8 / v0.10.0) or only survives
explicit logout. Earns its session as the v0.11.0 design pass.
- **Cloudflare Turnstile (or equivalent) on `/auth/otc/request`.**
*Surfaced by v0.7.0 — the endpoint is now the new abuse hot
path.* Per-email cooldown stops the trivial loop; what it
doesn't stop is a distributed scrape that fans out across a
large invitee list to harvest the "this email is admitted vs.
this email is not" signal indirectly (timing differences, SMTP
bounce-rate observation). The roadmap item-#10 candidate gates
the request endpoint behind a one-step browser-side challenge
before the bcrypt hash + SMTP send. Open questions: which
provider (Turnstile is the default since it's free and
privacy-respecting; hCaptcha and reCAPTCHA are also viable);
how the deployment configures it (`TURNSTILE_SITE_KEY` +
`TURNSTILE_SECRET_KEY` env vars, gated by `if
config.turnstile_site_key:` at the handler so existing
deployments don't break); whether the verify endpoint also
gets a challenge (probably yes for parity); and how the test
harness mocks the challenge. Earns its session as the v0.12.0
design pass.
Candidates surfaced during v0.13.0 (cookie / privacy consent, §14.5
and §14.6):
- **Policy content via content-repo file vs env var.** v0.13.0
shipped the deployment-policy-override path as two env vars
(`VITE_PRIVACY_POLICY_URL`, `VITE_COOKIES_POLICY_URL`) that the
framework's stub pages link out to. The alternative — accepting
a markdown file path the framework renders inline, parallel to
`PHILOSOPHY_PATH` per §14.2 — was deferred. The two compose:
a deployment could carry both an inline file (rendered above
the fold) and an external link (rendered below). Earns its own
topic when a real deployment ships a policy long enough that
the link-out shape bites and renders the link unread.
- **Global Privacy Control / Do-Not-Track headers.** v0.13.0
scoped the consent surface to the in-app banner and did not
honor browser-side GPC or DNT signals. The framework's stance
is that the in-app banner is the authoritative gesture — a
user who clears their consent in the banner has expressed
intent, and the GPC header is a coarser signal layered on top.
Earns its own topic if a regulatory regime emerges that treats
GPC as the legally-binding gesture, in which case the framework
would honor GPC as an automatic "essential only" choice unless
the user explicitly broadened it in-app.
- **Multi-language consent text.** The banner ships English-only.
i18n of the framework's user-facing strings is a broader topic
than the consent banner; carrying the work in that future topic
rather than as a per-surface translation pass.
- **Analytics SDK gating against `consent.js`.** Roadmap item #13
(target v0.15.0) lands the analytics SDK behind
`lib/consent.js`'s `getConsent().analytics` gate. The framework
contract is already in place; the SDK integration is the work
the item ships. Listed here so the dependency is documented.
### 19.3 Working agreement for the queue
Pre-build sessions ran on the queue agreement from prior versions
+1 -1
View File
@@ -1 +1 @@
0.8.0
0.5.0
-11
View File
@@ -81,14 +81,3 @@ WEBHOOK_EMAIL_BOUNCE_SECRET=
# Production default is hourly; tests override to seconds via the same
# env var.
HYGIENE_TICK_SECONDS=3600
# --- v0.7.0: email + one-time-code sign-in (§6.2) ---
# How long a one-time code stays valid after issuance. Re-requesting
# invalidates the prior code immediately regardless of TTL.
OTC_TTL_MINUTES=10
# Per-email cooldown between successive /auth/otc/request calls. The
# endpoint returns HTTP 429 when the cooldown blocks a request (the
# loud-failure shape so the abuse path is visible). Set to 0 to
# disable the cooldown — useful for tests but never in production.
OTC_REQUEST_COOLDOWN_SECONDS=60
+1 -97
View File
@@ -55,17 +55,6 @@ class FunderCredentialBody(BaseModel):
api_key: str = Field(min_length=1, max_length=2048)
class BetaRequestBody(BaseModel):
# v0.8.0 — captured on the first OTC sign-in. All three fields are
# required so the admin queue has a coherent triage shape.
# The bounds match the v0.7.0 OTC body (320 chars for email-ish
# headers; 4000 for the free-text reason — the same upper bound
# DeclineBody uses elsewhere in this file).
first_name: str = Field(min_length=1, max_length=120)
last_name: str = Field(min_length=1, max_length=120)
beta_request_reason: str = Field(min_length=1, max_length=4000)
def make_router(
config: Config,
gitea: Gitea,
@@ -131,28 +120,6 @@ def make_router(
user = auth.current_user(request)
if user is None:
return {"authenticated": False, "user": None}
# v0.8.0: surface `permission_state` plus the capture-flow
# readiness signal (`needs_profile`). The frontend gates
# the /beta-pending page and the inline banner off these
# fields, and decides whether to prompt for the first/last/why
# capture on first OTC sign-in.
row = db.conn().execute(
"SELECT first_name, last_name, beta_request_reason FROM users WHERE id = ?",
(user.user_id,),
).fetchone()
first_name = (row["first_name"] if row else None) or ""
last_name = (row["last_name"] if row else None) or ""
beta_request_reason = (row["beta_request_reason"] if row else None) or ""
# "Needs profile" iff the user is pending AND hasn't yet
# filed their beta-request capture. Granted users never see
# the capture prompt; pending users who already filed see
# the /beta-pending page without the capture form.
needs_profile = (
user.permission_state == "pending"
and not first_name
and not last_name
and not beta_request_reason
)
return {
"authenticated": True,
"user": {
@@ -162,68 +129,9 @@ def make_router(
"email": user.email,
"avatar_url": user.avatar_url,
"role": user.role,
"permission_state": user.permission_state,
"first_name": first_name,
"last_name": last_name,
"beta_request_reason": beta_request_reason,
"needs_profile": needs_profile,
},
}
# ---------------------------------------------------------------
# v0.8.0: /api/auth/me/beta-request — first-OTC profile capture
# (roadmap item #6). Lands first name, last name, and the free-
# text "why I should be included in the beta" on the signed-in
# user's row. Idempotent for the same already-pending user;
# refuses to overwrite a row that's already granted (so a
# bored already-granted user can't accidentally re-submit the
# form and clobber the admin's audit trail). Uses
# `require_user` rather than `require_contributor` because
# `require_contributor` already enforces `permission_state =
# 'granted'` and would refuse a pending user; the whole point
# of this endpoint is to register the request _from_ a pending
# user.
# ---------------------------------------------------------------
@router.post("/api/auth/me/beta-request")
async def submit_beta_request(body: BetaRequestBody, request: Request) -> dict[str, Any]:
user = auth.require_user(request)
row = db.conn().execute(
"SELECT permission_state, first_name, last_name, beta_request_reason FROM users WHERE id = ?",
(user.user_id,),
).fetchone()
if row is None:
# Defensive — the session pointed at a deleted row.
raise HTTPException(404, "User not found")
# Granted users have no business filing a beta request.
# 'revoked' likewise — the request flow is for fresh users
# only. Both shapes refuse with 409 (conflict) so the client
# can distinguish "you already have access" from
# "your access was revoked".
if row["permission_state"] == "granted":
raise HTTPException(409, "Your account is already granted access")
if row["permission_state"] == "revoked":
raise HTTPException(409, "Your account's access has been revoked")
# Re-submission from a pending user updates the row — the
# admin sees the latest text rather than a stale draft.
# The state stays 'pending'; only an admin can flip it.
db.conn().execute(
"""
UPDATE users
SET first_name = ?,
last_name = ?,
beta_request_reason = ?
WHERE id = ?
""",
(
body.first_name.strip(),
body.last_name.strip(),
body.beta_request_reason.strip(),
user.user_id,
),
)
return {"ok": True}
# ---------------------------------------------------------------
# §7: the catalog
# ---------------------------------------------------------------
@@ -397,11 +305,7 @@ def make_router(
proposed_at=entry_mod.today(),
graduated_at=None,
graduated_by=None,
# §9.2: the proposer is the implicit first owner at propose time.
# The §13.1 claim flow exists for *other* contributors to become
# owners on an RFC they didn't propose; the proposer never needs
# to claim their own RFC.
owners=[user.gitea_login],
owners=[],
arbiters=[],
tags=[t.strip() for t in payload.tags if t.strip()],
body=payload.pitch.strip() + "\n",
+1 -10
View File
@@ -520,16 +520,7 @@ def make_router(
@router.get("/api/rfcs/{slug}/graduate/progress")
async def graduate_progress(slug: str, request: Request):
# v0.6.0 (item #4): the progress SSE surfaces admin-internal step
# detail (repo name, PR number, rollback steps) that isn't part of
# the v0.3.0 anonymous-read contract for catalog/RFC bodies. The
# corresponding POST /graduate is gated to RFC owners/arbiters and
# app admins/owners via `_can_graduate`; the read SSE shares that
# operator-visible surface, so it requires at least an
# authenticated viewer. We keep the floor at require_user (not
# require_contributor) so a write-muted operator can still observe
# the progress of a graduation they kicked off before being muted.
auth.require_user(request)
del request
state = _get_active(slug)
if state is None:
raise HTTPException(404, "No graduation in flight for this slug")
-79
View File
@@ -14,8 +14,6 @@ The endpoints in this module are:
- `POST /api/users/me/quiet-hours` set / clear
- `POST /api/users/<id>/notification-mute` §15.8
- `DELETE /api/users/<id>/notification-mute` §15.8
- `GET /api/users/me/cookie-consent` §14.5
- `PUT /api/users/me/cookie-consent` §14.5
- `GET /api/email/unsubscribe` §15.4 one-click
- `POST /api/webhooks/email-bounce` §15.4 receiver
@@ -75,15 +73,6 @@ class BounceBody(BaseModel):
kind: str = Field(default="hard") # 'hard' or 'complaint'
class CookieConsentBody(BaseModel):
# `essential` is always true at the surface; we accept it for symmetry
# but never persist a false value (the framework's strictly-necessary
# cookies are not user-optional per SPEC §14.5).
essential: bool = True
analytics: bool = False
other: bool = False
# ---------------------------------------------------------------------------
# Router
# ---------------------------------------------------------------------------
@@ -373,74 +362,6 @@ def make_router(config: Config) -> APIRouter:
)
return {"ok": True}
# ----- Cookie consent (v0.13.0 / roadmap item #11; SPEC §14.5) -----
#
# The shape is intentionally small: three flags + a recorded-at stamp.
# The banner's local-vs-server precedence rule lives in the frontend
# (`consent.js`): on sign-in, the server row (if any) overrides local;
# otherwise local is uploaded.
@router.get("/api/users/me/cookie-consent")
async def get_cookie_consent(request: Request) -> dict[str, Any]:
viewer = auth.require_user(request)
row = db.conn().execute(
"""
SELECT essential, analytics, other_cookies, recorded_at
FROM cookie_consent WHERE user_id = ?
""",
(viewer.user_id,),
).fetchone()
if row is None:
return {
"essential": True,
"analytics": False,
"other": False,
"recorded_at": None,
}
return {
"essential": bool(row["essential"]),
"analytics": bool(row["analytics"]),
"other": bool(row["other_cookies"]),
"recorded_at": row["recorded_at"],
}
@router.put("/api/users/me/cookie-consent")
async def set_cookie_consent(body: CookieConsentBody, request: Request) -> dict[str, Any]:
viewer = auth.require_user(request)
# `essential` is the framework's strictly-necessary set; the
# surface accepts the flag for symmetry but never persists a
# false value. SPEC §14.5: a deployment that wants to make
# session-cookie storage optional must change the framework
# contract, not flip a flag here.
db.conn().execute(
"""
INSERT INTO cookie_consent
(user_id, essential, analytics, other_cookies, recorded_at)
VALUES (?, 1, ?, ?, datetime('now'))
ON CONFLICT(user_id) DO UPDATE SET
essential = 1,
analytics = excluded.analytics,
other_cookies = excluded.other_cookies,
recorded_at = excluded.recorded_at
""",
(
viewer.user_id,
1 if body.analytics else 0,
1 if body.other else 0,
),
)
row = db.conn().execute(
"SELECT recorded_at FROM cookie_consent WHERE user_id = ?",
(viewer.user_id,),
).fetchone()
return {
"ok": True,
"essential": True,
"analytics": bool(body.analytics),
"other": bool(body.other),
"recorded_at": row["recorded_at"] if row else None,
}
# ----- Email: one-click unsubscribe + bounce webhook -----
@router.get("/api/email/unsubscribe")
+6 -68
View File
@@ -30,12 +30,6 @@ class SessionUser:
email: str
avatar_url: str
role: str
# v0.8.0 / §6.1 — admission gate. Three states: 'pending' (waiting
# for an admin grant), 'granted' (active contributor), 'revoked'
# (was granted, later removed). Existing rows at migration time
# default to 'granted' so grandfathered users are unaffected; OTC
# provisions fresh users with 'pending' (see `app/otc.py`).
permission_state: str = "granted"
def as_actor(self) -> Actor:
return Actor(
@@ -96,13 +90,6 @@ def allowlist_is_active() -> bool:
def is_allowed_sign_in(profile: dict[str, Any]) -> bool:
"""Decide whether a freshly-completed OAuth profile may sign in.
v0.8.0 (item #6) replaces the allowlist gate with an admin-grant
flow at the OTC `/request` surface, but the Gitea OAuth callback
in `main.py` still consults this helper so the fallback path
keeps the v0.3.0 admission shape during the OAuth migration
window. The eventual removal of the OAuth callback (§19.2)
retires this function alongside it.
Three accept paths:
1. The allowlist is empty (gate off).
2. The Gitea profile's email is in `allowed_emails` (case-insensitive).
@@ -145,27 +132,17 @@ def provision_user(config: Config, profile: dict[str, Any]) -> SessionUser:
existing = c.execute("SELECT * FROM users WHERE gitea_id = ?", (gitea_id,)).fetchone()
if existing is None:
role = "owner" if config.owner_gitea_login and login == config.owner_gitea_login else "contributor"
# v0.8.0: a fresh OAuth-provisioned user is also subject to
# the admin-grant flow. The OAuth fallback only fires for
# users who pass `is_allowed_sign_in` (so they're already on
# the legacy allowlist or are grandfathered by gitea_id);
# 'granted' is the right default here since the allowlist
# check is itself the admin gesture. A future release that
# retires the OAuth callback (§19.2) collapses both paths
# under the same gate.
cur = c.execute(
"""
INSERT INTO users (gitea_id, gitea_login, email, display_name, avatar_url, role, permission_state)
VALUES (?, ?, ?, ?, ?, ?, 'granted')
INSERT INTO users (gitea_id, gitea_login, email, display_name, avatar_url, role)
VALUES (?, ?, ?, ?, ?, ?)
""",
(gitea_id, login, email, display, avatar, role),
)
user_id = cur.lastrowid
permission_state = "granted"
else:
user_id = existing["id"]
role = existing["role"]
permission_state = existing["permission_state"] or "granted"
c.execute(
"""
UPDATE users
@@ -183,7 +160,6 @@ def provision_user(config: Config, profile: dict[str, Any]) -> SessionUser:
email=email,
avatar_url=avatar,
role=role,
permission_state=permission_state,
)
@@ -202,12 +178,6 @@ def store_session(request: Request, user: SessionUser) -> None:
"email": user.email,
"avatar_url": user.avatar_url,
"role": user.role,
# v0.8.0: persist the admission state on the cookie payload so
# the post-cookie audit doesn't second-guess the row. The DB
# is re-read on every `current_user` call regardless (so an
# admin grant takes effect on the next request); this field
# is purely structural redundancy for the cookie shape.
"permission_state": user.permission_state,
}
@@ -218,31 +188,19 @@ def current_user(request: Request) -> SessionUser | None:
# Re-read the role from the database every request so role changes
# take effect on the next API call without forcing a logout.
row = db.conn().execute(
"SELECT id, gitea_id, gitea_login, email, display_name, avatar_url, role, permission_state FROM users WHERE id = ?",
"SELECT id, gitea_id, gitea_login, email, display_name, avatar_url, role FROM users WHERE id = ?",
(raw["user_id"],),
).fetchone()
if row is None:
return None
# v0.7.0: OTC-provisioned users have NULL gitea_id / gitea_login.
# Coerce nulls to the SessionUser's typed defaults so downstream
# code (Actor, _on_behalf_trailer) reads a stable shape regardless
# of which sign-in path the row came from. The DB remains the
# source of truth for "is this an OAuth-linked user" (gitea_id IS
# NOT NULL); the in-memory SessionUser is the per-request handle.
# v0.8.0: permission_state comes off the row directly. A NULL
# column value (shouldn't happen under the migration's
# NOT NULL DEFAULT, but be defensive) reads as 'granted' so the
# gate fails open for grandfathered surfaces rather than locking
# everyone out on a malformed row.
return SessionUser(
user_id=row["id"],
gitea_id=row["gitea_id"] or 0,
gitea_login=row["gitea_login"] or "",
gitea_id=row["gitea_id"],
gitea_login=row["gitea_login"],
display_name=row["display_name"],
email=row["email"] or "",
avatar_url=row["avatar_url"] or "",
role=row["role"],
permission_state=row["permission_state"] or "granted",
)
@@ -254,31 +212,11 @@ def require_user(request: Request) -> SessionUser:
def require_contributor(request: Request) -> SessionUser:
"""§6.1: authenticated, not write-muted, and granted by an admin.
v0.8.0 (item #6) widens this gate. A fresh OTC sign-in lands in
`permission_state='pending'`; the user can read everything an
anonymous viewer can read, but every write-shaped endpoint that
funnels through this dependency now refuses with 403 until an
admin grants them. The `pending` blast radius is the same as
anonymous (item #4 / v0.6.0 already audited the anon-write
refusal at every write site), so this widening is structurally
a relabel the same surfaces that already refused 401 to
anonymous now also refuse 403 to pending.
"""
"""§6.1: authenticated, not write-muted."""
user = require_user(request)
row = db.conn().execute("SELECT muted FROM users WHERE id = ?", (user.user_id,)).fetchone()
if row and row["muted"]:
raise HTTPException(status_code=403, detail="Your account is muted")
if user.permission_state != "granted":
# 'pending' is the post-OTC waiting state; 'revoked' is the
# admin-undid-the-grant state. Both refuse with the same 403
# shape; the client distinguishes via `/api/auth/me` which
# carries `permission_state` in the response.
raise HTTPException(
status_code=403,
detail="Your beta access request is in review",
)
return user
-96
View File
@@ -1,96 +0,0 @@
"""Outbound OTC email — a thin wrapper over the existing SMTP layer.
The §15.4 notification mailer in `email.py` is purpose-built for
inbox-driven mail (unsubscribe footers, quiet-hours holds, bundling).
OTC mail is structurally different: it carries a credential, has no
inbox row behind it, and ignores user-preferences (a contributor
who's opted out of every notification still needs to receive the
code they explicitly requested).
So this module reuses `EmailConfig.from_env()` for the SMTP plumbing
and the From identity, but writes its own envelope. In dev (no
SMTP_HOST set), the envelope is logged at INFO level and pushed to
the same `_SENT` buffer the notification mailer uses, so the
integration tests can assert on the outbound shape without standing
up an SMTP server.
The send is synchronous. The `/auth/otc/request` endpoint always
returns 202 regardless of send outcome the user-facing surface
doesn't know whether the SMTP relay was reachable, since revealing
that would let an attacker probe for valid emails on a tight loop.
"""
from __future__ import annotations
import logging
import smtplib
from email.message import EmailMessage
from email.utils import formataddr
from .email import EmailConfig, _SENT
log = logging.getLogger(__name__)
def send_otc_email(to_address: str, code: str) -> bool:
"""Compose and send the one-time-code email. Returns True on the
happy path; False on SMTP failure. The notifier-side buffer
`_SENT` is appended either way so tests can assert on content.
The subject and body intentionally avoid branding strings that
belong to a deployment only `EMAIL_FROM_NAME` (operator-supplied
via env) lands in the From line. The body names the code, the
TTL, and a single instruction line. No tracking pixel, no
deep-link query, no embedded JS plain text only."""
cfg = EmailConfig.from_env()
subject = f"Your sign-in code for {cfg.from_name}"
body = _body(code, cfg)
envelope = {
"to": to_address,
"from": formataddr((cfg.from_name, cfg.from_address)),
"subject": subject,
"body": body,
"kind": "otc",
}
_SENT.append(envelope)
if not cfg.enabled:
log.info("otc email disabled (EMAIL_ENABLED=0): to=%s", to_address)
return True
if not cfg.smtp_host:
# Dev fallback: surface the code at INFO so the operator can
# complete a sign-in flow without an SMTP relay. In production
# SMTP_HOST is always set per OHM's overlay.
log.info("otc email (stdout fallback): to=%s code=%s", to_address, code)
return True
try:
msg = EmailMessage()
msg["From"] = envelope["from"]
msg["To"] = to_address
msg["Subject"] = subject
msg.set_content(body)
smtp = smtplib.SMTP(cfg.smtp_host, cfg.smtp_port, timeout=30)
try:
if cfg.smtp_starttls:
smtp.starttls()
if cfg.smtp_user:
smtp.login(cfg.smtp_user, cfg.smtp_password)
smtp.send_message(msg)
finally:
smtp.quit()
return True
except Exception:
log.exception("otc email send failed: to=%s", to_address)
return False
def _body(code: str, cfg: EmailConfig) -> str:
return (
f"Your sign-in code is:\n\n"
f" {code}\n\n"
f"Enter this code in the sign-in screen to finish signing in.\n"
f"The code expires in 10 minutes. If you did not request this,\n"
f"you can safely ignore this email — no account was created.\n\n"
f"---\n"
f"{cfg.from_name} · {cfg.app_url}\n"
)
+1 -83
View File
@@ -12,21 +12,9 @@ from contextlib import asynccontextmanager
from fastapi import APIRouter, FastAPI, HTTPException, Request
from fastapi.responses import RedirectResponse
from pydantic import BaseModel, Field
from starlette.middleware.sessions import SessionMiddleware
from . import (
api as api_routes,
auth,
cache,
db,
digest,
email_otc,
hygiene,
otc,
providers as providers_mod,
webhooks,
)
from . import api as api_routes, auth, cache, db, digest, hygiene, providers as providers_mod, webhooks
from .bot import Bot
from .config import load_config
from .gitea import Gitea
@@ -35,15 +23,6 @@ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name
log = logging.getLogger("rfc_app")
class OtcRequestBody(BaseModel):
email: str = Field(min_length=3, max_length=320)
class OtcVerifyBody(BaseModel):
email: str = Field(min_length=3, max_length=320)
code: str = Field(min_length=1, max_length=16)
@asynccontextmanager
async def lifespan(app: FastAPI):
config = load_config()
@@ -143,65 +122,4 @@ def _oauth_router(config) -> APIRouter:
request.session.clear()
return RedirectResponse("/")
# ---------------------------------------------------------------
# v0.7.0: email + one-time-code sign-in (§6.2).
#
# Replaces the OAuth gesture as the primary human-auth path. The
# /auth/callback handler above remains functional as a fallback;
# the new UI no longer surfaces it. A future release retires the
# OAuth path entirely once every active user has signed in at
# least once via OTC.
# ---------------------------------------------------------------
@router.post("/auth/otc/request")
async def otc_request(body: OtcRequestBody):
outcome = otc.request_code(body.email)
if outcome.reason == "cooldown":
# Loud failure per the rate-limit primitive — the abuse
# surface should be visible to clients hammering /request.
raise HTTPException(429, "Wait before requesting another code")
if outcome.sent and outcome.code is not None:
email_otc.send_otc_email(body.email.strip(), outcome.code)
# 202 regardless of allowlist/invalid — don't leak which
# emails are recognized.
return {"ok": True}
@router.post("/auth/otc/verify")
async def otc_verify(body: OtcVerifyBody, request: Request):
result = otc.verify_code(body.email, body.code)
if not result.ok or result.user is None:
raise HTTPException(400, "Invalid or expired code")
auth.store_session(request, result.user)
# v0.8.0: surface `needs_profile` so the Login.jsx surface can
# decide whether to advance to the first/last/why capture step
# or jump straight to "/". `needs_profile=true` iff the user
# is `permission_state='pending'` AND the row has no profile
# fields yet — a fresh OTC user. Grandfathered users
# (`permission_state='granted'`) and pending users who already
# captured their fields both read as false.
row = db.conn().execute(
"SELECT first_name, last_name, beta_request_reason FROM users WHERE id = ?",
(result.user.user_id,),
).fetchone()
first_name = (row["first_name"] if row else None) or ""
last_name = (row["last_name"] if row else None) or ""
beta_request_reason = (row["beta_request_reason"] if row else None) or ""
needs_profile = (
result.user.permission_state == "pending"
and not first_name
and not last_name
and not beta_request_reason
)
return {
"ok": True,
"user": {
"id": result.user.user_id,
"display_name": result.user.display_name,
"email": result.user.email,
"role": result.user.role,
"permission_state": result.user.permission_state,
},
"needs_profile": needs_profile,
}
return router
-336
View File
@@ -1,336 +0,0 @@
"""§6.2 / v0.7.0 / v0.8.0: email + one-time-code sign-in.
Replaces the Gitea OAuth gesture as the primary human-auth path. The
Gitea bot user + token are still needed for server-side git
operations (repo reads, PR creation); only the operator-facing
sign-in surface moves through this module.
The shape:
* `request_code(email)` generates a 6-digit decimal code,
hashes it (bcrypt), stores the hash + expiry in `otc_codes`,
and dispatches a plain-text email via `email_otc.send`. It
invalidates any prior unused codes for the same email so a
re-request keeps the surface to one outstanding code per
address. The TTL comes from `OTC_TTL_MINUTES` (default 10).
A per-email cooldown (`OTC_REQUEST_COOLDOWN_SECONDS`, default
60) refuses back-to-back requests inside the window.
* `verify_code(email, code)` walks the most recent unconsumed
non-expired row for the email, checks the bcrypt hash, marks
the row consumed, and returns the linked or freshly-provisioned
user row.
* `provision_or_link_user(email)` is the migration path: if a
`users` row already carries `email` (case-insensitive), it is
reused `gitea_id` is left alone so a grandfathered OAuth-era
user keeps the linker intact. Otherwise a fresh contributor
row is provisioned with `gitea_id = NULL`, `gitea_login = NULL`,
and `permission_state = 'pending'` (v0.8.0 see below).
The endpoints in `main.py` thin-wrap this module.
v0.8.0 (roadmap item #6) replaces the v0.3.0 `allowed_emails` gate at
the request surface. The request handler used to silently drop OTC
requests for emails not on the allowlist; now any valid email
receives a code. The admission gate moves to `permission_state` on
the freshly-provisioned `users` row: a fresh user lands in 'pending'
and waits for an admin grant before write endpoints accept them.
Read surfaces stay open (the same blast radius v0.6.0 / item #4
already audited for anonymous viewers).
The `allowed_emails` table itself stays in the schema as a
fast-path bypass the admin UI from v0.3.0 continues to manage it,
and a future release (v0.9.0's admin user-management page) collapses
the two admission surfaces into one. The OTC request path no
longer consults the table.
"""
from __future__ import annotations
import logging
import os
import secrets
from dataclasses import dataclass
import bcrypt
from . import db
from .auth import SessionUser
log = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Tunables — env-driven with defaults so v0.7.0 needs no new secrets.
# ---------------------------------------------------------------------------
def _ttl_minutes() -> int:
raw = os.environ.get("OTC_TTL_MINUTES", "").strip()
if not raw:
return 10
try:
return max(1, int(raw))
except ValueError:
return 10
def _cooldown_seconds() -> int:
raw = os.environ.get("OTC_REQUEST_COOLDOWN_SECONDS", "").strip()
if not raw:
return 60
try:
return max(0, int(raw))
except ValueError:
return 60
# ---------------------------------------------------------------------------
# Code generation + hashing
# ---------------------------------------------------------------------------
def _new_code() -> str:
"""Six decimal digits. `secrets.randbelow` is CSPRNG-backed so the
code resists guessing even at the small (10^6) keyspace. The TTL
+ rate-limit are what carry the security weight the entropy of a
six-digit code by itself is intentionally human-readable."""
return f"{secrets.randbelow(1_000_000):06d}"
def _hash_code(code: str) -> str:
"""bcrypt over the code bytes. The hash is stored at rest; the code
itself only travels in the outbound email and the inbound verify
body."""
return bcrypt.hashpw(code.encode("utf-8"), bcrypt.gensalt()).decode("ascii")
def _check_code(code: str, code_hash: str) -> bool:
try:
return bcrypt.checkpw(code.encode("utf-8"), code_hash.encode("ascii"))
except (ValueError, TypeError):
return False
# ---------------------------------------------------------------------------
# Request path
#
# v0.8.0: the allowlist gate from v0.7.0 / v0.3.0 is removed here. Any
# valid email receives a code; the admission gate moved to
# `permission_state` on the freshly-provisioned `users` row (see
# `provision_or_link_user`). The `allowed_emails` table stays in the
# schema (admin UI from v0.3.0 still manages it); v0.9.0's admin
# user-management page will collapse the two surfaces.
# ---------------------------------------------------------------------------
@dataclass
class RequestOutcome:
"""The outcome of a `request_code` call.
`code` is None whenever no code was generated the cooldown
window blocked the request or the email was syntactically
invalid. The caller (the API endpoint) does not surface the
invalid-email shape to the user; it returns 202 either way.
The cooldown shape surfaces as a loud 429 per the v0.7.0
contract.
"""
sent: bool
code: str | None
reason: str # 'sent' | 'cooldown' | 'invalid'
def request_code(email: str) -> RequestOutcome:
email = (email or "").strip()
if not email or "@" not in email:
return RequestOutcome(sent=False, code=None, reason="invalid")
# Cooldown: refuse if a code was issued for this email in the last
# COOLDOWN_SECONDS. We surface it as a distinct outcome so the
# endpoint can return 429 — the spec calls this out as a "loud
# failure" so the abuse path is visible rather than swallowed.
cooldown = _cooldown_seconds()
if cooldown > 0:
row = db.conn().execute(
f"""
SELECT 1 FROM otc_codes
WHERE email = ?
AND datetime(created_at, '+{cooldown} seconds') > datetime('now')
LIMIT 1
""",
(email,),
).fetchone()
if row is not None:
return RequestOutcome(sent=False, code=None, reason="cooldown")
# Invalidate prior unused codes for this email. A re-request is
# always for the most recent code; older codes are dead.
db.conn().execute(
"""
UPDATE otc_codes
SET consumed_at = datetime('now')
WHERE email = ?
AND consumed_at IS NULL
""",
(email,),
)
code = _new_code()
code_hash = _hash_code(code)
ttl = _ttl_minutes()
db.conn().execute(
f"""
INSERT INTO otc_codes (email, code_hash, expires_at)
VALUES (?, ?, datetime('now', '+{ttl} minutes'))
""",
(email, code_hash),
)
return RequestOutcome(sent=True, code=code, reason="sent")
# ---------------------------------------------------------------------------
# Verify path
# ---------------------------------------------------------------------------
@dataclass
class VerifyOutcome:
"""Result of a `verify_code` call.
`user` is populated only on success. `reason` distinguishes the
failure modes the UI can render 'expired', 'consumed', 'wrong',
'unknown' (no outstanding code at all). The endpoint maps the
failure modes to a single 400 with a generic message; the reason
is logged for the operator.
"""
ok: bool
user: SessionUser | None
reason: str
def verify_code(email: str, code: str) -> VerifyOutcome:
email = (email or "").strip()
code = (code or "").strip()
if not email or not code:
return VerifyOutcome(ok=False, user=None, reason="invalid")
rows = db.conn().execute(
"""
SELECT id, code_hash, expires_at, consumed_at
FROM otc_codes
WHERE email = ?
ORDER BY id DESC
LIMIT 5
""",
(email,),
).fetchall()
if not rows:
return VerifyOutcome(ok=False, user=None, reason="unknown")
# Walk the recent rows so a user who pasted an older code still
# gets a sensible error — without this, the most-recent-row check
# would mask "you entered yesterday's code" as "wrong code".
matched = None
for row in rows:
if _check_code(code, row["code_hash"]):
matched = row
break
if matched is None:
return VerifyOutcome(ok=False, user=None, reason="wrong")
if matched["consumed_at"] is not None:
return VerifyOutcome(ok=False, user=None, reason="consumed")
expired = db.conn().execute(
"SELECT datetime(?) < datetime('now') AS expired",
(matched["expires_at"],),
).fetchone()["expired"]
if expired:
return VerifyOutcome(ok=False, user=None, reason="expired")
# Stamp consumed before provisioning so a parallel verify of the
# same row can't double-sign-in.
db.conn().execute(
"UPDATE otc_codes SET consumed_at = datetime('now') WHERE id = ?",
(matched["id"],),
)
user = provision_or_link_user(email)
return VerifyOutcome(ok=True, user=user, reason="ok")
# ---------------------------------------------------------------------------
# Provisioning — the migration path from OAuth identity to email identity.
# ---------------------------------------------------------------------------
def provision_or_link_user(email: str) -> SessionUser:
"""Link the OTC sign-in to a `users` row.
Match order:
1. An existing row whose email equals (case-insensitive) the
requested email the OAuth-era user is grandfathered in via
this path. `gitea_id` is preserved so a future OAuth round
trip still resolves the same row. `permission_state` is
read off the row as-is grandfathered users come through
migration with 'granted' (the column default), so their
contributor capabilities are unaffected.
2. Otherwise: a fresh contributor row with `gitea_id = NULL`,
`gitea_login = NULL`, and `permission_state = 'pending'`
(v0.8.0). The display name defaults to the local part of
the email (everything before the `@`); a separate
`POST /auth/me/beta-request` call lands first name / last
name / "why I want access" on the same row.
The §6.1 owner-zero bootstrap still applies: if the email matches
the configured `OWNER_GITEA_LOGIN`-derived owner identity, the row
is provisioned with role='owner'. v0.7.0 keeps that field as the
Gitea login (so existing deployments don't break); a future
release may add a parallel `OWNER_EMAIL` env if the OAuth route is
dropped entirely.
"""
email = email.strip()
existing = db.conn().execute(
"SELECT * FROM users WHERE email = ? COLLATE NOCASE",
(email,),
).fetchone()
if existing is not None:
db.conn().execute(
"UPDATE users SET last_seen_at = datetime('now') WHERE id = ?",
(existing["id"],),
)
return SessionUser(
user_id=existing["id"],
gitea_id=existing["gitea_id"] or 0,
gitea_login=existing["gitea_login"] or "",
display_name=existing["display_name"],
email=existing["email"] or email,
avatar_url=existing["avatar_url"] or "",
role=existing["role"],
permission_state=existing["permission_state"] or "granted",
)
display = email.split("@", 1)[0] or email
# v0.8.0: 'pending' is the explicit insert value; the migration
# default of 'granted' is what passes grandfathered users
# through. A fresh OTC user lands in 'pending' regardless of
# what the migration default says, so the gate engages reliably
# even if a future migration changes the default.
cur = db.conn().execute(
"""
INSERT INTO users (gitea_id, gitea_login, email, display_name, avatar_url, role, permission_state)
VALUES (NULL, NULL, ?, ?, '', 'contributor', 'pending')
""",
(email, display),
)
user_id = cur.lastrowid
return SessionUser(
user_id=user_id,
gitea_id=0,
gitea_login="",
display_name=display,
email=email,
avatar_url="",
role="contributor",
permission_state="pending",
)
-105
View File
@@ -1,105 +0,0 @@
-- §6.2 / v0.7.0: email + one-time-code sign-in.
--
-- Replaces the Gitea OAuth gesture as the primary human-auth path.
-- The Gitea bot user + token are still needed for server-side git
-- operations (repo reads, PR creation); only the operator-facing
-- sign-in surface moves. The /auth/callback OAuth route remains
-- functional during migration as a fallback, scheduled for removal
-- in a future release once every active user has signed in via OTC
-- at least once.
--
-- A row in `otc_codes` represents an outstanding 6-digit code that
-- was emailed to `email`. Codes are stored hashed (bcrypt) rather
-- than plaintext, so a database compromise does not expose the
-- in-flight code. TTL is enforced by `expires_at`. Each `verify`
-- success stamps `consumed_at` and refuses every later attempt
-- against the same row.
--
-- The §6.2 identity model under v0.7.0:
--
-- * `users.email` is the primary identity key for new sign-ins.
-- * `users.gitea_id` stays populated for users grandfathered in
-- via the OAuth-era flow; new users have `gitea_id = NULL`.
-- The unique-constraint on `gitea_id` is relaxed (in v0.5.0 it
-- was `INTEGER UNIQUE NOT NULL`) to permit the NULL.
-- * `users.email` becomes a (case-insensitive) unique key. An
-- existing OAuth user whose Gitea profile carried an email is
-- linked on first OTC sign-in; if no row matches, a fresh
-- contributor row is provisioned.
--
-- New env vars (v0.7.0):
-- * `OTC_TTL_MINUTES` (default 10): how long a code stays valid.
-- * `OTC_REQUEST_COOLDOWN_SECONDS` (default 60): per-email rate
-- limit between successive `/auth/otc/request` calls.
CREATE TABLE otc_codes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL COLLATE NOCASE,
code_hash TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT NOT NULL,
consumed_at TEXT
);
CREATE INDEX idx_otc_codes_email ON otc_codes (email, consumed_at, expires_at);
-- Relax `users.gitea_id` from `INTEGER UNIQUE NOT NULL` to a nullable
-- column with a partial unique index that ignores nulls. SQLite does
-- not support ALTER COLUMN, so we rebuild the table.
--
-- A few defensive notes:
-- * Every foreign key into `users(id)` continues to resolve — `id`
-- is the same INTEGER PRIMARY KEY in the rebuilt table.
-- * `email` is now declared NOCASE so a `WHERE email = ?` match
-- is case-insensitive without changing every read site. The
-- prior column accepted any text; existing rows pass through
-- unchanged.
-- * `gitea_login` likewise relaxes from NOT NULL to nullable, so
-- users provisioned by OTC alone don't carry a synthetic login.
CREATE TABLE users_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
gitea_id INTEGER,
gitea_login TEXT,
email TEXT COLLATE NOCASE,
display_name TEXT NOT NULL,
avatar_url TEXT,
role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'contributor')),
muted INTEGER NOT NULL DEFAULT 0,
email_personal_direct INTEGER NOT NULL DEFAULT 1,
email_watched_structural INTEGER NOT NULL DEFAULT 0,
email_admin_actionable INTEGER NOT NULL DEFAULT 1,
email_opt_out_all INTEGER NOT NULL DEFAULT 0,
digest_cadence TEXT NOT NULL DEFAULT 'weekly' CHECK (digest_cadence IN ('off', 'weekly', 'daily')),
notification_quiet_hours_start TEXT,
notification_quiet_hours_end TEXT,
notification_quiet_hours_timezone TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_seen_at TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT INTO users_new (
id, gitea_id, gitea_login, email, display_name, avatar_url, role,
muted, email_personal_direct, email_watched_structural,
email_admin_actionable, email_opt_out_all, digest_cadence,
notification_quiet_hours_start, notification_quiet_hours_end,
notification_quiet_hours_timezone, created_at, last_seen_at
)
SELECT
id, gitea_id, gitea_login, email, display_name, avatar_url, role,
muted, email_personal_direct, email_watched_structural,
email_admin_actionable, email_opt_out_all, digest_cadence,
notification_quiet_hours_start, notification_quiet_hours_end,
notification_quiet_hours_timezone, created_at, last_seen_at
FROM users;
DROP TABLE users;
ALTER TABLE users_new RENAME TO users;
CREATE INDEX idx_users_role ON users (role);
-- Partial unique indexes so NULLs are permitted but populated values
-- collide. Gitea linkage stays unique per gitea_id; OTC-era identity
-- is keyed on email (case-insensitive via NOCASE on the column).
CREATE UNIQUE INDEX idx_users_gitea_id ON users (gitea_id) WHERE gitea_id IS NOT NULL;
CREATE UNIQUE INDEX idx_users_gitea_login ON users (gitea_login) WHERE gitea_login IS NOT NULL;
CREATE UNIQUE INDEX idx_users_email ON users (email) WHERE email IS NOT NULL AND email != '';
-35
View File
@@ -1,35 +0,0 @@
-- v0.13.0 / roadmap item #11 — cookie consent.
--
-- The framework now ships a non-modal cookie consent banner per the
-- privacy-and-cookies UX (SPEC §14.5 / §14.6). Authenticated viewers
-- get their choice persisted server-side so it survives sign-out /
-- sign-in across devices; anonymous viewers persist their choice in
-- localStorage only.
--
-- Shape: a single row per user, three flags, plus a recorded-at stamp.
-- The flags are:
-- - essential: the framework's strictly-necessary cookies (session,
-- itsdangerous-signed payloads, CSRF if any). Permanently
-- true at the API surface — included in the row for
-- symmetry with the analytics / other flags rather than
-- because the user can switch it off.
-- - analytics: reserved for the §13 analytics SDK gating that lands
-- in v0.15.0. Off by default; opt-in via the banner.
-- - other: everything else (third-party embeds, social widgets).
-- Off by default; opt-in via the banner.
--
-- A NULL recorded_at means "no choice yet" — the banner should re-prompt
-- the next time the user signs in on a fresh device. Once recorded_at is
-- set, the banner is hidden until the user re-opens it from the
-- /settings/notifications "Privacy & cookies" tab.
--
-- The row is created lazily on first PUT. Absence of a row is equivalent
-- to NULL recorded_at — the banner shows.
CREATE TABLE cookie_consent (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
essential INTEGER NOT NULL DEFAULT 1 CHECK (essential IN (0, 1)),
analytics INTEGER NOT NULL DEFAULT 0 CHECK (analytics IN (0, 1)),
other_cookies INTEGER NOT NULL DEFAULT 0 CHECK (other_cookies IN (0, 1)),
recorded_at TEXT
);
-76
View File
@@ -1,76 +0,0 @@
-- §6.1 / §6.2 / §14.1 / v0.8.0: open beta-access request flow (roadmap item #6).
--
-- This release replaces v0.3.0's `allowed_emails` allowlist as the
-- admission control. Anyone with a valid email can sign in via the
-- v0.7.0 OTC flow; a fresh user lands in `permission_state='pending'`
-- until an admin grants access. The first-OTC flow captures three
-- profile fields (first name, last name, free-text "why I should be
-- included in the beta") that the admin sees when triaging the
-- request queue. The `allowed_emails` table stays in the schema as a
-- fast-path bypass — populated rows are still readable by the
-- existing admin UI; the OTC `/request` handler no longer consults
-- it. v0.9.0's admin user-management page will replace the
-- allowlist UI entirely.
--
-- Schema additions:
--
-- * `permission_state` — three-state CHECK: 'pending' | 'granted' |
-- 'revoked'. Default 'granted' so every row at migration time
-- passes through unaffected; only newly provisioned OTC users
-- land in 'pending' (the OTC verify path sets the column
-- explicitly on a fresh row, per `app/otc.py`). 'revoked' is the
-- admin gesture for an account that earned a grant then later
-- lost it; v0.8.0 doesn't surface a revoke UI, but the schema
-- slot is here so v0.9.0's admin user-management page can flip
-- the column without another migration.
--
-- * `first_name`, `last_name` — nullable TEXT. Captured on the
-- first OTC sign-in via `POST /auth/me/beta-request`. Existing
-- rows (OAuth-era users, OTC users provisioned in v0.7.0) carry
-- NULL through the migration; the admin queue treats an
-- unpopulated capture as "auto-grandfathered" since the row's
-- `permission_state` is already 'granted'.
--
-- * `beta_request_reason` — nullable TEXT. The free-text "why I
-- should be included" from the capture form. Bounded to ~4000
-- chars at the endpoint layer (no DB-level constraint —
-- SQLite's TEXT is unbounded).
--
-- * `permission_decided_by` — nullable INTEGER. The `users.id` of
-- the admin who flipped `permission_state` from 'pending' to
-- 'granted' (or 'granted' to 'revoked'). NULL for grandfathered
-- rows (they were never decided — they passed through at
-- migration). ON DELETE SET NULL because losing the admin row
-- should not cascade-delete the user whose access they granted.
--
-- * `permission_decided_at` — nullable TEXT timestamp (ISO 8601,
-- same shape as the existing `created_at` / `last_seen_at`).
-- Co-populated with `permission_decided_by` on each decision.
--
-- Grandfathered-row invariant:
--
-- Every row that exists at migration time has
-- `permission_state='granted'` and `permission_decided_by=NULL`
-- (the column default + NULL preservation). v0.8.0's auth gate
-- reads `permission_state='granted'` as the admission check, so
-- no existing user is locked out by the upgrade. v0.7.0's OTC
-- path is patched in the same release to set
-- `permission_state='pending'` explicitly on a fresh row, so the
-- gate engages only for users provisioned after the upgrade.
ALTER TABLE users ADD COLUMN permission_state TEXT NOT NULL DEFAULT 'granted'
CHECK (permission_state IN ('pending', 'granted', 'revoked'));
ALTER TABLE users ADD COLUMN first_name TEXT;
ALTER TABLE users ADD COLUMN last_name TEXT;
ALTER TABLE users ADD COLUMN beta_request_reason TEXT;
ALTER TABLE users ADD COLUMN permission_decided_by INTEGER
REFERENCES users(id) ON DELETE SET NULL;
ALTER TABLE users ADD COLUMN permission_decided_at TEXT;
-- Index for the v0.9.0 admin queue: list pending requests ordered by
-- when the user's row was created (the implicit "request received at"
-- timestamp, since v0.8.0 sets pending at the same moment as the row
-- itself is inserted via the OTC verify path).
CREATE INDEX idx_users_permission_state ON users (permission_state);
-1
View File
@@ -8,4 +8,3 @@ anthropic>=0.39
google-generativeai>=0.8
openai>=1.50
PyYAML>=6.0
bcrypt>=4.2
@@ -1,476 +0,0 @@
"""v0.6.0 (roadmap item #4) — "anon discuss + contribute off-limits"
vertical.
A sweep-the-edges hardening release. The v0.3.0 release hid the write
affordances from anonymous viewers; v0.5.0 added the PR-less discussion
surface with its own write gate. v0.6.0 audits both: every write-shaped
endpoint refuses anonymous callers with 401 (or 403 when the role check
runs after the auth check), and every anonymous-read surface stays
reachable.
This test is the regression net for the audit. It walks each module's
representative write endpoint as an anonymous client and asserts the
401/403, then walks the same surfaces' representative read endpoints
as anonymous and asserts the 200. The intent is breadth over depth:
one assertion per write endpoint family is enough to catch a
regression where someone strips the `auth.require_contributor` line.
Endpoints covered (one or two from each module):
- api.py: propose, decline (admin), withdraw,
funder credentials POST/DELETE, funder consent
POST/DELETE
- api_branches.py: promote-to-branch, start-edit-branch, metadata,
manual-flush, visibility, grants POST/DELETE,
threads POST, thread messages POST, resolve,
chat-seen, change accept/decline/reask
- api_prs.py: pr-draft, open-pr, seen, review, merge, withdraw,
description, resolution-branch
- api_discussion.py: thread create, message post, resolve
- api_admin.py: role POST, mute POST, allowlist POST/DELETE
- api_notifications.py: prefs POST, watch POST, mark-read POST,
quiet-hours POST, user-mute POST/DELETE
- api_graduation.py: graduate POST, claim POST, progress GET
The §15.7 reads (`/api/notifications`, `/api/watches`,
`/api/users/me/*`) are per-user surfaces they require an
authenticated viewer by definition; an anonymous 401 on those reads is
shape-correct, not a regression. The test does not assert reads on
those.
"""
from __future__ import annotations
import pytest
# Reuse the fixture / session / fake-Gitea harness from Slice 1.
from test_propose_vertical import ( # noqa: F401 — fixtures land via import
FakeGitea,
app_with_fake_gitea,
provision_user_row,
sign_in_as,
tmp_env,
)
from test_rfc_view_vertical import SEED_BODY, seed_active_rfc
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
def test_anonymous_can_read_every_public_surface(app_with_fake_gitea):
"""Per §14 / the v0.3.0 anonymous-read contract: the catalog, the
RFC view, the PR-less discussion surface, the philosophy page, and
the health probe must remain reachable for unauthenticated viewers.
This is the read side of the item #4 contract — the read surfaces
must NOT regress to require auth as the write gates tighten.
"""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="alice", role="contributor")
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
# No session cookie — viewer is anonymous.
client.cookies.clear()
# The five read surfaces an anonymous viewer must reach.
assert client.get("/api/health").status_code == 200
assert client.get("/api/philosophy").status_code == 200
assert client.get("/api/auth/me").status_code == 200
assert client.get("/api/rfcs").status_code == 200
assert client.get("/api/rfcs/ohm").status_code == 200
assert client.get("/api/rfcs/ohm/main").status_code == 200
assert client.get("/api/rfcs/ohm/discussion/threads").status_code == 200
assert client.get("/api/proposals").status_code == 200
def test_anonymous_propose_refused(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
client.cookies.clear()
r = client.post(
"/api/rfcs/propose",
json={"title": "X", "slug": "x", "pitch": "p", "tags": []},
)
assert r.status_code == 401
def test_anonymous_proposal_admin_paths_refused(app_with_fake_gitea):
"""The admin-gated proposal actions — merge, decline — must refuse
anonymous callers with 401 (the auth check runs before the role
check; both refusals are correct, but 401 is the structural signal
"no session at all")."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
client.cookies.clear()
# PR number doesn't need to exist — the gate runs first.
assert client.post("/api/proposals/1/merge").status_code == 401
assert (
client.post("/api/proposals/1/decline", json={"comment": "no"}).status_code
== 401
)
assert client.post("/api/proposals/1/withdraw").status_code == 401
def test_anonymous_branch_writes_refused_on_active_rfc(app_with_fake_gitea):
"""Branch-scoped writes on an active RFC: promote-to-branch,
manual-flush, visibility, grants, threads create, message post,
resolve, chat-seen, change accept/decline/reask. All must 401 for
anonymous callers."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
client.cookies.clear()
# Branch-scoped writes — slug + branch values are placeholders;
# the auth gate runs before any state lookup.
slug = "ohm"
branch = "feature-x"
assert (
client.post(
f"/api/rfcs/{slug}/branches/main/promote-to-branch",
json={},
).status_code == 401
)
assert (
client.post(
f"/api/rfcs/{slug}/branches/{branch}/manual-flush",
json={"new_content": "hi", "paragraph_count": 1},
).status_code == 401
)
assert (
client.post(
f"/api/rfcs/{slug}/branches/{branch}/visibility",
json={"read_public": False},
).status_code == 401
)
assert (
client.post(
f"/api/rfcs/{slug}/branches/{branch}/grants",
json={"grantee_gitea_login": "alice"},
).status_code == 401
)
assert (
client.delete(
f"/api/rfcs/{slug}/branches/{branch}/grants/alice",
).status_code == 401
)
assert (
client.post(
f"/api/rfcs/{slug}/branches/{branch}/threads",
json={"thread_kind": "chat", "anchor_kind": "whole-doc"},
).status_code == 401
)
assert (
client.post(
f"/api/rfcs/{slug}/branches/{branch}/threads/1/messages",
json={"text": "hi"},
).status_code == 401
)
assert (
client.post(
f"/api/rfcs/{slug}/branches/{branch}/threads/1/resolve",
).status_code == 401
)
assert (
client.post(
f"/api/rfcs/{slug}/branches/{branch}/chat-seen",
json={"last_seen_message_id": 1},
).status_code == 401
)
assert (
client.post(
f"/api/rfcs/{slug}/branches/{branch}/changes/1/accept",
json={"proposed": "x"},
).status_code == 401
)
assert (
client.post(
f"/api/rfcs/{slug}/branches/{branch}/changes/1/decline",
).status_code == 401
)
assert (
client.post(
f"/api/rfcs/{slug}/branches/{branch}/changes/1/reask",
).status_code == 401
)
# Chat stream — POST shaped, same auth gate.
assert (
client.post(
f"/api/rfcs/{slug}/branches/{branch}/threads/1/chat",
json={"text": "hi"},
).status_code == 401
)
def test_anonymous_super_draft_writes_refused(app_with_fake_gitea):
"""Super-draft-scoped writes: start-edit-branch and metadata. The
PR open / merge paths share the gate via api_prs.py see the
PR-flow test below for those."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
client.cookies.clear()
assert (
client.post(
"/api/rfcs/anything/start-edit-branch", json={}
).status_code == 401
)
assert (
client.post(
"/api/rfcs/anything/metadata", json={"title": "x"}
).status_code == 401
)
def test_anonymous_pr_flow_writes_refused(app_with_fake_gitea):
"""All §10 PR-flow writes — open, merge, withdraw, description,
review, seen, pr-draft, resolution-branch must 401 for anonymous."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
client.cookies.clear()
slug, branch, pr = "ohm", "feature-x", 1
assert (
client.post(
f"/api/rfcs/{slug}/branches/{branch}/pr-draft"
).status_code == 401
)
assert (
client.post(
f"/api/rfcs/{slug}/branches/{branch}/open-pr",
json={"title": "t", "description": "d"},
).status_code == 401
)
assert (
client.post(
f"/api/rfcs/{slug}/prs/{pr}/seen",
json={"last_seen_message_id": 1},
).status_code == 401
)
assert (
client.post(
f"/api/rfcs/{slug}/prs/{pr}/review",
json={"text": "x", "anchor_payload": {}},
).status_code == 401
)
assert (
client.post(
f"/api/rfcs/{slug}/prs/{pr}/merge"
).status_code == 401
)
assert (
client.post(
f"/api/rfcs/{slug}/prs/{pr}/withdraw"
).status_code == 401
)
assert (
client.post(
f"/api/rfcs/{slug}/prs/{pr}/description",
json={"title": "t", "description": "d"},
).status_code == 401
)
assert (
client.post(
f"/api/rfcs/{slug}/prs/{pr}/resolution-branch"
).status_code == 401
)
def test_anonymous_discussion_writes_refused(app_with_fake_gitea):
"""The v0.5.0 PR-less discussion surface — write gates must hold.
This duplicates the assertion in `test_discussion_vertical.py` and
keeps it here too as the canonical home for the item #4 audit."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
client.cookies.clear()
assert (
client.post(
"/api/rfcs/ohm/discussion/threads",
json={"message": "drive-by"},
).status_code == 401
)
assert (
client.post(
"/api/rfcs/ohm/discussion/threads/1/messages",
json={"text": "drive-by"},
).status_code == 401
)
assert (
client.post(
"/api/rfcs/ohm/discussion/threads/1/resolve"
).status_code == 401
)
def test_anonymous_admin_writes_refused(app_with_fake_gitea):
"""Admin surfaces — role, mute, allowlist — refuse anonymous.
The auth check runs before the require_admin role check, so the
response is 401."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
client.cookies.clear()
assert (
client.post(
"/api/admin/users/1/role", json={"role": "admin"}
).status_code == 401
)
assert (
client.post(
"/api/admin/users/1/mute", json={"muted": True}
).status_code == 401
)
assert (
client.post(
"/api/admin/allowlist", json={"email": "x@y.z"}
).status_code == 401
)
assert (
client.delete("/api/admin/allowlist/x@y.z").status_code == 401
)
# Admin reads also gated.
assert client.get("/api/admin/users").status_code == 401
assert client.get("/api/admin/audit").status_code == 401
assert client.get("/api/admin/permission-events").status_code == 401
assert client.get("/api/admin/graduation-queue").status_code == 401
assert client.get("/api/admin/allowlist").status_code == 401
def test_anonymous_notification_writes_refused(app_with_fake_gitea):
"""Notification preference / watch / mark-read / user-mute writes —
all per-user surfaces, all require an authenticated viewer."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
client.cookies.clear()
assert (
client.post(
"/api/users/me/notification-preferences",
json={"email_personal_direct": False},
).status_code == 401
)
assert (
client.post(
"/api/users/me/quiet-hours",
json={"start": None, "end": None, "timezone": None},
).status_code == 401
)
assert (
client.post("/api/rfcs/ohm/watch", json={"state": "watching"}).status_code
== 401
)
assert client.post("/api/notifications/1/read").status_code == 401
assert (
client.post("/api/notifications/read", json={}).status_code == 401
)
assert client.post("/api/users/1/notification-mute").status_code == 401
assert client.delete("/api/users/1/notification-mute").status_code == 401
def test_anonymous_funder_writes_refused(app_with_fake_gitea):
"""§6.7 funder credential + consent writes — registering a key,
consenting to fund all refuse anonymous callers."""
from fastapi.testclient import TestClient
app, fake = app_with_fake_gitea
with TestClient(app) as client:
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
client.cookies.clear()
assert (
client.post(
"/api/users/me/funder/credentials",
json={"provider": "anthropic", "api_key": "sk-test"},
).status_code == 401
)
assert (
client.delete(
"/api/users/me/funder/credentials/anthropic"
).status_code == 401
)
assert (
client.post("/api/rfcs/ohm/funder/consent").status_code == 401
)
assert (
client.delete("/api/rfcs/ohm/funder/consent").status_code == 401
)
def test_anonymous_graduation_writes_refused(app_with_fake_gitea):
"""§13 graduation: the POST kickoff and POST claim both refuse
anonymous. The progress SSE was gated to require_user in v0.6.0
(item #4) since it surfaces admin-internal step detail."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
client.cookies.clear()
assert (
client.post(
"/api/rfcs/anything/graduate",
json={
"rfc_id": "RFC-0001",
"repo_name": "rfc-0001-x",
"owners": ["alice"],
},
).status_code == 401
)
assert client.post("/api/rfcs/anything/claim").status_code == 401
# v0.6.0 tightening: progress SSE now requires require_user.
# No graduation is in flight, but the auth check runs first.
assert (
client.get("/api/rfcs/anything/graduate/progress").status_code == 401
)
def test_anonymous_can_read_published_pr_view(app_with_fake_gitea):
"""The PR review page is §11.3 universal-public — once a PR is
open, anonymous viewers can read it. This guards against a
regression where the read endpoint accidentally grows an auth
gate."""
from fastapi.testclient import TestClient
from app import db
app, fake = app_with_fake_gitea
with TestClient(app) as client:
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
# Seed an open PR row directly — the cache shape is enough for
# the read endpoint; the live Gitea fetch falls back gracefully.
db.conn().execute(
"""
INSERT INTO cached_prs
(rfc_slug, pr_kind, repo, pr_number, title, description, state,
opened_by, opened_at, head_branch, base_branch, head_sha)
VALUES ('ohm', 'rfc_branch', 'wiggleverse/rfc-0001-ohm', 7, 't', 'd',
'open', 'alice', datetime('now'), 'feature-x', 'main', 'sha7')
"""
)
client.cookies.clear()
# Anonymous read on an open PR: should be 200. The endpoint may
# surface a partial response (the FakeGitea won't have the head
# branch's RFC.md, so branch_body falls back to empty) but the
# auth gate must let the read through.
r = client.get("/api/rfcs/ohm/prs/7")
assert r.status_code == 200
body = r.json()
assert body["capabilities"]["is_anonymous"] is True
assert body["capabilities"]["can_merge"] is False
assert body["capabilities"]["can_post_review"] is False
-390
View File
@@ -1,390 +0,0 @@
"""End-to-end integration tests for v0.8.0's open beta-access request
flow (§6.1 / §14.1, roadmap item #6).
The release replaces v0.3.0's `allowed_emails` allowlist as the
admission gate. Any valid email can sign in via the v0.7.0 OTC flow;
a fresh user lands in `permission_state='pending'` until an admin
grants access. The first-OTC flow captures first name, last name,
and a free-text "why I should be included in the beta" via a new
`POST /api/auth/me/beta-request` endpoint.
The tests prove:
* A fresh OTC user lands `permission_state='pending'` with empty
profile fields, and the verify-response carries `needs_profile=true`.
* `POST /api/auth/me/beta-request` populates the three fields and
leaves the row in `pending`.
* A pending user is refused write endpoints (representative
samples: propose RFC, post discussion thread). The refusal is
403 (not 401 they're authenticated, just not granted).
* An admin-grant flow promotes pending granted. v0.8.0 doesn't
ship an admin UI for this (deferred to item #7 / v0.9.0), so
the test flips the column directly via DB and asserts that
`require_contributor` now admits the user.
* A grandfathered user (existing row pre-migration, default
`permission_state='granted'`) is unaffected write endpoints
accept them.
* The `/auth/otc/request` endpoint accepts any email the
v0.7.0 allowlist gate is gone from this path. The `allowed_emails`
table stays in the schema; the admin UI from v0.3.0 continues to
manage it for the fast-path bypass deployments may use.
"""
from __future__ import annotations
import pytest
from test_propose_vertical import ( # noqa: F401 — fixtures land via import
FakeGitea,
app_with_fake_gitea,
provision_user_row,
sign_in_as,
tmp_env,
)
def _reset_outbound():
from app import email as email_mod
email_mod.reset_sent_envelopes()
def _outbound_otc_codes(to_address: str | None = None) -> list[str]:
"""Pluck the code line from every OTC envelope in the test buffer."""
from app import email as email_mod
out = []
for env in email_mod.sent_envelopes():
if env.get("kind") != "otc":
continue
if to_address is not None and env["to"] != to_address:
continue
for line in env["body"].splitlines():
tok = line.strip()
if tok.isdigit() and len(tok) == 6:
out.append(tok)
break
return out
# ---------------------------------------------------------------------------
# Fresh OTC sign-in lands pending with empty fields
# ---------------------------------------------------------------------------
def test_fresh_otc_user_lands_pending_with_empty_profile(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
# Request + verify the OTC.
r = client.post("/auth/otc/request", json={"email": "newcomer@example.com"})
assert r.status_code == 200, r.text
code = _outbound_otc_codes("newcomer@example.com")[-1]
r = client.post("/auth/otc/verify", json={"email": "newcomer@example.com", "code": code})
assert r.status_code == 200, r.text
body = r.json()
# The verify response carries the new fields v0.8.0 added.
assert body["needs_profile"] is True
assert body["user"]["permission_state"] == "pending"
# The row reflects the same: pending state, no profile yet.
row = db.conn().execute(
"SELECT permission_state, first_name, last_name, beta_request_reason FROM users WHERE email = ? COLLATE NOCASE",
("newcomer@example.com",),
).fetchone()
assert row is not None
assert row["permission_state"] == "pending"
assert row["first_name"] is None
assert row["last_name"] is None
assert row["beta_request_reason"] is None
# /api/auth/me surfaces the same shape.
me = client.get("/api/auth/me").json()
assert me["authenticated"] is True
assert me["user"]["permission_state"] == "pending"
assert me["user"]["needs_profile"] is True
assert me["user"]["first_name"] == ""
assert me["user"]["last_name"] == ""
assert me["user"]["beta_request_reason"] == ""
# ---------------------------------------------------------------------------
# beta-request endpoint captures the fields and leaves state pending
# ---------------------------------------------------------------------------
def test_beta_request_populates_fields_keeps_state_pending(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
# Sign in the fresh user via the full OTC flow.
client.post("/auth/otc/request", json={"email": "alice@example.com"})
code = _outbound_otc_codes("alice@example.com")[-1]
client.post("/auth/otc/verify", json={"email": "alice@example.com", "code": code})
# Submit the capture form.
r = client.post(
"/api/auth/me/beta-request",
json={
"first_name": "Alice",
"last_name": "Liddell",
"beta_request_reason": "I want to help write the RFCs.",
},
)
assert r.status_code == 200, r.text
# The row reflects the captured fields; state stays pending.
row = db.conn().execute(
"SELECT permission_state, first_name, last_name, beta_request_reason FROM users WHERE email = ? COLLATE NOCASE",
("alice@example.com",),
).fetchone()
assert row["permission_state"] == "pending"
assert row["first_name"] == "Alice"
assert row["last_name"] == "Liddell"
assert row["beta_request_reason"] == "I want to help write the RFCs."
# /api/auth/me now reports needs_profile=false (fields are set).
me = client.get("/api/auth/me").json()
assert me["user"]["permission_state"] == "pending"
assert me["user"]["needs_profile"] is False
assert me["user"]["first_name"] == "Alice"
def test_beta_request_refuses_anonymous(app_with_fake_gitea):
"""The endpoint requires authentication — an anonymous caller can't
file a request without first signing in via OTC."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
client.cookies.clear()
r = client.post(
"/api/auth/me/beta-request",
json={"first_name": "A", "last_name": "B", "beta_request_reason": "Hi"},
)
assert r.status_code == 401
def test_beta_request_refuses_granted_user(app_with_fake_gitea):
"""A grandfathered (already granted) user has no business filing a
beta request. The endpoint refuses with 409 so the client can
distinguish the failure from "we don't know you" (401)."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="grandfathered", role="contributor")
sign_in_as(
client,
user_id=1,
gitea_login="grandfathered",
display_name="Grandfathered",
role="contributor",
)
r = client.post(
"/api/auth/me/beta-request",
json={"first_name": "G", "last_name": "F", "beta_request_reason": "x"},
)
assert r.status_code == 409
# ---------------------------------------------------------------------------
# Pending user is refused write endpoints; admin grant promotes them
# ---------------------------------------------------------------------------
def test_pending_user_is_refused_write_endpoints(app_with_fake_gitea):
"""A pending user can read everything anonymous can read, but every
write-shaped endpoint refuses with 403. The refusal shape mirrors
the v0.6.0 / item #4 audit's anon-401 — both are "no contributor
capability"; pending is the authenticated-but-ungranted variant.
Representative samples: propose RFC, post discussion thread.
"""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
# Sign in via fresh OTC — lands pending.
client.post("/auth/otc/request", json={"email": "pending@example.com"})
code = _outbound_otc_codes("pending@example.com")[-1]
client.post("/auth/otc/verify", json={"email": "pending@example.com", "code": code})
# Reads work — every anonymous surface stays reachable.
assert client.get("/api/health").status_code == 200
assert client.get("/api/rfcs").status_code == 200
assert client.get("/api/philosophy").status_code == 200
# Propose — write-shaped, refused with 403.
r = client.post(
"/api/rfcs/propose",
json={"title": "T", "slug": "t", "pitch": "p", "tags": []},
)
assert r.status_code == 403
# The error body mentions the review state so a UI surface can
# render the right message — but the test asserts only on the
# status code (the body shape is the FastAPI default detail).
def test_admin_grant_promotes_pending_to_granted(app_with_fake_gitea):
"""v0.8.0 doesn't ship an admin UI for this — it's deferred to
item #7 / v0.9.0. For this release, an admin gesture is an
`UPDATE users SET permission_state='granted' WHERE email=?`. The
test flips the column directly via DB and asserts the
`require_contributor` gate now admits the user.
"""
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
# Sign in a fresh OTC user — lands pending.
client.post("/auth/otc/request", json={"email": "promoted@example.com"})
code = _outbound_otc_codes("promoted@example.com")[-1]
client.post("/auth/otc/verify", json={"email": "promoted@example.com", "code": code})
# Before the grant: propose refused with 403.
r = client.post(
"/api/rfcs/propose",
json={"title": "T", "slug": "t-pre", "pitch": "p", "tags": []},
)
assert r.status_code == 403
# The admin gesture (v0.8.0 shape — direct UPDATE; v0.9.0 will
# ship a UI). The test stamps `permission_decided_by` and
# `permission_decided_at` as the v0.9.0 admin UI will, so the
# column population exercises the schema slot. user_id=99 is
# a placeholder admin row — provision it so the FK resolves.
provision_user_row(user_id=99, login="adminuser", role="admin")
db.conn().execute(
"""
UPDATE users
SET permission_state = 'granted',
permission_decided_by = 99,
permission_decided_at = datetime('now')
WHERE email = ?
""",
("promoted@example.com",),
)
# The next request reads the fresh column from the DB. The
# propose endpoint reaches the route body now (it then refuses
# for a different reason — the slug 't-prop' will fail
# the slug-format check or hit a mock-gitea path — but the
# status code is _not_ 403/401, which is the v0.8.0 assertion).
r = client.post(
"/api/rfcs/propose",
json={"title": "Title", "slug": "tprop", "pitch": "Pitch text.", "tags": []},
)
assert r.status_code != 403, r.text
assert r.status_code != 401, r.text
def test_grandfathered_user_is_unaffected_by_migration(app_with_fake_gitea):
"""An existing `users` row at migration time has
`permission_state='granted'` via the column default. The
grandfathered user passes write endpoints without filing a
beta request and without the admin UI. v0.6.0 (anon-write
audit) is the v0.6.0 contract; v0.8.0 widens the gate but
does not break this case.
"""
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=5, login="oldhand", role="contributor")
# provision_user_row uses INSERT OR REPLACE INTO users with
# the column list it knows; permission_state is not in that
# list, so it picks up the column default ('granted') on
# insert. Confirm directly.
row = db.conn().execute(
"SELECT permission_state FROM users WHERE id = 5"
).fetchone()
assert row["permission_state"] == "granted"
sign_in_as(
client,
user_id=5,
gitea_login="oldhand",
display_name="Old Hand",
role="contributor",
)
# Propose is write-shaped; the call should not refuse on
# the permission_state gate. (Subsequent failure modes —
# e.g. mock-gitea wiring — are not the v0.8.0 concern; this
# test asserts on the gate, not the propose body's success.)
r = client.post(
"/api/rfcs/propose",
json={"title": "Title", "slug": "gf-slug", "pitch": "Pitch.", "tags": []},
)
assert r.status_code != 403, r.text
assert r.status_code != 401, r.text
# ---------------------------------------------------------------------------
# /auth/otc/request accepts any email — the v0.7.0 allowlist gate is gone
# ---------------------------------------------------------------------------
def test_otc_request_accepts_any_email_regardless_of_allowlist(app_with_fake_gitea):
"""v0.7.0 silently dropped OTC requests for emails not on the
`allowed_emails` table. v0.8.0 reverses this: the request
endpoint sends a code to any valid email; admission gates at
`permission_state` post-verify instead. The `allowed_emails`
table stays in the schema as a fast-path bypass for
deployments that want to pre-mark known-good emails (the v0.9.0
admin user-management page will collapse the two surfaces).
"""
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
# Populate the allowlist with one specific email so the v0.7.0
# gate would have engaged. v0.8.0 ignores it for the request
# path.
db.conn().execute("INSERT INTO allowed_emails (email) VALUES (?)", ("known@example.com",))
# An email NOT on the allowlist still gets a code under v0.8.0.
r = client.post("/auth/otc/request", json={"email": "stranger@example.com"})
assert r.status_code == 200
codes = _outbound_otc_codes("stranger@example.com")
assert len(codes) == 1, "OTC code must be sent regardless of allowlist state"
# The row is there and the user can complete sign-in (and will
# land in 'pending' per the other tests).
row = db.conn().execute(
"SELECT 1 FROM otc_codes WHERE email = ?",
("stranger@example.com",),
).fetchone()
assert row is not None
def test_allowlist_table_still_present_in_schema(app_with_fake_gitea):
"""The schema migration leaves the `allowed_emails` table in
place the admin UI from v0.3.0 still manages it for the
fast-path bypass deployments may use. This is a regression net
for "did the v0.8.0 cleanup accidentally drop the table"."""
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app):
# The table accepts inserts (i.e. it exists) — no schema check
# gymnastics needed.
db.conn().execute("INSERT INTO allowed_emails (email) VALUES (?)", ("kept@example.com",))
row = db.conn().execute(
"SELECT email FROM allowed_emails WHERE email = ?",
("kept@example.com",),
).fetchone()
assert row is not None
@@ -1,205 +0,0 @@
"""End-to-end tests for v0.13.0 / roadmap item #11 — cookie / privacy consent.
Covers the §17 endpoints (`GET` / `PUT /api/users/me/cookie-consent`) and
the §14.5 storage contract:
* GET on a fresh user returns no-choice-yet (recorded_at is None,
essential=True, analytics=False, other=False).
* PUT writes a row, stamps recorded_at, and the choice survives.
* PUT with `analytics=true, other=false` round-trips faithfully.
* `essential` is permanently true at the API surface a PUT that
requests essential=false is still persisted with essential=true.
* The endpoint requires authentication (401 for anon).
* A second PUT updates the existing row in place (single row per
user, recorded_at re-stamps).
* Choice persists across sign-out / sign-in.
"""
from __future__ import annotations
import pytest
from test_propose_vertical import ( # noqa: F401
FakeGitea,
app_with_fake_gitea,
provision_user_row,
sign_in_as,
tmp_env,
)
def test_get_cookie_consent_fresh_user_has_no_choice(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
r = client.get("/api/users/me/cookie-consent")
assert r.status_code == 200, r.text
body = r.json()
assert body["essential"] is True
assert body["analytics"] is False
assert body["other"] is False
assert body["recorded_at"] is None
def test_put_cookie_consent_records_choice(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
r = client.put(
"/api/users/me/cookie-consent",
json={"essential": True, "analytics": True, "other": False},
)
assert r.status_code == 200, r.text
body = r.json()
assert body["ok"] is True
assert body["essential"] is True
assert body["analytics"] is True
assert body["other"] is False
assert body["recorded_at"] is not None
# Round-trip the read endpoint.
r = client.get("/api/users/me/cookie-consent")
body = r.json()
assert body["essential"] is True
assert body["analytics"] is True
assert body["other"] is False
assert body["recorded_at"] is not None
def test_put_cookie_consent_forces_essential_true(app_with_fake_gitea):
"""§14.5: `essential` is permanently true at the API surface. A
request that sets it to false is accepted (for symmetry with the
other two flags) but persisted as true.
"""
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
r = client.put(
"/api/users/me/cookie-consent",
json={"essential": False, "analytics": False, "other": False},
)
assert r.status_code == 200, r.text
assert r.json()["essential"] is True
# Confirm at the schema layer too — the persisted row has essential=1.
row = db.conn().execute(
"SELECT essential FROM cookie_consent WHERE user_id = ?",
(2,),
).fetchone()
assert row["essential"] == 1
def test_cookie_consent_requires_auth(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
r = client.get("/api/users/me/cookie-consent")
assert r.status_code == 401, r.text
r = client.put(
"/api/users/me/cookie-consent",
json={"essential": True, "analytics": True, "other": True},
)
assert r.status_code == 401, r.text
def test_put_cookie_consent_upserts_in_place(app_with_fake_gitea):
"""A second PUT updates the existing row rather than inserting a new
one. Verifies the §14.5 single-row-per-user shape.
"""
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
client.put(
"/api/users/me/cookie-consent",
json={"essential": True, "analytics": True, "other": False},
)
client.put(
"/api/users/me/cookie-consent",
json={"essential": True, "analytics": False, "other": True},
)
rows = db.conn().execute(
"SELECT analytics, other_cookies FROM cookie_consent WHERE user_id = ?",
(2,),
).fetchall()
assert len(rows) == 1
assert rows[0]["analytics"] == 0
assert rows[0]["other_cookies"] == 1
def test_cookie_consent_persists_across_sign_out_in(app_with_fake_gitea):
"""§14.5 precedence: the server row survives sign-out / sign-in.
"""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
client.put(
"/api/users/me/cookie-consent",
json={"essential": True, "analytics": True, "other": True},
)
# Simulate sign-out by clearing the session cookie.
client.cookies.clear()
# Anonymous viewer cannot read.
r = client.get("/api/users/me/cookie-consent")
assert r.status_code == 401
# Sign back in as Alice. The server row is still there.
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
r = client.get("/api/users/me/cookie-consent")
body = r.json()
assert body["analytics"] is True
assert body["other"] is True
assert body["recorded_at"] is not None
def test_two_users_have_independent_rows(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
provision_user_row(user_id=3, login="bob", role="contributor")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
client.put(
"/api/users/me/cookie-consent",
json={"essential": True, "analytics": True, "other": False},
)
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob", role="contributor")
client.put(
"/api/users/me/cookie-consent",
json={"essential": True, "analytics": False, "other": False},
)
# Each user reads their own row.
r = client.get("/api/users/me/cookie-consent").json()
assert r["analytics"] is False # Bob's
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
r = client.get("/api/users/me/cookie-consent").json()
assert r["analytics"] is True # Alice's
-349
View File
@@ -1,349 +0,0 @@
"""End-to-end integration tests for the v0.7.0 email/OTC sign-in
vertical (§6.2).
The release replaces the Gitea OAuth gesture as the primary human
sign-in path. The tests prove:
* `/auth/otc/request` is rate-limited per-email back-to-back
requests inside `OTC_REQUEST_COOLDOWN_SECONDS` are refused with
429 (the loud-failure shape the spec calls out).
* The happy path: request code lands in the outbound buffer
verify with the code session cookie surfaces an authenticated
user via `/api/auth/me`.
* Expired codes refuse with 400.
* Already-consumed codes refuse with 400 on re-use.
* Wrong codes refuse with 400.
* Allowlist gate (v0.8.0 update): v0.7.0 silently dropped requests
for emails not on `allowed_emails`. v0.8.0 (item #6) removed
that gate from the request path; the admission gate is now
`permission_state` on the freshly-provisioned `users` row,
asserted in test_beta_access_vertical.py. The tests below
confirm v0.8.0's open-request shape for both on-list and
off-list emails.
* Migration link: an existing OAuth-era user (with a `users.email`
row) is linked by email on first OTC sign-in `gitea_id` is
preserved.
* Provisioning path: an unrecognized email creates a fresh
contributor row with `gitea_id = NULL`.
The Gitea bot user + token are still required at process construction
(every test harness sets the same `GITEA_*` env vars); the OTC flow
itself never reaches Gitea. The fakes from `test_propose_vertical`
remain in scope so the rest of the app boots cleanly.
"""
from __future__ import annotations
import pytest
from test_propose_vertical import ( # noqa: F401
FakeGitea,
app_with_fake_gitea,
provision_user_row,
tmp_env,
)
def _reset_outbound():
from app import email as email_mod
email_mod.reset_sent_envelopes()
def _outbound_otc_codes(to_address: str | None = None) -> list[str]:
"""Pluck the `code` line out of every OTC email in the test buffer.
The OTC mailer stamps `kind='otc'` on the envelope so the §15.4
notification mailer's envelopes (the unsubscribe-footer shape)
don't accidentally satisfy the assertion. Each envelope's body
carries the code on its own indented line; this helper extracts
just that token so the test reads the same way the user would
read the email.
"""
from app import email as email_mod
out = []
for env in email_mod.sent_envelopes():
if env.get("kind") != "otc":
continue
if to_address is not None and env["to"] != to_address:
continue
for line in env["body"].splitlines():
tok = line.strip()
if tok.isdigit() and len(tok) == 6:
out.append(tok)
break
return out
# ---------------------------------------------------------------------------
# Happy path
# ---------------------------------------------------------------------------
def test_otc_request_then_verify_signs_in_a_fresh_user(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
# Request: 202 + a single OTC envelope to the requested address.
r = client.post("/auth/otc/request", json={"email": "newcomer@example.com"})
assert r.status_code == 200, r.text
codes = _outbound_otc_codes("newcomer@example.com")
assert len(codes) == 1
code = codes[0]
# Verify: 200 + session cookie + me-shape now reads authenticated.
r = client.post("/auth/otc/verify", json={"email": "newcomer@example.com", "code": code})
assert r.status_code == 200, r.text
me = client.get("/api/auth/me").json()
assert me["authenticated"] is True
assert me["user"]["email"] == "newcomer@example.com"
# Fresh provisioning: no gitea linker. The display name is the
# local part of the email per §6.2.
assert me["user"]["role"] == "contributor"
assert me["user"]["display_name"] == "newcomer"
# The `users` row reflects the same: gitea_id NULL, email set.
from app import db
row = db.conn().execute(
"SELECT gitea_id, email FROM users WHERE email = ? COLLATE NOCASE",
("newcomer@example.com",),
).fetchone()
assert row is not None
assert row["gitea_id"] is None
assert row["email"] == "newcomer@example.com"
# ---------------------------------------------------------------------------
# Failure modes on verify
# ---------------------------------------------------------------------------
def test_otc_verify_refuses_wrong_code(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
client.post("/auth/otc/request", json={"email": "alice@example.com"})
r = client.post("/auth/otc/verify", json={"email": "alice@example.com", "code": "000000"})
assert r.status_code == 400
def test_otc_verify_refuses_consumed_code(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
client.post("/auth/otc/request", json={"email": "alice@example.com"})
code = _outbound_otc_codes("alice@example.com")[-1]
# First verify succeeds.
r1 = client.post("/auth/otc/verify", json={"email": "alice@example.com", "code": code})
assert r1.status_code == 200
# Drop the session cookie so the re-verify reads as fresh.
client.cookies.clear()
# Second verify with the same code is refused — `consumed_at`
# stamped on the row blocks the replay.
r2 = client.post("/auth/otc/verify", json={"email": "alice@example.com", "code": code})
assert r2.status_code == 400
def test_otc_verify_refuses_expired_code(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
client.post("/auth/otc/request", json={"email": "alice@example.com"})
code = _outbound_otc_codes("alice@example.com")[-1]
# Backdate the row's expires_at to the past. The TTL setting is
# an env var (default 10 min); rather than waiting, the test
# rewrites the row.
db.conn().execute(
"UPDATE otc_codes SET expires_at = datetime('now', '-1 minute') WHERE email = ?",
("alice@example.com",),
)
r = client.post("/auth/otc/verify", json={"email": "alice@example.com", "code": code})
assert r.status_code == 400
# ---------------------------------------------------------------------------
# Rate limiting
# ---------------------------------------------------------------------------
def test_otc_request_rate_limited_per_email(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
r1 = client.post("/auth/otc/request", json={"email": "alice@example.com"})
assert r1.status_code == 200
# Cooldown defaults to 60s; the second back-to-back call is
# refused with a loud 429.
r2 = client.post("/auth/otc/request", json={"email": "alice@example.com"})
assert r2.status_code == 429
# The buffer still has exactly one envelope — the rate-limited
# call didn't double-send.
assert len(_outbound_otc_codes("alice@example.com")) == 1
def test_otc_request_cooldown_is_per_email_not_global(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
r1 = client.post("/auth/otc/request", json={"email": "alice@example.com"})
assert r1.status_code == 200
# Different email, fresh cooldown.
r2 = client.post("/auth/otc/request", json={"email": "bob@example.com"})
assert r2.status_code == 200
# ---------------------------------------------------------------------------
# Allowlist gate — v0.8.0 update
#
# v0.7.0 gated the OTC request endpoint on the `allowed_emails` table:
# emails not on the list got a silent drop (still 202, but no code).
# v0.8.0 (roadmap item #6) reverses this: the request endpoint
# accepts any valid email and sends a code. The admission gate moves
# to `permission_state` on the freshly-provisioned `users` row,
# which the next-tier tests in test_beta_access_vertical.py cover.
# The `allowed_emails` table stays in the schema as a fast-path
# bypass for admin convenience.
# ---------------------------------------------------------------------------
def test_otc_request_admits_emails_regardless_of_allowlist_population(app_with_fake_gitea):
"""v0.8.0: the OTC request path no longer consults `allowed_emails`.
Whether the allowlist is empty or populated, every valid email
receives a code; admission gates at `permission_state` post-verify.
"""
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
# Populate the allowlist with one specific email; the v0.7.0
# gate would have engaged here.
db.conn().execute("INSERT INTO allowed_emails (email) VALUES (?)", ("invited@example.com",))
# The not-on-list email still gets a code under v0.8.0.
r = client.post("/auth/otc/request", json={"email": "stranger@example.com"})
assert r.status_code == 200
assert len(_outbound_otc_codes("stranger@example.com")) == 1
def test_otc_request_admits_allowlisted_email(app_with_fake_gitea):
"""v0.8.0: still works for emails that happen to be on the legacy
allowlist the table is no longer consulted at request time but
populated rows are admitted alongside everyone else (since the
gate is now open at the request surface)."""
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
db.conn().execute("INSERT INTO allowed_emails (email) VALUES (?)", ("invited@example.com",))
r = client.post("/auth/otc/request", json={"email": "invited@example.com"})
assert r.status_code == 200
assert len(_outbound_otc_codes("invited@example.com")) == 1
# ---------------------------------------------------------------------------
# Migration path — link by email to an OAuth-era user
# ---------------------------------------------------------------------------
def test_otc_links_to_existing_oauth_user_by_email(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
# Seed an OAuth-era row. `provision_user_row` writes
# email=<login>@test, so we sign in via OTC with the matching
# email and expect the same `users.id` to come back.
provision_user_row(user_id=42, login="legacyuser", role="contributor")
existing = db.conn().execute(
"SELECT id, gitea_id FROM users WHERE id = ?", (42,)
).fetchone()
assert existing["gitea_id"] == 42 # OAuth linker is set.
r = client.post("/auth/otc/request", json={"email": "legacyuser@test"})
assert r.status_code == 200
code = _outbound_otc_codes("legacyuser@test")[-1]
r = client.post("/auth/otc/verify", json={"email": "legacyuser@test", "code": code})
assert r.status_code == 200
# /api/auth/me reports the linked user — same id, original role.
me = client.get("/api/auth/me").json()
assert me["authenticated"] is True
assert me["user"]["id"] == 42
assert me["user"]["role"] == "contributor"
# gitea_id is preserved on the linked row — the migration path
# doesn't disturb the OAuth linker.
row = db.conn().execute(
"SELECT gitea_id FROM users WHERE id = ?", (42,)
).fetchone()
assert row["gitea_id"] == 42
def test_otc_provisions_fresh_user_when_email_matches_no_one(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
r = client.post("/auth/otc/request", json={"email": "newperson@example.com"})
assert r.status_code == 200
code = _outbound_otc_codes("newperson@example.com")[-1]
r = client.post("/auth/otc/verify", json={"email": "newperson@example.com", "code": code})
assert r.status_code == 200
# A fresh row landed with NULL gitea_id (no OAuth linker).
row = db.conn().execute(
"SELECT id, gitea_id, gitea_login, role FROM users WHERE email = ? COLLATE NOCASE",
("newperson@example.com",),
).fetchone()
assert row is not None
assert row["gitea_id"] is None
assert row["gitea_login"] is None
assert row["role"] == "contributor"
# ---------------------------------------------------------------------------
# Re-request invalidates prior code
# ---------------------------------------------------------------------------
def test_otc_re_request_invalidates_prior_unused_code(app_with_fake_gitea, monkeypatch):
from fastapi.testclient import TestClient
# Drop the cooldown so the second request lands instead of 429ing.
monkeypatch.setenv("OTC_REQUEST_COOLDOWN_SECONDS", "0")
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
client.post("/auth/otc/request", json={"email": "alice@example.com"})
first = _outbound_otc_codes("alice@example.com")[-1]
client.post("/auth/otc/request", json={"email": "alice@example.com"})
second = _outbound_otc_codes("alice@example.com")[-1]
assert first != second
# The old code is invalidated — verify with `first` now refuses.
r = client.post("/auth/otc/verify", json={"email": "alice@example.com", "code": first})
assert r.status_code == 400
# The new code still works.
r = client.post("/auth/otc/verify", json={"email": "alice@example.com", "code": second})
assert r.status_code == 200
-40
View File
@@ -496,11 +496,6 @@ def test_propose_to_super_draft_vertical(app_with_fake_gitea):
proposal = r.json()
assert proposal["entry"]["title"] == "Open Human Model"
assert proposal["entry"]["state"] == "super-draft"
# §9.2: the proposer is the implicit first owner at propose time.
# The owners field is a single-element list containing exactly the
# session user's gitea_login — no request-supplied owner field
# exists or is honored.
assert proposal["entry"]["owners"] == ["alice"]
assert proposal["affordances"]["merge"] is True
# Owner merges. The catalog picks up the new super-draft.
@@ -521,10 +516,6 @@ def test_propose_to_super_draft_vertical(app_with_fake_gitea):
view = r.json()
assert view["state"] == "super-draft"
assert "shared definition" in view["body"]
# §9.2: the auto-set proposer-owner survives the meta-repo round-trip
# — it's in the file's frontmatter on main after merge, not just
# in the pending-PR view above.
assert view["owners"] == ["alice"]
# The pending-ideas list no longer carries the merged proposal.
r = client.get("/api/proposals")
@@ -577,37 +568,6 @@ def test_anonymous_cannot_propose(app_with_fake_gitea):
assert r.status_code == 401
def test_proposer_is_auto_owner_request_payload_ignored(app_with_fake_gitea):
"""§9.2: the owners field on the new entry is always exactly
`[session.gitea_login]`. The propose endpoint never accepts an owner
from the client; a request payload that smuggles one in is ignored
by the Pydantic body model and the auto-set value lands instead.
"""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=11, login="alice", role="contributor")
sign_in_as(client, user_id=11, gitea_login="alice", display_name="Alice", role="contributor")
# Extra unknown fields like `owners` are dropped by the
# ProposeBody model; the session user is the only source of truth.
r = client.post("/api/rfcs/propose", json={
"title": "Spoof attempt",
"slug": "spoof-attempt",
"pitch": "p",
"tags": [],
"owners": ["mallory", "eve"],
"proposed_by": "mallory@test",
})
assert r.status_code == 200, r.text
pr_number = r.json()["pr_number"]
r = client.get(f"/api/proposals/{pr_number}")
assert r.status_code == 200, r.text
entry = r.json()["entry"]
assert entry["owners"] == ["alice"]
# proposed_by also comes from the session, never the body.
assert entry["proposed_by"] in ("alice@test", "alice")
def test_withdraw_by_proposer_works(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
-25
View File
@@ -24,28 +24,3 @@ VITE_APP_NAME=
# VITE_BETA_CONTACT=ben@wiggleverse.org
# VITE_BETA_CONTACT=DM @ben on Matrix
VITE_BETA_CONTACT=
# Optional URL to the deployment's privacy policy (v0.13.0+, SPEC §14.5).
# The framework ships a minimal default privacy policy at `/privacy`
# that describes the framework's stance and lists the cookies the
# framework sets. When this var is set to an http(s) URL, the page
# renders the framework's stub above a link to the configured URL —
# deployments use this to layer their own policy content on top
# without forking the framework. Unset is OK; the stub is sufficient
# for a deployment that has nothing specific to add.
#
# Examples:
# VITE_PRIVACY_POLICY_URL=https://wiggleverse.org/privacy
VITE_PRIVACY_POLICY_URL=
# Optional URL to the deployment's cookies policy (v0.13.0+, SPEC §14.6).
# Same shape as VITE_PRIVACY_POLICY_URL. The framework's default
# `/cookies` page lists exactly which cookies the framework sets
# (rfc_session, the consent-choice localStorage entry); a deployment
# that adds its own cookies (analytics SDK once #13 lands, third-party
# embeds) points this var at a page that documents the full list.
# Unset is OK; the stub is sufficient for a default-config deployment.
#
# Examples:
# VITE_COOKIES_POLICY_URL=https://wiggleverse.org/cookies
VITE_COOKIES_POLICY_URL=
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "rfc-app-frontend",
"version": "0.8.0",
"version": "0.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "rfc-app-frontend",
"version": "0.8.0",
"version": "0.5.0",
"dependencies": {
"@codemirror/commands": "^6.10.3",
"@codemirror/lang-markdown": "^6.5.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "rfc-app-frontend",
"private": true,
"version": "0.8.0",
"version": "0.5.0",
"type": "module",
"scripts": {
"dev": "vite",
-222
View File
@@ -352,109 +352,6 @@
}
.landing .secondary-link:hover { color: #1a1a1a; text-decoration: underline; }
/* --- v0.7.0: email + one-time-code sign-in (§6.2) --- */
.otc-login {
flex: 1;
display: flex; align-items: center; justify-content: center;
padding: 40px 24px;
}
.otc-login-inner {
max-width: 360px;
width: 100%;
display: flex; flex-direction: column;
gap: 14px;
}
.otc-login h1 {
font-size: 22px;
font-weight: 600;
margin: 0 0 4px;
}
.otc-login .otc-hint {
color: #555;
font-size: 14px;
line-height: 1.5;
margin: 0;
}
.otc-login input {
width: 100%;
padding: 10px 12px;
font-size: 15px;
border: 1px solid #ddd;
border-radius: 6px;
box-sizing: border-box;
}
.otc-login input:focus {
outline: none;
border-color: #1a1a1a;
}
.otc-login button[type="submit"] {
background: #1a1a1a; color: #fff;
border: none; border-radius: 6px;
padding: 10px 18px;
font-size: 14px; font-weight: 600;
cursor: pointer;
}
.otc-login button[type="submit"]:hover:not(:disabled) { background: #333; }
.otc-login button[type="submit"]:disabled { opacity: 0.5; cursor: not-allowed; }
.otc-login form {
display: flex; flex-direction: column;
gap: 10px;
}
.otc-actions {
display: flex; align-items: center; gap: 12px;
}
.otc-login .btn-link-quiet {
background: none; border: none;
color: #666; font-size: 13px;
cursor: pointer; padding: 0;
}
.otc-login .btn-link-quiet:hover { color: #1a1a1a; text-decoration: underline; }
/* v0.8.0 — labels + textarea for the first-OTC profile capture step. */
.otc-field-label {
font-size: 12px; color: #666;
margin: 8px 0 -4px;
font-weight: 600;
}
.otc-login textarea {
width: 100%;
padding: 10px 12px;
font-size: 15px;
border: 1px solid #ddd;
border-radius: 6px;
box-sizing: border-box;
font-family: inherit;
resize: vertical;
}
.otc-login textarea:focus {
outline: none;
border-color: #1a1a1a;
}
.otc-shortcut-hint {
color: #888; font-size: 12px; margin: 4px 0 0;
}
.otc-shortcut-hint kbd {
background: #f0f0ee; border: 1px solid #ddd; border-radius: 3px;
padding: 1px 5px; font-size: 11px; font-family: inherit;
}
.otc-status {
color: #555; font-size: 13px;
background: #f7f6f0;
border-left: 3px solid #cfc8a8;
padding: 8px 12px;
margin: 4px 0 0;
}
.otc-fallback {
font-size: 12px; color: #777;
margin: 16px 0 0;
display: flex; gap: 8px; align-items: center; flex-wrap: wrap;
}
.otc-fallback a, .otc-fallback .otc-fallback-link {
color: #666; text-decoration: none;
}
.otc-fallback a:hover { color: #1a1a1a; text-decoration: underline; }
.otc-fallback-sep { color: #ccc; }
/* --- Beta-pending page (post-OAuth-rejection) --- */
.beta-pending {
@@ -486,22 +383,6 @@
.btn-link-quiet { color: #666; text-decoration: none; font-size: 13px; }
.btn-link-quiet:hover { color: #1a1a1a; text-decoration: underline; }
/* v0.8.0 thin "your beta access is in review" banner. Shown on every
page (other than /beta-pending itself, which carries the larger
form of the message). Sits just under the app header so it doesn't
compete with the catalog rail. */
.pending-access-banner {
background: #fff8e0;
border-bottom: 1px solid #e6dca0;
color: #4a3f00;
font-size: 13px;
padding: 8px 16px;
text-align: center;
}
.pending-access-banner a {
color: #4a3f00; text-decoration: underline;
}
/* ── §8 RFC view: three-column shape ─────────────────────────────────── */
.main-pane {
@@ -1983,106 +1864,3 @@
.discussion-readonly {
font-size: 12px; color: #666; padding: 4px 0;
}
/* §14.5 — cookie consent banner (v0.13.0) */
.cookie-consent-banner {
position: fixed;
left: 0; right: 0; bottom: 0;
z-index: 1000;
background: #fff;
border-top: 1px solid #d1d5db;
box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.08);
padding: 20px 24px;
}
.cookie-consent-body {
max-width: 880px; margin: 0 auto;
display: flex; flex-direction: column; gap: 12px;
}
.cookie-consent-title {
margin: 0; font-size: 16px; font-weight: 700; color: #111;
}
.cookie-consent-intro {
margin: 0; font-size: 13px; color: #4b5563; line-height: 1.5;
}
.cookie-consent-choices {
border: none; padding: 0; margin: 0;
display: flex; flex-direction: column; gap: 6px;
}
.cookie-consent-choice {
display: flex; gap: 10px; align-items: flex-start;
padding: 10px 12px; border-radius: 6px;
border: 1px solid #e5e7eb;
cursor: pointer;
}
.cookie-consent-choice.is-selected {
border-color: #111; background: #f9fafb;
}
.cookie-consent-choice input[type=radio] { margin-top: 3px; }
.cookie-consent-choice-text {
display: flex; flex-direction: column; gap: 2px;
}
.cookie-consent-choice-label {
font-size: 13px; font-weight: 600; color: #111;
}
.cookie-consent-choice-desc {
font-size: 12px; color: #6b7280; line-height: 1.5;
}
.cookie-consent-links {
margin: 0; font-size: 12px; color: #6b7280;
}
.cookie-consent-links a { color: #111; text-decoration: underline; }
.cookie-consent-error {
margin: 0; font-size: 12px; color: #b91c1c;
}
.cookie-consent-actions {
display: flex; gap: 8px; justify-content: flex-end;
}
.visually-hidden {
position: absolute; width: 1px; height: 1px;
padding: 0; margin: -1px; overflow: hidden;
clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
}
/* §14.5 / §14.6 — privacy + cookies policy pages */
.policy-page {
max-width: 720px; margin: 0 auto;
padding: 0 32px 80px;
}
.policy-header {
display: flex; align-items: center; gap: 12px;
padding: 20px 0; border-bottom: 1px solid #f3f4f6;
margin-bottom: 24px;
}
.policy-back {
background: none; border: none; cursor: pointer;
color: #6b7280; font-size: 13px; padding: 4px 8px;
}
.policy-back:hover { color: #111; }
.policy-title { font-size: 13px; font-weight: 600; color: #6b7280; }
.policy-body { line-height: 1.7; color: #111; }
.policy-body h1 { font-size: 26px; margin: 0 0 6px; font-weight: 700; }
.policy-body .policy-subtitle { color: #6b7280; margin: 0 0 24px; font-size: 14px; }
.policy-body h2 { font-size: 16px; margin: 28px 0 8px; font-weight: 600; }
.policy-body p { margin: 0 0 12px; }
.policy-body ul { margin: 0 0 16px; padding-left: 22px; }
.policy-body li { margin-bottom: 6px; }
.policy-body code {
background: #f3f4f6; padding: 1px 5px; border-radius: 3px;
font-family: ui-monospace, monospace; font-size: 12px;
}
.policy-body .policy-footnote {
margin-top: 24px; font-size: 12px; color: #6b7280;
}
.policy-table {
width: 100%; border-collapse: collapse;
font-size: 13px; margin: 8px 0 16px;
}
.policy-table th, .policy-table td {
text-align: left; padding: 8px 10px;
border-bottom: 1px solid #f3f4f6;
vertical-align: top;
}
.policy-table th {
font-size: 11px; text-transform: uppercase;
color: #6b7280; letter-spacing: 0.05em; font-weight: 600;
}
+7 -60
View File
@@ -8,15 +8,11 @@ import PRView from './components/PRView.jsx'
import ProposalView from './components/ProposalView.jsx'
import ProposeModal from './components/ProposeModal.jsx'
import Landing from './components/Landing.jsx'
import Login from './components/Login.jsx'
import BetaPending from './components/BetaPending.jsx'
import Philosophy from './components/Philosophy.jsx'
import NotificationSettings from './components/NotificationSettings.jsx'
import Admin from './components/Admin.jsx'
import ToastHost, { showToast } from './components/ToastHost.jsx'
import CookieConsentBanner from './components/CookieConsentBanner.jsx'
import Privacy from './pages/Privacy.jsx'
import Cookies from './pages/Cookies.jsx'
import './App.css'
export default function App() {
@@ -27,19 +23,8 @@ export default function App() {
const [inboxOpen, setInboxOpen] = useState(false)
const [unreadCount, setUnreadCount] = useState(0)
const [inboxTick, setInboxTick] = useState(0)
// §14.5: a tick that, when bumped, asks <CookieConsentBanner> to
// re-open even if the user has already made a choice. The settings
// "Privacy & cookies" tab dispatches a `rfc-app:cookie-consent-reopen`
// event that bumps this.
const [consentReopenTick, setConsentReopenTick] = useState(0)
const navigate = useNavigate()
useEffect(() => {
const handler = () => setConsentReopenTick(t => t + 1)
window.addEventListener('rfc-app:cookie-consent-reopen', handler)
return () => window.removeEventListener('rfc-app:cookie-consent-reopen', handler)
}, [])
useEffect(() => {
getMe()
.then(setMe)
@@ -87,15 +72,11 @@ export default function App() {
// The deployment is in private beta: anonymous visitors get the full
// app in read-only mode (viewer = null is passed through to every
// component), and write affordances are hidden at the component
// level. v0.8.0 (§6.1 / item #6): authenticated users with
// `permission_state='pending'` also pass through as `viewer` with
// their state attached every write-gated affordance reads the
// state and treats pending the same as anonymous, while reads
// remain open. The /beta-pending page is the home root for a
// pending user.
// level. /beta-pending is the post-OAuth-rejection page reachable by
// anyone. The original §14.1 Landing surface is retained for the
// `/welcome` URL only, in case a deployment wants to link to it.
const viewer = me?.authenticated ? me.user : null
const isAdmin = viewer && (viewer.role === 'owner' || viewer.role === 'admin')
const isPending = viewer && viewer.permission_state === 'pending'
return (
<div className="app">
@@ -140,23 +121,17 @@ export default function App() {
<a className="btn-link" href="/auth/logout">Sign out</a>
</>
) : (
<Link className="btn-signin-header" to="/login" title="Private beta — only invited emails can sign in">
<a className="btn-signin-header" href="/auth/login" title="Private beta — only invited emails can sign in">
Sign in <span className="beta-chip">Beta</span>
</Link>
</a>
)}
</div>
</header>
{isPending && <PendingAccessBanner />}
<div className="app-body">
<Routes>
<Route path="/welcome" element={<Landing />} />
<Route path="/login" element={<Login />} />
<Route path="/beta-pending" element={<BetaPending viewer={viewer} />} />
<Route path="/beta-pending" element={<BetaPending />} />
<Route path="/philosophy" element={<PhilosophyWithSidebar viewer={viewer} />} />
{/* §14.5 / §14.6: cookie-consent companions to /philosophy.
Available to anonymous and authenticated viewers alike. */}
<Route path="/privacy" element={<PolicyShell><Privacy /></PolicyShell>} />
<Route path="/cookies" element={<PolicyShell><Cookies /></PolicyShell>} />
{viewer && (
<Route path="/settings/notifications" element={<NotificationSettingsWithSidebar viewer={viewer} />} />
)}
@@ -184,7 +159,6 @@ export default function App() {
</div>
{proposeOpen && viewer && (
<ProposeModal
viewer={viewer}
onClose={() => setProposeOpen(false)}
onSubmitted={({ pr_number }) => {
setProposeOpen(false)
@@ -197,18 +171,10 @@ export default function App() {
<Inbox onClose={() => setInboxOpen(false)} lastChangeTick={inboxTick} />
)}
<ToastHost />
<CookieConsentBanner viewer={viewer} forceOpen={consentReopenTick} />
</div>
)
}
function PolicyShell({ children }) {
// §14.5 / §14.6 policy pages reuse the chrome-pane shape so they
// render full-width without the catalog rail. The components inside
// carry their own back affordance per Philosophy.jsx's pattern.
return <main className="chrome-pane">{children}</main>
}
function PhilosophyWithSidebar({ viewer }) {
// The chrome surfaces (§14.2 philosophy, §15 settings, §6/§17 admin)
// all use the full app body no catalog left pane, no propose modal.
@@ -237,26 +203,7 @@ function AdminWithSidebar({ viewer }) {
)
}
function PendingAccessBanner() {
// v0.8.0 thin banner shown on every page (other than /beta-pending
// itself, which carries the same message in larger form) when the
// signed-in user's `permission_state='pending'`. Sign-out works
// normally via the header affordance.
return (
<div className="pending-access-banner">
Your beta access request is in review.{' '}
<Link to="/beta-pending">Learn more </Link>
</div>
)
}
function Welcome({ viewer }) {
// v0.8.0 a pending user landing on "/" gets the same page they'd
// see at /beta-pending, inline. This is the post-OTC home root for
// a user awaiting admin grant.
if (viewer && viewer.permission_state === 'pending') {
return <BetaPending viewer={viewer} />
}
if (!viewer) {
return (
<div className="welcome">
@@ -268,7 +215,7 @@ function Welcome({ viewer }) {
</p>
<p>
Discussion and contribution are in private <strong>Beta</strong>
read freely, and <Link to="/login">sign in</Link> if your email has
read freely, and <a href="/auth/login">sign in</a> if your email has
been invited.
</p>
<p>
-57
View File
@@ -25,46 +25,6 @@ export async function getMe() {
return jsonOrThrow(res)
}
// ── v0.7.0: email + one-time-code sign-in (§6.2) ─────────────────────────
//
// The legacy /auth/login → /auth/callback OAuth flow remains during the
// migration — the new UI just no longer points at it primarily. These
// two helpers drive the Login.jsx surface.
export async function requestOtc(email) {
const res = await fetch('/auth/otc/request', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
})
return jsonOrThrow(res)
}
export async function verifyOtc(email, code) {
const res = await fetch('/auth/otc/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, code }),
})
return jsonOrThrow(res)
}
// ── v0.8.0: open beta-access request flow (§6.1 / §14.1) ─────────────────
//
// On the first OTC sign-in, the user lands in `permission_state='pending'`
// and `/api/auth/me` reports `needs_profile=true`. The Login.jsx surface
// then prompts for first/last/why and POSTs them here. After this lands,
// the user sees the /beta-pending page until an admin grants access.
export async function submitBetaRequest({ first_name, last_name, beta_request_reason }) {
const res = await fetch('/api/auth/me/beta-request', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ first_name, last_name, beta_request_reason }),
})
return jsonOrThrow(res)
}
export async function listRFCs() {
return jsonOrThrow(await fetch('/api/rfcs'))
}
@@ -548,23 +508,6 @@ export async function setQuietHours({ start, end, timezone } = {}) {
}))
}
// v0.13.0 / roadmap item #11: cookie consent (SPEC §14.5).
export async function getCookieConsent() {
return jsonOrThrow(await fetch('/api/users/me/cookie-consent'))
}
export async function setCookieConsent({ analytics, other } = {}) {
return jsonOrThrow(await fetch('/api/users/me/cookie-consent', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
essential: true,
analytics: !!analytics,
other: !!other,
}),
}))
}
export async function muteUser(userId) {
return jsonOrThrow(await fetch(`/api/users/${userId}/notification-mute`, { method: 'POST' }))
}
+19 -40
View File
@@ -1,59 +1,38 @@
// BetaPending.jsx the "your request is in review" page (§6.1 / §14.1).
// BetaPending.jsx the post-OAuth-rejection page.
//
// v0.3.0 introduced this surface as the post-OAuth-rejection page (a
// user whose email wasn't on the `allowed_emails` table bounced here).
// v0.8.0 (roadmap item #6) repurposes it as the post-OTC pending-grant
// page: any authenticated user whose `permission_state='pending'` lands
// here on root visits, after a fresh-OTC profile capture, or via the
// header "Your beta access is in review" affordance.
//
// The deployment supplies a contact channel via VITE_BETA_CONTACT (an
// email, URL, or short instruction). If unset, we render a generic
// ask-the-operator line.
// When a deployment is in private-beta mode (i.e. its `allowed_emails`
// table has any rows), the OAuth callback redirects unrecognised users
// here instead of provisioning them. The framework cannot know the
// deployment operator's preferred contact channel so the deployment
// supplies one via VITE_BETA_CONTACT (an email, URL, or short
// instruction). If unset, we render a generic ask-the-operator line.
import { Link } from 'react-router-dom'
export default function BetaPending({ viewer }) {
export default function BetaPending() {
const contact = import.meta.env.VITE_BETA_CONTACT || ''
const isPending = viewer?.permission_state === 'pending'
return (
<div className="beta-pending">
<div className="beta-pending-inner">
<h1>
{isPending
? 'Your request is in review.'
: `${import.meta.env.VITE_APP_NAME} is in private Beta.`}
</h1>
{isPending ? (
<>
<p>
Thanks for telling us a bit about yourself. An admin will
review your request and get back to you as soon as we can.
</p>
<p>
While you wait, the catalog on the left lists every super-draft
and active RFC in the framework reading is open. Discussion
and contribution unlock once your access is granted.
</p>
</>
) : (
<p>
Discussion and contribution are gated to invited contributors for
now. Reading is open every super-draft, every active RFC, and
every public conversation is visible without signing in.
</p>
)}
<h1>{import.meta.env.VITE_APP_NAME} is in private Beta.</h1>
<p>
Discussion and contribution are gated to invited emails for now.
Reading is open every super-draft, every active RFC, and every
public conversation is visible without signing in.
</p>
{contact ? (
<p className="beta-pending-contact">
Questions? Contact <strong>{contact}</strong>.
To request access, contact <strong>{contact}</strong> with the
email address you'd like to sign in with.
</p>
) : (
<p className="beta-pending-contact">
Questions? Contact the deployment operator.
To request access, contact the deployment operator with the email
address you'd like to sign in with.
</p>
)}
<div className="beta-pending-actions">
<Link className="btn-primary" to="/">Browse the catalog</Link>
<Link className="btn-primary" to="/">Browse as a guest</Link>
<Link className="btn-link-quiet" to="/philosophy">Read the philosophy </Link>
</div>
</div>
@@ -1,164 +0,0 @@
// CookieConsentBanner.jsx v0.13.0 / roadmap item #11 / SPEC §14.5.
//
// A non-modal bottom-of-page banner that asks the user once which
// categories of cookies they accept. The framework's strictly-necessary
// cookies (session, signed payloads) are always on; the user can opt in
// or out of analytics (which gates the §13 SDK landing in v0.15.0) and
// "other" (third-party embeds, social widgets if a deployment adds any).
//
// Visible until the user makes a choice. Hides itself once the choice
// is recorded. The /settings/notifications "Privacy & cookies" tab
// surfaces the current choice and re-opens the banner via `forceOpen`.
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { getConsent, setConsent, hasChosen, hydrateFromServer } from '../lib/consent.js'
import { getCookieConsent, setCookieConsent } from '../api.js'
const CATEGORIES = [
{
key: 'essential-only',
label: 'Essential only',
description: 'Just the cookies the app needs to keep you signed in and protect submissions. (Sign-in session, signed payloads.)',
flags: { analytics: false, other: false },
},
{
key: 'essential-analytics',
label: 'Essential + analytics',
description: 'Adds anonymous usage analytics so the framework can see which surfaces get used. No third-party scripts beyond the analytics SDK.',
flags: { analytics: true, other: false },
},
{
key: 'essential-analytics-other',
label: 'Essential + analytics + other',
description: 'Adds analytics plus any third-party embeds the deployment configures (e.g. social widgets). Choose this if you want the full surface.',
flags: { analytics: true, other: true },
},
]
function selectionKeyFor(consent) {
if (consent.analytics && consent.other) return 'essential-analytics-other'
if (consent.analytics && !consent.other) return 'essential-analytics'
return 'essential-only'
}
export default function CookieConsentBanner({ viewer, forceOpen, onClosed }) {
const [open, setOpen] = useState(() => forceOpen || !hasChosen())
const [choice, setChoice] = useState(() => selectionKeyFor(getConsent()))
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
// When forceOpen flips (settings "Change" affordance), re-render the
// banner and pre-select the user's current choice.
useEffect(() => {
if (forceOpen) {
setOpen(true)
setChoice(selectionKeyFor(getConsent()))
}
}, [forceOpen])
// Server-side hydrate for authenticated viewers per the v0.13.0
// precedence rule: a server row overrides local; absent server row,
// upload the local choice.
useEffect(() => {
if (!viewer?.user_id) return
let cancelled = false
getCookieConsent()
.then(record => {
if (cancelled) return
if (record.recorded_at) {
// Server is authoritative adopt + hide the banner unless
// the settings page forced it open.
hydrateFromServer(record)
setChoice(selectionKeyFor(record))
if (!forceOpen) setOpen(false)
} else if (hasChosen()) {
// Local has a choice the server doesn't know about yet push.
const local = getConsent()
setCookieConsent({ analytics: local.analytics, other: local.other })
.then(r => hydrateFromServer(r))
.catch(() => {})
}
})
.catch(() => {
// Network or auth error leave the local-only path in place.
})
return () => { cancelled = true }
}, [viewer?.user_id]) // eslint-disable-line react-hooks/exhaustive-deps
if (!open) return null
async function save() {
setSaving(true)
setError(null)
const picked = CATEGORIES.find(c => c.key === choice) || CATEGORIES[0]
try {
setConsent(picked.flags)
if (viewer?.user_id) {
// Best-effort server persistence. A failure here doesn't
// invalidate the local choice; the banner still hides because
// the user expressed their preference. The server can catch up
// on the next sign-in via the hydrate path above.
try {
const r = await setCookieConsent(picked.flags)
hydrateFromServer(r)
} catch (e) {
setError(`Saved locally; server sync failed (${e.message}).`)
}
}
setOpen(false)
onClosed?.()
} finally {
setSaving(false)
}
}
return (
<div className="cookie-consent-banner" role="region" aria-label="Cookie consent">
<div className="cookie-consent-body">
<h2 className="cookie-consent-title">Cookies &amp; privacy</h2>
<p className="cookie-consent-intro">
This site uses cookies. Essential cookies keep you signed in and
protect your submissions; analytics and other cookies are
optional. Choose what you allow you can change this any time
from <Link to="/settings/notifications">Settings &rarr; Privacy &amp; cookies</Link>.
</p>
<fieldset className="cookie-consent-choices">
<legend className="visually-hidden">Cookie categories</legend>
{CATEGORIES.map(c => (
<label key={c.key} className={`cookie-consent-choice ${choice === c.key ? 'is-selected' : ''}`}>
<input
type="radio"
name="cookie-consent-choice"
value={c.key}
checked={choice === c.key}
onChange={() => setChoice(c.key)}
disabled={saving}
/>
<span className="cookie-consent-choice-text">
<span className="cookie-consent-choice-label">{c.label}</span>
<span className="cookie-consent-choice-desc">{c.description}</span>
</span>
</label>
))}
</fieldset>
<p className="cookie-consent-links">
<Link to="/cookies">Cookies policy</Link>
<span aria-hidden> &middot; </span>
<Link to="/privacy">Privacy policy</Link>
</p>
{error && <p className="cookie-consent-error">{error}</p>}
<div className="cookie-consent-actions">
<button
type="button"
className="btn-primary"
onClick={save}
disabled={saving}
>
{saving ? 'Saving…' : 'Save choice'}
</button>
</div>
</div>
</div>
)
}
+1 -1
View File
@@ -28,7 +28,7 @@ export default function Landing() {
first RFC defining <em>human</em>. Build the dictionary first.
</p>
<Link className="btn-signin" to="/login">Sign in</Link>
<a className="btn-signin" href="/auth/login">Sign in with Gitea</a>
<Link className="secondary-link" to="/philosophy">Read the full philosophy </Link>
<ul className="landing-deck">
-275
View File
@@ -1,275 +0,0 @@
// Login.jsx v0.7.0's primary sign-in surface (§6.2), extended by
// v0.8.0 (§6.1 / §14.1, roadmap item #6) with the first-OTC profile
// capture step.
//
// Three-step (the third is conditional):
// 1. Enter email POST /auth/otc/request on 200, advance.
// On 429 (rate-limit), surface a "wait a moment" hint and keep
// the user on step 1.
// 2. Enter the six-digit code from the email POST /auth/otc/verify
// on 200, the response body carries `needs_profile`:
// * needs_profile=false (returning user, OAuth-era grandfather,
// or already-captured pending user): redirect to "/".
// * needs_profile=true (fresh OTC sign-in, no profile fields
// yet): advance to step 3.
// Cmd/Ctrl+Enter on the code field is the keyboard shortcut.
// 3. First name, last name, and "why I should be included in the
// beta" POST /api/auth/me/beta-request redirect to
// /beta-pending. The user's row stays `permission_state='pending'`
// until an admin grants access.
//
// Server-side, /auth/otc/request returns 202 uniformly so abuse paths
// (e.g. distributed allowlist-probing) don't leak the recognized-email
// set. This surface never distinguishes "we couldn't reach you" from
// "we don't know you" it just advances to step 2. If a request was
// rate-limited, the user sees a 429 hint and stays on step 1.
//
// The legacy Gitea OAuth callback remains at /auth/login /auth/callback
// during the migration; we surface a "Sign in with Gitea" link as a
// fallback in the footer so users with active OAuth sessions or older
// invite emails still have a path.
import { useEffect, useRef, useState } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { requestOtc, verifyOtc, submitBetaRequest } from '../api'
export default function Login() {
const [step, setStep] = useState('email')
const [email, setEmail] = useState('')
const [code, setCode] = useState('')
// v0.8.0 step 3 capture fields.
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
const [reason, setReason] = useState('')
const [status, setStatus] = useState('')
const [busy, setBusy] = useState(false)
const emailRef = useRef(null)
const codeRef = useRef(null)
const firstNameRef = useRef(null)
const navigate = useNavigate()
useEffect(() => {
if (step === 'email') emailRef.current?.focus()
else if (step === 'code') codeRef.current?.focus()
else if (step === 'profile') firstNameRef.current?.focus()
}, [step])
async function submitEmail(e) {
e.preventDefault()
if (!email.trim() || !email.includes('@')) {
setStatus('Enter a valid email address.')
return
}
setBusy(true)
setStatus('')
try {
await requestOtc(email.trim())
setStep('code')
setStatus('Check your inbox — a six-digit code is on the way.')
} catch (err) {
if (err.status === 429) {
setStatus('Slow down — wait a minute before requesting another code.')
} else {
setStatus(err.message || 'Could not request a code. Try again.')
}
} finally {
setBusy(false)
}
}
async function submitCode(e) {
if (e) e.preventDefault()
if (!code.trim() || code.trim().length !== 6) {
setStatus('Enter the six-digit code from your email.')
return
}
setBusy(true)
setStatus('')
try {
const result = await verifyOtc(email.trim(), code.trim())
// v0.8.0 a fresh OTC user lands in `permission_state='pending'`
// with no profile fields. The verify response now carries a
// `needs_profile` flag the server stamped from the row state;
// surface the capture form here instead of jumping straight to
// "/". The fallback path (no flag, e.g. an older backend
// before the migration ran) jumps to "/" as before.
if (result?.needs_profile) {
setStep('profile')
setStatus('')
setBusy(false)
return
}
// Reload so App.jsx's getMe() picks up the fresh session. We
// navigate to "/" via a hard load so any cached "anonymous"
// view state in memory is dropped cleanly.
window.location.assign('/')
} catch (err) {
setStatus('That code is invalid or expired. Try again, or request a new code.')
setBusy(false)
}
}
async function submitProfile(e) {
if (e) e.preventDefault()
const fn = firstName.trim()
const ln = lastName.trim()
const why = reason.trim()
if (!fn || !ln || !why) {
setStatus('All three fields are required.')
return
}
setBusy(true)
setStatus('')
try {
await submitBetaRequest({
first_name: fn,
last_name: ln,
beta_request_reason: why,
})
// The user is still `permission_state='pending'`; bounce them
// to /beta-pending so the next thing they see is the
// "your request is in review" page. Hard-load so App.jsx
// re-fetches /api/auth/me and picks up the captured fields.
window.location.assign('/beta-pending')
} catch (err) {
setStatus(err.message || 'Could not submit your request. Try again.')
setBusy(false)
}
}
function onCodeKey(e) {
// §6.2 ergonomic: Cmd/Ctrl+Enter submits from the code field.
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
submitCode(e)
}
}
function backToEmail() {
setStep('email')
setCode('')
setStatus('')
}
return (
<div className="otc-login">
<div className="otc-login-inner">
<h1>Sign in</h1>
{step === 'email' && (
<form onSubmit={submitEmail}>
<p className="otc-hint">
Enter your email. We'll send you a one-time code.
</p>
<input
ref={emailRef}
type="email"
autoComplete="email"
value={email}
onChange={e => setEmail(e.target.value)}
placeholder="you@example.com"
required
disabled={busy}
/>
<button type="submit" disabled={busy || !email.trim()}>
{busy ? 'Sending…' : 'Send code'}
</button>
</form>
)}
{step === 'code' && (
<form onSubmit={submitCode}>
<p className="otc-hint">
Enter the six-digit code we sent to <strong>{email}</strong>.
</p>
<input
ref={codeRef}
type="text"
inputMode="numeric"
pattern="[0-9]*"
autoComplete="one-time-code"
maxLength={6}
value={code}
onChange={e => setCode(e.target.value.replace(/\D/g, ''))}
onKeyDown={onCodeKey}
placeholder="123456"
required
disabled={busy}
/>
<div className="otc-actions">
<button type="submit" disabled={busy || code.length !== 6}>
{busy ? 'Signing in…' : 'Sign in'}
</button>
<button
type="button"
className="btn-link-quiet"
onClick={backToEmail}
disabled={busy}
>
Use a different email
</button>
</div>
<p className="otc-shortcut-hint">
Tip: <kbd></kbd>+<kbd>Enter</kbd> (or <kbd>Ctrl</kbd>+<kbd>Enter</kbd>) to sign in.
</p>
</form>
)}
{step === 'profile' && (
<form onSubmit={submitProfile}>
<p className="otc-hint">
You're signed in. {import.meta.env.VITE_APP_NAME} is in private
beta tell us a bit about yourself and an admin will review
your request.
</p>
<label className="otc-field-label">First name</label>
<input
ref={firstNameRef}
type="text"
autoComplete="given-name"
value={firstName}
onChange={e => setFirstName(e.target.value)}
required
disabled={busy}
maxLength={120}
/>
<label className="otc-field-label">Last name</label>
<input
type="text"
autoComplete="family-name"
value={lastName}
onChange={e => setLastName(e.target.value)}
required
disabled={busy}
maxLength={120}
/>
<label className="otc-field-label">
Why you'd like to be included in the beta
</label>
<textarea
value={reason}
onChange={e => setReason(e.target.value)}
required
disabled={busy}
rows={5}
maxLength={4000}
placeholder="A sentence or two is plenty."
/>
<div className="otc-actions">
<button
type="submit"
disabled={busy || !firstName.trim() || !lastName.trim() || !reason.trim()}
>
{busy ? 'Submitting…' : 'Submit request'}
</button>
</div>
</form>
)}
{status && <p className="otc-status">{status}</p>}
{step !== 'profile' && (
<p className="otc-fallback">
<Link to="/philosophy">Read the philosophy </Link>
<span className="otc-fallback-sep">·</span>
<a href="/auth/login">Sign in with Gitea (fallback)</a>
</p>
)}
</div>
</div>
)
}
@@ -28,9 +28,7 @@ import {
unmuteUser,
muteUser,
searchUsers,
getCookieConsent,
} from '../api.js'
import { getConsent, onConsentChange, hydrateFromServer } from '../lib/consent.js'
const CHURN_REFUSAL = 'Per-commit and per-message email is intentionally not offered. The digest aggregates this activity weekly.'
@@ -50,67 +48,10 @@ export default function NotificationSettings({ viewer }) {
<QuietHoursSection />
<WatchesSection />
<MutesSection viewer={viewer} />
<PrivacyCookiesSection />
</div>
)
}
// §14.5 cookie / privacy consent (v0.13.0 / roadmap item #11)
function PrivacyCookiesSection() {
const [consent, setConsent] = useState(() => getConsent())
useEffect(() => {
// Pull the server-side row on mount; if it has a recorded_at the
// local snapshot is updated via hydrate.
getCookieConsent()
.then(record => { if (record.recorded_at) hydrateFromServer(record) })
.catch(() => {})
return onConsentChange(next => setConsent(next))
}, [])
function reopenBanner() {
// App.jsx listens for this event and bumps the forceOpen tick on
// <CookieConsentBanner>. The banner pre-selects the current choice
// from the snapshot, so the user can revise rather than restart.
window.dispatchEvent(new CustomEvent('rfc-app:cookie-consent-reopen'))
}
const summary = (() => {
if (!consent.recorded_at) {
return 'No choice recorded — the consent banner is being shown to you.'
}
if (consent.analytics && consent.other) {
return 'Essential + analytics + other.'
}
if (consent.analytics) {
return 'Essential + analytics.'
}
return 'Essential only.'
})()
return (
<SectionShell
title="Privacy & cookies"
subtitle="What categories of cookies you've allowed. Essential cookies are always on; analytics and other categories are opt-in."
>
<div className="settings-row">
<span className="settings-note"><strong>Current choice:</strong> {summary}</span>
</div>
{consent.recorded_at && (
<p className="settings-note muted">Recorded {consent.recorded_at}.</p>
)}
<div className="settings-row">
<button className="btn-primary" onClick={reopenBanner}>
Change
</button>
<Link to="/cookies" className="btn-link-muted">Cookies policy</Link>
<Link to="/privacy" className="btn-link-muted">Privacy policy</Link>
</div>
</SectionShell>
)
}
// §15.4 email category toggles
function EmailPreferencesSection() {
+1 -7
View File
@@ -20,7 +20,7 @@ function slugify(title) {
.replace(/^-+|-+$/g, '')
}
export default function ProposeModal({ viewer, onClose, onSubmitted }) {
export default function ProposeModal({ onClose, onSubmitted }) {
const [title, setTitle] = useState('')
const [slug, setSlug] = useState('')
const [slugEdited, setSlugEdited] = useState(false)
@@ -131,12 +131,6 @@ export default function ProposeModal({ viewer, onClose, onSubmitted }) {
</div>
)}
{viewer && (
<p className="field-help" style={{ marginTop: 14, marginBottom: 0 }}>
Owner: <strong>{viewer.display_name || viewer.gitea_login}</strong> you'll be the first owner of this super-draft. Additional owners can claim later (§13.1).
</p>
)}
{error && <p className="field-error">{error}</p>}
</div>
<div className="modal-actions">
-154
View File
@@ -1,154 +0,0 @@
// consent.js — cookie / privacy consent state (v0.13.0, SPEC §14.5).
//
// The framework's analytics SDK gating (roadmap item #13, target v0.15.0)
// will read from this module. v0.13.0 ships the storage + the banner +
// the on-change pub/sub; no analytics SDK ships yet.
//
// Shape of a consent record:
//
// { essential: true, analytics: bool, other: bool, recorded_at: string | null }
//
// `essential` is always true at the API surface; it's included for
// symmetry. `recorded_at` is null when the user has not yet made a
// choice — the banner is shown until it's non-null.
//
// Precedence:
// - Anonymous viewer: localStorage is the only source.
// - Authenticated viewer: on sign-in, the server row (if any) overrides
// local; if the server has no row, the local choice is uploaded.
//
// The fan-out is intentionally tiny — three flags. The banner writes
// once; subscribers re-read on demand via `getConsent()` and can
// register `onConsentChange(cb)` to be notified of subsequent updates.
//
// IMPORTANT: don't import this from analytics SDKs that themselves
// set cookies on load. Read consent first, then conditionally `import()`
// the SDK module — that's the contract item #13 will follow.
const LS_KEY = 'rfc-app.cookie-consent.v1'
const DEFAULT = Object.freeze({
essential: true,
analytics: false,
other: false,
recorded_at: null,
})
const listeners = new Set()
function readLocal() {
try {
const raw = localStorage.getItem(LS_KEY)
if (!raw) return null
const parsed = JSON.parse(raw)
if (!parsed || typeof parsed !== 'object') return null
return normalize(parsed)
} catch {
return null
}
}
function writeLocal(record) {
try {
localStorage.setItem(LS_KEY, JSON.stringify(normalize(record)))
} catch {
// localStorage may be unavailable (private mode, disabled storage).
// In that case we behave as if no choice was ever made — the banner
// shows on every load. Acceptable per §14.5: the user can still
// refuse to consent on each visit.
}
}
function normalize(record) {
return {
essential: true,
analytics: !!record.analytics,
other: !!record.other,
recorded_at: record.recorded_at || null,
}
}
// In-memory snapshot. Initialised lazily on first read so the module
// import order doesn't matter; refreshed by `setConsent` and
// `hydrateFromServer`.
let _snapshot = null
function snapshot() {
if (_snapshot == null) {
_snapshot = readLocal() || { ...DEFAULT }
}
return _snapshot
}
function emit() {
for (const cb of listeners) {
try { cb(snapshot()) } catch {}
}
}
/** Read the current consent record. Always returns a normalized object;
* `recorded_at: null` means the user has not yet chosen. */
export function getConsent() {
return snapshot()
}
/** True if the user has made a choice. The banner uses this to decide
* whether to render itself on load. */
export function hasChosen() {
return snapshot().recorded_at != null
}
/** Subscribe to consent updates. Returns an unsubscribe function. */
export function onConsentChange(cb) {
listeners.add(cb)
return () => listeners.delete(cb)
}
/** Write a new choice locally and emit. Returns the new snapshot. The
* server-side persistence path is handled separately by the banner /
* settings surface via the API client; this helper is for both anon
* and authenticated callers because localStorage is the always-on
* layer (the server row is a backup that survives sign-out). */
export function setConsent({ analytics = false, other = false } = {}) {
const next = normalize({
analytics,
other,
recorded_at: new Date().toISOString(),
})
_snapshot = next
writeLocal(next)
emit()
return next
}
/** Adopt a server-side record as authoritative. Called by the banner /
* settings surface after sign-in when the server returns a non-null
* recorded_at. Updates local + memory + emits to subscribers. */
export function hydrateFromServer(record) {
if (!record || !record.recorded_at) return snapshot()
const next = normalize(record)
_snapshot = next
writeLocal(next)
emit()
return next
}
/** Reset local state used by the settings "Change" affordance to
* re-prompt the banner. Does not touch the server row; the user must
* re-confirm a choice and the banner uploads on save. */
export function clearLocal() {
try { localStorage.removeItem(LS_KEY) } catch {}
_snapshot = { ...DEFAULT }
emit()
return _snapshot
}
// Cross-tab sync: if another tab writes the key, mirror the change here.
// Wrapped in a guard so SSR / non-browser test contexts don't blow up.
if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
window.addEventListener('storage', e => {
if (e.key !== LS_KEY) return
_snapshot = readLocal() || { ...DEFAULT }
emit()
})
}
-125
View File
@@ -1,125 +0,0 @@
// Cookies.jsx v0.13.0 / roadmap item #11 / SPEC §14.6.
//
// Lists the framework's cookies, by category, with each cookie's
// purpose. Deployments override via `VITE_COOKIES_POLICY_URL` (linked
// below the framework's stub list, same shape as the privacy page).
//
// Keeping the list in source makes the framework self-documenting:
// when a future framework release adds or removes a cookie, this page
// is the change-record. Item #13's analytics SDK will add its own row
// to the analytics-category list in v0.15.0.
import { useNavigate, Link } from 'react-router-dom'
const COOKIES = [
{
name: 'rfc_session',
category: 'Essential',
purpose: "Signed session cookie that remembers who you're signed in as. itsdangerous-signed; HttpOnly; SameSite=Lax.",
lifetime: 'Session (cleared on sign-out).',
},
{
name: 'rfc-app.cookie-consent.v1',
category: 'Essential',
purpose: 'localStorage entry (not a cookie strictly, but tracked here for symmetry) that remembers your consent choice on this device. Cleared on browser data reset.',
lifetime: 'Until cleared.',
},
]
export default function Cookies() {
const navigate = useNavigate()
const deploymentUrl = (import.meta.env.VITE_COOKIES_POLICY_URL || '').trim()
const appName = import.meta.env.VITE_APP_NAME || 'this deployment'
return (
<div className="policy-page">
<header className="policy-header">
<button
className="policy-back"
onClick={() => (history.length > 1 ? navigate(-1) : navigate('/'))}
>
Back
</button>
<span className="policy-title">Cookies policy</span>
</header>
<article className="policy-body">
<h1>Cookies policy</h1>
<p className="policy-subtitle">
What {appName} stores in your browser, by category.
</p>
<h2>Categories</h2>
<ul>
<li>
<strong>Essential</strong> required for the app to keep
you signed in, protect submissions, and remember your
consent choice. Cannot be switched off (without these the
app cannot function).
</li>
<li>
<strong>Analytics</strong> optional anonymous usage
telemetry. Off by default; opt-in via the consent banner.
As of v0.13.0 no analytics SDK ships; roadmap item #13
(v0.15.0) adds one behind this gate.
</li>
<li>
<strong>Other</strong> third-party embeds, social
widgets, or anything else the deployment chooses to enable.
Off by default; opt-in via the consent banner. The
framework ships no such cookies by default.
</li>
</ul>
<h2>Current cookies set by the framework</h2>
<table className="policy-table">
<thead>
<tr>
<th>Name</th>
<th>Category</th>
<th>Purpose</th>
<th>Lifetime</th>
</tr>
</thead>
<tbody>
{COOKIES.map(c => (
<tr key={c.name}>
<td><code>{c.name}</code></td>
<td>{c.category}</td>
<td>{c.purpose}</td>
<td>{c.lifetime}</td>
</tr>
))}
</tbody>
</table>
<h2>Manage your choice</h2>
<p>
Change your consent any time from{' '}
<Link to="/settings/notifications">
Settings &rarr; Privacy &amp; cookies
</Link>. The "Change" affordance re-opens the consent banner
with your current selection pre-loaded.
</p>
{deploymentUrl ? (
<>
<h2>Deployment-specific cookies</h2>
<p>
This deployment may add additional cookies on top of the
framework's. See the full deployment policy at:
</p>
<p>
<a href={deploymentUrl} target="_blank" rel="noopener noreferrer">
{deploymentUrl}
</a>
</p>
</>
) : null}
<p className="policy-footnote">
See also the <Link to="/privacy">privacy policy</Link>.
</p>
</article>
</div>
)
}
-118
View File
@@ -1,118 +0,0 @@
// Privacy.jsx v0.13.0 / roadmap item #11 / SPEC §14.5.
//
// The framework's default privacy policy page. Reachable by anonymous
// and authenticated viewers alike at `/privacy`. The text below is a
// minimal stub that describes the framework's stance; deployments are
// expected to override it via the `VITE_PRIVACY_POLICY_URL` env var.
//
// When `VITE_PRIVACY_POLICY_URL` is set:
// - http(s) URL the page renders the framework's stub above a
// "Read the full deployment policy" link to the configured URL.
// We don't iframe-embed third-party policy hosts because their
// Content-Security-Policy frequently refuses framing; the link is
// the predictable affordance.
//
// The framework's stub is intentionally short the rules that matter
// to a user are: (1) what categories of cookies the app sets, (2) how
// to change consent, (3) where to reach the deployment operator with a
// complaint. Each deployment's content repo can carry a fuller version.
import { useNavigate, Link } from 'react-router-dom'
export default function Privacy() {
const navigate = useNavigate()
const deploymentUrl = (import.meta.env.VITE_PRIVACY_POLICY_URL || '').trim()
const appName = import.meta.env.VITE_APP_NAME || 'this deployment'
return (
<div className="policy-page">
<header className="policy-header">
<button
className="policy-back"
onClick={() => (history.length > 1 ? navigate(-1) : navigate('/'))}
>
Back
</button>
<span className="policy-title">Privacy policy</span>
</header>
<article className="policy-body">
<h1>Privacy policy</h1>
<p className="policy-subtitle">
What {appName} stores, why, and how to control it.
</p>
<h2>What we store</h2>
<p>
{appName} runs on the Wiggleverse RFC framework. The framework
stores the identity you sign in with (your Gitea login,
display name, email, and avatar URL), the proposals and edits
you author, the discussion threads you participate in, and
your notification preferences. Authoring is public by design
this is a framework for public-async RFC work, and threads,
changes, and PRs are visible to anyone who reaches the
deployment. Settings (notification toggles, quiet hours, mute
list, cookie consent) are private to your account.
</p>
<h2>Cookies</h2>
<p>
The app sets a small set of cookies. The full list is on the{' '}
<Link to="/cookies">cookies policy page</Link>. You can choose
which categories you allow from the consent banner shown on
your first visit or from <Link to="/settings/notifications">
Settings &rarr; Privacy &amp; cookies</Link> any time
afterwards.
</p>
<h2>Analytics</h2>
<p>
The framework supports an optional anonymous analytics layer
gated behind your consent choice. As of v0.13.0 no analytics
SDK ships in the framework; deployments that enable analytics
do so via a later framework version (roadmap item #13). The
consent toggle exists today so the gate is already in place
when the SDK lands.
</p>
<h2>Your data, your control</h2>
<ul>
<li>Revoke cookie consent any time from settings.</li>
<li>
Edit notification preferences including the global email
opt-out from{' '}
<Link to="/settings/notifications">notification settings</Link>.
</li>
<li>
Your authored content (proposals, threads, edits) is public
and not retractable from the meta-repo's Git history. If you
need a redaction, reach the deployment operator directly.
</li>
</ul>
{deploymentUrl ? (
<>
<h2>Deployment-specific policy</h2>
<p>
This deployment may layer additional policy on top of the
framework's defaults. Read the full deployment policy at:
</p>
<p>
<a href={deploymentUrl} target="_blank" rel="noopener noreferrer">
{deploymentUrl}
</a>
</p>
</>
) : (
<>
<h2>Deployment contact</h2>
<p>
For deployment-specific privacy questions data subject
requests, redaction requests, complaints contact the
operator of {appName}.
</p>
</>
)}
</article>
</div>
)
}