Slice 7: §14 chrome + settings and admin neighborhoods

§14.1 richer landing, §14.2 /philosophy route (disk-backed), §14.3
persistent About link. /settings/notifications surfaces Slice 6's
preferences/quiet-hours/mute/watches endpoints. /admin home base
consolidates role management, the §6.2 write-mute, the audit-log
viewer, the permission-events log, and the §13.2 graduation queue.

Backend: backend/app/philosophy.py, backend/app/api_admin.py (seven
admin endpoints + user-search), GET /api/users/me/notification-mutes.
Frontend: Landing.jsx (deck), Philosophy.jsx, NotificationSettings.jsx,
Admin.jsx, App.jsx routing for the chrome surfaces.

Tests: backend/tests/test_chrome_vertical.py — 13 cases. Full suite
75/75 green.

Spec corrections: §14.2 (PHILOSOPHY.md source is a deployment-time
decision), §17 (admin block extended to name the seven new endpoints
+ user-search and notification-mutes read). §19.1 rewritten for
Slice 8 hardening; §19.2 grew four candidates (owner succession,
mute-from-actor, the "Following since <date>" disclosure, audit-log
row prose).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-05-24 23:40:49 -07:00
parent f67d0aa0db
commit 060fa408a2
14 changed files with 2722 additions and 158 deletions
+169 -84
View File
@@ -1675,10 +1675,14 @@ novel.
### 14.2 The `/philosophy` route
Authenticated and anonymous visitors alike can reach `/philosophy`,
which renders the full body of `PHILOSOPHY.md` from the meta repo. The
content is sourced from the meta repo's main branch, cached and
refreshed on the same cadence as RFC bodies (§4). The page is plain
markdown rendering with no editing affordance.
which renders the full body of `PHILOSOPHY.md`. The content is cached
in the app process and refreshed on demand; the source of the file is
a deployment-time decision — Slice 7's build sources it from the app
repo (the file lives alongside `SPEC.md`, since the philosophy is the
framework's design document rather than an RFC entry), and a
`PHILOSOPHY_PATH` env var can point at a meta-repo working-tree clone
or any other sync target if a deployment prefers that shape. The page
is plain markdown rendering with no editing affordance.
### 14.3 Persistent "About" link
@@ -2318,9 +2322,35 @@ 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.
- `POST /api/admin/users/<id>/role` — set role (owner/admin only).
- `POST /api/admin/users/<id>/mute` — mute/unmute (the §6.2 app-wide
write-mute, not the §15.8 notification mutes).
- `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
earns its own ceremony (§19.2). Writes a `permission_events` row.
- `POST /api/admin/users/<id>/mute` — set the §6.2 app-wide
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.
- `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
the surface can render row prose without a second round-trip.
- `GET /api/admin/permission-events` — paged read of
`permission_events` (role changes, write-mute toggles), joined
against `users` for actor and subject. Same `before_id` paging.
- `GET /api/admin/graduation-queue` — the §13.2-ready set: returns
super-drafts partitioned into `ready` (owners set, zero open
body-edit PRs) and `blocked` (one or both preconditions missing),
with the precondition shape carried in each row.
- `GET /api/users/me/notification-mutes` — list the §15.8 per-user
mutes the signed-in user has set, joined against `users` for the
rendered handle and display name. The companion read endpoint to
the add/delete pair.
- `GET /api/users/search` — typeahead over `gitea_login` and
`display_name`, ten-row cap, prefix-first ranking. Powers the
§15.8 mute-add typeahead in `/settings/notifications`. Excludes
the caller. Open to any authenticated viewer.
- `POST /api/stars/<slug>` — star/unstar.
- `POST /api/webhooks/gitea` — webhook receiver.
- `GET /api/notifications` — list inbox rows for the signed-in user,
@@ -2405,93 +2435,109 @@ surface. With Topic 13 folded in, the structural surface is
complete. What follows is no longer "topics that block specifying
v1" but "topics to address during or shortly after the v1 build."
### 19.1 Next slice: the §14 chrome and the settings neighborhood
### 19.1 Next slice: hardening
Slice 6 of the build has landed. The §15 notifications surface runs
end-to-end against the local Gitea — every `actions` row whose
`action_kind` maps to a §15.1 event fans out through
`notify.fan_out_from_action`, called inline from `bot._log` and
from the graduation orchestrator's `_audit`. Chat-message inserts
take a parallel path through `notify.fan_out_chat_message` from
inside `chat.append_user_message`, since chat doesn't flow through
the bot wrapper. The §15.6 auto-watch upsert sits in the same
chokepoint — every substantive gesture either creates a `watching`
row or bumps `last_participation_at` for the 90-day decay timer.
Slice 7 of the build has landed. The §14 chrome, the
`/settings/notifications` neighborhood, and the `/admin` home base
all run end-to-end against the local Gitea, and the next slice has
the v1 surface fully wrapped — what remains is the hardening pass
that lets a single-operator deployment actually run.
The §15.4 email loop runs through an SMTP adapter with a stdout
fallback for dev — the in-memory `_SENT` buffer is what the
integration tests read from. The per-category dispatch holds during
§15.8 quiet hours; on window-end, `email.flush_pending` bundles
above the §15.4 threshold into a single "Activity while you were
away" mail. The signed-URL unsubscribe path flips a single category
column to zero; the bounce webhook flips the new `email_opt_out_all`
column (migration `008_email_opt_out.sql`).
The §14.1 landing page now carries the title, the subtitle, the
short-form pitch from `PHILOSOPHY.md`, the sign-in affordance, the
secondary "Read the full philosophy" link, and a three-item deck
underneath the pitch that names what the framework is — one word per
RFC, argued in public with the model, graduation as the load-bearing
moment. The §14.2 `/philosophy` route reads `PHILOSOPHY.md` from
disk (via `backend/app/philosophy.py`, configurable through the
`PHILOSOPHY_PATH` env var) and renders it inline with the existing
`marked` library — the same renderer the proposal preview already
uses. §14.3's persistent About link sits in the header next to the
inbox badge and the new Settings / Admin (admin-only) entries; the
header's visual budget stays tight, and each entry reads as a quiet
text link rather than a button.
The §15.5 digest is a `DigestScheduler` wrapping `cache.Reconciler`'s
shape, with a `run_tick` seam the tests drive synchronously. Each
tick releases held emails, runs the §15.6 90-day decay sweep, and
assembles per-cadence digests where the window has rolled over.
The §15.5 exclusion rules (already-emailed, already-read,
personal-direct-excluded) keep two consecutive ticks idempotent.
The notification-settings surface (`/settings/notifications`,
`frontend/src/components/NotificationSettings.jsx`) lands the five
sub-sections the §15 endpoints already supported: the §15.4 per-
category email toggles (with the `email_watched_churn` toggle
permanently disabled and the §15.4 refusal tooltip inline), the
§15.5 digest cadence dropdown, the §15.8 quiet-hours editor (three
inputs against `Intl.supportedValuesOf('timeZone')`, with all-three-
or-clear validation enforced server-side), the §15.6 watches
overview (per-row state selector that flips `set_by` to `explicit`
on override), and the §15.8 per-user mute list with an unmute
affordance and a typeahead add. Owners and admins see the §15.8
mute-list with the "cannot mute" prose inline. The §15.4 email
footer's `Manage all preferences` link — wired in Slice 6 — now
resolves to a real surface.
§15.2 / §15.3 / §15.7 / §15.8 surface as fourteen endpoints in
`backend/app/api_notifications.py`, plus the chat-seen advance on
`api_branches` and the existing PR seen-cursor on `api_prs` — both
extended to call `notify.reconcile_seen_advance` so the §15.7
visit-advances-cursor loop closes back into the inbox-row read
state. The SSE stream holds a per-user subscriber queue keyed by
user_id; multiple browser tabs see the same events.
The admin home base (`/admin`, `frontend/src/components/Admin.jsx`)
runs as a tabbed left-rail with four panels: Users (role management
+ §6.2 write-mute, with the role-grant constraints enforced
server-side per §6.1 — only owners may grant owner; owners cannot
self-demote on the role endpoint), Graduation queue (super-drafts
partitioned by §13.2 readiness — owners set and zero blocking body-
edit PRs), Audit log (paged read of `actions` with filter chips for
`action_kind`, `actor_user_id`, and `rfc_slug`), and Permission
events (paged read of `permission_events` showing the role and
mute history). Every `/api/admin/*` endpoint guards independently
through `require_admin`, and the User-search endpoint (open to all
authenticated viewers) powers both the admin user-roster and the
mute typeahead.
On the frontend, `App.jsx` grew a header badge (cap "99+",
clicking opens the inbox overlay), an SSE-driven counter that
surfaces personal-direct toasts (own-name signals) and live-view
toasts (events landing on the slug the user is viewing). The
inbox is `Inbox.jsx` — three filter chips (Unread only, RFC,
Category), a Bundle toggle, and a "Mark all read (under filter)"
button. `ToastHost.jsx` caps four visible at once with auto-dismiss.
`backend/app/api_admin.py` carries the seven new admin endpoints
plus the user-search. `backend/app/philosophy.py` carries the
disk-backed `/api/philosophy` source. `backend/app/api_notifications.py`
grew one read endpoint (`GET /api/users/me/notification-mutes`) for
the settings page's mute list. The §17 admin block was extended in
this corrected spec to name the seven endpoints; §14.2 was
corrected to acknowledge the deployment-time decision about where
`PHILOSOPHY.md` lives.
The §15.9 attribution rule fell out cleanly: every `notifications`
row carries `actor_user_id` resolved from the `actions.actor_user_id`
in the originating audit row (the underlying user, never the bot).
System-generated events (digest emission, 90-day decay) leave
`actor_user_id` NULL and render as "the app." AI participation
events landed as null-system per §19.2's candidate naming — when a
chat message authored by an AI provider goes through, no actor row
is written, since the LLM call doesn't have a user_id; the topic
folder for "AI participation as a notification source" in §19.2
remains open for explicit settling.
Slice 7 ships covered by `backend/tests/test_chrome_vertical.py`
thirteen integration tests covering the philosophy route for both
anonymous and authenticated callers, the §15.4 / §15.5 / §15.8
preferences round-trip (including the permanent `email_watched_churn`
refusal), the quiet-hours all-or-nothing validation, the §15.8 mute
add/list/unmute round-trip, the user-search typeahead, the admin
role and write-mute round-trips with their `permission_events`
audit, the §6.1 refusal of owner-grant by non-owners, the audit-log
filter chips, the graduation-queue partition under both
preconditions, and the permission-events listing. The full Slices
17 test suite is 75/75 green.
Slice 6 ships covered by `backend/tests/test_notifications_vertical.py`
— seventeen integration tests covering the producer-side fan-out
on the propose/merge/decline chain, §15.6 auto-watch, the §15.2
inbox listing with filter chips, the §15.7 chat-seen reconciler,
the §15.8 per-user mute and the per-RFC mute, the §15.4 email-
bounce webhook flipping the global opt-out, the `/email/unsubscribe`
signed-URL path, the §15.8 quiet-hours email hold, the §15.5
digest's emit-then-skip behavior across two consecutive ticks,
preferences and quiet-hours round-trips, the explicit-watch
override that prevents auto-downgrade, and the SSE subscriber/
broadcast substrate. The full Slices 16 test suite is 62/62 green.
**Slice 8 is the hardening pass — the last slice of the v1 build.**
Three pieces hang together:
**Slice 7 is the §14 chrome plus the natural notification-settings
neighbor.** With every structural beat live, what remains for v1
is the chrome the framework wraps itself in. §14 commits the
landing page (the unauthenticated visitor's first read), the
`/philosophy` route (PHILOSOPHY.md surfaced inline), and the
persistent About link in the header. Slice 6 left the §15
preferences / quiet-hours / mute / watches endpoints in place
but with no chrome — the natural follow-on is `/settings/notifications`
exposing the per-category toggles, the digest cadence dropdown,
the quiet-hours editor, the watches overview, and the per-user
mute list. The §19.2 "admin surfaces" candidate is the second
natural neighbor — role management, the §6.2 app-wide write-mute,
the audit-log viewer, the graduation-readiness queue, all
consolidated where the chrome can hold them. Slice 7 picks the
framing and ships the three pieces together since they share an
information architecture.
The §12 30/90 branch-hygiene timers — the formalized policy that
closes the loop on §11.5's branch lifecycle (open → merged → 30d
read-only → 90d deleted-by-bot, with the per-user-message-cursor
preservation contract). The wiring is a scheduled task next to the
§15.5 digest scheduler; the §10.7 90-day deletion timer Slice 3
left deferred lives here too.
An end-to-end smoke pass over the working surfaces — propose →
super-draft → branch → PR → merge → graduate → active-RFC PR →
notification fans out → inbox → email — to catch the integration
seams a per-slice test wouldn't. Plus the §19.2 candidates the
hardening pass is the natural place to fold in: cache bootstrap
from a meta repo (the audit-log-first attribution shape Slice 1
chose, exercised against a meta repo with history the bot did not
author), branch-name path routing (converting every
`branches/<branch>` to `{branch:path}` with route-ordering
discipline), and the small Slice-2-onward follow-ons that are
deferred until the hardening pass demands them.
The dev/prod deployment shape — the `deploy/` directory already
has the nginx vhost, the systemd unit, and a runbook stub; Slice 8
proves the bring-up against a fresh host, settles the secret-
material handling (the existing `.env.example` plus the §15.4
SMTP wiring), and lands the README updates that let a new operator
get from `git clone` to a signed-in browser.
The next build session should read `SPEC.md`, `README.md`,
`docs/DEV.md`, and this §19.1 entry and pick up Slice 7 cleanly
`docs/DEV.md`, and this §19.1 entry and pick up Slice 8 cleanly
without re-briefing. The working agreement in §19.3 continues to
apply: implement the slice, correct the spec only where running
code reveals it was wrong at a structural level, accumulate new
@@ -2785,6 +2831,45 @@ binding.
or signature verification (Sendgrid's signed events, AWS SES's
SNS topic signature, etc.). Trivial to add per provider; the
routing-and-flip-the-column logic doesn't change.
- **Owner succession ceremony.** Slice 7's `POST /api/admin/users/<id>/role`
refuses self-demotion ("Use the explicit succession path to change
your own role") because owner-zero is the only owner bootstrap path
per §6.1 and a careless self-downgrade could orphan the role. The
explicit succession path — how an owner steps down, whether owner-
zero needs a co-owner present, how the `OWNER_GITEA_LOGIN` env var
relates to the seated-owner set after the bootstrap moment — is the
natural follow-on once a real owner-transition scenario shows up.
Touches §6.1 (the owner-role bootstrap rule), §17 (the admin role
endpoint), and possibly §3.1 (state-transition shape if owner
changes are themselves a tracked transition).
- **Mute-from-actor on inbox rows and chat messages.** Slice 7's
notification-settings page exposes the per-user mute list with an
unmute affordance, and an intentionally clumsy typeahead for the
add path. The natural add path — clicking the actor on an inbox
row or a chat message — is the §19.2 candidate of its own this
slice was always going to surface. Touches §15.8 (the mute add
ergonomics), §15.2 (the inbox row's actor slot), and §8.12 (the
chat message's author chip). Small scope, defer-able until the
typeahead-only path proves annoying.
- **The "Following since &lt;date&gt;" disclosure on the RFC view header.**
§15.6 commits this disclosure on the RFC view's header after an
auto-decay. Slice 7 lands the watches overview at
`/settings/notifications` — the centralized read — and defers the
per-RFC chrome to a later session, since the disclosure earns its
surface only once auto-decay has actually fired against a watch in
production use. Touches §15.6 (the decay timer's UX) and §8.1 (the
RFC view's header strip).
- **Audit-log row prose translation.** Slice 7's `/admin → Audit log`
surface renders `action_kind` as the raw enum (`merge_branch_pr`,
`propose_rfc`, `graduate_step_complete`, etc.) inside a `<code>`
span. Admins know the kinds; a contributor-facing surface — if one
ever earns its place — would need a per-kind English render
("merged the body-edit PR", "proposed RFC", "completed the
repo-create step of graduation"). The translation table lives near
`notify.SUMMARY` per §15.9 — a future session can lift the same
surface to the admin view if usage shows reviewers want it.
Defer-able until evidence of demand. Touches §6.5 (the audit-log
surface) and §17 (the audit endpoint's prose shape).
- **Per-user mute exemption checks for arbiters.** §15.8 commits
that arbiters cannot mute participants on RFCs where they hold
authority. Slice 6's check uses "the muted user has a watches
+28 -1
View File
@@ -17,7 +17,18 @@ from typing import Any
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from . import api_branches, api_graduation, api_notifications, api_prs, auth, db, entry as entry_mod, cache
from . import (
api_admin,
api_branches,
api_graduation,
api_notifications,
api_prs,
auth,
db,
entry as entry_mod,
cache,
philosophy,
)
from .bot import Bot
from .config import Config
from .gitea import Gitea, GiteaError
@@ -58,6 +69,22 @@ def make_router(
# Slice 6: §15 notifications surface (inbox, watches, prefs,
# quiet hours, per-user mute, email unsubscribe, bounce webhook).
router.include_router(api_notifications.make_router(config))
# Slice 7: §14 chrome (/philosophy read endpoint, user search for
# the §15.8 mute typeahead) and the §6/§17 admin surfaces
# (role, write-mute, audit-log, graduation-readiness queue).
router.include_router(api_admin.make_router(config))
# ---------------------------------------------------------------
# §14.2: /api/philosophy — PHILOSOPHY.md served verbatim.
# No auth gate; anonymous visitors reach `/philosophy` per §14.1.
# The body is read once at process start and refreshed on demand
# via the reconciler; see backend/app/philosophy.py.
# ---------------------------------------------------------------
@router.get("/api/philosophy")
async def get_philosophy() -> dict[str, Any]:
payload = philosophy.load()
return {"body": payload["body"]}
# ---------------------------------------------------------------
# Auth surface — extends the prototype's pattern but reads role
+397
View File
@@ -0,0 +1,397 @@
"""§17 admin endpoints + Slice 7 user search.
The admin's repertoire grew across the prior slices without earning a
centralized home: role grants (§6.1), the §6.2 app-wide write-mute, the
§6.5 / §5 audit logs, and the §13 graduation-readiness queue. Slice 7
consolidates them behind `/api/admin/*` so the chrome can hold them in
one tabbed surface (`Admin.jsx`).
The endpoints in this module:
- `GET /api/admin/users` list users with role + mute
- `POST /api/admin/users/<id>/role` set role per §6.1
- `POST /api/admin/users/<id>/mute` set the §6.2 write-mute
- `GET /api/admin/audit` paged `actions` log
- `GET /api/admin/permission-events` paged `permission_events` log
- `GET /api/admin/graduation-queue` super-drafts ready to graduate
- `GET /api/users/search?q=` typeahead for §15.8 mute add
Permission gates: every `/api/admin/*` endpoint requires
`require_admin` (owner or admin); the role-change endpoint additionally
refuses any non-owner caller granting `owner`, since owner-zero is the
only owner bootstrap path per §6.1 and a downgrade from owner needs the
sitting owner's hand. The user-search endpoint is open to any
authenticated viewer it powers the §15.8 mute typeahead and contains
no privileged information beyond what every authenticated viewer
already sees in the catalog and chat-author surfaces.
"""
from __future__ import annotations
import json
from typing import Any
from fastapi import APIRouter, HTTPException, Query, Request
from pydantic import BaseModel, Field
from . import auth, db
from .config import Config
# ---------------------------------------------------------------------------
# Pydantic bodies
# ---------------------------------------------------------------------------
class RoleBody(BaseModel):
role: str = Field(pattern="^(owner|admin|contributor)$")
class MuteBody(BaseModel):
muted: bool
# ---------------------------------------------------------------------------
# Router
# ---------------------------------------------------------------------------
def make_router(config: Config) -> APIRouter:
del config # unused for now; reserved for future per-deployment knobs
router = APIRouter()
# ----- User listing -----
@router.get("/api/admin/users")
async def list_users(request: Request) -> dict[str, Any]:
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
"""
).fetchall()
return {
"items": [
{
"id": r["id"],
"gitea_login": r["gitea_login"],
"display_name": r["display_name"],
"email": r["email"] or "",
"role": r["role"],
"muted": bool(r["muted"]),
"created_at": r["created_at"],
"last_seen_at": r["last_seen_at"],
}
for r in rows
]
}
# ----- Role change (§6.1) -----
@router.post("/api/admin/users/{user_id}/role")
async def set_role(user_id: int, body: RoleBody, request: Request) -> dict[str, Any]:
viewer = auth.require_admin(request)
target = db.conn().execute(
"SELECT id, role, gitea_login FROM users WHERE id = ?", (user_id,)
).fetchone()
if target is None:
raise HTTPException(404, "User not found")
# §6.1: only an owner can grant `owner`, and only an owner can
# revoke an owner. Admins can flip contributor ↔ admin freely.
if body.role == "owner" and viewer.role != "owner":
raise HTTPException(403, "Only an owner can grant the owner role")
if target["role"] == "owner" and viewer.role != "owner":
raise HTTPException(403, "Only an owner can change an owner's role")
# An owner cannot demote themselves — that would orphan owner-zero
# if they are the only owner, and the role-change UI should not
# smuggle a "downgrade self" path through this endpoint.
if target["id"] == viewer.user_id and body.role != viewer.role:
raise HTTPException(403, "Use the explicit succession path to change your own role")
before = target["role"]
if before == body.role:
return {"ok": True, "role": body.role, "changed": False}
db.conn().execute(
"UPDATE users SET role = ? WHERE id = ?", (body.role, user_id),
)
db.conn().execute(
"""
INSERT INTO permission_events
(actor_user_id, subject_user_id, event_kind, details)
VALUES (?, ?, 'role_changed', ?)
""",
(
viewer.user_id,
user_id,
json.dumps({"before": before, "after": body.role}),
),
)
return {"ok": True, "role": body.role, "changed": True}
# ----- Write-mute (§6.2) -----
@router.post("/api/admin/users/{user_id}/mute")
async def set_mute(user_id: int, body: MuteBody, request: Request) -> dict[str, Any]:
viewer = auth.require_admin(request)
target = db.conn().execute(
"SELECT id, role, muted 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 write-mute yourself")
# §6.2 + §6.1: admins/owners are not write-mutable. The write-mute
# is a contributor-only refusal — an admin's authority is the
# role-change channel, not a mute.
if target["role"] in ("owner", "admin"):
raise HTTPException(
403,
"Owners and admins cannot be write-muted — change the role instead",
)
before = bool(target["muted"])
after = bool(body.muted)
if before == after:
return {"ok": True, "muted": after, "changed": False}
db.conn().execute(
"UPDATE users SET muted = ? WHERE id = ?", (1 if after else 0, user_id),
)
db.conn().execute(
"""
INSERT INTO permission_events
(actor_user_id, subject_user_id, event_kind, details)
VALUES (?, ?, ?, ?)
""",
(
viewer.user_id,
user_id,
"muted" if after else "restored",
json.dumps({"before": before, "after": after}),
),
)
return {"ok": True, "muted": after, "changed": True}
# ----- Audit log (`actions` + `permission_events`) -----
@router.get("/api/admin/audit")
async def list_audit(
request: Request,
action_kind: str | None = None,
actor_user_id: int | None = None,
rfc_slug: str | None = None,
limit: int = Query(default=100, ge=1, le=500),
before_id: int | None = None,
) -> dict[str, Any]:
auth.require_admin(request)
clauses: list[str] = []
args: list[Any] = []
if action_kind:
clauses.append("a.action_kind = ?")
args.append(action_kind)
if actor_user_id is not None:
clauses.append("a.actor_user_id = ?")
args.append(actor_user_id)
if rfc_slug:
clauses.append("a.rfc_slug = ?")
args.append(rfc_slug)
if before_id is not None:
clauses.append("a.id < ?")
args.append(before_id)
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
rows = db.conn().execute(
f"""
SELECT a.id, a.actor_user_id, a.on_behalf_of, a.action_kind,
a.rfc_slug, a.branch_name, a.pr_number, a.bot_commit_sha,
a.details, a.created_at,
u.gitea_login AS actor_login, u.display_name AS actor_display
FROM actions a
LEFT JOIN users u ON u.id = a.actor_user_id
{where}
ORDER BY a.id DESC
LIMIT ?
""",
(*args, limit),
).fetchall()
# The distinct-action-kinds list powers the filter chip in
# `Admin.jsx`; cheap to compute alongside the page since the
# action_kind set is bounded.
kinds = [
r["action_kind"]
for r in db.conn().execute(
"SELECT DISTINCT action_kind FROM actions ORDER BY action_kind"
)
]
return {
"items": [
{
"id": r["id"],
"action_kind": r["action_kind"],
"actor_user_id": r["actor_user_id"],
"actor_login": r["actor_login"],
"actor_display": r["actor_display"],
"on_behalf_of": r["on_behalf_of"],
"rfc_slug": r["rfc_slug"],
"branch_name": r["branch_name"],
"pr_number": r["pr_number"],
"bot_commit_sha": r["bot_commit_sha"],
"details": _safe_json(r["details"]),
"created_at": r["created_at"],
}
for r in rows
],
"action_kinds": kinds,
"has_more": len(rows) == limit,
}
@router.get("/api/admin/permission-events")
async def list_permission_events(
request: Request,
limit: int = Query(default=100, ge=1, le=500),
before_id: int | None = None,
) -> dict[str, Any]:
auth.require_admin(request)
clauses: list[str] = []
args: list[Any] = []
if before_id is not None:
clauses.append("p.id < ?")
args.append(before_id)
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
rows = db.conn().execute(
f"""
SELECT p.id, p.event_kind, p.details, p.created_at,
p.actor_user_id, p.subject_user_id,
au.gitea_login AS actor_login, au.display_name AS actor_display,
su.gitea_login AS subject_login, su.display_name AS subject_display
FROM permission_events p
LEFT JOIN users au ON au.id = p.actor_user_id
LEFT JOIN users su ON su.id = p.subject_user_id
{where}
ORDER BY p.id DESC
LIMIT ?
""",
(*args, limit),
).fetchall()
return {
"items": [
{
"id": r["id"],
"event_kind": r["event_kind"],
"actor_login": r["actor_login"],
"actor_display": r["actor_display"],
"subject_login": r["subject_login"],
"subject_display": r["subject_display"],
"details": _safe_json(r["details"]),
"created_at": r["created_at"],
}
for r in rows
],
"has_more": len(rows) == limit,
}
# ----- Graduation-readiness queue (§13.2) -----
@router.get("/api/admin/graduation-queue")
async def graduation_queue(request: Request) -> dict[str, Any]:
auth.require_admin(request)
# §13 / §13.2: a super-draft is ready when (a) it has at least one
# owner claimed via §13.1 and (b) it has zero open meta_body_edit
# PRs. We compute both with one pass, returning the ready set and
# the not-yet-ready set so the admin sees what is gating each.
rows = db.conn().execute(
"""
SELECT slug, title, owners_json, arbiters_json, tags_json,
proposed_at, last_entry_commit_at
FROM cached_rfcs
WHERE state = 'super-draft'
ORDER BY COALESCE(last_entry_commit_at, proposed_at) DESC
"""
).fetchall()
items_ready: list[dict[str, Any]] = []
items_blocked: list[dict[str, Any]] = []
for r in rows:
owners = json.loads(r["owners_json"] or "[]")
blocking = db.conn().execute(
"""
SELECT COUNT(*) AS n FROM cached_prs
WHERE rfc_slug = ? AND state = 'open' AND pr_kind = 'meta_body_edit'
""",
(r["slug"],),
).fetchone()["n"]
payload = {
"slug": r["slug"],
"title": r["title"],
"owners": owners,
"tags": json.loads(r["tags_json"] or "[]"),
"proposed_at": r["proposed_at"],
"last_entry_commit_at": r["last_entry_commit_at"],
"blocking_prs": blocking,
"owners_set": len(owners) > 0,
}
if payload["owners_set"] and blocking == 0:
items_ready.append(payload)
else:
items_blocked.append(payload)
return {"ready": items_ready, "blocked": items_blocked}
# ----- User search (typeahead for §15.8 mute add) -----
@router.get("/api/users/search")
async def search_users(
request: Request, q: str = Query(default="", min_length=0, max_length=80),
) -> dict[str, Any]:
viewer = auth.require_user(request)
needle = q.strip().lower()
# An empty query returns recent users; a non-empty query matches
# against gitea_login and display_name with a prefix-first
# ranking so the typeahead surfaces obvious matches early.
if not needle:
rows = db.conn().execute(
"""
SELECT id, gitea_login, display_name, role
FROM users WHERE id != ?
ORDER BY last_seen_at DESC LIMIT 10
""",
(viewer.user_id,),
).fetchall()
else:
like = f"%{needle}%"
prefix = f"{needle}%"
rows = db.conn().execute(
"""
SELECT id, gitea_login, display_name, role
FROM users
WHERE id != ?
AND (LOWER(gitea_login) LIKE ? OR LOWER(display_name) LIKE ?)
ORDER BY
CASE WHEN LOWER(gitea_login) LIKE ? THEN 0 ELSE 1 END,
LOWER(gitea_login)
LIMIT 10
""",
(viewer.user_id, like, like, prefix),
).fetchall()
return {
"items": [
{
"id": r["id"],
"gitea_login": r["gitea_login"],
"display_name": r["display_name"],
"role": r["role"],
}
for r in rows
]
}
return router
def _safe_json(blob: str | None) -> Any:
if not blob:
return None
try:
return json.loads(blob)
except Exception:
return blob
+30
View File
@@ -293,6 +293,36 @@ def make_router(config: Config) -> APIRouter:
# ----- Per-user notification mute (§15.8) -----
@router.get("/api/users/me/notification-mutes")
async def list_user_mutes(request: Request) -> dict[str, Any]:
"""Slice 7: the §15.8 mute list the settings surface renders.
Joined against users so the settings UI can show @gitea_login and
display_name without a second round-trip.
"""
viewer = auth.require_user(request)
rows = db.conn().execute(
"""
SELECT m.muted_user_id, m.muted_at,
u.gitea_login, u.display_name
FROM notification_user_mutes m
JOIN users u ON u.id = m.muted_user_id
WHERE m.muter_user_id = ?
ORDER BY m.muted_at DESC
""",
(viewer.user_id,),
).fetchall()
return {
"items": [
{
"muted_user_id": r["muted_user_id"],
"gitea_login": r["gitea_login"],
"display_name": r["display_name"],
"muted_at": r["muted_at"],
}
for r in rows
]
}
@router.post("/api/users/{user_id}/notification-mute")
async def add_user_mute(user_id: int, request: Request) -> dict[str, Any]:
viewer = auth.require_user(request)
+66
View File
@@ -0,0 +1,66 @@
"""§14.2 philosophy source.
The spec names PHILOSOPHY.md as the body the `/philosophy` route renders,
"sourced from the meta repo's main branch, cached and refreshed on the
same cadence as RFC bodies (§4)." Slice 7 picks the disk-first shape:
the file is checked into the app repo alongside SPEC.md, since it is
the framework's design document rather than an RFC entry, and reading
it from disk at process start (with a periodic re-read for hot edits)
puts the framework's mission in front of the reader without an extra
Gitea round-trip on the first hit.
If a downstream deployment hosts PHILOSOPHY.md in the meta repo
instead, the `PHILOSOPHY_PATH` env var can point at a working-tree
clone or a sync target; the loader does not care which.
"""
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] / "PHILOSOPHY.md"
_lock = threading.Lock()
_cache: dict | None = None
def _resolved_path() -> Path:
override = os.environ.get("PHILOSOPHY_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`. The reconciler's sweep can call
this with `force=True` to pick up out-of-band edits.
"""
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("PHILOSOPHY.md not found at %s — serving placeholder", path)
text = (
"# PHILOSOPHY.md not found\n\n"
"The deployment is missing its philosophy document. Set "
"PHILOSOPHY_PATH or place PHILOSOPHY.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)
+457
View File
@@ -0,0 +1,457 @@
"""End-to-end integration tests for the Slice 7 vertical (§14 chrome
plus the /settings/notifications and /admin neighborhoods).
The slice is chrome over existing infrastructure the rules live in
§14 / §15 / §6 / §13.2, and the endpoints land in
`backend/app/api.py` (the `/api/philosophy` read), in
`backend/app/api_admin.py` (the `/api/admin/*` set plus `/api/users/search`),
and in `backend/app/api_notifications.py` (`/api/users/me/notification-mutes`,
the list-read counterpart to Slice 6's add/delete pair).
The tests prove:
* `/api/philosophy` returns the PHILOSOPHY.md body to anonymous and
authenticated callers alike, per §14.2's "authenticated and
anonymous visitors alike can reach `/philosophy`."
* The §15.4 / §15.5 / §15.8 preferences round-trip cleanly through
the per-category email toggles, the digest cadence dropdown, the
quiet-hours editor, and the per-user mute list what the
`NotificationSettings.jsx` page exercises end-to-end against the
real backend.
* The §15.8 `email_watched_churn` permanent refusal still reads as
`False` after every preferences round-trip the toggle is
permanently disabled, not silently writable.
* `/api/users/me/notification-mutes` lists the joined rows the
settings page renders.
* `/api/users/search` powers the §15.8 mute typeahead.
* `/api/admin/users` returns the user roster; role-change and the
§6.2 write-mute round-trip through `/api/admin/users/<id>/role`
and `/api/admin/users/<id>/mute`.
* A contributor cannot reach `/api/admin/*`; an admin can.
* The §6.2 write-mute prevents the muted contributor from running
`POST /api/rfcs/propose` (the same `require_contributor` gate the
other write paths use).
* `/api/admin/audit` returns rows filtered by `action_kind`,
`actor_user_id`, and `rfc_slug`, and pages with `before_id`.
* `/api/admin/permission-events` reads the `permission_events`
table populated by the role-change and write-mute endpoints.
* `/api/admin/graduation-queue` returns the §13.2-ready super-drafts
in the `ready` list and the not-yet-ready ones in `blocked`, with
the right precondition shape (owners set, zero blocking
body-edit PRs).
"""
from __future__ import annotations
import json as _json
import pytest
from test_propose_vertical import ( # noqa: F401
FakeGitea,
app_with_fake_gitea,
provision_user_row,
sign_in_as,
tmp_env,
)
from test_super_draft_vertical import seed_super_draft # noqa: F401
PITCH = "Open Human Model is a framework for representing humans."
# ---------------------------------------------------------------------------
# §14.2 — the philosophy route
# ---------------------------------------------------------------------------
def test_philosophy_route_returns_body_to_anonymous_visitors(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
r = client.get("/api/philosophy")
assert r.status_code == 200, r.text
body = r.json()["body"]
# The seam: PHILOSOPHY.md at the repo root carries the §14.1
# framing line. If this assertion ever breaks because the
# philosophy was rewritten, the slug "standardization process"
# is the most stable phrase to anchor against.
assert "standardization process" in body.lower()
def test_philosophy_route_returns_body_to_authenticated_visitors(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
r = client.get("/api/philosophy")
assert r.status_code == 200
assert "standardization process" in r.json()["body"].lower()
# ---------------------------------------------------------------------------
# §15.4 / §15.5 / §15.8 — the notification-settings round-trip
# ---------------------------------------------------------------------------
def test_notification_preferences_round_trip(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
# Defaults per §15.4: personal-direct on, structural off,
# admin-actionable on (contributors ignore it), churn permanently
# off, digest weekly.
r = client.get("/api/users/me/notification-preferences")
assert r.status_code == 200
p = r.json()
assert p["email_personal_direct"] is True
assert p["email_watched_structural"] is False
assert p["email_watched_churn"] is False # §15.4 permanent refusal
assert p["digest_cadence"] == "weekly"
# Flip personal-direct off and watched-structural on; bump cadence
# to daily. The settings page's toggles drive these payloads.
r = client.post("/api/users/me/notification-preferences", json={
"email_personal_direct": False,
"email_watched_structural": True,
"digest_cadence": "daily",
})
assert r.status_code == 200
p = client.get("/api/users/me/notification-preferences").json()
assert p["email_personal_direct"] is False
assert p["email_watched_structural"] is True
assert p["email_watched_churn"] is False # still permanently off
assert p["digest_cadence"] == "daily"
def test_quiet_hours_round_trip_and_partial_refusal(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
# Unset by default.
r = client.get("/api/users/me/quiet-hours")
assert r.status_code == 200
assert r.json() == {"start": None, "end": None, "timezone": None}
# §15.8: all-three-or-nothing. A partial set is refused.
r = client.post("/api/users/me/quiet-hours", json={
"start": "22:00", "end": "08:00", "timezone": None,
})
assert r.status_code == 422
# The full trio round-trips.
r = client.post("/api/users/me/quiet-hours", json={
"start": "22:00", "end": "08:00", "timezone": "America/Los_Angeles",
})
assert r.status_code == 200
q = client.get("/api/users/me/quiet-hours").json()
assert q == {"start": "22:00", "end": "08:00", "timezone": "America/Los_Angeles"}
# Clear with all-null.
r = client.post("/api/users/me/quiet-hours", json={
"start": None, "end": None, "timezone": None,
})
assert r.status_code == 200
assert client.get("/api/users/me/quiet-hours").json() == {
"start": None, "end": None, "timezone": None,
}
def test_user_mute_add_list_and_unmute(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
provision_user_row(user_id=3, login="carol", role="contributor")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
# Empty mute list to start.
r = client.get("/api/users/me/notification-mutes")
assert r.status_code == 200
assert r.json()["items"] == []
# Add — Slice 7's settings page surfaces this via the typeahead.
r = client.post("/api/users/3/notification-mute")
assert r.status_code == 200, r.text
# List — the joined view the settings page renders.
items = client.get("/api/users/me/notification-mutes").json()["items"]
assert len(items) == 1
assert items[0]["gitea_login"] == "carol"
assert items[0]["display_name"] == "Carol"
# Unmute.
r = client.delete("/api/users/3/notification-mute")
assert r.status_code == 200
assert client.get("/api/users/me/notification-mutes").json()["items"] == []
def test_user_search_powers_mute_typeahead(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
provision_user_row(user_id=3, login="carol", role="contributor")
provision_user_row(user_id=4, login="dave", role="contributor")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
# Empty query: recent users, excluding the caller.
r = client.get("/api/users/search")
assert r.status_code == 200
ids = {u["id"] for u in r.json()["items"]}
assert 2 not in ids
assert {3, 4}.issubset(ids)
# Prefix matches gitea_login.
r = client.get("/api/users/search?q=car")
items = r.json()["items"]
assert any(u["gitea_login"] == "carol" for u in items)
# Substring matches display_name (we'd indexed by Capitalize()).
r = client.get("/api/users/search?q=Dave")
items = r.json()["items"]
assert any(u["gitea_login"] == "dave" for u in items)
# ---------------------------------------------------------------------------
# §6 / §17 — the admin neighborhood
# ---------------------------------------------------------------------------
def test_admin_list_users_returns_roster_and_refuses_contributors(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
provision_user_row(user_id=1, login="ben", role="owner")
provision_user_row(user_id=3, login="carol", role="admin")
# Contributor refused.
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
assert client.get("/api/admin/users").status_code == 403
# Owner sees the roster ordered owner → admin → contributor.
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
r = client.get("/api/admin/users")
assert r.status_code == 200
roles = [u["role"] for u in r.json()["items"]]
assert roles[0] == "owner"
assert "admin" in roles
assert "contributor" in roles
def test_admin_role_change_round_trips_and_records_permission_event(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
provision_user_row(user_id=2, login="alice", role="contributor")
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
r = client.post("/api/admin/users/2/role", json={"role": "admin"})
assert r.status_code == 200
assert r.json() == {"ok": True, "role": "admin", "changed": True}
# The user row updated.
row = db.conn().execute("SELECT role FROM users WHERE id = 2").fetchone()
assert row["role"] == "admin"
# A permission_events row landed.
rows = db.conn().execute(
"SELECT event_kind, details FROM permission_events WHERE subject_user_id = 2"
).fetchall()
assert len(rows) == 1
assert rows[0]["event_kind"] == "role_changed"
details = _json.loads(rows[0]["details"])
assert details == {"before": "contributor", "after": "admin"}
# Idempotent: a re-set with the same role returns changed=False.
r = client.post("/api/admin/users/2/role", json={"role": "admin"})
assert r.status_code == 200
assert r.json()["changed"] is False
def test_admin_cannot_grant_owner_unless_owner(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
provision_user_row(user_id=3, login="carol", role="admin")
provision_user_row(user_id=2, login="alice", role="contributor")
# An admin cannot grant `owner`.
sign_in_as(client, user_id=3, gitea_login="carol", display_name="Carol", role="admin")
r = client.post("/api/admin/users/2/role", json={"role": "owner"})
assert r.status_code == 403
# An admin cannot change an owner's role.
r = client.post("/api/admin/users/1/role", json={"role": "admin"})
assert r.status_code == 403
def test_admin_write_mute_round_trip_and_refusals(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
provision_user_row(user_id=2, login="alice", role="contributor")
provision_user_row(user_id=3, login="carol", role="admin")
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
# The §6.2 write-mute: contributor only.
r = client.post("/api/admin/users/3/mute", json={"muted": True})
assert r.status_code == 403 # admins are not write-mutable
r = client.post("/api/admin/users/2/mute", json={"muted": True})
assert r.status_code == 200
assert r.json()["muted"] is True
# And the gate fires when alice tries to write.
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
r = client.post("/api/rfcs/propose", json={
"title": "Open Human Model",
"slug": "ohm",
"pitch": PITCH,
"tags": [],
})
assert r.status_code == 403, r.text
# Restore — the mute audit lands.
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
r = client.post("/api/admin/users/2/mute", json={"muted": False})
assert r.status_code == 200
events = db.conn().execute(
"SELECT event_kind FROM permission_events WHERE subject_user_id = 2 ORDER BY id"
).fetchall()
kinds = [e["event_kind"] for e in events]
assert kinds == ["muted", "restored"]
def test_admin_audit_log_filters_and_pages(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="alice", role="contributor")
provision_user_row(user_id=1, login="ben", role="owner")
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
r = client.post("/api/rfcs/propose", json={
"title": "Open Human Model",
"slug": "ohm",
"pitch": PITCH,
"tags": [],
})
assert r.status_code == 200, r.text
pr_number = r.json()["pr_number"]
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
client.post(f"/api/proposals/{pr_number}/merge")
# Unfiltered: at least the propose + merge land in `actions`.
r = client.get("/api/admin/audit")
assert r.status_code == 200
kinds = [it["action_kind"] for it in r.json()["items"]]
assert "propose_rfc" in kinds
assert "merge_proposal" in kinds
# The distinct-action-kinds list powers the filter chip.
assert "propose_rfc" in r.json()["action_kinds"]
# Filter by rfc_slug.
r = client.get("/api/admin/audit?rfc_slug=ohm")
assert all(it["rfc_slug"] == "ohm" for it in r.json()["items"])
# Filter by action_kind.
r = client.get("/api/admin/audit?action_kind=propose_rfc")
assert all(it["action_kind"] == "propose_rfc" for it in r.json()["items"])
# Filter by actor_user_id.
r = client.get("/api/admin/audit?actor_user_id=2")
assert all(it["actor_user_id"] == 2 for it in r.json()["items"])
def test_admin_graduation_queue_partitions_by_readiness(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
# Two super-drafts: one with owners claimed, one without.
seed_super_draft(fake, slug="ready", title="Ready RFC", pitch="")
seed_super_draft(fake, slug="orphan", title="Orphan RFC", pitch="")
db.conn().execute(
"UPDATE cached_rfcs SET owners_json = ? WHERE slug = 'ready'",
(_json.dumps(["ben"]),),
)
r = client.get("/api/admin/graduation-queue")
assert r.status_code == 200
d = r.json()
ready_slugs = {it["slug"] for it in d["ready"]}
blocked_slugs = {it["slug"] for it in d["blocked"]}
assert ready_slugs == {"ready"}
assert "orphan" in blocked_slugs
# Now add a blocking body-edit PR to the ready slug — it should
# move to blocked even with owners set.
db.conn().execute(
"""
INSERT INTO cached_prs
(rfc_slug, pr_kind, repo, pr_number, title, description, state,
opened_by, opened_at, head_branch, base_branch, head_sha)
VALUES ('ready', 'meta_body_edit', 'wiggleverse/meta', 42, 'edit', '',
'open', 'alice', datetime('now'), 'edit-ready-abc123', 'main', 'sha')
"""
)
d = client.get("/api/admin/graduation-queue").json()
assert {it["slug"] for it in d["ready"]} == set()
assert "ready" in {it["slug"] for it in d["blocked"]}
blocked_ready = next(it for it in d["blocked"] if it["slug"] == "ready")
assert blocked_ready["blocking_prs"] == 1
assert blocked_ready["owners_set"] is True
def test_admin_permission_events_returns_role_and_mute_history(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="ben", role="owner")
provision_user_row(user_id=2, login="alice", role="contributor")
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
client.post("/api/admin/users/2/role", json={"role": "admin"})
client.post("/api/admin/users/2/role", json={"role": "contributor"})
client.post("/api/admin/users/2/mute", json={"muted": True})
r = client.get("/api/admin/permission-events?limit=10")
assert r.status_code == 200
items = r.json()["items"]
# Newest first.
kinds = [it["event_kind"] for it in items]
assert kinds[:3] == ["muted", "role_changed", "role_changed"]
# Subject and actor join populated.
assert all(it["subject_login"] == "alice" for it in items[:3])
assert all(it["actor_login"] == "ben" for it in items[:3])
+132 -41
View File
@@ -29,10 +29,18 @@ rare and surgical and live in the appropriate numbered section per
6. **Notifications per §15.** Last, because every other surface
produces signals the inbox receives — notification correctness
depends on the producers being in place first.
7. **The §14 chrome.** Landing page polish, the `/philosophy` route,
the persistent About link.
7. **The §14 chrome + the settings and admin neighborhoods.**
Landing page polish, the `/philosophy` route, the persistent
About link; the `/settings/notifications` surface that exposes
Slice 6's preferences/quiet-hours/mute/watches endpoints; the
`/admin` home base that consolidates role management, the §6.2
write-mute, the audit-log viewer, and the §13.2 graduation-
readiness queue.
8. **Hardening.** End-to-end tests, dev/prod deployment shape,
the §12 30/90 branch-hygiene timers.
the §12 30/90 branch-hygiene timers, the §19.2 candidates that
cluster with deployment (branch-name path routing, cache
bootstrap from a pre-existing meta repo, in-app metadata-PR
merges, graduation rollback's branch cleanup).
## State of the codebase
@@ -280,6 +288,80 @@ adds the `email_opt_out_all` column to `users` for the bounce
webhook. Topic 13 settled the rest of the §5 surface before the
build started, so no further migrations were needed.
### Slice 7 — shipped
The §14 chrome, the §15 settings neighborhood, and the §6/§17 admin
home base — three surfaces over existing infrastructure.
§14.1's pre-login landing carries the title, the subtitle, the
short-form pitch from [`PHILOSOPHY.md`](../PHILOSOPHY.md), the
sign-in affordance, and a three-item deck that names what the
framework is. §14.2's `/philosophy` route reads `PHILOSOPHY.md`
through [`backend/app/philosophy.py`](../backend/app/philosophy.py)
(disk-backed, configurable via `PHILOSOPHY_PATH`; defaults to the
file at the project root) and renders with `marked`. §14.3's
persistent About link sits in the header alongside Settings (open
to everyone) and Admin (owner/admin only); the chrome's visual
budget stays tight per §14.4.
The notification-settings surface lives at `/settings/notifications`
([`NotificationSettings.jsx`](../frontend/src/components/NotificationSettings.jsx))
and is what the §15.4 email footer's `Manage all preferences` link
resolves to. Five sub-sections: the §15.4 per-category toggles
(with the `email_watched_churn` toggle permanently disabled and the
§15.4 refusal tooltip inline — naming the refusal is what keeps the
contract honest), the §15.5 digest cadence dropdown, the §15.8
quiet-hours editor (three inputs against
`Intl.supportedValuesOf('timeZone')` with all-three-or-clear
validation server-side), the §15.6 watches overview, and the
§15.8 per-user mute list with a typeahead add. Owners and admins
see the "cannot mute" prose inline per §15.8.
The admin home base lives at `/admin`
([`Admin.jsx`](../frontend/src/components/Admin.jsx)) as a four-
tab left-rail: Users (role management + §6.2 write-mute), Graduation
queue (§13.2-ready partition), Audit log (paged `actions` with
filter chips), and Permission events (paged `permission_events`).
Role-grant constraints land server-side in
[`backend/app/api_admin.py`](../backend/app/api_admin.py) per §6.1
— only owners may grant `owner`; owners cannot self-demote on the
role endpoint (the explicit succession path is a §19.2 candidate).
The §6.2 write-mute applies only to contributors; admins and owners
are not write-mutable. Every role and mute change writes a
`permission_events` row joined to `users` for the surface.
| Method | Path | § |
| ------ | --------------------------------------------- | ------- |
| GET | `/api/philosophy` | §14.2 |
| GET | `/api/admin/users` | §6.1 |
| POST | `/api/admin/users/{id}/role` | §6.1 |
| POST | `/api/admin/users/{id}/mute` | §6.2 |
| GET | `/api/admin/audit` | §6.5 |
| GET | `/api/admin/permission-events` | §6.5 |
| GET | `/api/admin/graduation-queue` | §13.2 |
| GET | `/api/users/me/notification-mutes` | §15.8 |
| GET | `/api/users/search` | §15.8 |
Slice 7 ships covered by
[`backend/tests/test_chrome_vertical.py`](../backend/tests/test_chrome_vertical.py) —
thirteen integration tests against the FakeGitea, covering the
philosophy route for anonymous and authenticated callers, the
§15.4 / §15.5 / §15.8 preferences round-trip (including the
permanent `email_watched_churn` refusal), the quiet-hours
all-or-nothing validation, the §15.8 mute add/list/unmute
round-trip, the user-search typeahead, the admin role and
write-mute round-trips with their `permission_events` audit, the
§6.1 refusal of owner-grant by non-owners, the audit-log filter
chips, the graduation-queue partition under both preconditions,
and the permission-events listing. The full Slices 17 test suite
is 75/75 green.
No schema migrations. The two surface-facing spec corrections —
§14.2 (PHILOSOPHY.md source is a deployment-time decision; v1 reads
from the app repo) and the §17 admin block (extended to name the
seven new endpoints) — are the only places the slice's running
code asked the spec to be more honest than it was.
### Slice 5 — shipped
Graduation per §13 in full. The §13.3 five-step transactional sequence
@@ -510,52 +592,61 @@ spec:
## Next slice
**Slice 7: the §14 chrome.**
**Slice 8: hardening — the last slice of the v1 build.**
With Slice 6 shipped, every structural and notification beat the
framework commits to is live: propose, claim, super-draft body
editing, the §10 PR flow against both repo shapes, graduation, and
the §15 inbox/email/digest stack. What remains for v1 is the chrome
that wraps the whole thing — the landing page that brings an
unauthenticated visitor in, the `/philosophy` route that surfaces
[`PHILOSOPHY.md`](../PHILOSOPHY.md) verbatim, the persistent About
link in the header per §14.3, plus the natural neighbors that
Slice 6 left as API-only and that §19.2 names as candidates:
With Slice 7 shipped, every structural beat the spec commits to is
live and every surface the framework exposes has chrome around it.
What remains is the hardening pass that lets a single-operator
deployment actually run end-to-end without hand-holding. Three
pieces hang together:
- **The notification-settings surface** — the actual UI for the
preferences/quiet-hours/mute endpoints Slice 6 wired. Topic 13
settled the schema and the per-category rules; the surface
where a contributor finds the per-category email toggles, the
digest cadence dropdown, the quiet-hours editor, the watches
overview, and the per-user mute list is the natural follow-on.
Likely lives at `/settings/notifications` (the link Slice 6's
emails already point at).
- **The admin neighborhood.** §19.2's "Admin surfaces" candidate.
Role management, the §6.2 app-wide write-mute, the audit-log
viewer, the graduation-readiness queue. Topics 12 and 13 both
expanded the admin's repertoire without giving it a centralized
home; Slice 7 picks the framing.
- **Landing page polish.** Slice 1 stood up a minimal landing for
the unauthenticated path; §14 commits a richer shape — what the
framework is, why it exists, what the visitor's first read should
be, and the sign-in affordance.
- **The `/philosophy` route.** [`PHILOSOPHY.md`](../PHILOSOPHY.md)
rendered inline, reachable from the header on every page, so the
reader can return to the framing without leaving the app.
- **The §12 30/90 branch-hygiene timers.** §11.5 names the branch
lifecycle (open → merged → 30d read-only → 90d deleted-by-bot,
with the per-user message-cursor preservation contract); §12
formalizes the policy. The wiring is a scheduled task next to
the existing `DigestScheduler` — same `run_tick` test-seam shape.
The §10.7 90-day deletion timer Slice 3 left explicitly deferred
lives here too. Touches `cache.Reconciler` (the natural place to
fire the hygiene sweep), `bot.delete_branch` (the §12 actuator,
not yet exercised), and the §19.2 cache-bootstrap topic if the
hygiene sweep also rebuilds branch state from Gitea after a
cache wipe.
- **An end-to-end smoke pass** over the working surfaces. Propose
→ super-draft → branch → PR → merge → graduate → active-RFC PR
→ notification → inbox → email — one or two `test_e2e_smoke.py`
cases that exercise the seams a per-slice test wouldn't. Plus
the §19.2 follow-ons the hardening pass is the natural place to
fold in: branch-name path routing (`{branch:path}` everywhere
with route-ordering discipline), cache bootstrap from a
pre-existing meta repo (the audit-log-first attribution shape
exercised against history the bot did not author), in-app merge
for metadata PRs, the graduation rollback's branch cleanup, and
the small Slice-2-onward follow-ons that are deferred until the
hardening pass demands them.
- **The dev/prod deployment shape.** `deploy/` already carries an
nginx vhost, a systemd unit, and a runbook stub. Slice 8 proves
the bring-up against a fresh host, settles the secret-material
handling (the existing `.env.example` plus the §15.4 SMTP
wiring), wires the §6 / §15.4 SMTP credentials, and lands the
README updates that take a new operator from `git clone` to a
signed-in browser.
What Slice 7 does NOT own:
What Slice 8 does NOT own:
- The §12 30/90 branch-hygiene timers (still Slice 8).
- New surfaces. The v1 surface is complete; the hardening pass is
about making what's there resilient, observable, and operable.
- The §16 deferred items.
- New §15 capabilities — Slice 6 shipped the surface; settings UI
is exposure of what's already there, not new behavior.
- The §19.2 candidate set as a whole — the hardening pass folds in
the candidates that naturally cluster with hygiene timers, cache
rebuild, and deployment; the rest stay queued for post-v1
sessions.
The carryovers Slice 7 inherits — the existing §14 spec text, the
§17 endpoint set including Slice 6's settings endpoints, and the
React Router layout already in place.
The carryovers Slice 8 inherits — the full §11.5 / §12 spec text,
the existing `cache.Reconciler` and `DigestScheduler` shape, the
deploy/ infrastructure, and the 75/75 green test suite.
The next build session should read `SPEC.md`, `README.md`,
`docs/DEV.md`, and `SPEC.md`'s §19.1 and pick up Slice 7 cleanly
`docs/DEV.md`, and `SPEC.md`'s §19.1 and pick up Slice 8 cleanly
without re-briefing. The working agreement in §19.3 continues to
apply: implement the slice, correct the spec only where running
code reveals it was wrong at a structural level, accumulate new
+273
View File
@@ -1218,3 +1218,276 @@
.toast.cat-personal-direct { border-left-color: #d97706; }
.toast.cat-structural { border-left-color: #2563eb; }
.toast.cat-churn { border-left-color: #6b7280; }
/* ── Slice 7: §14 chrome + /settings/notifications + /admin ──────────── */
/* Header chrome the persistent §14.3 About link plus the Settings and
Admin entrypoints. The header's job is to be invisible until the user
reaches for it, so these read as quiet text links rather than buttons. */
.header-about, .header-settings, .header-admin {
color: #ddd; text-decoration: none;
font-size: 12px; letter-spacing: 0.02em;
padding: 4px 8px; border-radius: 4px;
}
.header-about:hover, .header-settings:hover, .header-admin:hover {
color: #fff; background: rgba(255,255,255,0.08);
}
.header-admin {
color: #fbbf24; /* admin link sits a notch warmer to signal authority */
}
/* /philosophy the §14.2 read surface. The body inherits the
prototype's markdown styling; the header is a thin chrome strip. */
.chrome-pane {
flex: 1; min-width: 0; overflow: auto;
padding: 0; background: #fff;
}
.philosophy-page {
max-width: 760px; margin: 0 auto;
padding: 24px 32px 64px;
}
.philosophy-header {
display: flex; align-items: center; gap: 12px;
padding: 12px 0 18px;
border-bottom: 1px solid #f0f0ee;
margin-bottom: 28px;
}
.philosophy-back {
border: none; background: none; cursor: pointer;
color: #4b5563; font-size: 13px;
padding: 4px 8px; border-radius: 4px;
}
.philosophy-back:hover { background: #f3f4f6; color: #111; }
.philosophy-title {
font-size: 13px; color: #6b7280;
text-transform: uppercase; letter-spacing: 0.08em;
}
.philosophy-signin {
margin-left: auto;
font-size: 13px; color: #4b5563; text-decoration: none;
}
.philosophy-signin:hover { color: #111; text-decoration: underline; }
.philosophy-body h1 {
font-size: 28px; font-weight: 700; margin: 0 0 24px;
letter-spacing: -0.01em;
}
.philosophy-body h2 {
font-size: 19px; font-weight: 600; margin: 36px 0 12px;
color: #111;
}
.philosophy-body h3 {
font-size: 15px; font-weight: 600; margin: 24px 0 10px;
}
.philosophy-body p {
margin: 0 0 16px; line-height: 1.65; font-size: 15px; color: #1f2937;
}
.philosophy-body em { color: #4b5563; font-style: italic; }
.philosophy-body strong { color: #111; }
.philosophy-body ul, .philosophy-body ol {
margin: 0 0 20px; padding-left: 24px;
}
.philosophy-body li { margin-bottom: 8px; line-height: 1.6; }
.philosophy-body hr {
border: none; border-top: 1px solid #e5e7eb; margin: 32px 0;
}
.philosophy-body code {
background: #f3f4f6; padding: 1px 5px; border-radius: 3px;
font-size: 0.92em;
}
/* Richer landing page (§14.1) adds the three-item deck under the
pitch. The .landing container's flex centering stays from the
prototype; the new content lives inside .landing-inner. */
.landing-inner {
max-width: 620px;
display: flex; flex-direction: column; align-items: center;
}
.landing-deck {
list-style: none; padding: 0;
margin: 48px 0 0; text-align: left;
display: flex; flex-direction: column; gap: 18px;
border-top: 1px solid #e5e7eb; padding-top: 32px;
max-width: 540px;
}
.landing-deck li {
font-size: 14px; line-height: 1.6; color: #374151;
}
.landing-deck strong { color: #111; }
/* /settings/notifications */
.settings-page {
max-width: 720px; margin: 0 auto;
padding: 24px 32px 64px;
}
.settings-header h1 {
margin: 0 0 6px; font-size: 24px; font-weight: 700; letter-spacing: -0.01em;
}
.settings-sub {
color: #6b7280; font-size: 13px; margin: 0 0 24px;
line-height: 1.5;
}
.settings-section {
border: 1px solid #e5e7eb; border-radius: 8px;
padding: 20px 24px; margin-bottom: 16px; background: #fff;
}
.settings-section h2 {
margin: 0 0 4px; font-size: 15px; font-weight: 600;
}
.settings-section-body { display: flex; flex-direction: column; gap: 12px; }
.settings-row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
.quiet-hours-row label {
display: flex; flex-direction: column; gap: 4px;
font-size: 12px; color: #6b7280;
}
.quiet-hours-row input, .quiet-hours-row select {
border: 1px solid #d1d5db; border-radius: 6px;
padding: 6px 10px; font-size: 13px;
background: white; color: #111;
}
.settings-note { font-size: 12px; color: #6b7280; margin: 0; }
.settings-note.warning { color: #b91c1c; }
.toggle-row {
display: flex; align-items: flex-start; gap: 12px;
cursor: pointer; padding: 10px 0;
border-top: 1px solid #f3f4f6;
}
.toggle-row:first-child { border-top: none; padding-top: 0; }
.toggle-row.disabled { cursor: not-allowed; opacity: 0.6; }
.toggle-row input[type=checkbox] { margin-top: 3px; }
.toggle-text { display: flex; flex-direction: column; gap: 2px; }
.toggle-label { font-size: 14px; font-weight: 500; color: #111; }
.toggle-desc { font-size: 12px; color: #6b7280; line-height: 1.5; }
.btn-primary {
background: #111; color: #fff; border: none;
padding: 7px 14px; border-radius: 6px; font-size: 13px; cursor: pointer;
}
.btn-primary:hover { background: #333; }
.btn-primary:disabled { background: #9ca3af; cursor: not-allowed; }
.btn-link-muted {
background: none; border: none; cursor: pointer;
color: #6b7280; font-size: 12px; padding: 4px 6px;
}
.btn-link-muted:hover { color: #111; text-decoration: underline; }
.settings-table, .admin-table {
width: 100%; border-collapse: collapse;
font-size: 13px;
}
.settings-table th, .admin-table th {
text-align: left; padding: 6px 8px;
font-size: 11px; text-transform: uppercase;
color: #6b7280; letter-spacing: 0.05em; font-weight: 600;
border-bottom: 1px solid #e5e7eb;
}
.settings-table td, .admin-table td {
padding: 8px 8px; border-bottom: 1px solid #f3f4f6;
}
.settings-table select { font-size: 13px; padding: 3px 6px; }
.set-by {
font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em;
padding: 2px 6px; border-radius: 4px; font-weight: 600;
}
.set-by-auto { background: #f3f4f6; color: #6b7280; }
.set-by-explicit { background: #dbeafe; color: #1e40af; }
.mutes-list { list-style: none; padding: 0; margin: 8px 0 0; }
.mutes-row {
display: flex; align-items: center; gap: 10px;
padding: 6px 8px; border-bottom: 1px solid #f3f4f6;
font-size: 13px;
}
.mute-handle { font-weight: 500; color: #111; }
.mute-when { font-size: 11px; margin-left: auto; }
.mute-typeahead { position: relative; }
.mute-typeahead input {
width: 100%; box-sizing: border-box;
border: 1px solid #d1d5db; border-radius: 6px;
padding: 7px 10px; font-size: 13px; outline: none;
}
.mute-typeahead input:focus { border-color: #111; }
.mute-typeahead-results {
position: absolute; top: 100%; left: 0; right: 0;
background: white; border: 1px solid #d1d5db; border-radius: 6px;
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
margin-top: 4px; padding: 4px 0; z-index: 10;
list-style: none;
}
.mute-typeahead-results button {
display: flex; gap: 10px; align-items: center;
width: 100%; padding: 6px 10px;
background: none; border: none; text-align: left; cursor: pointer;
font-size: 13px;
}
.mute-typeahead-results button:hover { background: #f9fafb; }
/* /admin */
.admin-page {
display: flex; height: 100%;
width: 100%;
}
.admin-rail {
width: 220px; flex-shrink: 0;
background: #f9fafb; border-right: 1px solid #e5e7eb;
padding: 24px 16px;
}
.admin-rail h2 {
font-size: 13px; color: #6b7280;
text-transform: uppercase; letter-spacing: 0.08em;
margin: 0 0 12px;
}
.admin-rail ul { list-style: none; padding: 0; margin: 0 0 24px; }
.admin-rail li { margin-bottom: 2px; }
.admin-rail-link {
display: block; padding: 6px 10px;
font-size: 13px; color: #374151; text-decoration: none;
border-radius: 4px;
}
.admin-rail-link:hover { background: #f3f4f6; }
.admin-rail-link.active { background: #111; color: #fff; }
.admin-rail-note {
font-size: 11px; color: #6b7280; line-height: 1.5;
}
.admin-content {
flex: 1; min-width: 0;
padding: 28px 36px;
overflow: auto;
}
.admin-tab-header h2 {
margin: 0 0 4px; font-size: 18px; font-weight: 700;
}
.admin-tab-header p { margin: 0 0 24px; font-size: 13px; }
.admin-section-h {
font-size: 13px; text-transform: uppercase;
letter-spacing: 0.05em; color: #6b7280;
margin: 24px 0 8px;
}
.audit-filters {
display: flex; gap: 8px; margin-bottom: 18px; flex-wrap: wrap;
}
.audit-filters input, .audit-filters select {
border: 1px solid #d1d5db; border-radius: 6px;
padding: 5px 9px; font-size: 13px; background: white;
}
.audit-table code {
font-size: 11px; background: #f3f4f6;
padding: 1px 5px; border-radius: 3px;
}
.user-cell { display: flex; flex-direction: column; gap: 1px; }
.user-handle { font-weight: 500; color: #111; }
.mute-toggle {
display: inline-flex; align-items: center; gap: 6px;
font-size: 13px; cursor: pointer;
}
.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; }
.grad-queue-link:hover strong { text-decoration: underline; }
.muted { color: #6b7280; }
.error { color: #b91c1c; }
+79 -17
View File
@@ -8,6 +8,9 @@ import PRView from './components/PRView.jsx'
import ProposalView from './components/ProposalView.jsx'
import ProposeModal from './components/ProposeModal.jsx'
import Landing from './components/Landing.jsx'
import Philosophy from './components/Philosophy.jsx'
import NotificationSettings from './components/NotificationSettings.jsx'
import Admin from './components/Admin.jsx'
import ToastHost, { showToast } from './components/ToastHost.jsx'
import './App.css'
@@ -65,8 +68,16 @@ export default function App() {
return <div className="boot">Loading</div>
}
// §14.2: the philosophy route is reachable by anonymous visitors too.
// Resolve it before the authentication gate so a signed-out reader
// who follows the §14.1 landing link does not get bounced to sign-in.
if (!me?.authenticated) {
return <Landing />
return (
<Routes>
<Route path="/philosophy" element={<Philosophy authenticated={false} />} />
<Route path="*" element={<Landing />} />
</Routes>
)
}
return (
@@ -76,6 +87,21 @@ export default function App() {
<Link to="/">Wiggleverse RFCs</Link>
</div>
<div className="header-right">
{/* §14.3: the persistent About link. One word, no badge, no
state visible from every authenticated screen so a
contributor mid-PR who wonders why a conversation is
public can reach the answer in two clicks. */}
<Link to="/philosophy" className="header-about" title="Why this exists (§14)">
About
</Link>
<Link to="/settings/notifications" className="header-settings" title="Notification settings (§15)">
Settings
</Link>
{(me.user.role === 'owner' || me.user.role === 'admin') && (
<Link to="/admin" className="header-admin" title="Admin home base">
Admin
</Link>
)}
<button
className="inbox-trigger"
onClick={() => setInboxOpen(o => !o)}
@@ -92,18 +118,27 @@ export default function App() {
</div>
</header>
<div className="app-body">
<Catalog
onProposeRFC={() => setProposeOpen(true)}
version={catalogVersion}
/>
<main className="main-pane">
<Routes>
<Route path="/" element={<Welcome viewer={me.user} />} />
<Route path="/rfc/:slug" element={<RFCView viewer={me.user} />} />
<Route path="/rfc/:slug/pr/:prNumber" element={<PRView viewer={me.user} />} />
<Route path="/proposals/:prNumber" element={<ProposalView viewer={me.user} onChange={() => setCatalogVersion(v => v + 1)} />} />
</Routes>
</main>
<Routes>
<Route path="/philosophy" element={<PhilosophyWithSidebar viewer={me.user} />} />
<Route path="/settings/notifications" element={<NotificationSettingsWithSidebar viewer={me.user} />} />
<Route path="/admin/*" element={<AdminWithSidebar viewer={me.user} />} />
<Route path="*" element={
<>
<Catalog
onProposeRFC={() => setProposeOpen(true)}
version={catalogVersion}
/>
<main className="main-pane">
<Routes>
<Route path="/" element={<Welcome viewer={me.user} />} />
<Route path="/rfc/:slug" element={<RFCView viewer={me.user} />} />
<Route path="/rfc/:slug/pr/:prNumber" element={<PRView viewer={me.user} />} />
<Route path="/proposals/:prNumber" element={<ProposalView viewer={me.user} onChange={() => setCatalogVersion(v => v + 1)} />} />
</Routes>
</main>
</>
} />
</Routes>
</div>
{proposeOpen && (
<ProposeModal
@@ -123,6 +158,34 @@ export default function App() {
)
}
function PhilosophyWithSidebar() {
// The chrome surfaces (§14.2 philosophy, §15 settings, §6/§17 admin)
// all use the full app body no catalog left pane, no propose modal.
// The header carries the navigation back; the body is a single
// reading surface.
return (
<main className="chrome-pane">
<Philosophy authenticated={true} />
</main>
)
}
function NotificationSettingsWithSidebar({ viewer }) {
return (
<main className="chrome-pane">
<NotificationSettings viewer={viewer} />
</main>
)
}
function AdminWithSidebar({ viewer }) {
return (
<main className="chrome-pane">
<Admin viewer={viewer} />
</main>
)
}
function Welcome({ viewer }) {
return (
<div className="welcome">
@@ -134,10 +197,9 @@ function Welcome({ viewer }) {
idea PR against the meta repository.
</p>
<p>
Slice 1 of the build is in place: propose idea PR owner merges
super-draft appears in the catalog super-draft view renders. The
revision flow, per-branch chat, AI participation, and the PR surface
land in subsequent slices.
Wondering why a conversation is public, why graduation costs what it
does, or why the model is in the chat? <Link to="/philosophy">Read the
philosophy</Link>.
</p>
</div>
)
+62
View File
@@ -478,6 +478,68 @@ export async function advanceChatSeen(slug, branch, lastSeenMessageId) {
}))
}
// ---------------------------------------------------------------------------
// §14.2 / Slice 7: PHILOSOPHY.md surface
// ---------------------------------------------------------------------------
export async function getPhilosophy() {
return jsonOrThrow(await fetch('/api/philosophy'))
}
// ---------------------------------------------------------------------------
// Slice 7: admin neighborhood (§17 admin/* + user search for the §15.8 mute
// typeahead).
// ---------------------------------------------------------------------------
export async function listAdminUsers() {
return jsonOrThrow(await fetch('/api/admin/users'))
}
export async function setUserRole(userId, role) {
return jsonOrThrow(await fetch(`/api/admin/users/${userId}/role`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role }),
}))
}
export async function setUserMute(userId, muted) {
return jsonOrThrow(await fetch(`/api/admin/users/${userId}/mute`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ muted }),
}))
}
export async function listAuditLog({ actionKind, actorUserId, rfcSlug, beforeId, limit } = {}) {
const params = new URLSearchParams()
if (actionKind) params.set('action_kind', actionKind)
if (actorUserId != null) params.set('actor_user_id', actorUserId)
if (rfcSlug) params.set('rfc_slug', rfcSlug)
if (beforeId != null) params.set('before_id', beforeId)
if (limit != null) params.set('limit', limit)
const qs = params.toString()
return jsonOrThrow(await fetch(`/api/admin/audit${qs ? `?${qs}` : ''}`))
}
export async function listPermissionEvents({ beforeId, limit } = {}) {
const params = new URLSearchParams()
if (beforeId != null) params.set('before_id', beforeId)
if (limit != null) params.set('limit', limit)
const qs = params.toString()
return jsonOrThrow(await fetch(`/api/admin/permission-events${qs ? `?${qs}` : ''}`))
}
export async function listGraduationQueue() {
return jsonOrThrow(await fetch('/api/admin/graduation-queue'))
}
export async function searchUsers(q) {
const params = new URLSearchParams()
if (q) params.set('q', q)
return jsonOrThrow(await fetch(`/api/users/search?${params}`))
}
// SSE subscription helper. Returns a close() function. The handler
// surface mirrors §15.3: a snapshot event on open, then per-notification
// `notification` events, plus `read` events when another tab marks a row.
+408
View File
@@ -0,0 +1,408 @@
// /admin the admin home base.
//
// Topics 12 and 13 both expanded the admin's repertoire without giving
// it a centralized home. Slice 7 consolidates them: role management,
// the §6.2 app-wide write-mute, the audit-log viewer, the
// graduation-readiness queue, and a read of the permission-events log
// five thin sub-surfaces behind a left-rail menu.
//
// The page is admin-only; the App.jsx route mounts it only when the
// viewer's role is owner or admin, and every /api/admin/* endpoint
// guards independently.
import { useEffect, useMemo, useState } from 'react'
import { Routes, Route, NavLink, Link } from 'react-router-dom'
import {
listAdminUsers,
setUserRole,
setUserMute,
listAuditLog,
listPermissionEvents,
listGraduationQueue,
} from '../api.js'
const TABS = [
{ path: 'users', label: 'Users' },
{ path: 'graduation', label: 'Graduation queue' },
{ path: 'audit', label: 'Audit log' },
{ path: 'permissions', label: 'Permission events' },
]
export default function Admin({ viewer }) {
return (
<div className="admin-page">
<nav className="admin-rail">
<h2>Admin</h2>
<ul>
{TABS.map(t => (
<li key={t.path}>
<NavLink
to={t.path}
className={({ isActive }) => `admin-rail-link ${isActive ? 'active' : ''}`}
>
{t.label}
</NavLink>
</li>
))}
</ul>
<p className="admin-rail-note">
Signed in as <strong>{viewer.display_name}</strong> ({viewer.role}).
You can <Link to="/">return to the catalog</Link> at any time.
</p>
</nav>
<div className="admin-content">
<Routes>
<Route index element={<UsersTab />} />
<Route path="users" element={<UsersTab />} />
<Route path="graduation" element={<GraduationTab />} />
<Route path="audit" element={<AuditTab />} />
<Route path="permissions" element={<PermissionsTab />} />
</Routes>
</div>
</div>
)
}
// Users + role + write-mute (§6.1 / §6.2)
function UsersTab() {
const [users, setUsers] = useState(null)
const [busy, setBusy] = useState({})
const [error, setError] = useState(null)
async function refresh() {
setError(null)
try {
const r = await listAdminUsers()
setUsers(r.items || [])
} catch (e) {
setError(e.message)
}
}
useEffect(() => { refresh() }, [])
async function changeRole(userId, role) {
setBusy(b => ({ ...b, [userId]: true }))
setError(null)
try {
await setUserRole(userId, role)
setUsers(prev => prev.map(u => u.id === userId ? { ...u, role } : u))
} catch (e) {
setError(e.message)
} finally {
setBusy(b => ({ ...b, [userId]: false }))
}
}
async function toggleMute(userId, muted) {
setBusy(b => ({ ...b, [userId]: true }))
setError(null)
try {
await setUserMute(userId, muted)
setUsers(prev => prev.map(u => u.id === userId ? { ...u, muted } : u))
} catch (e) {
setError(e.message)
} finally {
setBusy(b => ({ ...b, [userId]: false }))
}
}
if (users == null) return <p className="muted">Loading users</p>
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.
</p>
</header>
{error && <p className="settings-note warning">{error}</p>}
<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>
))}
</tbody>
</table>
</div>
)
}
// Graduation-readiness queue (§13.2)
function GraduationTab() {
const [data, setData] = useState(null)
const [error, setError] = useState(null)
useEffect(() => {
listGraduationQueue()
.then(setData)
.catch(e => setError(e.message))
}, [])
if (error) return <p className="settings-note warning">{error}</p>
if (data == null) return <p className="muted">Loading queue</p>
return (
<div className="admin-tab">
<header className="admin-tab-header">
<h2>Graduation queue</h2>
<p className="muted">
Super-drafts with owners claimed and zero blocking body-edit PRs.
Open one to run the §13.3 graduation sequence.
</p>
</header>
<h3 className="admin-section-h">Ready ({data.ready.length})</h3>
{data.ready.length === 0 && (
<p className="muted">No super-drafts ready right now.</p>
)}
<ul className="grad-queue">
{data.ready.map(item => (
<li key={item.slug}>
<Link to={`/rfc/${item.slug}`} className="grad-queue-link">
<strong>{item.title}</strong>
<span className="muted"> owners: {item.owners.join(', ')}</span>
</Link>
</li>
))}
</ul>
<h3 className="admin-section-h">Blocked ({data.blocked.length})</h3>
{data.blocked.length === 0 && (
<p className="muted">No blocked super-drafts.</p>
)}
<ul className="grad-queue">
{data.blocked.map(item => (
<li key={item.slug}>
<Link to={`/rfc/${item.slug}`} className="grad-queue-link">
<strong>{item.title}</strong>
<span className="muted">
{' — '}
{!item.owners_set && 'no owners yet'}
{!item.owners_set && item.blocking_prs > 0 && '; '}
{item.blocking_prs > 0 && `${item.blocking_prs} open body-edit PR${item.blocking_prs === 1 ? '' : 's'}`}
</span>
</Link>
</li>
))}
</ul>
</div>
)
}
// Audit log (`actions`) filter chips + paging
function AuditTab() {
const [data, setData] = useState(null)
const [filters, setFilters] = useState({ actionKind: '', actorUserId: '', rfcSlug: '' })
const [error, setError] = useState(null)
async function load(beforeId = null) {
setError(null)
try {
const r = await listAuditLog({
actionKind: filters.actionKind || undefined,
actorUserId: filters.actorUserId ? Number(filters.actorUserId) : undefined,
rfcSlug: filters.rfcSlug || undefined,
beforeId,
limit: 100,
})
setData(r)
} catch (e) {
setError(e.message)
}
}
useEffect(() => { load() }, [filters])
const kinds = useMemo(() => data?.action_kinds || [], [data])
return (
<div className="admin-tab">
<header className="admin-tab-header">
<h2>Audit log</h2>
<p className="muted">
Every bot-mediated write lands here. The most recent rows show first;
filter to narrow to one kind, one actor, or one RFC.
</p>
</header>
<div className="audit-filters">
<select
value={filters.actionKind}
onChange={e => setFilters(f => ({ ...f, actionKind: e.target.value }))}
>
<option value="">All action kinds</option>
{kinds.map(k => <option key={k} value={k}>{k}</option>)}
</select>
<input
type="text"
placeholder="RFC slug…"
value={filters.rfcSlug}
onChange={e => setFilters(f => ({ ...f, rfcSlug: e.target.value }))}
/>
<input
type="number"
placeholder="Actor user_id…"
value={filters.actorUserId}
onChange={e => setFilters(f => ({ ...f, actorUserId: e.target.value }))}
/>
</div>
{error && <p className="settings-note warning">{error}</p>}
{data == null && <p className="muted">Loading</p>}
{data?.items?.length === 0 && (
<p className="muted">No rows match this filter.</p>
)}
{data?.items?.length > 0 && (
<table className="admin-table audit-table">
<thead>
<tr>
<th>When</th>
<th>Action</th>
<th>Actor</th>
<th>On behalf of</th>
<th>RFC</th>
<th>PR / branch</th>
</tr>
</thead>
<tbody>
{data.items.map(row => (
<tr key={row.id}>
<td className="muted">{row.created_at}</td>
<td><code>{row.action_kind}</code></td>
<td>{row.actor_display || row.actor_login || '—'}</td>
<td>{row.on_behalf_of}</td>
<td>{row.rfc_slug || '—'}</td>
<td>
{row.pr_number != null && <span>#{row.pr_number} </span>}
{row.branch_name && <code>{row.branch_name}</code>}
</td>
</tr>
))}
</tbody>
</table>
)}
{data?.has_more && (
<button
className="btn-link-muted"
onClick={() => load(data.items[data.items.length - 1].id)}
>
Load older
</button>
)}
</div>
)
}
// Permission events (`permission_events`)
function PermissionsTab() {
const [data, setData] = useState(null)
const [error, setError] = useState(null)
useEffect(() => {
listPermissionEvents({ limit: 100 })
.then(setData)
.catch(e => setError(e.message))
}, [])
if (error) return <p className="settings-note warning">{error}</p>
if (data == null) return <p className="muted">Loading</p>
return (
<div className="admin-tab">
<header className="admin-tab-header">
<h2>Permission events</h2>
<p className="muted">
Every role change and write-mute toggle. The companion to the
audit log, scoped to authorization changes.
</p>
</header>
{data.items.length === 0 && (
<p className="muted">No permission events yet.</p>
)}
{data.items.length > 0 && (
<table className="admin-table">
<thead>
<tr>
<th>When</th>
<th>Event</th>
<th>Actor</th>
<th>Subject</th>
<th>Details</th>
</tr>
</thead>
<tbody>
{data.items.map(r => (
<tr key={r.id}>
<td className="muted">{r.created_at}</td>
<td><code>{r.event_kind}</code></td>
<td>{r.actor_display || r.actor_login || '—'}</td>
<td>{r.subject_display || r.subject_login || '—'}</td>
<td className="muted">
{r.details ? formatDetails(r.details) : ''}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)
}
function formatDetails(details) {
if (!details || typeof details !== 'object') return String(details ?? '')
if (details.before != null && details.after != null) {
return `${String(details.before)}${String(details.after)}`
}
return JSON.stringify(details)
}
+51 -15
View File
@@ -1,24 +1,60 @@
// Landing.jsx §14.1's pre-login surface.
//
// Title, subtitle, the short-form pitch from PHILOSOPHY.md, then the
// single primary action: "Sign in with Gitea." The visual design is
// deferred per §14.4; the structural commitments are here.
// The landing page has three jobs per §14.1: name what this thing is,
// pitch why someone would care, and offer the sign-in affordance. The
// visual treatment is deferred per §14.4 what matters here is the
// hierarchy. Title and subtitle frame the framework, the short-form
// pitch (sourced verbatim from the top of PHILOSOPHY.md) does the
// argument, and the single primary action lets a reader who is sold
// step through. The secondary link to `/philosophy` is for the reader
// who is interested but needs more before signing in.
//
// The deck below the pitch is intentionally restrained three crisp
// claims about what the framework *is*, anchored in the spec's
// structural decisions, so the reader who has not yet read the
// philosophy can still tell at a glance whether this is for them.
import { Link } from 'react-router-dom'
export default function Landing() {
return (
<div className="landing">
<h1>Wiggleverse RFCs</h1>
<p className="subtitle">A standards process for shared meaning between humans and machines.</p>
<p className="pitch">
Large language models work brilliantly with programming languages because every
word in Python or C has a definitive meaning enforced by tooling. They struggle
with natural language because no such dictionary exists for words like
<em> consent</em>, <em> trait</em>, or <em> agency</em> words that do enormous
work in any system that interacts with humans. The Wiggleverse RFC framework is
a standardization process for that vocabulary. Build the dictionary first.
</p>
<a className="btn-signin" href="/auth/login">Sign in with Gitea</a>
<a className="secondary-link" href="/philosophy">Read the full philosophy </a>
<div className="landing-inner">
<h1>Wiggleverse RFCs</h1>
<p className="subtitle">
A standards process for shared meaning between humans and machines.
</p>
<p className="pitch">
Large language models work brilliantly with programming languages because every
word in Python or C has a definitive meaning enforced by tooling. They struggle
with natural language because no such dictionary exists for words like
<em> consent</em>, <em> trait</em>, or <em> agency</em> words that do enormous
work in any system that interacts with humans. The Wiggleverse RFC framework is
the standardization process for that vocabulary. Build the dictionary first.
</p>
<a className="btn-signin" href="/auth/login">Sign in with Gitea</a>
<Link className="secondary-link" to="/philosophy">Read the full philosophy </Link>
<ul className="landing-deck">
<li>
<strong>One word per RFC.</strong> An RFC defines a single word its meaning,
its relationships to other defined words, and the protocol by which humans and
machines interact with it.
</li>
<li>
<strong>Argued in public, with the model.</strong> Every definition is the
product of a transcript: a human and a model in careful argument until the
ambiguity is gone. The argument is the evidence the definition was earned.
</li>
<li>
<strong>Graduation is the load-bearing moment.</strong> A super-draft is the
start of the conversation. Graduation gives the definition a permanent home
and a stable identifier and only then can other RFCs build on it.
</li>
</ul>
</div>
</div>
)
}
@@ -0,0 +1,509 @@
// /settings/notifications the §15.4 / §15.5 / §15.6 / §15.8 surface.
//
// Topic 13 settled the schema and the per-category rules; Slice 6 wired
// the endpoints; Slice 7 lands the surface where a contributor finds
// the per-category email toggles, the digest cadence dropdown, the
// quiet-hours editor, the watches overview, and the per-user mute
// list. The §15.4 email footer's "Manage all preferences" link points
// at this route.
//
// Each sub-section is intentionally thin the rules are settled in
// §15; this page renders them. The one piece of voice the spec
// commits to and the surface inherits: the `email_watched_churn`
// toggle renders as permanently disabled with the §15.4 refusal
// tooltip ("Per-commit and per-message email is intentionally not
// offered. The digest aggregates this activity weekly."). Naming the
// refusal is the spec's commitment; silently omitting the toggle
// would let the contract drift.
import { useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import {
getNotificationPreferences,
setNotificationPreferences,
getQuietHours,
setQuietHours,
listWatches,
setWatch,
unmuteUser,
muteUser,
searchUsers,
} from '../api.js'
const CHURN_REFUSAL = 'Per-commit and per-message email is intentionally not offered. The digest aggregates this activity weekly.'
export default function NotificationSettings({ viewer }) {
return (
<div className="settings-page">
<header className="settings-header">
<h1>Notification settings</h1>
<p className="settings-sub">
Which signals reach you, how, and when. The rules live in §15;
this page is where you tune them.
</p>
</header>
<EmailPreferencesSection />
<DigestCadenceSection />
<QuietHoursSection />
<WatchesSection />
<MutesSection viewer={viewer} />
</div>
)
}
// §15.4 email category toggles
function EmailPreferencesSection() {
const [prefs, setPrefs] = useState(null)
const [saving, setSaving] = useState(false)
const [savedNote, setSavedNote] = useState('')
const [error, setError] = useState(null)
useEffect(() => {
getNotificationPreferences().then(setPrefs).catch(e => setError(e.message))
}, [])
async function update(field, value) {
setSaving(true)
setSavedNote('')
try {
await setNotificationPreferences({ [field]: value })
setPrefs(p => ({ ...p, [field]: value }))
setSavedNote('Saved.')
} catch (e) {
setError(e.message)
} finally {
setSaving(false)
setTimeout(() => setSavedNote(''), 1500)
}
}
if (!prefs) return <SectionShell title="Email" subtitle={error || 'Loading…'} />
return (
<SectionShell title="Email" subtitle="Categories you want delivered as email. The inbox always carries everything; email is opt-in by category.">
<Toggle
label="Personal direct"
description="You are the named subject — proposals merged, decisions, claims, named asks. Default on."
checked={!!prefs.email_personal_direct}
onChange={v => update('email_personal_direct', v)}
disabled={saving}
/>
<Toggle
label="Watched structural"
description="Decisions on RFCs you watch — merges, declines, graduation, ownership changes. Default off."
checked={!!prefs.email_watched_structural}
onChange={v => update('email_watched_structural', v)}
disabled={saving}
/>
<Toggle
label="Admin actionable"
description="Decisions only an admin can act on. Defaults on for admins/owners; ignored for contributors."
checked={!!prefs.email_admin_actionable}
onChange={v => update('email_admin_actionable', v)}
disabled={saving}
/>
<Toggle
label="Watched churn (per-commit, per-message)"
description={CHURN_REFUSAL}
checked={false}
disabled
title={CHURN_REFUSAL}
/>
{!!prefs.email_opt_out_all && (
<p className="settings-note warning">
A hard bounce or complaint from your mailbox flipped the global
email opt-out. No email will be sent until you contact an admin
to clear the flag, even if individual categories are enabled.
</p>
)}
<p className="settings-note">{savedNote}</p>
</SectionShell>
)
}
// §15.5 digest cadence
function DigestCadenceSection() {
const [cadence, setCadence] = useState(null)
const [saving, setSaving] = useState(false)
useEffect(() => {
getNotificationPreferences().then(p => setCadence(p.digest_cadence || 'weekly'))
}, [])
async function update(value) {
setSaving(true)
try {
await setNotificationPreferences({ digest_cadence: value })
setCadence(value)
} finally {
setSaving(false)
}
}
return (
<SectionShell
title="Digest cadence"
subtitle="The §15.5 digest gathers churn-category activity into a single periodic email. Independent of the category toggles above."
>
<div className="settings-row">
<select
value={cadence ?? 'weekly'}
onChange={e => update(e.target.value)}
disabled={saving || cadence == null}
>
<option value="off">Off never send a digest</option>
<option value="weekly">Weekly</option>
<option value="daily">Daily</option>
</select>
</div>
</SectionShell>
)
}
// §15.8 quiet hours
function QuietHoursSection() {
const [data, setData] = useState(null)
const [draft, setDraft] = useState({ start: '', end: '', timezone: '' })
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
// The browser ships an IANA tz list per §15.8 preferable to a
// free-text field, since the API validates the trio.
const timezones = useMemo(() => {
try {
return Intl.supportedValuesOf('timeZone')
} catch {
return ['UTC']
}
}, [])
useEffect(() => {
getQuietHours().then(q => {
setData(q)
setDraft({
start: q.start || '',
end: q.end || '',
timezone: q.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
})
})
}, [])
async function save() {
setSaving(true)
setError(null)
try {
await setQuietHours({ start: draft.start, end: draft.end, timezone: draft.timezone })
setData({ start: draft.start, end: draft.end, timezone: draft.timezone })
} catch (e) {
setError(e.message)
} finally {
setSaving(false)
}
}
async function clear() {
setSaving(true)
setError(null)
try {
await setQuietHours({ start: null, end: null, timezone: null })
setData({ start: null, end: null, timezone: null })
setDraft({ start: '', end: '', timezone: Intl.DateTimeFormat().resolvedOptions().timeZone })
} catch (e) {
setError(e.message)
} finally {
setSaving(false)
}
}
if (!data) return <SectionShell title="Quiet hours" subtitle="Loading…" />
const isSet = !!(data.start && data.end && data.timezone)
return (
<SectionShell
title="Quiet hours"
subtitle="Email holds during this window; inbox rows still land. On window-end, the §15.4 bundle email releases everything above the threshold."
>
<div className="settings-row quiet-hours-row">
<label>
Start
<input
type="time"
value={draft.start}
onChange={e => setDraft(d => ({ ...d, start: e.target.value }))}
/>
</label>
<label>
End
<input
type="time"
value={draft.end}
onChange={e => setDraft(d => ({ ...d, end: e.target.value }))}
/>
</label>
<label>
Timezone
<select
value={draft.timezone}
onChange={e => setDraft(d => ({ ...d, timezone: e.target.value }))}
>
{timezones.map(tz => <option key={tz} value={tz}>{tz}</option>)}
</select>
</label>
</div>
<div className="settings-row">
<button className="btn-primary" disabled={saving} onClick={save}>
{isSet ? 'Update quiet hours' : 'Set quiet hours'}
</button>
{isSet && (
<button className="btn-link-muted" disabled={saving} onClick={clear}>
Clear
</button>
)}
</div>
{error && <p className="settings-note warning">{error}</p>}
</SectionShell>
)
}
// §15.6 watches overview
function WatchesSection() {
const [watches, setWatches] = useState(null)
const [updating, setUpdating] = useState({})
useEffect(() => { listWatches().then(r => setWatches(r.items || [])) }, [])
async function update(slug, state) {
setUpdating(u => ({ ...u, [slug]: true }))
try {
const r = await setWatch(slug, state)
setWatches(prev => prev.map(w => w.rfc_slug === slug
? { ...w, state: r.state, set_by: 'explicit' }
: w
))
} finally {
setUpdating(u => ({ ...u, [slug]: false }))
}
}
if (!watches) return <SectionShell title="Watches" subtitle="Loading…" />
return (
<SectionShell
title="Watches"
subtitle="What you receive structural-category notifications about. Auto-set when you participate, decays after 90 days of silence. An explicit choice here exempts the row from the auto-decay."
>
{watches.length === 0 && (
<p className="muted">No watches yet. Open an RFC, propose, or join a thread auto-watch will set one for you.</p>
)}
{watches.length > 0 && (
<table className="settings-table">
<thead>
<tr>
<th>RFC</th>
<th>State</th>
<th>Set by</th>
<th>Last participation</th>
</tr>
</thead>
<tbody>
{watches.map(w => (
<tr key={w.rfc_slug}>
<td>
<Link to={`/rfc/${w.rfc_slug}`}>{w.rfc_title || w.rfc_slug}</Link>
</td>
<td>
<select
value={w.state}
onChange={e => update(w.rfc_slug, e.target.value)}
disabled={!!updating[w.rfc_slug]}
>
<option value="watching">Watching</option>
<option value="following">Following</option>
<option value="muted">Muted</option>
</select>
</td>
<td>
<span className={`set-by set-by-${w.set_by}`}>
{w.set_by === 'explicit' ? 'You' : 'Auto'}
</span>
</td>
<td className="muted">
{w.last_participation_at || '—'}
</td>
</tr>
))}
</tbody>
</table>
)}
</SectionShell>
)
}
// §15.8 per-user mute list + typeahead add
function MutesSection({ viewer }) {
const [mutes, setMutes] = useState(null)
const [error, setError] = useState(null)
async function refresh() {
try {
const r = await listMutes()
setMutes(r.items || [])
} catch (e) {
setError(e.message)
}
}
useEffect(() => { refresh() }, [])
async function remove(userId) {
await unmuteUser(userId)
setMutes(prev => prev.filter(m => m.muted_user_id !== userId))
}
return (
<SectionShell
title="Muted users"
subtitle="Notifications from these users won't reach your inbox. (Mute does not gate visibility — you can still read what they post.) Adding a mute here is the catch-all path; the intended path is to mute from an inbox row's actor avatar."
>
{viewer.role === 'owner' || viewer.role === 'admin' ? (
<p className="muted">
Owners and admins cannot mute notifications the role requires
receiving signals from everyone. (§15.8)
</p>
) : (
<MuteTypeahead onMuted={refresh} />
)}
{mutes && mutes.length === 0 && (
<p className="muted">No active mutes.</p>
)}
{mutes && mutes.length > 0 && (
<ul className="mutes-list">
{mutes.map(m => (
<li key={m.muted_user_id} className="mutes-row">
<span className="mute-handle">@{m.gitea_login}</span>
<span className="mute-name muted">{m.display_name}</span>
<span className="mute-when muted">since {m.muted_at}</span>
<button className="btn-link-muted" onClick={() => remove(m.muted_user_id)}>
Unmute
</button>
</li>
))}
</ul>
)}
{error && <p className="settings-note warning">{error}</p>}
</SectionShell>
)
}
// The mute-list read endpoint isn't a separate route in the API today
// (§17 names add/delete only); we read the join here against /api/users/me
// via a tiny dedicated endpoint that mirrors the watches shape. For
// Slice 7's v1 surface, we compute the list client-side from a server
// endpoint that returns the joined rows added in api_admin.py's
// neighborhood for proximity.
async function listMutes() {
const res = await fetch('/api/users/me/notification-mutes')
if (!res.ok) {
const detail = await res.text()
throw new Error(detail || `HTTP ${res.status}`)
}
return res.json()
}
function MuteTypeahead({ onMuted }) {
const [q, setQ] = useState('')
const [results, setResults] = useState([])
const [open, setOpen] = useState(false)
const [busy, setBusy] = useState(false)
const [hint, setHint] = useState('')
useEffect(() => {
const t = setTimeout(() => {
searchUsers(q).then(r => setResults(r.items || []))
}, 120)
return () => clearTimeout(t)
}, [q])
async function mute(user) {
setBusy(true)
setHint('')
try {
await muteUser(user.id)
setQ('')
setOpen(false)
onMuted?.()
} catch (e) {
setHint(e.message)
} finally {
setBusy(false)
}
}
return (
<div className="mute-typeahead">
<input
type="text"
placeholder="Mute a user by login or name…"
value={q}
onChange={e => { setQ(e.target.value); setOpen(true) }}
onFocus={() => setOpen(true)}
onBlur={() => setTimeout(() => setOpen(false), 150)}
disabled={busy}
/>
{open && results.length > 0 && (
<ul className="mute-typeahead-results">
{results.map(r => (
<li key={r.id}>
<button onClick={() => mute(r)} disabled={busy}>
<span className="mute-handle">@{r.gitea_login}</span>
<span className="muted">{r.display_name}</span>
{(r.role === 'owner' || r.role === 'admin') && (
<span className="muted">{r.role}</span>
)}
</button>
</li>
))}
</ul>
)}
{hint && <p className="settings-note warning">{hint}</p>}
</div>
)
}
// Small layout primitives
function SectionShell({ title, subtitle, children }) {
return (
<section className="settings-section">
<header>
<h2>{title}</h2>
{subtitle && <p className="settings-sub">{subtitle}</p>}
</header>
<div className="settings-section-body">{children}</div>
</section>
)
}
function Toggle({ label, description, checked, onChange, disabled, title }) {
return (
<label className={`toggle-row ${disabled ? 'disabled' : ''}`} title={title}>
<input
type="checkbox"
checked={!!checked}
onChange={e => onChange?.(e.target.checked)}
disabled={disabled}
/>
<span className="toggle-text">
<span className="toggle-label">{label}</span>
{description && <span className="toggle-desc">{description}</span>}
</span>
</label>
)
}
+61
View File
@@ -0,0 +1,61 @@
// §14.2 the `/philosophy` route.
//
// Renders PHILOSOPHY.md verbatim with light app chrome around it.
// Reachable by anonymous visitors (linked from the §14.1 landing) and
// by authenticated viewers (linked from the persistent §14.3 About
// header). The chrome here is small by design: a "Back" affordance
// that goes wherever the viewer came from, and a render of the
// markdown body. The §14.4 commitment ("not pushed at returning users
// via banners or modals") is the guardrail this route serves the
// document, nothing else.
//
// The route is also the natural read surface for anonymous reachers:
// without a sign-in they cannot navigate the catalog, but they can
// read the philosophy that animates the work.
import { useEffect, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { marked } from 'marked'
import { getPhilosophy } from '../api.js'
export default function Philosophy({ authenticated }) {
const [body, setBody] = useState('')
const [error, setError] = useState(null)
const [loading, setLoading] = useState(true)
const navigate = useNavigate()
useEffect(() => {
let active = true
getPhilosophy()
.then(r => { if (active) setBody(r.body || '') })
.catch(e => { if (active) setError(e.message || String(e)) })
.finally(() => { if (active) setLoading(false) })
return () => { active = false }
}, [])
const html = body ? marked.parse(body) : ''
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">Why this exists</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 philosophy: {error}</p>}
{!loading && !error && (
<div dangerouslySetInnerHTML={{ __html: html }} />
)}
</article>
</div>
)
}