Compare commits

...

2 Commits

Author SHA1 Message Date
Ben Stull 7872b921ed Release 0.9.0: admin user-management page + new-request notifications 2026-05-28 03:39:25 -07:00
Ben Stull de28272914 Release 0.14.0: public /docs user guide (DOCS.md served at /docs)
Ships DOCS.md and the /docs route — a plain-prose translation of
SPEC.md for users, distinct from the binding spec. Originating need
was the admin-vs-owner role distinction on /admin/users (§6.1);
response is a single guide that covers the framework's user-facing
surfaces end-to-end (roles, proposing, branches, PRs, graduation,
notifications). Mirrors /philosophy: markdown file at repo root +
sibling backend loader + MarkdownPreview render. No schema migration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 03:08:28 -07:00
18 changed files with 2023 additions and 119 deletions
+275
View File
@@ -23,6 +23,79 @@ 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
@@ -264,6 +337,208 @@ v0.7.0. The lockout shape (5 attempts, 15 minutes) and the length
range (420) are hard-coded in `backend/app/passcode.py`. See
§19.2 for the env-tunable candidate.
## 0.9.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).
### 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.
### 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.
### Environment variables
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.**
+605
View File
@@ -0,0 +1,605 @@
# 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`.
+102 -57
View File
@@ -398,9 +398,23 @@ endpoints accept the user. The capture-fields step (first name,
last name, free-text "why I should be included in the beta") feeds
the admin's triage queue. The `allowed_emails` table stays in the
schema as a fast-path bypass — the v0.3.0 admin UI still manages
it — but the OTC request path no longer consults it. v0.9.0's
admin user-management page replaces the allowlist UI and ships the
pending-queue triage surface.
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
@@ -2286,8 +2300,14 @@ 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`, `digest_emitted`. The enum is extensible; the
build session adjusts as new gestures are wired in.
`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.
### 15.2 The inbox
@@ -2945,8 +2965,15 @@ 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 with role and write-mute state,
for the §6 / Slice 7 admin surface.
- `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.
- `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
@@ -2955,6 +2982,16 @@ 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
@@ -3794,56 +3831,64 @@ on every other page until an admin grants. v0.8.0 (roadmap item
Candidates surfaced during v0.8.0 (open beta-access request flow,
§6.1 / §14.1, item #6):
- **Admin user-management page** (`/admin/users`). *Surfaced by
v0.8.0 — the release ships the pending-state column but no
admin UI to triage it.* For v0.8.0, the admin gesture is an
out-of-band `UPDATE users SET permission_state='granted' WHERE
email=?`. v0.9.0 (roadmap item #7) is expected to ship the
triage queue: a list of `permission_state='pending'` rows
sorted by `created_at`, each showing the captured first /
last / why fields, with Grant and Revoke buttons that stamp
`permission_decided_by` and `permission_decided_at` (schema
slots already in place per `migrations/014_beta_access.sql`).
The page composes naturally with the existing `/admin/allowlist`
surface — both are admission-control gestures so v0.9.0 may
fold the allowlist UI into this page (see next candidate).
Decision points: do grants / revokes also fire email
notifications to the user (probably yes — the notifications
layer from v0.6.0 has the personal-direct channel for it); is
there a "decline with reason" gesture that surfaces in the
user's view (probably yes — symmetric with §9.3's
proposal-decline shape); does the page support bulk grants
(probably no for v0.9.0 — the queue volume is operator-scale,
not user-scale). Earns its session as the v0.9.0 design pass.
- **Allowlist deprecation.** *Surfaced by v0.8.0 — the
`allowed_emails` table stays in the schema but the OTC
request path no longer consults it.* v0.8.0 left the table
and the `/admin/allowlist` UI in place as a fast-path bypass
for deployments that want to pre-mark known-good emails (the
v0.8.0 contract is that those emails still go through the
pending-grant flow; the table itself is no longer a gate). A
future release retires both — probably v0.9.0 alongside the
admin user-management page, since the two surfaces are
functionally redundant once the pending queue lands.
Decision points: drop the table outright (a schema migration)
or leave it as a non-functional surface and remove only the
UI (a frontend-only change); how to handle existing
`allowed_emails` rows at the cutover (probably: walk them
into the pending queue with `permission_state='granted'` for
any matching `users` row, leave unmatched rows as a no-op
since v0.8.0 doesn't consult them anymore). Earns its
session as a sub-topic of the v0.9.0 admin user-management
pass.
- **Admin notification on new beta request.** *Surfaced by
v0.8.0 — the capture endpoint writes to the row but does
not signal admins.* v0.9.0 candidate (item #7 again): when a
`POST /api/auth/me/beta-request` lands, fire an `admin-actionable`
notification (per §15.4's category set) to every owner / admin
so the queue doesn't go stale. The §15 infrastructure already
supports the category; the open question is whether the
notification is per-request (one email per submission) or
digested (a daily summary). Earns its session alongside the
admin user-management page.
- **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.
- **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`
+1 -1
View File
@@ -1 +1 @@
0.10.0
0.9.0
+25
View File
@@ -26,10 +26,12 @@ from . import (
api_prs,
auth,
db,
docs as docs_mod,
entry as entry_mod,
cache,
funder,
health,
notify,
philosophy,
providers as providers_mod,
)
@@ -122,6 +124,17 @@ 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.
# ---------------------------------------------------------------
@@ -225,6 +238,18 @@ def make_router(
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}
# ---------------------------------------------------------------
+117 -4
View File
@@ -50,6 +50,16 @@ 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)
@@ -68,13 +78,41 @@ 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 id, gitea_login, display_name, email, role, muted,
created_at, last_seen_at
FROM users
ORDER BY role = 'owner' DESC, role = 'admin' DESC, display_name COLLATE NOCASE
SELECT u.id, u.gitea_login, u.display_name, u.email, u.role, u.muted,
u.created_at, u.last_seen_at,
u.permission_state, u.first_name, u.last_name,
u.beta_request_reason,
u.permission_decided_by, u.permission_decided_at,
d.gitea_login AS decided_by_login,
d.display_name AS decided_by_display
FROM users u
LEFT JOIN users d ON d.id = u.permission_decided_by
ORDER BY
CASE u.permission_state
WHEN 'pending' THEN 0
WHEN 'granted' THEN 1
WHEN 'revoked' THEN 2
ELSE 3
END,
u.role = 'owner' DESC, u.role = 'admin' DESC,
COALESCE(u.last_seen_at, u.created_at) DESC,
u.display_name COLLATE NOCASE
"""
).fetchall()
return {
@@ -88,6 +126,13 @@ 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
]
@@ -136,6 +181,74 @@ def make_router(config: Config) -> APIRouter:
)
return {"ok": True, "role": body.role, "changed": True}
# ----- Permission state (§6.1, v0.9.0 roadmap item #7) -----
@router.post("/api/admin/users/{user_id}/permission")
async def set_permission(user_id: int, body: PermissionStateBody, request: Request) -> dict[str, Any]:
"""Flip a user's `permission_state` between pending/granted/revoked.
v0.8.0 wired the column shape but shipped no admin UI for it
the grant gesture was a manual `UPDATE users` against the DB.
v0.9.0 (roadmap item #7) lands the admin user-management page;
this endpoint is its single write surface.
Audit shape: every flip writes a `permission_events` row with
event_kind in {'permission_granted', 'permission_revoked',
'permission_repended'} so §6.5's log carries the change. The
`permission_decided_by` / `permission_decided_at` columns on
the user row are co-stamped so the user listing can render
"granted by @ben at <date>" without a second join through
the audit table.
Refuses with 422 if the admin tries to flip their own row
(no self-grant / self-revoke; symmetric to set_mute's
self-mute refusal and set_role's self-downgrade refusal).
"""
viewer = auth.require_admin(request)
target = db.conn().execute(
"SELECT id, role, permission_state FROM users WHERE id = ?",
(user_id,),
).fetchone()
if target is None:
raise HTTPException(404, "User not found")
if target["id"] == viewer.user_id:
raise HTTPException(422, "You cannot change your own permission state")
before = target["permission_state"] or "granted"
after = body.state
if before == after:
return {"ok": True, "permission_state": after, "changed": False}
db.conn().execute(
"""
UPDATE users
SET permission_state = ?,
permission_decided_by = ?,
permission_decided_at = datetime('now')
WHERE id = ?
""",
(after, viewer.user_id, user_id),
)
event_kind = {
"granted": "permission_granted",
"revoked": "permission_revoked",
"pending": "permission_repended",
}[after]
db.conn().execute(
"""
INSERT INTO permission_events
(actor_user_id, subject_user_id, event_kind, details)
VALUES (?, ?, ?, ?)
""",
(
viewer.user_id,
user_id,
event_kind,
json.dumps({"before": before, "after": after}),
),
)
return {"ok": True, "permission_state": after, "changed": True}
# ----- Write-mute (§6.2) -----
@router.post("/api/admin/users/{user_id}/mute")
+61
View File
@@ -0,0 +1,61 @@
"""User-facing docs source.
Mirrors `philosophy.py` shape. Serves `DOCS.md` from the repo root
the framework's plain-prose user guide to roles, contribution flow,
and notification surfaces, distinct from the binding `SPEC.md`. Read
from disk on first call and cached in-process; the periodic
reconciler can call `refresh()` to pick up out-of-band edits.
`DOCS_PATH` overrides the default location if a deployment hosts the
file elsewhere (a meta-repo working-tree clone, a sync target, etc.).
"""
from __future__ import annotations
import logging
import os
import threading
from pathlib import Path
log = logging.getLogger(__name__)
_DEFAULT_PATH = Path(__file__).resolve().parents[2] / "DOCS.md"
_lock = threading.Lock()
_cache: dict | None = None
def _resolved_path() -> Path:
override = os.environ.get("DOCS_PATH", "").strip()
if override:
return Path(override).expanduser().resolve()
return _DEFAULT_PATH
def load(force: bool = False) -> dict:
"""Return the cached `{body, path, mtime}` payload, reading from disk
on first call or when `force=True`.
"""
global _cache
with _lock:
if _cache is not None and not force:
return _cache
path = _resolved_path()
try:
text = path.read_text(encoding="utf-8")
mtime = path.stat().st_mtime
except FileNotFoundError:
log.warning("DOCS.md not found at %s — serving placeholder", path)
text = (
"# DOCS.md not found\n\n"
"The deployment is missing its user guide. Set "
"DOCS_PATH or place DOCS.md at the project root."
)
mtime = 0.0
_cache = {"body": text, "path": str(path), "mtime": mtime}
return _cache
def refresh() -> dict:
"""Force-reread from disk. Returns the new payload."""
return load(force=True)
+11
View File
@@ -139,6 +139,10 @@ _EVENT_TO_CATEGORY: dict[str, str] = {
"graduation_complete": "personal-direct",
"super_draft_graduation_ready": "admin-actionable",
"claim_opened": "structural",
# v0.9.0: roadmap item #7. A fresh beta-access request lands as
# an admin-actionable signal so it consults `email_admin_actionable`
# and reaches owners/admins only.
"new_beta_request": "admin-actionable",
}
@@ -285,6 +289,13 @@ def _deep_link(payload: dict, cfg: EmailConfig) -> str:
slug = payload.get("rfc_slug")
pr = payload.get("pr_number")
branch = payload.get("branch_name")
event_kind = payload.get("event_kind")
# v0.9.0: framework-scoped admin signals link to the admin
# surface, not /rfc/... The `new_beta_request` event is the
# canonical example; future framework-scoped admin events
# may reuse the same branch.
if event_kind == "new_beta_request":
return f"{cfg.app_url}/admin/users"
if slug and pr:
return f"{cfg.app_url}/rfc/{slug}/pr/{pr}"
if slug and branch:
+72
View File
@@ -64,6 +64,7 @@ log = logging.getLogger(__name__)
CATEGORY_PERSONAL = "personal-direct"
CATEGORY_STRUCTURAL = "structural"
CATEGORY_CHURN = "churn"
CATEGORY_ADMIN_ACTIONABLE = "admin-actionable"
# Action kinds whose actor's first interaction with a slug triggers
# auto-watch per §15.6. The substantive-gesture list in the spec is
@@ -208,6 +209,67 @@ 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,
@@ -707,6 +769,16 @@ def render_summary(event_kind: str, actor_display: str | None, rfc_title: str |
return f"{actor} began graduating {title}."
if event_kind == "pr_conflict_with_main":
return f"{actor} started a resolution branch on {title}."
if event_kind == "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}"
+425
View File
@@ -0,0 +1,425 @@
"""End-to-end integration tests for v0.9.0's admin user-management page
and new-beta-request notifications (roadmap item #7, §6.1 / §15).
The release lands two halves of the same surface:
* **Admin notification on new beta request.** When a pending user
submits `POST /api/auth/me/beta-request`, every owner/admin
receives a `new_beta_request` notification (the §15 substrate
insert lands the row; the §15.4 email path dispatches subject to
the recipient's `email_admin_actionable` toggle).
* **Admin user-management surface** at `/admin/users`. The
`GET /api/admin/users` listing carries every user with their
permission_state, profile fields, sign-up reason, and decision
audit. The new `POST /api/admin/users/<id>/permission` endpoint
flips the column and writes a `permission_events` row.
The tests prove:
* The first beta-request submission fans a `new_beta_request`
row out to every admin/owner (and not to the requester
themselves). The row carries the captured profile in
`payload.extras`.
* Re-submitting the form from the same pending user doesn't
re-fan (we only notify on the row's first complete state).
* `GET /api/admin/users` carries the v0.9.0 columns
(permission_state, first/last/reason, decided_by).
* `POST /api/admin/users/<id>/permission` flips the state,
stamps decided_by/at, and writes a `permission_events` row.
* The endpoint refuses self-flip (422) and refuses non-admin
callers (403).
* The endpoint accepts only the three valid states (422 on
anything else).
"""
from __future__ import annotations
from test_propose_vertical import ( # noqa: F401 — fixtures land via import
FakeGitea,
app_with_fake_gitea,
provision_user_row,
sign_in_as,
tmp_env,
)
def _reset_outbound():
from app import email as email_mod
email_mod.reset_sent_envelopes()
def _outbound_otc_codes(to_address: str | None = None) -> list[str]:
from app import email as email_mod
out = []
for env in email_mod.sent_envelopes():
if env.get("kind") != "otc":
continue
if to_address is not None and env["to"] != to_address:
continue
for line in env["body"].splitlines():
tok = line.strip()
if tok.isdigit() and len(tok) == 6:
out.append(tok)
break
return out
def _provision_pending_user(client, email: str) -> int:
"""Sign in a fresh OTC user (lands `pending`) and return their user_id."""
from app import db
_reset_outbound()
client.post("/auth/otc/request", json={"email": email})
code = _outbound_otc_codes(email)[-1]
client.post("/auth/otc/verify", json={"email": email, "code": code})
row = db.conn().execute(
"SELECT id FROM users WHERE email = ? COLLATE NOCASE", (email,)
).fetchone()
return row["id"]
# ---------------------------------------------------------------------------
# Admin notification on beta-request submission
# ---------------------------------------------------------------------------
def test_beta_request_submission_notifies_every_admin(app_with_fake_gitea):
"""First-time submission of a beta-request fans a notification out
to every owner and admin. The requester themselves never receives
a row (filtered out by user_id even if they happened to be in the
admin set, which they aren't in practice — fresh OTC users are
`contributor`+`pending`)."""
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
# Provision two admins and one owner so the fan-out has multiple
# targets. The OWNER_GITEA_LOGIN-derived ownership doesn't fire
# here (no OAuth round-trip in this path); we seed the role
# directly.
provision_user_row(user_id=10, login="ownerzero", role="owner")
provision_user_row(user_id=11, login="admin_one", role="admin")
provision_user_row(user_id=12, login="admin_two", role="admin")
provision_user_row(user_id=13, login="contrib_one", role="contributor")
# Sign in a fresh OTC user → permission_state='pending'.
requester_id = _provision_pending_user(client, "newbie@example.com")
# Capture-form submit.
r = client.post(
"/api/auth/me/beta-request",
json={
"first_name": "Newt",
"last_name": "Newcomer",
"beta_request_reason": "I want to write the Human RFC.",
},
)
assert r.status_code == 200, r.text
# Every owner + admin gets a `new_beta_request` notification.
# The contributor (id=13) does not. The requester (whoever id
# they got) does not.
rows = db.conn().execute(
"""
SELECT recipient_user_id, event_kind, actor_user_id, payload
FROM notifications
WHERE event_kind = 'new_beta_request'
"""
).fetchall()
recipients = sorted(r["recipient_user_id"] for r in rows)
assert recipients == [10, 11, 12], f"unexpected recipients: {recipients}"
# Actor is the requester (§15.9: never the bot).
for r in rows:
assert r["actor_user_id"] == requester_id
import json as _json
extras = _json.loads(r["payload"])
assert extras["requester_first_name"] == "Newt"
assert extras["requester_last_name"] == "Newcomer"
assert extras["requester_email"] == "newbie@example.com"
def test_beta_request_resubmit_does_not_re_notify(app_with_fake_gitea):
"""Once a user has completed the capture form, re-submitting it
(the endpoint is idempotent for pending users) must not re-fan a
fresh notification to every admin that would carpet-bomb the
inbox on every typo correction."""
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=20, login="adminzero", role="admin")
_provision_pending_user(client, "carpet@example.com")
body = {
"first_name": "Carpet",
"last_name": "Bomb",
"beta_request_reason": "first draft",
}
r1 = client.post("/api/auth/me/beta-request", json=body)
assert r1.status_code == 200
# Re-submit with edited reason — endpoint accepts (idempotent
# update), but the admin inbox stays at one row.
body2 = dict(body, beta_request_reason="cleaner final draft")
r2 = client.post("/api/auth/me/beta-request", json=body2)
assert r2.status_code == 200
rows = db.conn().execute(
"SELECT COUNT(*) AS n FROM notifications WHERE event_kind = 'new_beta_request'"
).fetchone()
assert rows["n"] == 1
def test_beta_request_notification_is_admin_actionable_category(app_with_fake_gitea):
"""The §15.4 category mapping must route `new_beta_request` to the
admin-actionable bucket so the email gate consults
`email_admin_actionable` (and skips for non-admin recipients).
"""
from app import email as email_mod
assert email_mod.category_for("new_beta_request", "structural") == "admin-actionable"
# ---------------------------------------------------------------------------
# /api/admin/users — listing carries the v0.9.0 columns
# ---------------------------------------------------------------------------
def test_admin_users_listing_carries_permission_columns(app_with_fake_gitea):
"""The Users tab consumes this shape — confirm every required
column is on the response."""
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
# Seed an admin and a pending user with all the v0.8.0 columns
# populated. Direct-DB insert avoids the OTC dance (which would
# overwrite the cookie); the test above proves the capture
# pathway end-to-end and this one just exercises the listing
# surface's shape.
provision_user_row(user_id=30, login="ben", role="owner")
db.conn().execute(
"""
INSERT INTO users (id, gitea_id, gitea_login, email,
display_name, avatar_url, role,
permission_state, first_name, last_name,
beta_request_reason)
VALUES (31, NULL, NULL, 'pendinguser@example.com',
'pendinguser', '', 'contributor',
'pending', 'Penn', 'Ding', 'I want in.')
"""
)
sign_in_as(
client, user_id=30, gitea_login="ben",
display_name="Ben", role="owner",
)
r = client.get("/api/admin/users")
assert r.status_code == 200
items = r.json()["items"]
assert isinstance(items, list)
pending = next(
(i for i in items if i["email"] == "pendinguser@example.com"), None,
)
assert pending is not None
assert pending["permission_state"] == "pending"
assert pending["first_name"] == "Penn"
assert pending["last_name"] == "Ding"
assert pending["beta_request_reason"] == "I want in."
assert pending["permission_decided_at"] is None
assert pending["permission_decided_by_login"] is None
# Pending bucket is listed first (sort order).
assert items[0]["permission_state"] == "pending"
# ---------------------------------------------------------------------------
# /api/admin/users/<id>/permission — the flip endpoint
# ---------------------------------------------------------------------------
def test_permission_flip_grant_promotes_pending_to_granted(app_with_fake_gitea):
"""The end-to-end gesture: a fresh OTC user lands pending, an admin
flips them to granted via the endpoint, the row reflects the new
state + decided_by/at, and a `permission_events` audit row lands."""
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
# Pending user.
pending_id = _provision_pending_user(client, "flip@example.com")
# Admin acting on them.
provision_user_row(user_id=40, login="adminflipper", role="admin")
sign_in_as(
client, user_id=40, gitea_login="adminflipper",
display_name="Admin Flipper", role="admin",
)
r = client.post(
f"/api/admin/users/{pending_id}/permission",
json={"state": "granted"},
)
assert r.status_code == 200, r.text
body = r.json()
assert body["permission_state"] == "granted"
assert body["changed"] is True
# Row reflects the new state + decision stamp.
row = db.conn().execute(
"SELECT permission_state, permission_decided_by, permission_decided_at "
"FROM users WHERE id = ?",
(pending_id,),
).fetchone()
assert row["permission_state"] == "granted"
assert row["permission_decided_by"] == 40
assert row["permission_decided_at"] is not None
# Audit row landed in permission_events.
events = db.conn().execute(
"""
SELECT actor_user_id, subject_user_id, event_kind
FROM permission_events
WHERE event_kind = 'permission_granted'
"""
).fetchall()
assert len(events) == 1
assert events[0]["actor_user_id"] == 40
assert events[0]["subject_user_id"] == pending_id
def test_permission_flip_revoke_promotes_granted_to_revoked(app_with_fake_gitea):
"""Revoke is the symmetric gesture. Used when an account earned a
grant then later lost it (§6.1 / `revoked` state)."""
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=50, login="goner", role="contributor")
# Default permission_state is 'granted' via the column default.
provision_user_row(user_id=51, login="adminrevoker", role="admin")
sign_in_as(
client, user_id=51, gitea_login="adminrevoker",
display_name="Admin Revoker", role="admin",
)
r = client.post(
"/api/admin/users/50/permission",
json={"state": "revoked"},
)
assert r.status_code == 200, r.text
row = db.conn().execute(
"SELECT permission_state FROM users WHERE id = 50"
).fetchone()
assert row["permission_state"] == "revoked"
events = db.conn().execute(
"SELECT event_kind FROM permission_events "
"WHERE event_kind = 'permission_revoked' AND subject_user_id = 50"
).fetchall()
assert len(events) == 1
def test_permission_flip_refuses_self(app_with_fake_gitea):
"""Symmetric to set_mute / set_role: an admin can't self-flip.
The state-change channel for one's own grant is somebody else's
hand."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=60, login="selfflipper", role="admin")
sign_in_as(
client, user_id=60, gitea_login="selfflipper",
display_name="Self Flipper", role="admin",
)
r = client.post(
"/api/admin/users/60/permission",
json={"state": "revoked"},
)
assert r.status_code == 422
def test_permission_flip_refuses_non_admin(app_with_fake_gitea):
"""The endpoint is admin-only (§17 admin/* requires require_admin).
A contributor caller is refused 403; an anonymous caller 401."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=70, login="target", role="contributor")
provision_user_row(user_id=71, login="contrib", role="contributor")
sign_in_as(
client, user_id=71, gitea_login="contrib",
display_name="Contrib", role="contributor",
)
r = client.post(
"/api/admin/users/70/permission",
json={"state": "granted"},
)
assert r.status_code == 403
client.cookies.clear()
r = client.post(
"/api/admin/users/70/permission",
json={"state": "granted"},
)
assert r.status_code == 401
def test_permission_flip_refuses_invalid_state(app_with_fake_gitea):
"""Pydantic regex pattern refuses anything outside the three
canonical states with 422."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=80, login="targetx", role="contributor")
provision_user_row(user_id=81, login="adminx", role="admin")
sign_in_as(
client, user_id=81, gitea_login="adminx",
display_name="Admin X", role="admin",
)
r = client.post(
"/api/admin/users/80/permission",
json={"state": "banished"},
)
assert r.status_code == 422
def test_permission_flip_no_op_when_state_already_matches(app_with_fake_gitea):
"""An admin flipping a granted user to granted gets 200 with
`changed: false` no audit row, no decided_at update."""
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=90, login="alreadygranted", role="contributor")
provision_user_row(user_id=91, login="adminN", role="admin")
sign_in_as(
client, user_id=91, gitea_login="adminN",
display_name="Admin N", role="admin",
)
before_events = db.conn().execute(
"SELECT COUNT(*) AS n FROM permission_events"
).fetchone()["n"]
r = client.post(
"/api/admin/users/90/permission",
json={"state": "granted"},
)
assert r.status_code == 200
body = r.json()
assert body["changed"] is False
after_events = db.conn().execute(
"SELECT COUNT(*) AS n FROM permission_events"
).fetchone()["n"]
assert after_events == before_events
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "rfc-app-frontend",
"version": "0.10.0",
"version": "0.9.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "rfc-app-frontend",
"version": "0.10.0",
"version": "0.9.0",
"dependencies": {
"@codemirror/commands": "^6.10.3",
"@codemirror/lang-markdown": "^6.5.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "rfc-app-frontend",
"private": true,
"version": "0.10.0",
"version": "0.9.0",
"type": "module",
"scripts": {
"dev": "vite",
+48
View File
@@ -1889,6 +1889,54 @@
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; }
+13
View File
@@ -11,6 +11,7 @@ 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'
@@ -111,6 +112,9 @@ 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
@@ -153,6 +157,7 @@ export default function App() {
<Route path="/login" element={<Login />} />
<Route path="/beta-pending" element={<BetaPending viewer={viewer} />} />
<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>} />
@@ -221,6 +226,14 @@ function PhilosophyWithSidebar({ viewer }) {
)
}
function DocsWithSidebar({ viewer }) {
return (
<main className="chrome-pane">
<Docs authenticated={!!viewer} />
</main>
)
}
function NotificationSettingsWithSidebar({ viewer }) {
return (
<main className="chrome-pane">
+17
View File
@@ -632,6 +632,10 @@ 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).
@@ -657,6 +661,19 @@ export async function setUserMute(userId, muted) {
}))
}
// v0.9.0 — roadmap item #7. Flip a user's permission_state between
// 'pending', 'granted', and 'revoked'. The Users tab on the admin
// page wires Grant / Revoke buttons against this endpoint; the
// returned `changed` flag is false when the requested state already
// matched the row.
export async function setUserPermission(userId, state) {
return jsonOrThrow(await fetch(`/api/admin/users/${userId}/permission`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ state }),
}))
}
export async function listAuditLog({ actionKind, actorUserId, rfcSlug, beforeId, limit } = {}) {
const params = new URLSearchParams()
if (actionKind) params.set('action_kind', actionKind)
+157 -17
View File
@@ -16,6 +16,7 @@ import {
listAdminUsers,
setUserRole,
setUserMute,
setUserPermission,
listAuditLog,
listPermissionEvents,
listGraduationQueue,
@@ -68,12 +69,26 @@ export default function Admin({ viewer }) {
)
}
// Users + role + write-mute (§6.1 / §6.2)
// Users + role + write-mute + permission grant/revoke (§6.1 / §6.2)
//
// v0.9.0 (roadmap item #7) lands the user-management surface. The table
// shows every user with their permission_state, sign-up reason (when
// pending), role, write-mute, and Grant / Revoke controls. State filter
// chips above the table narrow to one bucket the "Pending" chip is the
// admin's daily inbox shape.
const STATE_CHIPS = [
{ value: 'all', label: 'All' },
{ value: 'pending', label: 'Pending' },
{ value: 'granted', label: 'Granted' },
{ value: 'revoked', label: 'Revoked' },
]
function UsersTab() {
const [users, setUsers] = useState(null)
const [busy, setBusy] = useState({})
const [error, setError] = useState(null)
const [stateFilter, setStateFilter] = useState('all')
async function refresh() {
setError(null)
@@ -113,42 +128,122 @@ 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">
Role changes write to <code>permission_events</code>. The §6.2
write-mute applies to contributors only promote to admin to
remove a user's ability to write without silencing them.
The pending bucket is the beta-access review queue (§6.1 /
v0.8.0). Grant or revoke writes to <code>permission_events</code>
and stamps <code>permission_decided_by</code> +{' '}
<code>permission_decided_at</code>. Role and write-mute controls
retain their v0.7.0 semantics promote to admin to remove a
user's ability to write without silencing them.
</p>
</header>
{error && <p className="settings-note warning">{error}</p>}
<table className="admin-table">
<div className="admin-filter-chips">
{STATE_CHIPS.map(chip => (
<button
key={chip.value}
type="button"
className={`admin-chip${stateFilter === chip.value ? ' active' : ''}`}
onClick={() => setStateFilter(chip.value)}
>
{chip.label} <span className="admin-chip-count">{counts[chip.value] ?? 0}</span>
</button>
))}
</div>
{filtered.length === 0 ? (
<p className="muted">No users in this bucket.</p>
) : (
<table className="admin-table admin-users-table">
<thead>
<tr>
<th>User</th>
<th>State</th>
<th>Role</th>
<th>Write-muted</th>
<th>Signed up</th>
<th>Last seen</th>
</tr>
</thead>
<tbody>
{users.map(u => (
<tr key={u.id}>
{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">@{u.gitea_login}</span>
<span className="muted">{u.display_name}</span>
<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 => changeRole(u.id, e.target.value)}
disabled={!!busy[u.id]}
onChange={e => onChangeRole(e.target.value)}
disabled={busy}
>
<option value="contributor">Contributor</option>
<option value="admin">Admin</option>
@@ -161,8 +256,8 @@ function UsersTab() {
<input
type="checkbox"
checked={!!u.muted}
onChange={e => toggleMute(u.id, e.target.checked)}
disabled={!!busy[u.id]}
onChange={e => onToggleMute(e.target.checked)}
disabled={busy}
/>
{u.muted ? 'Muted' : 'Active'}
</label>
@@ -170,11 +265,56 @@ function UsersTab() {
<span className="muted">N/A</span>
)}
</td>
<td className="muted">{u.last_seen_at}</td>
<td className="muted">{u.created_at || '—'}</td>
<td className="muted">{u.last_seen_at || '—'}</td>
</tr>
))}
</tbody>
</table>
{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>
)}
</div>
)
}
+5 -2
View File
@@ -27,8 +27,11 @@ export default function BetaPending({ viewer }) {
{isPending ? (
<>
<p>
Thanks for telling us a bit about yourself. An admin will
review your request and get back to you as soon as we can.
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
+51
View File
@@ -0,0 +1,51 @@
// `/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>
)
}