Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f3a8ec33a |
+95
-552
@@ -23,180 +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.14.0 — 2026-05-28
|
||||
|
||||
**Minor — no operator action required; new optional env var.** This
|
||||
release ships `DOCS.md` and the `/docs` route — a public-facing user
|
||||
guide that translates `SPEC.md` into plain prose for readers,
|
||||
proposers, and contributors. The originating need was the
|
||||
admin-vs-owner distinction on the `/admin/users` surface (the §6.1
|
||||
role separation was load-bearing but only documented in spec voice);
|
||||
the response was a single guide that covers the framework's user-
|
||||
facing surfaces end-to-end. Mirrors `/philosophy` end-to-end: a
|
||||
markdown file checked into the repo root, served by a sibling backend
|
||||
loader, rendered with `MarkdownPreview`. No schema migration. No
|
||||
required env-var changes. The new "Docs" header link sits alongside
|
||||
the persistent "About" link from §14.3 and is reachable by anonymous
|
||||
viewers per the same v0.3.0 anonymous-read contract.
|
||||
|
||||
### Added
|
||||
|
||||
- **`DOCS.md`** at the repo root — the user-facing guide. Covers
|
||||
reading anonymously, signing in, proposing an RFC, super-drafts vs
|
||||
active RFCs, the discussion-vs-contribution distinction (§10.10),
|
||||
working on a branch (contribute mode, AI proposals, manual edits,
|
||||
flags, branch visibility, contribute grants, hygiene), opening and
|
||||
reviewing PRs, graduation (§13), withdrawal and reopening, the AI
|
||||
participant (§6.6 / §6.7 / §18), notifications and watch states
|
||||
(§15), and the full roles-and-permissions story (§6 in plain
|
||||
prose: anonymous / contributor / admin / owner, per-RFC
|
||||
owners + arbiters, per-branch contribute grants, the write-mute,
|
||||
and the three structurally distinct "mutes"). Framework-neutral —
|
||||
no deployment-specific names or corpus references; consistent with
|
||||
`CLAUDE.md`'s separation-of-concerns rule.
|
||||
- **`backend/app/docs.py`** — sibling loader for `philosophy.py`.
|
||||
Reads `DOCS.md` from the repo root with the same disk-first,
|
||||
in-process-cached, `refresh()`-on-demand shape. Optional
|
||||
`DOCS_PATH` env var points at an alternative source (e.g. a
|
||||
meta-repo working-tree clone) for deployments that prefer that.
|
||||
- **`§17` endpoint** — `GET /api/docs` returns
|
||||
`{ "body": "<DOCS.md verbatim>" }`. Anonymous-reachable, same
|
||||
contract as `GET /api/philosophy`.
|
||||
- **`frontend/src/components/Docs.jsx`** — the `/docs` reading
|
||||
surface. Mirrors `Philosophy.jsx`: chrome with Back / "USER GUIDE" /
|
||||
Home affordances, body rendered through `MarkdownPreview`.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`backend/app/api.py`** — imports `docs as docs_mod` alongside
|
||||
`philosophy` in the relative-import block; registers the new
|
||||
`GET /api/docs` handler immediately after `GET /api/philosophy`.
|
||||
- **`frontend/src/api.js`** — exports `getDocs()` alongside
|
||||
`getPhilosophy()`. Same fetch shape, different endpoint path.
|
||||
- **`frontend/src/App.jsx`** — imports `Docs` alongside `Philosophy`,
|
||||
registers the `/docs` route alongside `/philosophy`, adds the
|
||||
persistent "Docs" header link alongside "About", and adds the
|
||||
`DocsWithSidebar` chrome wrapper alongside `PhilosophyWithSidebar`.
|
||||
|
||||
### Upgrade steps (from 0.13.0)
|
||||
|
||||
- You **MUST** rebuild the frontend and restart the backend after
|
||||
upgrading so the new `/docs` route, the new endpoint, and the new
|
||||
loader are picked up. `frontend/package.json#version` and `VERSION`
|
||||
both move to `0.14.0`. No schema migration; the new endpoint
|
||||
serves a checked-in file.
|
||||
- You **MAY** set `DOCS_PATH` to an absolute path if your deployment
|
||||
hosts `DOCS.md` outside the framework's repo (e.g. as a sync target
|
||||
from a content repo). Unset is supported — the framework's
|
||||
`DOCS.md` at the repo root is the default, mirroring how
|
||||
`PHILOSOPHY_PATH` works for `/api/philosophy`.
|
||||
- You **MAY** customize `DOCS.md` for your deployment if you want
|
||||
deployment-specific phrasing layered on top of the framework's
|
||||
guide. The file is a regular markdown source; standard `vim`/`git`
|
||||
edits suffice. Framework upgrades that ship a new `DOCS.md` will
|
||||
show as a normal merge in your deployment-overlay layer.
|
||||
|
||||
## 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.10.0 — 2026-05-28
|
||||
|
||||
**Minor — schema migration required; new auth path is additive.**
|
||||
@@ -337,393 +163,106 @@ v0.7.0. The lockout shape (5 attempts, 15 minutes) and the length
|
||||
range (4–20) are hard-coded in `backend/app/passcode.py`. See
|
||||
§19.2 for the env-tunable candidate.
|
||||
|
||||
## 0.9.0 — 2026-05-28
|
||||
## 0.13.0 — 2026-05-28
|
||||
|
||||
**Minor — no schema migration; reuses v0.8.0 columns and existing
|
||||
SMTP.** This release ships the admin user-management surface at
|
||||
`/admin/users` and the new-beta-request notifications that feed
|
||||
it (roadmap item #7, SPEC §6.1 / §15 / §17). The two halves
|
||||
compose: admins receive an inbox + email signal the moment a
|
||||
pending user submits the v0.8.0 capture form; clicking through
|
||||
lands on the page where they Grant or Revoke access.
|
||||
|
||||
The page consumes the v0.8.0 schema columns
|
||||
(`permission_state`, `first_name`, `last_name`,
|
||||
`beta_request_reason`, `permission_decided_by`,
|
||||
`permission_decided_at`) without adding new ones — migration
|
||||
slot 016 stays reserved for a future release. The single new
|
||||
write endpoint, `POST /api/admin/users/<id>/permission`,
|
||||
replaces v0.8.0's documented manual `UPDATE users SET
|
||||
permission_state='granted'` gesture with an audited UI flip.
|
||||
|
||||
Admin notifications ride the existing §15 chokepoint — the
|
||||
`new_beta_request` event_kind is added to the enum with
|
||||
category `admin-actionable`, fan-out is to every owner / admin
|
||||
minus the requester themselves, and the §15.4 email dispatch
|
||||
only reaches recipients whose `email_admin_actionable` toggle
|
||||
is on (the default for owners + admins). The event is the
|
||||
framework's first non-RFC-scoped notification — `rfc_slug` is
|
||||
NULL and the email deep-link points `/admin/users` instead of
|
||||
`/rfc/<slug>`.
|
||||
|
||||
Decision on `/admin/allowlist`: the surface stays as a
|
||||
sibling sub-tab, not folded into `/admin/users`. The two have
|
||||
different keys (allowlist by email pre-sign-up, user list by
|
||||
user_id post-sign-up) and a union row would be confusing
|
||||
rather than clarifying. The allowlist's fast-path-bypass role
|
||||
from v0.8.0 is unchanged; retiring the table outright is
|
||||
deferred to a later session (see §19.2).
|
||||
|
||||
### Upgrade steps (from 0.10.0)
|
||||
|
||||
1. **MAY** rebuild the frontend. The build is the same shape
|
||||
as v0.10.0; the lockfile pins to `0.9.0` so `npm install`
|
||||
in `frontend/` updates it cleanly. No new env vars on the
|
||||
frontend; the existing `VITE_APP_NAME` requirement carries
|
||||
over.
|
||||
2. **MAY** restart the backend. No schema migration runs in
|
||||
this release — every v0.9.0 column is from
|
||||
`014_beta_access.sql` (v0.8.0). Restart only if you want
|
||||
the new endpoints registered in this version's binary.
|
||||
3. **SHOULD** verify SMTP can reach the deployment's admin
|
||||
inbox before the first pending user submits the capture
|
||||
form. v0.9.0 reuses the v0.7.0 SMTP configuration
|
||||
(`SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`,
|
||||
`SMTP_STARTTLS`, `EMAIL_FROM`, `EMAIL_FROM_NAME`); a
|
||||
misconfigured deployment will still write the inbox row,
|
||||
but the admin won't hear about it through email. No new
|
||||
env var is required — the framework reuses the existing
|
||||
admin-user list (role IN ('owner', 'admin')) as the
|
||||
notification recipients.
|
||||
4. **SHOULD** announce the surface to existing admins. Wording
|
||||
suggestion: "There's now a Users tab in /admin where you can
|
||||
Grant or Revoke beta access — and you'll get an email +
|
||||
inbox row when a fresh request lands. The manual SQL gesture
|
||||
from v0.8.0 still works but is no longer the documented
|
||||
path."
|
||||
5. **MAY** drain the existing pending queue through the new UI.
|
||||
If your deployment carried pending users through the v0.8.0
|
||||
manual-UPDATE window, the Pending bucket on `/admin/users`
|
||||
surfaces all of them with their captured profile. Granting
|
||||
from the UI stamps `permission_decided_by` /
|
||||
`permission_decided_at`, which any prior manual UPDATE
|
||||
gestures may have left NULL (no harm done — the v0.8.0
|
||||
contract didn't require those stamps).
|
||||
**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
|
||||
|
||||
- **`POST /api/admin/users/<id>/permission`** — body
|
||||
`{state: 'pending'|'granted'|'revoked'}`. Flips the column,
|
||||
stamps `permission_decided_by` + `permission_decided_at`,
|
||||
and writes a `permission_events` row with event_kind in
|
||||
`{permission_granted, permission_revoked,
|
||||
permission_repended}`. Refuses 422 on self-flip (symmetric
|
||||
to `set_mute` / `set_role` self-action refusals) and 422
|
||||
on invalid state. Returns `{ok, permission_state, changed}`
|
||||
where `changed=false` indicates a no-op (the requested
|
||||
state already matched).
|
||||
- **Widened `GET /api/admin/users` response** carrying
|
||||
`permission_state`, `first_name`, `last_name`,
|
||||
`beta_request_reason`, `created_at`,
|
||||
`permission_decided_at`, plus joined
|
||||
`permission_decided_by_login` /
|
||||
`permission_decided_by_display`. Sort order surfaces
|
||||
`pending` rows first (the admin queue), then `granted`,
|
||||
then `revoked`; within a bucket, owners precede admins
|
||||
precede contributors, with recency as the tiebreaker.
|
||||
- **`new_beta_request` event_kind** in the §15.1 enum. Fired
|
||||
by `notify.fan_out_new_beta_request` from the first
|
||||
successful `POST /api/auth/me/beta-request` (re-submits
|
||||
from the same pending user don't re-fire — the row's
|
||||
first-time-complete check guards against carpet-bombing).
|
||||
Recipients: every owner + admin minus the requester
|
||||
themselves. Category: `admin-actionable`. Deep-link:
|
||||
`/admin/users`. Actor: the requester per §15.9.
|
||||
- **`/admin/users` page enhancements** in `Admin.jsx`. The
|
||||
Users tab gains a state filter chip row (All / Pending /
|
||||
Granted / Revoked with counts), a Grant / Revoke control
|
||||
column, a Permission state badge, and an expandable
|
||||
"why they want access" row beneath each pending user.
|
||||
Sign-up timestamp surfaces in a new column.
|
||||
- **`frontend/src/api.js#setUserPermission`** — client for
|
||||
the new endpoint, neighboring `setUserMute` and
|
||||
`setUserRole`.
|
||||
- **`/beta-pending` copy update** in `BetaPending.jsx`. The
|
||||
"your request is in review" page now honestly references
|
||||
the admin-email signal v0.9.0 ships and admits the
|
||||
framework does not commit to an SLA — turnaround depends
|
||||
on operator availability, and the deployment operator is
|
||||
the right person to ask if a wait runs long. No
|
||||
deployment-specific text is baked in; per-deployment copy
|
||||
lives in §13 of the deployment's repo, not in the
|
||||
framework.
|
||||
- **`backend/tests/test_admin_users_vertical.py`** — 10 new
|
||||
tests covering: a beta-request submission fans
|
||||
notifications to every admin + owner (and not to the
|
||||
requester or to contributors); the requester's profile
|
||||
fields land in the notification payload; re-submitting
|
||||
the capture form does not re-fan; the `admin-actionable`
|
||||
category mapping is wired; the `/api/admin/users`
|
||||
listing carries the v0.8.0 columns with `pending` rows
|
||||
sorted first; the permission-flip endpoint promotes
|
||||
pending → granted with the right audit shape; the
|
||||
endpoint promotes granted → revoked; the endpoint
|
||||
refuses self-flip with 422; the endpoint refuses
|
||||
non-admin callers with 403 and anonymous callers with
|
||||
401; the endpoint refuses invalid states with 422; a
|
||||
state-already-matches flip returns `changed=false`
|
||||
without writing an audit row.
|
||||
- **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
|
||||
|
||||
- **`backend/app/api.py`** — the `POST /api/auth/me/beta-request`
|
||||
handler now calls `notify.fan_out_new_beta_request` after the
|
||||
capture UPDATE lands, gated on the row not previously having
|
||||
all three profile fields populated (so re-submits don't
|
||||
re-fan).
|
||||
- **`backend/app/api_admin.py`** — the `list_users` query joins
|
||||
against `users d ON d.id = u.permission_decided_by` for the
|
||||
deciding-admin handle. The new `set_permission` endpoint
|
||||
lives alongside `set_role` / `set_mute`.
|
||||
- **`backend/app/notify.py`** — adds
|
||||
`CATEGORY_ADMIN_ACTIONABLE`, the `fan_out_new_beta_request`
|
||||
helper, and the `new_beta_request` arm in `render_summary`.
|
||||
- **`backend/app/email.py`** — the `_EVENT_TO_CATEGORY` map
|
||||
carries `new_beta_request → admin-actionable`, and
|
||||
`_deep_link` routes framework-scoped admin signals to
|
||||
`/admin/users` instead of `/rfc/<slug>`.
|
||||
- **`frontend/src/components/Admin.jsx`** — the `UsersTab`
|
||||
component is rewritten with state filter chips, a per-row
|
||||
`UserRow` + `PermissionCell` decomposition, and a
|
||||
`useMemo`-cached counts table. The pending-row reason
|
||||
blockquote renders as a secondary `<tr>` beneath the user
|
||||
row when present.
|
||||
- **`frontend/src/components/BetaPending.jsx`** — pending-state
|
||||
copy revised to reference the admin email signal honestly.
|
||||
- **`frontend/src/App.css`** — new admin-chip / permission-badge
|
||||
/ user-row-reason rules.
|
||||
- **`SPEC.md`** §6 opening, §15.1 event-kinds enum, §17 admin
|
||||
endpoints, §19.2 candidates list — per §19.3 rule-2.
|
||||
- **`VERSION`** → `0.9.0`. `frontend/package.json#version` and
|
||||
the lockfile mirror.
|
||||
- **`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.
|
||||
|
||||
### Environment variables
|
||||
### Upgrade steps (from 0.7.0)
|
||||
|
||||
None new. The release reuses the v0.7.0 SMTP configuration
|
||||
(`SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`,
|
||||
`SMTP_STARTTLS`, `EMAIL_FROM`, `EMAIL_FROM_NAME`,
|
||||
`EMAIL_ENABLED`, `EMAIL_BUNDLE_THRESHOLD`) and the existing
|
||||
admin-user list (role IN ('owner', 'admin')) as the
|
||||
notification recipients. A deployment whose SMTP is misconfigured
|
||||
will still see the inbox rows; only the email channel is muted.
|
||||
|
||||
### Deferred to later releases
|
||||
|
||||
- **Grant / revoke notification to the user** — symmetric
|
||||
signal: when an admin grants or revokes access, fire a
|
||||
`personal-direct` notification (event_kind
|
||||
`permission_change_affecting_me`, already in the §15.1
|
||||
enum) so the affected user sees the state change in
|
||||
their inbox and email. v0.9.0 audits the gesture in
|
||||
`permission_events` but does not yet escape the
|
||||
app-internal log to the user. See §19.2.
|
||||
- **Decline-with-reason on revoke** — the current Revoke
|
||||
gesture takes only a confirmation; a follow-up release
|
||||
may capture a free-text reason in
|
||||
`permission_events.details`. See §19.2.
|
||||
- **Allowlist deprecation** — the `/admin/allowlist` sub-tab
|
||||
stays in place in v0.9.0 (the two surfaces have different
|
||||
keys and a union row would be confusing). Retiring the
|
||||
table outright is deferred to a session that can re-read
|
||||
the post-v0.9.0 operator experience and decide whether
|
||||
the fast-path-bypass role is still pulling weight. See
|
||||
§19.2.
|
||||
|
||||
## 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.
|
||||
- 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
|
||||
|
||||
@@ -1312,3 +851,7 @@ names itself.
|
||||
- `CLAUDE.md` at the repo root capturing the separation-of-concerns
|
||||
rule for working sessions.
|
||||
|
||||
## 0.1.0 — v1 build
|
||||
|
||||
Initial release. See `docs/DEV.md` for the slicing plan and build
|
||||
history.
|
||||
|
||||
@@ -1,605 +0,0 @@
|
||||
# Using the RFC app
|
||||
|
||||
This is the user-facing guide to the Wiggleverse RFC framework — how to
|
||||
read what's here, propose a new RFC, contribute to one that already
|
||||
exists, and understand who is allowed to do what.
|
||||
|
||||
This guide describes the framework. Individual deployments brand and
|
||||
configure themselves independently — the name in the header and the
|
||||
corpus the RFCs are about belong to the deployment, not to this
|
||||
document.
|
||||
|
||||
For the *why* of the framework, read the [philosophy](/philosophy).
|
||||
For the binding technical contract, see `SPEC.md` in the repository.
|
||||
|
||||
---
|
||||
|
||||
## Reading without signing in
|
||||
|
||||
You can read the catalog and every public RFC without an account.
|
||||
Anonymous visitors can:
|
||||
|
||||
- Browse the catalog of super-drafts and active RFCs.
|
||||
- Open any RFC and read its canonical body.
|
||||
- Read any public branch — its diff and its chat thread.
|
||||
- Read any pull request — its diff, its conversation, its review
|
||||
comments.
|
||||
- Read the discussion that has accumulated on an RFC's main view.
|
||||
|
||||
Reading is open by design. The framework's claim is that the *argument
|
||||
behind a definition* is the evidence that the definition was earned,
|
||||
and an argument that disappears behind a sign-in wall stops carrying
|
||||
that evidence.
|
||||
|
||||
What you cannot do without an account: chat, propose a new RFC,
|
||||
create a branch, open a PR, drop a flag, or post on a discussion
|
||||
thread. Every write affordance is replaced with a sign-in prompt.
|
||||
|
||||
---
|
||||
|
||||
## Signing in
|
||||
|
||||
While the framework is in private beta, only invited email addresses
|
||||
can complete sign-in. If your email is on the allowlist, the
|
||||
"Sign in" button in the header completes the flow and lands you on
|
||||
the catalog with full read and write access. If your email is not on
|
||||
the allowlist, you'll be sent to a short "pending" page explaining
|
||||
the gate.
|
||||
|
||||
Once you have an account, you're a **contributor** by default — the
|
||||
role that grants every write affordance the app exposes, scoped by
|
||||
the per-RFC and per-branch rules described below.
|
||||
|
||||
---
|
||||
|
||||
## Proposing a new RFC
|
||||
|
||||
A new RFC begins as a proposal. The "+ Propose new RFC" button at
|
||||
the bottom of the catalog opens a small modal that collects four
|
||||
things:
|
||||
|
||||
- **Title.** The word, concept, or topic this RFC would define.
|
||||
- **Slug.** A kebab-cased identifier derived from the title. It is
|
||||
the entry's stable handle from this moment until it graduates;
|
||||
collisions with existing entries or open proposals are caught
|
||||
inline.
|
||||
- **Pitch.** One or two paragraphs answering *why this RFC is
|
||||
needed*. This becomes the body of the entry.
|
||||
- **Tags.** Optional. The AI suggests tags from the pitch; you can
|
||||
accept, dismiss, or type your own.
|
||||
|
||||
Submitting the modal does one concrete thing: it opens a pull
|
||||
request against the framework's meta repository, adding one new
|
||||
file under `rfcs/`. There is no other Git artifact and no other
|
||||
side-effect. You are returned to the **pending-idea view** for the
|
||||
new proposal.
|
||||
|
||||
A pending idea is publicly readable but not yet a super-draft. The
|
||||
catalog surfaces it in a "Pending ideas" disclosure at the bottom
|
||||
of the list. A conversation can accumulate on the pending-idea view
|
||||
before it is admitted — contributors can argue, in public, about
|
||||
whether the entry belongs in the catalog at all.
|
||||
|
||||
Three outcomes are possible:
|
||||
|
||||
- **Merge.** An admin or owner merges the proposal PR. The entry
|
||||
becomes a super-draft and graduates from the "Pending ideas"
|
||||
section into the main catalog. Any conversation that accumulated
|
||||
on the pending-idea view migrates with it.
|
||||
- **Decline.** An admin or owner declines, attaching a written
|
||||
comment. You see the comment on your next visit, along with a
|
||||
one-click affordance to revise and re-propose.
|
||||
- **Withdraw.** You can withdraw your own proposal at any time. The
|
||||
entry will not appear in any default view; the conversation that
|
||||
accumulated stays attached to the closed PR as historical record.
|
||||
|
||||
You are automatically the first owner of any RFC you propose. The
|
||||
claim flow described under [Roles & permissions](#roles--permissions)
|
||||
is for *other* contributors to add themselves as owners later, not
|
||||
for the proposer.
|
||||
|
||||
---
|
||||
|
||||
## What a super-draft is
|
||||
|
||||
A super-draft is an entry that has been admitted to the catalog but
|
||||
does not yet have its own dedicated repository. Most of the
|
||||
argument that shapes a definition happens here. The framework
|
||||
assumes — and the philosophy explicitly invites — that many
|
||||
super-drafts will not survive the argument, and that is fine. The
|
||||
entries that do survive earn their place in the catalog by being
|
||||
defensible in public.
|
||||
|
||||
Opening a super-draft from the catalog gives you the same surface
|
||||
an active RFC uses:
|
||||
|
||||
- The canonical body in the centre, read-only by default.
|
||||
- A chat thread on the right where the public conversation lives.
|
||||
- A breadcrumb dropdown listing any in-flight edit branches and
|
||||
any open body-edit PRs against this entry.
|
||||
- A "Start Contributing" affordance that cuts a fresh edit branch
|
||||
and lands you in contribute mode.
|
||||
|
||||
Edits to a super-draft body propagate through pull requests against
|
||||
the meta repository — there is no dedicated RFC repository yet.
|
||||
|
||||
---
|
||||
|
||||
## What an active RFC is
|
||||
|
||||
An active RFC is an entry that has been **graduated**. It has its
|
||||
own dedicated repository, an integer `RFC-NNNN` identifier, and a
|
||||
canonical body file (`RFC.md`) inside that repository. The catalog
|
||||
distinguishes super-drafts and active RFCs at a glance.
|
||||
|
||||
Opening an active RFC gives you:
|
||||
|
||||
- `main` — the canonical body, always read-only. Changes to `main`
|
||||
arrive exclusively through pull requests.
|
||||
- A breadcrumb listing every open branch and pull request on this
|
||||
RFC.
|
||||
- A per-branch chat thread on the right. Each branch has its own
|
||||
conversation, including `main` itself.
|
||||
- A "Start Contributing" affordance: on `main` it cuts a new branch
|
||||
and lands you on it in contribute mode; on any other branch you
|
||||
already have push access to, it flips that branch into
|
||||
contribute mode.
|
||||
|
||||
---
|
||||
|
||||
## Discussion vs contribution
|
||||
|
||||
The framework draws an explicit distinction between two surfaces
|
||||
that other tools tend to conflate:
|
||||
|
||||
- **Discussion** is what the RFC is *for*. The chat thread on an
|
||||
RFC's main view is the place for "what about this part?" or
|
||||
"have we considered…?" questions that don't yet warrant proposing
|
||||
a specific edit. Posting on a discussion thread does not create
|
||||
any Git artifact; the conversation lives in the app database.
|
||||
- **Contribution** is how an RFC *changes*. Editing the canonical
|
||||
body requires opening a branch and, eventually, a pull request.
|
||||
The pull request is the place a specific proposed change is
|
||||
reviewed and merged.
|
||||
|
||||
Reading both surfaces is open to anonymous visitors. Posting on
|
||||
either requires a contributor account.
|
||||
|
||||
---
|
||||
|
||||
## Working on a branch
|
||||
|
||||
Contribute mode flips one branch into edit-enabled. The centre
|
||||
column splits: a markdown source pane on the left, a live-rendered
|
||||
preview on the right. Fenced `mermaid` blocks render as diagrams in
|
||||
the preview.
|
||||
|
||||
Two kinds of edits accumulate on a branch:
|
||||
|
||||
- **AI-proposed changes.** You ask the AI a question or request a
|
||||
revision in the branch's chat. When the AI proposes a concrete
|
||||
edit, that edit appears as a *change card* in a panel below the
|
||||
chat — not yet applied to the document. You can **accept**,
|
||||
**decline**, or **edit before accepting**. Accepting produces
|
||||
one commit on the branch with the original text, the proposed
|
||||
text, and the AI's reason recorded in the commit body.
|
||||
- **Manual edits.** Typing directly into the source pane buffers
|
||||
locally and flushes as a single commit on an idle window, a
|
||||
branch switch, or an explicit "Save now" button. Manual edits
|
||||
also appear as change cards in the same panel — same evidence
|
||||
shape, different author.
|
||||
|
||||
Every accepted change is one commit. The framework does not
|
||||
support squash-merges or fixup-style cleanups: the per-change
|
||||
commit granularity is the framework's evidence unit, and
|
||||
collapsing it would erase what was earned.
|
||||
|
||||
### Discuss mode vs contribute mode
|
||||
|
||||
A branch defaults to discuss mode — read-only, with chat enabled.
|
||||
AI proposals still appear in chat, but they are *buffered* rather
|
||||
than applied; a single CTA invites you to flip the branch into
|
||||
contribute mode if you want to act on them. The toggle is an
|
||||
*intent* affordance, not a permission one. If you don't have push
|
||||
access to the branch, the toggle is disabled with a sign-in or
|
||||
request-access path.
|
||||
|
||||
`main` is special: contribute mode is never available there. The
|
||||
"Start Contributing" button on `main` always cuts a new branch.
|
||||
|
||||
### Flags
|
||||
|
||||
Anywhere you can read, you can drop a flag. A flag is the
|
||||
lightweight "I'm pointing at this, it's a problem" gesture — a
|
||||
single short declarative statement anchored to a passage. Creating
|
||||
a flag requires a contributor account but does not require push
|
||||
access to the branch: any signed-in contributor who can read a
|
||||
passage can point at it and say it's wrong.
|
||||
|
||||
Flags don't block PR merges by design — making them a merge gate
|
||||
would re-create the failure mode where contributors hastily "resolve"
|
||||
threads to unblock a button. Flags are prominent on PR headers but
|
||||
non-blocking.
|
||||
|
||||
### Branch visibility
|
||||
|
||||
A new branch is publicly readable by default. The branch creator
|
||||
can flip a branch to private, in which case only the creator, any
|
||||
explicit grantees, and the RFC's per-RFC owners and arbiters can
|
||||
read it. Owners and arbiters can flip it back.
|
||||
|
||||
**Opening a PR makes the branch fully public.** If your branch is
|
||||
currently private, the "Open PR" affordance asks you to confirm
|
||||
this before submitting. There is no concept of a private PR — the
|
||||
framework's evidence claim depends on the argument being readable.
|
||||
|
||||
### Who can push to a branch
|
||||
|
||||
Every branch has one of three contribute modes:
|
||||
|
||||
- **`just-me`** (default) — only the branch creator can push.
|
||||
- **`specific`** — only the branch creator and explicitly granted
|
||||
contributors can push.
|
||||
- **`any-contributor`** — any signed-in contributor can push.
|
||||
|
||||
The branch creator and the RFC's per-RFC owners and arbiters can
|
||||
change this setting at any time.
|
||||
|
||||
### Branch hygiene
|
||||
|
||||
A branch with no associated PR auto-closes after 30 days of
|
||||
inactivity. A closed branch is deleted from the Git host 60 days
|
||||
later. Closed branches remain in the catalog under a "show closed"
|
||||
filter — closing is a state, not a censorship event. The chat
|
||||
attached to a closed or deleted branch is preserved as historical
|
||||
record.
|
||||
|
||||
Owners and arbiters can *pin* a branch to disable the auto-close
|
||||
timer if the work is paused but legitimately ongoing.
|
||||
|
||||
---
|
||||
|
||||
## Opening and reviewing a pull request
|
||||
|
||||
A pull request is the deliberate "ready for review" gesture for
|
||||
work that has accumulated on a branch. The "Open PR" affordance is
|
||||
available on any branch with at least one commit ahead of `main`.
|
||||
|
||||
The PR creation modal collects two AI-drafted fields, both editable
|
||||
before submit:
|
||||
|
||||
- **Title.** A one-line description of the change, in spec voice.
|
||||
- **Description.** Two to four sentences pulling from the branch
|
||||
chat, written for an arbiter.
|
||||
|
||||
There is no reviewer picker. The RFC's arbiters are the implicit
|
||||
reviewer set.
|
||||
|
||||
### The PR review page
|
||||
|
||||
The review page shows the diff, the branch's compressed chat
|
||||
(messages that produced accepted changes are expanded, the rest is
|
||||
behind a "Show full conversation" toggle), and the review-comment
|
||||
surface inline below the chat.
|
||||
|
||||
Review comments are not a separate concept from chat — they live in
|
||||
the same thread, anchored to a range in the diff. The framework's
|
||||
claim is that the disagreement an arbiter raises about a proposed
|
||||
change is the same *kind* of thing as the disagreement that
|
||||
produced the proposed change in the first place, and the two should
|
||||
share a surface.
|
||||
|
||||
Each PR records a per-user seen-cursor. New diff hunks and new
|
||||
conversation messages since your last visit render with a subtle
|
||||
accent. The cursor advances on view; you do not have to mark
|
||||
anything as read.
|
||||
|
||||
### Merging a PR
|
||||
|
||||
Per-RFC owners and arbiters can merge; app-wide admins and owners
|
||||
also retain this capability. The merge produces a no-fast-forward
|
||||
commit on `main`, preserving every per-acceptance commit as an
|
||||
individually reachable node in `main`'s history.
|
||||
|
||||
Merge is hard-blocked **only** by Git-level conflicts with `main`.
|
||||
Open review threads, pending change-cards, unresolved chat threads,
|
||||
and open flags do not block merge by design.
|
||||
|
||||
### Conflicts with main
|
||||
|
||||
A conflict surfaces on the PR page as a read-only banner. A "Start
|
||||
resolution branch" affordance cuts a fresh branch off `main`'s
|
||||
current tip, replays the work into it (asking the AI to resolve
|
||||
unambiguous conflicts, surfacing the rest for you), and opens a new
|
||||
PR. The original PR auto-closes when the resolution PR merges.
|
||||
|
||||
Fixup commits on the existing branch are not supported. Per-change
|
||||
commit granularity is the framework's evidence unit; admitting
|
||||
"fix merge conflict with main" commits would dilute it.
|
||||
|
||||
---
|
||||
|
||||
## Graduation: super-draft → active RFC
|
||||
|
||||
Graduation is the moment a super-draft becomes a canonical entry
|
||||
in the catalog. It is initiated by an app-wide admin, an app-wide
|
||||
owner, or one of the RFC's per-RFC owners or arbiters from the
|
||||
super-draft's page.
|
||||
|
||||
Two preconditions block the action:
|
||||
|
||||
- **The super-draft must have at least one owner.** The proposer
|
||||
is automatically the first owner; if they have stepped away, any
|
||||
contributor can use the "Claim ownership" affordance to add
|
||||
themselves.
|
||||
- **No open body-edit PRs against the super-draft's entry.** An
|
||||
open body-edit PR would attempt to re-introduce a body to a
|
||||
frontmatter-only entry after graduation runs. Merge or withdraw
|
||||
them first.
|
||||
|
||||
When the dialog confirms, the framework runs a transactional
|
||||
sequence: create a fresh Git repository for the RFC, seed it with
|
||||
the super-draft's body as `RFC.md`, update the meta-repo entry to
|
||||
`state: active` with the integer ID and the new repository's URL,
|
||||
auto-merge that update. If any step fails partway, the sequence
|
||||
rolls back — the half-created repository is deleted and the
|
||||
unmerged update is abandoned. The dialog shows each step in flight
|
||||
and tells you exactly what happened.
|
||||
|
||||
The chat thread on the super-draft moves to the new repository's
|
||||
`main` chat at graduation. Edit-branch chats from the super-draft
|
||||
phase stay attached to their original branches on the meta repo
|
||||
and surface from the new RFC view under a "Pre-graduation history"
|
||||
section.
|
||||
|
||||
Graduation is not reversible. The path forward from an active RFC
|
||||
is withdrawal, not back to super-draft.
|
||||
|
||||
---
|
||||
|
||||
## Withdrawing and reopening
|
||||
|
||||
An active RFC or a super-draft can be withdrawn by the proposer
|
||||
(for a super-draft they proposed) or by an admin or owner. A
|
||||
withdrawn entry stays in the catalog as a historical record but is
|
||||
hidden from default views. The entry is filterable back in.
|
||||
|
||||
An admin or owner can reopen a withdrawn entry back into the
|
||||
super-draft state. The history is preserved across the transition.
|
||||
|
||||
---
|
||||
|
||||
## AI in the chat
|
||||
|
||||
The chat on every RFC, super-draft, branch, and PR has an AI
|
||||
participant by default. The framework treats the AI as one voice
|
||||
among many in a public argument — not an oracle, and not a
|
||||
co-author whose name lands on commits.
|
||||
|
||||
You invoke the AI by writing into the chat composer and submitting.
|
||||
Each message can pick a model from the picker (the option list is
|
||||
configurable per RFC). The AI responds in the chat; when its
|
||||
response includes a concrete change to the document, that change
|
||||
appears as a card you can accept, decline, or edit.
|
||||
|
||||
When you accept an AI's proposed change, the commit's
|
||||
`On-behalf-of:` trailer names *you*, not the AI. The AI's authorship
|
||||
survives only as evidence — the original proposal in the commit body
|
||||
and the message that produced it in the chat record. The framework
|
||||
is explicit about this: AI participation produces evidence; it does
|
||||
not produce authorship.
|
||||
|
||||
Two configuration knobs scope AI participation per RFC:
|
||||
|
||||
- **Which models are available.** The meta-repo entry's frontmatter
|
||||
carries an optional `models:` list. Absent means the RFC inherits
|
||||
whatever models the deployment is provisioned to run. An empty
|
||||
list (`models: []`) opts the RFC out of AI entirely — every AI
|
||||
surface is absent rather than disabled-but-present.
|
||||
- **Whose credentials pay.** By default the deployment operator's
|
||||
API credentials cover AI calls on every RFC. A `funder:`
|
||||
frontmatter field can name a single contributor whose registered
|
||||
credentials pay for AI calls on this RFC instead. The named
|
||||
contributor must explicitly consent from their settings page;
|
||||
either side can revoke at any time.
|
||||
|
||||
Per-RFC AI configuration is edited through the meta-repo PR flow
|
||||
that governs the rest of the entry's frontmatter — by the RFC's
|
||||
per-RFC owners and arbiters, or by app-wide admins or owners.
|
||||
|
||||
---
|
||||
|
||||
## Notifications
|
||||
|
||||
The framework's public-async work model produces signals that
|
||||
shouldn't all reach you the same way. Five surfaces compose:
|
||||
|
||||
- **In-app inbox.** The durable triage surface. One mental space
|
||||
across every RFC you have any relationship to, with per-RFC and
|
||||
per-category filters. Reachable from the inbox icon in the
|
||||
header.
|
||||
- **Badges.** Ambient pull-ins. A single integer beside the inbox
|
||||
icon (count of unread notifications). A small binary dot on
|
||||
individual catalog rows for watched RFCs with unseen activity.
|
||||
No per-row counts and no per-section counts.
|
||||
- **Toasts.** Transient mid-session signals. Used only for your own
|
||||
actions completing, and for events arriving on the view you're
|
||||
currently looking at.
|
||||
- **Email.** The single channel that escapes the app. Opt-in per
|
||||
category, conservative defaults. One-click unsubscribe per
|
||||
category.
|
||||
- **Digest.** Aggregation for activity on watched RFCs you haven't
|
||||
triaged through any other channel.
|
||||
|
||||
### Watch states
|
||||
|
||||
Every RFC has one of three implicit relationship states for you:
|
||||
|
||||
- **Watching.** You receive structural signals for the RFC.
|
||||
- **Following.** You receive only churn-grade signals (new
|
||||
commits, new chat messages on threads you didn't participate
|
||||
in). This is a lighter relationship than watching.
|
||||
- **Muted.** You receive no signals for the RFC. The mute is
|
||||
per-RFC and self-imposed; it does not affect what others see
|
||||
or what reaches you on *other* RFCs.
|
||||
|
||||
Watch states transition automatically based on your participation,
|
||||
with explicit overrides available from each RFC's header and from
|
||||
the notification settings page.
|
||||
|
||||
### Email categories
|
||||
|
||||
Four categories with distinct defaults:
|
||||
|
||||
- **Personal-direct events** — default on. Signals where you are
|
||||
the named subject. The contract is that when your name is on the
|
||||
action, the framework reaches out of band.
|
||||
- **Watched-RFC structural events** — default off. PR opened on a
|
||||
watched RFC, PR merged, graduation, withdrawal. Inbox and badges
|
||||
carry these by default; the email toggle is opt-in.
|
||||
- **Watched-RFC churn** — permanently off, by design. Per-commit
|
||||
and per-message email is intentionally not offered. The digest
|
||||
aggregates this activity weekly.
|
||||
- **Admin-actionable events** — default on for admins and owners,
|
||||
unused for contributors.
|
||||
|
||||
### Quiet hours
|
||||
|
||||
You can set a daily window during which email notifications are
|
||||
held. Messages held during the window are released at window end —
|
||||
bundled into a single "Activity while you were away" email if a
|
||||
threshold accumulated, otherwise sent individually.
|
||||
|
||||
---
|
||||
|
||||
## Roles & permissions
|
||||
|
||||
Authorization in this framework is owned by the app itself, not by
|
||||
the Git host. The Git host sees only a single bot account — every
|
||||
commit, every PR, every merge passes through it on a user's behalf
|
||||
— and the *app* decides which users are authorized to ask the bot
|
||||
to do which things.
|
||||
|
||||
### The four app-wide roles
|
||||
|
||||
Each role is a strict superset of the one below it.
|
||||
|
||||
1. **Anonymous.** Anyone who has not signed in. Can read public
|
||||
RFCs, public branches, and public PRs; cannot chat, propose,
|
||||
create branches, or open PRs.
|
||||
|
||||
2. **Contributor.** The default role for any authenticated
|
||||
account. Adds everything anonymous can do, plus: propose new
|
||||
RFCs, create branches on any RFC repository, open PRs from
|
||||
branches they have push access to, post on chat anywhere they
|
||||
can read, claim ownership of unclaimed super-drafts.
|
||||
|
||||
3. **Admin.** Adds the ability to act on any RFC, anywhere in the
|
||||
framework. Concretely: merge any PR on any RFC, graduate any
|
||||
super-draft, set branch visibility on anyone's behalf, withdraw
|
||||
or reopen any entry, write-mute or restore any contributor,
|
||||
grant or revoke the **admin** role.
|
||||
|
||||
4. **Owner.** Adds two capabilities admin does not have: grant or
|
||||
revoke the **owner** role itself, and disable an account
|
||||
entirely. The framework names a single "owner zero" at
|
||||
bootstrap.
|
||||
|
||||
The practical difference between admin and owner is narrow but
|
||||
load-bearing: admin is the operational tier — it does the day-to-
|
||||
day moderation and stewardship work; owner is the tier that
|
||||
controls the admin tier. Disabling an account and creating other
|
||||
owners are owner-only because they affect the framework's chain of
|
||||
authority itself.
|
||||
|
||||
The app refuses to let the last owner demote themselves silently —
|
||||
losing the last owner would leave nobody able to grant the role
|
||||
back. Role changes are recorded in an append-only `permission_events`
|
||||
log; an admin's own admin/users page shows the log of who promoted,
|
||||
demoted, or muted whom.
|
||||
|
||||
### Per-RFC delegated authority
|
||||
|
||||
The four roles above are framework-wide. Within an individual RFC,
|
||||
the meta-repo entry's frontmatter names two additional groups:
|
||||
|
||||
- **`owners:`** — contributors elevated for this RFC. They can
|
||||
grant push access on any branch in the RFC, merge any PR on the
|
||||
RFC, change branch visibility, and withdraw the RFC.
|
||||
- **`arbiters:`** — contributors with merge authority for this RFC.
|
||||
Functionally similar to per-RFC owners for merge decisions; the
|
||||
distinction matters in some configuration paths.
|
||||
|
||||
Per-RFC owners and arbiters are **not** app-wide admins. Their
|
||||
elevated powers are scoped strictly to the RFC named in the
|
||||
frontmatter. This is what lets the framework distribute work
|
||||
without putting one person on the hook for every action.
|
||||
|
||||
The proposer of an RFC is automatically the first per-RFC owner.
|
||||
Additional per-RFC owners are added through a "Claim ownership"
|
||||
PR against the meta repository; app-wide admins or owners merge
|
||||
it.
|
||||
|
||||
### Per-branch contribute grants
|
||||
|
||||
Within an RFC, the branch creator and the RFC's per-RFC owners
|
||||
and arbiters can grant push access to specific contributors on a
|
||||
specific branch — `specific` contribute mode, described under
|
||||
"Working on a branch."
|
||||
|
||||
### The write-mute
|
||||
|
||||
An app-wide admin or owner can **mute** a contributor. A muted
|
||||
account retains read access and keeps its existing branches, but
|
||||
cannot create new branches, open new PRs, propose new RFCs, or
|
||||
post chat. This is a moderation tool, distinct from removing the
|
||||
account; restoring is the reverse gesture.
|
||||
|
||||
The write-mute applies only to contributors. Promoting a user to
|
||||
admin or owner is the way to remove a user's write-restriction in
|
||||
the structural sense; the write-mute is for *retaining* an account
|
||||
while removing its ability to act.
|
||||
|
||||
Every mute and every restore is recorded in `permission_events`.
|
||||
|
||||
### Three different "mutes"
|
||||
|
||||
The word "mute" appears in three structurally distinct places.
|
||||
They share a word and nothing else.
|
||||
|
||||
- **Write-mute.** Admin-imposed. Removes a contributor's ability
|
||||
to post or push. Described above.
|
||||
- **Per-RFC notification mute.** Self-imposed. Sets your watch
|
||||
state on a specific RFC to *muted* — you stop receiving signals
|
||||
for that RFC, in inbox, badges, and email. Does not affect what
|
||||
others see.
|
||||
- **Per-user notification mute.** Self-imposed. Suppresses
|
||||
notifications produced by a specific other user, anywhere in
|
||||
the framework. Notification-volume only — it does not affect
|
||||
what you can read.
|
||||
|
||||
A write-muted contributor continues to receive notifications
|
||||
normally, so they can triage what they can't act on, and so a
|
||||
restore lands cleanly.
|
||||
|
||||
### Audit trail
|
||||
|
||||
Every gesture that changes app state — role changes, mutes,
|
||||
graduations, withdrawals, grant changes — is recorded in
|
||||
append-only logs the app maintains. Git commit history is for
|
||||
code archaeology; the app's audit log is the accountability
|
||||
record. An admin's page surfaces both `permission_events` (the
|
||||
role/mute log) and `actions` (the state-transition log) for
|
||||
review.
|
||||
|
||||
---
|
||||
|
||||
## Where to learn more
|
||||
|
||||
- The framework's *why* lives in [the philosophy
|
||||
document](/philosophy).
|
||||
- The binding technical contract — section numbers (`§n.n`)
|
||||
referenced throughout this guide — is in `SPEC.md` in the
|
||||
framework's source repository.
|
||||
- Deployment operators have their own recipe in
|
||||
`docs/DEPLOYMENTS.md`.
|
||||
@@ -388,34 +388,6 @@ 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
|
||||
(roadmap item #7) shipped the user-management surface that
|
||||
consumes the `permission_state` column: `/admin/users` carries
|
||||
every user with their state, profile, and sign-up reason, plus
|
||||
Grant / Revoke controls that flip the column and write a
|
||||
`permission_events` audit row. The capture-form submission also
|
||||
fans a `new_beta_request` notification out to every admin/owner
|
||||
through the §15 substrate, so the queue surfaces in the inbox +
|
||||
email channels admins already have.
|
||||
|
||||
The `/admin/allowlist` sub-tab stays in place alongside
|
||||
`/admin/users` rather than merging: the two surfaces have
|
||||
different keys (allowlist by email pre-sign-up, user list by
|
||||
user_id post-sign-up) and a union row would be confusing rather
|
||||
than clarifying. The allowlist's role narrowed to "fast-path
|
||||
bypass for known-good emails" with the v0.8.0 admission shift;
|
||||
v0.9.0 retains that role unchanged.
|
||||
|
||||
### 6.1 Four roles, each a strict superset of the one below
|
||||
|
||||
1. **Anonymous.** Can read public RFCs (the meta repo's main branch,
|
||||
@@ -432,14 +404,10 @@ v0.9.0 retains that role unchanged.
|
||||
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
|
||||
at this role; v0.7.0 keeps the v0.3.0 allowlist gate (`allowed_emails`)
|
||||
as the admission control, deferring the open beta-access request
|
||||
flow to a later release. 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.
|
||||
@@ -481,38 +449,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
|
||||
@@ -2097,19 +2033,6 @@ 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`,
|
||||
@@ -2300,14 +2223,8 @@ signal taxonomy this section commits to. The starting set:
|
||||
`graduation_complete`, `graduation_rolled_back`, `rfc_withdrawn`,
|
||||
`rfc_reopened`, `claim_opened`, `claim_merged`,
|
||||
`permission_change_affecting_me`, `app_wide_mute_set`,
|
||||
`app_wide_mute_lifted`, `new_beta_request`, `digest_emitted`.
|
||||
The enum is extensible; the build session adjusts as new gestures
|
||||
are wired in. The `new_beta_request` event (v0.9.0, roadmap item
|
||||
#7) is framework-scoped rather than RFC-scoped — the row's
|
||||
`rfc_slug` is NULL and the deep-link points `/admin/users`
|
||||
instead of `/rfc/<slug>` — but otherwise rides the standard
|
||||
fan-out chokepoint with category `admin-actionable` so the §15.4
|
||||
email gate only reaches owners/admins.
|
||||
`app_wide_mute_lifted`, `digest_emitted`. The enum is extensible; the
|
||||
build session adjusts as new gestures are wired in.
|
||||
|
||||
### 15.2 The inbox
|
||||
|
||||
@@ -2752,45 +2669,25 @@ The follow-up session will refine this. A minimal starting set:
|
||||
- `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.
|
||||
via the SMTP layer. Returns HTTP 200 (`{ok:true}`) uniformly so
|
||||
allowlist state (§6.1 / §6.2) is not leaked to callers. 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.
|
||||
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.
|
||||
contributor row with `gitea_id = NULL`), and stores the session
|
||||
cookie. Returns HTTP 200 on success with a minimal user payload;
|
||||
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.
|
||||
- `GET /auth/passcode/check` — unauthenticated. Query param `email`.
|
||||
Returns `{has_passcode: boolean}`. The frontend's `/login` surface
|
||||
calls this after the email step to decide whether to render a
|
||||
@@ -2965,15 +2862,8 @@ The follow-up session will refine this. A minimal starting set:
|
||||
- `POST /api/rfcs/<slug>/prs/<pr_number>/withdraw` — withdraw per §10.8.
|
||||
- `POST /api/rfcs/<slug>/prs/<pr_number>/resolution-branch` — cut a
|
||||
fresh resolution branch and replay per §10.9.
|
||||
- `GET /api/admin/users` — list users for the §6 / Slice 7 admin
|
||||
surface. v0.9.0 (roadmap item #7) widened the payload to carry
|
||||
`permission_state`, `first_name`, `last_name`, `beta_request_reason`,
|
||||
`created_at`, `permission_decided_at`, and the joined
|
||||
`permission_decided_by_login` / `permission_decided_by_display`
|
||||
for the user-management page. Sort order surfaces `pending` rows
|
||||
first (the daily admin queue), then `granted`, then `revoked`;
|
||||
within a bucket, owners precede admins precede contributors,
|
||||
with recency as the tiebreaker.
|
||||
- `GET /api/admin/users` — list users with role and write-mute state,
|
||||
for the §6 / Slice 7 admin surface.
|
||||
- `POST /api/admin/users/<id>/role` — set role. Only owners may grant
|
||||
or revoke `owner`; admins may flip contributor ↔ admin freely. An
|
||||
owner-self-demotion is refused on this endpoint; owner succession
|
||||
@@ -2982,16 +2872,6 @@ The follow-up session will refine this. A minimal starting set:
|
||||
write-mute (not the §15.8 notification mutes). Refused on owners
|
||||
and admins — for them, the role-change channel is the right
|
||||
refusal. Writes a `permission_events` row.
|
||||
- `POST /api/admin/users/<id>/permission` — v0.9.0 (roadmap item #7).
|
||||
Flip `permission_state` between `pending`, `granted`, and `revoked`.
|
||||
Stamps `permission_decided_by` + `permission_decided_at` on the
|
||||
row and writes a `permission_events` row with event_kind in
|
||||
`{permission_granted, permission_revoked, permission_repended}`.
|
||||
Refuses with 422 if the admin tries to flip their own row
|
||||
(symmetric to the `set_mute` / `set_role` self-action refusals).
|
||||
v0.8.0 shipped the column shape with no admin UI — operators ran
|
||||
a manual `UPDATE users` to grant access; v0.9.0 retires the
|
||||
manual gesture.
|
||||
- `GET /api/admin/audit` — paged read of the `actions` log with
|
||||
filters `action_kind`, `actor_user_id`, `rfc_slug`, plus `before_id`
|
||||
for the page boundary. Returns the joined actor login/display so
|
||||
@@ -3815,80 +3695,22 @@ 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`). *Shipped in
|
||||
v0.9.0 (roadmap item #7).* The listing surfaces every user with
|
||||
permission_state, profile fields, sign-up reason, and a Grant /
|
||||
Revoke control set; the `POST /api/admin/users/<id>/permission`
|
||||
endpoint flips the column and writes a `permission_events` row.
|
||||
v0.9.0 left the `/admin/allowlist` sub-tab in place rather than
|
||||
merging (see allowlist deprecation below). The grant/revoke
|
||||
notify-the-user surface is deferred (see the new candidate
|
||||
below).
|
||||
- **Allowlist deprecation.** *Decision deferred past v0.9.0.*
|
||||
v0.9.0 considered merging `/admin/allowlist` into the new
|
||||
`/admin/users` page but kept the surface as a sibling sub-tab:
|
||||
the two have different keys (allowlist by email pre-sign-up,
|
||||
user list by user_id post-sign-up) and a union row would be
|
||||
confusing rather than clarifying. The fast-path-bypass role
|
||||
the allowlist has carried since v0.8.0 stays intact; the
|
||||
cutover to retire the table outright is a later session.
|
||||
Decision points unchanged from v0.8.0: 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
|
||||
once the v0.9.0 admin queue has run long enough to confirm the
|
||||
allowlist's bypass role is no longer pulling weight.
|
||||
- **Admin notification on new beta request.** *Shipped in v0.9.0
|
||||
(roadmap item #7).* The `POST /api/auth/me/beta-request`
|
||||
handler now calls `notify.fan_out_new_beta_request`, which
|
||||
fans a `new_beta_request` event (category `admin-actionable`,
|
||||
rfc_slug NULL) out to every owner / admin. The §15 chokepoint
|
||||
handles the SSE broadcast and the §15.4 email dispatch; the
|
||||
email reaches only recipients whose `email_admin_actionable`
|
||||
toggle is on (the default for owners + admins).
|
||||
- **Grant / revoke notification to the user.** *Surfaced by
|
||||
v0.9.0.* The new flip endpoint stamps `permission_decided_by` +
|
||||
writes a `permission_events` row but does not yet signal the
|
||||
affected user that their state changed. A future release could
|
||||
fire a `personal-direct` notification (event_kind
|
||||
`permission_change_affecting_me`, already in the §15.1 enum) so
|
||||
a granted user sees "Your beta-access request was approved" in
|
||||
their inbox and email, and a revoked user sees a parallel
|
||||
refusal notice. Decision points: does revocation include a
|
||||
reason field (probably yes — symmetric with §9.3's decline
|
||||
comment); does grant carry a welcome message (probably no —
|
||||
the existing welcome surfaces are sufficient); does the
|
||||
notification escape the §15.8 mute path (probably yes — it's
|
||||
a personal-direct admission state change). Earns its session
|
||||
as a follow-up to the v0.9.0 page.
|
||||
- **Decline-with-reason on permission revoke.** *Surfaced by
|
||||
v0.9.0.* The current Revoke gesture takes only a confirmation;
|
||||
there is no audit-visible reason captured. A future release
|
||||
could add a free-text reason input that lands in the
|
||||
`permission_events.details` JSON column (no schema change
|
||||
needed — the column is already JSON-shaped). This is the
|
||||
symmetric companion to the §9.3 proposal-decline contract.
|
||||
Earns its session alongside the grant/revoke notification
|
||||
candidate above.
|
||||
- **First-OTC profile capture.** *Surfaced by v0.7.0's email/OTC
|
||||
migration.* When a fresh email lands at `/auth/otc/verify` with
|
||||
no matching `users.email` row, v0.7.0 provisions the row with
|
||||
`display_name = <local part of email>` and no other identity
|
||||
fields. The roadmap item-#6 candidate (v0.8.0) is expected to
|
||||
add a one-shot profile-capture step on the first-OTC sign-in:
|
||||
first name, last name, and a free-text "why I want access" field
|
||||
that flows into the open beta-access request queue (also item #6)
|
||||
that replaces the v0.3.0 `allowed_emails` gate. The schema slot
|
||||
exists implicitly already (`users.display_name` is updateable,
|
||||
the audit-log + permission-events tables carry the freeform
|
||||
notes); the structural decision is what gates the capture (modal
|
||||
on `/login` after verify? a one-time redirect to `/welcome/profile`?
|
||||
a deferred banner on the main view?) and how it interacts with
|
||||
the open-access request flow that replaces the allowlist. Earns
|
||||
its session as the v0.8.0 design pass.
|
||||
- **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`
|
||||
|
||||
+6
-113
@@ -26,12 +26,10 @@ from . import (
|
||||
api_prs,
|
||||
auth,
|
||||
db,
|
||||
docs as docs_mod,
|
||||
entry as entry_mod,
|
||||
cache,
|
||||
funder,
|
||||
health,
|
||||
notify,
|
||||
philosophy,
|
||||
providers as providers_mod,
|
||||
)
|
||||
@@ -57,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,
|
||||
@@ -124,17 +111,6 @@ def make_router(
|
||||
payload = philosophy.load()
|
||||
return {"body": payload["body"]}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# /api/docs — DOCS.md served verbatim. Sibling of /api/philosophy:
|
||||
# no auth gate, same disk-first load + cache shape, same intent —
|
||||
# public read surface for a markdown file checked into the repo.
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.get("/api/docs")
|
||||
async def get_docs() -> dict[str, Any]:
|
||||
payload = docs_mod.load()
|
||||
return {"body": payload["body"]}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Auth surface — reads role from our users table per §6.
|
||||
# ---------------------------------------------------------------
|
||||
@@ -144,27 +120,15 @@ def make_router(
|
||||
user = auth.current_user(request)
|
||||
if user is None:
|
||||
return {"authenticated": False, "user": None}
|
||||
# v0.8.0 + v0.10.0: single round-trip for everything the
|
||||
# frontend gates UI off of — beta-access state + passcode state.
|
||||
# v0.10.0: surface a single `has_passcode` flag so the §6.2
|
||||
# settings tab can render "Set passcode" vs. "Change/Remove
|
||||
# passcode" without a second round trip. The set-at timestamp
|
||||
# rides along for the same reason. The hash itself is never
|
||||
# exposed.
|
||||
row = db.conn().execute(
|
||||
"SELECT first_name, last_name, beta_request_reason, "
|
||||
"passcode_hash, passcode_set_at "
|
||||
"FROM users WHERE id = ?",
|
||||
"SELECT passcode_hash, passcode_set_at FROM users WHERE id = ?",
|
||||
(user.user_id,),
|
||||
).fetchone()
|
||||
first_name = (row["first_name"] if row else None) or ""
|
||||
last_name = (row["last_name"] if row else None) or ""
|
||||
beta_request_reason = (row["beta_request_reason"] if row else None) or ""
|
||||
# "Needs profile" iff the user is pending AND hasn't yet
|
||||
# filed their beta-request capture. Granted users never see
|
||||
# the capture prompt; pending users who already filed see
|
||||
# the /beta-pending page without the capture form.
|
||||
needs_profile = (
|
||||
user.permission_state == "pending"
|
||||
and not first_name
|
||||
and not last_name
|
||||
and not beta_request_reason
|
||||
)
|
||||
has_passcode = bool(row and row["passcode_hash"])
|
||||
passcode_set_at = row["passcode_set_at"] if (row and has_passcode) else None
|
||||
return {
|
||||
@@ -176,82 +140,11 @@ def make_router(
|
||||
"email": user.email,
|
||||
"avatar_url": user.avatar_url,
|
||||
"role": user.role,
|
||||
"permission_state": user.permission_state,
|
||||
"first_name": first_name,
|
||||
"last_name": last_name,
|
||||
"beta_request_reason": beta_request_reason,
|
||||
"needs_profile": needs_profile,
|
||||
"has_passcode": has_passcode,
|
||||
"passcode_set_at": passcode_set_at,
|
||||
},
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# v0.8.0: /api/auth/me/beta-request — first-OTC profile capture
|
||||
# (roadmap item #6). Lands first name, last name, and the free-
|
||||
# text "why I should be included in the beta" on the signed-in
|
||||
# user's row. Idempotent for the same already-pending user;
|
||||
# refuses to overwrite a row that's already granted (so a
|
||||
# bored already-granted user can't accidentally re-submit the
|
||||
# form and clobber the admin's audit trail). Uses
|
||||
# `require_user` rather than `require_contributor` because
|
||||
# `require_contributor` already enforces `permission_state =
|
||||
# 'granted'` and would refuse a pending user; the whole point
|
||||
# of this endpoint is to register the request _from_ a pending
|
||||
# user.
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.post("/api/auth/me/beta-request")
|
||||
async def submit_beta_request(body: BetaRequestBody, request: Request) -> dict[str, Any]:
|
||||
user = auth.require_user(request)
|
||||
row = db.conn().execute(
|
||||
"SELECT permission_state, first_name, last_name, beta_request_reason FROM users WHERE id = ?",
|
||||
(user.user_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
# Defensive — the session pointed at a deleted row.
|
||||
raise HTTPException(404, "User not found")
|
||||
# Granted users have no business filing a beta request.
|
||||
# 'revoked' likewise — the request flow is for fresh users
|
||||
# only. Both shapes refuse with 409 (conflict) so the client
|
||||
# can distinguish "you already have access" from
|
||||
# "your access was revoked".
|
||||
if row["permission_state"] == "granted":
|
||||
raise HTTPException(409, "Your account is already granted access")
|
||||
if row["permission_state"] == "revoked":
|
||||
raise HTTPException(409, "Your account's access has been revoked")
|
||||
# Re-submission from a pending user updates the row — the
|
||||
# admin sees the latest text rather than a stale draft.
|
||||
# The state stays 'pending'; only an admin can flip it.
|
||||
db.conn().execute(
|
||||
"""
|
||||
UPDATE users
|
||||
SET first_name = ?,
|
||||
last_name = ?,
|
||||
beta_request_reason = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(
|
||||
body.first_name.strip(),
|
||||
body.last_name.strip(),
|
||||
body.beta_request_reason.strip(),
|
||||
user.user_id,
|
||||
),
|
||||
)
|
||||
# v0.9.0 (roadmap item #7): notify every admin/owner of the
|
||||
# fresh request. Only the first submission is the
|
||||
# "newly-pending" gesture — re-submits from the same user
|
||||
# would otherwise carpet the admin inbox. We fire only when
|
||||
# this is the row's first time getting all three fields
|
||||
# populated (the prior row carried at least one NULL).
|
||||
prior = row # captured before the UPDATE above
|
||||
was_already_complete = bool(
|
||||
prior["first_name"] and prior["last_name"] and prior["beta_request_reason"]
|
||||
)
|
||||
if not was_already_complete:
|
||||
notify.fan_out_new_beta_request(requester_user_id=user.user_id)
|
||||
return {"ok": True}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# §7: the catalog
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
+4
-117
@@ -50,16 +50,6 @@ class MuteBody(BaseModel):
|
||||
muted: bool
|
||||
|
||||
|
||||
class PermissionStateBody(BaseModel):
|
||||
# v0.9.0: the admin flip from the user-management page (roadmap
|
||||
# item #7). `pending` is not surfaceable from the admin UI —
|
||||
# only the OTC verify path lands a row in `pending` — but we
|
||||
# accept it in the pattern in case a future restore-to-queue
|
||||
# gesture wants to re-pend a granted user; today the UI only
|
||||
# exposes `granted` and `revoked`.
|
||||
state: str = Field(pattern="^(pending|granted|revoked)$")
|
||||
|
||||
|
||||
class AllowlistAddBody(BaseModel):
|
||||
email: str = Field(min_length=3, max_length=320)
|
||||
note: str | None = Field(default=None, max_length=200)
|
||||
@@ -78,41 +68,13 @@ def make_router(config: Config) -> APIRouter:
|
||||
|
||||
@router.get("/api/admin/users")
|
||||
async def list_users(request: Request) -> dict[str, Any]:
|
||||
"""v0.9.0: the user-management surface (roadmap item #7).
|
||||
|
||||
The listing carries every column the admin queue needs to triage
|
||||
pending beta-access requests alongside the existing role/mute
|
||||
affordances. Sort order surfaces pending requests first (so the
|
||||
admin lands on the inbox shape), then granted, then revoked;
|
||||
within a state, ownership/role and recency are the tiebreakers
|
||||
so the legacy ordering (owner first, then admin, then by name)
|
||||
is preserved inside the granted bucket.
|
||||
|
||||
`permission_decided_by_login` joins the deciding admin row so
|
||||
the UI can render "granted by @ben" without a second round-trip.
|
||||
"""
|
||||
auth.require_admin(request)
|
||||
rows = db.conn().execute(
|
||||
"""
|
||||
SELECT u.id, u.gitea_login, u.display_name, u.email, u.role, u.muted,
|
||||
u.created_at, u.last_seen_at,
|
||||
u.permission_state, u.first_name, u.last_name,
|
||||
u.beta_request_reason,
|
||||
u.permission_decided_by, u.permission_decided_at,
|
||||
d.gitea_login AS decided_by_login,
|
||||
d.display_name AS decided_by_display
|
||||
FROM users u
|
||||
LEFT JOIN users d ON d.id = u.permission_decided_by
|
||||
ORDER BY
|
||||
CASE u.permission_state
|
||||
WHEN 'pending' THEN 0
|
||||
WHEN 'granted' THEN 1
|
||||
WHEN 'revoked' THEN 2
|
||||
ELSE 3
|
||||
END,
|
||||
u.role = 'owner' DESC, u.role = 'admin' DESC,
|
||||
COALESCE(u.last_seen_at, u.created_at) DESC,
|
||||
u.display_name COLLATE NOCASE
|
||||
SELECT id, gitea_login, display_name, email, role, muted,
|
||||
created_at, last_seen_at
|
||||
FROM users
|
||||
ORDER BY role = 'owner' DESC, role = 'admin' DESC, display_name COLLATE NOCASE
|
||||
"""
|
||||
).fetchall()
|
||||
return {
|
||||
@@ -126,13 +88,6 @@ def make_router(config: Config) -> APIRouter:
|
||||
"muted": bool(r["muted"]),
|
||||
"created_at": r["created_at"],
|
||||
"last_seen_at": r["last_seen_at"],
|
||||
"permission_state": r["permission_state"] or "granted",
|
||||
"first_name": r["first_name"] or "",
|
||||
"last_name": r["last_name"] or "",
|
||||
"beta_request_reason": r["beta_request_reason"] or "",
|
||||
"permission_decided_at": r["permission_decided_at"],
|
||||
"permission_decided_by_login": r["decided_by_login"],
|
||||
"permission_decided_by_display": r["decided_by_display"],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
@@ -181,74 +136,6 @@ def make_router(config: Config) -> APIRouter:
|
||||
)
|
||||
return {"ok": True, "role": body.role, "changed": True}
|
||||
|
||||
# ----- Permission state (§6.1, v0.9.0 roadmap item #7) -----
|
||||
|
||||
@router.post("/api/admin/users/{user_id}/permission")
|
||||
async def set_permission(user_id: int, body: PermissionStateBody, request: Request) -> dict[str, Any]:
|
||||
"""Flip a user's `permission_state` between pending/granted/revoked.
|
||||
|
||||
v0.8.0 wired the column shape but shipped no admin UI for it —
|
||||
the grant gesture was a manual `UPDATE users` against the DB.
|
||||
v0.9.0 (roadmap item #7) lands the admin user-management page;
|
||||
this endpoint is its single write surface.
|
||||
|
||||
Audit shape: every flip writes a `permission_events` row with
|
||||
event_kind in {'permission_granted', 'permission_revoked',
|
||||
'permission_repended'} so §6.5's log carries the change. The
|
||||
`permission_decided_by` / `permission_decided_at` columns on
|
||||
the user row are co-stamped so the user listing can render
|
||||
"granted by @ben at <date>" without a second join through
|
||||
the audit table.
|
||||
|
||||
Refuses with 422 if the admin tries to flip their own row
|
||||
(no self-grant / self-revoke; symmetric to set_mute's
|
||||
self-mute refusal and set_role's self-downgrade refusal).
|
||||
"""
|
||||
viewer = auth.require_admin(request)
|
||||
target = db.conn().execute(
|
||||
"SELECT id, role, permission_state FROM users WHERE id = ?",
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
if target is None:
|
||||
raise HTTPException(404, "User not found")
|
||||
if target["id"] == viewer.user_id:
|
||||
raise HTTPException(422, "You cannot change your own permission state")
|
||||
|
||||
before = target["permission_state"] or "granted"
|
||||
after = body.state
|
||||
if before == after:
|
||||
return {"ok": True, "permission_state": after, "changed": False}
|
||||
|
||||
db.conn().execute(
|
||||
"""
|
||||
UPDATE users
|
||||
SET permission_state = ?,
|
||||
permission_decided_by = ?,
|
||||
permission_decided_at = datetime('now')
|
||||
WHERE id = ?
|
||||
""",
|
||||
(after, viewer.user_id, user_id),
|
||||
)
|
||||
event_kind = {
|
||||
"granted": "permission_granted",
|
||||
"revoked": "permission_revoked",
|
||||
"pending": "permission_repended",
|
||||
}[after]
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO permission_events
|
||||
(actor_user_id, subject_user_id, event_kind, details)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
viewer.user_id,
|
||||
user_id,
|
||||
event_kind,
|
||||
json.dumps({"before": before, "after": after}),
|
||||
),
|
||||
)
|
||||
return {"ok": True, "permission_state": after, "changed": True}
|
||||
|
||||
# ----- Write-mute (§6.2) -----
|
||||
|
||||
@router.post("/api/admin/users/{user_id}/mute")
|
||||
|
||||
+4
-60
@@ -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,7 +188,7 @@ def current_user(request: Request) -> SessionUser | None:
|
||||
# Re-read the role from the database every request so role changes
|
||||
# take effect on the next API call without forcing a logout.
|
||||
row = db.conn().execute(
|
||||
"SELECT id, gitea_id, gitea_login, email, display_name, avatar_url, role, 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:
|
||||
@@ -229,11 +199,6 @@ def current_user(request: Request) -> SessionUser | None:
|
||||
# of which sign-in path the row came from. The DB remains the
|
||||
# source of truth for "is this an OAuth-linked user" (gitea_id IS
|
||||
# NOT NULL); the in-memory SessionUser is the per-request handle.
|
||||
# v0.8.0: permission_state comes off the row directly. A NULL
|
||||
# column value (shouldn't happen under the migration's
|
||||
# NOT NULL DEFAULT, but be defensive) reads as 'granted' so the
|
||||
# gate fails open for grandfathered surfaces rather than locking
|
||||
# everyone out on a malformed row.
|
||||
return SessionUser(
|
||||
user_id=row["id"],
|
||||
gitea_id=row["gitea_id"] or 0,
|
||||
@@ -242,7 +207,6 @@ def current_user(request: Request) -> SessionUser | None:
|
||||
email=row["email"] or "",
|
||||
avatar_url=row["avatar_url"] or "",
|
||||
role=row["role"],
|
||||
permission_state=row["permission_state"] or "granted",
|
||||
)
|
||||
|
||||
|
||||
@@ -254,31 +218,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
|
||||
|
||||
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
"""User-facing docs source.
|
||||
|
||||
Mirrors `philosophy.py` shape. Serves `DOCS.md` from the repo root —
|
||||
the framework's plain-prose user guide to roles, contribution flow,
|
||||
and notification surfaces, distinct from the binding `SPEC.md`. Read
|
||||
from disk on first call and cached in-process; the periodic
|
||||
reconciler can call `refresh()` to pick up out-of-band edits.
|
||||
|
||||
`DOCS_PATH` overrides the default location if a deployment hosts the
|
||||
file elsewhere (a meta-repo working-tree clone, a sync target, etc.).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_DEFAULT_PATH = Path(__file__).resolve().parents[2] / "DOCS.md"
|
||||
|
||||
_lock = threading.Lock()
|
||||
_cache: dict | None = None
|
||||
|
||||
|
||||
def _resolved_path() -> Path:
|
||||
override = os.environ.get("DOCS_PATH", "").strip()
|
||||
if override:
|
||||
return Path(override).expanduser().resolve()
|
||||
return _DEFAULT_PATH
|
||||
|
||||
|
||||
def load(force: bool = False) -> dict:
|
||||
"""Return the cached `{body, path, mtime}` payload, reading from disk
|
||||
on first call or when `force=True`.
|
||||
"""
|
||||
global _cache
|
||||
with _lock:
|
||||
if _cache is not None and not force:
|
||||
return _cache
|
||||
path = _resolved_path()
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
mtime = path.stat().st_mtime
|
||||
except FileNotFoundError:
|
||||
log.warning("DOCS.md not found at %s — serving placeholder", path)
|
||||
text = (
|
||||
"# DOCS.md not found\n\n"
|
||||
"The deployment is missing its user guide. Set "
|
||||
"DOCS_PATH or place DOCS.md at the project root."
|
||||
)
|
||||
mtime = 0.0
|
||||
_cache = {"body": text, "path": str(path), "mtime": mtime}
|
||||
return _cache
|
||||
|
||||
|
||||
def refresh() -> dict:
|
||||
"""Force-reread from disk. Returns the new payload."""
|
||||
return load(force=True)
|
||||
@@ -139,10 +139,6 @@ _EVENT_TO_CATEGORY: dict[str, str] = {
|
||||
"graduation_complete": "personal-direct",
|
||||
"super_draft_graduation_ready": "admin-actionable",
|
||||
"claim_opened": "structural",
|
||||
# v0.9.0: roadmap item #7. A fresh beta-access request lands as
|
||||
# an admin-actionable signal so it consults `email_admin_actionable`
|
||||
# and reaches owners/admins only.
|
||||
"new_beta_request": "admin-actionable",
|
||||
}
|
||||
|
||||
|
||||
@@ -289,13 +285,6 @@ def _deep_link(payload: dict, cfg: EmailConfig) -> str:
|
||||
slug = payload.get("rfc_slug")
|
||||
pr = payload.get("pr_number")
|
||||
branch = payload.get("branch_name")
|
||||
event_kind = payload.get("event_kind")
|
||||
# v0.9.0: framework-scoped admin signals link to the admin
|
||||
# surface, not /rfc/... The `new_beta_request` event is the
|
||||
# canonical example; future framework-scoped admin events
|
||||
# may reuse the same branch.
|
||||
if event_kind == "new_beta_request":
|
||||
return f"{cfg.app_url}/admin/users"
|
||||
if slug and pr:
|
||||
return f"{cfg.app_url}/rfc/{slug}/pr/{pr}"
|
||||
if slug and branch:
|
||||
|
||||
@@ -182,26 +182,6 @@ def _oauth_router(config) -> APIRouter:
|
||||
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": {
|
||||
@@ -209,9 +189,7 @@ def _oauth_router(config) -> APIRouter:
|
||||
"display_name": result.user.display_name,
|
||||
"email": result.user.email,
|
||||
"role": result.user.role,
|
||||
"permission_state": result.user.permission_state,
|
||||
},
|
||||
"needs_profile": needs_profile,
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@@ -64,7 +64,6 @@ log = logging.getLogger(__name__)
|
||||
CATEGORY_PERSONAL = "personal-direct"
|
||||
CATEGORY_STRUCTURAL = "structural"
|
||||
CATEGORY_CHURN = "churn"
|
||||
CATEGORY_ADMIN_ACTIONABLE = "admin-actionable"
|
||||
|
||||
# Action kinds whose actor's first interaction with a slug triggers
|
||||
# auto-watch per §15.6. The substantive-gesture list in the spec is
|
||||
@@ -209,67 +208,6 @@ def fan_out_from_action(
|
||||
)
|
||||
|
||||
|
||||
def fan_out_new_beta_request(
|
||||
*,
|
||||
requester_user_id: int,
|
||||
) -> None:
|
||||
"""v0.9.0 (roadmap item #7): announce a fresh beta-access request to
|
||||
every admin/owner.
|
||||
|
||||
Called from `POST /api/auth/me/beta-request` after the row's
|
||||
first/last/why fields are populated. Fan-out shape mirrors the §15
|
||||
chokepoint contract: one row per recipient, written via `_emit_one`
|
||||
so the SSE broadcast + email dispatch run through the same surface
|
||||
every other notification uses. The event has no rfc_slug (it is
|
||||
framework-scoped, not RFC-scoped); the deep-link payload points
|
||||
`/admin/users` instead of `/rfc/<slug>`.
|
||||
|
||||
Actor is the requester per §15.9 (the underlying user, never the
|
||||
bot). Category is `admin-actionable` so the §15.4 email gate
|
||||
consults `email_admin_actionable` (owners/admins-only by
|
||||
construction) and the digest exclusion rules treat it identically
|
||||
to other admin-actionable signals (graduation_ready et al).
|
||||
|
||||
Recipients are owners + admins minus the requester themselves
|
||||
(a self-promotion shouldn't reach the requester's own inbox). The
|
||||
requester is never in the role set in practice — the endpoint
|
||||
refuses 'granted'/'revoked' callers and a fresh OTC user lands
|
||||
`contributor`+`pending` — but we filter regardless so the call
|
||||
is robust to future changes in the auth gate.
|
||||
"""
|
||||
requester = db.conn().execute(
|
||||
"SELECT first_name, last_name, email, display_name FROM users WHERE id = ?",
|
||||
(requester_user_id,),
|
||||
).fetchone()
|
||||
if requester is None:
|
||||
return
|
||||
first = (requester["first_name"] or "").strip()
|
||||
last = (requester["last_name"] or "").strip()
|
||||
email = requester["email"] or ""
|
||||
display = requester["display_name"] or email or "a new user"
|
||||
full_name = (f"{first} {last}").strip() or display
|
||||
details = {
|
||||
"requester_user_id": requester_user_id,
|
||||
"requester_first_name": first,
|
||||
"requester_last_name": last,
|
||||
"requester_email": email,
|
||||
"requester_display": full_name,
|
||||
}
|
||||
for recipient_id in _admin_user_ids():
|
||||
if recipient_id == requester_user_id:
|
||||
continue
|
||||
_emit_one(
|
||||
recipient_user_id=recipient_id,
|
||||
event_kind="new_beta_request",
|
||||
category=CATEGORY_ADMIN_ACTIONABLE,
|
||||
actor_user_id=requester_user_id,
|
||||
rfc_slug=None,
|
||||
branch_name=None,
|
||||
pr_number=None,
|
||||
details=details,
|
||||
)
|
||||
|
||||
|
||||
def fan_out_chat_message(
|
||||
*,
|
||||
actor_user_id: int,
|
||||
@@ -769,16 +707,6 @@ def render_summary(event_kind: str, actor_display: str | None, rfc_title: str |
|
||||
return f"{actor} began graduating {title}."
|
||||
if event_kind == "pr_conflict_with_main":
|
||||
return f"{actor} started a resolution branch on {title}."
|
||||
if event_kind == "new_beta_request":
|
||||
# v0.9.0: framework-scoped, not RFC-scoped. The actor (the
|
||||
# requester) and the captured full name + email read as
|
||||
# one self-contained sentence; the inbox row and the email
|
||||
# body share this text per §15.4.
|
||||
full_name = extras.get("requester_display") or actor
|
||||
email_addr = extras.get("requester_email") or ""
|
||||
if email_addr:
|
||||
return f"New beta-access request from {full_name} ({email_addr})."
|
||||
return f"New beta-access request from {full_name}."
|
||||
return f"{event_kind} on {title}"
|
||||
|
||||
|
||||
|
||||
+45
-52
@@ -1,4 +1,4 @@
|
||||
"""§6.2 / v0.7.0 / v0.8.0: email + one-time-code sign-in.
|
||||
"""§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
|
||||
@@ -25,25 +25,14 @@ The shape:
|
||||
`users` row already carries `email` (case-insensitive), it is
|
||||
reused — `gitea_id` is left alone so a grandfathered OAuth-era
|
||||
user keeps the linker intact. Otherwise a fresh contributor
|
||||
row is provisioned with `gitea_id = NULL`, `gitea_login = NULL`,
|
||||
and `permission_state = 'pending'` (v0.8.0 — see below).
|
||||
row is provisioned with `gitea_id = NULL`, `gitea_login = NULL`.
|
||||
|
||||
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.
|
||||
The endpoints in `main.py` thin-wrap this module. The allowlist gate
|
||||
from v0.3.0 is consulted at request time — if `allowed_emails` is
|
||||
populated and the requested address isn't on it, the request returns
|
||||
202 as usual but no email is sent. This intentionally does not leak
|
||||
allowlist state to the caller; the §19.2 candidate for v0.8.0
|
||||
replaces this gate with an admin-grant flow.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -55,7 +44,7 @@ from dataclasses import dataclass
|
||||
import bcrypt
|
||||
|
||||
from . import db
|
||||
from .auth import SessionUser
|
||||
from .auth import SessionUser, allowlist_is_active
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -112,15 +101,25 @@ def _check_code(code: str, code_hash: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Allowlist gate — shared with the OAuth flow.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _allowlist_admits(email: str) -> bool:
|
||||
"""The same allowlist v0.3.0 introduced for OAuth, applied to OTC
|
||||
requests. If the allowlist is populated and the email is not on it,
|
||||
we still respond 202 to the caller, but no code is sent."""
|
||||
if not allowlist_is_active():
|
||||
return True
|
||||
row = db.conn().execute(
|
||||
"SELECT 1 FROM allowed_emails WHERE email = ? LIMIT 1", (email,)
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request path
|
||||
#
|
||||
# v0.8.0: the allowlist gate from v0.7.0 / v0.3.0 is removed here. Any
|
||||
# valid email receives a code; the admission gate moved to
|
||||
# `permission_state` on the freshly-provisioned `users` row (see
|
||||
# `provision_or_link_user`). The `allowed_emails` table stays in the
|
||||
# schema (admin UI from v0.3.0 still manages it); v0.9.0's admin
|
||||
# user-management page will collapse the two surfaces.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -128,16 +127,14 @@ def _check_code(code: str, code_hash: str) -> bool:
|
||||
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.
|
||||
`code` is None whenever no code was generated — either because the
|
||||
allowlist denied the email or because the cooldown window blocked
|
||||
the request. The caller (the API endpoint) does not surface this
|
||||
distinction to the user; it returns 202 either way.
|
||||
"""
|
||||
sent: bool
|
||||
code: str | None
|
||||
reason: str # 'sent' | 'cooldown' | 'invalid'
|
||||
reason: str # 'sent' | 'allowlist' | 'cooldown' | 'invalid'
|
||||
|
||||
|
||||
def request_code(email: str) -> RequestOutcome:
|
||||
@@ -163,6 +160,13 @@ def request_code(email: str) -> RequestOutcome:
|
||||
if row is not None:
|
||||
return RequestOutcome(sent=False, code=None, reason="cooldown")
|
||||
|
||||
# Allowlist: silently drop the send if the email isn't on the list.
|
||||
# The row is not written either — there's nothing for verify to
|
||||
# match against, so the user-facing experience is "I never got an
|
||||
# email", which is the intended shape for the private-beta gate.
|
||||
if not _allowlist_admits(email):
|
||||
return RequestOutcome(sent=False, code=None, reason="allowlist")
|
||||
|
||||
# Invalidate prior unused codes for this email. A re-request is
|
||||
# always for the most recent code; older codes are dead.
|
||||
db.conn().execute(
|
||||
@@ -271,16 +275,12 @@ def provision_or_link_user(email: str) -> SessionUser:
|
||||
1. An existing row whose email equals (case-insensitive) the
|
||||
requested email — the OAuth-era user is grandfathered in via
|
||||
this path. `gitea_id` is preserved so a future OAuth round
|
||||
trip still resolves the same row. `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.
|
||||
trip still resolves the same row.
|
||||
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.
|
||||
`gitea_login = NULL`. The display name defaults to the local
|
||||
part of the email (everything before the `@`) — users can
|
||||
rename later via the §19.2 first-OTC profile-capture flow
|
||||
that v0.8.0 introduces.
|
||||
|
||||
The §6.1 owner-zero bootstrap still applies: if the email matches
|
||||
the configured `OWNER_GITEA_LOGIN`-derived owner identity, the row
|
||||
@@ -307,19 +307,13 @@ def provision_or_link_user(email: str) -> SessionUser:
|
||||
email=existing["email"] or email,
|
||||
avatar_url=existing["avatar_url"] or "",
|
||||
role=existing["role"],
|
||||
permission_state=existing["permission_state"] or "granted",
|
||||
)
|
||||
|
||||
display = email.split("@", 1)[0] or email
|
||||
# v0.8.0: 'pending' is the explicit insert value; the migration
|
||||
# default of 'granted' is what passes grandfathered users
|
||||
# through. A fresh OTC user lands in 'pending' regardless of
|
||||
# what the migration default says, so the gate engages reliably
|
||||
# even if a future migration changes the default.
|
||||
cur = db.conn().execute(
|
||||
"""
|
||||
INSERT INTO users (gitea_id, gitea_login, email, display_name, avatar_url, role, permission_state)
|
||||
VALUES (NULL, NULL, ?, ?, '', 'contributor', 'pending')
|
||||
INSERT INTO users (gitea_id, gitea_login, email, display_name, avatar_url, role)
|
||||
VALUES (NULL, NULL, ?, ?, '', 'contributor')
|
||||
""",
|
||||
(email, display),
|
||||
)
|
||||
@@ -332,5 +326,4 @@ def provision_or_link_user(email: str) -> SessionUser:
|
||||
email=email,
|
||||
avatar_url="",
|
||||
role="contributor",
|
||||
permission_state="pending",
|
||||
)
|
||||
|
||||
@@ -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,425 +0,0 @@
|
||||
"""End-to-end integration tests for v0.9.0's admin user-management page
|
||||
and new-beta-request notifications (roadmap item #7, §6.1 / §15).
|
||||
|
||||
The release lands two halves of the same surface:
|
||||
|
||||
* **Admin notification on new beta request.** When a pending user
|
||||
submits `POST /api/auth/me/beta-request`, every owner/admin
|
||||
receives a `new_beta_request` notification (the §15 substrate
|
||||
insert lands the row; the §15.4 email path dispatches subject to
|
||||
the recipient's `email_admin_actionable` toggle).
|
||||
|
||||
* **Admin user-management surface** at `/admin/users`. The
|
||||
`GET /api/admin/users` listing carries every user with their
|
||||
permission_state, profile fields, sign-up reason, and decision
|
||||
audit. The new `POST /api/admin/users/<id>/permission` endpoint
|
||||
flips the column and writes a `permission_events` row.
|
||||
|
||||
The tests prove:
|
||||
|
||||
* The first beta-request submission fans a `new_beta_request`
|
||||
row out to every admin/owner (and not to the requester
|
||||
themselves). The row carries the captured profile in
|
||||
`payload.extras`.
|
||||
* Re-submitting the form from the same pending user doesn't
|
||||
re-fan (we only notify on the row's first complete state).
|
||||
* `GET /api/admin/users` carries the v0.9.0 columns
|
||||
(permission_state, first/last/reason, decided_by).
|
||||
* `POST /api/admin/users/<id>/permission` flips the state,
|
||||
stamps decided_by/at, and writes a `permission_events` row.
|
||||
* The endpoint refuses self-flip (422) and refuses non-admin
|
||||
callers (403).
|
||||
* The endpoint accepts only the three valid states (422 on
|
||||
anything else).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401 — fixtures land via import
|
||||
FakeGitea,
|
||||
app_with_fake_gitea,
|
||||
provision_user_row,
|
||||
sign_in_as,
|
||||
tmp_env,
|
||||
)
|
||||
|
||||
|
||||
def _reset_outbound():
|
||||
from app import email as email_mod
|
||||
email_mod.reset_sent_envelopes()
|
||||
|
||||
|
||||
def _outbound_otc_codes(to_address: str | None = None) -> list[str]:
|
||||
from app import email as email_mod
|
||||
out = []
|
||||
for env in email_mod.sent_envelopes():
|
||||
if env.get("kind") != "otc":
|
||||
continue
|
||||
if to_address is not None and env["to"] != to_address:
|
||||
continue
|
||||
for line in env["body"].splitlines():
|
||||
tok = line.strip()
|
||||
if tok.isdigit() and len(tok) == 6:
|
||||
out.append(tok)
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def _provision_pending_user(client, email: str) -> int:
|
||||
"""Sign in a fresh OTC user (lands `pending`) and return their user_id."""
|
||||
from app import db
|
||||
_reset_outbound()
|
||||
client.post("/auth/otc/request", json={"email": email})
|
||||
code = _outbound_otc_codes(email)[-1]
|
||||
client.post("/auth/otc/verify", json={"email": email, "code": code})
|
||||
row = db.conn().execute(
|
||||
"SELECT id FROM users WHERE email = ? COLLATE NOCASE", (email,)
|
||||
).fetchone()
|
||||
return row["id"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Admin notification on beta-request submission
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_beta_request_submission_notifies_every_admin(app_with_fake_gitea):
|
||||
"""First-time submission of a beta-request fans a notification out
|
||||
to every owner and admin. The requester themselves never receives
|
||||
a row (filtered out by user_id even if they happened to be in the
|
||||
admin set, which they aren't in practice — fresh OTC users are
|
||||
`contributor`+`pending`)."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
# Provision two admins and one owner so the fan-out has multiple
|
||||
# targets. The OWNER_GITEA_LOGIN-derived ownership doesn't fire
|
||||
# here (no OAuth round-trip in this path); we seed the role
|
||||
# directly.
|
||||
provision_user_row(user_id=10, login="ownerzero", role="owner")
|
||||
provision_user_row(user_id=11, login="admin_one", role="admin")
|
||||
provision_user_row(user_id=12, login="admin_two", role="admin")
|
||||
provision_user_row(user_id=13, login="contrib_one", role="contributor")
|
||||
|
||||
# Sign in a fresh OTC user → permission_state='pending'.
|
||||
requester_id = _provision_pending_user(client, "newbie@example.com")
|
||||
|
||||
# Capture-form submit.
|
||||
r = client.post(
|
||||
"/api/auth/me/beta-request",
|
||||
json={
|
||||
"first_name": "Newt",
|
||||
"last_name": "Newcomer",
|
||||
"beta_request_reason": "I want to write the Human RFC.",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# Every owner + admin gets a `new_beta_request` notification.
|
||||
# The contributor (id=13) does not. The requester (whoever id
|
||||
# they got) does not.
|
||||
rows = db.conn().execute(
|
||||
"""
|
||||
SELECT recipient_user_id, event_kind, actor_user_id, payload
|
||||
FROM notifications
|
||||
WHERE event_kind = 'new_beta_request'
|
||||
"""
|
||||
).fetchall()
|
||||
recipients = sorted(r["recipient_user_id"] for r in rows)
|
||||
assert recipients == [10, 11, 12], f"unexpected recipients: {recipients}"
|
||||
# Actor is the requester (§15.9: never the bot).
|
||||
for r in rows:
|
||||
assert r["actor_user_id"] == requester_id
|
||||
import json as _json
|
||||
extras = _json.loads(r["payload"])
|
||||
assert extras["requester_first_name"] == "Newt"
|
||||
assert extras["requester_last_name"] == "Newcomer"
|
||||
assert extras["requester_email"] == "newbie@example.com"
|
||||
|
||||
|
||||
def test_beta_request_resubmit_does_not_re_notify(app_with_fake_gitea):
|
||||
"""Once a user has completed the capture form, re-submitting it
|
||||
(the endpoint is idempotent for pending users) must not re-fan a
|
||||
fresh notification to every admin — that would carpet-bomb the
|
||||
inbox on every typo correction."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=20, login="adminzero", role="admin")
|
||||
_provision_pending_user(client, "carpet@example.com")
|
||||
|
||||
body = {
|
||||
"first_name": "Carpet",
|
||||
"last_name": "Bomb",
|
||||
"beta_request_reason": "first draft",
|
||||
}
|
||||
r1 = client.post("/api/auth/me/beta-request", json=body)
|
||||
assert r1.status_code == 200
|
||||
|
||||
# Re-submit with edited reason — endpoint accepts (idempotent
|
||||
# update), but the admin inbox stays at one row.
|
||||
body2 = dict(body, beta_request_reason="cleaner final draft")
|
||||
r2 = client.post("/api/auth/me/beta-request", json=body2)
|
||||
assert r2.status_code == 200
|
||||
|
||||
rows = db.conn().execute(
|
||||
"SELECT COUNT(*) AS n FROM notifications WHERE event_kind = 'new_beta_request'"
|
||||
).fetchone()
|
||||
assert rows["n"] == 1
|
||||
|
||||
|
||||
def test_beta_request_notification_is_admin_actionable_category(app_with_fake_gitea):
|
||||
"""The §15.4 category mapping must route `new_beta_request` to the
|
||||
admin-actionable bucket so the email gate consults
|
||||
`email_admin_actionable` (and skips for non-admin recipients).
|
||||
"""
|
||||
from app import email as email_mod
|
||||
|
||||
assert email_mod.category_for("new_beta_request", "structural") == "admin-actionable"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /api/admin/users — listing carries the v0.9.0 columns
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_admin_users_listing_carries_permission_columns(app_with_fake_gitea):
|
||||
"""The Users tab consumes this shape — confirm every required
|
||||
column is on the response."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
# Seed an admin and a pending user with all the v0.8.0 columns
|
||||
# populated. Direct-DB insert avoids the OTC dance (which would
|
||||
# overwrite the cookie); the test above proves the capture
|
||||
# pathway end-to-end and this one just exercises the listing
|
||||
# surface's shape.
|
||||
provision_user_row(user_id=30, login="ben", role="owner")
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO users (id, gitea_id, gitea_login, email,
|
||||
display_name, avatar_url, role,
|
||||
permission_state, first_name, last_name,
|
||||
beta_request_reason)
|
||||
VALUES (31, NULL, NULL, 'pendinguser@example.com',
|
||||
'pendinguser', '', 'contributor',
|
||||
'pending', 'Penn', 'Ding', 'I want in.')
|
||||
"""
|
||||
)
|
||||
|
||||
sign_in_as(
|
||||
client, user_id=30, gitea_login="ben",
|
||||
display_name="Ben", role="owner",
|
||||
)
|
||||
|
||||
r = client.get("/api/admin/users")
|
||||
assert r.status_code == 200
|
||||
items = r.json()["items"]
|
||||
assert isinstance(items, list)
|
||||
pending = next(
|
||||
(i for i in items if i["email"] == "pendinguser@example.com"), None,
|
||||
)
|
||||
assert pending is not None
|
||||
assert pending["permission_state"] == "pending"
|
||||
assert pending["first_name"] == "Penn"
|
||||
assert pending["last_name"] == "Ding"
|
||||
assert pending["beta_request_reason"] == "I want in."
|
||||
assert pending["permission_decided_at"] is None
|
||||
assert pending["permission_decided_by_login"] is None
|
||||
# Pending bucket is listed first (sort order).
|
||||
assert items[0]["permission_state"] == "pending"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /api/admin/users/<id>/permission — the flip endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_permission_flip_grant_promotes_pending_to_granted(app_with_fake_gitea):
|
||||
"""The end-to-end gesture: a fresh OTC user lands pending, an admin
|
||||
flips them to granted via the endpoint, the row reflects the new
|
||||
state + decided_by/at, and a `permission_events` audit row lands."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
# Pending user.
|
||||
pending_id = _provision_pending_user(client, "flip@example.com")
|
||||
|
||||
# Admin acting on them.
|
||||
provision_user_row(user_id=40, login="adminflipper", role="admin")
|
||||
sign_in_as(
|
||||
client, user_id=40, gitea_login="adminflipper",
|
||||
display_name="Admin Flipper", role="admin",
|
||||
)
|
||||
|
||||
r = client.post(
|
||||
f"/api/admin/users/{pending_id}/permission",
|
||||
json={"state": "granted"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["permission_state"] == "granted"
|
||||
assert body["changed"] is True
|
||||
|
||||
# Row reflects the new state + decision stamp.
|
||||
row = db.conn().execute(
|
||||
"SELECT permission_state, permission_decided_by, permission_decided_at "
|
||||
"FROM users WHERE id = ?",
|
||||
(pending_id,),
|
||||
).fetchone()
|
||||
assert row["permission_state"] == "granted"
|
||||
assert row["permission_decided_by"] == 40
|
||||
assert row["permission_decided_at"] is not None
|
||||
|
||||
# Audit row landed in permission_events.
|
||||
events = db.conn().execute(
|
||||
"""
|
||||
SELECT actor_user_id, subject_user_id, event_kind
|
||||
FROM permission_events
|
||||
WHERE event_kind = 'permission_granted'
|
||||
"""
|
||||
).fetchall()
|
||||
assert len(events) == 1
|
||||
assert events[0]["actor_user_id"] == 40
|
||||
assert events[0]["subject_user_id"] == pending_id
|
||||
|
||||
|
||||
def test_permission_flip_revoke_promotes_granted_to_revoked(app_with_fake_gitea):
|
||||
"""Revoke is the symmetric gesture. Used when an account earned a
|
||||
grant then later lost it (§6.1 / `revoked` state)."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=50, login="goner", role="contributor")
|
||||
# Default permission_state is 'granted' via the column default.
|
||||
provision_user_row(user_id=51, login="adminrevoker", role="admin")
|
||||
sign_in_as(
|
||||
client, user_id=51, gitea_login="adminrevoker",
|
||||
display_name="Admin Revoker", role="admin",
|
||||
)
|
||||
|
||||
r = client.post(
|
||||
"/api/admin/users/50/permission",
|
||||
json={"state": "revoked"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
row = db.conn().execute(
|
||||
"SELECT permission_state FROM users WHERE id = 50"
|
||||
).fetchone()
|
||||
assert row["permission_state"] == "revoked"
|
||||
|
||||
events = db.conn().execute(
|
||||
"SELECT event_kind FROM permission_events "
|
||||
"WHERE event_kind = 'permission_revoked' AND subject_user_id = 50"
|
||||
).fetchall()
|
||||
assert len(events) == 1
|
||||
|
||||
|
||||
def test_permission_flip_refuses_self(app_with_fake_gitea):
|
||||
"""Symmetric to set_mute / set_role: an admin can't self-flip.
|
||||
The state-change channel for one's own grant is somebody else's
|
||||
hand."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=60, login="selfflipper", role="admin")
|
||||
sign_in_as(
|
||||
client, user_id=60, gitea_login="selfflipper",
|
||||
display_name="Self Flipper", role="admin",
|
||||
)
|
||||
r = client.post(
|
||||
"/api/admin/users/60/permission",
|
||||
json={"state": "revoked"},
|
||||
)
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
def test_permission_flip_refuses_non_admin(app_with_fake_gitea):
|
||||
"""The endpoint is admin-only (§17 admin/* requires require_admin).
|
||||
A contributor caller is refused 403; an anonymous caller 401."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=70, login="target", role="contributor")
|
||||
provision_user_row(user_id=71, login="contrib", role="contributor")
|
||||
sign_in_as(
|
||||
client, user_id=71, gitea_login="contrib",
|
||||
display_name="Contrib", role="contributor",
|
||||
)
|
||||
r = client.post(
|
||||
"/api/admin/users/70/permission",
|
||||
json={"state": "granted"},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
client.cookies.clear()
|
||||
r = client.post(
|
||||
"/api/admin/users/70/permission",
|
||||
json={"state": "granted"},
|
||||
)
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_permission_flip_refuses_invalid_state(app_with_fake_gitea):
|
||||
"""Pydantic regex pattern refuses anything outside the three
|
||||
canonical states with 422."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=80, login="targetx", role="contributor")
|
||||
provision_user_row(user_id=81, login="adminx", role="admin")
|
||||
sign_in_as(
|
||||
client, user_id=81, gitea_login="adminx",
|
||||
display_name="Admin X", role="admin",
|
||||
)
|
||||
r = client.post(
|
||||
"/api/admin/users/80/permission",
|
||||
json={"state": "banished"},
|
||||
)
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
def test_permission_flip_no_op_when_state_already_matches(app_with_fake_gitea):
|
||||
"""An admin flipping a granted user to granted gets 200 with
|
||||
`changed: false` — no audit row, no decided_at update."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=90, login="alreadygranted", role="contributor")
|
||||
provision_user_row(user_id=91, login="adminN", role="admin")
|
||||
sign_in_as(
|
||||
client, user_id=91, gitea_login="adminN",
|
||||
display_name="Admin N", role="admin",
|
||||
)
|
||||
|
||||
before_events = db.conn().execute(
|
||||
"SELECT COUNT(*) AS n FROM permission_events"
|
||||
).fetchone()["n"]
|
||||
|
||||
r = client.post(
|
||||
"/api/admin/users/90/permission",
|
||||
json={"state": "granted"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["changed"] is False
|
||||
|
||||
after_events = db.conn().execute(
|
||||
"SELECT COUNT(*) AS n FROM permission_events"
|
||||
).fetchone()["n"]
|
||||
assert after_events == before_events
|
||||
@@ -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
|
||||
@@ -13,13 +13,9 @@ sign-in path. The tests prove:
|
||||
* Expired codes refuse with 400.
|
||||
* Already-consumed codes refuse with 400 on re-use.
|
||||
* Wrong codes refuse with 400.
|
||||
* Allowlist gate (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.
|
||||
* Allowlist gate: when `allowed_emails` is populated and the email
|
||||
isn't on it, the response is still 202 (no leak), but no email
|
||||
lands in the outbound buffer and verify finds no matching code.
|
||||
* 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.
|
||||
@@ -205,45 +201,33 @@ def test_otc_request_cooldown_is_per_email_not_global(app_with_fake_gitea):
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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.
|
||||
# Allowlist gate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
def test_otc_request_silently_drops_when_email_not_on_allowlist(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()
|
||||
# Populate the allowlist with one specific email; the v0.7.0
|
||||
# gate would have engaged here.
|
||||
# Populate the allowlist so the gate turns on.
|
||||
db.conn().execute("INSERT INTO allowed_emails (email) VALUES (?)", ("invited@example.com",))
|
||||
|
||||
# The not-on-list email still gets a code under v0.8.0.
|
||||
r = client.post("/auth/otc/request", json={"email": "stranger@example.com"})
|
||||
# Still 202 — the allowlist's state is not leaked to callers.
|
||||
assert r.status_code == 200
|
||||
assert len(_outbound_otc_codes("stranger@example.com")) == 1
|
||||
# But no email was sent, and no row landed in otc_codes.
|
||||
assert _outbound_otc_codes("stranger@example.com") == []
|
||||
row = db.conn().execute(
|
||||
"SELECT 1 FROM otc_codes WHERE email = ?",
|
||||
("stranger@example.com",),
|
||||
).fetchone()
|
||||
assert row is None
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "rfc-app-frontend",
|
||||
"version": "0.9.0",
|
||||
"version": "0.10.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "rfc-app-frontend",
|
||||
"version": "0.9.0",
|
||||
"version": "0.10.0",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "^6.10.3",
|
||||
"@codemirror/lang-markdown": "^6.5.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "rfc-app-frontend",
|
||||
"private": true,
|
||||
"version": "0.9.0",
|
||||
"version": "0.10.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -410,26 +410,6 @@
|
||||
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;
|
||||
}
|
||||
@@ -486,22 +466,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 {
|
||||
@@ -1889,54 +1853,6 @@
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
font-size: 13px; cursor: pointer;
|
||||
}
|
||||
|
||||
/* v0.9.0 — admin user-management surface (roadmap item #7). */
|
||||
.admin-filter-chips {
|
||||
display: flex; gap: 6px; margin-bottom: 16px; flex-wrap: wrap;
|
||||
}
|
||||
.admin-chip {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
background: #fff; border: 1px solid #d1d5db; border-radius: 999px;
|
||||
padding: 4px 12px; font-size: 12px; color: #374151; cursor: pointer;
|
||||
}
|
||||
.admin-chip:hover { background: #f9fafb; }
|
||||
.admin-chip.active {
|
||||
background: #111; color: #fff; border-color: #111;
|
||||
}
|
||||
.admin-chip-count {
|
||||
font-size: 11px; opacity: 0.7;
|
||||
}
|
||||
.admin-users-table td { vertical-align: top; padding-top: 10px; padding-bottom: 10px; }
|
||||
.permission-cell { display: flex; flex-direction: column; gap: 4px; }
|
||||
.permission-actions { display: flex; gap: 6px; }
|
||||
.permission-badge {
|
||||
display: inline-block;
|
||||
font-size: 11px; font-weight: 600;
|
||||
padding: 2px 8px; border-radius: 999px;
|
||||
text-transform: uppercase; letter-spacing: 0.04em;
|
||||
width: max-content;
|
||||
}
|
||||
.permission-badge-pending {
|
||||
background: #fef3c7; color: #92400e;
|
||||
}
|
||||
.permission-badge-granted {
|
||||
background: #dcfce7; color: #166534;
|
||||
}
|
||||
.permission-badge-revoked {
|
||||
background: #fee2e2; color: #991b1b;
|
||||
}
|
||||
.permission-decided { font-size: 11px; }
|
||||
.user-row-reason td {
|
||||
background: #fffbeb; border-top: none !important;
|
||||
padding: 0 16px 12px !important;
|
||||
}
|
||||
.user-reason-block {
|
||||
border-left: 3px solid #f59e0b;
|
||||
padding: 8px 12px; font-size: 13px;
|
||||
background: #fffbeb;
|
||||
}
|
||||
.user-reason-block strong { display: block; margin-bottom: 4px; color: #92400e; }
|
||||
.user-reason-block p { margin: 0; white-space: pre-wrap; color: #374151; }
|
||||
.grad-queue { list-style: none; padding: 0; margin: 8px 0 24px; }
|
||||
.grad-queue li { padding: 8px 0; border-bottom: 1px solid #f3f4f6; }
|
||||
.grad-queue-link { color: #111; text-decoration: none; font-size: 14px; }
|
||||
|
||||
+4
-41
@@ -11,7 +11,6 @@ import Landing from './components/Landing.jsx'
|
||||
import Login from './components/Login.jsx'
|
||||
import BetaPending from './components/BetaPending.jsx'
|
||||
import Philosophy from './components/Philosophy.jsx'
|
||||
import Docs from './components/Docs.jsx'
|
||||
import NotificationSettings from './components/NotificationSettings.jsx'
|
||||
import Admin from './components/Admin.jsx'
|
||||
import ToastHost, { showToast } from './components/ToastHost.jsx'
|
||||
@@ -88,15 +87,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">
|
||||
@@ -112,9 +107,6 @@ export default function App() {
|
||||
<Link to="/philosophy" className="header-about" title="Why this exists (§14)">
|
||||
About
|
||||
</Link>
|
||||
<Link to="/docs" className="header-about" title="User guide">
|
||||
Docs
|
||||
</Link>
|
||||
{viewer && (
|
||||
<Link to="/settings/notifications" className="header-settings" title="Notification settings (§15)">
|
||||
Settings
|
||||
@@ -150,14 +142,12 @@ export default function App() {
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
{isPending && <PendingAccessBanner />}
|
||||
<div className="app-body">
|
||||
<Routes>
|
||||
<Route path="/welcome" element={<Landing />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/beta-pending" element={<BetaPending viewer={viewer} />} />
|
||||
<Route path="/beta-pending" element={<BetaPending />} />
|
||||
<Route path="/philosophy" element={<PhilosophyWithSidebar viewer={viewer} />} />
|
||||
<Route path="/docs" element={<DocsWithSidebar viewer={viewer} />} />
|
||||
{/* §14.5 / §14.6: cookie-consent companions to /philosophy.
|
||||
Available to anonymous and authenticated viewers alike. */}
|
||||
<Route path="/privacy" element={<PolicyShell><Privacy /></PolicyShell>} />
|
||||
@@ -226,14 +216,6 @@ function PhilosophyWithSidebar({ viewer }) {
|
||||
)
|
||||
}
|
||||
|
||||
function DocsWithSidebar({ viewer }) {
|
||||
return (
|
||||
<main className="chrome-pane">
|
||||
<Docs authenticated={!!viewer} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function NotificationSettingsWithSidebar({ viewer }) {
|
||||
return (
|
||||
<main className="chrome-pane">
|
||||
@@ -250,26 +232,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">
|
||||
|
||||
@@ -49,22 +49,6 @@ export async function verifyOtc(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)
|
||||
}
|
||||
|
||||
// ── v0.10.0: user-set passcodes after OTC (§6.2, roadmap item #8) ─────────
|
||||
//
|
||||
// After a successful OTC sign-in, a contributor may set a passcode and
|
||||
@@ -632,10 +616,6 @@ export async function getPhilosophy() {
|
||||
return jsonOrThrow(await fetch('/api/philosophy'))
|
||||
}
|
||||
|
||||
export async function getDocs() {
|
||||
return jsonOrThrow(await fetch('/api/docs'))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Slice 7: admin neighborhood (§17 admin/* + user search for the §15.8 mute
|
||||
// typeahead).
|
||||
@@ -661,19 +641,6 @@ export async function setUserMute(userId, muted) {
|
||||
}))
|
||||
}
|
||||
|
||||
// v0.9.0 — roadmap item #7. Flip a user's permission_state between
|
||||
// 'pending', 'granted', and 'revoked'. The Users tab on the admin
|
||||
// page wires Grant / Revoke buttons against this endpoint; the
|
||||
// returned `changed` flag is false when the requested state already
|
||||
// matched the row.
|
||||
export async function setUserPermission(userId, state) {
|
||||
return jsonOrThrow(await fetch(`/api/admin/users/${userId}/permission`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ state }),
|
||||
}))
|
||||
}
|
||||
|
||||
export async function listAuditLog({ actionKind, actorUserId, rfcSlug, beforeId, limit } = {}) {
|
||||
const params = new URLSearchParams()
|
||||
if (actionKind) params.set('action_kind', actionKind)
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
listAdminUsers,
|
||||
setUserRole,
|
||||
setUserMute,
|
||||
setUserPermission,
|
||||
listAuditLog,
|
||||
listPermissionEvents,
|
||||
listGraduationQueue,
|
||||
@@ -69,26 +68,12 @@ export default function Admin({ viewer }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ── Users + role + write-mute + permission grant/revoke (§6.1 / §6.2) ──────
|
||||
//
|
||||
// v0.9.0 (roadmap item #7) lands the user-management surface. The table
|
||||
// shows every user with their permission_state, sign-up reason (when
|
||||
// pending), role, write-mute, and Grant / Revoke controls. State filter
|
||||
// chips above the table narrow to one bucket — the "Pending" chip is the
|
||||
// admin's daily inbox shape.
|
||||
|
||||
const STATE_CHIPS = [
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'pending', label: 'Pending' },
|
||||
{ value: 'granted', label: 'Granted' },
|
||||
{ value: 'revoked', label: 'Revoked' },
|
||||
]
|
||||
// ── Users + role + write-mute (§6.1 / §6.2) ────────────────────────────────
|
||||
|
||||
function UsersTab() {
|
||||
const [users, setUsers] = useState(null)
|
||||
const [busy, setBusy] = useState({})
|
||||
const [error, setError] = useState(null)
|
||||
const [stateFilter, setStateFilter] = useState('all')
|
||||
|
||||
async function refresh() {
|
||||
setError(null)
|
||||
@@ -128,193 +113,68 @@ function UsersTab() {
|
||||
}
|
||||
}
|
||||
|
||||
async function flipPermission(userId, state) {
|
||||
setBusy(b => ({ ...b, [userId]: true }))
|
||||
setError(null)
|
||||
try {
|
||||
await setUserPermission(userId, state)
|
||||
// Refresh the full row so permission_decided_{at,by_*} update too.
|
||||
await refresh()
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setBusy(b => ({ ...b, [userId]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const c = { all: 0, pending: 0, granted: 0, revoked: 0 }
|
||||
if (users) {
|
||||
c.all = users.length
|
||||
for (const u of users) {
|
||||
const s = u.permission_state || 'granted'
|
||||
if (s in c) c[s] += 1
|
||||
}
|
||||
}
|
||||
return c
|
||||
}, [users])
|
||||
|
||||
if (users == null) return <p className="muted">Loading users…</p>
|
||||
|
||||
const filtered = stateFilter === 'all'
|
||||
? users
|
||||
: users.filter(u => (u.permission_state || 'granted') === stateFilter)
|
||||
|
||||
return (
|
||||
<div className="admin-tab">
|
||||
<header className="admin-tab-header">
|
||||
<h2>Users</h2>
|
||||
<p className="muted">
|
||||
The pending bucket is the beta-access review queue (§6.1 /
|
||||
v0.8.0). Grant or revoke writes to <code>permission_events</code>
|
||||
and stamps <code>permission_decided_by</code> +{' '}
|
||||
<code>permission_decided_at</code>. Role and write-mute controls
|
||||
retain their v0.7.0 semantics — promote to admin to remove a
|
||||
user's ability to write without silencing them.
|
||||
Role changes write to <code>permission_events</code>. The §6.2
|
||||
write-mute applies to contributors only — promote to admin to
|
||||
remove a user's ability to write without silencing them.
|
||||
</p>
|
||||
</header>
|
||||
{error && <p className="settings-note warning">{error}</p>}
|
||||
|
||||
<div className="admin-filter-chips">
|
||||
{STATE_CHIPS.map(chip => (
|
||||
<button
|
||||
key={chip.value}
|
||||
type="button"
|
||||
className={`admin-chip${stateFilter === chip.value ? ' active' : ''}`}
|
||||
onClick={() => setStateFilter(chip.value)}
|
||||
>
|
||||
{chip.label} <span className="admin-chip-count">{counts[chip.value] ?? 0}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<p className="muted">No users in this bucket.</p>
|
||||
) : (
|
||||
<table className="admin-table admin-users-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>State</th>
|
||||
<th>Role</th>
|
||||
<th>Write-muted</th>
|
||||
<th>Signed up</th>
|
||||
<th>Last seen</th>
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Role</th>
|
||||
<th>Write-muted</th>
|
||||
<th>Last seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map(u => (
|
||||
<tr key={u.id}>
|
||||
<td>
|
||||
<div className="user-cell">
|
||||
<span className="user-handle">@{u.gitea_login}</span>
|
||||
<span className="muted">{u.display_name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<select
|
||||
value={u.role}
|
||||
onChange={e => changeRole(u.id, e.target.value)}
|
||||
disabled={!!busy[u.id]}
|
||||
>
|
||||
<option value="contributor">Contributor</option>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="owner">Owner</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
{u.role === 'contributor' ? (
|
||||
<label className="mute-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!u.muted}
|
||||
onChange={e => toggleMute(u.id, e.target.checked)}
|
||||
disabled={!!busy[u.id]}
|
||||
/>
|
||||
{u.muted ? 'Muted' : 'Active'}
|
||||
</label>
|
||||
) : (
|
||||
<span className="muted">N/A</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="muted">{u.last_seen_at}</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map(u => (
|
||||
<UserRow
|
||||
key={u.id}
|
||||
user={u}
|
||||
busy={!!busy[u.id]}
|
||||
onChangeRole={role => changeRole(u.id, role)}
|
||||
onToggleMute={muted => toggleMute(u.id, muted)}
|
||||
onFlipPermission={state => flipPermission(u.id, state)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function UserRow({ user: u, busy, onChangeRole, onToggleMute, onFlipPermission }) {
|
||||
const state = u.permission_state || 'granted'
|
||||
const fullName = [u.first_name, u.last_name].filter(Boolean).join(' ').trim()
|
||||
const handle = u.gitea_login ? `@${u.gitea_login}` : (u.email || u.display_name)
|
||||
return (
|
||||
<>
|
||||
<tr>
|
||||
<td>
|
||||
<div className="user-cell">
|
||||
<span className="user-handle">{handle}</span>
|
||||
<span className="muted">
|
||||
{fullName || u.display_name}
|
||||
{u.email ? ` · ${u.email}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<PermissionCell user={u} busy={busy} onFlipPermission={onFlipPermission} />
|
||||
</td>
|
||||
<td>
|
||||
<select
|
||||
value={u.role}
|
||||
onChange={e => onChangeRole(e.target.value)}
|
||||
disabled={busy}
|
||||
>
|
||||
<option value="contributor">Contributor</option>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="owner">Owner</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
{u.role === 'contributor' ? (
|
||||
<label className="mute-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!u.muted}
|
||||
onChange={e => onToggleMute(e.target.checked)}
|
||||
disabled={busy}
|
||||
/>
|
||||
{u.muted ? 'Muted' : 'Active'}
|
||||
</label>
|
||||
) : (
|
||||
<span className="muted">N/A</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="muted">{u.created_at || '—'}</td>
|
||||
<td className="muted">{u.last_seen_at || '—'}</td>
|
||||
</tr>
|
||||
{state === 'pending' && u.beta_request_reason ? (
|
||||
<tr className="user-row-reason">
|
||||
<td colSpan={6}>
|
||||
<div className="user-reason-block">
|
||||
<strong>Why they want access:</strong>
|
||||
<p>{u.beta_request_reason}</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function PermissionCell({ user: u, busy, onFlipPermission }) {
|
||||
const state = u.permission_state || 'granted'
|
||||
const decidedSuffix = u.permission_decided_at
|
||||
? ` · by ${u.permission_decided_by_login ? '@' + u.permission_decided_by_login : '—'} at ${u.permission_decided_at}`
|
||||
: ''
|
||||
return (
|
||||
<div className="permission-cell">
|
||||
<span className={`permission-badge permission-badge-${state}`}>{state}</span>
|
||||
<div className="permission-actions">
|
||||
{state !== 'granted' && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-link-quiet"
|
||||
disabled={busy}
|
||||
onClick={() => onFlipPermission('granted')}
|
||||
>Grant</button>
|
||||
)}
|
||||
{state === 'granted' && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-link-quiet"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
if (confirm(`Revoke access for ${u.display_name || u.email}?`)) {
|
||||
onFlipPermission('revoked')
|
||||
}
|
||||
}}
|
||||
>Revoke</button>
|
||||
)}
|
||||
</div>
|
||||
{decidedSuffix && (
|
||||
<div className="permission-decided muted">{decidedSuffix.replace(/^ · /, '')}</div>
|
||||
)}
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,62 +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. The deployment's
|
||||
admins are notified by email as soon as a request lands;
|
||||
we don't commit to a fixed SLA — turnaround depends on
|
||||
operator availability — and the deployment operator is
|
||||
the right person to ask if a wait runs long.
|
||||
</p>
|
||||
<p>
|
||||
While you wait, the catalog on the left lists every super-draft
|
||||
and active RFC in the framework — reading is open. Discussion
|
||||
and contribution unlock once your access is granted.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p>
|
||||
Discussion and contribution are gated to invited contributors for
|
||||
now. Reading is open — every super-draft, every active RFC, and
|
||||
every public conversation is visible without signing in.
|
||||
</p>
|
||||
)}
|
||||
<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,51 +0,0 @@
|
||||
// `/docs` — the user-facing guide.
|
||||
//
|
||||
// Sibling of Philosophy.jsx: same chrome, same data path, different
|
||||
// source file. Renders DOCS.md verbatim with light chrome around it.
|
||||
// Reachable anonymously, same as `/philosophy`, so a visitor can read
|
||||
// the guide before deciding to sign in.
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import MarkdownPreview from './MarkdownPreview.jsx'
|
||||
import { getDocs } from '../api.js'
|
||||
|
||||
export default function Docs({ authenticated }) {
|
||||
const [body, setBody] = useState('')
|
||||
const [error, setError] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
getDocs()
|
||||
.then(r => { if (active) setBody(r.body || '') })
|
||||
.catch(e => { if (active) setError(e.message || String(e)) })
|
||||
.finally(() => { if (active) setLoading(false) })
|
||||
return () => { active = false }
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="philosophy-page">
|
||||
<header className="philosophy-header">
|
||||
<button
|
||||
className="philosophy-back"
|
||||
onClick={() => (history.length > 1 ? navigate(-1) : navigate('/'))}
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<span className="philosophy-title">User guide</span>
|
||||
{!authenticated && (
|
||||
<Link className="philosophy-signin" to="/">Home</Link>
|
||||
)}
|
||||
</header>
|
||||
<article className="philosophy-body">
|
||||
{loading && <p className="muted">Loading…</p>}
|
||||
{error && <p className="error">Could not load the guide: {error}</p>}
|
||||
{!loading && !error && (
|
||||
<MarkdownPreview content={body} />
|
||||
)}
|
||||
</article>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,107 +1,68 @@
|
||||
// Login.jsx — the composed sign-in surface (§6.2) after the v0.10.0
|
||||
// (passcodes, roadmap item #8) rebase onto v0.8.0 (beta-access-request
|
||||
// capture, §6.1 / §14.1, roadmap item #6). v0.7.0 (roadmap item #5)
|
||||
// established the email + OTC scaffolding both releases extended.
|
||||
// Login.jsx — v0.7.0's email + OTC sign-in surface (§6.2), extended
|
||||
// in v0.10.0 with passcode sign-in (roadmap item #8).
|
||||
//
|
||||
// Four-to-six-step flow (most users see three; the longest path is
|
||||
// pending-user with no passcode, who never sees the passcode steps):
|
||||
// Three-to-five-step flow:
|
||||
// 1. Enter email → GET /auth/passcode/check.
|
||||
// * If `has_passcode`: advance to step 'passcode'.
|
||||
// * Otherwise: POST /auth/otc/request, advance to step 'code'.
|
||||
// 2a. Step 'passcode': enter passcode → POST /auth/passcode/verify.
|
||||
// * On 200: redirect to /.
|
||||
// * On 423: passcode is locked (5 consecutive failures); the
|
||||
// UI auto-falls back to OTC by requesting a fresh code.
|
||||
// * On 400: wrong passcode; the user can retry or click
|
||||
// "Use a code instead" to fall back to OTC manually.
|
||||
// 2b. Step 'code' (the v0.7.0 path): enter the six-digit code →
|
||||
// POST /auth/otc/verify → on 200, the server has signed in the
|
||||
// user. If the user has no passcode set, we show step
|
||||
// 'offer-passcode' inviting them to set one for faster sign-in
|
||||
// next time. Dismiss skips to /; "Set passcode" advances to
|
||||
// step 'set-passcode'.
|
||||
// 3. Step 'set-passcode': enter a passcode → POST /auth/passcode/set
|
||||
// → redirect to /. The user can also "Skip for now".
|
||||
//
|
||||
// 1. 'email' Enter email → GET /auth/passcode/check.
|
||||
// * has_passcode=true → step 'passcode'.
|
||||
// * has_passcode=false → POST /auth/otc/request,
|
||||
// step 'code'.
|
||||
// 429 on either dispatch surfaces a "wait a
|
||||
// moment" hint and keeps the user on step 1.
|
||||
// Server-side, /auth/otc/request always returns 202 for an unrecognized
|
||||
// email (so the allowlist gate doesn't leak), so this surface never
|
||||
// distinguishes "we couldn't reach you" from "we don't know you". The
|
||||
// check endpoint also returns `has_passcode: false` for an unknown
|
||||
// email — so an unknown email always lands in the OTC path, no
|
||||
// account-enumeration signal.
|
||||
//
|
||||
// 2a. 'passcode' Enter passcode → POST /auth/passcode/verify.
|
||||
// * 200 → redirect to "/".
|
||||
// * 423 (lockout, 5 consecutive failures) →
|
||||
// auto-fall back to OTC by requesting a fresh
|
||||
// code and advancing to step 'code'.
|
||||
// * 400 → wrong passcode; user can retry or
|
||||
// click "Use a code instead" to fall back
|
||||
// manually.
|
||||
//
|
||||
// 2b. 'code' Enter the six-digit code → POST /auth/otc/verify.
|
||||
// On 200, fetch /api/auth/me and branch:
|
||||
// * needs_profile === true → 'capture-profile'
|
||||
// * has_passcode === false → 'offer-passcode'
|
||||
// * otherwise → redirect to "/".
|
||||
// needs_profile WINS over has_passcode — a
|
||||
// pending user goes through the §6.1 capture
|
||||
// flow first; setting a passcode while waiting
|
||||
// for admin grant gains them nothing.
|
||||
// Cmd/Ctrl+Enter on the code field is the
|
||||
// keyboard shortcut.
|
||||
//
|
||||
// 3. 'capture-profile' (v0.8.0, §6.1) First name, last name, and "why
|
||||
// I should be included in the beta" → POST
|
||||
// /api/auth/me/beta-request → redirect to
|
||||
// /beta-pending. The user's row stays
|
||||
// permission_state='pending' until an admin
|
||||
// grants access; they can set a passcode later
|
||||
// from settings, or on a future sign-in once
|
||||
// granted.
|
||||
//
|
||||
// 4a. 'offer-passcode' (v0.10.0) "Set a passcode for faster sign-in
|
||||
// next time?" Yes → 'set-passcode'. Skip → "/".
|
||||
//
|
||||
// 4b. 'set-passcode' Pick a passcode (4–20 chars) → POST
|
||||
// /auth/passcode/set → redirect to "/". A
|
||||
// "Skip for now" link also redirects to "/".
|
||||
//
|
||||
// Server-side, /auth/otc/request returns 202 uniformly and
|
||||
// /auth/passcode/check returns has_passcode=false for an unknown
|
||||
// email, so this surface never distinguishes "we couldn't reach you"
|
||||
// from "we don't know you" — an unknown email always lands in the
|
||||
// OTC path with no account-enumeration signal.
|
||||
//
|
||||
// The legacy Gitea OAuth callback remains at /auth/login →
|
||||
// /auth/callback during the v0.7.0 migration; we surface a "Sign in
|
||||
// with Gitea" link as a fallback in the footer so users with active
|
||||
// OAuth sessions or older invite emails still have a path. We hide
|
||||
// the fallback on 'capture-profile' so a half-captured pending user
|
||||
// doesn't bail out into the OAuth path mid-form.
|
||||
// The legacy Gitea OAuth callback remains at /auth/login → /auth/callback
|
||||
// during the v0.7.0 migration; we surface a "Sign in with Gitea" link
|
||||
// as a fallback in the footer so users with active OAuth sessions or
|
||||
// older invite emails still have a path.
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate, Link } from 'react-router-dom'
|
||||
import {
|
||||
requestOtc,
|
||||
verifyOtc,
|
||||
submitBetaRequest,
|
||||
checkPasscode,
|
||||
verifyPasscode,
|
||||
setPasscode as apiSetPasscode,
|
||||
} from '../api'
|
||||
|
||||
export default function Login() {
|
||||
// Steps: 'email' → 'passcode' or 'code' → (on the OTC path, after
|
||||
// verify) one of: 'capture-profile' (pending user), 'offer-passcode'
|
||||
// (no passcode yet), or straight to "/". 'set-passcode' is reached
|
||||
// from 'offer-passcode'.
|
||||
// Steps: 'email' → 'passcode' or 'code' → (after OTC verify) optional
|
||||
// 'offer-passcode' → optional 'set-passcode'. The latter two only
|
||||
// appear on the OTC path for accounts that don't yet have a passcode.
|
||||
const [step, setStep] = useState('email')
|
||||
const [email, setEmail] = useState('')
|
||||
const [code, setCode] = useState('')
|
||||
const [passcode, setPasscode] = useState('')
|
||||
const [newPasscode, setNewPasscode] = useState('')
|
||||
// v0.8.0 — capture-profile fields.
|
||||
const [firstName, setFirstName] = useState('')
|
||||
const [lastName, setLastName] = useState('')
|
||||
const [reason, setReason] = useState('')
|
||||
const [status, setStatus] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const emailRef = useRef(null)
|
||||
const codeRef = useRef(null)
|
||||
const passcodeRef = useRef(null)
|
||||
const newPasscodeRef = useRef(null)
|
||||
const firstNameRef = useRef(null)
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
if (step === 'email') emailRef.current?.focus()
|
||||
else if (step === 'code') codeRef.current?.focus()
|
||||
else if (step === 'passcode') passcodeRef.current?.focus()
|
||||
else if (step === 'capture-profile') firstNameRef.current?.focus()
|
||||
else if (step === 'set-passcode') newPasscodeRef.current?.focus()
|
||||
}, [step])
|
||||
|
||||
@@ -144,10 +105,6 @@ export default function Login() {
|
||||
setStatus('')
|
||||
try {
|
||||
await verifyPasscode(email.trim(), passcode.trim())
|
||||
// Reload so App.jsx's getMe() picks up the fresh session. A
|
||||
// returning passcode user is by definition already past the
|
||||
// §6.1 capture step (they couldn't have set a passcode while
|
||||
// pending), so we go straight to "/".
|
||||
window.location.assign('/')
|
||||
} catch (err) {
|
||||
if (err.status === 423) {
|
||||
@@ -190,73 +147,23 @@ export default function Login() {
|
||||
setStatus('')
|
||||
try {
|
||||
await verifyOtc(email.trim(), code.trim())
|
||||
// OTC verified — the server has signed in the user. Fetch the
|
||||
// canonical /api/auth/me to decide where to land:
|
||||
// * needs_profile → §6.1 capture (then /beta-pending).
|
||||
// * no passcode → §6.2 offer-passcode (then /).
|
||||
// * otherwise → /.
|
||||
// needs_profile wins over has_passcode: a pending user can't yet
|
||||
// do anything that benefits from faster sign-in, so we don't
|
||||
// distract them with the passcode offer mid-admission.
|
||||
const meResp = await fetch('/api/auth/me', { credentials: 'include' })
|
||||
let me = null
|
||||
if (meResp.ok) {
|
||||
try {
|
||||
me = await meResp.json()
|
||||
} catch (_) {
|
||||
me = null
|
||||
}
|
||||
}
|
||||
if (me?.needs_profile === true) {
|
||||
setStep('capture-profile')
|
||||
setStatus('')
|
||||
setBusy(false)
|
||||
return
|
||||
}
|
||||
if (me?.has_passcode === false) {
|
||||
// OTC verified. If the user has no passcode, offer to set one
|
||||
// before redirecting. We re-read `has_passcode` from the server
|
||||
// rather than caching the step-1 result because the user could
|
||||
// have set a passcode in another tab between then and now.
|
||||
const { has_passcode } = await checkPasscode(email.trim())
|
||||
if (has_passcode) {
|
||||
window.location.assign('/')
|
||||
} else {
|
||||
setStep('offer-passcode')
|
||||
setStatus('')
|
||||
setBusy(false)
|
||||
return
|
||||
}
|
||||
// Either /me returned the granted-with-passcode shape, or the
|
||||
// call failed but the session cookie is set — fall through to
|
||||
// a hard reload so App.jsx re-fetches and renders accordingly.
|
||||
window.location.assign('/')
|
||||
} catch (err) {
|
||||
setStatus('That code is invalid or expired. Try again, or request a new code.')
|
||||
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,
|
||||
})
|
||||
// Hard-load so App.jsx re-fetches /api/auth/me and picks up
|
||||
// the captured fields. The user stays permission_state='pending'
|
||||
// until an admin grants access — the next thing they should
|
||||
// see is the "your request is in review" page.
|
||||
window.location.assign('/beta-pending')
|
||||
} catch (err) {
|
||||
setStatus(err.message || 'Could not submit your request. Try again.')
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function submitNewPasscode(e) {
|
||||
if (e) e.preventDefault()
|
||||
const pc = newPasscode.trim()
|
||||
@@ -290,21 +197,6 @@ export default function Login() {
|
||||
}
|
||||
}
|
||||
|
||||
function onReasonKey(e) {
|
||||
// §6.1 ergonomic: Cmd/Ctrl+Enter submits the capture form from
|
||||
// the reason textarea (the multi-line input that would otherwise
|
||||
// swallow Enter as a newline).
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||||
submitProfile(e)
|
||||
}
|
||||
}
|
||||
|
||||
function onNewPasscodeKey(e) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||||
submitNewPasscode(e)
|
||||
}
|
||||
}
|
||||
|
||||
function backToEmail() {
|
||||
setStep('email')
|
||||
setCode('')
|
||||
@@ -438,60 +330,6 @@ export default function Login() {
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
{step === 'capture-profile' && (
|
||||
<form onSubmit={submitProfile}>
|
||||
<p className="otc-hint">
|
||||
You're signed in. {import.meta.env.VITE_APP_NAME} is in private
|
||||
beta — tell us a bit about yourself and an admin will review
|
||||
your request.
|
||||
</p>
|
||||
<label className="otc-field-label">First name</label>
|
||||
<input
|
||||
ref={firstNameRef}
|
||||
type="text"
|
||||
autoComplete="given-name"
|
||||
value={firstName}
|
||||
onChange={e => setFirstName(e.target.value)}
|
||||
required
|
||||
disabled={busy}
|
||||
maxLength={120}
|
||||
/>
|
||||
<label className="otc-field-label">Last name</label>
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="family-name"
|
||||
value={lastName}
|
||||
onChange={e => setLastName(e.target.value)}
|
||||
required
|
||||
disabled={busy}
|
||||
maxLength={120}
|
||||
/>
|
||||
<label className="otc-field-label">
|
||||
Why you'd like to be included in the beta
|
||||
</label>
|
||||
<textarea
|
||||
value={reason}
|
||||
onChange={e => setReason(e.target.value)}
|
||||
onKeyDown={onReasonKey}
|
||||
required
|
||||
disabled={busy}
|
||||
rows={5}
|
||||
maxLength={4000}
|
||||
placeholder="A sentence or two is plenty."
|
||||
/>
|
||||
<div className="otc-actions">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || !firstName.trim() || !lastName.trim() || !reason.trim()}
|
||||
>
|
||||
{busy ? 'Submitting…' : 'Submit request'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="otc-shortcut-hint">
|
||||
Tip: <kbd>⌘</kbd>+<kbd>Enter</kbd> (or <kbd>Ctrl</kbd>+<kbd>Enter</kbd>) to submit.
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
{step === 'offer-passcode' && (
|
||||
<div className="otc-offer-passcode">
|
||||
<p className="otc-hint">
|
||||
@@ -528,7 +366,6 @@ export default function Login() {
|
||||
autoComplete="new-password"
|
||||
value={newPasscode}
|
||||
onChange={e => setNewPasscode(e.target.value)}
|
||||
onKeyDown={onNewPasscodeKey}
|
||||
placeholder="New passcode"
|
||||
required
|
||||
disabled={busy}
|
||||
@@ -551,13 +388,11 @@ export default function Login() {
|
||||
</form>
|
||||
)}
|
||||
{status && <p className="otc-status">{status}</p>}
|
||||
{step !== 'capture-profile' && (
|
||||
<p className="otc-fallback">
|
||||
<Link to="/philosophy">Read the philosophy →</Link>
|
||||
<span className="otc-fallback-sep">·</span>
|
||||
<a href="/auth/login">Sign in with Gitea (fallback)</a>
|
||||
</p>
|
||||
)}
|
||||
<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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user