From 7d9f525d63574fb804b949624098b23bea15f6c9 Mon Sep 17 00:00:00 2001 From: GrittyBot <30755786+codeforphilly-bot@users.noreply.github.com> Date: Wed, 11 Nov 2026 19:33:47 +0000 Subject: [PATCH 01/23] docs(specs): agent accounts that stage spam votes An AI agent can hold a narrow grant to read the moderation roster and cast spam or not-spam votes. Its votes are labeled agent-, stage for three days, and any staff vote overrules them. Proposal for review; nothing is implemented. Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_01S8dthiZS2vwh674eh2DDQs --- specs/api/agents.md | 157 ++++++++++++++++++++++++++++ specs/api/moderation.md | 145 ++++++++++++++++++++----- specs/api/people.md | 2 + specs/behaviors/authorization.md | 40 +++++++ specs/behaviors/person-lifecycle.md | 19 ++-- specs/behaviors/private-storage.md | 19 +++- specs/behaviors/spam-exclusion.md | 38 +++++-- specs/behaviors/storage.md | 1 + specs/data-model.md | 35 +++++++ specs/screens/admin-members.md | 60 +++++++++-- 10 files changed, 464 insertions(+), 52 deletions(-) create mode 100644 specs/api/agents.md diff --git a/specs/api/agents.md b/specs/api/agents.md new file mode 100644 index 0000000..11a329c --- /dev/null +++ b/specs/api/agents.md @@ -0,0 +1,157 @@ +# Agents API + +An **agent** is an automated moderator: a program, not a person, that staff +let cast `spam` / `not spam` votes on the [admin members +roster](../screens/admin-members.md). It has its own site account and reaches +the API with a **grant**: a bearer token an administrator mints for it, scoped +to moderation and nothing else. Its votes are labeled as an agent's in the data +and on the roster, they **stage** (see +[person-lifecycle.md → Scheduled spam purge](../behaviors/person-lifecycle.md#scheduled-spam-purge)), +and any staff vote overrules them. + +The first agent is Code for Philly's ops bot. The site does not care what runs +behind a grant; it cares that the grant is narrow, attributable and revocable. + +| Method | Path | Auth | Purpose | +| ------ | ---- | ---- | ------- | +| `GET` | `/api/admin/agents` | administrator | Every agent account and its grants. | +| `POST` | `/api/admin/agents` | administrator | Create an agent account (and optionally its first grant). | +| `POST` | `/api/admin/agents/:slug/grants` | administrator | Mint a grant; the token is returned once. | +| `POST` | `/api/admin/agents/:slug/grants/:id/revoke` | administrator | Revoke a grant at once. | +| `POST` | `/api/admin/agents/:slug/withdraw-votes` | staff | Withdraw every vote the agent has staged, in one commit. | + +Anyone else gets `404 not_found`, like the rest of `/api/admin`. The +moderation endpoints an agent calls with its token are in +[moderation.md](moderation.md). + +## The agent account + +An agent account is a `people` record created by an administrator here, never +by GitHub sign-in: + +- `accountLevel = user`, no `githubUserId`, no legacy password, and a private + profile with no email. **It cannot sign in to the site or to Slack**: there + is no identity for OAuth, a password or SAML to bind to. The only way in is a + grant. +- It is public like any member (`/members/`), and its `bio` says what it + is and who runs it. It is an agent because the `agent-grants` sheet holds a + grant for it; nothing else marks it. +- It can never be raised to staff: `POST /api/people/:slug/account-level` + refuses (`409 conflict`, `error.code = "agent_account"`) for a person with + any grant on record. Agents are never staff, so an agent's vote and a human's + vote can never come from the same account. +- Moderation treats it as off limits: nobody votes on it + (`422`, `slug: agent`), and the [scheduled spam + purge](../behaviors/person-lifecycle.md#scheduled-spam-purge) never takes it. + To stop an agent, revoke its grants. + +## Grants + +A grant is one bearer token for one agent account, with a fixed list of scopes +and a hard expiry. + +| Scope | Allows | +| ----- | ------ | +| `moderation:read` | `GET /api/admin/members` and `GET /api/admin/members/:slug`, with the redactions in [moderation.md → Agent callers](moderation.md#agent-callers). | +| `moderation:vote` | `POST /api/admin/members/:slug/vote` and `DELETE /api/admin/members/:slug/vote`, within the [agent vote rules](moderation.md#agent-votes). | + +There are no other scopes. A grant does not make its holder `user`, `self` or +`staff` anywhere else ([authorization.md → Agent grants](../behaviors/authorization.md#agent-grants)). + +- **Token:** `cfpa_` followed by 32 random bytes, base64url. Shown once, in the + mint response. The site keeps only its SHA-256 hash, in the private store + ([private-storage.md](../behaviors/private-storage.md)); the public + `agent-grants` record says that the grant exists, for whom, with which + scopes, by whom and until when — never the token or its hash. +- **Presented as** `Authorization: Bearer cfpa_…` on each request. No cookie, + so no CSRF surface. Never logged; never written to a commit. +- **Expiry:** `expiresInDays` 1–180, default 90. An expired grant is refused + like a revoked one. Renewal is a new grant and a revoke of the old one. +- **Revocation** is immediate: the in-memory grant table is updated in the same + request. Revoking also stops the agent's staged votes from counting toward a + purge ([person-lifecycle.md](../behaviors/person-lifecycle.md#scheduled-spam-purge)). +- **Last used:** the API records `lastUsedAt` on the private record, at most + once an hour per grant. + +## GET /api/admin/agents + +```json +{ + "success": true, + "data": [ + { + "person": { "slug": "example-bot", "fullName": "Example Bot", "deletedAt": null }, + "grants": [ + { "id": "…", "label": "ops bot, host vm-1", "scopes": ["moderation:read", "moderation:vote"], + "createdAt": "…", "createdBy": { "slug": "jane", "fullName": "Jane Doe" }, + "expiresAt": "…", "revokedAt": null, "lastUsedAt": "…", "active": true } + ], + "stagedVotes": { "spam": 3, "legit": 1 } + } + ] +} +``` + +`stagedVotes` counts the agent's votes on people no human has voted on. + +## POST /api/admin/agents + +```json +{ + "slug": "example-bot", + "fullName": "Example Bot", + "bio": "An AI ops bot run by … Its votes on /admin/members are staged; staff overrule them.", + "grant": { "label": "ops bot, host vm-1", "scopes": ["moderation:read", "moderation:vote"], "expiresInDays": 90 } +} +``` + +`bio` is required (an agent says what it is). `grant` is optional. Creates the +person, an empty private profile, and the grant in one transaction; the slug +follows [slug-handles.md](../behaviors/slug-handles.md). + +Response `201`: `{ person, grant, token }` — `token` only when a grant was +minted, and only in this response. + +## POST /api/admin/agents/:slug/grants + +`{ "label", "scopes", "expiresInDays" }` → `201 { grant, token }`. `404` when +`slug` is not an agent account (a member cannot be turned into an agent: create +the agent instead). + +## POST /api/admin/agents/:slug/grants/:id/revoke + +Sets `revokedAt = now()`. `200 { grant }`. Revoking a revoked grant is a no-op. + +## POST /api/admin/agents/:slug/withdraw-votes + +The undo for an agent that has gone wrong. Staff, not only administrators: +anyone who can overrule a vote one at a time can overrule them all. + +```json +{ "since": "2026-11-01T00:00:00Z" } +``` + +`since` is optional. Deletes every `agent-` evaluation (newer than +`since`) on a person with **no human vote**, and reactivates each such person +whose deactivation that agent's vote caused (the [reversal +rule](../behaviors/person-lifecycle.md#deactivate-soft-self-service)). Votes on +people a human has since voted on are left as the record. One commit. + +Response `200`: `{ "withdrawn": 7, "reactivated": 5 }`. Withdrawing does not +revoke the grant; do both when the token itself is in doubt. + +## Commits + +Every write here is a commit on the data repo through the write mutex, by the +calling administrator or staff member, with `Action: agent.create`, +`agent.grant`, `agent.revoke` or `agent.withdraw-votes`, `Subject-Type: person`, +`Subject-Slug: `, and `Grant-Id: ` where one grant is +concerned. Agent grants are a change of access, so they are in the public audit +log the same way an account-level change is. + +## Errors + +- `422 validation_failed` — unknown scope, `expiresInDays` out of range, + missing `bio`, bad or taken slug +- `404 not_found` — not an administrator (staff for `withdraw-votes`), or no + such agent or grant diff --git a/specs/api/moderation.md b/specs/api/moderation.md index 91a4812..a2fcf83 100644 --- a/specs/api/moderation.md +++ b/specs/api/moderation.md @@ -3,17 +3,22 @@ Staff-facing endpoints behind the [admin members screen](../screens/admin-members.md): a newest-first roster of members with their footprint on the site, and a way to record a **human spam verdict** that the offline spam pipeline treats as final -(see [behaviors/spam-exclusion.md](../behaviors/spam-exclusion.md)). - -All endpoints require `accountLevel ∈ {staff, administrator}`. Anyone else gets -`404 not_found` — like the other staff surfaces, the endpoints' existence is not -a signal. - -| Method | Path | Purpose | -| ------ | ---- | ------- | -| `GET` | `/api/admin/members` | Paginated roster, newest signup first, with vote state and footprint counts. | -| `GET` | `/api/admin/members/:slug` | One member's full footprint plus every human vote on record. | -| `POST` | `/api/admin/members/:slug/vote` | Record the caller's verdict; applies the side effect described below. | +(see [behaviors/spam-exclusion.md](../behaviors/spam-exclusion.md)). An +[agent](agents.md) holding a grant may read the roster and cast **staged** +votes that any human vote overrules ([Agent votes](#agent-votes)). + +All endpoints require `accountLevel ∈ {staff, administrator}`, or an agent +grant with the scope named below (`agent:` in +[authorization.md](../behaviors/authorization.md#agent-grants)). Anyone else +gets `404 not_found` — like the other staff surfaces, the endpoints' existence +is not a signal. + +| Method | Path | Auth | Purpose | +| ------ | ---- | ---- | ------- | +| `GET` | `/api/admin/members` | `staff \| agent:moderation:read` | Paginated roster, newest signup first, with vote state and footprint counts. | +| `GET` | `/api/admin/members/:slug` | `staff \| agent:moderation:read` | One member's full footprint plus every vote on record. | +| `POST` | `/api/admin/members/:slug/vote` | `staff \| agent:moderation:vote` | Record the caller's verdict; applies the side effect described below. | +| `DELETE` | `/api/admin/members/:slug/vote` | `agent:moderation:vote` | Withdraw the calling agent's own vote. | ## GET /api/admin/members @@ -21,8 +26,8 @@ a signal. | Param | Type | Notes | | ----- | ---- | ----- | -| `q` | string | Substring match on `fullName`, `slug`, `bio`, and (staff-visible) `email` — searched for every query, so `.com` finds every member on that domain. Applies to the whole roster, then paginates. | -| `vote` | enum | `none` (no human vote yet) \| `spam` \| `legit`. Filters on the **latest** human vote. | +| `q` | string | Substring match on `fullName`, `slug`, `bio`, and (staff-visible) `email` — searched for every query, so `.com` finds every member on that domain. For an agent caller, only the email's domain is searched. Applies to the whole roster, then paginates. | +| `vote` | enum | `none` (no human vote yet — includes agent-staged rows) \| `staged` (no human vote, and an agent vote) \| `spam` \| `legit`. `spam` and `legit` filter on the **latest** human vote. | | `origin` | enum | `imported` (has a laddr `legacyId`) \| `signed-up` (created on this site through GitHub). | | `joinedAfter`, `joinedBefore` | ISO date | Inclusive bounds on `createdAt`. | | `includeDeactivated` | bool | Default `true` — moderation needs to see what it hid. | @@ -41,6 +46,7 @@ a signal. "deletedAt": null, "origin": "signed-up", "email": "jane@example.org", + "emailDomain": "example.org", "hasGitHubLink": true, "github": { "login": "janedoe", "accountCreatedAt": "2019-03-02T…", "publicRepos": 12, "followers": 40, "status": "ok", "checkedAt": "…" }, "lastLoginAt": "2026-09-18T01:12:00Z", @@ -50,6 +56,7 @@ a signal. "bioExcerpt": "first ~160 chars of bio, markdown stripped", "footprint": { "memberships": 2, "updates": 1, "buzz": 0, "blogPosts": 0, "helpWantedInterest": 1, "tags": 3 }, "latestVote": { "verdict": "legit", "voter": { "slug": "chris", "fullName": "Chris Alfano" }, "evaluatedAt": "…" }, + "stagedVote": null, "purgeAfter": null, "signals": ["slack-sso", "has-footprint"], "attention": 0 @@ -78,10 +85,29 @@ Field notes: `github-new-account` (< 30 days), `github-no-activity` (0 repos and 0 followers). Informational: `slack-sso`, `has-footprint`. -`purgeAfter` is when the [scheduled spam purge](../behaviors/person-lifecycle.md#scheduled-spam-purge) will take the member — three days after the newest human vote, when every human vote is `spam` and the member is still deactivated and not staff — or `null`. +`purgeAfter` is when the [scheduled spam purge](../behaviors/person-lifecycle.md#scheduled-spam-purge) will take the member — three days after the newest **deciding** vote, when every deciding vote is `spam` and the member is still deactivated and not staff — or `null`. The deciding votes are the human votes; when there are none, the votes of agents that still hold an active grant. None of it is reachable by a non-staff caller (who gets 404 anyway). `latestVote` is `null` -when no human has voted. +when no human has voted; it never holds an agent's vote. + +`stagedVote` is the newest agent vote when **no human has voted** on the +member, else `null`: + +```json +"stagedVote": { + "verdict": "spam", + "agent": { "slug": "example-bot", "fullName": "Example Bot" }, + "reasoning": "Business name; GitHub account 3h old; promotional link added to bio 6 min after sign-up.", + "flags": ["business-name", "github-new-account", "bio-added-link"], + "evaluatedAt": "…", + "counts": true +} +``` + +`counts` is `false` when the agent no longer holds an active grant: the vote +stays on the row as a record but no longer queues a purge. + +`emailDomain` is the part of `email` after the `@`, or `null`. ## GET /api/admin/members/:slug @@ -101,13 +127,15 @@ The row above, plus the full footprint and vote history: "tags": [ { "handle", "type" } ] }, "votes": [ - { "verdict": "spam", "reasoning": "…", "voter": { "slug", "fullName" }, "evaluatedAt": "…" } + { "kind": "human", "verdict": "spam", "reasoning": "…", "flags": ["manual-override"], "voter": { "slug", "fullName" }, "evaluatedAt": "…" }, + { "kind": "agent", "verdict": "spam", "reasoning": "…", "flags": ["github-new-account"], "voter": { "slug", "fullName" }, "evaluatedAt": "…" } ] } } ``` -`votes` is newest first. Everything in `footprint` is drawn from the in-memory +`votes` is newest first, human and agent votes together; `kind` tells them +apart (from the `evaluator` prefix — [spam-exclusion.md](../behaviors/spam-exclusion.md#where-verdicts-come-from)). Everything in `footprint` is drawn from the in-memory public state — nothing here is fetched from elsewhere. ### Errors @@ -117,9 +145,10 @@ public state — nothing here is fetched from elsewhere. ## POST /api/admin/members/:slug/vote Record the caller's verdict on this person as a `person-evaluations` record with -`evaluator = "human-"` (one record per voter per person — voting -again replaces the caller's previous record). Committed through the write mutex -as the caller, so the vote carries the voter's pseudonymous git identity. +`evaluator = "human-"` — or `"agent-"` when the caller is +an agent (one record per voter per person — voting again replaces the caller's +previous record). Committed through the write mutex as the caller, so the vote +carries the voter's pseudonymous git identity. ### Request @@ -127,14 +156,24 @@ as the caller, so the vote carries the voter's pseudonymous git identity. { "verdict": "spam" | "legit", "reasoning": "optional, ≤ 1000 chars" } ``` +An agent also sends `flags` (0–10 short reason tags, each +`^[a-z0-9][a-z0-9:-]{0,39}$`), and `reasoning` is **required** for it (1–1000 +chars): a person reviewing the staged vote reads it. Human votes carry +`flags = ["manual-override"]` and `confidence = 1`; agent votes carry +`["agent", …flags]` and no `confidence`. + ### Side effects (per [behaviors/person-lifecycle.md](../behaviors/person-lifecycle.md)) - `verdict = "spam"` → the person is **deactivated** in the same transaction (`deletedAt = now()` if not already set). -- `verdict = "legit"` → if the person's previous **latest** human vote was `spam` - (i.e. this vote reverses a moderation hide), `deletedAt` is cleared. A - self-deactivation is left alone. -- Voting on yourself is rejected. +- `verdict = "legit"` → if the person's previous **latest** vote, human or + agent, was `spam` (i.e. this vote reverses a moderation hide — a staff + member's or an agent's), `deletedAt` is cleared. A self-deactivation is left + alone. +- A human vote on a member an agent had staged is an ordinary human vote: from + then on the human votes decide and the agent's record is only history. That + is how staff **overrule** (`legit`) or **confirm** (`spam`) a staged vote. +- Voting on yourself, or on an agent account, is rejected. ### Response — 200 @@ -144,8 +183,60 @@ as the caller, so the vote carries the voter's pseudonymous git identity. ### Errors -- `422 validation_failed` — bad verdict, reasoning too long, or `slug` is the caller -- `404 not_found` — slug doesn't exist, or the caller is not staff +- `422 validation_failed` — bad verdict, reasoning too long or (agent) missing, + bad flags, `slug` is the caller, or `slug` is an agent account +- `409 conflict` — (agent) `error.code = "human_verdict_exists"`, `"member_too_old"` + or `"protected_account"`; see [Agent votes](#agent-votes) +- `429 rate_limited` — (agent) over its daily spam-vote allowance +- `404 not_found` — slug doesn't exist, or the caller is not staff and holds no + grant with the scope + +## DELETE /api/admin/members/:slug/vote + +Agent only. Deletes the calling agent's own `agent-` record on this +person, so an agent can take back a ruling as freely as it made one. If that +was the person's latest vote and it was `spam`, and no other agent's `spam` +vote remains, the person is reactivated (the reversal rule). Response `200 { person, stagedVote }`; +`404` when the agent has no vote on the person. Staff change a vote by voting +again; clearing a human vote is not offered. + +## Agent callers + +A request carrying an agent grant (`Authorization: Bearer cfpa_…`) sees the +roster as staff do, less what an agent does not need: + +- `email` is always `null`; `emailDomain` is kept (a throwaway domain is a + spam signal; the address is not). `person` in the detail response omits + `email` too. +- `q` matches `fullName`, `slug`, `bio` and the email **domain** only. +- Rows and the GitHub re-probe are the same as for staff; the probe is + bounded the same way. + +## Agent votes + +An agent's vote is a **staged** ruling: it acts at once (a `spam` vote hides +the member), it is labeled as an agent's everywhere, and it purges after three +days only if no human has voted in the meantime. The rules, checked inside the +write mutex: + +1. **Humans are final.** An agent may not vote on a member any human has voted + on: `409 human_verdict_exists`. Its earlier vote on that member stays as the + record and has no effect. +2. **Only ordinary members.** The member must be `accountLevel = user` and not + an agent account: `409 protected_account` / `422`. +3. **Only new members, for `spam`.** A `spam` vote needs the member's + `createdAt` within the last **30 days**: `409 member_too_old`. Long-standing + members are a human's call. `legit` votes have no age limit. +4. **Volume.** At most **20** `spam` votes per agent in any rolling 24 hours, + counted from the agent's own records' `evaluatedAt` (a re-vote counts again): + `429 rate_limited` with `Retry-After`. The general per-account write cap in + [conventions.md](conventions.md#rate-limiting) applies on top. +5. **Attribution.** The commit is authored by the agent account + (` <@users.noreply.codeforphilly.org>`), subject + `: POST /api/admin/members//vote` (or `DELETE`), with the + usual trailers plus `Actor-Kind: agent` and `Grant-Id: ` + ([storage.md → Commit message shape](../behaviors/storage.md#commit-message-shape)). + `Actor-Account-Level` is `user`. ## Relationship to other specs @@ -156,6 +247,8 @@ as the caller, so the vote carries the voter's pseudonymous git identity. deactivate side effect and its reversal rule. - [api/people.md](people.md) — `deactivate`, `reactivate`, `purge`, and the staff-only fields reused here. +- [api/agents.md](agents.md) — agent accounts, grants, and withdrawing an + agent's staged votes. ## POST /api/admin/private-data/purge-orphans diff --git a/specs/api/people.md b/specs/api/people.md index fa3cad3..6ce125b 100644 --- a/specs/api/people.md +++ b/specs/api/people.md @@ -255,6 +255,8 @@ Administrator-only. Changes a person's `accountLevel`. This is the *only* way to `level` is one of `user` | `staff` | `administrator`. Setting the person's current level is an idempotent no-op (still 200). +An [agent account](agents.md) (a person with any `agent-grants` record) cannot be raised above `user`: `409 conflict`, `error.code = "agent_account"`. + ### Response — 200 Returns the updated person (same shape as `GET /api/people/:slug`), so the caller sees the new `accountLevel`. diff --git a/specs/behaviors/authorization.md b/specs/behaviors/authorization.md index 07e1dfc..7e5e1a7 100644 --- a/specs/behaviors/authorization.md +++ b/specs/behaviors/authorization.md @@ -37,6 +37,7 @@ Authorization rules in endpoint and screen specs use these markers. The vocabula | `poster` / `author` | Current user authored the resource (a buzz item, an update, a help-wanted role). | | `staff` | `accountLevel ∈ {staff, administrator}`. | | `administrator` | `accountLevel = administrator`. | +| `agent:` | The request carries an active [agent grant](#agent-grants) that includes ``. | When multiple markers separated by `|` are listed (`maintainer | staff`), any one satisfying suffices. @@ -129,6 +130,45 @@ A common pattern is to store the latest refresh-token `jti` per person and rejec - `401 unauthenticated` if no valid session (cookie missing, JWT invalid, JWT revoked, or refresh required) — frontend redirects to `/login?return=` - `403 forbidden` if the session is valid but the caller lacks the required marker — frontend shows an inline error or a 403 page; no redirect +## Agent grants + +A grant is the one way a program reaches the API as itself rather than as a +person: an opaque bearer token an administrator mints for an [agent +account](../api/agents.md), with a fixed list of scopes and an expiry of at +most 180 days. It exists so an automated moderator never needs a person's +session, a browser sign-in, or staff powers. + +- **Presented as** `Authorization: Bearer cfpa_…`. The session middleware + hashes it (SHA-256), looks the hash up in the in-memory grant table (loaded + from the private store at boot, updated on every mint and revoke), and + checks: not revoked, not expired, the agent's person record exists, is not + deactivated, and is `accountLevel = user`. Any failure → `401 unauthenticated` + (`error.code = "grant_invalid"`), never a fall-through to anonymous, so a + broken token is loud. A request with both a grant and a session cookie is + refused the same way. +- **What it satisfies.** Only `agent:` markers for its own scopes. For + every other marker — `user`, `self`, `staff`, and the optional-session + behaviour of `public` reads — the caller is anonymous. A grant cannot edit a + profile, join a project, refresh into a cookie session, or reach any endpoint + that does not name its scope. +- **Who it is.** The request's `person` is the agent account, so commits are + authored by it and self-checks (no voting on yourself) apply. The commit adds + `Actor-Kind: agent` and `Grant-Id: ` ([storage.md](storage.md#commit-message-shape)). +- **Revocation** is immediate and needs no signing-key rotation: the grant + table is the source of truth on every request, unlike a stateless JWT. +- **Limits.** Agent requests count against the per-account rate limits like + anyone's; endpoints add their own (agent spam votes: + [api/moderation.md → Agent votes](../api/moderation.md#agent-votes)). +- **Secret handling.** The token is shown once at mint and never stored, + logged (the `authorization` header is redacted from request logs), or written + to either data store; only its hash is kept, in the private store. + +Why not a staff account with a long-lived session: the session JWT lives in +cookies minted by a browser sign-in, its refresh token is not rotated, and it +carries the full `staff` level (every staff endpoint, every member's email, +deactivating anyone). Votes cast that way would also be recorded as +`human-` — false in a public record. A grant is narrower on every count. + ## CSRF - The session cookie is `SameSite=Lax`, so cross-origin POSTs from third parties can't carry it. This is sufficient for a same-origin SPA. diff --git a/specs/behaviors/person-lifecycle.md b/specs/behaviors/person-lifecycle.md index 258a749..3421bd9 100644 --- a/specs/behaviors/person-lifecycle.md +++ b/specs/behaviors/person-lifecycle.md @@ -5,7 +5,7 @@ A person record has two removal paths with very different intent and reversibili | State | Set by | Effect | Reversible | | ----- | ------ | ------ | ---------- | | **Active** | default | Normal — visible in lists, detail, and as a reference on content. | — | -| **Deactivated** | self, staff/admin, **or a staff spam vote** | Soft hide. `deletedAt` set. Hidden from public lists + detail; references render a placeholder. The person **can still sign in** and reactivate. | Reactivate (clears `deletedAt`). | +| **Deactivated** | self, staff/admin, **or a spam vote** (staff, or a staged [agent](../api/agents.md) vote) | Soft hide. `deletedAt` set. Hidden from public lists + detail; references render a placeholder. The person **can still sign in** and reactivate. | Reactivate (clears `deletedAt`). | | **Purged** | admin only | Cascading hard delete of the person + their content, in a single commit. | Via git history only (revert the commit). | ## Deactivate (soft, self-service) @@ -17,7 +17,8 @@ The privacy / self-removal path — members should be able to remove themselves; - **Visibility while deactivated:** excluded from public list endpoints; `GET /api/people/:slug` returns 404 for non-staff (staff may still fetch it, with `deletedAt` populated). Anywhere a deactivated person is referenced (project member grids, project-update/project-buzz authors, help-wanted "posted by", blog author) the serialized reference is a **"Deactivated user" placeholder** (no slug link, generic avatar) rather than the person — substitute, do not omit, so counts/history stay coherent. - **Login is NOT blocked** — a deactivated user can still authenticate and reactivate themselves. No session revocation. - **Surfaces:** self at `/account` ("Deactivate my account" / "Reactivate"); staff/admin via a person "Danger Zone"; and the **admin members screen**, where a `spam` vote deactivates the person in the same transaction that records the vote ([api/moderation.md](../api/moderation.md)). -- **Vote reversal rule:** a `legit` vote clears `deletedAt` only when the person's previous latest human vote was `spam` — that is, when moderation set it. A self-deactivation is never undone by a vote. +- **Deciding votes:** the human votes on a person, when there are any; otherwise the votes of agents that hold an active grant ([api/moderation.md → Agent votes](../api/moderation.md#agent-votes)). An agent vote stops deciding the moment any human votes on the person, and while its agent holds no active grant. +- **Vote reversal rule:** a `legit` vote (or an agent withdrawing its vote) clears `deletedAt` only when the person's previous latest vote — human or agent, whether or not it still decides — was `spam`, that is, when moderation set it. A self-deactivation is never undone by a vote. ## Purge (cascading hard delete, admin only) @@ -38,15 +39,17 @@ The garbage-collection path for spam — the runtime sibling of the offline spam A `spam` vote on the admin members screen deactivates the person at once; three days later, if nobody has disagreed, the site purges them on its own. Staff shouldn't have to come back and press a second button for every spam account, and three days is long enough for another staff member to look and object. +An agent's `spam` vote **stages** the same way: it hides the person at once and purges them three days later unless a human votes in the meantime. The three days are the review window: the roster shows every staged vote, labeled as the agent's, with its reasoning and purge date, and one **Not spam** click from any staff member overrules it ([screens/admin-members.md](../screens/admin-members.md)). Nothing an agent does deletes anything sooner. + - **When:** an in-process job runs once a day (and once shortly after boot) — **only on the deployment where `CFP_SCHEDULED_PURGE=true`, which is production alone.** Sandbox and production serve the same `published` branch, so any pod that runs the sweep is deleting production data; exactly one may. With the flag off the pod never sweeps and logs that at boot. - **Who it takes:** every person who - 1. has at least one human `spam` vote, and every human vote on them is `spam` — a `legit` or `uncertain` vote from anyone holds them back; - 2. got their newest human vote at least **3 days** ago; + 1. has at least one deciding `spam` vote, and every deciding vote on them is `spam` — a `legit` or `uncertain` vote holds them back. With any human vote on record, only human votes decide; with none, the votes of agents holding an active grant decide (an agent's vote with no active grant behind it queues nobody); + 2. got their newest deciding vote at least **3 days** ago — a staff member confirming a staged vote with their own `spam` restarts the three days from their vote; 3. is still deactivated (`deletedAt` set) — reactivating someone takes them out of the queue; - 4. is not `staff` or `administrator`. + 4. is not `staff` or `administrator`, and is not an agent account. - **What it does:** exactly what Purge does for each of them, including projects they created that nobody else touched. Within one run, "nobody else" means nobody outside the batch: a project made by one queued spam account and joined only by others in the same queue goes too. -- **One commit per run** for the whole batch, authored by the site (`cfp-api`), its message listing the purged slugs. A run with nobody queued writes nothing. -- The human votes stay in `person-evaluations` as the record of why. +- **One commit per run** for the whole batch, authored by the site (`cfp-api`), its message listing the purged slugs. A slug queued on agent votes alone is listed as ` (staged by agent-)`, and the commit carries an `Agent-Staged: ` trailer counting them, so `git log` separates what a person decided from what only an agent did. A run with nobody queued writes nothing. +- The votes — human and agent — stay in `person-evaluations` as the record of why. ## Authorization summary @@ -57,6 +60,8 @@ A `spam` vote on the admin members screen deactivates the person at once; three | Purge | – | – | ✓ | | Scheduled spam purge | – | – (their spam vote queues it) | – (runs as the site) | +An agent with a `moderation:vote` grant may cast staged spam / not-spam votes within the [agent vote rules](../api/moderation.md#agent-votes); it can deactivate (a spam vote) and reverse its own deactivation, and nothing else in this table. + ## Relationship to other specs - storage.md — all writes go through the in-process mutex; purge is one transaction. diff --git a/specs/behaviors/private-storage.md b/specs/behaviors/private-storage.md index 472c0a1..893f4a1 100644 --- a/specs/behaviors/private-storage.md +++ b/specs/behaviors/private-storage.md @@ -35,13 +35,28 @@ Two `.jsonl` files in the bucket. Each holds the latest state of every record of ```text bucket/ ├── profiles.jsonl # private profile per Person — email, newsletter prefs, etc. -└── legacy-passwords.jsonl # imported from laddr; drains to zero post-migration +├── legacy-passwords.jsonl # imported from laddr; drains to zero post-migration +└── agent-grant-secrets.jsonl # SHA-256 hash of each agent grant's token (see below) ``` +(The store also holds `account-claim-requests.jsonl`, `password-tokens.jsonl` +and session metadata; their shapes are in their own specs.) + That's the entire on-disk footprint. No per-record files, no indexes on disk, no append-only log series. ### Record shapes +**`agent-grant-secrets.jsonl`** — one line per [agent grant](authorization.md#agent-grants): + +```json +{ "grantId": "0195…", "personId": "0195…", "tokenHash": "", "lastUsedAt": "2026-11-12T03:10:00Z" } +``` + +Everything else about a grant (scopes, expiry, who minted it, revocation) is +public, on the `agent-grants` sheet ([data-model.md](../data-model.md#agentgrant)). +Only the hash lives here, so a leaked bucket yields no usable token. A purge of +the agent account deletes its lines with the rest of its private data. + **`profiles.jsonl`** — one line per Person: ```json @@ -164,7 +179,7 @@ The filesystem backend doesn't replicate versioning. Dev mode is lossy by design ## What's NOT in the bucket - **Authentication credentials** (passwords, hashed or otherwise) for the *active* system. v1 has no password auth — only GitHub OAuth — so this is trivially true. The `legacy-passwords.jsonl` is the one exception, drains to zero, and is treated as one-shot migration data. -- **Auth tokens.** JWT signing keys live in the deploy environment, not the bucket. Revocation state is in-memory only (see [behaviors/authorization.md](authorization.md)). +- **Auth tokens.** JWT signing keys live in the deploy environment, not the bucket. Revocation state is in-memory only (see [behaviors/authorization.md](authorization.md)). Agent grant tokens are not stored either — only their hashes (above). - **Public records.** Anything that the public site displays — project READMEs, member profiles, tags, help-wanted listings — stays in the public gitsheets repo. ## Audit diff --git a/specs/behaviors/spam-exclusion.md b/specs/behaviors/spam-exclusion.md index 7e8b982..45c9008 100644 --- a/specs/behaviors/spam-exclusion.md +++ b/specs/behaviors/spam-exclusion.md @@ -32,17 +32,31 @@ Two sources write the same record shape into the **`person-evaluations`** sheet and committed to **`published`** in this repo as `evaluator = "human-"`, authored by the voter. They are small, summary-only, and attributable, which is what a public civic dataset can carry. +3. **Agent votes** are cast on the site by an [agent](../api/agents.md) — an + automated moderator holding a scoped grant — through the same endpoint, and + committed to **`published`** as `evaluator = "agent-"`, authored by + the agent's account. They are as public and attributable as human votes, + but they are **staged**: any human vote on the person overrules them + ([api/moderation.md → Agent votes](../api/moderation.md#agent-votes)). + +The `evaluator` prefix is the evaluator's kind, and the code reads it as such: + +| Prefix | Kind | Where it lives | Decides | +| ------ | ---- | -------------- | ------- | +| `human-` | a staff member | `published` | always, when present | +| `agent-` | an agent account on the site | `published` | only when no human has voted, and only at runtime | +| anything else (`heuristic-v1`, `haiku-2026-05`, `github-probe`, …) | an offline machine pass | private repo | only through the prune's threshold rule | Each record: | Field | Meaning | | ----- | ------- | | `personSlug` | the evaluated person | -| `evaluator` | model/run id (e.g. `haiku-2026-05`) or `human-` | +| `evaluator` | model/run id (e.g. `haiku-2026-05`), `human-`, or `agent-` | | `verdict` | `"spam"` \| `"legit"` \| `"uncertain"` | -| `confidence` | 0–1 (LLM); absent on heuristic records, which carry `score` instead; `1` on human votes | -| `flags` | array of short reason tags | -| `reasoning` | free-text justification (optional on human votes) | +| `confidence` | 0–1 (LLM); absent on heuristic records, which carry `score` instead; `1` on human votes; absent on agent votes | +| `flags` | array of short reason tags (`["manual-override"]` on human votes; `["agent", …]` on agent votes) | +| `reasoning` | free-text justification (optional on human votes, required on agent votes) | | `evaluatedAt` | ISO 8601 UTC | The record shape is the versioned schema contract @@ -66,6 +80,14 @@ deliberately **conservative — only confident spam is pruned**: > latest one decides: `spam` → pruned (membership protection does not apply — > a person looked at the profile); `legit` → kept. > +> **An agent vote never prunes.** The offline prune counts an `agent-*` `legit` +> as a `legit` verdict (it protects the person like any evaluator's), and +> ignores an `agent-*` `spam`: a staged spam vote reaches deletion only through +> the runtime [scheduled spam purge](./person-lifecycle.md#scheduled-spam-purge), +> after its three days. (An agent vote has no `confidence`, so the machine rule +> below would ignore it anyway; the prune says so explicitly rather than relying +> on that.) +> > Otherwise a person is **pruned as spam** iff they have at least one `spam` > verdict with `confidence ≥ SPAM_CONFIDENCE_THRESHOLD` (default **0.8**), no > `legit` verdict at any confidence, **and no `project-membership`** (real @@ -105,10 +127,10 @@ records, not a full-tree replacement like the importer). ## What the runtime sees The runtime loads exactly one evaluation sheet: `person-evaluations` from -`published`, which by construction holds only human votes. It uses them for the -admin members screen and for the vote side effect (a `spam` vote deactivates the -person immediately — [person-lifecycle.md](./person-lifecycle.md)); the public -read services stay spam-unaware. After a prune, `published` holds +`published`, which by construction holds only human and agent votes. It uses +them for the admin members screen, for the vote side effect (a `spam` vote +deactivates the person immediately — [person-lifecycle.md](./person-lifecycle.md)), +and for the scheduled spam purge; the public read services stay spam-unaware. After a prune, `published` holds only kept people (legit + uncertain + unevaluated minus confident spam), so the in-memory state, indices, and FTS are built over that smaller set. Dangling references are avoided by the cascade, so member lists, help-wanted interest, and diff --git a/specs/behaviors/storage.md b/specs/behaviors/storage.md index 6ecf90a..66c7edb 100644 --- a/specs/behaviors/storage.md +++ b/specs/behaviors/storage.md @@ -271,6 +271,7 @@ Rules: - The **author and committer identity is pseudonymous** in the public repo: ` <@users.noreply.codeforphilly.org>`. **Real email addresses are never written as commit authors** — git stores author identity forever and the data repo is public. The pseudonymous form keeps `git log --author=` and `git blame` working naturally for audit purposes without leaking PII. - For anonymous requests, author is `Anonymous `. - For system/automated commits (migration import, reconciliation), author is `Code for Philly API `. + - For requests made with an [agent grant](authorization.md#agent-grants), author is the agent account's pseudonymous identity like any person's, and the commit adds two trailers: `Actor-Kind: agent` and `Grant-Id: ` (never the token). `git log --grep='^Actor-Kind: agent$'` lists everything agents did. - **`User-Ip` and `User-Agent` trailers are NOT included.** The public commit history is forever-public; client IPs and user agents are PII that doesn't belong there. If a security incident needs request forensics, that's what the optional access-log sidecar (private store) is for — not the public git log. - The **trailers** follow git's standard trailer format (parseable by `git interpret-trailers --parse`). Key convention is HTTP-header style: first letter capitalized, multi-word keys hyphenated, rest lowercase. Examples: `Subject-Type`, `Response-Code`. Single-word keys: `Action`, `Reason`, `Host`. - `Action` is namespaced with a dot (`project.soft-delete`, `tag.merge`, `account-level.change`) so trailer filters like `git log --grep='^Action: project\.'` work cleanly. diff --git a/specs/data-model.md b/specs/data-model.md index 141d551..d058e37 100644 --- a/specs/data-model.md +++ b/specs/data-model.md @@ -41,6 +41,8 @@ Tag ──── TagAssignment ──── (Project | Person | HelpWantedRole | polymorphic via taggableType + taggableId Person ── has ── Revocation (0:many; revoked JWT IDs) +Person ── has ── AgentGrant (0:many; only agent accounts) +Person ── evaluated by ── PersonEvaluation (0:many; staff and agent spam votes) SlugHistory ── points at any renamed entity by (entityType, oldSlug) PRIVATE (S3-compatible bucket) ───────────────────────────────────────── @@ -195,6 +197,39 @@ A periodic background task (in-process) sweeps `revocations` for records whose ` - `revokedJtis: Set` — checked on every authenticated request +## AgentGrant + +A scoped API grant for an [agent account](api/agents.md) — an automated +moderator. Public so that which programs may act on the site, with which +powers, granted by whom, is part of the civic record; the token itself never +is (its hash is private — [private-storage.md](behaviors/private-storage.md)). + +**Sheet:** `agent-grants` +**Path template:** `agent-grants/${personSlug}/${id}.toml` +**Contract:** `codeforphilly.org/agent-grants/v1` + +| Field | Type | Notes | +| ------- | ------ | ------- | +| id | uuid | | +| personSlug | string | the agent account. Follows renames like other slug references. | +| label | string | 1–80 chars; what and where (e.g. "ops bot, host vm-1") | +| scopes | string[] | from `moderation:read`, `moderation:vote` | +| createdAt | iso8601 | | +| createdBySlug | string | the administrator who minted it | +| expiresAt | iso8601 | at most 180 days after `createdAt` | +| revokedAt | iso8601 nullable | | + +A person with any `agent-grants` record is an agent account: never staff, +never voted on, never taken by the scheduled spam purge. + +## PersonEvaluation + +One spam verdict per (person, evaluator) — `person-evaluations/${personSlug}/${evaluator}.toml`, +contract `codeforphilly.org/person-evaluations/v2`. On `published` it holds +staff votes (`evaluator = "human-"`) and agent votes +(`"agent-"`). Field table, evaluator kinds and aggregation: +[behaviors/spam-exclusion.md](behaviors/spam-exclusion.md#where-verdicts-come-from). + ## Project **Sheet:** `projects` diff --git a/specs/screens/admin-members.md b/specs/screens/admin-members.md index c627f5a..487e583 100644 --- a/specs/screens/admin-members.md +++ b/specs/screens/admin-members.md @@ -19,11 +19,20 @@ as final. A spam vote hides the member at once; the [scheduled spam purge](../behaviors/person-lifecycle.md#scheduled-spam-purge) deletes them three days later unless another vote disagrees. The page intro says so. +An [agent](../api/agents.md) — an AI moderator staff have granted access — may +cast votes too. Its votes are **staged**: they hide a member at once like a +staff spam vote, but this page labels them as the agent's, shows its reasoning +on the row, and lets any staff member overrule (**Not spam**) or confirm +(**Spam**) them with one click. The intro says that as well, and names the +date a staged vote purges. + ## Data Requirements - `GET /api/admin/members` on entry and on every filter/sort/page change - `GET /api/admin/members/:slug` when a row is expanded or the detail route opens - `POST /api/admin/members/:slug/vote` on either vote button +- `GET /api/admin/agents`, `POST /api/admin/agents/:slug/withdraw-votes` and the + grant endpoints in the Agents panel ([api/agents.md](../api/agents.md)) See [api/moderation.md](../api/moderation.md). @@ -32,8 +41,9 @@ See [api/moderation.md](../api/moderation.md). ### Roster - Newest signup first by default; sort toggles for name and last sign-in. -- Filter bar: text search; **Vote**: any / **no vote yet (default)** / voted - spam / voted legit; **Origin**: any / signed up here / imported; joined date +- Filter bar: text search; **Vote**: any / **no human vote yet (default)** / + staged by an agent / voted spam / voted legit — the default includes + agent-staged rows, so a staged vote is never out of the default view; **Origin**: any / signed up here / imported; joined date range; a "show deactivated" toggle (on by default — moderation needs to see what it hid). - Each row: avatar, `fullName` (link to the public profile, opens in a new tab), @@ -46,6 +56,12 @@ See [api/moderation.md](../api/moderation.md). - no vote → two buttons **Spam** / **Not spam** - voted → a badge `Spam · by {voter} · {when}` or `Not spam · …` with a "Change" affordance that re-shows the buttons + - staged by an agent (`stagedVote`, no human vote) → a violet badge + `Staged spam · {agent} (AI) · {when}` or `Staged not spam · …`, the first + line of the agent's reasoning beside it (full text on hover and in the + detail), its flags as small chips, **and the two buttons still shown**: Not + spam overrules, Spam confirms. When `counts` is false the badge is grey and + reads `… · access revoked`, and its tooltip says it will not purge anyone. - **Badges** (from the row's `origin`, `github`, `lastSlackSsoAt`, `emailBounce`, and `signals`): - `Signed up here` (green) or `Imported` (neutral). @@ -62,7 +78,8 @@ See [api/moderation.md](../api/moderation.md). present), so the eye lands on the spam shape without reading every field. Rows with `has-footprint` and `attention = 0` stay plain. - Deactivated members render dimmed with a "Deactivated" chip; a member hidden by - a spam vote shows "Hidden by {voter}". When `purgeAfter` is set, a red chip says when the + a spam vote shows "Hidden by {voter}" ("Hidden by {agent} (AI)" for a + staged vote). When `purgeAfter` is set, a red chip says when the [scheduled spam purge](../behaviors/person-lifecycle.md#scheduled-spam-purge) takes them: "Purges {date}", or "Purging at the next daily sweep" once the date has passed; its tooltip says a Not spam vote or reactivation stops it. @@ -75,8 +92,24 @@ See [api/moderation.md](../api/moderation.md). Full bio (rendered markdown, server-side), then the footprint as lists with links: project memberships (role, joined), authored updates, buzz, blog posts, help-wanted interest, tags. Then the vote history, newest first, each with -voter, verdict, reasoning, and time. Then the same two buttons, plus a link to -the person's Danger Zone for admins (purge lives there, not here). +voter, verdict, reasoning, and time; an agent's vote is marked "AI agent" and +shows its flags, and once a human has voted the agent's votes read "overruled" +or "confirmed". Then the same two buttons, plus a link to the person's Danger +Zone for admins (purge lives there, not here). + +### Agents panel + +A collapsed **Agents** section at the foot of the page, one line per agent +account (`GET /api/admin/agents`): its name (linking to its profile), how many +votes it has staged now, and its grants (label, scopes, created by, expires, +last used, active or revoked). + +- **Show staged** sets the Vote filter to "staged by an agent". +- **Withdraw staged votes** (staff) — a confirm saying how many votes it removes + and how many members it un-hides, then `POST …/withdraw-votes`. +- **Mint grant** / **Revoke** / **New agent** (administrators only). A minted + token is shown once, in a dialog with a copy button and the words "This is + the only time it is shown"; closing the dialog drops it from the page. ### Voting @@ -86,7 +119,11 @@ the person's Danger Zone for admins (purge lives there, not here). in the same transaction. - **Not spam** calls the endpoint directly (reasoning optional via the same disclosure). If the member had been hidden by a spam vote, the row un-dims. -- The buttons are disabled on the caller's own row. +- The buttons are disabled on the caller's own row and on agent accounts'. +- On a staged row, **Not spam** and **Spam** are ordinary staff votes: the + first un-hides the member and stops the purge; the second confirms it and + restarts the three days from now. Either way the row then shows the staff + vote, and the agent's vote moves to the history. - Errors surface as a toast; the row state is refetched, never guessed. ## Actions @@ -97,16 +134,21 @@ the person's Danger Zone for admins (purge lives there, not here). | Expand row | `GET /api/admin/members/:slug` | Show footprint + votes | | Spam | `POST …/vote { verdict: "spam", reasoning? }` | Row → deactivated + vote badge | | Not spam | `POST …/vote { verdict: "legit", reasoning? }` | Row → vote badge; un-dim if vote-hidden | +| Withdraw staged votes (Agents panel) | `POST /api/admin/agents/:slug/withdraw-votes` | Refetch roster; toast with the counts | +| Mint / Revoke grant, New agent (Agents panel, admin) | [api/agents.md](../api/agents.md) | Refresh the panel; show a new token once | ## Authorization Staff and administrators. Purge is not offered here (admin-only, on the person page). Votes are attributed to the individual caller and can be changed by that -caller at any time. +caller at any time. Grants are minted and revoked by administrators only. +Agents never see this page: they hold no session, only an API grant. ## Out of scope (v1) -- Machine verdicts, confidence, or reasoning on this page — the pipeline's - concern, and its data lives in a private repo. +- Offline machine verdicts, confidence, or reasoning on this page — the + pipeline's concern, and its data lives in a private repo. (Agent votes are + not machine verdicts in this sense: they are cast on the site, public, and + shown.) - Bulk actions. - Running the heuristic in-process at signup (natural follow-up). From 58c073800be9628a09bf17e67a27a8e7941b2e00 Mon Sep 17 00:00:00 2001 From: GrittyBot <30755786+codeforphilly-bot@users.noreply.github.com> Date: Wed, 11 Nov 2026 19:33:47 +0000 Subject: [PATCH 02/23] chore(plans): add bot-spam-votes, planned Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_01S8dthiZS2vwh674eh2DDQs --- plans/bot-spam-votes.md | 259 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 259 insertions(+) create mode 100644 plans/bot-spam-votes.md diff --git a/plans/bot-spam-votes.md b/plans/bot-spam-votes.md new file mode 100644 index 0000000..b3a706f --- /dev/null +++ b/plans/bot-spam-votes.md @@ -0,0 +1,259 @@ +--- +status: planned +depends: [scheduled-spam-purge, admin-members-moderation, sheet-contracts] +specs: + - specs/api/agents.md + - specs/api/moderation.md + - specs/api/people.md + - specs/behaviors/authorization.md + - specs/behaviors/person-lifecycle.md + - specs/behaviors/spam-exclusion.md + - specs/behaviors/storage.md + - specs/behaviors/private-storage.md + - specs/data-model.md + - specs/screens/admin-members.md +--- + +# Plan: Staged spam votes from an agent (the ops bot) + +## Scope + +Chris (2026-11-11), replying to the ops bot's flag of a spam sign-up: "let's +build a tool where you can apply 'spam' and 'not spam' votes as our screen in +/admin/members enables. Those rulings stage for 3 days so you can freely set +them as you see evidence." + +Today only a staff session can vote, the only way into a session is a browser +GitHub sign-in, and every vote is recorded as `human-`. This plan gives +an automated moderator its own narrow way in and its own label, and makes the +three-day window a review window people can see and act on. + +**In:** + +- Agent accounts and **agent grants**: a public `agent-grants` sheet (contract + `codeforphilly.org/agent-grants/v1`), token hashes in the private store, and + the admin endpoints in `api/agents.md` (create agent, mint, revoke, list, + withdraw staged votes). +- Session middleware: `Authorization: Bearer cfpa_…` resolves to an agent + principal that satisfies only `agent:` markers; `401 grant_invalid` + otherwise; header redacted from logs. +- Moderation: `staff | agent:…` on the three member endpoints; `DELETE …/vote` + for agents; `agent-` evaluator; the agent vote rules (humans final, + `user` only, 30-day member age for `spam`, 20 spam votes / 24h, required + reasoning, flags); agent-caller redaction (`email` null, `emailDomain`, + domain-only `q`); `stagedVote`, `emailDomain`, `vote=staged`, `kind` on + detail votes. +- Lifecycle: "deciding votes" (humans if any, else agents with an active + grant) in the reversal rule, `spamPurgeDueAt` and the sweep; agent accounts + excluded from the sweep and from votes; `Agent-Staged` trailer and + `(staged by …)` lines on the sweep commit. +- Commit metadata: `Actor-Kind: agent` + `Grant-Id` trailers. +- `account-level` refuses to raise an agent account. +- `prune-spam`: `agent-*` `legit` protects; `agent-*` `spam` ignored. +- `/admin/members`: staged badge with reasoning and flags, buttons kept on + staged rows, "staged by an agent" filter, "Hidden by {agent} (AI)", history + labels, the Agents panel (show staged, withdraw, mint/revoke/new agent). + +**Out:** + +- The bot-side command and loop policy — the `grittybot` repo, described + below, its own plan there after this ships. +- Creating the production agent account and minting its grant — an operator + step after deploy (Rollout below), not code. +- A public "AI agent" chip on `/members/` — follow-up; the agent's bio + says it for now. +- Clearing a human vote; bulk staff actions; scopes beyond moderation. +- The existing gap that a spam-hidden member can self-reactivate from + `/account` and so leave the purge queue — true of staff votes today; worth + its own spec decision, not this plan's. + +## Implements + +- [api/agents.md](../specs/api/agents.md) — every endpoint; the agent account + rules (no sign-in identity, never staff, never voted on or swept); grant + token format, hashing, expiry, revocation, `lastUsedAt`. +- [api/moderation.md](../specs/api/moderation.md) — auth column, `vote=staged`, + `emailDomain`, `stagedVote`, `purgeAfter` on deciding votes, `kind` on + detail votes, agent request fields, `DELETE …/vote`, Agent callers, Agent + votes (rules 1–5), new errors. +- [api/people.md](../specs/api/people.md) — `agent_account` refusal on + account-level. +- [behaviors/authorization.md](../specs/behaviors/authorization.md) — + `agent:` marker; Agent grants. +- [behaviors/person-lifecycle.md](../specs/behaviors/person-lifecycle.md) — + deciding votes, reversal rule, staged purge, sweep commit shape. +- [behaviors/spam-exclusion.md](../specs/behaviors/spam-exclusion.md) — + evaluator kinds; agent votes never prune. +- [behaviors/storage.md](../specs/behaviors/storage.md) — agent commit trailers. +- [behaviors/private-storage.md](../specs/behaviors/private-storage.md) — + `agent-grant-secrets.jsonl`. +- [data-model.md](../specs/data-model.md) — `AgentGrant`; `PersonEvaluation` + pointer. +- [screens/admin-members.md](../specs/screens/admin-members.md) — staged rows, + filter, history labels, Agents panel. + +## Approach + +1. **Schema + sheet.** `AgentGrantSchema` in `packages/shared` (Zod, with + `references('people.slug')` on `personSlug`), `AGENT_EVALUATOR_PREFIX`, + `agentEvaluatorFor`, `isAgentEvaluator`, and an `evaluatorKind()` that + returns `human | agent | machine`. Generate the `agent-grants/v1` contract + the way `sheet-contracts` does; add `apps/api/gitsheets/agent-grants.toml`. + Data repo: prepare the sheet config on `empty`, `fixture`, + `legacy-import`, `published` before the API deploy that reads it (as + `person-evaluations-contract` did). +2. **State.** Load `agent-grants` into in-memory state (`agentGrants`, + `agentGrantsByPerson`) in the loader, incremental reload and + `StateApply`; add it to the slug-rename reference cascade and the purge + cascade (a purged agent loses its grants and secrets). Private store: + `agent-grant-secrets.jsonl` with `getGrantSecretByHash`, `putGrantSecret`, + `deleteGrantSecretsFor(personId)`; build a `Map` at boot. +3. **Auth.** In `auth/middleware.ts`, before the cookie: a `Bearer cfpa_` + header → hash → grant → checks → `SessionContext` with `person` = agent, + `accountLevel: 'user'`, and `agent: { grantId, scopes }`. Grant + cookie + together → 401. `requireAuth` / `requireAuthMarker` gain + `agent:`; every existing marker treats a session with `agent` set + as anonymous (one guard at the top, so no route can forget). Redact + `authorization` in the Fastify logger config. Throttle `lastUsedAt` writes + to one an hour. +4. **Moderation service.** `decidingVotes(state, slug, now)` used by + `castVote`'s reversal check, `spamPurgeDueAt`, `stagedVote` and the vote + filter, so there is one definition. `castVote` takes the caller kind; the + agent path runs rules 1–4 inside the mutex (rate count from the agent's + own records), writes `agent-` with `["agent", …flags]` and required + reasoning. `withdrawVote` for `DELETE`. Agent read redaction in `#row` and + the detail route. `spamPurgeDueAt` needs grant state (active grant per + agent) — pass it in with `state`. +5. **Commit meta.** `buildTransactionOptions` adds `Actor-Kind: agent` and + `Grant-Id` when `session.agent` is set. Sweep message lists + `(staged by agent-)` and the `Agent-Staged` trailer. +6. **Agents routes.** `routes/agents.ts` + `services/agents.ts` per + `api/agents.md`; create-agent writes person + empty private profile + + grant + secret in one dual-store transaction; `withdraw-votes` deletes and + reactivates in one commit. +7. **people.account-level** refuses agent accounts. +8. **prune-spam** `foldEvaluation`: `agent-*` legit → `hasAnyLegit`; agent spam + skipped; test both. +9. **Web.** `AdminMembers.tsx`: staged badge, reasoning line, flag chips, + buttons on staged rows, filter option, history labels, Agents panel with + the once-only token dialog. +10. **Docs.** `docs/operations/` — a short "Agents" runbook: create the agent, + mint, hand the token to its host, rotate, revoke, withdraw. + +The work fits one PR; if review prefers smaller pieces, split at 1–5 (API, +no UI: agents can vote, the existing roster shows staged spam as an +unlabeled hide) — **don't ship that half alone to production**, since the +review window is only real once step 9 lands. + +## Validation + +- [ ] `npm run type-check && npm run lint && npm test` clean. +- [ ] Auth tests: a valid grant reaches `GET /api/admin/members` and + `POST …/vote`; the same token is refused (401) on `user`-marked routes + (e.g. `PATCH /api/people/:slug`), `POST /api/auth/refresh` mints nothing + from it, and public reads see it as anonymous; revoked, expired, deactivated-agent and grant+cookie all + 401 `grant_invalid`; a grant without `moderation:vote` gets 404 on vote. +- [ ] The token never appears in the commit, the private store (only its + hash), or a captured request log line. +- [ ] Vote rules: agent vote on a human-voted member → 409 + `human_verdict_exists`; on staff/agent/self → 409/422; spam on a member + older than 30 days → 409 `member_too_old`; 21st spam vote in 24h → 429 + with `Retry-After`; missing reasoning → 422. +- [ ] Side effects: agent spam hides; agent legit / `DELETE` un-hides only + when the agent's spam hid them; a staff `legit` on a staged member + un-hides and stops the purge; a staff `spam` confirms and moves + `purgeAfter` to its own date. +- [ ] Sweep: an agent-only spam member ≥ 3 days with an active grant is + purged, listed `(staged by agent-)`, `Agent-Staged: 1`; with the + grant revoked, not queued and `stagedVote.counts = false`; agent accounts + never queued. +- [ ] Commit for an agent vote: author is the agent's pseudonymous identity, + subject `: POST /api/admin/members//vote`, trailers include + `Actor-Kind: agent`, `Grant-Id`, `Actor-Account-Level: user`. +- [ ] `withdraw-votes`: one commit; counts right; votes on human-voted + members untouched. +- [ ] account-level refuses to raise an agent (`409 agent_account`). +- [ ] prune-spam: agent legit protects; agent spam does not prune. +- [ ] Contract: `gitsheets contracts verify` clean on every data-repo branch + with the `agent-grants` config; boot fails loudly without it. +- [ ] Browser (devbox): create an agent, mint (token shown once), vote with + `curl` as the agent, see the staged badge with reasoning in the default + roster view, overrule with Not spam, withdraw, revoke. + +## Risks / unknowns + +- **A leaked token** can hide up to 20 new members a day until noticed. + Bounded by the age rule (no established member), the daily cap, the + agent's account and reasoning on every row, hiding being reversible, and + three days before anything deletes; recovery is revoke (purges stop at + once) then withdraw (hidden members return), both one click. +- **Nobody looks during the three days.** The window is only a safeguard if + someone reviews. The bot side reports each vote to the operators' channel + and the members nearing purge (below); the roster's default view includes + staged rows. +- **An agent arguing with staff.** Rule 1 makes that impossible on the site; + the bot's own policy must still not re-vote someone a human cleared + (it can't — 409 — but it should not retry). +- **Data-repo ordering.** The API must not boot against a branch without the + `agent-grants` config; land the data-repo commits first (as + `person-evaluations-contract` did). +- **`lastUsedAt` writes** to the private store on a polling agent; throttled + to one an hour per grant. + +## Bot side (grittybot repo, separate plan after this ships) + +A small command, `bin/member-vote`, the bot's only way to vote: + +``` +bin/member-vote show # roster row + votes, JSON +bin/member-vote spam|not-spam --reason "…" [--flag ]… [--dry-run] +bin/member-vote clear # DELETE its own vote +bin/member-vote staged # its staged votes and purge dates +``` + +- **Credential:** `~/.config/grittybot/cfp-agent-token`, mode 0600, beside the + Slack app token, never in the repo, read by the command only; the site base + URL in `opsbot.json` (`site.base_url`). The operator mints the grant and + places the file; the bot never sees a person's credential and holds no + site session. +- **Output:** the API's result plus the purge date, one JSON line appended to + the cycle log; a refusal (409/429) is reported as such, never retried + around. +- **Visibility:** every vote it casts is posted to `#grittybot-hq` in the same + cycle (member slug, verdict, reasons, purge date, roster link); once a day, + a list of its staged votes that purge within 24 hours. That is what makes + the three days a review window in practice. +- **When it votes (loop policy, in the bot's specs):** from the + `30-data-commits` stream's `new-member` and `profile-edit` items, it votes + `spam` only when at least two independent signals agree and none + contradicts, e.g. a business or brand name as the member name; a GitHub + account hours old (`author-account-h-old`) or the roster's + `github-new-account` / `github-no-activity`; a promotional link added to + the bio minutes after sign-up (`bio-added-link`, `has-link`); a throwaway + `emailDomain`; a slug a purge removed before (`slug-purged-before`). It + does **not** vote spam on anyone with a project footprint shared with + other people, anyone who has signed into Slack, or anyone it is talking + to; those it flags to the operators instead. It votes `not-spam` or + `clear` when new evidence contradicts its staged vote (a real project, a + human vouching), at once. +- **Rules change in the bot repo:** the bot's non-negotiables gate deletes + on the operator's gate word. Record the operator's 2026-11-11 direction + that a staged member vote is not a delete (the site's three-day window and + human overrule are the gate) as a reviewed PR to the bot's protocols; a + person merges it. + +## Rollout + +1. Data repo: `agent-grants` sheet config on every branch. +2. Release + deploy (sandbox first; sandbox shares `published` but does not + sweep). +3. An administrator creates the agent account on production with a bio + saying it is an AI run by Code for Philly, mints a + `moderation:read, moderation:vote` grant (90 days), and places the token + on the bot's host. +4. The bot ships `bin/member-vote` and its policy; first week, it posts every + vote and the operator reviews the lot. + +## Notes + +## Follow-ups From c0b962583f3f3ba13671a1a852c5657f2049adfc Mon Sep 17 00:00:00 2001 From: GrittyBot <30755786+codeforphilly-bot@users.noreply.github.com> Date: Thu, 12 Nov 2026 18:20:37 +0000 Subject: [PATCH 03/23] docs(specs): agent purges for posted spam, block re-signup after purge The operator's policy: purge at once when an account posts spam, vote when a signup only looks like spam. Adds a separate purge scope with API-enforced preconditions, a public spam-purges record that blocks the same GitHub account from signing straight back in, and a fix so a purged account's votes no longer attach to a new account under the same slug. Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_01S8dthiZS2vwh674eh2DDQs --- plans/bot-spam-votes.md | 534 +++++++++++++++++++++------- specs/api/agents.md | 74 +++- specs/api/auth.md | 8 +- specs/api/moderation.md | 247 ++++++++++++- specs/api/people.md | 20 ++ specs/behaviors/authorization.md | 9 +- specs/behaviors/person-lifecycle.md | 88 ++++- specs/behaviors/spam-exclusion.md | 11 +- specs/behaviors/storage.md | 2 +- specs/data-model.md | 56 ++- specs/screens/admin-members.md | 67 +++- specs/screens/login.md | 1 + 12 files changed, 945 insertions(+), 172 deletions(-) diff --git a/plans/bot-spam-votes.md b/plans/bot-spam-votes.md index b3a706f..69f817c 100644 --- a/plans/bot-spam-votes.md +++ b/plans/bot-spam-votes.md @@ -3,6 +3,7 @@ status: planned depends: [scheduled-spam-purge, admin-members-moderation, sheet-contracts] specs: - specs/api/agents.md + - specs/api/auth.md - specs/api/moderation.md - specs/api/people.md - specs/behaviors/authorization.md @@ -12,9 +13,10 @@ specs: - specs/behaviors/private-storage.md - specs/data-model.md - specs/screens/admin-members.md + - specs/screens/login.md --- -# Plan: Staged spam votes from an agent (the ops bot) +# Plan: Agent moderation for the ops bot (staged spam votes, posted-spam purges) ## Scope @@ -23,127 +25,250 @@ build a tool where you can apply 'spam' and 'not spam' votes as our screen in /admin/members enables. Those rulings stage for 3 days so you can freely set them as you see evidence." -Today only a staff session can vote, the only way into a session is a browser -GitHub sign-in, and every vote is recorded as `human-`. This plan gives -an automated moderator its own narrow way in and its own label, and makes the -three-day window a review window people can see and act on. +Chris (2026-11-12), the policy this plan implements: "When someone actually +posts spam, purge immediately. For signups that are obviously spam but haven't +posted anything spammy, log a spam vote to put them in the 3-day purge queue +pending doing anything that makes them not look like spam." + +Read as two cases: + +- **Posted spam**: the account itself put spam content on the site — a + promotional bio or link in it, a spam project, update, buzz item or + help-wanted role. The agent **purges at once**. +- **Looks like spam, nothing posted**: a business or site name as the member + name, a throwaway GitHub account. The agent **casts a spam vote**; the member + is hidden and sits in the three-day queue, purged at the end unless + something makes them not look like spam. + +Two recent cases the design is tested against (described generically): + +1. An account signed up under a business name and, 90 seconds later, set a + promotional bio with a link. Staff purged it. Nine hours later the same + GitHub account signed up again, got the same slug, and posted the same bio + within 80 seconds. +2. An account signed up under a site name, self-joined an existing project as + a plain member, and within 70 seconds set a bio advertising a streaming + site, with a link. + +Today only a staff session can vote or purge, the only way into a session is a +browser GitHub sign-in, every vote is recorded as `human-`, and a purged +GitHub account signs straight back in (case 1). This plan gives an automated +moderator its own narrow way in and its own label, a fenced purge for posted +spam, a public log of spam purges that also blocks the purged GitHub account +from signing up again, and fixes votes from a purged account attaching to a +new account with the same slug. **In:** - Agent accounts and **agent grants**: a public `agent-grants` sheet (contract `codeforphilly.org/agent-grants/v1`), token hashes in the private store, and the admin endpoints in `api/agents.md` (create agent, mint, revoke, list, - withdraw staged votes). + withdraw staged votes). Three scopes: `moderation:read`, `moderation:vote`, + `moderation:purge` (purge needs read; never implied). - Session middleware: `Authorization: Bearer cfpa_…` resolves to an agent principal that satisfies only `agent:` markers; `401 grant_invalid` otherwise; header redacted from logs. -- Moderation: `staff | agent:…` on the three member endpoints; `DELETE …/vote` - for agents; `agent-` evaluator; the agent vote rules (humans final, - `user` only, 30-day member age for `spam`, 20 spam votes / 24h, required - reasoning, flags); agent-caller redaction (`email` null, `emailDomain`, - domain-only `q`); `stagedVote`, `emailDomain`, `vote=staged`, `kind` on - detail votes. +- **Votes** (the "looks like spam" case): `staff | agent:…` on the member + endpoints; `DELETE …/vote` for agents; `agent-` evaluator; the agent + vote rules (humans final, `user` only, 30-day member age for `spam`, 20 spam + votes / 24h, required reasoning, flags); agent-caller redaction (`email` + null, `emailDomain`, domain-only `q`); `stagedVote`, `emailDomain`, + `vote=staged`, `kind` on detail votes. +- **Agent purge** (the "posted spam" case): `POST /api/admin/members/:slug/purge` + with `reason` (no URL), `flags`, 1–5 `evidence` items the API verifies, and + `dryRun`; the eight agent purge rules (signed-up `user` only, joined ≤ 7 + days, no human vote, no standing `legit` vote, no Slack sign-in, no shared + project, evidence goes with the purge, 5 / 24h); one commit per purge with + `Purge-Id` and `Evidence` trailers; the agent's vote record written as the + "why". +- **`spam-purges` sheet** (contract `codeforphilly.org/spam-purges/v1`): + written by agent purges, by the scheduled sweep, and by a staff purge marked + as spam (`{ spam: true, reason }` on `POST /api/people/:slug/purge`; a + checkbox in the Danger Zone dialog). `GET /api/admin/spam-purges` and + `POST /api/admin/spam-purges/:id/unblock`. +- **Re-registration block** (case 1): the GitHub callback refuses + `signup_blocked` for a GitHub id an active `spam-purges` record blocks, and + link-GitHub refuses `github_blocked`; `/login` shows the message. +- **Votes belong to one account** (a fix): votes with `evaluatedAt` earlier + than the person's `createdAt` are an earlier account's and count for nothing + — in the roster fields, deciding votes, the reversal rule, agent rules, the + sweep and the offline prune. New `slug-purged-before` signal; detail + `earlierVotes` / `earlierPurges`. - Lifecycle: "deciding votes" (humans if any, else agents with an active grant) in the reversal rule, `spamPurgeDueAt` and the sweep; agent accounts - excluded from the sweep and from votes; `Agent-Staged` trailer and - `(staged by …)` lines on the sweep commit. + excluded from the sweep, votes and purges; `Agent-Staged` trailer and + `(staged by …)` lines on the sweep commit; sweep writes `spam-purges`. - Commit metadata: `Actor-Kind: agent` + `Grant-Id` trailers. - `account-level` refuses to raise an agent account. -- `prune-spam`: `agent-*` `legit` protects; `agent-*` `spam` ignored. +- `prune-spam`: `agent-*` `legit` protects; `agent-*` `spam` ignored; votes + older than the person ignored. - `/admin/members`: staged badge with reasoning and flags, buttons kept on staged rows, "staged by an agent" filter, "Hidden by {agent} (AI)", history - labels, the Agents panel (show staged, withdraw, mint/revoke/new agent). + labels, `Slug purged before` badge and Earlier account block, the recent + agent purges banner, the Agents panel (show staged, purge counts, withdraw, + mint/revoke/new agent, second confirm for a purge grant) and its purge log + (reason, evidence links, commit link, unblock). +- `docs/operations/runbook.md`: "Agents" (create, mint, rotate, revoke, + withdraw) and "Restore a purged member". **Out:** - The bot-side command and loop policy — the `grittybot` repo, described below, its own plan there after this ships. -- Creating the production agent account and minting its grant — an operator - step after deploy (Rollout below), not code. +- Creating the production agent account and minting its grants — operator + steps after deploy (Rollout below), not code. +- A staff "Restore" button. Restores follow the runbook (git revert, unblock, + Not spam); a `POST /api/admin/spam-purges/:id/restore` is a follow-up if + restores become routine. - A public "AI agent" chip on `/members/` — follow-up; the agent's bio says it for now. +- Email or Slack notification from the site to staff on an agent purge — the + bot posts each one to its operators' channel, and the roster banner shows + them for seven days. - Clearing a human vote; bulk staff actions; scopes beyond moderation. +- Recording who added a project membership (`addedBySlug`): would let the API + tell a maintainer-added member from a self-joiner; the bot reads it from the + commit author for now. - The existing gap that a spam-hidden member can self-reactivate from `/account` and so leave the purge queue — true of staff votes today; worth its own spec decision, not this plan's. +- The existing gap that the purge cascade leaves help-wanted roles a purged + person posted on a project the purge keeps. Agent purges cannot reach it + (posting a role needs a maintainer, and maintaining a shared project blocks + the purge); a staff purge can. +- The private store's missing versioning in production (see Risks): the + `private-storage.md` claim that production buckets are versioned does not + hold for the filesystem PVC in use. Not this plan's to fix; it limits what a + restore recovers. ## Implements -- [api/agents.md](../specs/api/agents.md) — every endpoint; the agent account - rules (no sign-in identity, never staff, never voted on or swept); grant - token format, hashing, expiry, revocation, `lastUsedAt`. -- [api/moderation.md](../specs/api/moderation.md) — auth column, `vote=staged`, - `emailDomain`, `stagedVote`, `purgeAfter` on deciding votes, `kind` on - detail votes, agent request fields, `DELETE …/vote`, Agent callers, Agent - votes (rules 1–5), new errors. -- [api/people.md](../specs/api/people.md) — `agent_account` refusal on - account-level. +- [api/agents.md](../specs/api/agents.md) — every endpoint (list now staff); + the agent account rules (no sign-in identity, never staff, never voted on, + purged or swept); the three scopes and purge-needs-read; token format, + hashing, expiry, revocation, `lastUsedAt`; `purges` counts on the list. +- [api/moderation.md](../specs/api/moderation.md) — auth column; Votes belong + to one account; `slug-purged-before`; `vote=staged`, `emailDomain`, + `stagedVote`, `purgeAfter` on deciding votes; `kind`, `earlierVotes`, + `earlierPurges` on detail; agent vote fields, `DELETE …/vote`; Agent + callers; Agent votes (rules 1–5); `POST …/purge` and Agent purges (rules + 1–8, effects, commit); `GET /api/admin/spam-purges`; `POST …/unblock`. +- [api/people.md](../specs/api/people.md) — purge body `{ spam, reason }`; + `agent_account` refusal on account-level. +- [api/auth.md](../specs/api/auth.md) — callback "Purged as spam" check, + `signup_blocked`; link-GitHub `github_blocked`. - [behaviors/authorization.md](../specs/behaviors/authorization.md) — - `agent:` marker; Agent grants. + `agent:` marker; Agent grants; purge is its own scope. - [behaviors/person-lifecycle.md](../specs/behaviors/person-lifecycle.md) — - deciding votes, reversal rule, staged purge, sweep commit shape. + deciding votes (this account only), reversal rule, staged purge, Agent + purge, spam or not on staff purge, sweep writes `spam-purges`, Restoring a + purged member, authorization summary. - [behaviors/spam-exclusion.md](../specs/behaviors/spam-exclusion.md) — - evaluator kinds; agent votes never prune. -- [behaviors/storage.md](../specs/behaviors/storage.md) — agent commit trailers. + evaluator kinds; agent votes never prune; agent purge records; earlier + account's votes ignored. +- [behaviors/storage.md](../specs/behaviors/storage.md) — agent commit + trailers, agent purge trailers. - [behaviors/private-storage.md](../specs/behaviors/private-storage.md) — `agent-grant-secrets.jsonl`. -- [data-model.md](../specs/data-model.md) — `AgentGrant`; `PersonEvaluation` - pointer. +- [data-model.md](../specs/data-model.md) — `AgentGrant` (three scopes); + `PersonEvaluation` account rule; `SpamPurge`. - [screens/admin-members.md](../specs/screens/admin-members.md) — staged rows, - filter, history labels, Agents panel. + filter, history labels, `Slug purged before`, Earlier account, recent agent + purges banner, Agents panel, purge log. +- [screens/login.md](../specs/screens/login.md) — `signup_blocked` message. ## Approach -1. **Schema + sheet.** `AgentGrantSchema` in `packages/shared` (Zod, with - `references('people.slug')` on `personSlug`), `AGENT_EVALUATOR_PREFIX`, - `agentEvaluatorFor`, `isAgentEvaluator`, and an `evaluatorKind()` that - returns `human | agent | machine`. Generate the `agent-grants/v1` contract - the way `sheet-contracts` does; add `apps/api/gitsheets/agent-grants.toml`. - Data repo: prepare the sheet config on `empty`, `fixture`, - `legacy-import`, `published` before the API deploy that reads it (as +1. **Votes belong to one account** (a fix that stands alone; land first if + split). One helper, `votesOnAccount(state, person)`, filtering + `evaluationsByPerson` to `evaluatedAt >= person.createdAt`; use it in + `spamPurgeDueAt`, `latestHumanVerdict`, the roster's vote fields and the + vote filter. `prune-spam` applies the same filter. Tests: a re-registered + slug with an old staff spam vote shows no `latestVote`, is not queued when + deactivated, and lists the vote under `earlierVotes`. +2. **Schema + sheets.** `AgentGrantSchema` and `SpamPurgeSchema` in + `packages/shared` (Zod; `references('people.slug')` on + `AgentGrant.personSlug`, **no** reference on `SpamPurge.personSlug`), + `AGENT_EVALUATOR_PREFIX`, `agentEvaluatorFor`, `isAgentEvaluator`, and an + `evaluatorKind()` that returns `human | agent | machine`. Generate the + `agent-grants/v1` and `spam-purges/v1` contracts the way `sheet-contracts` + does; add `apps/api/gitsheets/agent-grants.toml` and `spam-purges.toml`. + Data repo: prepare both sheet configs on `empty`, `fixture`, + `legacy-import`, `published` before the API deploy that reads them (as `person-evaluations-contract` did). -2. **State.** Load `agent-grants` into in-memory state (`agentGrants`, - `agentGrantsByPerson`) in the loader, incremental reload and - `StateApply`; add it to the slug-rename reference cascade and the purge - cascade (a purged agent loses its grants and secrets). Private store: +3. **State.** Load `agent-grants` (`agentGrants`, `agentGrantsByPerson`) and + `spam-purges` (`spamPurges`, `spamPurgesByGithubUserId`, `spamPurgesBySlug`) + in the loader, incremental reload and `StateApply`; add `agent-grants` to + the slug-rename reference cascade (not `spam-purges`) and the purge cascade + (a purged agent loses its grants and secrets). Private store: `agent-grant-secrets.jsonl` with `getGrantSecretByHash`, `putGrantSecret`, `deleteGrantSecretsFor(personId)`; build a `Map` at boot. -3. **Auth.** In `auth/middleware.ts`, before the cookie: a `Bearer cfpa_` +4. **Auth.** In `auth/middleware.ts`, before the cookie: a `Bearer cfpa_` header → hash → grant → checks → `SessionContext` with `person` = agent, `accountLevel: 'user'`, and `agent: { grantId, scopes }`. Grant + cookie together → 401. `requireAuth` / `requireAuthMarker` gain `agent:`; every existing marker treats a session with `agent` set as anonymous (one guard at the top, so no route can forget). Redact `authorization` in the Fastify logger config. Throttle `lastUsedAt` writes - to one an hour. -4. **Moderation service.** `decidingVotes(state, slug, now)` used by - `castVote`'s reversal check, `spamPurgeDueAt`, `stagedVote` and the vote - filter, so there is one definition. `castVote` takes the caller kind; the - agent path runs rules 1–4 inside the mutex (rate count from the agent's - own records), writes `agent-` with `["agent", …flags]` and required - reasoning. `withdrawVote` for `DELETE`. Agent read redaction in `#row` and - the detail route. `spamPurgeDueAt` needs grant state (active grant per - agent) — pass it in with `state`. -5. **Commit meta.** `buildTransactionOptions` adds `Actor-Kind: agent` and - `Grant-Id` when `session.agent` is set. Sweep message lists + to one an hour. In `auth/github-oauth.ts`, `completeCallback` checks + `spamPurgesByGithubUserId` after `resolveIdentity` returns anything but + `existing` → `signup_blocked`; `completeLinkCallback` → `github_blocked`. +5. **Moderation service: votes.** `decidingVotes(state, slug, now)` (on top + of step 1) used by `castVote`'s reversal check, `spamPurgeDueAt`, + `stagedVote` and the vote filter, so there is one definition. `castVote` + takes the caller kind; the agent path runs rules 1–4 inside the mutex + (rate count from the agent's own records), writes `agent-` with + `["agent", …flags]` and required reasoning. `withdrawVote` for `DELETE` + (409 once a human has voted). Agent read redaction in `#row` and the + detail route. `spamPurgeDueAt` needs grant state (active grant per agent) — + pass it in with `state`. `slug-purged-before` signal from + `spamPurgesBySlug`. +6. **Moderation service: agent purge.** `agentPurge(tx, session, slug, body)` + in `services/moderation.ts`: validate body (reason without URL — a plain + `https?://` or `www.` test plus bare-domain-with-path), resolve each + evidence item to its record and data-repo path, check ownership and + non-empty field; then rules 1–8 inside the mutex. Reuse `purgePeople` from + `services/person-purge.ts` for the cascade and a dry-run variant of + `purgePreview` for rule 7 ("goes with the purge") and the `deleted` + counts. Write the agent's evaluation and the `spam-purges` record in the + same transaction; `beforeCommit` is the data repo HEAD read under the lock + (verify gitsheets exposes it — see Risks). Route in `routes/moderation.ts`. +7. **Staff purge + sweep.** `POST /api/people/:slug/purge` accepts + `{ spam, reason }` and writes a `spam-purges` record when `spam`; the + sweep writes one per purged person (`by = "scheduled"`, reason naming the + deciding voters). +8. **Spam-purges routes.** `GET /api/admin/spam-purges` (filters, `url`s + built from the configured data repo GitHub URL + `beforeCommit` + path, + `commitUrl` to the record path's history) and `POST …/unblock`. +9. **Commit meta.** `buildTransactionOptions` adds `Actor-Kind: agent` and + `Grant-Id` when `session.agent` is set, plus `Purge-Id` and `Evidence` + trailers for an agent purge, reason as body. Sweep message lists `(staged by agent-)` and the `Agent-Staged` trailer. -6. **Agents routes.** `routes/agents.ts` + `services/agents.ts` per - `api/agents.md`; create-agent writes person + empty private profile + - grant + secret in one dual-store transaction; `withdraw-votes` deletes and - reactivates in one commit. -7. **people.account-level** refuses agent accounts. -8. **prune-spam** `foldEvaluation`: `agent-*` legit → `hasAnyLegit`; agent spam - skipped; test both. -9. **Web.** `AdminMembers.tsx`: staged badge, reasoning line, flag chips, - buttons on staged rows, filter option, history labels, Agents panel with - the once-only token dialog. -10. **Docs.** `docs/operations/` — a short "Agents" runbook: create the agent, - mint, hand the token to its host, rotate, revoke, withdraw. - -The work fits one PR; if review prefers smaller pieces, split at 1–5 (API, -no UI: agents can vote, the existing roster shows staged spam as an -unlabeled hide) — **don't ship that half alone to production**, since the -review window is only real once step 9 lands. +10. **Agents routes.** `routes/agents.ts` + `services/agents.ts` per + `api/agents.md`; create-agent writes person + empty private profile + + grant + secret in one dual-store transaction; `withdraw-votes` deletes + and reactivates in one commit, skipping purged slugs; mint refuses + `moderation:purge` without `moderation:read`. +11. **people.account-level** refuses agent accounts. +12. **prune-spam** `foldEvaluation`: `agent-*` legit → `hasAnyLegit`; agent + spam skipped; earlier-account votes skipped; test all three. +13. **Web.** `AdminMembers.tsx`: staged badge, reasoning line, flag chips, + buttons on staged rows, filter option, history labels, `Slug purged + before` badge and Earlier account block, recent agent purges banner, + Agents panel with the once-only token dialog and the purge-grant second + confirm, purge log with Unblock. Danger Zone purge dialog: the "This was + spam" checkbox. `/login`: `signup_blocked` message. +14. **Docs.** `docs/operations/runbook.md` — "Agents" (create the agent, mint, + hand the token to its host, rotate, add or drop the purge scope, revoke, + withdraw) and "Restore a purged member" (the steps in + `person-lifecycle.md`). + +The work fits one PR but splits cleanly: **(a)** step 1 alone (a bug fix +today); **(b)** steps 2–5, 9–12 and the vote half of 13 (agents can vote); +**(c)** steps 6–8 and the rest of 13–14 (agent purge, the purge log, the +sign-up block). Don't ship (b) to production without its UI — the review +window is only real once staff can see staged votes — and don't mint a purge +grant before (c)'s purge log is live. ## Validation @@ -151,108 +276,265 @@ review window is only real once step 9 lands. - [ ] Auth tests: a valid grant reaches `GET /api/admin/members` and `POST …/vote`; the same token is refused (401) on `user`-marked routes (e.g. `PATCH /api/people/:slug`), `POST /api/auth/refresh` mints nothing - from it, and public reads see it as anonymous; revoked, expired, deactivated-agent and grant+cookie all - 401 `grant_invalid`; a grant without `moderation:vote` gets 404 on vote. + from it, and public reads see it as anonymous; revoked, expired, + deactivated-agent and grant+cookie all 401 `grant_invalid`; a grant + without `moderation:vote` gets 404 on vote; without `moderation:purge`, 404 + on purge; minting purge without read → 422. - [ ] The token never appears in the commit, the private store (only its hash), or a captured request log line. - [ ] Vote rules: agent vote on a human-voted member → 409 `human_verdict_exists`; on staff/agent/self → 409/422; spam on a member older than 30 days → 409 `member_too_old`; 21st spam vote in 24h → 429 - with `Retry-After`; missing reasoning → 422. + with `Retry-After`; missing reasoning → 422; `DELETE` after a human vote → + 409. - [ ] Side effects: agent spam hides; agent legit / `DELETE` un-hides only when the agent's spam hid them; a staff `legit` on a staged member un-hides and stops the purge; a staff `spam` confirms and moves `purgeAfter` to its own date. +- [ ] **Case 1 fixture**: a member created 2 minutes ago with a business name + and a bio with a link; agent purge citing `person#bio` → 200, one commit, + person and avatar gone, `spam-purges` record with `githubUserId`, + `blocksSignIn`; the reason's URL check rejects a reason containing the + link. A GitHub callback for the same `gh.id` → redirect + `/login?error=signup_blocked`, nothing written. After `unblock`, the + callback creates a fresh person; the roster shows `slug-purged-before`, no + `latestVote`, and the old votes under `earlierVotes`. The same flow with a + staff Danger Zone purge (`spam: true`) blocks the same way; with + `spam: false`, no record and no block. +- [ ] **Case 2 fixture**: a 1-minute-old member who self-joined another + person's project (not maintainer) and set a promotional bio → agent purge + 200; their membership is gone; the project, its other members and their + records are unchanged. +- [ ] Agent purge refusals: member 8 days old → `member_too_old`; any human + vote (spam or legit) → `human_verdict_exists`; an agent `legit` → + `legit_vote_exists`; `lastSlackSsoAt` set → `established_member`; created a + project someone else joined, or maintains a project with another member → + `shared_project`; evidence on a record not theirs or empty → + `evidence_invalid`; evidence on a project the purge keeps → + `evidence_survives`; imported member → `protected_account`; 6th purge in + 24h → 429. Each refusal writes nothing. `dryRun: true` passes or fails the + same and writes nothing. +- [ ] Agent purge commit: author the agent's pseudonymous identity; subject + `: POST /api/admin/members//purge`; body the reason; trailers + `Action: person.purge`, `Actor-Kind: agent`, `Grant-Id`, `Purge-Id`, one + `Evidence:` per item; `git show ^:` shows the spam. - [ ] Sweep: an agent-only spam member ≥ 3 days with an active grant is - purged, listed `(staged by agent-)`, `Agent-Staged: 1`; with the - grant revoked, not queued and `stagedVote.counts = false`; agent accounts - never queued. + purged, listed `(staged by agent-)`, `Agent-Staged: 1`, with a + `spam-purges` record `by = "scheduled"`; with the grant revoked, not queued + and `stagedVote.counts = false`; agent accounts never queued; a + re-registered slug with only an earlier account's spam vote is never queued. - [ ] Commit for an agent vote: author is the agent's pseudonymous identity, subject `: POST /api/admin/members//vote`, trailers include `Actor-Kind: agent`, `Grant-Id`, `Actor-Account-Level: user`. -- [ ] `withdraw-votes`: one commit; counts right; votes on human-voted - members untouched. +- [ ] `withdraw-votes`: one commit; counts right; votes on human-voted and on + purged members untouched. - [ ] account-level refuses to raise an agent (`409 agent_account`). -- [ ] prune-spam: agent legit protects; agent spam does not prune. +- [ ] prune-spam: agent legit protects; agent spam does not prune; an + earlier account's human spam vote does not prune the new account. - [ ] Contract: `gitsheets contracts verify` clean on every data-repo branch - with the `agent-grants` config; boot fails loudly without it. -- [ ] Browser (devbox): create an agent, mint (token shown once), vote with - `curl` as the agent, see the staged badge with reasoning in the default - roster view, overrule with Not spam, withdraw, revoke. + with the `agent-grants` and `spam-purges` configs; boot fails loudly + without them. +- [ ] Restore runbook rehearsed on the devbox: agent-purge a fixture member, + unblock, `git revert --no-commit` keeping `spam-purges/`, push, hot reload, + vote Not spam, sign in as that GitHub user. +- [ ] Browser (devbox): create an agent, mint (token shown once; purge grant + asks twice), vote and purge with `curl` as the agent, see the staged badge + with reasoning in the default roster view, overrule with Not spam, see the + banner and the purge log with evidence and commit links, unblock, withdraw, + revoke; see `/login?error=signup_blocked`. ## Risks / unknowns -- **A leaked token** can hide up to 20 new members a day until noticed. - Bounded by the age rule (no established member), the daily cap, the - agent's account and reasoning on every row, hiding being reversible, and - three days before anything deletes; recovery is revoke (purges stop at - once) then withdraw (hidden members return), both one click. +- **A wrong purge.** Unlike a vote, it acts before anyone looks. Bounded by + the API rules (new, unestablished, unvoted, nothing shared, evidence the + member authored), the 5-a-day cap, a public log with the reason and the + spam as it stood, the banner, and the bot posting every purge to its + operators. Recovery is the runbook: public records come back exactly; the + **private profile does not** (no versioning or backup of the production + PVC — `docs/operations/deploy.md`), so the person's email, newsletter choice + and Slack link are lost and rebuilt on their next GitHub sign-in. +- **A leaked token.** With read + vote it can hide up to 20 new members a day; + with purge, also delete up to 5 a day of members under 7 days old who + authored something. Recovery: revoke (purges and staged purges stop at + once), withdraw (hidden members return), restore any wrong purges by hand. + The purge scope is why the first grant leaves it out (Rollout). +- **The API cannot judge content.** It checks that the cited record is the + member's and non-empty, not that it is spam. A purge citing an innocent bio + passes the API; the reason, evidence links and operator channel are what + catch it. The narrow population (≤ 7 days, nothing shared, no Slack) keeps + the cost of that small. - **Nobody looks during the three days.** The window is only a safeguard if someone reviews. The bot side reports each vote to the operators' channel and the members nearing purge (below); the roster's default view includes staged rows. -- **An agent arguing with staff.** Rule 1 makes that impossible on the site; - the bot's own policy must still not re-vote someone a human cleared - (it can't — 409 — but it should not retry). +- **An agent arguing with staff.** The rules make that impossible on the site; + the bot's own policy must still not re-vote or purge someone a human + cleared (it can't — 409 — but it should not retry). +- **Blocking a GitHub id is permanent until someone unblocks.** A person whose + account was purged as spam by mistake can't sign up again and is told to + email; staff unblock from the log. +- **`beforeCommit`.** The purge record wants the data repo commit it was + applied on. If gitsheets' transaction does not expose its parent, read + `HEAD` of the data branch under the write lock before transacting; verify + in step 6. - **Data-repo ordering.** The API must not boot against a branch without the - `agent-grants` config; land the data-repo commits first (as - `person-evaluations-contract` did). + `agent-grants` and `spam-purges` configs; land the data-repo commits first + (as `person-evaluations-contract` did). - **`lastUsedAt` writes** to the private store on a polling agent; throttled to one an hour per grant. +## Open questions + +Each with a recommended answer. (The 2026-11-11 question "purge a bot-only +spam vote after three days with no person looking?" is answered by the +policy: yes.) + +1. **Daily caps and age windows.** 20 spam votes / 24h on members under 30 + days; 5 purges / 24h on members under 7 days. *Recommend as written*: both + real cases posted within two minutes of signing up. +2. **Should the agent see full emails?** *No*, the domain only. +3. **Create the agent as a site record, with no GitHub sign-in?** *Yes.* +4. **Should staff voting Spam on a staged row restart the three days?** *Yes*, + the existing rule. +5. **Grant lifetime?** *90 days*, renewed by an administrator. +6. **Is purge a separate scope, and does the first grant carry it?** + *Separate* (`moderation:purge`, never implied). *First grant: read + vote + only.* Mint a read + vote + purge grant (and revoke the old one) once the + purge log is live and the bot's rules change is merged. +7. **Does any human vote block an agent purge, or only "not spam"?** *Any.* A + staff "not spam" must win; a staff "spam" already hid the member and set a + clock the agent should not shorten. +8. **Does a project membership block an agent purge?** *Only a shared one*: + creating a project someone else touched, or maintaining a project with + another member. A plain self-join (case 2) does not block — joining is + self-service and carries no maintainer's judgment; the membership goes and + the project stays. +9. **Does a Slack sign-in block an agent purge?** *Yes* — the person got far + enough to use the community; a human decides. +10. **Is the re-registration block in scope, and where does it live?** *In + scope*, as a public `spam-purges` record checked at the GitHub callback, + with a staff unblock. `githubUserId` is already public on every person + record and in the purged record's history, so a public block list exposes + nothing new and keeps the block auditable; the private store has no + history in production. Not adopted: treating "slug or GitHub id purged + before" as posted-spam evidence on its own — with the block, the same + GitHub id cannot return, and a new GitHub account gets a new slug, so the + rule would only matter after an unblock, which is exactly when it should + not fire. The bot uses `slug-purged-before` as a strong vote signal. +11. **Should the scheduled sweep's purges also block sign-up?** *Yes*: three + days unopposed is a spam verdict. A mistaken one is one Unblock click. +12. **Staff Danger Zone purge: "This was spam" checkbox default?** *Checked + when the person has a spam vote on their current account, else + unchecked*, so an erase-me request never lands on the block list by + default. +13. **Restore: endpoint or runbook?** *Runbook now*; an endpoint if restores + pass about one a month. +14. **Forbid URLs in a purge reason?** *Yes*, so the spam link is never + republished in a permanent public commit; the evidence path points at it. +15. **Should the member's name or avatar count as posted-spam evidence?** + *No.* A business name is the "looks like spam" case (a vote). Avatars are + rarely the spam and are hard to review from a log; revisit if they become + the vector. +16. **Ship the "votes belong to one account" fix with this plan or first?** + *First, as its own small PR* (step 1): it is a bug for staff votes today + (case 1's re-registration showed the earlier account's staff vote as its + own). +17. **Should the site itself notify staff of an agent purge?** *Not yet*: the + bot posts each purge to its operators' channel and the roster banner + shows it for seven days. Revisit if purges go unseen. + +Kept from before, not addressed here: a member hidden by a spam vote can still +sign in and reactivate themselves, which takes them out of the purge queue. +That is true of staff votes today. + ## Bot side (grittybot repo, separate plan after this ships) -A small command, `bin/member-vote`, the bot's only way to vote: +A small command, `bin/member-vote`, the bot's only way to vote or purge: ``` bin/member-vote show # roster row + votes, JSON bin/member-vote spam|not-spam --reason "…" [--flag ]… [--dry-run] bin/member-vote clear # DELETE its own vote +bin/member-vote purge --evidence … --reason "…" [--flag ]… [--dry-run] bin/member-vote staged # its staged votes and purge dates +bin/member-vote purges [--since ] # its purge log ``` +- **`purge`** requires at least one `--evidence` (a data-repo path such as + `people/.toml#bio` or `project-updates//.toml`, or the + site URL of the record, which the command maps to one) and `--reason` (the + command refuses a reason with a URL before calling the API). `--dry-run` + sends `dryRun: true` and prints what would go. - **Credential:** `~/.config/grittybot/cfp-agent-token`, mode 0600, beside the Slack app token, never in the repo, read by the command only; the site base URL in `opsbot.json` (`site.base_url`). The operator mints the grant and places the file; the bot never sees a person's credential and holds no site session. -- **Output:** the API's result plus the purge date, one JSON line appended to - the cycle log; a refusal (409/429) is reported as such, never retried - around. -- **Visibility:** every vote it casts is posted to `#grittybot-hq` in the same - cycle (member slug, verdict, reasons, purge date, roster link); once a day, - a list of its staged votes that purge within 24 hours. That is what makes - the three days a review window in practice. -- **When it votes (loop policy, in the bot's specs):** from the - `30-data-commits` stream's `new-member` and `profile-edit` items, it votes - `spam` only when at least two independent signals agree and none - contradicts, e.g. a business or brand name as the member name; a GitHub - account hours old (`author-account-h-old`) or the roster's - `github-new-account` / `github-no-activity`; a promotional link added to - the bio minutes after sign-up (`bio-added-link`, `has-link`); a throwaway - `emailDomain`; a slug a purge removed before (`slug-purged-before`). It - does **not** vote spam on anyone with a project footprint shared with - other people, anyone who has signed into Slack, or anyone it is talking - to; those it flags to the operators instead. It votes `not-spam` or - `clear` when new evidence contradicts its staged vote (a real project, a - human vouching), at once. -- **Rules change in the bot repo:** the bot's non-negotiables gate deletes - on the operator's gate word. Record the operator's 2026-11-11 direction - that a staged member vote is not a delete (the site's three-day window and - human overrule are the gate) as a reviewed PR to the bot's protocols; a - person merges it. +- **Output:** the API's result plus the purge date or purge record, one JSON + line appended to the cycle log; a refusal (409/422/429) is reported as + such, never retried around. +- **Visibility:** every vote **and every purge** it makes is posted to + `#grittybot-hq` in the same cycle (member slug, verdict or purge, reason, + evidence paths, purge date or commit link, roster or purge-log link); once + a day, a list of its staged votes that purge within 24 hours. +- **Decision rule (loop policy, in the bot's specs), in the operator's + words:** "When someone actually posts spam, purge immediately. For signups + that are obviously spam but haven't posted anything spammy, log a spam vote + to put them in the 3-day purge queue pending doing anything that makes them + not look like spam." From the `30-data-commits` stream's `new-member` and + `profile-edit` items: + - **Purge** when the member has posted spam: a bio (or a project, update, + buzz item or role they authored) that promotes a business, product or + site unrelated to civic tech, typically with a link, and it is that + record the bot cites. If the API refuses (too old, shared project, Slack, + a human vote), it votes `spam` instead when allowed and flags the member + to the operators. + - **Vote `spam`** when nothing spammy is posted but at least two + independent signals agree and none contradicts: a business or site name + as the member name; a GitHub account hours old (`author-account-h-old`) + or the roster's `github-new-account` / `github-no-activity`; a throwaway + `emailDomain`; `slug-purged-before`. + - **Never** vote or purge anyone it is talking to, anyone a maintainer added + to a project (the membership commit's author is not the member), or + anyone with a project footprint shared with other people; those it flags + to the operators. + - **Clear its vote (or vote `not-spam`)** at once when a member it staged + stops looking like spam. What counts: a staff `not-spam` (the site already + overrules; the bot just stops tracking); a maintainer adding them to a + project or making them a maintainer; a real profile — a bio that + describes a person and their interests, with no promotional link; a + non-promotional update or comment on a project with other members; a + Slack sign-in; a reply to the bot or an operator. What does not count on + its own: a self-join to a project, an RSVP, an avatar, or a renamed + account that still reads as a business. `not-spam` when it has positive + evidence; `clear` when the signal is only that the spam case no longer + holds. +- **Rules change in the bot repo:** the bot's own rule card makes every delete + wait for an operator's gate word, given in the operator's own words for + that one action. A staged vote is not a delete (the site's three days and + human overrule are the gate), but a purge is. A standing authorization for + posted-spam purges under this policy is a change to the bot's rules, being + proposed separately as a reviewed PR to the bot's protocols; a person + merges it. Until it merges, the bot votes `spam` on posted-spam accounts + (which hides the spam at once) and asks the operator, even if its grant + carries the purge scope. ## Rollout -1. Data repo: `agent-grants` sheet config on every branch. +1. Data repo: `agent-grants` and `spam-purges` sheet configs on every branch. 2. Release + deploy (sandbox first; sandbox shares `published` but does not sweep). 3. An administrator creates the agent account on production with a bio saying it is an AI run by Code for Philly, mints a `moderation:read, moderation:vote` grant (90 days), and places the token on the bot's host. -4. The bot ships `bin/member-vote` and its policy; first week, it posts every - vote and the operator reviews the lot. +4. The bot ships `bin/member-vote` (vote verbs) and its policy; first week, it + posts every vote and the operator reviews the lot. +5. Once the purge log is live in production and the bot's rules change is + merged, an administrator mints a + `moderation:read, moderation:vote, moderation:purge` grant (90 days), + revokes the read + vote one, and replaces the token file. The bot turns on + the `purge` verb; first week, the operator reviews every purge in the log. ## Notes diff --git a/specs/api/agents.md b/specs/api/agents.md index 11a329c..f283c3c 100644 --- a/specs/api/agents.md +++ b/specs/api/agents.md @@ -1,20 +1,31 @@ # Agents API An **agent** is an automated moderator: a program, not a person, that staff -let cast `spam` / `not spam` votes on the [admin members -roster](../screens/admin-members.md). It has its own site account and reaches -the API with a **grant**: a bearer token an administrator mints for it, scoped -to moderation and nothing else. Its votes are labeled as an agent's in the data -and on the roster, they **stage** (see -[person-lifecycle.md → Scheduled spam purge](../behaviors/person-lifecycle.md#scheduled-spam-purge)), -and any staff vote overrules them. +let act on the [admin members roster](../screens/admin-members.md). It has its +own site account and reaches the API with a **grant**: a bearer token an +administrator mints for it, scoped to moderation and nothing else. It can do +two things, each under its own scope: + +- **Vote** `spam` / `not spam` on a member who only *looks* like spam. Its + votes are labeled as an agent's in the data and on the roster, they **stage** + (see [person-lifecycle.md → Scheduled spam purge](../behaviors/person-lifecycle.md#scheduled-spam-purge)), + and any staff vote overrules them. +- **Purge** a new member who has *posted* spam, at once, naming the record + that is the spam and why + ([moderation.md → Agent purges](moderation.md#agent-purges)). The site checks + the evidence and a set of preconditions; it does not judge the content. + +Those are the operator's two cases: "When someone actually posts spam, purge +immediately. For signups that are obviously spam but haven't posted anything +spammy, log a spam vote to put them in the 3-day purge queue pending doing +anything that makes them not look like spam." The first agent is Code for Philly's ops bot. The site does not care what runs behind a grant; it cares that the grant is narrow, attributable and revocable. | Method | Path | Auth | Purpose | | ------ | ---- | ---- | ------- | -| `GET` | `/api/admin/agents` | administrator | Every agent account and its grants. | +| `GET` | `/api/admin/agents` | staff | Every agent account, its grants, and its recent purges. | | `POST` | `/api/admin/agents` | administrator | Create an agent account (and optionally its first grant). | | `POST` | `/api/admin/agents/:slug/grants` | administrator | Mint a grant; the token is returned once. | | `POST` | `/api/admin/agents/:slug/grants/:id/revoke` | administrator | Revoke a grant at once. | @@ -41,7 +52,7 @@ by GitHub sign-in: any grant on record. Agents are never staff, so an agent's vote and a human's vote can never come from the same account. - Moderation treats it as off limits: nobody votes on it - (`422`, `slug: agent`), and the [scheduled spam + (`422`, `slug: agent`), no agent purges it, and the [scheduled spam purge](../behaviors/person-lifecycle.md#scheduled-spam-purge) never takes it. To stop an agent, revoke its grants. @@ -54,10 +65,19 @@ and a hard expiry. | ----- | ------ | | `moderation:read` | `GET /api/admin/members` and `GET /api/admin/members/:slug`, with the redactions in [moderation.md → Agent callers](moderation.md#agent-callers). | | `moderation:vote` | `POST /api/admin/members/:slug/vote` and `DELETE /api/admin/members/:slug/vote`, within the [agent vote rules](moderation.md#agent-votes). | +| `moderation:purge` | `POST /api/admin/members/:slug/purge`, within the [agent purge rules](moderation.md#agent-purges). | There are no other scopes. A grant does not make its holder `user`, `self` or `staff` anywhere else ([authorization.md → Agent grants](../behaviors/authorization.md#agent-grants)). +`moderation:purge` is its own scope, not implied by `moderation:vote`, so an +administrator can let an agent stage votes without letting it delete anything. +A grant with `moderation:purge` must also carry `moderation:read` (`422` +otherwise): an agent that cannot read the roster cannot check what it is about +to delete. Scopes are fixed for a grant's life; adding purge to an agent is a +new grant and a revoke of the old one, so the change is its own public +`agent.grant` commit. + - **Token:** `cfpa_` followed by 32 random bytes, base64url. Shown once, in the mint response. The site keeps only its SHA-256 hash, in the private store ([private-storage.md](../behaviors/private-storage.md)); the public @@ -70,6 +90,8 @@ There are no other scopes. A grant does not make its holder `user`, `self` or - **Revocation** is immediate: the in-memory grant table is updated in the same request. Revoking also stops the agent's staged votes from counting toward a purge ([person-lifecycle.md](../behaviors/person-lifecycle.md#scheduled-spam-purge)). + It does not undo purges already made; those are restored one by one + ([person-lifecycle.md → Restoring a purged member](../behaviors/person-lifecycle.md#restoring-a-purged-member)). - **Last used:** the API records `lastUsedAt` on the private record, at most once an hour per grant. @@ -86,13 +108,21 @@ There are no other scopes. A grant does not make its holder `user`, `self` or "createdAt": "…", "createdBy": { "slug": "jane", "fullName": "Jane Doe" }, "expiresAt": "…", "revokedAt": null, "lastUsedAt": "…", "active": true } ], - "stagedVotes": { "spam": 3, "legit": 1 } + "stagedVotes": { "spam": 3, "legit": 1 }, + "purges": { "last24h": 1, "last30d": 4 } } ] } ``` `stagedVotes` counts the agent's votes on people no human has voted on. +`purges` counts the agent's `spam-purges` records; the records themselves, with +reason and evidence, are `GET /api/admin/spam-purges?by=agent-` +([moderation.md](moderation.md#get-apiadminspam-purges)). + +Staff, not only administrators, may list agents: the panel that shows what an +agent staged and purged is a staff review surface, and nothing here is secret +(grants are public records; tokens are never returned after mint). ## POST /api/admin/agents @@ -100,7 +130,7 @@ There are no other scopes. A grant does not make its holder `user`, `self` or { "slug": "example-bot", "fullName": "Example Bot", - "bio": "An AI ops bot run by … Its votes on /admin/members are staged; staff overrule them.", + "bio": "An AI ops bot run by … Its spam votes on /admin/members are staged and staff overrule them; it purges only accounts that posted spam, and says why.", "grant": { "label": "ops bot, host vm-1", "scopes": ["moderation:read", "moderation:vote"], "expiresInDays": 90 } } ``` @@ -135,10 +165,16 @@ anyone who can overrule a vote one at a time can overrule them all. `since`) on a person with **no human vote**, and reactivates each such person whose deactivation that agent's vote caused (the [reversal rule](../behaviors/person-lifecycle.md#deactivate-soft-self-service)). Votes on -people a human has since voted on are left as the record. One commit. +people a human has since voted on are left as the record, and so are votes on +people who no longer exist (purged: their votes are the record of why). One +commit. Response `200`: `{ "withdrawn": 7, "reactivated": 5 }`. Withdrawing does not -revoke the grant; do both when the token itself is in doubt. +revoke the grant; do both when the token itself is in doubt. Withdrawing does +not touch the agent's purges: there is no bulk undo for a delete. Each is +restored on its own +([person-lifecycle.md → Restoring a purged member](../behaviors/person-lifecycle.md#restoring-a-purged-member)), +and the purge log names every one ([moderation.md](moderation.md#get-apiadminspam-purges)). ## Commits @@ -147,11 +183,13 @@ calling administrator or staff member, with `Action: agent.create`, `agent.grant`, `agent.revoke` or `agent.withdraw-votes`, `Subject-Type: person`, `Subject-Slug: `, and `Grant-Id: ` where one grant is concerned. Agent grants are a change of access, so they are in the public audit -log the same way an account-level change is. +log the same way an account-level change is. (An agent's own votes and purges +are committed as the agent: [moderation.md](moderation.md#agent-votes).) ## Errors -- `422 validation_failed` — unknown scope, `expiresInDays` out of range, - missing `bio`, bad or taken slug -- `404 not_found` — not an administrator (staff for `withdraw-votes`), or no - such agent or grant +- `422 validation_failed` — unknown scope, `moderation:purge` without + `moderation:read`, `expiresInDays` out of range, missing `bio`, bad or taken + slug +- `404 not_found` — not an administrator (staff for `GET` and + `withdraw-votes`), or no such agent or grant diff --git a/specs/api/auth.md b/specs/api/auth.md index b97cb01..cb69e3b 100644 --- a/specs/api/auth.md +++ b/specs/api/auth.md @@ -85,6 +85,10 @@ Handles the OAuth callback after the user authorizes (or denies) on GitHub. - **b) New Person needed, no legacy match.** Create a fresh `Person` + `PrivateProfile`, link the GitHub identity, issue session, redirect. - **c) Legacy candidate(s) found.** Issue a short-lived **claim-pending JWT** (5 minutes, scope `claim`) and redirect to `/account-claim?candidates=...`. The user confirms or declines, finalizing identity via [api/account-claim.md](account-claim.md). + **Purged as spam.** Before (b) or (c): if no live Person holds `gh.id` and a [`spam-purges`](../data-model.md#spampurge) record with `githubUserId = gh.id` has `blocksSignIn = true` and no `unblockedAt`, nothing is written and the user is redirected to `/login?error=signup_blocked`. Case (a) is never blocked: a live Person with that id is a member staff restored ([person-lifecycle.md → Restoring a purged member](../behaviors/person-lifecycle.md#restoring-a-purged-member)). The check is an in-memory index lookup (`spamPurgesByGithubUserId`), so it adds no latency. + + Without this, a purged account signs straight back in: the purge deleted the Person, so step 6 finds no `githubUserId` match, no email match (the private profile went with it) and no username match (no Person holds the slug any more), and creates a fresh Person — whose slug, derived from the GitHub login, is the one the purge freed. + ### Response In every successful case the user is redirected to either `return` (validated same-origin) or `/account-claim`. The redirect carries `Set-Cookie` headers for the session JWTs (cases a, b) or for the claim-pending JWT (case c). @@ -95,6 +99,7 @@ In every successful case the user is redirected to either `return` (validated sa - `401 unauthenticated` with code `oauth_session_invalid` — signed-session cookie tampered/expired - `502 bad_gateway` with code `github_unreachable` — GitHub API call failed; user redirected to `/login?error=github_unreachable` - `403 forbidden` with code `email_unverified` — GitHub returned no verified email (user has email visibility off AND no verified primary); user redirected to `/login?error=email_unverified` with a help message about GitHub email visibility +- `403 forbidden` with code `signup_blocked` — this GitHub account was purged as spam and staff have not unblocked it; user redirected to `/login?error=signup_blocked` ## POST /api/auth/login @@ -201,7 +206,7 @@ Empty body. The flow is purely redirect-driven. ### Behavior 1. The route sets a short-lived signed cookie (`cfp_oauth_session`) with `mode = 'link'` and the current `personId`, then redirects to GitHub OAuth (same `?return=...` mechanics as `/api/auth/github/start`). -2. The callback verifies the OAuth result. If `Person.githubUserId` is already set on the linking Person: `409 github_already_linked`. If the GitHub `id` is bound to a *different* Person: `409 github_id_in_use_elsewhere` (resolved by admin merge, not self-service). +2. The callback verifies the OAuth result. If `Person.githubUserId` is already set on the linking Person: `409 github_already_linked`. If the GitHub `id` is bound to a *different* Person: `409 github_id_in_use_elsewhere` (resolved by admin merge, not self-service). If a `spam-purges` record blocks the GitHub `id` (as in the callback's "Purged as spam" check): `403 github_blocked`. 3. Otherwise: set `Person.githubUserId = gh.id`, `Person.githubLogin = gh.login`, `Person.githubLinkedAt = now`. Refresh `PrivateProfile.email` to the GitHub primary verified email **only if the user consents** at the link-confirmation screen (toggle defaults to "keep current email"). 4. Redirect to `?return` or `/account`. @@ -213,6 +218,7 @@ Empty body. The flow is purely redirect-driven. - `github_already_linked` — caller already has a GitHub link - `github_id_in_use_elsewhere` — another Person already owns this `gh.id` +- `github_blocked` — this GitHub account was purged as spam and is blocked - `oauth_state_mismatch`, `oauth_session_invalid`, `github_unreachable` — same as `/api/auth/github/start` ## GET /api/auth/me diff --git a/specs/api/moderation.md b/specs/api/moderation.md index a2fcf83..0e04772 100644 --- a/specs/api/moderation.md +++ b/specs/api/moderation.md @@ -4,8 +4,12 @@ Staff-facing endpoints behind the [admin members screen](../screens/admin-member a newest-first roster of members with their footprint on the site, and a way to record a **human spam verdict** that the offline spam pipeline treats as final (see [behaviors/spam-exclusion.md](../behaviors/spam-exclusion.md)). An -[agent](agents.md) holding a grant may read the roster and cast **staged** -votes that any human vote overrules ([Agent votes](#agent-votes)). +[agent](agents.md) holding a grant may read the roster, cast **staged** votes +that any human vote overrules ([Agent votes](#agent-votes)) on members who only +look like spam, and **purge** a new member who has posted spam +([Agent purges](#agent-purges)). Every spam purge, by anyone, leaves a public +[`spam-purges`](../data-model.md#spampurge) record that stops the same GitHub +account signing up again until staff unblock it. All endpoints require `accountLevel ∈ {staff, administrator}`, or an agent grant with the scope named below (`agent:` in @@ -19,6 +23,31 @@ is not a signal. | `GET` | `/api/admin/members/:slug` | `staff \| agent:moderation:read` | One member's full footprint plus every vote on record. | | `POST` | `/api/admin/members/:slug/vote` | `staff \| agent:moderation:vote` | Record the caller's verdict; applies the side effect described below. | | `DELETE` | `/api/admin/members/:slug/vote` | `agent:moderation:vote` | Withdraw the calling agent's own vote. | +| `POST` | `/api/admin/members/:slug/purge` | `agent:moderation:purge` | Purge a new member who posted spam, citing the evidence. | +| `GET` | `/api/admin/spam-purges` | `staff \| agent:moderation:read` | The spam-purge log, newest first. | +| `POST` | `/api/admin/spam-purges/:id/unblock` | `staff` | Let the purged GitHub account sign up again. | + +Staff purge a member from the person's Danger Zone +([api/people.md → purge](people.md#post-apipeopleslugpurge), administrators +only); this file holds only the agent's purge. + +## Votes belong to one account + +`person-evaluations` records are keyed by slug, and a purge leaves them in +place as the record of why. A later sign-up can get the same slug (a GitHub +sign-up takes its slug from the GitHub login, and the purge freed it). A vote +whose `evaluatedAt` is **earlier than the person's `createdAt`** was cast on +that earlier account, and every rule in this file and in +[person-lifecycle.md](../behaviors/person-lifecycle.md) ignores it: it is not +the `latestVote` or `stagedVote`, it does not decide, it does not set +`purgeAfter`, it does not trip `human_verdict_exists`, and the reversal rule +does not see it. The detail response lists such votes apart, as +`earlierVotes`, and the row carries the `slug-purged-before` signal. + +(Before this rule the earlier account's votes attached to the new one: a +re-registration showed the old staff vote as its own, an agent could not act +on it, and a self-deactivation would have put it straight into the sweep on a +vote nobody had cast on it.) ## GET /api/admin/members @@ -83,12 +112,15 @@ Field notes: `bio-links:`, `email-name-mismatch` (email local-part shares no ≥3-letter token with the display name), `email-bounced:`, `github-gone`, `github-new-account` (< 30 days), `github-no-activity` (0 repos and 0 - followers). Informational: `slack-sso`, `has-footprint`. + followers), `slug-purged-before` (a [`spam-purges`](../data-model.md#spampurge) + record names this slug: an earlier account under it was purged as spam). + Informational: `slack-sso`, `has-footprint`. `purgeAfter` is when the [scheduled spam purge](../behaviors/person-lifecycle.md#scheduled-spam-purge) will take the member — three days after the newest **deciding** vote, when every deciding vote is `spam` and the member is still deactivated and not staff — or `null`. The deciding votes are the human votes; when there are none, the votes of agents that still hold an active grant. None of it is reachable by a non-staff caller (who gets 404 anyway). `latestVote` is `null` -when no human has voted; it never holds an agent's vote. +when no human has voted; it never holds an agent's vote. Every vote field here +counts only votes on this account ([Votes belong to one account](#votes-belong-to-one-account)). `stagedVote` is the newest agent vote when **no human has voted** on the member, else `null`: @@ -129,14 +161,20 @@ The row above, plus the full footprint and vote history: "votes": [ { "kind": "human", "verdict": "spam", "reasoning": "…", "flags": ["manual-override"], "voter": { "slug", "fullName" }, "evaluatedAt": "…" }, { "kind": "agent", "verdict": "spam", "reasoning": "…", "flags": ["github-new-account"], "voter": { "slug", "fullName" }, "evaluatedAt": "…" } - ] + ], + "earlierVotes": [], + "earlierPurges": [ { "id": "…", "purgedAt": "…", "by": "human-jane", "reason": "…" } ] } } ``` `votes` is newest first, human and agent votes together; `kind` tells them -apart (from the `evaluator` prefix — [spam-exclusion.md](../behaviors/spam-exclusion.md#where-verdicts-come-from)). Everything in `footprint` is drawn from the in-memory -public state — nothing here is fetched from elsewhere. +apart (from the `evaluator` prefix — [spam-exclusion.md](../behaviors/spam-exclusion.md#where-verdicts-come-from)). +`earlierVotes` (same shape) are votes on an earlier account under this slug, +and `earlierPurges` the [`spam-purges`](../data-model.md#spampurge) records for +this slug ([Votes belong to one account](#votes-belong-to-one-account)). +Everything here is drawn from the in-memory public state — nothing is fetched +from elsewhere. ### Errors @@ -200,6 +238,15 @@ vote remains, the person is reactivated (the reversal rule). Response `200 { per `404` when the agent has no vote on the person. Staff change a vote by voting again; clearing a human vote is not offered. +This is how the "pending doing anything that makes them not look like spam" +half of the policy works: when a member the agent staged does something that +makes them look legitimate, the agent clears its vote (back to unvoted, in the +default review view) or votes `legit` (when it has positive evidence). What +counts is the agent's judgment, listed in the bot's own policy; the site only +records it. Once a human has voted on the member the agent's vote is history +and stays: `DELETE` answers `409 human_verdict_exists`, as `withdraw-votes` +leaves it ([agents.md](agents.md#post-apiadminagentsslugwithdraw-votes)). + ## Agent callers A request carrying an agent grant (`Authorization: Bearer cfpa_…`) sees the @@ -220,7 +267,8 @@ days only if no human has voted in the meantime. The rules, checked inside the write mutex: 1. **Humans are final.** An agent may not vote on a member any human has voted - on: `409 human_verdict_exists`. Its earlier vote on that member stays as the + on (on this account: [Votes belong to one account](#votes-belong-to-one-account)): + `409 human_verdict_exists`. Its earlier vote on that member stays as the record and has no effect. 2. **Only ordinary members.** The member must be `accountLevel = user` and not an agent account: `409 protected_account` / `422`. @@ -238,6 +286,187 @@ write mutex: ([storage.md → Commit message shape](../behaviors/storage.md#commit-message-shape)). `Actor-Account-Level` is `user`. +A staged vote is the right tool when the member has **not posted** anything +spammy: a business or site name as the member name, a throwaway GitHub +account. When the member has posted spam, the agent purges instead +([Agent purges](#agent-purges)); if it holds no purge scope it votes `spam` +(which hides the spam at once) and leaves the purge to staff. + +## POST /api/admin/members/:slug/purge + +Agent only (`agent:moderation:purge`). Purges a member **who has posted spam** +— the account itself put spam content on the site — at once, without the +three-day wait. The agent names the record that is the spam and says why; the +site verifies the record is the member's and that the +[agent purge rules](#agent-purges) hold, then runs the same cascade as an +administrator's purge +([person-lifecycle.md → Purge](../behaviors/person-lifecycle.md#purge-cascading-hard-delete-admin-only)). + +### Request + +```json +{ + "reason": "Signed up under a business name; 90 s later set a bio advertising that business, with a link.", + "flags": ["bio-promo-link", "business-name", "github-new-account"], + "evidence": [ + { "record": "person", "field": "bio" } + ], + "dryRun": false +} +``` + +- `reason` — required, 1–1000 chars, and **no URL** (`422`, `reason`: a purge + must not republish the spam link in a permanent public commit; cite the + record instead). +- `flags` — 0–10 tags, as on a vote. +- `evidence` — 1–5 items, each naming one record **the member authored** and, + where the record has several text fields, which one: + + | `record` | Identified by | The member's when | `field` | + | -------- | ------------- | ----------------- | ------- | + | `person` | (the member) | always | `bio` (required) | + | `project` | `project` (slug) | `createdById` is the member | `title` \| `summary` \| `overview` (optional) | + | `project-update` | `project`, `number` | `authorId` is the member | — | + | `project-buzz` | `project`, `slug` | `postedById` is the member | — | + | `help-wanted-role` | `project`, `roleId` | `postedById` is the member | — | + + The site checks that each record exists, is the member's, and that the cited + field (or the record's body) is not empty; otherwise `422 evidence_invalid` + naming the item. It does not judge whether the content is spam: that is the + agent's call, and the reason and evidence make it reviewable. The member's + *name* is not evidence of posting: a business name with nothing posted is a + vote, not a purge. +- `dryRun` — run every check and return what would be deleted; write nothing. + +### Response — 200 + +```json +{ + "success": true, + "data": { + "purge": { …the spam-purges record… }, + "deleted": { "projects": [ { "slug": "…", "title": "…" } ], "memberships": 1, "updates": 0, "buzz": 0, "helpWantedRoles": 0, "rsvps": 0 }, + "dryRun": false + } +} +``` + +### Errors + +- `422 validation_failed` — missing or URL-bearing `reason`, bad flags, no + evidence or more than 5, `slug` is the caller or an agent account +- `422 evidence_invalid` — an evidence record missing, not the member's, or + empty +- `409 conflict` — `error.code` one of `human_verdict_exists`, + `legit_vote_exists`, `member_too_old`, `protected_account`, + `established_member`, `shared_project`, `evidence_survives`; see + [Agent purges](#agent-purges) +- `429 rate_limited` — over the daily purge allowance, with `Retry-After` +- `404 not_found` — slug doesn't exist, or no grant with the scope + +## Agent purges + +An agent purge is **immediate**: no staging, no three days. That is the +operator's policy for an account that has posted spam ("purge immediately"), +and the reason it is fenced harder than a vote. The rules, checked inside the +write mutex, in this order: + +1. **Only ordinary, signed-up members.** `accountLevel = user`, not an agent + account, no laddr `legacyId`: `409 protected_account` / `422`. +2. **Only brand-new members.** `createdAt` within the last **7 days**: + `409 member_too_old`. Posted spam comes within minutes of sign-up (both + cases this was designed against posted within two minutes); seven days + covers a slow weekend. Anything older is a staged vote at most (30 days), + and past that a human's call. +3. **Humans are final.** Any human vote on this account, `spam` or `legit`, + blocks: `409 human_verdict_exists`. A staff `legit` always wins; a staff + `spam` has already hidden the member and put them on the clock, and the + agent does not shorten a person's clock. +4. **No `legit` vote standing.** Any agent's `legit` vote on this account + (its own included) blocks: `409 legit_vote_exists`. An agent that changes + its mind clears its `legit` first, so the record shows it did. +5. **Not established.** A member who has signed into Slack through the site + (`lastSlackSsoAt` set) is past the point an agent decides alone: + `409 established_member`. +6. **Nothing shared.** The member must not have created a project anyone else + has touched (the rule in + [person-lifecycle.md → Purge](../behaviors/person-lifecycle.md#purge-cascading-hard-delete-admin-only)), + and must not be a maintainer of any project with another member: + `409 shared_project`. A purge must never take a project or leave one + without its maintainer on an agent's say-so. + **Being a plain member of someone else's project does not block.** Joining + is self-service (`POST /api/projects/:slug/members/join`, any signed-in + user, no maintainer acceptance), so it carries no one else's judgment, and + spam accounts join real projects to borrow their credibility. The purge + removes the member's own membership and anything they posted there; the + project and everyone else's records are untouched. (A membership a + maintainer *added* would be different, but the membership record does not + say who added it; the agent's own policy treats a maintainer-added + membership as a reason not to act — see the plan.) +7. **The evidence goes with the purge.** Every evidence record must be one the + purge deletes (the member's own record, a project the purge takes, or an + update, buzz item or role they authored that goes with them): + `409 evidence_survives`. The agent may not cite content it is not removing. +8. **Volume.** At most **5** purges per agent in any rolling 24 hours, counted + from its `spam-purges` records: `429 rate_limited` with `Retry-After`. + Separate from, and lower than, the 20 spam votes. + +When every rule holds, in one transaction and **one commit per purge** (never +batched, so it reverts on its own): + +- the cascade in + [person-lifecycle.md → Purge](../behaviors/person-lifecycle.md#purge-cascading-hard-delete-admin-only), + public and private, including avatars and other attachments of deleted + records; +- the agent's `person-evaluations` record on the slug is written (or replaced) + as `verdict = "spam"`, `flags = ["agent", "purged", …flags]`, `reasoning = + reason` — the vote is the durable "why" the way a staff vote is; +- a [`spam-purges`](../data-model.md#spampurge) record with `by = + "agent-"`, the grant id, the reason, flags and evidence (each with + its data-repo path), the commit the purge was applied on, and + `blocksSignIn = true`. + +The commit is authored by the agent account, subject +`: POST /api/admin/members//purge`, body the `reason`, and +trailers `Action: person.purge`, `Subject-Type: person`, `Subject-Id`, +`Subject-Slug`, `Actor-Kind: agent`, `Grant-Id`, `Purge-Id: `, +and one `Evidence: [#]` per item +([storage.md → Commit message shape](../behaviors/storage.md#commit-message-shape)). +`git log --grep='^Action: person.purge$' --grep='^Actor-Kind: agent$' --all-match` +lists every agent purge; `git show ^:` shows the spam as +it stood. + +A wrong purge is restored by hand +([person-lifecycle.md → Restoring a purged member](../behaviors/person-lifecycle.md#restoring-a-purged-member)); +revoking the grant stops further purges at once. + +## GET /api/admin/spam-purges + +The spam-purge log: every [`spam-purges`](../data-model.md#spampurge) record, +newest first. A purged member has no roster row left, so this is where staff +see what was purged, by whom and why. + +| Param | Type | Notes | +| ----- | ---- | ----- | +| `by` | string | `agent` \| `human` \| `scheduled`, or one evaluator (`agent-`, `human-`). | +| `slug` | string | Records for one slug. | +| `since` | ISO date | `purgedAt` on or after. | +| `blocking` | bool | Only records whose sign-in block is in force. | +| `page`, `perPage` | int | Default `perPage = 50`, max 200. | + +Each item is the record plus `by` resolved (`{ kind, slug, fullName }`), each +evidence item's `url` (the record as it stood, on the data repo's GitHub at +`beforeCommit`), and `commitUrl` (the history of the record's own path, whose +first entry is the purge commit). + +## POST /api/admin/spam-purges/:id/unblock + +Staff. Lifts the record's sign-in block: sets `unblockedAt = now()` and +`unblockedBySlug`; the record itself stays. `{ "reason": "optional, ≤ 1000" }`. +`200 { purge }`; unblocking an unblocked record is a no-op. Committed as the +caller with `Action: spam-purge.unblock`. Used when a purge was wrong (with the +restore) or a person asks to start over. + ## Relationship to other specs - [behaviors/spam-exclusion.md](../behaviors/spam-exclusion.md) — the @@ -249,6 +478,8 @@ write mutex: staff-only fields reused here. - [api/agents.md](agents.md) — agent accounts, grants, and withdrawing an agent's staged votes. +- [api/auth.md](auth.md) — the GitHub callback refuses a GitHub account a + `spam-purges` record blocks. ## POST /api/admin/private-data/purge-orphans diff --git a/specs/api/people.md b/specs/api/people.md index 6ce125b..edb14fd 100644 --- a/specs/api/people.md +++ b/specs/api/people.md @@ -209,6 +209,26 @@ Administrator-only. Atomically hard-deletes the person record and cascades: proj Unlike the offline spam-prune (which nulls `authorId` on updates), purge DELETES the authored content — it is the on-demand garbage-collection path for spam accounts. +### Request body (optional) + +```json +{ "spam": true, "reason": "optional, ≤ 1000 chars" } +``` + +`spam` defaults to `false`. When `true`, the same commit writes a +[`spam-purges`](../data-model.md#spampurge) record (`by = "human-"`, +`blocksSignIn = true`), so the purged GitHub account cannot sign up again until +staff unblock it ([api/auth.md](auth.md#get-apiauthgithubcallback)). A purge +that is not for spam (a member asked to be erased) writes no such record: +keeping that person's GitHub id on a public block list would be the opposite of +what they asked. The Danger Zone's confirm dialog has a "This was spam: block +this GitHub account from signing up again" checkbox, checked by default when +the person has a spam vote on their current account. + +An [agent](agents.md) purges through +[`POST /api/admin/members/:slug/purge`](moderation.md#post-apiadminmembersslugpurge), +never here. + ### Response — 204 ### Errors diff --git a/specs/behaviors/authorization.md b/specs/behaviors/authorization.md index 7e5e1a7..95b7089 100644 --- a/specs/behaviors/authorization.md +++ b/specs/behaviors/authorization.md @@ -158,7 +158,12 @@ session, a browser sign-in, or staff powers. table is the source of truth on every request, unlike a stateless JWT. - **Limits.** Agent requests count against the per-account rate limits like anyone's; endpoints add their own (agent spam votes: - [api/moderation.md → Agent votes](../api/moderation.md#agent-votes)). + [api/moderation.md → Agent votes](../api/moderation.md#agent-votes); agent + purges: [api/moderation.md → Agent purges](../api/moderation.md#agent-purges)). +- **Deleting is its own scope.** `moderation:purge` is never implied by another + scope and is the only scope that deletes anything. It does not make an agent + an administrator: it reaches one endpoint, for members under seven days old + who posted spam, with evidence the API checks, at most five a day. - **Secret handling.** The token is shown once at mint and never stored, logged (the `authorization` header is redacted from request logs), or written to either data store; only its hash is kept, in the private store. @@ -168,6 +173,8 @@ cookies minted by a browser sign-in, its refresh token is not rotated, and it carries the full `staff` level (every staff endpoint, every member's email, deactivating anyone). Votes cast that way would also be recorded as `human-` — false in a public record. A grant is narrower on every count. +Likewise an agent never gets `administrator` to reach the existing purge: that +would let it purge anyone, of any age, with no stated reason. ## CSRF diff --git a/specs/behaviors/person-lifecycle.md b/specs/behaviors/person-lifecycle.md index 3421bd9..4b15fba 100644 --- a/specs/behaviors/person-lifecycle.md +++ b/specs/behaviors/person-lifecycle.md @@ -6,7 +6,7 @@ A person record has two removal paths with very different intent and reversibili | ----- | ------ | ------ | ---------- | | **Active** | default | Normal — visible in lists, detail, and as a reference on content. | — | | **Deactivated** | self, staff/admin, **or a spam vote** (staff, or a staged [agent](../api/agents.md) vote) | Soft hide. `deletedAt` set. Hidden from public lists + detail; references render a placeholder. The person **can still sign in** and reactivate. | Reactivate (clears `deletedAt`). | -| **Purged** | admin only | Cascading hard delete of the person + their content, in a single commit. | Via git history only (revert the commit). | +| **Purged** | admin; the scheduled spam purge; or an [agent](../api/agents.md) with a purge grant, for a new member who posted spam | Cascading hard delete of the person + their content, in a single commit. | Via git history only — [Restoring a purged member](#restoring-a-purged-member). | ## Deactivate (soft, self-service) @@ -17,29 +17,105 @@ The privacy / self-removal path — members should be able to remove themselves; - **Visibility while deactivated:** excluded from public list endpoints; `GET /api/people/:slug` returns 404 for non-staff (staff may still fetch it, with `deletedAt` populated). Anywhere a deactivated person is referenced (project member grids, project-update/project-buzz authors, help-wanted "posted by", blog author) the serialized reference is a **"Deactivated user" placeholder** (no slug link, generic avatar) rather than the person — substitute, do not omit, so counts/history stay coherent. - **Login is NOT blocked** — a deactivated user can still authenticate and reactivate themselves. No session revocation. - **Surfaces:** self at `/account` ("Deactivate my account" / "Reactivate"); staff/admin via a person "Danger Zone"; and the **admin members screen**, where a `spam` vote deactivates the person in the same transaction that records the vote ([api/moderation.md](../api/moderation.md)). -- **Deciding votes:** the human votes on a person, when there are any; otherwise the votes of agents that hold an active grant ([api/moderation.md → Agent votes](../api/moderation.md#agent-votes)). An agent vote stops deciding the moment any human votes on the person, and while its agent holds no active grant. +- **Deciding votes:** the human votes on a person, when there are any; otherwise the votes of agents that hold an active grant ([api/moderation.md → Agent votes](../api/moderation.md#agent-votes)). An agent vote stops deciding the moment any human votes on the person, and while its agent holds no active grant. Only votes on **this account** count: a vote older than the person's `createdAt` was cast on an earlier account under the same slug and is ignored here and everywhere below ([api/moderation.md → Votes belong to one account](../api/moderation.md#votes-belong-to-one-account)). - **Vote reversal rule:** a `legit` vote (or an agent withdrawing its vote) clears `deletedAt` only when the person's previous latest vote — human or agent, whether or not it still decides — was `spam`, that is, when moderation set it. A self-deactivation is never undone by a vote. ## Purge (cascading hard delete, admin only) The garbage-collection path for spam — the runtime sibling of the offline spam-prune (behaviors/spam-exclusion.md). -- **Who:** administrators only. +- **Who:** administrators; the [scheduled spam purge](#scheduled-spam-purge); and, for a new member who posted spam only, an agent holding `moderation:purge` ([Agent purge](#agent-purge)). (The heading keeps its old name so links to it hold.) - **Mechanism:** one write-mutex transaction that hard-deletes: the `people` record; their `project-membership`; their `help-wanted-interest`; their person `tag-assignment`; AND their authored `project-update`, `project-buzz`, and `blog-post` records (unlike the prune which nulls authorId — purge DELETES the content, it's garbage). Their **private** data goes in the same transaction: private profile, legacy password credential, password-reset tokens, account-claim requests naming them, and their session metadata. A purged person leaves nothing behind in either store. - **Projects only they touched go too.** In the same transaction, purge hard-deletes every project the person created that nobody else has touched — spam accounts create spam projects, and a project left behind with no members is a soft-deleted husk an admin can't clear. A project qualifies when both hold: 1. **Created by the person:** `createdById` is the person; for a project with no `createdById` (imported from laddr, or created before the field existed), the person is its `maintainerId`. 2. **No one else touched it:** every membership is the person's, every update was authored by them, every buzz posted by them, every help-wanted role posted by them with no interest from anyone else, and no RSVP from anyone else names the project. A qualifying project is deleted with everything that hangs off it: the project record and its attachments, its memberships, updates, buzz, help-wanted roles and their interest, its tag-assignments, and RSVPs naming it (slug-history entries are left to expire). A project anyone else touched is left exactly as it is (the person's own records on it are removed as above). + Attachments (avatars, buzz images) go with the records they hang off. - **Atomic + git-revertable** (one commit). - **Surface:** person "Danger Zone" (admin only), behind a confirm dialog. - **Preview:** the confirm dialog lists the projects that will be deleted with the person (`GET /api/people/:slug/purge-preview`), or says none will be. +- **Spam or not.** The dialog asks whether this was spam (checked by default when the person has a spam vote). A spam purge writes a [`spam-purges`](../data-model.md#spampurge) record in the same commit — who, why, and the person's `githubUserId` — which blocks that GitHub account from signing up again until staff unblock it ([api/auth.md](../api/auth.md#get-apiauthgithubcallback)). A purge that is not for spam writes nothing of the kind. +- **What stays.** The person's `person-evaluations` records stay, keyed by the slug, as the record of why; they do not attach to a later account that gets the same slug. + +### Agent purge + +The operator's policy: "When someone actually posts spam, purge immediately." +An agent holding `moderation:purge` may purge a member who has **posted spam** +— the account itself put spam on the site: a promotional bio or link, a spam +project, update, buzz item or help-wanted role — without the three-day wait +([api/moderation.md → Agent purges](../api/moderation.md#agent-purges)). + +- **Narrower than an administrator's purge by precondition, not by a smaller + cascade.** The API refuses unless the member joined in the last 7 days, is a + signed-up `user` with no human vote, no standing `legit` vote and no Slack + sign-in, created no project anyone else touched and maintains no project with + other members; the agent cites 1–5 records the member authored, which the API + checks exist, are theirs and go with the purge; at most 5 a day per agent. + Within those fences the cascade is the one above, so nothing an agent purge + reaches belongs to anyone else, and the spam it cites is removed with the + account. A plain membership on someone else's project does not block (a + self-service join is no one else's judgment); the membership goes and the + project stays. +- **One commit per purge**, authored by the agent, with the reason in the body + and `Evidence:` trailers; a `spam-purges` record (`by = "agent-"`) and + the agent's `spam` vote record are written in the same commit. +- **After the fact:** a purged member has no roster row, so the purge shows in + the [admin members screen's](../screens/admin-members.md#agents-panel) + purge log, with reason, evidence links and the commit; the agent also posts + each one to its operators' channel. + +## Restoring a purged member + +A purge deletes from the data repo's working tree, but the repo is git: every +deleted public record is in the purge commit's parent. Private data (email, +newsletter preference, Slack sign-in time, GitHub reputation facts) is **not** +recoverable: the production private store is a filesystem volume with no +versioning and no backup today ([deploy.md](../../docs/operations/deploy.md); +[private-storage.md → Bucket versioning](private-storage.md#bucket-versioning) +describes the S3 backend, which is not the one in use). + +The runbook step (`docs/operations/runbook.md` → "Restore a purged member"): + +1. **Find the commit.** For an agent or staff spam purge, the purge log links + it (the history of the `spam-purges` record's path); for a non-spam staff + purge, `git log --grep='^Action: person.purge$'` and the slug; for the + sweep, the sweep commit naming the slug. +2. **Lift the block**: `POST /api/admin/spam-purges/:id/unblock` (staff, from + the purge log), with a reason. The `spam-purges` record stays, now marked + unblocked: it is the log that the purge happened and was undone. +3. **Restore the records** in a working clone of the data repo and push to + `published`; the hot-reload webhook rebuilds the in-memory state. An agent + or staff purge is one person per commit, so revert it but keep the log: + `git revert --no-commit && git checkout HEAD -- spam-purges/`, then + commit with `Action: person.restore` and the slug. A sweep commit purges a + batch: check out only that person's paths from the parent + (`git checkout ^ -- people/.toml people// …`) rather than + reverting the whole commit. +4. **Vote Not spam** on the restored member on the roster. The restored record + is as it was before the purge, which may be deactivated by a spam vote that + is back too (a staff vote the purge left in place, or an agent's staged + vote the revert brought back); Not spam un-hides them and keeps the sweep + off them. +5. **If the slug was taken since** (someone signed up again under it), the + restore conflicts on `people/.toml`; that is a merge for an + administrator to decide, not a revert. +6. **Tell the person** to sign in with GitHub: the restored `githubUserId` + matches, and sign-in rebuilds their private profile from GitHub (email and + reputation facts). Their newsletter choice and Slack sign-in history are + gone. + +No staff "Restore" button for now: a restore is rare, needs judgment (what +else changed since, whether the slug was re-taken), and a git revert restores +the exact records. If restores become routine (more than about one a month), +a `POST /api/admin/spam-purges/:id/restore` that replays the parent's records +is the follow-up. ## Scheduled spam purge A `spam` vote on the admin members screen deactivates the person at once; three days later, if nobody has disagreed, the site purges them on its own. Staff shouldn't have to come back and press a second button for every spam account, and three days is long enough for another staff member to look and object. -An agent's `spam` vote **stages** the same way: it hides the person at once and purges them three days later unless a human votes in the meantime. The three days are the review window: the roster shows every staged vote, labeled as the agent's, with its reasoning and purge date, and one **Not spam** click from any staff member overrules it ([screens/admin-members.md](../screens/admin-members.md)). Nothing an agent does deletes anything sooner. +An agent's `spam` vote **stages** the same way: it hides the person at once and purges them three days later unless a human votes in the meantime. The three days are the review window: the roster shows every staged vote, labeled as the agent's, with its reasoning and purge date, and one **Not spam** click from any staff member overrules it ([screens/admin-members.md](../screens/admin-members.md)). A staged vote never deletes anything sooner. This is the operator's second case — "for signups that are obviously spam but haven't posted anything spammy, log a spam vote to put them in the 3-day purge queue pending doing anything that makes them not look like spam" — and the agent clears its own vote when the member does ([api/moderation.md → DELETE …/vote](../api/moderation.md#delete-apiadminmembersslugvote)). The only way an agent deletes sooner is an [agent purge](#agent-purge), for a member who posted spam. - **When:** an in-process job runs once a day (and once shortly after boot) — **only on the deployment where `CFP_SCHEDULED_PURGE=true`, which is production alone.** Sandbox and production serve the same `published` branch, so any pod that runs the sweep is deleting production data; exactly one may. With the flag off the pod never sweeps and logs that at boot. - **Who it takes:** every person who @@ -50,6 +126,7 @@ An agent's `spam` vote **stages** the same way: it hides the person at once and - **What it does:** exactly what Purge does for each of them, including projects they created that nobody else touched. Within one run, "nobody else" means nobody outside the batch: a project made by one queued spam account and joined only by others in the same queue goes too. - **One commit per run** for the whole batch, authored by the site (`cfp-api`), its message listing the purged slugs. A slug queued on agent votes alone is listed as ` (staged by agent-)`, and the commit carries an `Agent-Staged: ` trailer counting them, so `git log` separates what a person decided from what only an agent did. A run with nobody queued writes nothing. - The votes — human and agent — stay in `person-evaluations` as the record of why. +- Each purged person gets a [`spam-purges`](../data-model.md#spampurge) record in the same commit, `by = "scheduled"`, its reason naming the deciding voters, `blocksSignIn = true`: three days with nobody objecting is a spam verdict, and the GitHub account should not sign straight back up. ## Authorization summary @@ -59,8 +136,9 @@ An agent's `spam` vote **stages** the same way: it hides the person at once and | Spam / not-spam vote (deactivates / may reactivate) | – | ✓ | ✓ | | Purge | – | – | ✓ | | Scheduled spam purge | – | – (their spam vote queues it) | – (runs as the site) | +| Unblock a spam-purged GitHub account | – | ✓ | ✓ | -An agent with a `moderation:vote` grant may cast staged spam / not-spam votes within the [agent vote rules](../api/moderation.md#agent-votes); it can deactivate (a spam vote) and reverse its own deactivation, and nothing else in this table. +An agent with a `moderation:vote` grant may cast staged spam / not-spam votes within the [agent vote rules](../api/moderation.md#agent-votes); it can deactivate (a spam vote) and reverse its own deactivation. An agent with a `moderation:purge` grant may also purge, within the [agent purge rules](../api/moderation.md#agent-purges). Nothing else in this table. ## Relationship to other specs diff --git a/specs/behaviors/spam-exclusion.md b/specs/behaviors/spam-exclusion.md index 45c9008..bdff65b 100644 --- a/specs/behaviors/spam-exclusion.md +++ b/specs/behaviors/spam-exclusion.md @@ -86,7 +86,16 @@ deliberately **conservative — only confident spam is pruned**: > the runtime [scheduled spam purge](./person-lifecycle.md#scheduled-spam-purge), > after its three days. (An agent vote has no `confidence`, so the machine rule > below would ignore it anyway; the prune says so explicitly rather than relying -> on that.) +> on that.) An agent's purge of a member who *posted* spam is a runtime act, +> not a vote: it deletes the member at once, under its own rules +> ([api/moderation.md → Agent purges](../api/moderation.md#agent-purges)), and +> leaves an `agent-*` `spam` record (flags `agent`, `purged`) on a slug that no +> longer has a person, which the prune has nothing to do with. +> +> **Only this account's votes count.** A `human-*` or `agent-*` record whose +> `evaluatedAt` is earlier than the person's `createdAt` was cast on an earlier +> account under the same slug (purged since); the prune ignores it, as the +> runtime does ([api/moderation.md → Votes belong to one account](../api/moderation.md#votes-belong-to-one-account)). > > Otherwise a person is **pruned as spam** iff they have at least one `spam` > verdict with `confidence ≥ SPAM_CONFIDENCE_THRESHOLD` (default **0.8**), no diff --git a/specs/behaviors/storage.md b/specs/behaviors/storage.md index 66c7edb..ce21403 100644 --- a/specs/behaviors/storage.md +++ b/specs/behaviors/storage.md @@ -271,7 +271,7 @@ Rules: - The **author and committer identity is pseudonymous** in the public repo: ` <@users.noreply.codeforphilly.org>`. **Real email addresses are never written as commit authors** — git stores author identity forever and the data repo is public. The pseudonymous form keeps `git log --author=` and `git blame` working naturally for audit purposes without leaking PII. - For anonymous requests, author is `Anonymous `. - For system/automated commits (migration import, reconciliation), author is `Code for Philly API `. - - For requests made with an [agent grant](authorization.md#agent-grants), author is the agent account's pseudonymous identity like any person's, and the commit adds two trailers: `Actor-Kind: agent` and `Grant-Id: ` (never the token). `git log --grep='^Actor-Kind: agent$'` lists everything agents did. + - For requests made with an [agent grant](authorization.md#agent-grants), author is the agent account's pseudonymous identity like any person's, and the commit adds two trailers: `Actor-Kind: agent` and `Grant-Id: ` (never the token). `git log --grep='^Actor-Kind: agent$'` lists everything agents did. An agent purge also carries `Purge-Id: ` and one `Evidence: [#]` per cited record, with the agent's reason as the body ([api/moderation.md → Agent purges](../api/moderation.md#agent-purges)); the reason may not contain a URL, so the spam link is never republished in a commit message. - **`User-Ip` and `User-Agent` trailers are NOT included.** The public commit history is forever-public; client IPs and user agents are PII that doesn't belong there. If a security incident needs request forensics, that's what the optional access-log sidecar (private store) is for — not the public git log. - The **trailers** follow git's standard trailer format (parseable by `git interpret-trailers --parse`). Key convention is HTTP-header style: first letter capitalized, multi-word keys hyphenated, rest lowercase. Examples: `Subject-Type`, `Response-Code`. Single-word keys: `Action`, `Reason`, `Host`. - `Action` is namespaced with a dot (`project.soft-delete`, `tag.merge`, `account-level.change`) so trailer filters like `git log --grep='^Action: project\.'` work cleanly. diff --git a/specs/data-model.md b/specs/data-model.md index d058e37..23651dd 100644 --- a/specs/data-model.md +++ b/specs/data-model.md @@ -43,6 +43,7 @@ Tag ──── TagAssignment ──── (Project | Person | HelpWantedRole | Person ── has ── Revocation (0:many; revoked JWT IDs) Person ── has ── AgentGrant (0:many; only agent accounts) Person ── evaluated by ── PersonEvaluation (0:many; staff and agent spam votes) +SpamPurge ── outlives the Person it records (by slug + githubUserId) SlugHistory ── points at any renamed entity by (entityType, oldSlug) PRIVATE (S3-compatible bucket) ───────────────────────────────────────── @@ -213,14 +214,14 @@ is (its hash is private — [private-storage.md](behaviors/private-storage.md)). | id | uuid | | | personSlug | string | the agent account. Follows renames like other slug references. | | label | string | 1–80 chars; what and where (e.g. "ops bot, host vm-1") | -| scopes | string[] | from `moderation:read`, `moderation:vote` | +| scopes | string[] | from `moderation:read`, `moderation:vote`, `moderation:purge`; `moderation:purge` requires `moderation:read` | | createdAt | iso8601 | | | createdBySlug | string | the administrator who minted it | | expiresAt | iso8601 | at most 180 days after `createdAt` | | revokedAt | iso8601 nullable | | A person with any `agent-grants` record is an agent account: never staff, -never voted on, never taken by the scheduled spam purge. +never voted on or purged by an agent, never taken by the scheduled spam purge. ## PersonEvaluation @@ -230,6 +231,57 @@ staff votes (`evaluator = "human-"`) and agent votes (`"agent-"`). Field table, evaluator kinds and aggregation: [behaviors/spam-exclusion.md](behaviors/spam-exclusion.md#where-verdicts-come-from). +The records outlive a purge (they are the record of why), and the path is +keyed by slug, so a later account with the same slug sits beside them. A +record belongs to the account whose `createdAt` is at or before its +`evaluatedAt`; older records are the earlier account's and count for nothing +on the new one ([api/moderation.md → Votes belong to one account](api/moderation.md#votes-belong-to-one-account)). + +## SpamPurge + +One record per person purged **as spam** — by an administrator who marked the +purge as spam, by an [agent purge](api/moderation.md#agent-purges), or by the +[scheduled spam purge](behaviors/person-lifecycle.md#scheduled-spam-purge). It +is the log staff read once the member's row is gone, and the sign-up block +that keeps the same GitHub account from coming straight back. A purge that is +not for spam (a member asked to be erased) writes none. + +**Sheet:** `spam-purges` +**Path template:** `spam-purges/${personSlug}/${id}.toml` +**Contract:** `codeforphilly.org/spam-purges/v1` + +| Field | Type | Notes | +| ------- | ------ | ------- | +| id | uuid | | +| personSlug | string | the purged slug. **Not** a reference: the person is gone, and the slug may be taken again. Never rewritten by a rename cascade. | +| personId | uuid | the purged record's id | +| githubUserId | int nullable | the purged person's GitHub id, when linked; what the sign-up block matches | +| purgedAt | iso8601 | | +| by | string | `human-`, `agent-`, or `scheduled` | +| grantId | uuid nullable | the agent's grant, for `agent-*` | +| reason | string nullable | 1–1000 chars, no URL; required for `agent-*`; for `scheduled`, names the deciding voters | +| flags | string[] | reason tags, as on a vote | +| evidence | table[] | agent purges: `{ record, field?, path }` per cited record (`path` is its data-repo path, e.g. `project-updates/example/3.toml`); empty otherwise | +| beforeCommit | string | the data-repo commit the purge was applied on top of: where the evidence can be read as it stood | +| blocksSignIn | bool | `true` for every spam purge today; kept as a field so a future non-blocking kind needs no contract change | +| unblockedAt | iso8601 nullable | set by `POST /api/admin/spam-purges/:id/unblock` | +| unblockedBySlug | string nullable | the staff member who unblocked | +| unblockReason | string nullable | | + +**Public, on purpose.** `githubUserId` is already a public field on every +`people` record, and the purged record (with it) stays in the repo's history, +so the block list exposes nothing new; the slug's spam verdict is already +public in `person-evaluations` and the purge commit. Keeping the block in the +public store makes it auditable (who blocked whom, why, who lifted it) like +every other moderation act. It holds no name, email or bio: the scrubbed +snapshot needs no new rule. The private store was considered and rejected: it +has no history in production, and a block nobody can see is a block nobody can +question. + +**Secondary in-memory indices:** `spamPurgesByGithubUserId: Map>` +(the OAuth callback's check), `spamPurgesBySlug: Map>` (the +`slug-purged-before` signal and the detail's `earlierPurges`). + ## Project **Sheet:** `projects` diff --git a/specs/screens/admin-members.md b/specs/screens/admin-members.md index 487e583..79469f4 100644 --- a/specs/screens/admin-members.md +++ b/specs/screens/admin-members.md @@ -26,6 +26,13 @@ on the row, and lets any staff member overrule (**Not spam**) or confirm (**Spam**) them with one click. The intro says that as well, and names the date a staged vote purges. +An agent granted the purge scope may also **purge** a new member who posted +spam, at once ([api/moderation.md → Agent purges](../api/moderation.md#agent-purges)). +A purged member has no row left, so this page shows those purges in the +Agents panel's purge log, with the agent's reason, links to the evidence as it +stood and to the commit, and a banner while any is recent. The intro says this +too. + ## Data Requirements - `GET /api/admin/members` on entry and on every filter/sort/page change @@ -33,6 +40,9 @@ date a staged vote purges. - `POST /api/admin/members/:slug/vote` on either vote button - `GET /api/admin/agents`, `POST /api/admin/agents/:slug/withdraw-votes` and the grant endpoints in the Agents panel ([api/agents.md](../api/agents.md)) +- `GET /api/admin/spam-purges` on entry (`by=agent&since=<7 days ago>`, for the + banner) and when the purge log opens; `POST /api/admin/spam-purges/:id/unblock` + from the log See [api/moderation.md](../api/moderation.md). @@ -73,6 +83,8 @@ See [api/moderation.md](../api/moderation.md). - `Email bounced · {type}` (red) when `emailBounce` is set. - `email ≠ name` (amber), `{n} links in bio` (amber), `no bio`, `no avatar` (neutral) from the signals. + - `Slug purged before` (red) from `slug-purged-before`: an earlier account + under this slug was purged as spam; the detail shows when, by whom and why. - **Attention tint**: the row's left border goes amber at `attention ≥ 3` and red at `attention ≥ 5` (or whenever `github-gone` / `email-bounced` is present), so the eye lands on the spam shape without reading every field. @@ -94,22 +106,56 @@ links: project memberships (role, joined), authored updates, buzz, blog posts, help-wanted interest, tags. Then the vote history, newest first, each with voter, verdict, reasoning, and time; an agent's vote is marked "AI agent" and shows its flags, and once a human has voted the agent's votes read "overruled" -or "confirmed". Then the same two buttons, plus a link to the person's Danger -Zone for admins (purge lives there, not here). +or "confirmed". When the slug was used before, an **Earlier account** block +follows: the earlier account's purges (`earlierPurges`) and votes +(`earlierVotes`), greyed, saying they do not count for this member. Then the +same two buttons, plus a link to the person's Danger Zone for admins (an +administrator's purge lives there, not here). + +### Recent agent purges banner + +When any agent purged anyone in the last 7 days, a banner above the roster +says `{n} members purged by {agent} (AI) in the last 7 days` and opens the +purge log. It is not dismissable: it goes when the purges age out, so an agent +purge is seen by whoever opens this page that week. ### Agents panel A collapsed **Agents** section at the foot of the page, one line per agent account (`GET /api/admin/agents`): its name (linking to its profile), how many -votes it has staged now, and its grants (label, scopes, created by, expires, -last used, active or revoked). +votes it has staged now, how many members it purged in the last 24 hours and +30 days, and its grants (label, scopes, created by, expires, last used, active +or revoked). A grant carrying `moderation:purge` shows that scope in red. - **Show staged** sets the Vote filter to "staged by an agent". +- **Purge log** opens the log below, filtered to that agent. - **Withdraw staged votes** (staff) — a confirm saying how many votes it removes - and how many members it un-hides, then `POST …/withdraw-votes`. + and how many members it un-hides, then `POST …/withdraw-votes`. The confirm + says it does not undo purges. - **Mint grant** / **Revoke** / **New agent** (administrators only). A minted token is shown once, in a dialog with a copy button and the words "This is the only time it is shown"; closing the dialog drops it from the page. + Minting a grant with `moderation:purge` asks for a second confirm naming + what it allows (immediate purges of members under 7 days old who posted + spam, up to 5 a day). + +#### Purge log + +`GET /api/admin/spam-purges`, newest first, filterable by who purged (an agent, +staff, the daily sweep, or all). One line per purge: + +- the purged slug as plain text (there is no profile to link to), when, and by + whom (`{agent} (AI)`, a staff member, or "daily sweep"); +- the reason, in full, and the flags as chips; +- for an agent purge, each evidence item as a link to the record as it stood + before the purge (`people/.toml#bio`, `project-updates//.toml` + on the data repo at `beforeCommit`), so a reviewer reads the spam the agent + saw without it being shown on this page; +- **Commit**, linking to the purge commit; +- the sign-up block: `Blocks sign-up` with an **Unblock** button (staff; a + confirm with an optional reason), or `Unblocked by {staff} · {when}`; +- a "How to restore" link to the runbook step + ([person-lifecycle.md → Restoring a purged member](../behaviors/person-lifecycle.md#restoring-a-purged-member)). ### Voting @@ -136,13 +182,16 @@ last used, active or revoked). | Not spam | `POST …/vote { verdict: "legit", reasoning? }` | Row → vote badge; un-dim if vote-hidden | | Withdraw staged votes (Agents panel) | `POST /api/admin/agents/:slug/withdraw-votes` | Refetch roster; toast with the counts | | Mint / Revoke grant, New agent (Agents panel, admin) | [api/agents.md](../api/agents.md) | Refresh the panel; show a new token once | +| Open purge log (banner or Agents panel) | `GET /api/admin/spam-purges?…` | Show the log | +| Unblock (purge log) | `POST /api/admin/spam-purges/:id/unblock { reason? }` | Line shows "Unblocked by you" | ## Authorization -Staff and administrators. Purge is not offered here (admin-only, on the person -page). Votes are attributed to the individual caller and can be changed by that -caller at any time. Grants are minted and revoked by administrators only. -Agents never see this page: they hold no session, only an API grant. +Staff and administrators. An administrator's purge is not offered here (it is +on the person page). Votes are attributed to the individual caller and can be changed +by that caller at any time. Grants are minted and revoked by administrators +only; any staff member may read the purge log and unblock. Agents never see +this page: they hold no session, only an API grant. ## Out of scope (v1) diff --git a/specs/screens/login.md b/specs/screens/login.md index e984b16..6cd9da7 100644 --- a/specs/screens/login.md +++ b/specs/screens/login.md @@ -63,6 +63,7 @@ When `?error=` is present (from the OAuth callback or the password-login r | `github_unreachable` | "We couldn't reach GitHub. Please try again in a moment." | | `email_unverified` | "Your GitHub account doesn't have a verified email address visible to us. To sign in here, please [verify a primary email on GitHub](https://github.com/settings/emails) and ensure email visibility is enabled for our app." | | `invalid_credentials` | "The username or password you entered is incorrect." (rendered inline in the password form, not as a top-banner) | +| `signup_blocked` | "An account for this GitHub user was removed for spam, so it can't sign up again. If you think that's a mistake, email ." ([api/auth.md](../api/auth.md#get-apiauthgithubcallback)) | ## Actions From 991756b41ebf1d4c0ae5329647ab2a86d52ae0fb Mon Sep 17 00:00:00 2001 From: GrittyBot <30755786+codeforphilly-bot@users.noreply.github.com> Date: Thu, 12 Nov 2026 19:18:03 +0000 Subject: [PATCH 04/23] docs(specs): agents have no private profile, byRef on the purge log, scrub rule Three corrections found while building: the PrivateProfile schema requires an email, the purge log needs a resolved actor beside the stored string, and the scrubbed snapshot must pseudonymize the new slug fields. Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_01S8dthiZS2vwh674eh2DDQs --- specs/api/agents.md | 8 ++++---- specs/api/moderation.md | 9 +++++---- specs/data-model.md | 6 ++++-- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/specs/api/agents.md b/specs/api/agents.md index f283c3c..6677cce 100644 --- a/specs/api/agents.md +++ b/specs/api/agents.md @@ -40,8 +40,8 @@ moderation endpoints an agent calls with its token are in An agent account is a `people` record created by an administrator here, never by GitHub sign-in: -- `accountLevel = user`, no `githubUserId`, no legacy password, and a private - profile with no email. **It cannot sign in to the site or to Slack**: there +- `accountLevel = user`, no `githubUserId`, no legacy password, and no + private profile (a private profile holds an email, and an agent has none). **It cannot sign in to the site or to Slack**: there is no identity for OAuth, a password or SAML to bind to. The only way in is a grant. - It is public like any member (`/members/`), and its `bio` says what it @@ -136,8 +136,8 @@ agent staged and purged is a staff review surface, and nothing here is secret ``` `bio` is required (an agent says what it is). `grant` is optional. Creates the -person, an empty private profile, and the grant in one transaction; the slug -follows [slug-handles.md](../behaviors/slug-handles.md). +person and the grant (its token hash in the private store) in one transaction; +the slug follows [slug-handles.md](../behaviors/slug-handles.md). Response `201`: `{ person, grant, token }` — `token` only when a grant was minted, and only in this response. diff --git a/specs/api/moderation.md b/specs/api/moderation.md index 0e04772..4fce313 100644 --- a/specs/api/moderation.md +++ b/specs/api/moderation.md @@ -454,10 +454,11 @@ see what was purged, by whom and why. | `blocking` | bool | Only records whose sign-in block is in force. | | `page`, `perPage` | int | Default `perPage = 50`, max 200. | -Each item is the record plus `by` resolved (`{ kind, slug, fullName }`), each -evidence item's `url` (the record as it stood, on the data repo's GitHub at -`beforeCommit`), and `commitUrl` (the history of the record's own path, whose -first entry is the purge commit). +Each item is the record plus `byRef` (its `by` resolved: `{ kind, slug, +fullName }`; `by` itself stays the stored string), each evidence item's `url` +(the record as it stood, on the data repo's GitHub at `beforeCommit`), and +`commitUrl` (the history of the record's own path, whose first entry is the +purge commit). ## POST /api/admin/spam-purges/:id/unblock diff --git a/specs/data-model.md b/specs/data-model.md index 23651dd..38f3f86 100644 --- a/specs/data-model.md +++ b/specs/data-model.md @@ -273,8 +273,10 @@ not for spam (a member asked to be erased) writes none. so the block list exposes nothing new; the slug's spam verdict is already public in `person-evaluations` and the purge commit. Keeping the block in the public store makes it auditable (who blocked whom, why, who lifted it) like -every other moderation act. It holds no name, email or bio: the scrubbed -snapshot needs no new rule. The private store was considered and rejected: it +every other moderation act. It holds no name, email or bio. The scrubbed +snapshot pseudonymizes the slugs it does hold (`personSlug` when a current +member has it again, `unblockedBySlug`) like references, as it does +`AgentGrant.createdBySlug`, so no real member's slug leaks. The private store was considered and rejected: it has no history in production, and a block nobody can see is a block nobody can question. From 49bb3cbb0597c59a08010c413acadbc3f4dec780 Mon Sep 17 00:00:00 2001 From: GrittyBot <30755786+codeforphilly-bot@users.noreply.github.com> Date: Thu, 12 Nov 2026 19:18:03 +0000 Subject: [PATCH 05/23] fix(api): votes belong to one account A vote dated before the person's createdAt was cast on an earlier account with the same slug. It no longer counts in the roster, the reversal rule, the sweep or prune-spam. Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_01S8dthiZS2vwh674eh2DDQs --- apps/api/scripts/prune-spam.ts | 51 ++++++++- apps/api/src/routes/moderation.ts | 1 + apps/api/src/services/moderation.ts | 45 +++++--- apps/api/src/services/spam-purge.ts | 14 +-- apps/api/src/services/votes.ts | 41 +++++++ apps/api/tests/prune-spam.test.ts | 16 +++ apps/api/tests/votes-one-account.test.ts | 131 +++++++++++++++++++++++ 7 files changed, 273 insertions(+), 26 deletions(-) create mode 100644 apps/api/src/services/votes.ts create mode 100644 apps/api/tests/votes-one-account.test.ts diff --git a/apps/api/scripts/prune-spam.ts b/apps/api/scripts/prune-spam.ts index 0e0266c..ec671a1 100644 --- a/apps/api/scripts/prune-spam.ts +++ b/apps/api/scripts/prune-spam.ts @@ -149,11 +149,18 @@ async function openAnyRepo(path: string): Promise { * Fold one person-evaluation record into the per-person verdict map. A * record missing `verdict` (or, for machine records, `confidence`) does not * contribute — heuristic records carry `score`, never `confidence`. + * + * `createdAtBySlug` (the people on the served branch) drops a site vote cast + * on an earlier account under the same slug: a `human-*` record whose + * `evaluatedAt` is before the current person's `createdAt` was cast on a + * purged account, and counts for nothing on this one + * (specs/behaviors/spam-exclusion.md → "Only this account's votes count"). */ export function foldEvaluation( verdictMap: Map, record: Readonly>, threshold: number, + createdAtBySlug?: ReadonlyMap, ): void { const personSlug = record['personSlug']; const evaluator = record['evaluator']; @@ -168,6 +175,13 @@ export function foldEvaluation( const evaluatedAt = typeof rawAt === 'string' ? rawAt : rawAt instanceof Date ? rawAt.toISOString() : null; + if (isSiteVote(evaluator) && createdAtBySlug) { + const createdAt = createdAtBySlug.get(personSlug); + if (createdAt !== undefined && (evaluatedAt === null || Date.parse(evaluatedAt) < Date.parse(createdAt))) { + return; + } + } + let entry = verdictMap.get(personSlug); if (!entry) { entry = { hasConfidentSpam: false, hasAnyLegit: false, humanVerdict: null, humanEvaluatedAt: null }; @@ -187,6 +201,34 @@ export function foldEvaluation( } } +/** A vote cast on the site (as opposed to an offline machine pass). */ +function isSiteVote(evaluator: string): boolean { + return evaluator.startsWith(HUMAN_EVALUATOR_PREFIX); +} + +/** + * Every person's `createdAt` at `ref` in `repoPath`, by slug — what decides + * which site votes belong to the current account under a slug. + */ +export async function peopleCreatedAt(repoPath: string, ref: string): Promise> { + const repo = await openAnyRepo(repoPath); + const out = new Map(); + let records: readonly Record[]; + try { + records = await (await repo.openSheet('people', { ref })).queryAll(); + } catch (err) { + if (err instanceof ConfigError && err.code === 'config_missing') return out; + throw err; + } + for (const p of records) { + const slug = p['slug']; + const at = p['createdAt']; + const createdAt = typeof at === 'string' ? at : at instanceof Date ? at.toISOString() : null; + if (typeof slug === 'string' && createdAt) out.set(slug, createdAt); + } + return out; +} + /** * Read every person-evaluation at `ref` in `repo` and fold it into * `verdictMap`, through a read-only sheet pinned at `ref`. A ref without a @@ -198,6 +240,7 @@ export async function aggregateVerdicts( threshold: number, log: (msg: string) => void, verdictMap: Map = new Map(), + createdAtBySlug?: ReadonlyMap, ): Promise> { log(`[prune-spam] reading person-evaluations in ${repoPath} at ref=${ref}`); const repo = await openAnyRepo(repoPath); @@ -212,7 +255,7 @@ export async function aggregateVerdicts( else throw err; } log(`[prune-spam] found ${records.length} evaluation records`); - for (const record of records) foldEvaluation(verdictMap, record, threshold); + for (const record of records) foldEvaluation(verdictMap, record, threshold, createdAtBySlug); return verdictMap; } @@ -347,13 +390,17 @@ export async function pruneSpam(args: CliArgs): Promise { `[prune-spam] reading machine verdicts from ${args.evaluationsRepo}@${args.evaluationsRef}, ` + `human votes from ${args.dataRepo}@${args.humanVotesRef}, threshold=${args.threshold}`, ); + // Site votes on an earlier account under a slug don't count (either repo). + const createdAtBySlug = await peopleCreatedAt(args.dataRepo, args.humanVotesRef); const verdictMap = await aggregateVerdicts( args.evaluationsRepo, args.evaluationsRef, args.threshold, log, + new Map(), + createdAtBySlug, ); - await aggregateVerdicts(args.dataRepo, args.humanVotesRef, args.threshold, log, verdictMap); + await aggregateVerdicts(args.dataRepo, args.humanVotesRef, args.threshold, log, verdictMap, createdAtBySlug); const { pruneSet, humanSpam } = computePruneSet(verdictMap); log( diff --git a/apps/api/src/routes/moderation.ts b/apps/api/src/routes/moderation.ts index 35212fc..057dec3 100644 --- a/apps/api/src/routes/moderation.ts +++ b/apps/api/src/routes/moderation.ts @@ -93,6 +93,7 @@ export async function moderationRoutes(fastify: FastifyInstance): Promise person, footprint, votes: fastify.services.moderation.humanVotes(slug), + earlierVotes: fastify.services.moderation.earlierVotes(slug), }); }, ); diff --git a/apps/api/src/services/moderation.ts b/apps/api/src/services/moderation.ts index 1dc97b5..bd0c212 100644 --- a/apps/api/src/services/moderation.ts +++ b/apps/api/src/services/moderation.ts @@ -8,6 +8,7 @@ * side effect from specs/behaviors/person-lifecycle.md. */ import { spamPurgeDueAt } from './spam-purge.js'; +import { votesOnAccount, votesOnEarlierAccounts } from './votes.js'; import { PersonSchema, PersonEvaluationSchema, @@ -302,23 +303,36 @@ export class ModerationService { await Promise.all(Array.from({ length: Math.min(GITHUB_PROBE_CONCURRENCY, queue.length) }, worker)); } - /** Every human vote on a person, newest first. */ + /** + * Every human vote on this person's account, newest first. Votes on an + * earlier account under the same slug are not theirs (`earlierVotes`). + */ humanVotes(personSlug: string): VoteView[] { - const keys = this.#state.evaluationsByPerson.get(personSlug); - if (!keys) return []; - const votes: VoteView[] = []; - for (const key of keys) { - const rec = this.#state.personEvaluations.get(key); - if (!rec || !isHumanEvaluator(rec.evaluator)) continue; - votes.push(this.#voteView(rec)); - } - return votes.sort((a, b) => b.evaluatedAt.localeCompare(a.evaluatedAt)); + const person = this.#person(personSlug); + if (!person) return []; + return this.#views(votesOnAccount(this.#state, person).filter((r) => isHumanEvaluator(r.evaluator))); } latestHumanVote(personSlug: string): VoteView | null { return this.humanVotes(personSlug)[0] ?? null; } + /** Votes cast on an earlier account under this slug, newest first. */ + earlierVotes(personSlug: string): VoteView[] { + const person = this.#person(personSlug); + if (!person) return []; + return this.#views(votesOnEarlierAccounts(this.#state, person).filter((r) => isHumanEvaluator(r.evaluator))); + } + + #person(slug: string): Person | undefined { + const id = this.#state.personIdBySlug.get(slug); + return id ? this.#state.people.get(id) : undefined; + } + + #views(records: readonly PersonEvaluation[]): VoteView[] { + return records.map((r) => this.#voteView(r)).sort((a, b) => b.evaluatedAt.localeCompare(a.evaluatedAt)); + } + async listMembers(opts: MemberListOptions): Promise { const sort = parseSort(opts.sort); if (!sort) return { error: 'invalid_sort' }; @@ -590,7 +604,7 @@ export class ModerationWriteService { throw new ApiValidationError('Reasoning is too long', { reasoning: 'max 1000 characters' }); } - const previousLatest = latestHumanVerdict(this.#state, slug); + const previousLatest = latestHumanVerdict(this.#state, existing); const now = nowIso(); const vote: PersonEvaluation = PersonEvaluationSchema.parse({ personSlug: slug, @@ -620,13 +634,10 @@ export class ModerationWriteService { } } -function latestHumanVerdict(state: InMemoryState, personSlug: string): PersonEvaluation['verdict'] | null { - const keys = state.evaluationsByPerson.get(personSlug); - if (!keys) return null; +function latestHumanVerdict(state: InMemoryState, person: Person): PersonEvaluation['verdict'] | null { let latest: PersonEvaluation | null = null; - for (const key of keys) { - const rec = state.personEvaluations.get(key); - if (!rec || !isHumanEvaluator(rec.evaluator)) continue; + for (const rec of votesOnAccount(state, person)) { + if (!isHumanEvaluator(rec.evaluator)) continue; if (!latest || rec.evaluatedAt > latest.evaluatedAt) latest = rec; } return latest?.verdict ?? null; diff --git a/apps/api/src/services/spam-purge.ts b/apps/api/src/services/spam-purge.ts index 94e3ac5..99aefcb 100644 --- a/apps/api/src/services/spam-purge.ts +++ b/apps/api/src/services/spam-purge.ts @@ -13,6 +13,7 @@ import { StateApply } from '../store/state-apply.js'; import type { Store } from '../store/store.js'; import type { SessionMetadataStore } from '../auth/session-metadata.js'; import { purgePeople } from './person-purge.js'; +import { votesOnAccount } from './votes.js'; /** How long a spam vote stands before the sweep acts on it. */ export const SPAM_PURGE_GRACE_MS = 3 * 24 * 60 * 60 * 1000; @@ -21,16 +22,15 @@ export const SPAM_PURGE_INTERVAL_MS = 24 * 60 * 60 * 1000; /** * When the sweep will purge `person`, or null if it won't: every human vote - * on them is `spam`, they are still deactivated and not staff, and the date is - * three days after the newest vote. + * on their account is `spam`, they are still deactivated and not staff, and + * the date is three days after the newest vote. Votes cast on an earlier + * account under the same slug are ignored (./votes.ts). */ export function spamPurgeDueAt(state: InMemoryState, person: Person): Date | null { if (!person.deletedAt || person.accountLevel !== 'user') return null; - const votes = [...(state.evaluationsByPerson.get(person.slug) ?? [])] - .map((key) => state.personEvaluations.get(key)) - .filter((e) => e !== undefined && isHumanEvaluator(e.evaluator)); - if (votes.length === 0 || votes.some((v) => v!.verdict !== 'spam')) return null; - const newest = Math.max(...votes.map((v) => Date.parse(v!.evaluatedAt))); + const votes = votesOnAccount(state, person).filter((e) => isHumanEvaluator(e.evaluator)); + if (votes.length === 0 || votes.some((v) => v.verdict !== 'spam')) return null; + const newest = Math.max(...votes.map((v) => Date.parse(v.evaluatedAt))); return new Date(newest + SPAM_PURGE_GRACE_MS); } diff --git a/apps/api/src/services/votes.ts b/apps/api/src/services/votes.ts new file mode 100644 index 0000000..39b86a3 --- /dev/null +++ b/apps/api/src/services/votes.ts @@ -0,0 +1,41 @@ +/** + * Which `person-evaluations` records belong to which account — + * specs/api/moderation.md → "Votes belong to one account". + * + * Evaluations are keyed by slug and outlive a purge (they are the record of + * why). A later sign-up can get the same slug, so a vote dated before the + * person's `createdAt` was cast on an earlier account under that slug and + * counts for nothing on this one: not in the roster's vote fields, not in the + * reversal rule, not in the scheduled purge. + */ +import type { Person, PersonEvaluation } from '@cfp/shared/schemas'; + +import type { InMemoryState } from '../store/memory/state.js'; + +/** Whether `vote` was cast on this account (at or after its `createdAt`). */ +export function isOnAccount(vote: Pick, createdAt: string): boolean { + return Date.parse(vote.evaluatedAt) >= Date.parse(createdAt); +} + +/** Every evaluation on record under `slug`, current account or not. */ +export function evaluationsForSlug(state: InMemoryState, slug: string): PersonEvaluation[] { + const out: PersonEvaluation[] = []; + for (const key of state.evaluationsByPerson.get(slug) ?? []) { + const rec = state.personEvaluations.get(key); + if (rec) out.push(rec); + } + return out; +} + +/** The evaluations cast on this person's own account. */ +export function votesOnAccount(state: InMemoryState, person: Pick): PersonEvaluation[] { + return evaluationsForSlug(state, person.slug).filter((v) => isOnAccount(v, person.createdAt)); +} + +/** Evaluations under this slug cast on an earlier account (before `createdAt`). */ +export function votesOnEarlierAccounts( + state: InMemoryState, + person: Pick, +): PersonEvaluation[] { + return evaluationsForSlug(state, person.slug).filter((v) => !isOnAccount(v, person.createdAt)); +} diff --git a/apps/api/tests/prune-spam.test.ts b/apps/api/tests/prune-spam.test.ts index f53bd56..99f95d5 100644 --- a/apps/api/tests/prune-spam.test.ts +++ b/apps/api/tests/prune-spam.test.ts @@ -202,6 +202,22 @@ describe('prune-spam', () => { expect(await git(data.path, 'rev-parse', 'published')).toBe(summary.commitHash); }); + it("ignores a site vote cast on an earlier account under the same slug", async () => { + // `reborn` signed up again (LATER) after an earlier account under the slug + // drew a staff spam vote (NOW); that vote is not this account's. + const { data, evaluationsPath } = await setup({ + people: [{ ...person(1, 'reborn'), createdAt: LATER, updatedAt: LATER }, person(2, 'voted')], + machineEvals: [], + humanVotes: [ + { personSlug: 'reborn', evaluator: 'human-admin', verdict: 'spam', confidence: 1, flags: [], evaluatedAt: NOW }, + { personSlug: 'voted', evaluator: 'human-admin', verdict: 'spam', confidence: 1, flags: [], evaluatedAt: LATER }, + ], + }); + const summary = await pruneSpam(args(data, evaluationsPath)); + expect(summary.prunedPeople).toBe(1); + expect(await slugsOn(data.path, 'published', 'people')).toEqual(['reborn']); + }); + it('creates the local branch from origin/ on a fresh clone', async () => { const { data, evaluationsPath } = await setup({ people: [person(1, 'spammer'), person(2, 'kept')], diff --git a/apps/api/tests/votes-one-account.test.ts b/apps/api/tests/votes-one-account.test.ts new file mode 100644 index 0000000..5fbadf4 --- /dev/null +++ b/apps/api/tests/votes-one-account.test.ts @@ -0,0 +1,131 @@ +/** + * Votes belong to one account — specs/api/moderation.md → "Votes belong to + * one account". A person-evaluation whose `evaluatedAt` is earlier than the + * person's `createdAt` was cast on an earlier account under the same slug + * (purged since) and counts for nothing on the new one: not the roster's + * latest vote, not the reversal rule, not the scheduled purge. The detail + * response lists it apart, as `earlierVotes`. + */ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { FastifyInstance } from 'fastify'; + +import { buildApp } from '../src/app.js'; +import { mintSessionFor } from '../src/auth/issue.js'; +import { queuedForSpamPurge, spamPurgeDueAt } from '../src/services/spam-purge.js'; +import { createFullDataRepo, createPrivateStorageDir } from './helpers/test-full-repo.js'; +import { seedRawToml } from './helpers/seed-fixtures.js'; + +const JWT_KEY = 'test-jwt-signing-key-at-least-32-chars!!'; +const STAFF_ID = '01951a3c-0000-7000-8000-0000000001a1'; +const REBORN_ID = '01951a3c-0000-7000-8000-0000000001b1'; + +/** The earlier account's staff vote, cast before `reborn` signed up again. */ +const EARLIER_VOTE_AT = '2026-10-01T12:00:00Z'; +const REBORN_CREATED_AT = '2026-10-02T09:00:00Z'; + +describe('votes belong to one account', () => { + let dataRepo: { path: string; cleanup: () => Promise }; + let privateStore: { path: string; cleanup: () => Promise }; + let app: FastifyInstance; + let staffCookie: string; + + beforeAll(async () => { + dataRepo = await createFullDataRepo(); + privateStore = await createPrivateStorageDir(); + const person = (slug: string, id: string, level: string, createdAt: string, deletedAt?: string) => + [ + `id = "${id}"`, + `slug = "${slug}"`, + `fullName = "Test ${slug}"`, + `accountLevel = "${level}"`, + deletedAt ? `deletedAt = "${deletedAt}"` : '', + `createdAt = "${createdAt}"`, + `updatedAt = "${createdAt}"`, + ].filter(Boolean).join('\n'); + await seedRawToml(dataRepo.path, 'people/staffer.toml', person('staffer', STAFF_ID, 'staff', '2026-01-01T00:00:00Z'), 'seed staffer'); + // Deactivated by themselves, long after signing up again. + await seedRawToml( + dataRepo.path, + 'people/reborn.toml', + person('reborn', REBORN_ID, 'user', REBORN_CREATED_AT, '2026-10-03T00:00:00Z'), + 'seed reborn', + ); + await seedRawToml( + dataRepo.path, + 'person-evaluations/reborn/human-staffer.toml', + [ + `personSlug = "reborn"`, + `evaluator = "human-staffer"`, + `verdict = "spam"`, + `confidence = 1`, + `flags = ["manual-override"]`, + `reasoning = "earlier account: promo bio"`, + `evaluatedAt = "${EARLIER_VOTE_AT}"`, + ].join('\n'), + 'seed earlier vote', + ); + + app = await buildApp({ + serverOptions: { logger: false }, + overrideEnv: { + CFP_DATA_REPO_PATH: dataRepo.path, + STORAGE_BACKEND: 'filesystem', + CFP_PRIVATE_STORAGE_PATH: privateStore.path, + CFP_JWT_SIGNING_KEY: JWT_KEY, + NODE_ENV: 'test', + }, + }); + staffCookie = (await mintSessionFor(STAFF_ID, 'staff', JWT_KEY)).accessToken; + }, 60_000); + + afterAll(async () => { + await app.close(); + await dataRepo.cleanup(); + await privateStore.cleanup(); + }); + + const staff = () => ({ cookies: { cfp_session: staffCookie } }); + + it('the roster shows no latest vote and counts the member as unvoted', async () => { + const res = await app.inject({ method: 'GET', url: '/api/admin/members?vote=none', ...staff() }); + const row = res.json<{ data: Array<{ slug: string; latestVote: unknown; purgeAfter: unknown }> }>().data.find( + (r) => r.slug === 'reborn', + ); + expect(row).toBeDefined(); + expect(row!.latestVote).toBeNull(); + expect(row!.purgeAfter).toBeNull(); + + const spam = await app.inject({ method: 'GET', url: '/api/admin/members?vote=spam', ...staff() }); + expect(spam.json<{ data: Array<{ slug: string }> }>().data.map((r) => r.slug)).not.toContain('reborn'); + }); + + it('the detail lists the vote under earlierVotes, not votes', async () => { + const res = await app.inject({ method: 'GET', url: '/api/admin/members/reborn', ...staff() }); + expect(res.statusCode).toBe(200); + const d = res.json<{ data: { votes: unknown[]; earlierVotes: Array<{ verdict: string; reasoning: string; evaluatedAt: string }> } }>().data; + expect(d.votes).toEqual([]); + expect(d.earlierVotes).toHaveLength(1); + expect(d.earlierVotes[0]).toMatchObject({ verdict: 'spam', reasoning: 'earlier account: promo bio' }); + }); + + it('is never queued for the scheduled purge, though deactivated', () => { + const state = app.inMemoryState; + const reborn = state.people.get(REBORN_ID)!; + expect(reborn.deletedAt).toBeTruthy(); + expect(spamPurgeDueAt(state, reborn)).toBeNull(); + expect(queuedForSpamPurge(state, new Date('2027-01-01T00:00:00Z')).map((p) => p.slug)).not.toContain('reborn'); + }); + + it('a Not spam vote does not undo their own deactivation (no spam vote of this account to reverse)', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/admin/members/reborn/vote', + payload: { verdict: 'legit' }, + ...staff(), + }); + expect(res.statusCode).toBe(200); + const body = res.json<{ data: { person: { deletedAt: string | null }; latestVote: { verdict: string } } }>().data; + expect(body.person.deletedAt).not.toBeNull(); + expect(body.latestVote.verdict).toBe('legit'); + }); +}); From 71bf951824e93b7537025fcc1cd479cd88fb1331 Mon Sep 17 00:00:00 2001 From: GrittyBot <30755786+codeforphilly-bot@users.noreply.github.com> Date: Thu, 12 Nov 2026 19:18:03 +0000 Subject: [PATCH 06/23] feat(shared): agent-grants and spam-purges schemas, contracts and sheets Schemas, evaluator kinds, sheet configs and contracts for agent grants and the spam purge log, plus the private store for token hashes. Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_01S8dthiZS2vwh674eh2DDQs --- apps/api/gitsheets/agent-grants.toml | 10 ++ .../codeforphilly.org/agent-grants/v1.toml | 46 +++++++ .../codeforphilly.org/spam-purges/v1.toml | 87 ++++++++++++++ apps/api/gitsheets/spam-purges.toml | 11 ++ apps/api/scripts/scrub-data.ts | 24 +++- apps/api/src/routes/attachments.ts | 6 +- apps/api/src/services/reference-cascade.ts | 6 + apps/api/src/store/memory/incremental.ts | 6 + apps/api/src/store/memory/loader.ts | 7 ++ apps/api/src/store/memory/state.ts | 51 ++++++++ apps/api/src/store/private/base.ts | 61 +++++++++- apps/api/src/store/private/interface.ts | 13 +- apps/api/src/store/public.ts | 4 + apps/api/src/store/state-apply.ts | 26 ++++ apps/api/src/store/store.ts | 11 +- apps/api/tests/agent-grant-secrets.test.ts | 69 +++++++++++ apps/api/tests/reload-swap.test.ts | 23 ++++ apps/api/tests/rename-cascade.test.ts | 11 ++ apps/api/tests/scrub-data.test.ts | 17 +++ .../codeforphilly.org/agent-grants/v1.json | 62 ++++++++++ .../codeforphilly.org/spam-purges/v1.json | 112 ++++++++++++++++++ packages/shared/src/contracts.ts | 4 + packages/shared/src/schemas/agent-grant.ts | 51 ++++++++ packages/shared/src/schemas/index.ts | 19 ++- .../shared/src/schemas/person-evaluation.ts | 32 ++++- packages/shared/src/schemas/spam-purge.ts | 54 +++++++++ packages/shared/tests/contracts.test.ts | 3 + 27 files changed, 815 insertions(+), 11 deletions(-) create mode 100644 apps/api/gitsheets/agent-grants.toml create mode 100644 apps/api/gitsheets/contracts/codeforphilly.org/agent-grants/v1.toml create mode 100644 apps/api/gitsheets/contracts/codeforphilly.org/spam-purges/v1.toml create mode 100644 apps/api/gitsheets/spam-purges.toml create mode 100644 apps/api/tests/agent-grant-secrets.test.ts create mode 100644 contracts/codeforphilly.org/agent-grants/v1.json create mode 100644 contracts/codeforphilly.org/spam-purges/v1.json create mode 100644 packages/shared/src/schemas/agent-grant.ts create mode 100644 packages/shared/src/schemas/spam-purge.ts diff --git a/apps/api/gitsheets/agent-grants.toml b/apps/api/gitsheets/agent-grants.toml new file mode 100644 index 0000000..aaebbb5 --- /dev/null +++ b/apps/api/gitsheets/agent-grants.toml @@ -0,0 +1,10 @@ +[gitsheet] +path = "${{ personSlug }}/${{ id }}" +root = "agent-grants" + +# Scoped API grants for agent accounts — automated moderators an +# administrator lets act on the site (codeforphilly-ng specs/api/agents.md). +# Public on purpose: which programs may act, with which scopes, granted by +# whom, until when. The token and its hash are never here; the hash lives in +# the site's private store. The only writer is the site. +implements = ["codeforphilly.org/agent-grants/v1"] diff --git a/apps/api/gitsheets/contracts/codeforphilly.org/agent-grants/v1.toml b/apps/api/gitsheets/contracts/codeforphilly.org/agent-grants/v1.toml new file mode 100644 index 0000000..6cc58e3 --- /dev/null +++ b/apps/api/gitsheets/contracts/codeforphilly.org/agent-grants/v1.toml @@ -0,0 +1,46 @@ +"$id" = "https://codeforphilly.org/agent-grants/v1" +"$schema" = "http://json-schema.org/draft-07/schema#" +required = ["createdAt", "createdBySlug", "expiresAt", "id", "label", "personSlug", "scopes"] +title = "Agent grant" +type = "object" + +[properties.createdAt] +format = "date-time" +pattern = '^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$' +type = "string" + +[properties.createdBySlug] +minLength = 1 +type = "string" + +[properties.expiresAt] +format = "date-time" +pattern = '^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$' +type = "string" + +[properties.id] +format = "uuid" +pattern = "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" +type = "string" + +[properties.label] +maxLength = 80 +minLength = 1 +type = "string" + +[properties.personSlug] +minLength = 1 +type = "string" + +[properties.revokedAt] +format = "date-time" +pattern = '^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$' +type = "string" + +[properties.scopes] +minItems = 1 +type = "array" + +[properties.scopes.items] +enum = ["moderation:read", "moderation:vote", "moderation:purge"] +type = "string" diff --git a/apps/api/gitsheets/contracts/codeforphilly.org/spam-purges/v1.toml b/apps/api/gitsheets/contracts/codeforphilly.org/spam-purges/v1.toml new file mode 100644 index 0000000..b64641c --- /dev/null +++ b/apps/api/gitsheets/contracts/codeforphilly.org/spam-purges/v1.toml @@ -0,0 +1,87 @@ +"$id" = "https://codeforphilly.org/spam-purges/v1" +"$schema" = "http://json-schema.org/draft-07/schema#" +required = ["beforeCommit", "blocksSignIn", "by", "evidence", "flags", "id", "personId", "personSlug", "purgedAt"] +title = "Spam purge" +type = "object" + +[properties.beforeCommit] +minLength = 1 +type = "string" + +[properties.blocksSignIn] +type = "boolean" + +[properties.by] +minLength = 1 +type = "string" + +[properties.evidence] +type = "array" + +[properties.evidence.items] +required = ["path", "record"] +type = "object" + +[properties.evidence.items.properties.field] +minLength = 1 +type = "string" + +[properties.evidence.items.properties.path] +minLength = 1 +type = "string" + +[properties.evidence.items.properties.record] +enum = ["person", "project", "project-update", "project-buzz", "help-wanted-role"] +type = "string" + +[properties.flags] +type = "array" + +[properties.flags.items] +type = "string" + +[properties.githubUserId] +minimum = 1 +type = "integer" + +[properties.grantId] +format = "uuid" +pattern = "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" +type = "string" + +[properties.id] +format = "uuid" +pattern = "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" +type = "string" + +[properties.personId] +format = "uuid" +pattern = "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" +type = "string" + +[properties.personSlug] +minLength = 1 +type = "string" + +[properties.purgedAt] +format = "date-time" +pattern = '^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$' +type = "string" + +[properties.reason] +maxLength = 1000 +minLength = 1 +type = "string" + +[properties.unblockReason] +maxLength = 1000 +type = "string" + +[properties.unblockedAt] +format = "date-time" +pattern = '^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$' +type = "string" + +[properties.unblockedBySlug] +minLength = 1 +type = "string" diff --git a/apps/api/gitsheets/spam-purges.toml b/apps/api/gitsheets/spam-purges.toml new file mode 100644 index 0000000..32add7b --- /dev/null +++ b/apps/api/gitsheets/spam-purges.toml @@ -0,0 +1,11 @@ +[gitsheet] +path = "${{ personSlug }}/${{ id }}" +root = "spam-purges" + +# One record per person purged as spam — by an administrator who marked the +# purge as spam, by an agent purge, or by the scheduled spam purge +# (codeforphilly-ng specs/data-model.md#spampurge). It is the purge log and +# the sign-up block that keeps the same GitHub account from coming straight +# back until staff unblock it. `personSlug` is the purged slug, not a +# reference: the person is gone and the slug may be taken again. +implements = ["codeforphilly.org/spam-purges/v1"] diff --git a/apps/api/scripts/scrub-data.ts b/apps/api/scripts/scrub-data.ts index f67990b..6cd2b6e 100644 --- a/apps/api/scripts/scrub-data.ts +++ b/apps/api/scripts/scrub-data.ts @@ -148,15 +148,31 @@ export function rewritePersonReferences( slugMap: Map, ): Record { let out = record; - for (const ref of SHEET_REFERENCES) { - if (ref.sheet !== sheetName || ref.target.sheet !== 'people' || ref.target.field !== 'slug') continue; - const value = out[ref.field]; + const fields = [ + ...SHEET_REFERENCES.filter( + (ref) => ref.sheet === sheetName && ref.target.sheet === 'people' && ref.target.field === 'slug', + ).map((ref) => ref.field), + ...(PERSON_SLUG_FIELDS[sheetName] ?? []), + ]; + for (const field of fields) { + const value = out[field]; const pseudo = typeof value === 'string' ? slugMap.get(value) : undefined; - if (pseudo !== undefined) out = { ...out, [ref.field]: pseudo }; + if (pseudo !== undefined) out = { ...out, [field]: pseudo }; } return out; } +/** + * Fields that name a person by slug without being a declared reference (a + * rename does not rewrite them): who minted an agent grant, who unblocked a + * spam purge, and the purged slug itself, which a later sign-up may hold. + * The snapshot pseudonymizes them like references so no real slug leaks. + */ +const PERSON_SLUG_FIELDS: Readonly> = { + 'agent-grants': ['createdBySlug'], + 'spam-purges': ['personSlug', 'unblockedBySlug'], +}; + /** * Replace `@mention` occurrences of real person slugs in markdown-like text. */ diff --git a/apps/api/src/routes/attachments.ts b/apps/api/src/routes/attachments.ts index 18ac169..4564b53 100644 --- a/apps/api/src/routes/attachments.ts +++ b/apps/api/src/routes/attachments.ts @@ -16,7 +16,7 @@ import type { FastifyInstance, FastifyRequest } from 'fastify'; import { ApiNotFoundError, ApiValidationError } from '../lib/errors.js'; -import { PUBLIC_SHEET_SCHEMAS } from '../store/public.js'; +import { PUBLIC_SHEET_SCHEMAS, type PublicStore } from '../store/public.js'; type SheetName = keyof typeof PUBLIC_SHEET_SCHEMAS; @@ -108,7 +108,9 @@ export async function attachmentRoutes(fastify: FastifyInstance): Promise const name = segments[segments.length - 1] as string; const recordPath = segments.slice(1, -1).join('/'); - const sheet = fastify.store.public[sheetName]; + // Every sheet's getAttachment has the same shape; indexing the store by + // a union of sheet names is too complex for tsc to type directly. + const sheet = fastify.store.public[sheetName as 'people'] as Pick; const blob = await sheet.getAttachment(recordPath, name); if (!blob) throw new ApiNotFoundError(`attachment not found: ${key}`); diff --git a/apps/api/src/services/reference-cascade.ts b/apps/api/src/services/reference-cascade.ts index 338b438..7453fe0 100644 --- a/apps/api/src/services/reference-cascade.ts +++ b/apps/api/src/services/reference-cascade.ts @@ -12,6 +12,7 @@ */ import { SHEET_REFERENCES, type ContractSheet, type SheetReference } from '@cfp/shared'; import type { + AgentGrant, EventRsvp, HelpWantedInterestExpression, HelpWantedRole, @@ -77,6 +78,11 @@ const REFERENCING_SHEETS: Partial> remove: (a, r) => a.removeEventRsvp(r), upsert: (a, r) => a.upsertEventRsvp(r), }), + 'agent-grants': sheet({ + records: (s) => s.agentGrants.values(), + remove: (a, r) => a.removeAgentGrant(r), + upsert: (a, r) => a.upsertAgentGrant(r), + }), 'person-evaluations': sheet({ records: (s) => s.personEvaluations.values(), remove: (a, r) => a.removePersonEvaluation(r), diff --git a/apps/api/src/store/memory/incremental.ts b/apps/api/src/store/memory/incremental.ts index 43303ea..008aff0 100644 --- a/apps/api/src/store/memory/incremental.ts +++ b/apps/api/src/store/memory/incremental.ts @@ -12,6 +12,7 @@ import { RECORD_PATH_KEY, RECORD_SHEET_KEY, type DiffChange } from 'gitsheets'; import type { FtsEngine } from '../fts.js'; import type { PublicStore } from '../public.js'; import { + indexAgentGrant, indexBlogPost, indexEvent, indexEventRsvp, @@ -25,8 +26,10 @@ import { indexProjectBuzz, indexProjectUpdate, indexSlugHistory, + indexSpamPurge, indexTag, indexTagAssignment, + unindexAgentGrant, unindexBlogPost, unindexEvent, unindexEventRsvp, @@ -40,6 +43,7 @@ import { unindexProjectBuzz, unindexProjectUpdate, unindexSlugHistory, + unindexSpamPurge, unindexTag, unindexTagAssignment, type InMemoryState, @@ -116,6 +120,8 @@ const HANDLERS = [ handler({ sheet: 'event-series', unindex: unindexEventSeries, index: indexEventSeries }), handler({ sheet: 'events', unindex: unindexEvent, index: indexEvent }), handler({ sheet: 'event-rsvps', unindex: unindexEventRsvp, index: indexEventRsvp }), + handler({ sheet: 'agent-grants', unindex: unindexAgentGrant, index: indexAgentGrant }), + handler({ sheet: 'spam-purges', unindex: unindexSpamPurge, index: indexSpamPurge }), ] as const; interface CollectedChanges { diff --git a/apps/api/src/store/memory/loader.ts b/apps/api/src/store/memory/loader.ts index 6cfdc80..60383aa 100644 --- a/apps/api/src/store/memory/loader.ts +++ b/apps/api/src/store/memory/loader.ts @@ -7,6 +7,7 @@ import type { PublicStore } from '../public.js'; import { createEmptyState, + indexAgentGrant, indexBlogPost, indexEvent, indexEventRsvp, @@ -20,6 +21,7 @@ import { indexProjectBuzz, indexProjectUpdate, indexSlugHistory, + indexSpamPurge, indexTag, indexTagAssignment, type InMemoryState, @@ -54,6 +56,9 @@ export async function loadInMemoryState(publicStore: PublicStore): Promise; /** seriesId → Set — the series page. */ eventsBySeries: Map>; + + /** Agent grants by id (specs/data-model.md#agentgrant). */ + agentGrants: Map; + /** agent personSlug → Set. A person with any grant is an agent account. */ + agentGrantsByPerson: Map>; + + /** Spam-purge records by id (specs/data-model.md#spampurge). */ + spamPurges: Map; + /** githubUserId → Set — the GitHub callback's sign-up block check. */ + spamPurgesByGithubUserId: Map>; + /** purged personSlug → Set — `slug-purged-before`, `earlierPurges`. */ + spamPurgesBySlug: Map>; } /** @@ -246,6 +260,11 @@ export function createEmptyState(): InMemoryState { rsvpIdByEventAndPerson: new Map(), eventSeriesIdBySlug: new Map(), eventsBySeries: new Map(), + agentGrants: new Map(), + agentGrantsByPerson: new Map(), + spamPurges: new Map(), + spamPurgesByGithubUserId: new Map(), + spamPurgesBySlug: new Map(), }; } @@ -427,6 +446,25 @@ export function indexPersonEvaluation(state: InMemoryState, record: PersonEvalua set.add(key); } +/** Add or replace one agent grant and update its person index. */ +export function indexAgentGrant(state: InMemoryState, grant: AgentGrant): void { + const old = state.agentGrants.get(grant.id); + if (old) unindexAgentGrant(state, old); + state.agentGrants.set(grant.id, grant); + addToSet(state.agentGrantsByPerson, grant.personSlug, grant.id); +} + +/** Add or replace one spam-purge record and update its indices. */ +export function indexSpamPurge(state: InMemoryState, record: SpamPurge): void { + const old = state.spamPurges.get(record.id); + if (old) unindexSpamPurge(state, old); + state.spamPurges.set(record.id, record); + addToSet(state.spamPurgesBySlug, record.personSlug, record.id); + if (typeof record.githubUserId === 'number') { + addToSet(state.spamPurgesByGithubUserId, record.githubUserId, record.id); + } +} + // --------------------------------------------------------------------------- // Unindexing — the exact inverse of each index* above, so applying a commit // diff (unindex the old version, index the new one) leaves the state equal to @@ -539,3 +577,16 @@ export function unindexPersonEvaluation(state: InMemoryState, record: PersonEval state.personEvaluations.delete(key); removeFromSet(state.evaluationsByPerson, record.personSlug, key); } + +export function unindexAgentGrant(state: InMemoryState, grant: AgentGrant): void { + state.agentGrants.delete(grant.id); + removeFromSet(state.agentGrantsByPerson, grant.personSlug, grant.id); +} + +export function unindexSpamPurge(state: InMemoryState, record: SpamPurge): void { + state.spamPurges.delete(record.id); + removeFromSet(state.spamPurgesBySlug, record.personSlug, record.id); + if (typeof record.githubUserId === 'number') { + removeFromSet(state.spamPurgesByGithubUserId, record.githubUserId, record.id); + } +} diff --git a/apps/api/src/store/private/base.ts b/apps/api/src/store/private/base.ts index a210d73..5b89e83 100644 --- a/apps/api/src/store/private/base.ts +++ b/apps/api/src/store/private/base.ts @@ -1,11 +1,13 @@ import { AccountClaimRequestSchema, + AgentGrantSecretSchema, LegacyPasswordCredentialSchema, PasswordTokenSchema, PrivateProfileSchema, } from '@cfp/shared/schemas'; import type { AccountClaimRequest, + AgentGrantSecret, LegacyPasswordCredential, PasswordToken, PrivateProfile, @@ -23,6 +25,10 @@ export abstract class BasePrivateStore implements PrivateStore { protected legacyPasswords: Map = new Map(); protected passwordTokens: Map = new Map(); protected claimRequests: Map = new Map(); + /** Agent grant token hashes, keyed by grantId. */ + protected grantSecrets: Map = new Map(); + /** tokenHash → grantId, rebuilt with the indices. */ + protected grantIdByHash: Map = new Map(); readonly indices: PrivateIndices = { byEmail: new Map(), @@ -41,6 +47,7 @@ export abstract class BasePrivateStore implements PrivateStore { this.loadLegacyPasswords(), this.loadPasswordTokens(), this.loadClaimRequests(), + this.loadGrantSecrets(), ]); this.rebuildIndices(); } @@ -69,9 +76,16 @@ export abstract class BasePrivateStore implements PrivateStore { this.passwordTokens = parseJsonl(raw, PasswordTokenSchema, 'tokenHash'); } + private async loadGrantSecrets(): Promise { + const raw = await this.readRaw('agent-grant-secrets.jsonl'); + this.grantSecrets = parseJsonl(raw, AgentGrantSecretSchema, 'grantId'); + } + private rebuildIndices(): void { this.indices.byEmail.clear(); this.indices.byUnsubscribeToken.clear(); + this.grantIdByHash.clear(); + for (const secret of this.grantSecrets.values()) this.grantIdByHash.set(secret.tokenHash, secret.grantId); for (const profile of this.profiles.values()) { this.indices.byEmail.set(profile.email.toLowerCase(), profile.personId); @@ -161,6 +175,22 @@ export abstract class BasePrivateStore implements PrivateStore { return [...this.claimRequests.values()]; } + getGrantSecretByHash(tokenHash: string): AgentGrantSecret | null { + const grantId = this.grantIdByHash.get(tokenHash); + return grantId ? (this.grantSecrets.get(grantId) ?? null) : null; + } + + getGrantSecret(grantId: string): AgentGrantSecret | null { + return this.grantSecrets.get(grantId) ?? null; + } + + async putGrantSecret(secret: AgentGrantSecret): Promise { + const parsed = AgentGrantSecretSchema.parse(secret); + this.grantSecrets.set(parsed.grantId, parsed); + this.grantIdByHash.set(parsed.tokenHash, parsed.grantId); + await this.flushGrantSecrets(); + } + async readBlob(key: string): Promise { return this.readRaw(key); } @@ -175,6 +205,7 @@ export abstract class BasePrivateStore implements PrivateStore { const legacySnapshot = new Map(this.legacyPasswords); const claimRequestsSnapshot = new Map(this.claimRequests); const passwordTokensSnapshot = new Map(this.passwordTokens); + const grantSecretsSnapshot = new Map(this.grantSecrets); // Staged mutations applied only in-memory during the handler const stagedProfilePuts: Map = new Map(); @@ -183,6 +214,7 @@ export abstract class BasePrivateStore implements PrivateStore { const stagedLegacyDeletes: Set = new Set(); const stagedClaimRequestPuts: Map = new Map(); const stagedPersonPurges: Set = new Set(); + const stagedGrantSecretPuts: Map = new Map(); const tx: PrivateStoreTx = { putProfile: (profile) => { @@ -207,10 +239,17 @@ export abstract class BasePrivateStore implements PrivateStore { const parsed = AccountClaimRequestSchema.parse(req); stagedClaimRequestPuts.set(parsed.id, parsed); }, + putGrantSecret: (secret) => { + const parsed = AgentGrantSecretSchema.parse(secret); + stagedGrantSecretPuts.set(parsed.grantId, parsed); + }, deletePersonData: (personId) => { stagedPersonPurges.add(personId); stagedProfilePuts.delete(personId); stagedLegacyPuts.delete(personId); + for (const [id, secret] of stagedGrantSecretPuts) { + if (secret.personId === personId) stagedGrantSecretPuts.delete(id); + } }, }; @@ -223,6 +262,7 @@ export abstract class BasePrivateStore implements PrivateStore { this.legacyPasswords = legacySnapshot; this.claimRequests = claimRequestsSnapshot; this.passwordTokens = passwordTokensSnapshot; + this.grantSecrets = grantSecretsSnapshot; this.rebuildIndices(); throw err; } @@ -243,6 +283,10 @@ export abstract class BasePrivateStore implements PrivateStore { for (const [id, req] of stagedClaimRequestPuts) { this.claimRequests.set(id, req); } + for (const [id, secret] of stagedGrantSecretPuts) { + this.grantSecrets.set(id, secret); + } + let grantSecretsPurged = false; for (const personId of stagedPersonPurges) { this.profiles.delete(personId); this.legacyPasswords.delete(personId); @@ -252,6 +296,12 @@ export abstract class BasePrivateStore implements PrivateStore { for (const [hash, token] of this.passwordTokens) { if (token.personId === personId) this.passwordTokens.delete(hash); } + for (const [id, secret] of this.grantSecrets) { + if (secret.personId === personId) { + this.grantSecrets.delete(id); + grantSecretsPurged = true; + } + } } this.rebuildIndices(); @@ -270,6 +320,9 @@ export abstract class BasePrivateStore implements PrivateStore { if (purging) { flushOps.push(this.flushPasswordTokens()); } + if (grantSecretsPurged || stagedGrantSecretPuts.size > 0) { + flushOps.push(this.flushGrantSecrets()); + } try { await Promise.all(flushOps); @@ -280,6 +333,7 @@ export abstract class BasePrivateStore implements PrivateStore { this.legacyPasswords = legacySnapshot; this.claimRequests = claimRequestsSnapshot; this.passwordTokens = passwordTokensSnapshot; + this.grantSecrets = grantSecretsSnapshot; this.rebuildIndices(); throw new PrivateStoreError( 'private_store_unavailable', @@ -306,6 +360,11 @@ export abstract class BasePrivateStore implements PrivateStore { await this.writeRaw('account-claim-requests.jsonl', lines ? lines + '\n' : ''); } + protected async flushGrantSecrets(): Promise { + const lines = [...this.grantSecrets.values()].map((t) => JSON.stringify(t)).join('\n'); + await this.writeRaw('agent-grant-secrets.jsonl', lines ? lines + '\n' : ''); + } + protected async flushPasswordTokens(): Promise { const lines = [...this.passwordTokens.values()].map((t) => JSON.stringify(t)).join('\n'); await this.writeRaw('password-tokens.jsonl', lines ? lines + '\n' : ''); @@ -315,7 +374,7 @@ export abstract class BasePrivateStore implements PrivateStore { function parseJsonl( raw: string | null, schema: { parse: (input: unknown) => T }, - keyField: 'personId' | 'id' | 'tokenHash' = 'personId', + keyField: 'personId' | 'id' | 'tokenHash' | 'grantId' = 'personId', ): Map { const map = new Map(); if (!raw) return map; diff --git a/apps/api/src/store/private/interface.ts b/apps/api/src/store/private/interface.ts index c3ca282..a71acd3 100644 --- a/apps/api/src/store/private/interface.ts +++ b/apps/api/src/store/private/interface.ts @@ -1,5 +1,6 @@ import type { AccountClaimRequest, + AgentGrantSecret, LegacyPasswordCredential, PasswordToken, PrivateProfile, @@ -26,9 +27,12 @@ export interface PrivateStoreTx { putLegacyPassword(cred: LegacyPasswordCredential): void; deleteLegacyPassword(personId: string): void; putClaimRequest(req: AccountClaimRequest): void; + /** Store the hash of a newly minted agent grant's token. */ + putGrantSecret(secret: AgentGrantSecret): void; /** * Delete everything the private store holds for a person: profile, legacy - * password, password-reset tokens, and account-claim requests naming them + * password, password-reset tokens, account-claim requests naming them, and + * (for an agent account) its grant token hashes * (specs/behaviors/person-lifecycle.md → Purge). */ deletePersonData(personId: string): void; @@ -72,6 +76,13 @@ export interface PrivateStore { listOpenClaimRequests(): Promise; listAllClaimRequests(): Promise; + // --- Agent grant secrets (agent-grant-secrets.jsonl) --- + /** The secret whose token hashes to `tokenHash` (a Map lookup). */ + getGrantSecretByHash(tokenHash: string): AgentGrantSecret | null; + getGrantSecret(grantId: string): AgentGrantSecret | null; + /** Replace one secret record outside a transaction (used for `lastUsedAt`). */ + putGrantSecret(secret: AgentGrantSecret): Promise; + /** * Run a handler with a transaction object. On success, flush updated * `.jsonl` files to the backend. On throw, discard; in-memory state diff --git a/apps/api/src/store/public.ts b/apps/api/src/store/public.ts index bc29715..81480cc 100644 --- a/apps/api/src/store/public.ts +++ b/apps/api/src/store/public.ts @@ -5,6 +5,7 @@ import { openRepo, openStore } from 'gitsheets'; import type { Repository, Store, StoreTx, ValidatorMap } from 'gitsheets'; import { buildContract, type ContractSheet } from '@cfp/shared'; import { + AgentGrantSchema, BlogPostSchema, EventRsvpSchema, EventSchema, @@ -19,6 +20,7 @@ import { ProjectUpdateSchema, RevocationSchema, SlugHistorySchema, + SpamPurgeSchema, TagAssignmentSchema, TagSchema, } from '@cfp/shared/schemas'; @@ -44,6 +46,8 @@ export const PUBLIC_SHEET_SCHEMAS = { events: EventSchema, 'event-rsvps': EventRsvpSchema, 'event-series': EventSeriesSchema, + 'agent-grants': AgentGrantSchema, + 'spam-purges': SpamPurgeSchema, } as const satisfies ValidatorMap; /** diff --git a/apps/api/src/store/state-apply.ts b/apps/api/src/store/state-apply.ts index 59d3465..2244568 100644 --- a/apps/api/src/store/state-apply.ts +++ b/apps/api/src/store/state-apply.ts @@ -8,6 +8,7 @@ * applied — in-memory state stays in sync with the on-disk gitsheets state. */ import type { + AgentGrant, BlogPost, Event, EventRsvp, @@ -21,12 +22,14 @@ import type { ProjectMembership, ProjectUpdate, SlugHistory, + SpamPurge, Tag, TagAssignment, } from '@cfp/shared/schemas'; import type { FtsEngine } from './fts.js'; import { invalidateFacets } from './memory/facets.js'; import { + indexAgentGrant, indexEvent, indexEventRsvp, indexEventSeries, @@ -39,8 +42,10 @@ import { indexProjectBuzz, indexProjectUpdate, indexSlugHistory, + indexSpamPurge, indexTag, indexTagAssignment, + unindexAgentGrant, unindexEventRsvp, unindexEventSeries, unindexHelpWantedInterest, @@ -49,6 +54,7 @@ import { unindexPersonEvaluation, unindexProjectBuzz, unindexProjectUpdate, + unindexSpamPurge, type InMemoryState, } from './memory/state.js'; @@ -325,6 +331,26 @@ export class StateApply { return this; } + upsertAgentGrant(grant: AgentGrant): this { + this.#ops.push((state) => indexAgentGrant(state, grant)); + return this; + } + + removeAgentGrant(grant: AgentGrant): this { + this.#ops.push((state) => unindexAgentGrant(state, grant)); + return this; + } + + upsertSpamPurge(record: SpamPurge): this { + this.#ops.push((state) => indexSpamPurge(state, record)); + return this; + } + + removeSpamPurge(record: SpamPurge): this { + this.#ops.push((state) => unindexSpamPurge(state, record)); + return this; + } + apply(state: InMemoryState, fts: FtsEngine): void { for (const op of this.#ops) { op(state, fts); diff --git a/apps/api/src/store/store.ts b/apps/api/src/store/store.ts index 4f72596..94aa03e 100644 --- a/apps/api/src/store/store.ts +++ b/apps/api/src/store/store.ts @@ -1,5 +1,10 @@ import type { TransactionOptions, TransactionResult } from 'gitsheets'; -import type { AccountClaimRequest, LegacyPasswordCredential, PrivateProfile } from '@cfp/shared/schemas'; +import type { + AccountClaimRequest, + AgentGrantSecret, + LegacyPasswordCredential, + PrivateProfile, +} from '@cfp/shared/schemas'; import type { PrivateStore, PrivateStoreTx } from './private/index.js'; import type { PublicStore, PublicStoreTx } from './public.js'; @@ -80,6 +85,7 @@ export class Store { const stagedLegacyPasswordDeletes: string[] = []; const stagedClaimRequestPuts: AccountClaimRequest[] = []; const stagedPersonPurges: string[] = []; + const stagedGrantSecretPuts: AgentGrantSecret[] = []; const privateTx: PrivateStoreTx = { putProfile: (profile) => { stagedPrivatePuts.push(profile); }, @@ -87,6 +93,7 @@ export class Store { putLegacyPassword: (cred) => { stagedLegacyPasswordPuts.push(cred); }, deleteLegacyPassword: (personId) => { stagedLegacyPasswordDeletes.push(personId); }, putClaimRequest: (req) => { stagedClaimRequestPuts.push(req); }, + putGrantSecret: (secret) => { stagedGrantSecretPuts.push(secret); }, deletePersonData: (personId) => { stagedPersonPurges.push(personId); }, }; @@ -96,6 +103,7 @@ export class Store { stagedLegacyPasswordPuts.length > 0 || stagedLegacyPasswordDeletes.length > 0 || stagedClaimRequestPuts.length > 0 || + stagedGrantSecretPuts.length > 0 || stagedPersonPurges.length > 0; const flushPrivate = async (): Promise => { @@ -106,6 +114,7 @@ export class Store { for (const cred of stagedLegacyPasswordPuts) tx.putLegacyPassword(cred); for (const id of stagedLegacyPasswordDeletes) tx.deleteLegacyPassword(id); for (const req of stagedClaimRequestPuts) tx.putClaimRequest(req); + for (const secret of stagedGrantSecretPuts) tx.putGrantSecret(secret); for (const id of stagedPersonPurges) tx.deletePersonData(id); }); }; diff --git a/apps/api/tests/agent-grant-secrets.test.ts b/apps/api/tests/agent-grant-secrets.test.ts new file mode 100644 index 0000000..18dd4de --- /dev/null +++ b/apps/api/tests/agent-grant-secrets.test.ts @@ -0,0 +1,69 @@ +/** + * The private half of an agent grant — specs/behaviors/private-storage.md → + * `agent-grant-secrets.jsonl`: only the token's SHA-256 is kept, looked up by + * hash, and a purge of the agent account deletes its lines. + */ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { FilesystemPrivateStore } from '../src/store/private/index.js'; +import { createPrivateStorageDir } from './helpers/test-full-repo.js'; + +const AGENT = '01951a3c-0000-7000-8000-0000000003a1'; +const OTHER = '01951a3c-0000-7000-8000-0000000003a2'; +const G1 = '01951a3c-0000-7000-8000-0000000003b1'; +const G2 = '01951a3c-0000-7000-8000-0000000003b2'; +const H1 = 'a'.repeat(64); +const H2 = 'b'.repeat(64); + +describe('agent grant secrets (private store)', () => { + let dir: { path: string; cleanup: () => Promise }; + + beforeAll(async () => { + dir = await createPrivateStorageDir(); + }); + afterAll(async () => { + await dir.cleanup(); + }); + + it('stores hashes in a transaction, finds them by hash, and survives a reload', async () => { + const store = new FilesystemPrivateStore({ CFP_PRIVATE_STORAGE_PATH: dir.path }); + await store.load(); + await store.transact(async (tx) => { + tx.putGrantSecret({ grantId: G1, personId: AGENT, tokenHash: H1 }); + tx.putGrantSecret({ grantId: G2, personId: OTHER, tokenHash: H2 }); + }); + expect(store.getGrantSecretByHash(H1)?.grantId).toBe(G1); + expect(store.getGrantSecretByHash('c'.repeat(64))).toBeNull(); + + const raw = await readFile(join(dir.path, 'agent-grant-secrets.jsonl'), 'utf8'); + expect(raw.trim().split('\n')).toHaveLength(2); + + const reloaded = new FilesystemPrivateStore({ CFP_PRIVATE_STORAGE_PATH: dir.path }); + await reloaded.load(); + expect(reloaded.getGrantSecretByHash(H2)?.personId).toBe(OTHER); + }); + + it('records lastUsedAt outside a transaction', async () => { + const store = new FilesystemPrivateStore({ CFP_PRIVATE_STORAGE_PATH: dir.path }); + await store.load(); + const secret = store.getGrantSecret(G1)!; + await store.putGrantSecret({ ...secret, lastUsedAt: '2026-11-12T03:10:00Z' }); + const reloaded = new FilesystemPrivateStore({ CFP_PRIVATE_STORAGE_PATH: dir.path }); + await reloaded.load(); + expect(reloaded.getGrantSecret(G1)?.lastUsedAt).toBe('2026-11-12T03:10:00Z'); + }); + + it("a person purge deletes that person's grant secrets and no one else's", async () => { + const store = new FilesystemPrivateStore({ CFP_PRIVATE_STORAGE_PATH: dir.path }); + await store.load(); + await store.transact(async (tx) => { + tx.deletePersonData(AGENT); + }); + expect(store.getGrantSecretByHash(H1)).toBeNull(); + expect(store.getGrantSecretByHash(H2)?.grantId).toBe(G2); + const raw = await readFile(join(dir.path, 'agent-grant-secrets.jsonl'), 'utf8'); + expect(raw).not.toContain(H1); + }); +}); diff --git a/apps/api/tests/reload-swap.test.ts b/apps/api/tests/reload-swap.test.ts index 3accb4f..192355d 100644 --- a/apps/api/tests/reload-swap.test.ts +++ b/apps/api/tests/reload-swap.test.ts @@ -34,6 +34,8 @@ import { indexEvent, indexEventRsvp, indexEventSeries, + indexAgentGrant, + indexSpamPurge, indexHelpWantedInterest, indexPersonEvaluation, indexHelpWantedRole, @@ -186,6 +188,27 @@ function buildState(base: number, slugs: { project: string; buzz: string; oldSlu flags: [], evaluatedAt: '2026-05-01T00:00:00Z', }); + indexAgentGrant(state, { + id: uuid(base + 15), + personSlug: `bot-${base}`, + label: 'test', + scopes: ['moderation:read'], + createdAt: NOW, + createdBySlug: 'jane', + expiresAt: FAR_FUTURE, + }); + indexSpamPurge(state, { + id: uuid(base + 16), + personSlug: `purged-${base}`, + personId: uuid(base + 17), + githubUserId: base, + purgedAt: NOW, + by: 'scheduled', + flags: [], + evidence: [], + beforeCommit: 'abc', + blocksSignIn: true, + }); return state; } diff --git a/apps/api/tests/rename-cascade.test.ts b/apps/api/tests/rename-cascade.test.ts index d6520c1..4cc52de 100644 --- a/apps/api/tests/rename-cascade.test.ts +++ b/apps/api/tests/rename-cascade.test.ts @@ -78,6 +78,16 @@ async function seedReferencingRecords(): Promise { flags: [], evaluatedAt: NOW, }); + for (const [id, personSlug] of [ + ['01951a3c-0000-7000-8000-0000000000e6', fixtures.personSlug], + ['01951a3c-0000-7000-8000-0000000000e7', 'other-person'], + ] as const) { + await tx.sheet('agent-grants').upsert({ + id, personSlug, label: 'test grant', scopes: ['moderation:read'], createdAt: NOW, + createdBySlug: 'other-person', expiresAt: '2026-08-13T18:42:00Z', + }); + } + // A buzz image: an attachment of the buzz record, keyed under its path. const buzzSheet = tx.sheet('project-buzz'); const [buzz] = (await buzzSheet.queryAll()) as Array>; @@ -134,6 +144,7 @@ function inMemory(state: InMemoryState, sheet: ContractSheet): Rec[] { 'help-wanted-interest': state.helpWantedInterest, 'event-rsvps': state.eventRsvps, 'person-evaluations': state.personEvaluations, + 'agent-grants': state.agentGrants, }; const map = maps[sheet]; if (!map) throw new Error(`test has no in-memory view of ${sheet}`); diff --git a/apps/api/tests/scrub-data.test.ts b/apps/api/tests/scrub-data.test.ts index cd6d6ac..113a6e1 100644 --- a/apps/api/tests/scrub-data.test.ts +++ b/apps/api/tests/scrub-data.test.ts @@ -24,6 +24,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { buildSlugMap, rewriteMentions, + rewritePersonReferences, scrubPersonRecord, scrubProjectRecord, scrubRepo, @@ -145,6 +146,22 @@ describe('buildSlugMap', () => { // Unit tests: rewriteMentions // --------------------------------------------------------------------------- +describe('rewritePersonReferences', () => { + const map = new Map([['jane-admin', 'pseudo-a'], ['reborn', 'pseudo-b']]); + + it('pseudonymizes the person slugs agent grants and spam purges hold', () => { + expect(rewritePersonReferences('agent-grants', { personSlug: 'jane-admin', createdBySlug: 'jane-admin' }, map)).toEqual({ + personSlug: 'pseudo-a', + createdBySlug: 'pseudo-a', + }); + // A purged slug that nobody holds now stays as it is; one re-taken since is a real person's. + expect( + rewritePersonReferences('spam-purges', { personSlug: 'reborn', unblockedBySlug: 'jane-admin', by: 'human-x' }, map), + ).toEqual({ personSlug: 'pseudo-b', unblockedBySlug: 'pseudo-a', by: 'human-x' }); + expect(rewritePersonReferences('spam-purges', { personSlug: 'long-gone' }, map)).toEqual({ personSlug: 'long-gone' }); + }); +}); + describe('rewriteMentions', () => { it('rewrites @slug in text to pseudo-slug', () => { const map = new Map([['janedoe', 'bold-anchor']]); diff --git a/contracts/codeforphilly.org/agent-grants/v1.json b/contracts/codeforphilly.org/agent-grants/v1.json new file mode 100644 index 0000000..eb8e6e5 --- /dev/null +++ b/contracts/codeforphilly.org/agent-grants/v1.json @@ -0,0 +1,62 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://codeforphilly.org/agent-grants/v1", + "title": "Agent grant", + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "personSlug": { + "type": "string", + "minLength": 1 + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 80 + }, + "scopes": { + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "enum": [ + "moderation:read", + "moderation:vote", + "moderation:purge" + ] + } + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + "createdBySlug": { + "type": "string", + "minLength": 1 + }, + "expiresAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + "revokedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + } + }, + "required": [ + "createdAt", + "createdBySlug", + "expiresAt", + "id", + "label", + "personSlug", + "scopes" + ] +} diff --git a/contracts/codeforphilly.org/spam-purges/v1.json b/contracts/codeforphilly.org/spam-purges/v1.json new file mode 100644 index 0000000..9c78e2c --- /dev/null +++ b/contracts/codeforphilly.org/spam-purges/v1.json @@ -0,0 +1,112 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://codeforphilly.org/spam-purges/v1", + "title": "Spam purge", + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "personSlug": { + "type": "string", + "minLength": 1 + }, + "personId": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "githubUserId": { + "type": "integer", + "minimum": 1 + }, + "purgedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + "by": { + "type": "string", + "minLength": 1 + }, + "grantId": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 1000 + }, + "flags": { + "type": "array", + "items": { + "type": "string" + } + }, + "evidence": { + "type": "array", + "items": { + "type": "object", + "properties": { + "record": { + "type": "string", + "enum": [ + "person", + "project", + "project-update", + "project-buzz", + "help-wanted-role" + ] + }, + "field": { + "type": "string", + "minLength": 1 + }, + "path": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "path", + "record" + ] + } + }, + "beforeCommit": { + "type": "string", + "minLength": 1 + }, + "blocksSignIn": { + "type": "boolean" + }, + "unblockedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + "unblockedBySlug": { + "type": "string", + "minLength": 1 + }, + "unblockReason": { + "type": "string", + "maxLength": 1000 + } + }, + "required": [ + "beforeCommit", + "blocksSignIn", + "by", + "evidence", + "flags", + "id", + "personId", + "personSlug", + "purgedAt" + ] +} diff --git a/packages/shared/src/contracts.ts b/packages/shared/src/contracts.ts index b66df36..47f2508 100644 --- a/packages/shared/src/contracts.ts +++ b/packages/shared/src/contracts.ts @@ -17,6 +17,7 @@ import { toJSONSchema, type z } from 'zod'; import { + AgentGrantSchema, BlogPostSchema, EventRsvpSchema, EventSchema, @@ -31,6 +32,7 @@ import { ProjectUpdateSchema, RevocationSchema, SlugHistorySchema, + SpamPurgeSchema, TagAssignmentSchema, TagSchema, } from './schemas/index.js'; @@ -68,6 +70,8 @@ export const SHEET_CONTRACTS = { // v1 was hand-authored before contracts were generated; v2 is the first // generated version (it adds the evaluatedAt datetime pattern). 'person-evaluations': { schema: PersonEvaluationSchema, version: 2, title: 'Person evaluation' }, + 'agent-grants': { schema: AgentGrantSchema, version: 1, title: 'Agent grant' }, + 'spam-purges': { schema: SpamPurgeSchema, version: 1, title: 'Spam purge' }, } as const satisfies Record; export type ContractSheet = keyof typeof SHEET_CONTRACTS; diff --git a/packages/shared/src/schemas/agent-grant.ts b/packages/shared/src/schemas/agent-grant.ts new file mode 100644 index 0000000..9101def --- /dev/null +++ b/packages/shared/src/schemas/agent-grant.ts @@ -0,0 +1,51 @@ +import { z } from 'zod'; + +import { references } from '../references.js'; + +/** + * The scopes an agent grant may carry — specs/api/agents.md → Grants. There + * are no others; `moderation:purge` is never implied by another scope and a + * grant carrying it must also carry `moderation:read` (enforced where grants + * are minted, not here, so the published contract stays a plain list). + */ +export const AGENT_SCOPES = ['moderation:read', 'moderation:vote', 'moderation:purge'] as const; +export type AgentScope = (typeof AGENT_SCOPES)[number]; + +/** + * A scoped API grant for an agent account (an automated moderator) — the + * `agent-grants` sheet, `agent-grants/${personSlug}/${id}`. Public: which + * programs may act on the site, with which powers, granted by whom and until + * when. The token is never stored here, nor its hash (that is private: + * {@link AgentGrantSecretSchema}). See specs/data-model.md#agentgrant. + */ +export const AgentGrantSchema = z.object({ + id: z.string().uuid(), + /** The agent account. Follows renames like other slug references. */ + personSlug: z.string().min(1).meta(references('people.slug')), + /** What and where, e.g. "ops bot, host vm-1". */ + label: z.string().min(1).max(80), + scopes: z.array(z.enum(AGENT_SCOPES)).min(1), + createdAt: z.string().datetime({ offset: true }), + /** The administrator who minted it. */ + createdBySlug: z.string().min(1), + /** At most 180 days after `createdAt`. */ + expiresAt: z.string().datetime({ offset: true }), + revokedAt: z.string().datetime({ offset: true }).nullable().optional(), +}); + +export type AgentGrant = z.infer; + +/** + * The private half of a grant: the SHA-256 of its token, in the private + * store's `agent-grant-secrets.jsonl` (specs/behaviors/private-storage.md). + */ +export const AgentGrantSecretSchema = z.object({ + grantId: z.string().uuid(), + personId: z.string().uuid(), + /** SHA-256 hex of the plaintext token. The plaintext is never persisted. */ + tokenHash: z.string().regex(/^[0-9a-f]{64}$/), + /** Written at most once an hour per grant. */ + lastUsedAt: z.string().datetime({ offset: true }).nullable().optional(), +}); + +export type AgentGrantSecret = z.infer; diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts index f3258c7..4a02b13 100644 --- a/packages/shared/src/schemas/index.ts +++ b/packages/shared/src/schemas/index.ts @@ -57,8 +57,25 @@ export type { AccountClaimRequest } from './account-claim-request.js'; export { PersonEvaluationSchema, HUMAN_EVALUATOR_PREFIX, + AGENT_EVALUATOR_PREFIX, isHumanEvaluator, humanEvaluatorFor, + isAgentEvaluator, + agentEvaluatorFor, + evaluatorKind, + evaluatorSlug, personEvaluationKey, } from './person-evaluation.js'; -export type { PersonEvaluation } from './person-evaluation.js'; +export type { PersonEvaluation, EvaluatorKind } from './person-evaluation.js'; + +export { AgentGrantSchema, AgentGrantSecretSchema, AGENT_SCOPES } from './agent-grant.js'; +export type { AgentGrant, AgentGrantSecret, AgentScope } from './agent-grant.js'; + +export { + SpamPurgeSchema, + SpamPurgeEvidenceSchema, + SPAM_EVIDENCE_RECORDS, + SCHEDULED_PURGER, + spamPurgeBlocks, +} from './spam-purge.js'; +export type { SpamPurge, SpamPurgeEvidence, SpamEvidenceRecord } from './spam-purge.js'; diff --git a/packages/shared/src/schemas/person-evaluation.ts b/packages/shared/src/schemas/person-evaluation.ts index acd6126..0ff9533 100644 --- a/packages/shared/src/schemas/person-evaluation.ts +++ b/packages/shared/src/schemas/person-evaluation.ts @@ -6,7 +6,8 @@ import { references } from '../references.js'; * One spam verdict per (person, evaluator) — the `person-evaluations` sheet. * * On the public data repo the only writer is the site: staff votes with - * `evaluator = "human-"`. Machine evaluators (heuristic, LLM) live + * `evaluator = "human-"` and agent votes with + * `evaluator = "agent-"`. Machine evaluators (heuristic, LLM) live * in the private spam-detection repo and share this shape so the prune can * aggregate both. See specs/behaviors/spam-exclusion.md. */ @@ -26,6 +27,35 @@ export const PersonEvaluationSchema = z.object({ export type PersonEvaluation = z.infer; export const HUMAN_EVALUATOR_PREFIX = 'human-'; +export const AGENT_EVALUATOR_PREFIX = 'agent-'; + +/** + * The evaluator's kind, read from its prefix + * (specs/behaviors/spam-exclusion.md → Where verdicts come from): a staff + * member, an agent account on the site, or an offline machine pass. + */ +export type EvaluatorKind = 'human' | 'agent' | 'machine'; + +export function evaluatorKind(evaluator: string): EvaluatorKind { + if (evaluator.startsWith(HUMAN_EVALUATOR_PREFIX)) return 'human'; + if (evaluator.startsWith(AGENT_EVALUATOR_PREFIX)) return 'agent'; + return 'machine'; +} + +/** The voter's slug for a `human-` or `agent-` evaluator, else null. */ +export function evaluatorSlug(evaluator: string): string | null { + if (evaluator.startsWith(HUMAN_EVALUATOR_PREFIX)) return evaluator.slice(HUMAN_EVALUATOR_PREFIX.length); + if (evaluator.startsWith(AGENT_EVALUATOR_PREFIX)) return evaluator.slice(AGENT_EVALUATOR_PREFIX.length); + return null; +} + +export function isAgentEvaluator(evaluator: string): boolean { + return evaluator.startsWith(AGENT_EVALUATOR_PREFIX); +} + +export function agentEvaluatorFor(agentSlug: string): string { + return `${AGENT_EVALUATOR_PREFIX}${agentSlug}`; +} export function isHumanEvaluator(evaluator: string): boolean { return evaluator.startsWith(HUMAN_EVALUATOR_PREFIX); diff --git a/packages/shared/src/schemas/spam-purge.ts b/packages/shared/src/schemas/spam-purge.ts new file mode 100644 index 0000000..f4acf94 --- /dev/null +++ b/packages/shared/src/schemas/spam-purge.ts @@ -0,0 +1,54 @@ +import { z } from 'zod'; + +/** What an agent purge may cite as the spam the member posted. */ +export const SPAM_EVIDENCE_RECORDS = ['person', 'project', 'project-update', 'project-buzz', 'help-wanted-role'] as const; +export type SpamEvidenceRecord = (typeof SPAM_EVIDENCE_RECORDS)[number]; + +export const SpamPurgeEvidenceSchema = z.object({ + record: z.enum(SPAM_EVIDENCE_RECORDS), + /** The cited field, where the record has several text fields. */ + field: z.string().min(1).optional(), + /** The record's data-repo path, e.g. `project-updates/example/3.toml`. */ + path: z.string().min(1), +}); + +export type SpamPurgeEvidence = z.infer; + +/** + * One record per person purged **as spam** — the `spam-purges` sheet, + * `spam-purges/${personSlug}/${id}`. The log staff read once the member's + * row is gone, and the sign-up block that keeps the same GitHub account from + * coming straight back. See specs/data-model.md#spampurge. + * + * `personSlug` is deliberately **not** a reference: the person is gone and + * the slug may be taken again, so a rename cascade never rewrites it. + */ +export const SpamPurgeSchema = z.object({ + id: z.string().uuid(), + personSlug: z.string().min(1), + personId: z.string().uuid(), + githubUserId: z.number().int().min(1).nullable().optional(), + purgedAt: z.string().datetime({ offset: true }), + /** `human-`, `agent-`, or `scheduled`. */ + by: z.string().min(1), + grantId: z.string().uuid().nullable().optional(), + reason: z.string().min(1).max(1000).nullable().optional(), + flags: z.array(z.string()), + evidence: z.array(SpamPurgeEvidenceSchema), + /** The data-repo commit the purge was applied on top of. */ + beforeCommit: z.string().min(1), + blocksSignIn: z.boolean(), + unblockedAt: z.string().datetime({ offset: true }).nullable().optional(), + unblockedBySlug: z.string().min(1).nullable().optional(), + unblockReason: z.string().max(1000).nullable().optional(), +}); + +export type SpamPurge = z.infer; + +/** The `by` value of a purge the daily sweep made. */ +export const SCHEDULED_PURGER = 'scheduled'; + +/** Whether a record's sign-up block is in force. */ +export function spamPurgeBlocks(record: Pick): boolean { + return record.blocksSignIn && !record.unblockedAt; +} diff --git a/packages/shared/tests/contracts.test.ts b/packages/shared/tests/contracts.test.ts index 6bd9ae2..77deaf7 100644 --- a/packages/shared/tests/contracts.test.ts +++ b/packages/shared/tests/contracts.test.ts @@ -58,6 +58,9 @@ describe('SHEET_REFERENCES', () => { 'event-rsvps.eventSlug → events.slug', 'event-rsvps.personSlug → people.slug', 'person-evaluations.personSlug → people.slug', + // An agent grant follows its agent's renames; a spam-purges record's + // personSlug is deliberately not a reference (the person is gone). + 'agent-grants.personSlug → people.slug', ]); }); From 200a2cde35c60e9455656029c3fe13d477fba7b4 Mon Sep 17 00:00:00 2001 From: GrittyBot <30755786+codeforphilly-bot@users.noreply.github.com> Date: Thu, 12 Nov 2026 19:18:03 +0000 Subject: [PATCH 07/23] feat(api): agent grants with bearer auth, scopes and admin endpoints An agent authenticates with a cfpa_ token checked against a stored hash and is anonymous outside its scopes. Administrators create, mint and revoke grants. Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_01S8dthiZS2vwh674eh2DDQs --- apps/api/src/app.ts | 10 +- apps/api/src/auth/agent-grant.ts | 120 ++++++++ apps/api/src/auth/caller.ts | 59 ++++ apps/api/src/auth/guards.ts | 3 +- apps/api/src/auth/middleware.ts | 55 +++- apps/api/src/auth/require.ts | 25 +- apps/api/src/lib/errors.ts | 7 +- apps/api/src/plugins/rate-limit.ts | 3 +- apps/api/src/plugins/services.ts | 3 + apps/api/src/routes/agents.ts | 168 +++++++++++ apps/api/src/services/agents.ts | 293 +++++++++++++++++++ apps/api/src/services/person-purge.ts | 10 + apps/api/src/services/person.write.ts | 7 + apps/api/src/store/commit-meta.ts | 34 ++- apps/api/tests/agents-auth.test.ts | 349 +++++++++++++++++++++++ apps/api/tests/helpers/agent-fixtures.ts | 158 ++++++++++ 16 files changed, 1293 insertions(+), 11 deletions(-) create mode 100644 apps/api/src/auth/agent-grant.ts create mode 100644 apps/api/src/auth/caller.ts create mode 100644 apps/api/src/routes/agents.ts create mode 100644 apps/api/src/services/agents.ts create mode 100644 apps/api/tests/agents-auth.test.ts create mode 100644 apps/api/tests/helpers/agent-fixtures.ts diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 00404d8..caf8842 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -65,6 +65,7 @@ import { previewRoutes } from './routes/preview.js'; import { attachmentRoutes } from './routes/attachments.js'; import { chatRoutes } from './routes/chat.js'; import { moderationRoutes } from './routes/moderation.js'; +import { agentRoutes } from './routes/agents.js'; import { webhookRoutes } from './routes/webhooks.js'; import { samlRoutes } from './routes/saml.js'; import { internalRoutes } from './routes/internal.js'; @@ -91,10 +92,14 @@ export async function buildApp(opts: BuildAppOptions = {}): Promise; +} + +/** A fresh token and the hash that is all the site keeps of it. */ +export function mintAgentToken(): { token: string; tokenHash: string } { + const token = `${AGENT_TOKEN_PREFIX}${randomBytes(32).toString('base64url')}`; + return { token, tokenHash: hashAgentToken(token) }; +} + +export function hashAgentToken(token: string): string { + return createHash('sha256').update(token, 'utf8').digest('hex'); +} + +/** + * The `cfpa_…` token in an `Authorization: Bearer` header, or null when the + * header is absent or carries something else. + */ +export function bearerAgentToken(header: string | string[] | undefined): string | null { + const value = Array.isArray(header) ? header[0] : header; + if (!value) return null; + const match = /^Bearer\s+(\S+)\s*$/i.exec(value); + const token = match?.[1]; + return token && token.startsWith(AGENT_TOKEN_PREFIX) ? token : null; +} + +/** Not revoked and not expired at `now`. */ +export function isGrantActive(grant: AgentGrant, now: Date = new Date()): boolean { + return !grant.revokedAt && Date.parse(grant.expiresAt) > now.getTime(); +} + +/** A person with any grant on record is an agent account (specs/api/agents.md). */ +export function isAgentAccount(state: InMemoryState, slug: string): boolean { + return (state.agentGrantsByPerson.get(slug)?.size ?? 0) > 0; +} + +/** Whether the agent `slug` holds at least one active grant at `now`. */ +export function agentHasActiveGrant(state: InMemoryState, slug: string, now: Date = new Date()): boolean { + for (const id of state.agentGrantsByPerson.get(slug) ?? []) { + const grant = state.agentGrants.get(id); + if (grant && isGrantActive(grant, now)) return true; + } + return false; +} + +/** Every grant of one agent account. */ +export function grantsOf(state: InMemoryState, slug: string): AgentGrant[] { + return [...(state.agentGrantsByPerson.get(slug) ?? [])] + .map((id) => state.agentGrants.get(id)) + .filter((g): g is AgentGrant => g !== undefined); +} + +/** + * Resolve a presented token to its agent, or null when anything is wrong: + * unknown hash, no grant record, revoked, expired, or an agent account that + * is missing, deactivated, or not `accountLevel = user`. + */ +export function resolveAgentToken( + token: string, + state: InMemoryState, + privateStore: PrivateStore, + now: Date = new Date(), +): AgentPrincipal | null { + const secret = privateStore.getGrantSecretByHash(hashAgentToken(token)); + if (!secret) return null; + const grant = state.agentGrants.get(secret.grantId); + if (!grant || !isGrantActive(grant, now)) return null; + const personId = state.personIdBySlug.get(grant.personSlug); + const person = personId ? state.people.get(personId) : undefined; + if (!person || person.id !== secret.personId) return null; + if (person.deletedAt || person.accountLevel !== 'user') return null; + return { person, grant, scopes: new Set(grant.scopes) }; +} + +/** + * The session an agent's own writes are made as: the agent account as + * `person` (commits are authored by it; self-checks apply), level `user`, + * and the grant, which every marker but `agent:` treats as anonymous. + */ +export function agentSessionContext(agent: AgentPrincipal): SessionContext { + return { + person: agent.person, + accountLevel: 'user', + personId: agent.person.id, + agent: { grantId: agent.grant.id, scopes: [...agent.scopes] }, + }; +} diff --git a/apps/api/src/auth/caller.ts b/apps/api/src/auth/caller.ts new file mode 100644 index 0000000..fa4b3c1 --- /dev/null +++ b/apps/api/src/auth/caller.ts @@ -0,0 +1,59 @@ +/** + * Who is calling a staff surface that agents may also reach — + * specs/api/moderation.md (`staff | agent:`), specs/api/agents.md. + * + * Anyone who doesn't qualify gets `404 not_found`, like the rest of + * `/api/admin`: the endpoints' existence is not a signal. + */ +import type { AgentScope } from '@cfp/shared/schemas'; +import type { FastifyRequest } from 'fastify'; + +import { ApiNotFoundError } from '../lib/errors.js'; +import { agentSessionContext, type AgentPrincipal } from './agent-grant.js'; +import type { SessionContext } from './middleware.js'; + +export type ModerationCaller = + | { readonly kind: 'staff'; readonly session: SessionContext; readonly agent: null } + | { readonly kind: 'agent'; readonly session: SessionContext; readonly agent: AgentPrincipal }; + +function isStaffSession(session: SessionContext): boolean { + return !session.agent && (session.accountLevel === 'staff' || session.accountLevel === 'administrator'); +} + +/** + * The caller as staff, or as an agent whose grant carries `scope` (omit it + * for a staff-only surface). Anything else → 404. + */ +export function moderationCaller(request: FastifyRequest, scope?: AgentScope): ModerationCaller { + const agent = request.agent; + if (agent) { + if (scope && agent.scopes.has(scope)) return { kind: 'agent', session: agentSessionContext(agent), agent }; + throw new ApiNotFoundError('Not found'); + } + if (isStaffSession(request.session)) return { kind: 'staff', session: request.session, agent: null }; + throw new ApiNotFoundError('Not found'); +} + +/** An agent whose grant carries `scope`; staff and everyone else → 404. */ +export function agentCaller( + request: FastifyRequest, + scope: AgentScope, +): Extract { + const agent = request.agent; + if (agent && agent.scopes.has(scope)) return { kind: 'agent', session: agentSessionContext(agent), agent }; + throw new ApiNotFoundError('Not found'); +} + +/** Staff (or administrators); agents and everyone else → 404. */ +export function staffCaller(request: FastifyRequest): SessionContext { + if (!request.agent && isStaffSession(request.session)) return request.session; + throw new ApiNotFoundError('Not found'); +} + +/** Administrators only; agents and everyone else → 404. */ +export function administratorCaller(request: FastifyRequest): SessionContext { + if (!request.agent && !request.session.agent && request.session.accountLevel === 'administrator') { + return request.session; + } + throw new ApiNotFoundError('Not found'); +} diff --git a/apps/api/src/auth/guards.ts b/apps/api/src/auth/guards.ts index ba3050c..bd32a01 100644 --- a/apps/api/src/auth/guards.ts +++ b/apps/api/src/auth/guards.ts @@ -21,7 +21,8 @@ export function requireAuth(request: FastifyRequest, markers: AuthMarker[]): Ses if (markers.includes('public')) return session; - if (session.accountLevel === 'anonymous' || (!session.personId && !session.person)) { + // An agent session is anonymous to every marker here (authorization.md → Agent grants). + if (session.agent || session.accountLevel === 'anonymous' || (!session.personId && !session.person)) { throw new UnauthenticatedError('Authentication required'); } diff --git a/apps/api/src/auth/middleware.ts b/apps/api/src/auth/middleware.ts index 215cb8f..0ca86f6 100644 --- a/apps/api/src/auth/middleware.ts +++ b/apps/api/src/auth/middleware.ts @@ -18,7 +18,14 @@ import type { AccountLevel, GhIdentitySnapshot, LoginMethod } from './jwt.js'; import { verifyAccess } from './jwt.js'; import { InMemoryRevocationStore } from './revocation.js'; import { SessionMetadataStore } from './session-metadata.js'; -import type { Person } from '@cfp/shared/schemas'; +import type { AgentScope, Person } from '@cfp/shared/schemas'; +import { UnauthenticatedError } from '../lib/errors.js'; +import { + LAST_USED_WRITE_INTERVAL_MS, + bearerAgentToken, + resolveAgentToken, + type AgentPrincipal, +} from './agent-grant.js'; export interface SessionContext { /** Full Person record, or null for anonymous/claim-pending sessions. */ @@ -35,6 +42,13 @@ export interface SessionContext { * frontend treats undefined as "unknown" and falls back to default UI. */ readonly loginMethod?: LoginMethod; + /** + * Set only on the session an agent's own writes are made as + * (`agentSessionContext`). Every marker but `agent:` treats such a + * session as anonymous (specs/behaviors/authorization.md → Agent grants). + * `request.session` itself is never an agent session. + */ + readonly agent?: { readonly grantId: string; readonly scopes: readonly AgentScope[] }; } const ANONYMOUS_SESSION: SessionContext = { @@ -45,6 +59,12 @@ const ANONYMOUS_SESSION: SessionContext = { declare module 'fastify' { interface FastifyRequest { session: SessionContext; + /** + * The agent behind a valid `Authorization: Bearer cfpa_…` grant, or null. + * When set, `session` is anonymous: only routes that name an agent scope + * look here. + */ + agent: AgentPrincipal | null; } interface FastifyInstance { revocations: InMemoryRevocationStore; @@ -85,8 +105,41 @@ async function sessionMiddlewarePlugin(fastify: FastifyInstance): Promise // Decorate the request prototype with a default session fastify.decorateRequest('session', null as unknown as SessionContext); + fastify.decorateRequest('agent', null); + + // lastUsedAt is written at most once an hour per grant. + const lastUsedWrites = new Map(); + const touchGrant = (grantId: string): void => { + const now = Date.now(); + const secret = fastify.store.private.getGrantSecret(grantId); + if (!secret) return; + const last = Math.max(lastUsedWrites.get(grantId) ?? 0, secret.lastUsedAt ? Date.parse(secret.lastUsedAt) : 0); + if (now - last < LAST_USED_WRITE_INTERVAL_MS) return; + lastUsedWrites.set(grantId, now); + void fastify.store.private + .putGrantSecret({ ...secret, lastUsedAt: new Date(now).toISOString() }) + .catch((err: unknown) => fastify.log.warn({ err, grantId }, 'agent grant lastUsedAt write failed')); + }; fastify.addHook('onRequest', async (request) => { + request.agent = null; + + // An agent grant (specs/behaviors/authorization.md → Agent grants). A + // broken grant is loud (401 grant_invalid), never a fall-through to + // anonymous; a grant beside a session cookie is refused the same way. + const agentToken = bearerAgentToken(request.headers.authorization); + if (agentToken !== null) { + request.session = ANONYMOUS_SESSION; + if (request.cookies['cfp_session'] || request.cookies['cfp_refresh']) { + throw new UnauthenticatedError('An agent grant cannot be combined with a session', 'grant_invalid'); + } + const agent = resolveAgentToken(agentToken, fastify.inMemoryState, fastify.store.private); + if (!agent) throw new UnauthenticatedError('Invalid agent grant', 'grant_invalid'); + request.agent = agent; + touchGrant(agent.grant.id); + return; + } + const token = request.cookies['cfp_session']; if (!token) { request.session = ANONYMOUS_SESSION; diff --git a/apps/api/src/auth/require.ts b/apps/api/src/auth/require.ts index 0554489..e78b293 100644 --- a/apps/api/src/auth/require.ts +++ b/apps/api/src/auth/require.ts @@ -8,7 +8,7 @@ * with full entity context (project, owned-resource, self) to decide * `self`, `member`, `maintainer`, `poster`/`author` cases. */ -import type { Project, ProjectMembership } from '@cfp/shared/schemas'; +import { AGENT_SCOPES, type Project, type ProjectMembership } from '@cfp/shared/schemas'; import { ForbiddenError, UnauthenticatedError } from '../lib/errors.js'; import type { SessionContext } from './middleware.js'; @@ -79,12 +79,24 @@ const MARKER_TOKENS = new Set([ 'author', ]); +/** `agent:` — satisfied only by an agent grant carrying that scope. */ +const AGENT_MARKER_PREFIX = 'agent:'; +const AGENT_MARKER_SCOPES = new Set(AGENT_SCOPES as readonly string[]); + +function isAgentMarker(token: string): boolean { + return token.startsWith(AGENT_MARKER_PREFIX) && AGENT_MARKER_SCOPES.has(token.slice(AGENT_MARKER_PREFIX.length)); +} + /** * Check a marker expression like `maintainer | staff` against the session. * * Throws `UnauthenticatedError` if any non-`public` marker is required and the * caller is anonymous; throws `ForbiddenError` if the caller is authenticated * but none of the markers match. + * + * A session made for an agent grant (`session.agent`) satisfies only + * `agent:` markers for its own scopes; to every other marker it is + * anonymous (specs/behaviors/authorization.md → Agent grants). */ export function requireAuth(expression: MarkerExpression, ctx: AuthContext): SessionContext { const tokens = expression @@ -96,13 +108,20 @@ export function requireAuth(expression: MarkerExpression, ctx: AuthContext): Ses throw new Error(`requireAuth: empty marker expression`); } for (const t of tokens) { - if (!MARKER_TOKENS.has(t)) { + if (!MARKER_TOKENS.has(t) && !isAgentMarker(t)) { throw new Error(`requireAuth: unknown marker '${t}' in '${expression}'`); } } const { session } = ctx; + if (session.agent) { + const scopes = new Set(session.agent.scopes); + if (tokens.some((t) => isAgentMarker(t) && scopes.has(t.slice(AGENT_MARKER_PREFIX.length)))) return session; + if (tokens.includes('public')) return session; + throw new UnauthenticatedError('Authentication required'); + } + if (tokens.includes('public')) return session; // Every remaining marker requires authentication. @@ -125,7 +144,7 @@ export function requireAuth(expression: MarkerExpression, ctx: AuthContext): Ses /** Convenience: throws `UnauthenticatedError` unless the caller is signed in. */ export function requireSignedIn(session: SessionContext): SessionContext { - if (!isAuthenticated(session)) { + if (!isAuthenticated(session) || session.agent) { throw new UnauthenticatedError('Authentication required'); } return session; diff --git a/apps/api/src/lib/errors.ts b/apps/api/src/lib/errors.ts index 1bff9dc..5436492 100644 --- a/apps/api/src/lib/errors.ts +++ b/apps/api/src/lib/errors.ts @@ -36,10 +36,13 @@ export class RateLimitedError extends Error { /** Thrown when a request fails our own validation (422). */ export class ApiValidationError extends Error { readonly fields?: Record; - constructor(message: string, fields?: Record) { + /** `error.code`; `validation_failed` unless a spec names another (e.g. `evidence_invalid`). */ + readonly code: string; + constructor(message: string, fields?: Record, code = 'validation_failed') { super(message); this.name = 'ApiValidationError'; this.fields = fields; + this.code = code; } } @@ -113,7 +116,7 @@ export function mapError( if (err instanceof ApiValidationError) { void reply .code(422) - .send(errorResponse('validation_failed', err.message, traceId, err.fields)); + .send(errorResponse(err.code, err.message, traceId, err.fields)); return; } diff --git a/apps/api/src/plugins/rate-limit.ts b/apps/api/src/plugins/rate-limit.ts index 70ce9ca..a239947 100644 --- a/apps/api/src/plugins/rate-limit.ts +++ b/apps/api/src/plugins/rate-limit.ts @@ -114,7 +114,8 @@ async function rateLimitPlugin(fastify: FastifyInstance): Promise { const ip = clientIp(request); const isWrite = WRITE_METHODS.has(request.method); - const personId = request.session?.person?.id; + // An agent grant counts against its agent account like anyone's session. + const personId = request.session?.person?.id ?? request.agent?.person.id; if (isCredentialPath(path)) { check(ipBuckets, `credential:${ip}`, RATE_LIMITS.credentialPerIp); diff --git a/apps/api/src/plugins/services.ts b/apps/api/src/plugins/services.ts index 6b0e7fa..718464d 100644 --- a/apps/api/src/plugins/services.ts +++ b/apps/api/src/plugins/services.ts @@ -32,6 +32,7 @@ import { TagWriteService } from '../services/tag.write.js'; import { GitHubAccountService } from '../services/github-account.js'; import { AccountClaimService } from '../services/account-claim.js'; import { ModerationService, ModerationWriteService } from '../services/moderation.js'; +import { AgentService } from '../services/agents.js'; import { probeGitHubUser } from '../auth/github-client.js'; import { LoggingNotifier, type Notifier } from '../notify/index.js'; import { EmailNotifier } from '../notify/email-notifier.js'; @@ -64,6 +65,7 @@ declare module 'fastify' { accountClaim: AccountClaimService; moderation: ModerationService; moderationWrite: ModerationWriteService; + agents: AgentService; }; /** Shared in-memory state — write routes call StateApply.apply against this. */ inMemoryState: InMemoryState; @@ -153,6 +155,7 @@ async function servicesPlugin(fastify: FastifyInstance): Promise { }, ), moderationWrite: new ModerationWriteService(state), + agents: new AgentService(state, fastify.store.private), }); } diff --git a/apps/api/src/routes/agents.ts b/apps/api/src/routes/agents.ts new file mode 100644 index 0000000..c42d2c1 --- /dev/null +++ b/apps/api/src/routes/agents.ts @@ -0,0 +1,168 @@ +/** + * Agents API — specs/api/agents.md. + * + * GET /api/admin/agents — staff + * POST /api/admin/agents — administrator + * POST /api/admin/agents/:slug/grants — administrator + * POST /api/admin/agents/:slug/grants/:id/revoke — administrator + * POST /api/admin/agents/:slug/withdraw-votes — staff + * + * Anyone else (agents included) gets 404, like the rest of /api/admin. Every + * write is a public commit by the calling administrator or staff member; + * the token is in the mint response only, never in a commit or a log. + */ +import type { FastifyInstance } from 'fastify'; +import { uuidv7 } from 'uuidv7'; + +import { administratorCaller, staffCaller } from '../auth/caller.js'; +import { ok } from '../lib/response.js'; +import { buildTransactionOptions } from '../store/commit-meta.js'; +import type { GrantInput } from '../services/agents.js'; + +const GRANT_BODY = { + type: 'object', + properties: { + label: { type: 'string' }, + scopes: { type: 'array', items: { type: 'string' } }, + expiresInDays: { type: 'integer' }, + }, + required: ['label', 'scopes'], + additionalProperties: false, +} as const; + +export async function agentRoutes(fastify: FastifyInstance): Promise { + fastify.get( + '/api/admin/agents', + { schema: { tags: ['agents'], summary: 'Every agent account, its grants and recent purges (staff)' } }, + async (request) => { + staffCaller(request); + return ok(fastify.services.agents.list()); + }, + ); + + fastify.post( + '/api/admin/agents', + { + schema: { + tags: ['agents'], + summary: 'Create an agent account, optionally with its first grant (administrator)', + body: { + type: 'object', + properties: { + slug: { type: 'string' }, + fullName: { type: 'string' }, + bio: { type: 'string' }, + grant: GRANT_BODY, + }, + required: ['slug', 'fullName', 'bio'], + additionalProperties: false, + }, + }, + }, + async (request, reply) => { + const session = administratorCaller(request); + const body = request.body as { slug: string; fullName: string; bio: string; grant?: GrantInput }; + const slug = body.slug.trim().toLowerCase(); + const ids = { personId: uuidv7(), grantId: uuidv7() }; + const result = await fastify.store.transact( + { + ...buildTransactionOptions({ + request, + action: 'agent.create', + subjectType: 'person', + subjectId: ids.personId, + subjectSlug: slug, + responseCode: 201, + ...(body.grant ? { extraTrailers: { 'Grant-Id': ids.grantId } } : {}), + }), + // The token hash must land before the grant is public. + writeOrder: 'private-first', + }, + async (tx) => fastify.services.agents.create(tx, session.person!.slug, body, ids), + ); + result.value.stateApply.apply(fastify.inMemoryState, fastify.fts); + const { person, grant, token } = result.value; + return reply.code(201).send( + ok({ + person: await fastify.services.people.get(person.slug, undefined), + grant: grant ? fastify.services.agents.grantView(grant) : null, + ...(token ? { token } : {}), + }), + ); + }, + ); + + fastify.post( + '/api/admin/agents/:slug/grants', + { + schema: { + tags: ['agents'], + summary: 'Mint a grant for an agent; the token is returned once (administrator)', + params: { type: 'object', properties: { slug: { type: 'string' } }, required: ['slug'] }, + body: GRANT_BODY, + }, + }, + async (request, reply) => { + const session = administratorCaller(request); + const { slug } = request.params as { slug: string }; + const agent = fastify.services.agents.agentOr404(slug); + const grantId = uuidv7(); + const result = await fastify.store.transact( + { + ...buildTransactionOptions({ + request, + action: 'agent.grant', + subjectType: 'person', + subjectId: agent.id, + subjectSlug: slug, + responseCode: 201, + extraTrailers: { 'Grant-Id': grantId }, + }), + writeOrder: 'private-first', + }, + async (tx) => fastify.services.agents.mint(tx, slug, session.person!.slug, request.body as GrantInput, grantId), + ); + result.value.stateApply.apply(fastify.inMemoryState, fastify.fts); + return reply + .code(201) + .send(ok({ grant: fastify.services.agents.grantView(result.value.grant), token: result.value.token })); + }, + ); + + fastify.post( + '/api/admin/agents/:slug/grants/:id/revoke', + { + schema: { + tags: ['agents'], + summary: 'Revoke a grant at once (administrator)', + params: { + type: 'object', + properties: { slug: { type: 'string' }, id: { type: 'string' } }, + required: ['slug', 'id'], + }, + }, + }, + async (request) => { + administratorCaller(request); + const { slug, id } = request.params as { slug: string; id: string }; + const existing = fastify.services.agents.grantOr404(slug, id); + if (existing.revokedAt) return ok({ grant: fastify.services.agents.grantView(existing) }); + const agent = fastify.services.agents.agentOr404(slug); + const result = await fastify.store.transact( + buildTransactionOptions({ + request, + action: 'agent.revoke', + subjectType: 'person', + subjectId: agent.id, + subjectSlug: slug, + responseCode: 200, + extraTrailers: { 'Grant-Id': id }, + }), + async (tx) => fastify.services.agents.revoke(tx, slug, id), + ); + // Immediate: the next request with this token is refused. + result.value.stateApply.apply(fastify.inMemoryState, fastify.fts); + return ok({ grant: fastify.services.agents.grantView(result.value.grant) }); + }, + ); +} diff --git a/apps/api/src/services/agents.ts b/apps/api/src/services/agents.ts new file mode 100644 index 0000000..e2d9d61 --- /dev/null +++ b/apps/api/src/services/agents.ts @@ -0,0 +1,293 @@ +/** + * Agent accounts and their grants — specs/api/agents.md. + * + * An agent account is a `people` record an administrator creates here (never + * through a sign-in), marked as an agent by holding a grant. A grant is a + * bearer token for it with fixed scopes and an expiry; the public + * `agent-grants` record says it exists, the private store keeps only the + * token's hash, and the token itself is returned once, from the mint. + */ +import { uuidv7 } from 'uuidv7'; +import { + AGENT_SCOPES, + AgentGrantSchema, + PersonSchema, + agentEvaluatorFor, + evaluatorKind, + type AgentGrant, + type AgentScope, + type Person, +} from '@cfp/shared/schemas'; + +import { + GRANT_DEFAULT_DAYS, + GRANT_MAX_DAYS, + grantsOf, + isAgentAccount, + isGrantActive, + mintAgentToken, +} from '../auth/agent-grant.js'; +import { ApiNotFoundError, ApiValidationError } from '../lib/errors.js'; +import { isReservedSlug, isValidPersonSlug } from '../lib/slug.js'; +import type { InMemoryState } from '../store/memory/state.js'; +import type { PrivateStore } from '../store/private/index.js'; +import { StateApply } from '../store/state-apply.js'; +import type { DualStoreTx } from '../store/store.js'; +import { votesOnAccount } from './votes.js'; + +const DAY_MS = 24 * 60 * 60 * 1000; + +export interface GrantInput { + readonly label: string; + readonly scopes: readonly string[]; + readonly expiresInDays?: number; +} + +export interface CreateAgentInput { + readonly slug: string; + readonly fullName: string; + readonly bio: string; + readonly grant?: GrantInput; +} + +export interface PersonRef { + readonly slug: string; + readonly fullName: string; +} + +export interface GrantView { + readonly id: string; + readonly label: string; + readonly scopes: AgentScope[]; + readonly createdAt: string; + readonly createdBy: PersonRef; + readonly expiresAt: string; + readonly revokedAt: string | null; + readonly lastUsedAt: string | null; + readonly active: boolean; +} + +export interface AgentView { + readonly person: { readonly slug: string; readonly fullName: string; readonly deletedAt: string | null }; + readonly grants: GrantView[]; + readonly stagedVotes: { readonly spam: number; readonly legit: number }; + readonly purges: { readonly last24h: number; readonly last30d: number }; +} + +function nowIso(): string { + return new Date().toISOString(); +} + +/** + * Validate a grant request: known scopes, at least one, `moderation:purge` + * only with `moderation:read`, `expiresInDays` 1–180 (default 90). + */ +export function validateGrantInput(input: GrantInput): { label: string; scopes: AgentScope[]; days: number } { + const fields: Record = {}; + const label = typeof input.label === 'string' ? input.label.trim() : ''; + if (label.length < 1 || label.length > 80) fields['label'] = '1–80 characters'; + const known = new Set(AGENT_SCOPES); + const scopes = [...new Set(input.scopes)]; + if (scopes.length === 0) fields['scopes'] = 'at least one scope'; + else if (scopes.some((s) => !known.has(s))) fields['scopes'] = `unknown scope; allowed: ${AGENT_SCOPES.join(', ')}`; + else if (scopes.includes('moderation:purge') && !scopes.includes('moderation:read')) { + fields['scopes'] = 'moderation:purge requires moderation:read'; + } + const days = input.expiresInDays ?? GRANT_DEFAULT_DAYS; + if (!Number.isInteger(days) || days < 1 || days > GRANT_MAX_DAYS) fields['expiresInDays'] = `1–${GRANT_MAX_DAYS}`; + if (Object.keys(fields).length > 0) throw new ApiValidationError('Invalid grant', fields); + // Stored in the canonical order, whatever order they were asked for in. + const ordered = AGENT_SCOPES.filter((s) => scopes.includes(s)); + return { label, scopes: ordered, days }; +} + +export class AgentService { + readonly #state: InMemoryState; + readonly #privateStore: PrivateStore; + + constructor(state: InMemoryState, privateStore: PrivateStore) { + this.#state = state; + this.#privateStore = privateStore; + } + + #person(slug: string): Person | undefined { + const id = this.#state.personIdBySlug.get(slug); + return id ? this.#state.people.get(id) : undefined; + } + + #ref(slug: string): PersonRef { + return { slug, fullName: this.#person(slug)?.fullName ?? slug }; + } + + grantView(grant: AgentGrant, now: Date = new Date()): GrantView { + return { + id: grant.id, + label: grant.label, + scopes: [...grant.scopes], + createdAt: grant.createdAt, + createdBy: this.#ref(grant.createdBySlug), + expiresAt: grant.expiresAt, + revokedAt: grant.revokedAt ?? null, + lastUsedAt: this.#privateStore.getGrantSecret(grant.id)?.lastUsedAt ?? null, + active: isGrantActive(grant, now), + }; + } + + /** Every agent account, its grants, staged votes and recent purges. */ + list(now: Date = new Date()): AgentView[] { + const out: AgentView[] = []; + for (const slug of [...this.#state.agentGrantsByPerson.keys()].sort()) { + const person = this.#person(slug); + out.push({ + person: { slug, fullName: person?.fullName ?? slug, deletedAt: person?.deletedAt ?? null }, + grants: grantsOf(this.#state, slug) + .sort((a, b) => b.createdAt.localeCompare(a.createdAt)) + .map((g) => this.grantView(g, now)), + stagedVotes: this.stagedVoteCounts(slug), + purges: this.purgeCounts(slug, now), + }); + } + return out; + } + + /** The agent's votes on people no human has voted on (on their account). */ + stagedVoteCounts(agentSlug: string): { spam: number; legit: number } { + const evaluator = agentEvaluatorFor(agentSlug); + const counts = { spam: 0, legit: 0 }; + for (const person of this.#state.people.values()) { + const votes = votesOnAccount(this.#state, person); + const mine = votes.find((v) => v.evaluator === evaluator); + if (!mine || votes.some((v) => evaluatorKind(v.evaluator) === 'human')) continue; + if (mine.verdict === 'spam') counts.spam++; + else if (mine.verdict === 'legit') counts.legit++; + } + return counts; + } + + purgeCounts(agentSlug: string, now: Date = new Date()): { last24h: number; last30d: number } { + const by = agentEvaluatorFor(agentSlug); + let last24h = 0; + let last30d = 0; + for (const p of this.#state.spamPurges.values()) { + if (p.by !== by) continue; + const age = now.getTime() - Date.parse(p.purgedAt); + if (age < DAY_MS) last24h++; + if (age < 30 * DAY_MS) last30d++; + } + return { last24h, last30d }; + } + + /** The agent account `slug`, or 404 when there is none. */ + agentOr404(slug: string): Person { + const person = this.#person(slug); + if (!person || !isAgentAccount(this.#state, slug)) throw new ApiNotFoundError(`No agent '${slug}'`); + return person; + } + + /** + * Create the agent's person record (no sign-in identity, no private + * profile) and, optionally, its first grant, in one transaction. + */ + async create( + tx: DualStoreTx, + createdBySlug: string, + input: CreateAgentInput, + ids: { readonly personId?: string; readonly grantId?: string } = {}, + ): Promise<{ person: Person; grant: AgentGrant | null; token: string | null; stateApply: StateApply }> { + const fields: Record = {}; + const slug = typeof input.slug === 'string' ? input.slug.trim().toLowerCase() : ''; + if (!isValidPersonSlug(slug)) fields['slug'] = 'invalid format'; + else if (isReservedSlug(slug)) fields['slug'] = 'slug_reserved'; + else if (this.#state.personIdBySlug.has(slug)) fields['slug'] = 'slug_taken'; + const fullName = typeof input.fullName === 'string' ? input.fullName.trim() : ''; + if (fullName.length < 1 || fullName.length > 120) fields['fullName'] = '1–120 characters'; + const bio = typeof input.bio === 'string' ? input.bio.trim() : ''; + if (bio.length < 1) fields['bio'] = 'required: say what the agent is and who runs it'; + else if (bio.length > 10_000) fields['bio'] = 'max 10000 characters'; + const grantInput = input.grant ? validateGrantInput(input.grant) : null; + if (Object.keys(fields).length > 0) throw new ApiValidationError('Invalid agent', fields); + + const now = nowIso(); + const person = PersonSchema.parse({ + id: ids.personId ?? uuidv7(), + slug, + fullName, + bio, + accountLevel: 'user', + createdAt: now, + updatedAt: now, + }); + await tx.public.people.upsert(person); + const stateApply = new StateApply().upsertPerson(person); + + let grant: AgentGrant | null = null; + let token: string | null = null; + if (grantInput) { + const minted = await this.#writeGrant(tx, stateApply, person, createdBySlug, grantInput, now, ids.grantId); + grant = minted.grant; + token = minted.token; + } + return { person, grant, token, stateApply }; + } + + /** Mint a grant for an existing agent account; the token is returned once. */ + async mint( + tx: DualStoreTx, + agentSlug: string, + createdBySlug: string, + input: GrantInput, + grantId?: string, + ): Promise<{ grant: AgentGrant; token: string; stateApply: StateApply }> { + const person = this.agentOr404(agentSlug); + const valid = validateGrantInput(input); + const stateApply = new StateApply(); + const { grant, token } = await this.#writeGrant(tx, stateApply, person, createdBySlug, valid, nowIso(), grantId); + return { grant, token, stateApply }; + } + + async #writeGrant( + tx: DualStoreTx, + stateApply: StateApply, + person: Person, + createdBySlug: string, + input: { label: string; scopes: AgentScope[]; days: number }, + now: string, + grantId: string = uuidv7(), + ): Promise<{ grant: AgentGrant; token: string }> { + const grant = AgentGrantSchema.parse({ + id: grantId, + personSlug: person.slug, + label: input.label, + scopes: input.scopes, + createdAt: now, + createdBySlug, + expiresAt: new Date(Date.parse(now) + input.days * DAY_MS).toISOString(), + }); + const { token, tokenHash } = mintAgentToken(); + await tx.public['agent-grants'].upsert(grant); + tx.private.putGrantSecret({ grantId: grant.id, personId: person.id, tokenHash }); + stateApply.upsertAgentGrant(grant); + return { grant, token }; + } + + /** The grant `grantId` of agent `agentSlug`, or 404. */ + grantOr404(agentSlug: string, grantId: string): AgentGrant { + this.agentOr404(agentSlug); + const grant = this.#state.agentGrants.get(grantId); + if (!grant || grant.personSlug !== agentSlug) throw new ApiNotFoundError(`No grant '${grantId}'`); + return grant; + } + + /** Revoke at once; revoking a revoked grant is a no-op (no commit). */ + async revoke( + tx: DualStoreTx, + agentSlug: string, + grantId: string, + ): Promise<{ grant: AgentGrant; stateApply: StateApply }> { + const existing = this.grantOr404(agentSlug, grantId); + if (existing.revokedAt) return { grant: existing, stateApply: new StateApply() }; + const grant = AgentGrantSchema.parse({ ...existing, revokedAt: nowIso() }); + await tx.public['agent-grants'].upsert(grant); + return { grant, stateApply: new StateApply().upsertAgentGrant(grant) }; + } +} diff --git a/apps/api/src/services/person-purge.ts b/apps/api/src/services/person-purge.ts index cef740d..415b6a1 100644 --- a/apps/api/src/services/person-purge.ts +++ b/apps/api/src/services/person-purge.ts @@ -100,6 +100,16 @@ async function purgePersonRecords( } } + // 6b. An agent account's grants go with it (their token hashes went with + // the private data above), so nothing is left that could authenticate. + for (const grantId of state.agentGrantsByPerson.get(existing.slug) ?? new Set()) { + const grant = state.agentGrants.get(grantId); + if (grant && !deleted.has(grant.id)) { + await tx.public['agent-grants'].delete(grant); + stateApply.removeAgentGrant(grant); + } + } + // 7. Delete authored blog-posts. for (const post of state.blogPosts.values()) { if (post.authorId === personId) { diff --git a/apps/api/src/services/person.write.ts b/apps/api/src/services/person.write.ts index f84d02e..a28d520 100644 --- a/apps/api/src/services/person.write.ts +++ b/apps/api/src/services/person.write.ts @@ -31,6 +31,7 @@ import { isValidPersonSlug, } from '../lib/slug.js'; import { requireAuth } from '../auth/require.js'; +import { isAgentAccount } from '../auth/agent-grant.js'; import type { SessionContext } from '../auth/middleware.js'; import { applyTagsForEntity, type TagAssignmentInput, type TagNamespace } from './tag.write.js'; import type { PrivateStore } from '../store/private/index.js'; @@ -325,6 +326,12 @@ export class PersonWriteService { const previousLevel = existing.accountLevel; + // An agent account is never staff, so an agent's vote and a human's vote + // can never come from one account (specs/api/agents.md). + if (level !== 'user' && isAgentAccount(this.#state, existing.slug)) { + throw new ConflictError('An agent account cannot be raised above user', 'agent_account'); + } + // Idempotent: setting the current level is a no-op (no commit). if (previousLevel === level) { return { person: existing, previousLevel, stateApply: new StateApply() }; diff --git a/apps/api/src/store/commit-meta.ts b/apps/api/src/store/commit-meta.ts index a3bb35e..6ae6d1f 100644 --- a/apps/api/src/store/commit-meta.ts +++ b/apps/api/src/store/commit-meta.ts @@ -37,6 +37,12 @@ export interface CommitContext { readonly extraTrailers?: Readonly>; /** Optional override for the human summary in the commit body. */ readonly summary?: string; + /** + * The session the write is made as, when it is not `request.session` — an + * agent's writes are made as `agentSessionContext(request.agent)`, since + * `request.session` is anonymous for a grant. + */ + readonly session?: SessionContext; } /** @@ -45,7 +51,7 @@ export interface CommitContext { */ export function buildTransactionOptions(ctx: CommitContext): TransactionOptions { const { request, action, subjectType, subjectId, subjectSlug, responseCode, summary } = ctx; - const session = request.session; + const session = ctx.session ?? request.session; const author = pseudonymousAuthor(session); const actorSlug = session.person?.slug ?? 'anon'; @@ -67,6 +73,13 @@ export function buildTransactionOptions(ctx: CommitContext): TransactionOptions 'Response-Message': STATUS_CODES[responseCode] ?? 'Unknown', }; + // An agent grant's writes say so, and which grant (never the token): + // specs/behaviors/storage.md → Commit message shape. + if (session.agent) { + trailers['Actor-Kind'] = 'agent'; + trailers['Grant-Id'] = session.agent.grantId; + } + if (subjectType) trailers['Subject-Type'] = subjectType; if (subjectId) trailers['Subject-Id'] = subjectId; if (subjectSlug) trailers['Subject-Slug'] = subjectSlug; @@ -79,3 +92,22 @@ export function buildTransactionOptions(ctx: CommitContext): TransactionOptions return { author, committer: author, message, trailers }; } + +/** + * Move every trailer into the message itself, with `repeated` trailers (one + * key, several values — e.g. one `Evidence:` per cited record) after them. + * gitsheets takes trailers as a map, one value per key, and appends them as + * their own paragraph; a commit that needs a repeated key carries the whole + * trailer block in the message and passes no map, so `git interpret-trailers` + * still parses one block. + */ +export function withInlineTrailers( + opts: TransactionOptions, + repeated: ReadonlyArray, +): TransactionOptions { + const lines = [...Object.entries(opts.trailers ?? {}), ...repeated].map(([k, v]) => { + if (/[\r\n]/.test(v)) throw new Error(`trailer ${k} has a newline`); + return `${k}: ${v}`; + }); + return { ...opts, message: `${opts.message.replace(/\s+$/, '')}\n\n${lines.join('\n')}\n`, trailers: {} }; +} diff --git a/apps/api/tests/agents-auth.test.ts b/apps/api/tests/agents-auth.test.ts new file mode 100644 index 0000000..a58a787 --- /dev/null +++ b/apps/api/tests/agents-auth.test.ts @@ -0,0 +1,349 @@ +/** + * Agent accounts and grants — specs/api/agents.md, + * specs/behaviors/authorization.md → Agent grants. + * + * Covers: creating an agent and minting / revoking grants (administrators + * only; staff may list), the public commits those make, the token kept only + * as a hash, a grant being anonymous everywhere outside its scopes, every + * way a grant is refused (401 grant_invalid), and the account-level guard. + */ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { Writable } from 'node:stream'; + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { buildApp } from '../src/app.js'; +import { hashAgentToken } from '../src/auth/agent-grant.js'; +import { + JWT_KEY, + bearer, + createAgent, + personRecord, + startAgentTestEnv, + type AgentTestEnv, +} from './helpers/agent-fixtures.js'; + +const ADMIN = 1; +const STAFF = 2; +const USER = 3; + +describe('agent accounts and grants', () => { + let env: AgentTestEnv; + let admin: string; + let staff: string; + let user: string; + let agent: { token: string; grantId: string; personId: string }; + + beforeAll(async () => { + env = await startAgentTestEnv({ + records: { + people: [ + personRecord(ADMIN, 'boss', { accountLevel: 'administrator' }), + personRecord(STAFF, 'staffer', { accountLevel: 'staff' }), + personRecord(USER, 'regular'), + ], + }, + }); + admin = await env.cookie(ADMIN, 'administrator'); + staff = await env.cookie(STAFF, 'staff'); + user = await env.cookie(USER, 'user'); + agent = await createAgent(env, admin, { slug: 'example-bot', scopes: ['moderation:read', 'moderation:vote'] }); + }, 60_000); + + afterAll(async () => { + await env.close(); + }); + + describe('creating an agent', () => { + it('returns the token once and commits the person and grant as the administrator', async () => { + expect(agent.token).toMatch(/^cfpa_[A-Za-z0-9_-]{43}$/); + const log = await env.git('log', '--format=%an <%ae>%n%B%n---', '-1'); + expect(log).toContain('Test boss '); + expect(log).toContain('boss: POST /api/admin/agents'); + expect(log).toContain('Action: agent.create'); + expect(log).toContain('Subject-Slug: example-bot'); + expect(log).toContain(`Grant-Id: ${agent.grantId}`); + const files = await env.git('show', '--name-only', '--format=', 'HEAD'); + expect(files.trim().split('\n').sort()).toEqual([ + `agent-grants/example-bot/${agent.grantId}.toml`, + 'people/example-bot.toml', + ]); + const person = await env.git('show', 'HEAD:people/example-bot.toml'); + expect(person).not.toMatch(/githubUserId|githubLogin|slackSamlNameId/); + expect(person).toContain('accountLevel = "user"'); + }); + + it('keeps only the hash: the token is in no commit and not in the private store', async () => { + const everything = await env.git('log', '-p', '--all'); + expect(everything).not.toContain(agent.token); + expect(everything).not.toContain(hashAgentToken(agent.token)); + const secrets = await readFile(join(env.privatePath, 'agent-grant-secrets.jsonl'), 'utf8'); + expect(secrets).toContain(hashAgentToken(agent.token)); + expect(secrets).not.toContain(agent.token); + // No private profile: an agent has no email. + const profiles = await readFile(join(env.privatePath, 'profiles.jsonl'), 'utf8').catch(() => ''); + expect(profiles).not.toContain(agent.personId); + }); + + it('requires a bio and a free, valid slug', async () => { + const bad = await env.app.inject({ + method: 'POST', + url: '/api/admin/agents', + cookies: { cfp_session: admin }, + payload: { slug: 'regular', fullName: 'Taken', bio: '' }, + }); + expect(bad.statusCode).toBe(422); + expect(bad.json<{ error: { fields: Record } }>().error.fields).toMatchObject({ + slug: 'slug_taken', + bio: expect.any(String), + }); + }); + + it('is administrators only (staff and users 404; agents 404)', async () => { + const payload = { slug: 'other-bot', fullName: 'Other', bio: 'An AI.' }; + for (const opts of [{ cookies: { cfp_session: staff } }, { cookies: { cfp_session: user } }, bearer(agent.token)]) { + const res = await env.app.inject({ method: 'POST', url: '/api/admin/agents', payload, ...opts }); + expect(res.statusCode).toBe(404); + } + }); + }); + + describe('listing agents', () => { + it('staff see every agent, its grants (never a token) and counts', async () => { + const res = await env.app.inject({ method: 'GET', url: '/api/admin/agents', cookies: { cfp_session: staff } }); + expect(res.statusCode).toBe(200); + expect(res.body).not.toContain(agent.token); + const [row] = res.json<{ data: Array> }>().data; + expect(row).toMatchObject({ + person: { slug: 'example-bot', fullName: 'Example example-bot', deletedAt: null }, + stagedVotes: { spam: 0, legit: 0 }, + purges: { last24h: 0, last30d: 0 }, + }); + expect((row!['grants'] as unknown[])[0]).toMatchObject({ + id: agent.grantId, + scopes: ['moderation:read', 'moderation:vote'], + createdBy: { slug: 'boss', fullName: 'Test boss' }, + revokedAt: null, + active: true, + }); + }); + + it('404s for users, anonymous callers and agents', async () => { + for (const opts of [{ cookies: { cfp_session: user } }, {}, bearer(agent.token)]) { + const res = await env.app.inject({ method: 'GET', url: '/api/admin/agents', ...opts }); + expect(res.statusCode).toBe(404); + } + }); + }); + + describe('minting', () => { + const mint = (slug: string, payload: Record, cookie = admin) => + env.app.inject({ method: 'POST', url: `/api/admin/agents/${slug}/grants`, cookies: { cfp_session: cookie }, payload }); + + it('refuses moderation:purge without moderation:read, unknown scopes and bad expiries (422)', async () => { + expect((await mint('example-bot', { label: 'x', scopes: ['moderation:purge'] })).statusCode).toBe(422); + expect((await mint('example-bot', { label: 'x', scopes: ['moderation:purge', 'moderation:vote'] })).statusCode).toBe(422); + expect((await mint('example-bot', { label: 'x', scopes: ['admin:everything'] })).statusCode).toBe(422); + expect((await mint('example-bot', { label: 'x', scopes: ['moderation:read'], expiresInDays: 181 })).statusCode).toBe(422); + expect((await mint('example-bot', { label: 'x', scopes: ['moderation:read'], expiresInDays: 0 })).statusCode).toBe(422); + expect((await mint('example-bot', { label: '', scopes: ['moderation:read'] })).statusCode).toBe(422); + }); + + it('404s for a member who is not an agent, and for non-administrators', async () => { + expect((await mint('regular', { label: 'x', scopes: ['moderation:read'] })).statusCode).toBe(404); + expect((await mint('example-bot', { label: 'x', scopes: ['moderation:read'] }, staff)).statusCode).toBe(404); + }); + + it('mints a grant with a default 90-day expiry and an agent.grant commit', async () => { + const res = await mint('example-bot', { label: 'second host', scopes: ['moderation:read'] }); + expect(res.statusCode).toBe(201); + const { grant, token } = res.json<{ data: { grant: { id: string; createdAt: string; expiresAt: string }; token: string } }>().data; + expect(token).toMatch(/^cfpa_/); + expect(Date.parse(grant.expiresAt) - Date.parse(grant.createdAt)).toBe(90 * 86_400_000); + const msg = await env.git('log', '-1', '--format=%B'); + expect(msg).toContain('Action: agent.grant'); + expect(msg).toContain(`Grant-Id: ${grant.id}`); + expect(msg).not.toContain(token); + }); + }); + + describe('a grant outside its scopes', () => { + it('is anonymous on public reads and to /api/auth/me', async () => { + const me = await env.app.inject({ method: 'GET', url: '/api/auth/me', ...bearer(agent.token) }); + expect(me.statusCode).toBe(200); + expect(me.json<{ data: { person: unknown; accountLevel: string } }>().data).toMatchObject({ + person: null, + accountLevel: 'anonymous', + }); + const people = await env.app.inject({ method: 'GET', url: '/api/people/regular', ...bearer(agent.token) }); + expect(people.statusCode).toBe(200); + expect(people.json<{ data: Record }>().data['email'] ?? null).toBeNull(); + }); + + it('cannot edit a profile (even its own), join a project, or refresh into a cookie session', async () => { + const own = await env.app.inject({ + method: 'PATCH', + url: '/api/people/example-bot', + payload: { bio: 'changed' }, + ...bearer(agent.token), + }); + expect(own.statusCode).toBe(401); + const deactivate = await env.app.inject({ method: 'POST', url: '/api/people/regular/deactivate', ...bearer(agent.token) }); + expect(deactivate.statusCode).toBe(401); + const refresh = await env.app.inject({ method: 'POST', url: '/api/auth/refresh', ...bearer(agent.token) }); + expect(refresh.statusCode).toBe(401); + expect(refresh.headers['set-cookie']).toBeUndefined(); + }); + + it('gets 404 on staff and administrator surfaces', async () => { + const preview = await env.app.inject({ method: 'GET', url: '/api/people/regular/purge-preview', ...bearer(agent.token) }); + expect([401, 404]).toContain(preview.statusCode); + const purge = await env.app.inject({ method: 'POST', url: '/api/people/regular/purge', ...bearer(agent.token) }); + expect([401, 404]).toContain(purge.statusCode); + const level = await env.app.inject({ + method: 'POST', + url: '/api/people/regular/account-level', + payload: { level: 'staff' }, + ...bearer(agent.token), + }); + expect(level.statusCode).toBe(401); + }); + }); + + describe('refusals (401 grant_invalid)', () => { + const code = (res: { json: () => T }) => res.json<{ error: { code: string } }>().error.code; + + it('an unknown token', async () => { + const res = await env.app.inject({ method: 'GET', url: '/api/people', ...bearer(`cfpa_${'A'.repeat(43)}`) }); + expect(res.statusCode).toBe(401); + expect(code(res)).toBe('grant_invalid'); + }); + + it('a grant together with a session cookie', async () => { + const res = await env.app.inject({ + method: 'GET', + url: '/api/people', + headers: { authorization: `Bearer ${agent.token}` }, + cookies: { cfp_session: staff }, + }); + expect(res.statusCode).toBe(401); + expect(code(res)).toBe('grant_invalid'); + }); + + it('an expired grant', async () => { + const state = env.app.inMemoryState; + const grant = state.agentGrants.get(agent.grantId)!; + state.agentGrants.set(grant.id, { ...grant, expiresAt: '2026-01-01T00:00:00Z' }); + try { + const res = await env.app.inject({ method: 'GET', url: '/api/people', ...bearer(agent.token) }); + expect(res.statusCode).toBe(401); + expect(code(res)).toBe('grant_invalid'); + } finally { + state.agentGrants.set(grant.id, grant); + } + }); + + it('a deactivated agent account', async () => { + const off = await env.app.inject({ method: 'POST', url: '/api/people/example-bot/deactivate', cookies: { cfp_session: staff } }); + expect(off.statusCode).toBe(200); + const res = await env.app.inject({ method: 'GET', url: '/api/people', ...bearer(agent.token) }); + expect(res.statusCode).toBe(401); + expect(code(res)).toBe('grant_invalid'); + const on = await env.app.inject({ method: 'POST', url: '/api/people/example-bot/reactivate', cookies: { cfp_session: staff } }); + expect(on.statusCode).toBe(200); + const ok = await env.app.inject({ method: 'GET', url: '/api/people', ...bearer(agent.token) }); + expect(ok.statusCode).toBe(200); + }); + }); + + describe('account level', () => { + it('an agent account cannot be raised to staff (409 agent_account)', async () => { + const res = await env.app.inject({ + method: 'POST', + url: '/api/people/example-bot/account-level', + payload: { level: 'staff' }, + cookies: { cfp_session: admin }, + }); + expect(res.statusCode).toBe(409); + expect(res.json<{ error: { code: string } }>().error.code).toBe('agent_account'); + }); + }); + + describe('revoking', () => { + it('is immediate, commits agent.revoke, and is a no-op the second time', async () => { + const before = await env.app.inject({ method: 'GET', url: '/api/people', ...bearer(agent.token) }); + expect(before.statusCode).toBe(200); + + const url = `/api/admin/agents/example-bot/grants/${agent.grantId}/revoke`; + expect((await env.app.inject({ method: 'POST', url, cookies: { cfp_session: staff } })).statusCode).toBe(404); + const res = await env.app.inject({ method: 'POST', url, cookies: { cfp_session: admin } }); + expect(res.statusCode).toBe(200); + expect(res.json<{ data: { grant: { revokedAt: string | null; active: boolean } } }>().data.grant).toMatchObject({ + active: false, + }); + const msg = await env.git('log', '-1', '--format=%B'); + expect(msg).toContain('Action: agent.revoke'); + expect(msg).toContain(`Grant-Id: ${agent.grantId}`); + + const after = await env.app.inject({ method: 'GET', url: '/api/people', ...bearer(agent.token) }); + expect(after.statusCode).toBe(401); + + const head = await env.git('rev-parse', 'HEAD'); + const again = await env.app.inject({ method: 'POST', url, cookies: { cfp_session: admin } }); + expect(again.statusCode).toBe(200); + expect(await env.git('rev-parse', 'HEAD')).toBe(head); + }); + + it('404s for a grant of another agent or an unknown id', async () => { + const res = await env.app.inject({ + method: 'POST', + url: `/api/admin/agents/example-bot/grants/01951a3c-0000-7000-8000-00000000ffff/revoke`, + cookies: { cfp_session: admin }, + }); + expect(res.statusCode).toBe(404); + }); + }); +}); + +describe('request logs', () => { + it('never carry the agent token', async () => { + const lines: string[] = []; + const stream = new Writable({ + write(chunk, _enc, cb) { + lines.push(String(chunk)); + cb(); + }, + }); + const base = await startAgentTestEnv({ + records: { people: [personRecord(ADMIN, 'boss', { accountLevel: 'administrator' })] }, + }); + try { + const created = await createAgent(base, await base.cookie(ADMIN, 'administrator'), { + slug: 'logged-bot', + scopes: ['moderation:read'], + }); + await base.app.close(); + // A second app on the same stores, logging at debug to a captured stream. + const app = await buildApp({ + serverOptions: { logger: { level: 'trace', stream } }, + overrideEnv: { + CFP_DATA_REPO_PATH: base.dataPath, + STORAGE_BACKEND: 'filesystem', + CFP_PRIVATE_STORAGE_PATH: base.privatePath, + CFP_JWT_SIGNING_KEY: JWT_KEY, + NODE_ENV: 'test', + }, + }); + await app.inject({ method: 'GET', url: '/api/admin/members', ...bearer(created.token) }); + await app.inject({ method: 'GET', url: '/api/people', ...bearer(`cfpa_${'B'.repeat(43)}`) }); + await app.close(); + expect(lines.length).toBeGreaterThan(0); + const all = lines.join(''); + expect(all).not.toContain(created.token); + expect(all).not.toContain('B'.repeat(43)); + } finally { + await base.close().catch(() => undefined); + } + }, 60_000); +}); diff --git a/apps/api/tests/helpers/agent-fixtures.ts b/apps/api/tests/helpers/agent-fixtures.ts new file mode 100644 index 0000000..38cd5bb --- /dev/null +++ b/apps/api/tests/helpers/agent-fixtures.ts @@ -0,0 +1,158 @@ +/** + * Shared fixtures for the agent tests (specs/api/agents.md, + * specs/api/moderation.md): a data repo seeded in one commit, an app on it, + * staff / administrator sessions, and an agent created through the API. + * + * Every name here is invented; none is a real member's. + */ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +import type { FastifyInstance } from 'fastify'; +import { openRepo } from 'gitsheets'; +import { expect } from 'vitest'; + +import { buildApp } from '../../src/app.js'; +import { mintSessionFor } from '../../src/auth/issue.js'; +import { createFullDataRepo, createPrivateStorageDir } from './test-full-repo.js'; + +const exec = promisify(execFile); + +export const JWT_KEY = 'test-jwt-signing-key-at-least-32-chars!!'; +const SEED_AUTHOR = { name: 'cfp test', email: 'test@cfp.test' }; + +export type Rec = Record; + +/** `n` → a stable UUIDv7-shaped id for fixtures. */ +export const fid = (n: number): string => `01951a3c-0000-7000-8000-${String(n).padStart(12, '0')}`; + +export const minutesAgo = (m: number, now = Date.now()): string => new Date(now - m * 60_000).toISOString(); +export const daysAgo = (d: number, now = Date.now()): string => new Date(now - d * 86_400_000).toISOString(); + +export function personRecord(n: number, slug: string, over: Rec = {}): Rec { + const createdAt = (over['createdAt'] as string | undefined) ?? daysAgo(400); + return { + id: fid(n), + slug, + fullName: `Test ${slug}`, + accountLevel: 'user', + createdAt, + updatedAt: createdAt, + ...over, + }; +} + +export interface AgentTestEnv { + readonly app: FastifyInstance; + readonly dataPath: string; + readonly privatePath: string; + readonly cookie: (personN: number, level: 'user' | 'staff' | 'administrator') => Promise; + readonly git: (...args: string[]) => Promise; + readonly close: () => Promise; +} + +/** + * A data repo holding `records` (sheet → records, written in one commit), a + * private store holding `profiles`, and an app on both. + */ +export async function startAgentTestEnv(opts: { + records: Record; + profiles?: Rec[]; + env?: Record; +}): Promise { + const dataRepo = await createFullDataRepo(); + const privateStore = await createPrivateStorageDir(); + const repo = await openRepo({ gitDir: dataRepo.path }); + await repo.transact({ message: 'seed agent fixtures', author: SEED_AUTHOR }, async (tx) => { + for (const [sheet, records] of Object.entries(opts.records)) { + for (const r of records) await tx.sheet(sheet).upsert(r, { allowMissingBody: true }); + } + }); + if (opts.profiles?.length) { + const { writeFile } = await import('node:fs/promises'); + const { join } = await import('node:path'); + await writeFile( + join(privateStore.path, 'profiles.jsonl'), + opts.profiles.map((p) => JSON.stringify(p)).join('\n') + '\n', + ); + } + const app = await buildApp({ + serverOptions: { logger: false }, + overrideEnv: { + CFP_DATA_REPO_PATH: dataRepo.path, + STORAGE_BACKEND: 'filesystem', + CFP_PRIVATE_STORAGE_PATH: privateStore.path, + CFP_JWT_SIGNING_KEY: JWT_KEY, + NODE_ENV: 'test', + ...opts.env, + }, + }); + await app.ready(); + return { + app, + dataPath: dataRepo.path, + privatePath: privateStore.path, + cookie: async (n, level) => (await mintSessionFor(fid(n), level, JWT_KEY)).accessToken, + git: async (...args) => (await exec('git', ['-C', dataRepo.path, ...args], { maxBuffer: 64 * 1024 * 1024 })).stdout, + close: async () => { + await app.close(); + await dataRepo.cleanup(); + await privateStore.cleanup(); + }, + }; +} + +export function privateProfile(personId: string, email: string, over: Rec = {}): Rec { + return { + personId, + email, + emailRefreshedAt: '2026-05-01T00:00:00.000Z', + newsletter: null, + updatedAt: '2026-05-01T00:00:00.000Z', + ...over, + }; +} + +/** Create an agent account with one grant through the API, as an administrator. */ +export async function createAgent( + env: AgentTestEnv, + adminCookie: string, + opts: { slug: string; scopes: string[]; label?: string; expiresInDays?: number }, +): Promise<{ token: string; grantId: string; personId: string }> { + const res = await env.app.inject({ + method: 'POST', + url: '/api/admin/agents', + cookies: { cfp_session: adminCookie }, + payload: { + slug: opts.slug, + fullName: `Example ${opts.slug}`, + bio: 'An AI moderator run by the test suite. Its spam votes are staged; staff overrule them.', + grant: { label: opts.label ?? 'test host', scopes: opts.scopes, expiresInDays: opts.expiresInDays ?? 90 }, + }, + }); + expect(res.statusCode, res.body).toBe(201); + const data = res.json<{ data: { person: { id: string }; grant: { id: string }; token: string } }>().data; + return { token: data.token, grantId: data.grant.id, personId: data.person.id }; +} + +/** Mint another grant for an existing agent. */ +export async function mintGrant( + env: AgentTestEnv, + adminCookie: string, + slug: string, + scopes: string[], +): Promise<{ token: string; grantId: string }> { + const res = await env.app.inject({ + method: 'POST', + url: `/api/admin/agents/${slug}/grants`, + cookies: { cfp_session: adminCookie }, + payload: { label: 'second host', scopes, expiresInDays: 30 }, + }); + expect(res.statusCode, res.body).toBe(201); + const data = res.json<{ data: { grant: { id: string }; token: string } }>().data; + return { token: data.token, grantId: data.grant.id }; +} + +export const bearer = (token: string): { headers: { authorization: string } } => ({ + headers: { authorization: `Bearer ${token}` }, +}); From 5e34a1803d19aa3bde0d1d4581eaf6d09e058748 Mon Sep 17 00:00:00 2001 From: GrittyBot <30755786+codeforphilly-bot@users.noreply.github.com> Date: Thu, 12 Nov 2026 19:18:03 +0000 Subject: [PATCH 08/23] feat(api): staged agent votes, agent reads, withdraw Agent spam votes stage a member for the three-day sweep while the grant is active. Human votes are final. Staff can withdraw all of an agent's votes. Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_01S8dthiZS2vwh674eh2DDQs --- apps/api/scripts/prune-spam.ts | 13 +- apps/api/src/routes/agents.ts | 36 +++ apps/api/src/routes/moderation.ts | 100 +++++-- apps/api/src/services/moderation.ts | 396 ++++++++++++++++++++++++---- apps/api/src/services/person.ts | 17 +- apps/api/src/services/spam-purge.ts | 51 +++- apps/api/src/services/votes.ts | 52 +++- apps/api/tests/agent-votes.test.ts | 374 ++++++++++++++++++++++++++ apps/api/tests/prune-spam.test.ts | 25 ++ 9 files changed, 968 insertions(+), 96 deletions(-) create mode 100644 apps/api/tests/agent-votes.test.ts diff --git a/apps/api/scripts/prune-spam.ts b/apps/api/scripts/prune-spam.ts index ec671a1..28d3875 100644 --- a/apps/api/scripts/prune-spam.ts +++ b/apps/api/scripts/prune-spam.ts @@ -43,7 +43,7 @@ import { ConfigError, openRepo, RefError, type Repository } from 'gitsheets'; import { openPublicStore } from '../src/store/public.js'; import { SHEET_REFERENCES } from '@cfp/shared'; -import { HUMAN_EVALUATOR_PREFIX } from '@cfp/shared/schemas'; +import { AGENT_EVALUATOR_PREFIX, HUMAN_EVALUATOR_PREFIX } from '@cfp/shared/schemas'; import type { ProjectUpdate, TagAssignment } from '@cfp/shared/schemas'; /** @@ -187,6 +187,13 @@ export function foldEvaluation( entry = { hasConfidentSpam: false, hasAnyLegit: false, humanVerdict: null, humanEvaluatedAt: null }; verdictMap.set(personSlug, entry); } + if (evaluator.startsWith(AGENT_EVALUATOR_PREFIX)) { + // An agent vote never prunes: its `legit` protects like any evaluator's; + // its `spam` reaches deletion only through the runtime's scheduled purge + // after three days (specs/behaviors/spam-exclusion.md). + if (verdict === 'legit') entry.hasAnyLegit = true; + return; + } if (evaluator.startsWith(HUMAN_EVALUATOR_PREFIX)) { // Latest human vote wins; ties resolve to whichever is read last. if (verdict !== null && (entry.humanEvaluatedAt === null || (evaluatedAt ?? '') >= entry.humanEvaluatedAt)) { @@ -201,9 +208,9 @@ export function foldEvaluation( } } -/** A vote cast on the site (as opposed to an offline machine pass). */ +/** A vote cast on the site — staff or agent — as opposed to an offline machine pass. */ function isSiteVote(evaluator: string): boolean { - return evaluator.startsWith(HUMAN_EVALUATOR_PREFIX); + return evaluator.startsWith(HUMAN_EVALUATOR_PREFIX) || evaluator.startsWith(AGENT_EVALUATOR_PREFIX); } /** diff --git a/apps/api/src/routes/agents.ts b/apps/api/src/routes/agents.ts index c42d2c1..4267b1b 100644 --- a/apps/api/src/routes/agents.ts +++ b/apps/api/src/routes/agents.ts @@ -165,4 +165,40 @@ export async function agentRoutes(fastify: FastifyInstance): Promise { return ok({ grant: fastify.services.agents.grantView(result.value.grant) }); }, ); + + fastify.post( + '/api/admin/agents/:slug/withdraw-votes', + { + schema: { + tags: ['agents'], + summary: "Withdraw every vote an agent has staged, in one commit (staff)", + params: { type: 'object', properties: { slug: { type: 'string' } }, required: ['slug'] }, + body: { + type: ['object', 'null'], + properties: { since: { type: 'string', format: 'date-time' } }, + additionalProperties: false, + }, + }, + }, + async (request) => { + staffCaller(request); + const { slug } = request.params as { slug: string }; + const agent = fastify.services.agents.agentOr404(slug); + const since = (request.body as { since?: string } | null)?.since ?? null; + const result = await fastify.store.transact( + buildTransactionOptions({ + request, + action: 'agent.withdraw-votes', + subjectType: 'person', + subjectId: agent.id, + subjectSlug: slug, + responseCode: 200, + ...(since ? { summary: `since ${since}` } : {}), + }), + async (tx) => fastify.services.moderationWrite.withdrawAll(tx, slug, since), + ); + result.value.stateApply.apply(fastify.inMemoryState, fastify.fts); + return ok({ withdrawn: result.value.withdrawn, reactivated: result.value.reactivated }); + }, + ); } diff --git a/apps/api/src/routes/moderation.ts b/apps/api/src/routes/moderation.ts index 057dec3..412debc 100644 --- a/apps/api/src/routes/moderation.ts +++ b/apps/api/src/routes/moderation.ts @@ -1,28 +1,24 @@ /** * Moderation API — specs/api/moderation.md. * - * GET /api/admin/members — staff roster, newest signup first - * GET /api/admin/members/:slug — one member's footprint + human votes - * POST /api/admin/members/:slug/vote — record the caller's spam/legit verdict + * GET /api/admin/members — staff | agent:moderation:read + * GET /api/admin/members/:slug — staff | agent:moderation:read + * POST /api/admin/members/:slug/vote — staff | agent:moderation:vote + * DELETE /api/admin/members/:slug/vote — agent:moderation:vote * - * Every route is staff/admin only and answers 404 to anyone else: like the - * other staff surfaces, the endpoints' existence is not a signal. + * Anyone else gets 404: like the other staff surfaces, the endpoints' + * existence is not a signal. An agent reads the roster without emails and + * its votes are staged (moderation.md → Agent callers, Agent votes). */ -import type { FastifyInstance, FastifyRequest } from 'fastify'; +import type { FastifyInstance } from 'fastify'; import { ok, paginated } from '../lib/response.js'; import { ApiNotFoundError, ApiValidationError } from '../lib/errors.js'; import { getCallerSession } from '../services/permissions.js'; import { buildTransactionOptions } from '../store/commit-meta.js'; +import { agentCaller, moderationCaller } from '../auth/caller.js'; import type { MemberOrigin, VoteFilter } from '../services/moderation.js'; import { findOrphanPrivateData, purgeOrphanPrivateData } from '../services/private-orphans.js'; -function requireStaffOr404(request: FastifyRequest): void { - const level = request.session.accountLevel; - if (level !== 'staff' && level !== 'administrator') { - throw new ApiNotFoundError('Not found'); - } -} - export async function moderationRoutes(fastify: FastifyInstance): Promise { fastify.get( '/api/admin/members', @@ -34,7 +30,7 @@ export async function moderationRoutes(fastify: FastifyInstance): Promise type: 'object', properties: { q: { type: 'string' }, - vote: { type: 'string', enum: ['none', 'spam', 'legit'] }, + vote: { type: 'string', enum: ['none', 'staged', 'spam', 'legit'] }, origin: { type: 'string', enum: ['imported', 'signed-up'] }, joinedAfter: { type: 'string' }, joinedBefore: { type: 'string' }, @@ -48,7 +44,7 @@ export async function moderationRoutes(fastify: FastifyInstance): Promise }, }, async (request) => { - requireStaffOr404(request); + const caller = moderationCaller(request, 'moderation:read'); const q = request.query as Record; const result = await fastify.services.moderation.listMembers({ q: q['q'] as string | undefined, @@ -60,6 +56,7 @@ export async function moderationRoutes(fastify: FastifyInstance): Promise sort: q['sort'] as string | undefined, page: q['page'] as number | undefined, perPage: q['perPage'] as number | undefined, + viewer: caller.kind, }); if ('error' in result) { throw new ApiValidationError('Unknown sort key', { sort: 'unknown sort key' }); @@ -78,22 +75,28 @@ export async function moderationRoutes(fastify: FastifyInstance): Promise { schema: { tags: ['moderation'], - summary: "One member's footprint on the site and every human vote", + summary: "One member's footprint on the site and every vote on record", params: { type: 'object', properties: { slug: { type: 'string' } }, required: ['slug'] }, }, }, async (request) => { - requireStaffOr404(request); + const caller = moderationCaller(request, 'moderation:read'); const { slug } = request.params as { slug: string }; - const caller = getCallerSession(request); - const person = await fastify.services.people.get(slug, caller); - const footprint = fastify.services.moderation.footprint(slug); - if (!person || !footprint) throw new ApiNotFoundError(`Person '${slug}' not found`); + const moderation = fastify.services.moderation; + const person = + caller.kind === 'agent' + ? await fastify.services.people.get(slug, undefined, { moderationReader: true }) + : await fastify.services.people.get(slug, getCallerSession(request)); + const footprint = moderation.footprint(slug); + const row = await moderation.memberRow(slug, caller.kind); + if (!person || !footprint || !row) throw new ApiNotFoundError(`Person '${slug}' not found`); return ok({ + ...row, person, footprint, - votes: fastify.services.moderation.humanVotes(slug), - earlierVotes: fastify.services.moderation.earlierVotes(slug), + votes: moderation.votes(slug), + earlierVotes: moderation.earlierVotes(slug), + earlierPurges: moderation.earlierPurges(slug), }); }, ); @@ -110,6 +113,7 @@ export async function moderationRoutes(fastify: FastifyInstance): Promise properties: { verdict: { type: 'string', enum: ['spam', 'legit'] }, reasoning: { type: 'string', maxLength: 1000 }, + flags: { type: 'array', items: { type: 'string' } }, }, required: ['verdict'], additionalProperties: false, @@ -117,27 +121,67 @@ export async function moderationRoutes(fastify: FastifyInstance): Promise }, }, async (request) => { - requireStaffOr404(request); + const caller = moderationCaller(request, 'moderation:vote'); const { slug } = request.params as { slug: string }; - const body = request.body as { verdict: 'spam' | 'legit'; reasoning?: string }; + const body = request.body as { verdict: 'spam' | 'legit'; reasoning?: string; flags?: string[] }; + if (caller.kind === 'staff' && body.flags !== undefined) { + throw new ApiValidationError('Flags are for agent votes', { flags: 'not accepted on a staff vote' }); + } const result = await fastify.store.transact( buildTransactionOptions({ request, + session: caller.session, action: 'moderation.vote', subjectType: 'person', subjectSlug: slug, responseCode: 200, summary: `${body.verdict}${body.reasoning ? `: ${body.reasoning}` : ''}`, }), - async (tx) => fastify.services.moderationWrite.castVote(tx, slug, request.session, body), + async (tx) => fastify.services.moderationWrite.castVote(tx, slug, caller.session, body), ); result.value.stateApply.apply(fastify.inMemoryState, fastify.fts); - const caller = getCallerSession(request); - const person = await fastify.services.people.get(result.value.person.slug, caller); + const person = + caller.kind === 'agent' + ? await fastify.services.people.get(result.value.person.slug, undefined, { moderationReader: true }) + : await fastify.services.people.get(result.value.person.slug, getCallerSession(request)); return ok({ person, vote: result.value.vote, latestVote: fastify.services.moderation.latestHumanVote(slug), + stagedVote: fastify.services.moderation.stagedVote(slug), + purgeAfter: (await fastify.services.moderation.memberRow(slug, caller.kind))?.purgeAfter ?? null, + }); + }, + ); + + // DELETE /api/admin/members/:slug/vote — an agent takes back its own vote. + fastify.delete( + '/api/admin/members/:slug/vote', + { + schema: { + tags: ['moderation'], + summary: "Withdraw the calling agent's own vote (agents only)", + params: { type: 'object', properties: { slug: { type: 'string' } }, required: ['slug'] }, + }, + }, + async (request) => { + const caller = agentCaller(request, 'moderation:vote'); + const { slug } = request.params as { slug: string }; + const result = await fastify.store.transact( + buildTransactionOptions({ + request, + session: caller.session, + action: 'moderation.vote-withdraw', + subjectType: 'person', + subjectSlug: slug, + responseCode: 200, + }), + async (tx) => fastify.services.moderationWrite.withdrawVote(tx, slug, caller.session), + ); + result.value.stateApply.apply(fastify.inMemoryState, fastify.fts); + return ok({ + person: await fastify.services.people.get(slug, undefined, { moderationReader: true }), + stagedVote: fastify.services.moderation.stagedVote(slug), }); }, ); diff --git a/apps/api/src/services/moderation.ts b/apps/api/src/services/moderation.ts index bd0c212..8d0d0bb 100644 --- a/apps/api/src/services/moderation.ts +++ b/apps/api/src/services/moderation.ts @@ -1,33 +1,50 @@ /** - * Moderation: the staff members roster, per-member footprint, and human spam - * votes. Per specs/api/moderation.md and specs/screens/admin-members.md. + * Moderation: the staff members roster, per-member footprint, and spam votes + * by staff and by agents. Per specs/api/moderation.md and + * specs/screens/admin-members.md. * * Reads come entirely from in-memory state plus the private store (email) - * and session metadata (last sign-in). The only write is `castVote`, which - * records a `human-` person-evaluation and applies the lifecycle - * side effect from specs/behaviors/person-lifecycle.md. + * and session metadata (last sign-in). The writes record a + * `human-` or `agent-` person-evaluation and apply the + * lifecycle side effect from specs/behaviors/person-lifecycle.md; an agent's + * vote is staged and fenced by the rules in moderation.md → Agent votes. */ import { spamPurgeDueAt } from './spam-purge.js'; -import { votesOnAccount, votesOnEarlierAccounts } from './votes.js'; +import { + agentVotesOnAccount, + hasHumanVote, + humanVotesOnAccount, + newestVote, + stagedAgentVote, + votesOnAccount, + votesOnEarlierAccounts, +} from './votes.js'; import { PersonSchema, PersonEvaluationSchema, - HUMAN_EVALUATOR_PREFIX, + agentEvaluatorFor, + evaluatorKind, + evaluatorSlug, humanEvaluatorFor, - isHumanEvaluator, + type EvaluatorKind, type Person, type PersonEvaluation, + type SpamPurge, } from '@cfp/shared/schemas'; +import { agentHasActiveGrant, isAgentAccount, isGrantActive } from '../auth/agent-grant.js'; import { projectBySlug, type InMemoryState } from '../store/memory/state.js'; import type { PrivateStore } from '../store/private/interface.js'; import type { DualStoreTx } from '../store/store.js'; import { StateApply } from '../store/state-apply.js'; import type { SessionContext } from '../auth/middleware.js'; -import { ApiNotFoundError, ApiValidationError } from '../lib/errors.js'; +import { ApiNotFoundError, ApiValidationError, ConflictError, RateLimitedError } from '../lib/errors.js'; import { githubFactsFrom, type GitHubProbeResult } from '../auth/github-client.js'; export type VoteVerdict = 'spam' | 'legit'; -export type VoteFilter = 'none' | VoteVerdict; +export type VoteFilter = 'none' | 'staged' | VoteVerdict; + +/** Who is reading the roster: agents see email domains only (moderation.md → Agent callers). */ +export type RosterViewer = 'staff' | 'agent'; export interface MemberListOptions { readonly q?: string; @@ -39,6 +56,7 @@ export interface MemberListOptions { readonly sort?: string; readonly page?: number; readonly perPage?: number; + readonly viewer?: RosterViewer; } export interface VoterRef { @@ -47,12 +65,35 @@ export interface VoterRef { } export interface VoteView { + /** From the evaluator's prefix: a staff member's vote or an agent's. */ + readonly kind: EvaluatorKind; readonly verdict: 'spam' | 'legit' | 'uncertain'; readonly reasoning: string | null; + /** Reason tags, less the `manual-override` / `agent` markers. */ + readonly flags: string[]; readonly voter: VoterRef; readonly evaluatedAt: string; } +/** The newest agent vote when no human has voted (moderation.md → stagedVote). */ +export interface StagedVoteView { + readonly verdict: 'spam' | 'legit' | 'uncertain'; + readonly agent: VoterRef; + readonly reasoning: string | null; + readonly flags: string[]; + readonly evaluatedAt: string; + /** False once the agent holds no active grant: the vote no longer queues a purge. */ + readonly counts: boolean; +} + +/** A spam-purge record as the detail's `earlierPurges` lists it. */ +export interface EarlierPurgeView { + readonly id: string; + readonly purgedAt: string; + readonly by: string; + readonly reason: string | null; +} + export interface FootprintCounts { readonly memberships: number; readonly updates: number; @@ -82,7 +123,9 @@ export interface MemberRow { readonly deletedAt: string | null; /** Imported from laddr (has a legacyId) or signed up on this site through GitHub. */ readonly origin: MemberOrigin; + /** Always null for an agent caller. */ readonly email: string | null; + readonly emailDomain: string | null; readonly hasGitHubLink: boolean; readonly github: MemberGitHub | null; readonly lastLoginAt: string | null; @@ -91,7 +134,11 @@ export interface MemberRow { readonly emailBounce: { readonly type: string; readonly bouncedAt: string } | null; readonly bioExcerpt: string; readonly footprint: FootprintCounts; + /** The latest human vote on this account; never an agent's. */ readonly latestVote: VoteView | null; + readonly stagedVote: StagedVoteView | null; + /** Whether this is an agent account (nobody votes on it). */ + readonly isAgent: boolean; /** When the scheduled spam purge will take this member, or null (specs/behaviors/person-lifecycle.md). */ readonly purgeAfter: string | null; /** Row-local facts worth a glance; see `computeSignals`. */ @@ -182,6 +229,8 @@ export function computeSignals(input: { readonly emailBounce: { type: string } | null; readonly lastSlackSsoAt: string | null; readonly footprint: FootprintCounts; + /** A spam-purges record names this slug: an earlier account under it was purged as spam. */ + readonly slugPurgedBefore?: boolean; }): { signals: string[]; attention: number } { const { person } = input; const signals: string[] = []; @@ -208,6 +257,7 @@ export function computeSignals(input: { negative('github-no-activity'); } } + if (input.slugPurgedBefore) negative('slug-purged-before'); if (input.lastSlackSsoAt) signals.push('slack-sso'); const fp = input.footprint; if (fp.memberships + fp.updates + fp.buzz + fp.blogPosts + fp.helpWantedInterest > 0) signals.push('has-footprint'); @@ -238,6 +288,18 @@ function nowIso(): string { return new Date().toISOString(); } +/** The part of an email after the `@`, lowercased, or null. */ +export function emailDomainOf(email: string | null | undefined): string | null { + if (!email) return null; + const at = email.lastIndexOf('@'); + return at >= 0 && at < email.length - 1 ? email.slice(at + 1).toLowerCase() : null; +} + +/** Flags shown with a vote: the stored ones less the kind markers. */ +function shownFlags(rec: PersonEvaluation): string[] { + return rec.flags.filter((f) => f !== 'agent' && f !== 'manual-override'); +} + export class ModerationService { readonly #state: InMemoryState; readonly #privateStore: PrivateStore; @@ -304,13 +366,21 @@ export class ModerationService { } /** - * Every human vote on this person's account, newest first. Votes on an - * earlier account under the same slug are not theirs (`earlierVotes`). + * Every vote on this person's account — staff and agent — newest first. + * Votes on an earlier account under the same slug are not theirs + * (`earlierVotes`). */ + votes(personSlug: string): VoteView[] { + const person = this.#person(personSlug); + if (!person) return []; + return this.#views(votesOnAccount(this.#state, person).filter((r) => evaluatorKind(r.evaluator) !== 'machine')); + } + + /** Every human vote on this person's account, newest first. */ humanVotes(personSlug: string): VoteView[] { const person = this.#person(personSlug); if (!person) return []; - return this.#views(votesOnAccount(this.#state, person).filter((r) => isHumanEvaluator(r.evaluator))); + return this.#views(humanVotesOnAccount(this.#state, person)); } latestHumanVote(personSlug: string): VoteView | null { @@ -321,7 +391,35 @@ export class ModerationService { earlierVotes(personSlug: string): VoteView[] { const person = this.#person(personSlug); if (!person) return []; - return this.#views(votesOnEarlierAccounts(this.#state, person).filter((r) => isHumanEvaluator(r.evaluator))); + return this.#views( + votesOnEarlierAccounts(this.#state, person).filter((r) => evaluatorKind(r.evaluator) !== 'machine'), + ); + } + + /** The `spam-purges` records for this slug, newest first. */ + earlierPurges(personSlug: string): EarlierPurgeView[] { + return [...(this.#state.spamPurgesBySlug.get(personSlug) ?? [])] + .map((id) => this.#state.spamPurges.get(id)) + .filter((p): p is SpamPurge => p !== undefined) + .sort((a, b) => b.purgedAt.localeCompare(a.purgedAt)) + .map((p) => ({ id: p.id, purgedAt: p.purgedAt, by: p.by, reason: p.reason ?? null })); + } + + /** The roster's `stagedVote` for one person, or null. */ + stagedVote(personSlug: string, now: Date = new Date()): StagedVoteView | null { + const person = this.#person(personSlug); + if (!person) return null; + const rec = stagedAgentVote(this.#state, person); + if (!rec) return null; + const agentSlug = evaluatorSlug(rec.evaluator) ?? rec.evaluator; + return { + verdict: rec.verdict, + agent: this.#voterRef(agentSlug), + reasoning: rec.reasoning ?? null, + flags: shownFlags(rec), + evaluatedAt: rec.evaluatedAt, + counts: agentHasActiveGrant(this.#state, agentSlug, now), + }; } #person(slug: string): Person | undefined { @@ -329,6 +427,10 @@ export class ModerationService { return id ? this.#state.people.get(id) : undefined; } + #voterRef(slug: string): VoterRef { + return { slug, fullName: this.#person(slug)?.fullName ?? slug }; + } + #views(records: readonly PersonEvaluation[]): VoteView[] { return records.map((r) => this.#voteView(r)).sort((a, b) => b.evaluatedAt.localeCompare(a.evaluatedAt)); } @@ -351,11 +453,12 @@ export class ModerationService { if (opts.joinedBefore) people = people.filter((p) => p.createdAt <= opts.joinedBefore!); if (opts.vote) { people = people.filter((p) => { - const latest = this.latestHumanVote(p.slug); - if (opts.vote === 'none') return latest === null; - return latest?.verdict === opts.vote; + if (opts.vote === 'none') return !hasHumanVote(this.#state, p); + if (opts.vote === 'staged') return stagedAgentVote(this.#state, p) !== null; + return newestVote(humanVotesOnAccount(this.#state, p))?.verdict === opts.vote; }); } + const viewer: RosterViewer = opts.viewer ?? 'staff'; const authored = this.#authoredCounts(); const lastLogins = new Map(); @@ -375,8 +478,10 @@ export class ModerationService { } // Email is searched for any query, not just ones with an "@" — a bare // domain or local-part substring is the common case when triaging. + // An agent searches the domain only (moderation.md → Agent callers). const email = await emailOf(p); - if (email && email.toLowerCase().includes(q)) matched.push(p); + const searchable = viewer === 'agent' ? emailDomainOf(email) : email?.toLowerCase(); + if (searchable && searchable.includes(q)) matched.push(p); } people = matched; } @@ -404,24 +509,25 @@ export class ModerationService { await this.refreshGitHubFacts(slice); const items: MemberRow[] = []; - for (const p of slice) items.push(await this.#row(p, authored, sessionsOf(p))); + for (const p of slice) items.push(await this.#row(p, authored, sessionsOf(p), viewer)); return { items, totalItems: people.length, page, perPage }; } /** The roster row for one person (used by the list and the detail endpoint). */ - async memberRow(slug: string): Promise { + async memberRow(slug: string, viewer: RosterViewer = 'staff'): Promise { const id = this.#state.personIdBySlug.get(slug); const person = id ? this.#state.people.get(id) : undefined; if (!person) return null; await this.refreshGitHubFacts([person]); - return this.#row(person, this.#authoredCounts(), this.#sessions(person.id)); + return this.#row(person, this.#authoredCounts(), this.#sessions(person.id), viewer); } async #row( p: Person, authored: AuthoredCounts, sessions: { lastLoginAt: string | null; count: number }, + viewer: RosterViewer = 'staff', ): Promise { const profile = await this.#privateStore.getProfile(p.id); const github: MemberGitHub | null = @@ -456,6 +562,7 @@ export class ModerationService { emailBounce, lastSlackSsoAt, footprint, + slugPurgedBefore: (this.#state.spamPurgesBySlug.get(p.slug)?.size ?? 0) > 0, }); return { id: p.id, @@ -465,7 +572,8 @@ export class ModerationService { createdAt: p.createdAt, deletedAt: p.deletedAt ?? null, origin: typeof p.legacyId === 'number' ? 'imported' : 'signed-up', - email, + email: viewer === 'agent' ? null : email, + emailDomain: emailDomainOf(email), hasGitHubLink: typeof p.githubUserId === 'number', github, lastLoginAt: sessions.lastLoginAt, @@ -475,6 +583,8 @@ export class ModerationService { bioExcerpt: bioExcerpt(p.bio), footprint, latestVote: this.latestHumanVote(p.slug), + stagedVote: this.stagedVote(p.slug), + isAgent: isAgentAccount(this.#state, p.slug), purgeAfter: spamPurgeDueAt(this.#state, p)?.toISOString() ?? null, signals, attention, @@ -540,13 +650,13 @@ export class ModerationService { } #voteView(rec: PersonEvaluation): VoteView { - const voterSlug = rec.evaluator.slice(HUMAN_EVALUATOR_PREFIX.length); - const voterId = this.#state.personIdBySlug.get(voterSlug); - const voter = voterId ? this.#state.people.get(voterId) : undefined; + const voterSlug = evaluatorSlug(rec.evaluator) ?? rec.evaluator; return { + kind: evaluatorKind(rec.evaluator), verdict: rec.verdict, reasoning: rec.reasoning ?? null, - voter: { slug: voterSlug, fullName: voter?.fullName ?? voterSlug }, + flags: shownFlags(rec), + voter: this.#voterRef(voterSlug), evaluatedAt: rec.evaluatedAt, }; } @@ -570,6 +680,28 @@ export class ModerationService { export interface CastVoteInput { readonly verdict: VoteVerdict; readonly reasoning?: string; + /** Agents only: 0–10 reason tags. */ + readonly flags?: readonly string[]; +} + +/** Agent vote limits — specs/api/moderation.md → Agent votes. */ +export const AGENT_SPAM_VOTES_PER_DAY = 20; +export const AGENT_SPAM_VOTE_MAX_AGE_DAYS = 30; +const DAY_MS = 24 * 60 * 60 * 1000; +const FLAG_RE = /^[a-z0-9][a-z0-9:-]{0,39}$/; + +/** Validate an agent's reason tags (votes and purges): 0–10, each `^[a-z0-9][a-z0-9:-]{0,39}$`. */ +export function validateFlags(flags: readonly string[] | undefined): string[] { + const list = flags ?? []; + if (!Array.isArray(list) || list.length > 10 || list.some((f) => typeof f !== 'string' || !FLAG_RE.test(f))) { + throw new ApiValidationError('Invalid flags', { flags: '0–10 tags matching ^[a-z0-9][a-z0-9:-]{0,39}$' }); + } + return [...new Set(list)]; +} + +/** The reversal rule: was the latest vote on this account (human or agent) `spam`? */ +function latestVoteWasSpam(state: InMemoryState, person: Person): boolean { + return newestVote(votesOnAccount(state, person).filter((v) => evaluatorKind(v.evaluator) !== 'machine'))?.verdict === 'spam'; } export class ModerationWriteService { @@ -579,11 +711,34 @@ export class ModerationWriteService { this.#state = state; } + #personOr404(slug: string): Person { + const id = this.#state.personIdBySlug.get(slug); + const person = id ? this.#state.people.get(id) : undefined; + if (!person) throw new ApiNotFoundError(`Person '${slug}' not found`); + return person; + } + /** - * Record the caller's verdict as `person-evaluations//human-` - * and apply the lifecycle side effect: spam → deactivate; legit → reactivate - * only when the previous latest human vote was spam (a self-deactivation is - * never undone by a vote). One record per voter; re-voting replaces it. + * The agent behind `session`, re-checked inside the write mutex: a grant + * revoked since the request began stops here. + */ + #agentOf(session: SessionContext): Person { + const agent = session.person; + const grant = session.agent ? this.#state.agentGrants.get(session.agent.grantId) : undefined; + if (!agent || !grant || !isGrantActive(grant)) throw new ApiNotFoundError('Not found'); + return agent; + } + + /** + * Record the caller's verdict as `person-evaluations//` + * and apply the lifecycle side effect: spam → deactivate; legit → + * reactivate only when the previous latest vote on the account (human or + * agent) was spam (a self-deactivation is never undone by a vote). One + * record per voter; re-voting replaces it. + * + * Staff votes are `human-`, final. An agent session's votes are + * `agent-`, staged, and fenced by the agent vote rules, checked here + * inside the write mutex. */ async castVote( tx: DualStoreTx, @@ -593,36 +748,43 @@ export class ModerationWriteService { ): Promise<{ person: Person; vote: PersonEvaluation; stateApply: StateApply }> { const voter = session.person; if (!voter) throw new ApiNotFoundError(`Person '${slug}' not found`); + if (session.agent) this.#agentOf(session); - const id = this.#state.personIdBySlug.get(slug); - const existing = id ? this.#state.people.get(id) : undefined; - if (!existing) throw new ApiNotFoundError(`Person '${slug}' not found`); + const existing = this.#personOr404(slug); if (existing.id === voter.id) { throw new ApiValidationError('You cannot vote on your own account', { slug: 'self' }); } + if (isAgentAccount(this.#state, existing.slug)) { + throw new ApiValidationError('Nobody votes on an agent account', { slug: 'agent' }); + } if (input.reasoning !== undefined && input.reasoning.length > 1000) { throw new ApiValidationError('Reasoning is too long', { reasoning: 'max 1000 characters' }); } - const previousLatest = latestHumanVerdict(this.#state, existing); const now = nowIso(); - const vote: PersonEvaluation = PersonEvaluationSchema.parse({ - personSlug: slug, - evaluator: humanEvaluatorFor(voter.slug), - verdict: input.verdict, - confidence: 1, - flags: ['manual-override'], - ...(input.reasoning && input.reasoning.trim() !== '' ? { reasoning: input.reasoning.trim() } : {}), - evaluatedAt: now, - }); + let vote: PersonEvaluation; + if (session.agent) { + vote = this.#agentVote(voter, existing, input, now); + } else { + vote = PersonEvaluationSchema.parse({ + personSlug: slug, + evaluator: humanEvaluatorFor(voter.slug), + verdict: input.verdict, + confidence: 1, + flags: ['manual-override'], + ...(input.reasoning && input.reasoning.trim() !== '' ? { reasoning: input.reasoning.trim() } : {}), + evaluatedAt: now, + }); + } + const previousWasSpam = latestVoteWasSpam(this.#state, existing); await tx.public['person-evaluations'].upsert(vote); const stateApply = new StateApply().upsertPersonEvaluation(vote); let person = existing; if (input.verdict === 'spam' && !existing.deletedAt) { person = PersonSchema.parse({ ...existing, deletedAt: now, updatedAt: now }); - } else if (input.verdict === 'legit' && existing.deletedAt && previousLatest === 'spam') { + } else if (input.verdict === 'legit' && existing.deletedAt && previousWasSpam) { person = PersonSchema.parse({ ...existing, deletedAt: null, updatedAt: now }); } if (person !== existing) { @@ -632,13 +794,145 @@ export class ModerationWriteService { return { person, vote, stateApply }; } -} -function latestHumanVerdict(state: InMemoryState, person: Person): PersonEvaluation['verdict'] | null { - let latest: PersonEvaluation | null = null; - for (const rec of votesOnAccount(state, person)) { - if (!isHumanEvaluator(rec.evaluator)) continue; - if (!latest || rec.evaluatedAt > latest.evaluatedAt) latest = rec; + /** The agent vote rules (moderation.md → Agent votes, 1–4) and the record. */ + #agentVote(agent: Person, target: Person, input: CastVoteInput, now: string): PersonEvaluation { + const reasoning = input.reasoning?.trim() ?? ''; + if (reasoning.length < 1 || reasoning.length > 1000) { + throw new ApiValidationError('An agent must say why (1–1000 characters)', { reasoning: 'required, 1–1000 characters' }); + } + const flags = validateFlags(input.flags); + + // 1. Humans are final. + if (hasHumanVote(this.#state, target)) { + throw new ConflictError('A person has already voted on this member', 'human_verdict_exists'); + } + // 2. Only ordinary members. + if (target.accountLevel !== 'user') { + throw new ConflictError('Only ordinary members can be voted on by an agent', 'protected_account'); + } + if (input.verdict === 'spam') { + // 3. Only new members, for spam. + if (Date.parse(now) - Date.parse(target.createdAt) > AGENT_SPAM_VOTE_MAX_AGE_DAYS * DAY_MS) { + throw new ConflictError( + `A spam vote needs a member who joined in the last ${AGENT_SPAM_VOTE_MAX_AGE_DAYS} days`, + 'member_too_old', + ); + } + // 4. Volume: 20 spam votes in any rolling 24 hours, from its own records. + const recent = this.#agentSpamVoteTimes(agent.slug, Date.parse(now)); + if (recent.length >= AGENT_SPAM_VOTES_PER_DAY) { + const frees = recent[recent.length - AGENT_SPAM_VOTES_PER_DAY]! + DAY_MS; + throw new RateLimitedError(Math.max(1, Math.ceil((frees - Date.parse(now)) / 1000))); + } + } + + return PersonEvaluationSchema.parse({ + personSlug: target.slug, + evaluator: agentEvaluatorFor(agent.slug), + verdict: input.verdict, + flags: ['agent', ...flags.filter((f) => f !== 'agent')], + reasoning, + evaluatedAt: now, + }); + } + + /** `evaluatedAt` (ms) of the agent's spam votes in the last 24 hours, oldest first. */ + #agentSpamVoteTimes(agentSlug: string, now: number): number[] { + const evaluator = agentEvaluatorFor(agentSlug); + const times: number[] = []; + for (const rec of this.#state.personEvaluations.values()) { + if (rec.evaluator !== evaluator || rec.verdict !== 'spam' || rec.flags.includes('purged')) continue; + const at = Date.parse(rec.evaluatedAt); + if (now - at < DAY_MS) times.push(at); + } + return times.sort((a, b) => a - b); + } + + /** + * `DELETE /api/admin/members/:slug/vote` — the calling agent takes back + * its own vote. Refused once a human has voted (the agent's vote is then + * history). If it was the latest vote, it was `spam`, and no other agent's + * `spam` vote remains, the member is reactivated. + */ + async withdrawVote( + tx: DualStoreTx, + slug: string, + session: SessionContext, + ): Promise<{ person: Person; stateApply: StateApply }> { + const agent = this.#agentOf(session); + const existing = this.#personOr404(slug); + const evaluator = agentEvaluatorFor(agent.slug); + const mine = agentVotesOnAccount(this.#state, existing).find((v) => v.evaluator === evaluator); + if (!mine) throw new ApiNotFoundError(`No vote by ${agent.slug} on '${slug}'`); + if (hasHumanVote(this.#state, existing)) { + throw new ConflictError('A person has voted on this member; the agent vote is the record', 'human_verdict_exists'); + } + return this.#removeAgentVotes(tx, existing, [mine], new StateApply()); + } + + /** + * Delete `votes` (agent votes on `person`'s account) and apply the reversal + * rule: reactivate when the latest vote was one of them and `spam`, and no + * other agent's `spam` vote remains. + */ + async #removeAgentVotes( + tx: DualStoreTx, + existing: Person, + votes: readonly PersonEvaluation[], + stateApply: StateApply, + ): Promise<{ person: Person; stateApply: StateApply; reactivated: boolean }> { + const all = votesOnAccount(this.#state, existing).filter((v) => evaluatorKind(v.evaluator) !== 'machine'); + const latest = newestVote(all); + const removing = new Set(votes.map((v) => v.evaluator)); + for (const v of votes) { + await tx.public['person-evaluations'].delete(v); + stateApply.removePersonEvaluation(v); + } + const otherSpam = all.some( + (v) => !removing.has(v.evaluator) && evaluatorKind(v.evaluator) === 'agent' && v.verdict === 'spam', + ); + let person = existing; + if ( + existing.deletedAt && + latest !== null && + removing.has(latest.evaluator) && + latest.verdict === 'spam' && + !otherSpam + ) { + const now = nowIso(); + person = PersonSchema.parse({ ...existing, deletedAt: null, updatedAt: now }); + await tx.public.people.upsert(person); + stateApply.upsertPerson(person); + } + return { person, stateApply, reactivated: person !== existing }; + } + + /** + * `POST /api/admin/agents/:slug/withdraw-votes` — delete every vote agent + * `agentSlug` cast (newer than `since`) on a person with no human vote, + * reactivating each person its vote hid. Votes on people a human has since + * voted on, and on people who no longer exist (or on an earlier account + * under a slug), are left as the record. One commit. + */ + async withdrawAll( + tx: DualStoreTx, + agentSlug: string, + since: string | null, + ): Promise<{ withdrawn: number; reactivated: number; stateApply: StateApply }> { + const evaluator = agentEvaluatorFor(agentSlug); + const stateApply = new StateApply(); + let withdrawn = 0; + let reactivated = 0; + for (const person of [...this.#state.people.values()]) { + const mine = agentVotesOnAccount(this.#state, person).find((v) => v.evaluator === evaluator); + if (!mine) continue; + if (since && Date.parse(mine.evaluatedAt) < Date.parse(since)) continue; + if (hasHumanVote(this.#state, person)) continue; + const result = await this.#removeAgentVotes(tx, person, [mine], stateApply); + withdrawn++; + if (result.reactivated) reactivated++; + } + return { withdrawn, reactivated, stateApply }; } - return latest?.verdict ?? null; } diff --git a/apps/api/src/services/person.ts b/apps/api/src/services/person.ts index 7d0cbcf..b48a355 100644 --- a/apps/api/src/services/person.ts +++ b/apps/api/src/services/person.ts @@ -136,7 +136,17 @@ export class PersonService { return { items, totalItems, facets }; } - async get(slug: string, caller?: CallerSession): Promise { + /** + * `opts.moderationReader` is an agent reading the roster detail + * (specs/api/moderation.md → Agent callers): it sees what staff see of the + * record — deactivated people, `deletedAt`, the real `accountLevel` — but + * never the email, and holds no permissions. + */ + async get( + slug: string, + caller?: CallerSession, + opts: { readonly moderationReader?: boolean } = {}, + ): Promise { const personId = this.#state.personIdBySlug.get(slug); if (!personId) return null; const person = this.#state.people.get(personId); @@ -145,9 +155,10 @@ export class PersonService { const isStaff = caller?.accountLevel === 'staff' || caller?.accountLevel === 'administrator'; const isSelfCaller = caller?.id === person.id; + const moderationReader = opts.moderationReader === true; // Deactivated profiles 404 for everyone except staff and the person // themselves (self may view to reactivate). Per specs/behaviors/person-lifecycle.md. - if (person.deletedAt && !isStaff && !isSelfCaller) return null; + if (person.deletedAt && !isStaff && !isSelfCaller && !moderationReader) return null; const memberships = this.#getMembershipsForPerson(person.slug); const projectsMap = this.#getProjectsForMemberships(memberships); @@ -179,7 +190,7 @@ export class PersonService { tagAssignments, allTags: this.#state.tags, permissions, - callerAccountLevel: caller?.accountLevel, + callerAccountLevel: moderationReader ? 'staff' : caller?.accountLevel, callerPersonId: caller?.id, visibleEmail, }); diff --git a/apps/api/src/services/spam-purge.ts b/apps/api/src/services/spam-purge.ts index 99aefcb..0a602a0 100644 --- a/apps/api/src/services/spam-purge.ts +++ b/apps/api/src/services/spam-purge.ts @@ -4,7 +4,7 @@ * stood unopposed for three days, a daily sweep purges every queued person in * one commit. */ -import { isHumanEvaluator, type Person } from '@cfp/shared/schemas'; +import { evaluatorKind, type Person } from '@cfp/shared/schemas'; import type { FastifyBaseLogger } from 'fastify'; import type { FtsEngine } from '../store/fts.js'; @@ -13,7 +13,8 @@ import { StateApply } from '../store/state-apply.js'; import type { Store } from '../store/store.js'; import type { SessionMetadataStore } from '../auth/session-metadata.js'; import { purgePeople } from './person-purge.js'; -import { votesOnAccount } from './votes.js'; +import { decidingVotes } from './votes.js'; +import { isAgentAccount } from '../auth/agent-grant.js'; /** How long a spam vote stands before the sweep acts on it. */ export const SPAM_PURGE_GRACE_MS = 3 * 24 * 60 * 60 * 1000; @@ -21,14 +22,18 @@ export const SPAM_PURGE_GRACE_MS = 3 * 24 * 60 * 60 * 1000; export const SPAM_PURGE_INTERVAL_MS = 24 * 60 * 60 * 1000; /** - * When the sweep will purge `person`, or null if it won't: every human vote - * on their account is `spam`, they are still deactivated and not staff, and - * the date is three days after the newest vote. Votes cast on an earlier - * account under the same slug are ignored (./votes.ts). + * When the sweep will purge `person`, or null if it won't + * (specs/behaviors/person-lifecycle.md → Scheduled spam purge): every + * deciding vote on their account is `spam`, they are still deactivated, not + * staff and not an agent account, and the date is three days after the + * newest deciding vote. The deciding votes are the human votes, or with none, + * those of agents still holding an active grant (./votes.ts); votes cast on + * an earlier account under the same slug never count. */ -export function spamPurgeDueAt(state: InMemoryState, person: Person): Date | null { +export function spamPurgeDueAt(state: InMemoryState, person: Person, now: Date = new Date()): Date | null { if (!person.deletedAt || person.accountLevel !== 'user') return null; - const votes = votesOnAccount(state, person).filter((e) => isHumanEvaluator(e.evaluator)); + if (isAgentAccount(state, person.slug)) return null; + const votes = decidingVotes(state, person, now); if (votes.length === 0 || votes.some((v) => v.verdict !== 'spam')) return null; const newest = Math.max(...votes.map((v) => Date.parse(v.evaluatedAt))); return new Date(newest + SPAM_PURGE_GRACE_MS); @@ -38,12 +43,34 @@ export function spamPurgeDueAt(state: InMemoryState, person: Person): Date | nul export function queuedForSpamPurge(state: InMemoryState, now: Date): Person[] { return [...state.people.values()] .filter((p) => { - const due = spamPurgeDueAt(state, p); + const due = spamPurgeDueAt(state, p, now); return due !== null && due.getTime() <= now.getTime(); }) .sort((a, b) => a.slug.localeCompare(b.slug)); } +/** + * The agents whose votes alone queued `person` (no human vote on the + * account), e.g. `["agent-example-bot"]`; empty when a person decided. + */ +export function stagedBy(state: InMemoryState, person: Person, now: Date): string[] { + const votes = decidingVotes(state, person, now); + if (votes.length === 0 || votes.some((v) => evaluatorKind(v.evaluator) !== 'agent')) return []; + return [...new Set(votes.map((v) => v.evaluator))].sort(); +} + +/** The sweep commit's slug lines: `` or ` (staged by agent-)`. */ +export function sweepLines(state: InMemoryState, people: readonly Person[], now: Date): { lines: string[]; agentStaged: number } { + let agentStaged = 0; + const lines = people.map((p) => { + const agents = stagedBy(state, p, now); + if (agents.length === 0) return p.slug; + agentStaged++; + return `${p.slug} (staged by ${agents.join(', ')})`; + }); + return { lines, agentStaged }; +} + export interface SpamPurgeDeps { readonly state: InMemoryState; readonly fts: FtsEngine; @@ -64,13 +91,17 @@ export class SpamPurgeService { const queued = queuedForSpamPurge(this.#d.state, now); if (queued.length === 0) return []; const slugs = queued.map((p) => p.slug); + // A slug queued on agent votes alone says so, and the commit counts them, + // so `git log` separates what a person decided from what only an agent did. + const { lines, agentStaged } = sweepLines(this.#d.state, queued, now); const stateApply = new StateApply(); try { await this.#d.store.transact( { - message: `people: scheduled spam purge (${slugs.length})\n\n${slugs.join('\n')}`, + message: `people: scheduled spam purge (${slugs.length})\n\n${lines.join('\n')}`, author: { name: 'cfp-api', email: 'api@codeforphilly.org' }, + ...(agentStaged > 0 ? { trailers: { 'Agent-Staged': String(agentStaged) } } : {}), }, async (tx) => { // Re-check inside the write mutex: a vote or reactivation that landed diff --git a/apps/api/src/services/votes.ts b/apps/api/src/services/votes.ts index 39b86a3..bff418b 100644 --- a/apps/api/src/services/votes.ts +++ b/apps/api/src/services/votes.ts @@ -8,8 +8,9 @@ * counts for nothing on this one: not in the roster's vote fields, not in the * reversal rule, not in the scheduled purge. */ -import type { Person, PersonEvaluation } from '@cfp/shared/schemas'; +import { evaluatorKind, evaluatorSlug, type Person, type PersonEvaluation } from '@cfp/shared/schemas'; +import { agentHasActiveGrant } from '../auth/agent-grant.js'; import type { InMemoryState } from '../store/memory/state.js'; /** Whether `vote` was cast on this account (at or after its `createdAt`). */ @@ -39,3 +40,52 @@ export function votesOnEarlierAccounts( ): PersonEvaluation[] { return evaluationsForSlug(state, person.slug).filter((v) => !isOnAccount(v, person.createdAt)); } + +/** Votes on this account by staff (`human-*`). */ +export function humanVotesOnAccount(state: InMemoryState, person: Pick): PersonEvaluation[] { + return votesOnAccount(state, person).filter((v) => evaluatorKind(v.evaluator) === 'human'); +} + +/** Votes on this account by agents (`agent-*`). */ +export function agentVotesOnAccount(state: InMemoryState, person: Pick): PersonEvaluation[] { + return votesOnAccount(state, person).filter((v) => evaluatorKind(v.evaluator) === 'agent'); +} + +/** Whether any human has voted on this account. */ +export function hasHumanVote(state: InMemoryState, person: Pick): boolean { + return humanVotesOnAccount(state, person).length > 0; +} + +/** + * The deciding votes (specs/behaviors/person-lifecycle.md → Deciding votes): + * the human votes on this account when there are any; otherwise the votes of + * agents that hold an active grant at `now`. + */ +export function decidingVotes( + state: InMemoryState, + person: Pick, + now: Date = new Date(), +): PersonEvaluation[] { + const humans = humanVotesOnAccount(state, person); + if (humans.length > 0) return humans; + return agentVotesOnAccount(state, person).filter((v) => { + const agentSlug = evaluatorSlug(v.evaluator); + return agentSlug !== null && agentHasActiveGrant(state, agentSlug, now); + }); +} + +/** The newest of `votes`, or null. */ +export function newestVote(votes: readonly PersonEvaluation[]): PersonEvaluation | null { + let newest: PersonEvaluation | null = null; + for (const v of votes) if (!newest || Date.parse(v.evaluatedAt) > Date.parse(newest.evaluatedAt)) newest = v; + return newest; +} + +/** + * The newest agent vote on this account when no human has voted on it, else + * null — what the roster shows as `stagedVote`. + */ +export function stagedAgentVote(state: InMemoryState, person: Pick): PersonEvaluation | null { + if (hasHumanVote(state, person)) return null; + return newestVote(agentVotesOnAccount(state, person)); +} diff --git a/apps/api/tests/agent-votes.test.ts b/apps/api/tests/agent-votes.test.ts new file mode 100644 index 0000000..39799b2 --- /dev/null +++ b/apps/api/tests/agent-votes.test.ts @@ -0,0 +1,374 @@ +/** + * Agent reads and staged votes — specs/api/moderation.md → Agent callers, + * Agent votes, DELETE …/vote; specs/api/agents.md → withdraw-votes; + * specs/behaviors/person-lifecycle.md → Deciding votes, Scheduled spam purge. + */ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { queuedForSpamPurge, spamPurgeDueAt } from '../src/services/spam-purge.js'; +import { + bearer, + createAgent, + daysAgo, + fid, + minutesAgo, + personRecord, + privateProfile, + startAgentTestEnv, + type AgentTestEnv, + type Rec, +} from './helpers/agent-fixtures.js'; + +const ADMIN = 1; +const STAFF = 2; +const N = { + fresh: 10, + freshTwo: 11, + old: 12, + staffVoted: 13, + selfOff: 14, + confirm: 15, + overrule: 16, + revoked: 17, + withdrawA: 18, + withdrawB: 19, + sweep: 20, +}; +const RATE_BASE = 100; +const DAY = 86_400_000; + +interface Row { + slug: string; + email: string | null; + emailDomain: string | null; + deletedAt: string | null; + latestVote: { verdict: string } | null; + stagedVote: { verdict: string; agent: { slug: string }; reasoning: string; flags: string[]; counts: boolean } | null; + purgeAfter: string | null; + signals: string[]; +} + +describe('agent votes', () => { + let env: AgentTestEnv; + let admin: string; + let staff: string; + let bot: { token: string; grantId: string }; + let reader: { token: string; grantId: string }; + + const people: Rec[] = [ + personRecord(ADMIN, 'boss', { accountLevel: 'administrator' }), + personRecord(STAFF, 'staffer', { accountLevel: 'staff' }), + personRecord(N.fresh, 'fresh-shop', { createdAt: minutesAgo(2) }), + personRecord(N.freshTwo, 'fresh-two', { createdAt: daysAgo(1) }), + personRecord(N.old, 'old-member', { createdAt: daysAgo(60) }), + personRecord(N.staffVoted, 'staff-voted', { createdAt: daysAgo(1) }), + personRecord(N.selfOff, 'self-off', { createdAt: daysAgo(2), deletedAt: daysAgo(1) }), + personRecord(N.confirm, 'to-confirm', { createdAt: daysAgo(1) }), + personRecord(N.overrule, 'to-overrule', { createdAt: daysAgo(1) }), + personRecord(N.revoked, 'staged-revoked', { createdAt: daysAgo(1) }), + personRecord(N.withdrawA, 'withdraw-a', { createdAt: daysAgo(1) }), + personRecord(N.withdrawB, 'withdraw-b', { createdAt: daysAgo(1) }), + personRecord(N.sweep, 'sweep-me', { createdAt: daysAgo(1) }), + ...Array.from({ length: 21 }, (_, i) => personRecord(RATE_BASE + i, `rate-${i}`, { createdAt: daysAgo(1) })), + ]; + + beforeAll(async () => { + env = await startAgentTestEnv({ + records: { + people, + 'person-evaluations': [ + { personSlug: 'staff-voted', evaluator: 'human-staffer', verdict: 'legit', confidence: 1, flags: ['manual-override'], evaluatedAt: minutesAgo(30) }, + ], + }, + profiles: [ + privateProfile(fid(N.fresh), 'promo.desk@throwaway.example'), + privateProfile(fid(N.old), 'old.member@example.org'), + ], + }); + admin = await env.cookie(ADMIN, 'administrator'); + staff = await env.cookie(STAFF, 'staff'); + bot = await createAgent(env, admin, { slug: 'example-bot', scopes: ['moderation:read', 'moderation:vote'] }); + reader = await createAgent(env, admin, { slug: 'reader-bot', scopes: ['moderation:read'] }); + }, 90_000); + + afterAll(async () => { + await env.close(); + }); + + const roster = async (query: string, auth: Record): Promise => { + const res = await env.app.inject({ method: 'GET', url: `/api/admin/members?perPage=200${query}`, ...auth }); + expect(res.statusCode, res.body).toBe(200); + return res.json<{ data: Row[] }>().data; + }; + const row = async (slug: string): Promise => + (await roster('', { cookies: { cfp_session: staff } })).find((r) => r.slug === slug)!; + const vote = (slug: string, payload: Rec, auth: Record = bearer(bot.token)) => + env.app.inject({ method: 'POST', url: `/api/admin/members/${slug}/vote`, payload, ...auth }); + const unvote = (slug: string, auth: Record = bearer(bot.token)) => + env.app.inject({ method: 'DELETE', url: `/api/admin/members/${slug}/vote`, ...auth }); + const spam = { verdict: 'spam', reasoning: 'Business name; GitHub account 3h old.', flags: ['business-name', 'github-new-account'] }; + const code = (res: { json: () => T }) => res.json<{ error: { code: string } }>().error.code; + + describe('reading the roster', () => { + it('an agent sees the email domain, never the address, and searches the domain only', async () => { + const rows = await roster('', bearer(reader.token)); + const fresh = rows.find((r) => r.slug === 'fresh-shop')!; + expect(fresh.email).toBeNull(); + expect(fresh.emailDomain).toBe('throwaway.example'); + expect(rows.every((r) => r.email === null)).toBe(true); + + expect((await roster('&q=throwaway.example', bearer(reader.token))).map((r) => r.slug)).toEqual(['fresh-shop']); + expect(await roster('&q=promo.desk', bearer(reader.token))).toEqual([]); + // Staff still search the whole address. + expect((await roster('&q=promo.desk', { cookies: { cfp_session: staff } })).map((r) => r.slug)).toEqual(['fresh-shop']); + expect((await row('fresh-shop')).email).toBe('promo.desk@throwaway.example'); + }); + + it("an agent's detail omits the email but shows a deactivated member", async () => { + const res = await env.app.inject({ method: 'GET', url: '/api/admin/members/self-off', ...bearer(reader.token) }); + expect(res.statusCode).toBe(200); + const d = res.json<{ data: { person: { email: unknown; deletedAt: string | null }; email: unknown; votes: unknown[] } }>().data; + expect(d.person.email ?? null).toBeNull(); + expect(d.email).toBeNull(); + expect(d.person.deletedAt).not.toBeNull(); + const fresh = await env.app.inject({ method: 'GET', url: '/api/admin/members/fresh-shop', ...bearer(reader.token) }); + expect(fresh.body).not.toContain('promo.desk'); + }); + + it('a grant without moderation:vote gets 404 on vote and DELETE; staff cannot DELETE', async () => { + expect((await vote('fresh-two', spam, bearer(reader.token))).statusCode).toBe(404); + expect((await unvote('fresh-two', bearer(reader.token))).statusCode).toBe(404); + expect((await unvote('fresh-two', { cookies: { cfp_session: staff } })).statusCode).toBe(404); + }); + }); + + describe('casting', () => { + it('a spam vote hides the member, is labeled as the agent, and stages a purge in three days', async () => { + const res = await vote('fresh-shop', spam); + expect(res.statusCode, res.body).toBe(200); + const body = res.json<{ data: { person: { deletedAt: string | null }; vote: Rec; purgeAfter: string | null } }>().data; + expect(body.person.deletedAt).not.toBeNull(); + expect(body.vote).toMatchObject({ + evaluator: 'agent-example-bot', + verdict: 'spam', + flags: ['agent', 'business-name', 'github-new-account'], + reasoning: spam.reasoning, + }); + expect(body.vote['confidence']).toBeUndefined(); + expect(Date.parse(body.purgeAfter!) - Date.parse(body.vote['evaluatedAt'] as string)).toBe(3 * DAY); + + const r = await row('fresh-shop'); + expect(r.latestVote).toBeNull(); + expect(r.stagedVote).toMatchObject({ verdict: 'spam', agent: { slug: 'example-bot' }, flags: ['business-name', 'github-new-account'], counts: true }); + expect((await roster('&vote=none', { cookies: { cfp_session: staff } })).map((x) => x.slug)).toContain('fresh-shop'); + expect((await roster('&vote=staged', { cookies: { cfp_session: staff } })).map((x) => x.slug)).toEqual(['fresh-shop']); + }); + + it('commits as the agent, with Actor-Kind and Grant-Id, never the token', async () => { + const log = await env.git('log', '-1', '--format=%an <%ae>%n%B'); + expect(log).toContain('Example example-bot '); + expect(log).toContain('example-bot: POST /api/admin/members/fresh-shop/vote'); + expect(log).toContain('Action: moderation.vote'); + expect(log).toContain('Actor-Kind: agent'); + expect(log).toContain(`Grant-Id: ${bot.grantId}`); + expect(log).toContain('Actor-Account-Level: user'); + expect(log).not.toContain(bot.token); + }); + + it('requires reasoning and well-formed flags (422)', async () => { + expect((await vote('fresh-two', { verdict: 'spam' })).statusCode).toBe(422); + expect((await vote('fresh-two', { verdict: 'spam', reasoning: ' ' })).statusCode).toBe(422); + expect((await vote('fresh-two', { ...spam, flags: ['Not A Tag'] })).statusCode).toBe(422); + expect((await vote('fresh-two', { ...spam, flags: Array.from({ length: 11 }, (_, i) => `f${i}`) })).statusCode).toBe(422); + }); + + it('humans are final: 409 human_verdict_exists', async () => { + const res = await vote('staff-voted', spam); + expect(res.statusCode).toBe(409); + expect(code(res)).toBe('human_verdict_exists'); + }); + + it('never on staff (409 protected_account), itself or another agent (422)', async () => { + const onStaff = await vote('staffer', spam); + expect(onStaff.statusCode).toBe(409); + expect(code(onStaff)).toBe('protected_account'); + expect((await vote('example-bot', spam)).statusCode).toBe(422); + expect((await vote('reader-bot', spam)).statusCode).toBe(422); + // Staff cannot vote on an agent account either. + expect((await vote('reader-bot', { verdict: 'spam' }, { cookies: { cfp_session: staff } })).statusCode).toBe(422); + }); + + it('spam only on members under 30 days old (409 member_too_old); legit has no age limit', async () => { + const res = await vote('old-member', spam); + expect(res.statusCode).toBe(409); + expect(code(res)).toBe('member_too_old'); + const legit = await vote('old-member', { verdict: 'legit', reasoning: 'Long-standing member with projects.' }); + expect(legit.statusCode).toBe(200); + }); + }); + + describe('withdrawing its own vote (DELETE)', () => { + it('un-hides the member it hid; a second DELETE is 404', async () => { + const res = await unvote('fresh-shop'); + expect(res.statusCode, res.body).toBe(200); + const body = res.json<{ data: { person: { deletedAt: string | null }; stagedVote: unknown } }>().data; + expect(body.person.deletedAt).toBeNull(); + expect(body.stagedVote).toBeNull(); + expect((await unvote('fresh-shop')).statusCode).toBe(404); + const log = await env.git('log', '-1', '--format=%B'); + expect(log).toContain('example-bot: DELETE /api/admin/members/fresh-shop/vote'); + }); + + it('agent legit un-hides only what moderation hid (never a self-deactivation)', async () => { + const res = await vote('self-off', { verdict: 'legit', reasoning: 'Real bio, joined a project.' }); + expect(res.statusCode).toBe(200); + expect(res.json<{ data: { person: { deletedAt: string | null } } }>().data.person.deletedAt).not.toBeNull(); + }); + }); + + describe('staff on a staged row', () => { + it('Not spam overrules: un-hides, stops the purge, and the agent may not re-vote or withdraw', async () => { + expect((await vote('to-overrule', spam)).statusCode).toBe(200); + const res = await vote('to-overrule', { verdict: 'legit' }, { cookies: { cfp_session: staff } }); + expect(res.statusCode).toBe(200); + const r = await row('to-overrule'); + expect(r.deletedAt).toBeNull(); + expect(r.purgeAfter).toBeNull(); + expect(r.stagedVote).toBeNull(); + expect(r.latestVote?.verdict).toBe('legit'); + + const again = await vote('to-overrule', spam); + expect(code(again)).toBe('human_verdict_exists'); + const withdraw = await unvote('to-overrule'); + expect(withdraw.statusCode).toBe(409); + expect(code(withdraw)).toBe('human_verdict_exists'); + + const detail = await env.app.inject({ method: 'GET', url: '/api/admin/members/to-overrule', cookies: { cfp_session: staff } }); + const votes = detail.json<{ data: { votes: Array<{ kind: string; verdict: string }> } }>().data.votes; + expect(votes.map((v) => `${v.kind}:${v.verdict}`).sort()).toEqual(['agent:spam', 'human:legit']); + }); + + it('Spam confirms and restarts the three days from the staff vote', async () => { + const agentVote = await vote('to-confirm', spam); + const agentAt = Date.parse(agentVote.json<{ data: { vote: { evaluatedAt: string } } }>().data.vote.evaluatedAt); + await new Promise((r) => setTimeout(r, 20)); + const res = await vote('to-confirm', { verdict: 'spam' }, { cookies: { cfp_session: staff } }); + const staffAt = Date.parse(res.json<{ data: { vote: { evaluatedAt: string } } }>().data.vote.evaluatedAt); + expect(staffAt).toBeGreaterThan(agentAt); + const r = await row('to-confirm'); + expect(Date.parse(r.purgeAfter!)).toBe(staffAt + 3 * DAY); + expect(r.stagedVote).toBeNull(); + expect(r.latestVote?.verdict).toBe('spam'); + }); + }); + + describe('volume', () => { + it('20 spam votes in 24 hours; the 21st is 429 with Retry-After', async () => { + const rate = await createAgent(env, admin, { slug: 'busy-bot', scopes: ['moderation:read', 'moderation:vote'] }); + for (let i = 0; i < 20; i++) { + const res = await vote(`rate-${i}`, spam, bearer(rate.token)); + expect(res.statusCode, `vote ${i}: ${res.body}`).toBe(200); + } + const res = await vote('rate-20', spam, bearer(rate.token)); + expect(res.statusCode).toBe(429); + const retry = Number(res.headers['retry-after']); + expect(retry).toBeGreaterThan(23 * 3600); + expect(retry).toBeLessThanOrEqual(24 * 3600); + // A legit vote is not counted. + expect((await vote('rate-20', { verdict: 'legit', reasoning: 'Looks real.' }, bearer(rate.token))).statusCode).toBe(200); + }, 120_000); + }); + + describe('revoking the grant', () => { + it("stops the agent's staged votes from counting toward a purge", async () => { + const temp = await createAgent(env, admin, { slug: 'temp-bot', scopes: ['moderation:read', 'moderation:vote'] }); + expect((await vote('staged-revoked', spam, bearer(temp.token))).statusCode).toBe(200); + expect((await row('staged-revoked')).purgeAfter).not.toBeNull(); + + const revoke = await env.app.inject({ + method: 'POST', + url: `/api/admin/agents/temp-bot/grants/${temp.grantId}/revoke`, + cookies: { cfp_session: admin }, + }); + expect(revoke.statusCode).toBe(200); + const r = await row('staged-revoked'); + expect(r.purgeAfter).toBeNull(); + expect(r.stagedVote?.counts).toBe(false); + expect(r.deletedAt).not.toBeNull(); + const state = env.app.inMemoryState; + const later = new Date(Date.now() + 4 * DAY); + expect(queuedForSpamPurge(state, later).map((p) => p.slug)).not.toContain('staged-revoked'); + // And the token is dead at once. + expect((await vote('fresh-two', spam, bearer(temp.token))).statusCode).toBe(401); + }); + }); + + describe('withdraw-votes (staff)', () => { + it('deletes the staged votes in one commit, un-hides, and leaves votes a human followed', async () => { + const w = await createAgent(env, admin, { slug: 'wrong-bot', scopes: ['moderation:read', 'moderation:vote'] }); + expect((await vote('withdraw-a', spam, bearer(w.token))).statusCode).toBe(200); + expect((await vote('withdraw-b', spam, bearer(w.token))).statusCode).toBe(200); + expect((await vote('withdraw-b', { verdict: 'spam' }, { cookies: { cfp_session: staff } })).statusCode).toBe(200); + + const before = Number((await env.git('rev-list', '--count', 'HEAD')).trim()); + const url = '/api/admin/agents/wrong-bot/withdraw-votes'; + expect((await env.app.inject({ method: 'POST', url, ...bearer(w.token) })).statusCode).toBe(404); + const res = await env.app.inject({ method: 'POST', url, cookies: { cfp_session: staff }, payload: {} }); + expect(res.statusCode, res.body).toBe(200); + expect(res.json<{ data: { withdrawn: number; reactivated: number } }>().data).toEqual({ withdrawn: 1, reactivated: 1 }); + expect(Number((await env.git('rev-list', '--count', 'HEAD')).trim())).toBe(before + 1); + expect(await env.git('log', '-1', '--format=%B')).toContain('Action: agent.withdraw-votes'); + + expect((await row('withdraw-a')).deletedAt).toBeNull(); + const b = await env.app.inject({ method: 'GET', url: '/api/admin/members/withdraw-b', cookies: { cfp_session: staff } }); + const votes = b.json<{ data: { votes: Array<{ kind: string }> } }>().data.votes; + expect(votes.map((v) => v.kind).sort()).toEqual(['agent', 'human']); + }); + }); + + describe('the agents list', () => { + it('counts staged votes on people no human has voted on', async () => { + const res = await env.app.inject({ method: 'GET', url: '/api/admin/agents', cookies: { cfp_session: staff } }); + const busy = res.json<{ data: Array<{ person: { slug: string }; stagedVotes: { spam: number; legit: number } }> }>().data.find( + (a) => a.person.slug === 'busy-bot', + )!; + expect(busy.stagedVotes).toEqual({ spam: 20, legit: 1 }); + }); + }); + + describe('the scheduled sweep', () => { + it('purges an agent-only spam member after three days, listed as staged, with Agent-Staged', async () => { + expect((await vote('sweep-me', spam)).statusCode).toBe(200); + const later = new Date(Date.now() + 3 * DAY + 60_000); + const queued = queuedForSpamPurge(env.app.inMemoryState, later).map((p) => p.slug); + expect(queued).toContain('sweep-me'); + expect(queued).not.toContain('staged-revoked'); + + const purged = await env.app.spamPurge.sweep(later); + expect(purged).toContain('sweep-me'); + const msg = await env.git('log', '-1', '--format=%B'); + expect(msg).toContain('sweep-me (staged by agent-example-bot)'); + // to-confirm was decided by staff: listed plainly. + expect(msg).toMatch(/^to-confirm$/m); + // sweep-me plus busy-bot's twenty: queued on agent votes alone. + expect(msg).toMatch(/^Agent-Staged: 21$/m); + }); + + it('never queues an agent account', async () => { + const state = env.app.inMemoryState; + const agent = state.people.get(state.personIdBySlug.get('reader-bot')!)!; + const hidden = { ...agent, deletedAt: daysAgo(10) }; + state.personEvaluations.set('reader-bot/human-staffer', { + personSlug: 'reader-bot', evaluator: 'human-staffer', verdict: 'spam', confidence: 1, flags: [], evaluatedAt: new Date().toISOString(), + }); + state.evaluationsByPerson.set('reader-bot', new Set(['reader-bot/human-staffer'])); + try { + expect(spamPurgeDueAt(state, hidden, new Date(Date.now() + 10 * DAY))).toBeNull(); + } finally { + state.personEvaluations.delete('reader-bot/human-staffer'); + state.evaluationsByPerson.delete('reader-bot'); + } + }); + }); + +}); diff --git a/apps/api/tests/prune-spam.test.ts b/apps/api/tests/prune-spam.test.ts index 99f95d5..34f972a 100644 --- a/apps/api/tests/prune-spam.test.ts +++ b/apps/api/tests/prune-spam.test.ts @@ -218,6 +218,31 @@ describe('prune-spam', () => { expect(await slugsOn(data.path, 'published', 'people')).toEqual(['reborn']); }); + it('an agent legit protects; an agent spam never prunes; an earlier account\'s agent vote is ignored', async () => { + const agentVote = (personSlug: string, verdict: Evaluation['verdict'], evaluatedAt = NOW): Evaluation => ({ + personSlug, evaluator: 'agent-example-bot', verdict, flags: ['agent'], reasoning: 'staged', evaluatedAt, + }); + const { data, evaluationsPath } = await setup({ + people: [ + person(1, 'agent-cleared'), + person(2, 'agent-staged'), + { ...person(3, 'reborn-two'), createdAt: LATER, updatedAt: LATER }, + ], + machineEvals: [ + machine('agent-cleared', 'spam', 0.95), + machine('reborn-two', 'spam', 0.95), + ], + humanVotes: [ + agentVote('agent-cleared', 'legit'), + agentVote('agent-staged', 'spam'), + // Cast on an earlier account under the slug: it does not protect the new one. + agentVote('reborn-two', 'legit', NOW), + ], + }); + await pruneSpam(args(data, evaluationsPath)); + expect(await slugsOn(data.path, 'published', 'people')).toEqual(['agent-cleared', 'agent-staged']); + }); + it('creates the local branch from origin/ on a fresh clone', async () => { const { data, evaluationsPath } = await setup({ people: [person(1, 'spammer'), person(2, 'kept')], From 2296f2b0a305597efdcf6a6c973e6fc8b6944e48 Mon Sep 17 00:00:00 2001 From: GrittyBot <30755786+codeforphilly-bot@users.noreply.github.com> Date: Thu, 12 Nov 2026 19:18:03 +0000 Subject: [PATCH 09/23] feat(api): agent purge of new members who posted spam POST /api/admin/members/:slug/purge with reason, flags, evidence and dryRun. Every precondition is enforced in the API, one commit per purge. Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_01S8dthiZS2vwh674eh2DDQs --- apps/api/src/plugins/services.ts | 3 + apps/api/src/routes/moderation.ts | 86 ++++- apps/api/src/services/agent-purge.ts | 429 +++++++++++++++++++++++ apps/api/src/services/project-purge.ts | 11 + apps/api/tests/agent-purge.test.ts | 414 ++++++++++++++++++++++ apps/api/tests/helpers/agent-fixtures.ts | 3 + 6 files changed, 945 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/services/agent-purge.ts create mode 100644 apps/api/tests/agent-purge.test.ts diff --git a/apps/api/src/plugins/services.ts b/apps/api/src/plugins/services.ts index 718464d..f9cc8a5 100644 --- a/apps/api/src/plugins/services.ts +++ b/apps/api/src/plugins/services.ts @@ -33,6 +33,7 @@ import { GitHubAccountService } from '../services/github-account.js'; import { AccountClaimService } from '../services/account-claim.js'; import { ModerationService, ModerationWriteService } from '../services/moderation.js'; import { AgentService } from '../services/agents.js'; +import { AgentPurgeService } from '../services/agent-purge.js'; import { probeGitHubUser } from '../auth/github-client.js'; import { LoggingNotifier, type Notifier } from '../notify/index.js'; import { EmailNotifier } from '../notify/email-notifier.js'; @@ -66,6 +67,7 @@ declare module 'fastify' { moderation: ModerationService; moderationWrite: ModerationWriteService; agents: AgentService; + agentPurge: AgentPurgeService; }; /** Shared in-memory state — write routes call StateApply.apply against this. */ inMemoryState: InMemoryState; @@ -156,6 +158,7 @@ async function servicesPlugin(fastify: FastifyInstance): Promise { ), moderationWrite: new ModerationWriteService(state), agents: new AgentService(state, fastify.store.private), + agentPurge: new AgentPurgeService(state, fastify.store.private), }); } diff --git a/apps/api/src/routes/moderation.ts b/apps/api/src/routes/moderation.ts index 412debc..7c545b3 100644 --- a/apps/api/src/routes/moderation.ts +++ b/apps/api/src/routes/moderation.ts @@ -5,16 +5,19 @@ * GET /api/admin/members/:slug — staff | agent:moderation:read * POST /api/admin/members/:slug/vote — staff | agent:moderation:vote * DELETE /api/admin/members/:slug/vote — agent:moderation:vote + * POST /api/admin/members/:slug/purge — agent:moderation:purge * * Anyone else gets 404: like the other staff surfaces, the endpoints' * existence is not a signal. An agent reads the roster without emails and * its votes are staged (moderation.md → Agent callers, Agent votes). */ import type { FastifyInstance } from 'fastify'; +import { uuidv7 } from 'uuidv7'; import { ok, paginated } from '../lib/response.js'; import { ApiNotFoundError, ApiValidationError } from '../lib/errors.js'; import { getCallerSession } from '../services/permissions.js'; -import { buildTransactionOptions } from '../store/commit-meta.js'; +import { buildTransactionOptions, withInlineTrailers } from '../store/commit-meta.js'; +import { evidenceTrailer, validatePurgeBody, type AgentPurgeInput } from '../services/agent-purge.js'; import { agentCaller, moderationCaller } from '../auth/caller.js'; import type { MemberOrigin, VoteFilter } from '../services/moderation.js'; import { findOrphanPrivateData, purgeOrphanPrivateData } from '../services/private-orphans.js'; @@ -186,6 +189,87 @@ export async function moderationRoutes(fastify: FastifyInstance): Promise }, ); + // POST /api/admin/members/:slug/purge — an agent purges a new member who + // posted spam (moderation.md → Agent purges). One commit per purge. + fastify.post( + '/api/admin/members/:slug/purge', + { + schema: { + tags: ['moderation'], + summary: 'Purge a new member who posted spam, citing the evidence (agents only)', + params: { type: 'object', properties: { slug: { type: 'string' } }, required: ['slug'] }, + body: { + type: 'object', + properties: { + reason: { type: 'string' }, + flags: { type: 'array', items: { type: 'string' } }, + evidence: { + type: 'array', + items: { + type: 'object', + properties: { + record: { type: 'string' }, + field: { type: 'string' }, + project: { type: 'string' }, + number: { type: 'integer' }, + slug: { type: 'string' }, + roleId: { type: 'string' }, + }, + required: ['record'], + additionalProperties: false, + }, + }, + dryRun: { type: 'boolean' }, + }, + required: ['reason', 'evidence'], + additionalProperties: false, + }, + }, + }, + async (request) => { + const caller = agentCaller(request, 'moderation:purge'); + const { slug } = request.params as { slug: string }; + const input = request.body as AgentPurgeInput; + const service = fastify.services.agentPurge; + const member = service.memberOr404(slug); + const body = validatePurgeBody(member.slug, input); + const head = async (): Promise => (await fastify.publicRepo.resolveRef('HEAD')) ?? 'unknown'; + + if (input.dryRun) { + // Every check, under the write lock, and nothing written. + const preview = await fastify.publicRepo.withLock(async () => { + const now = new Date(); + const { deleted } = await service.check(caller.agent.person, caller.agent.grant.id, member, body, now); + const purge = service.record(uuidv7(), caller.agent.person, caller.agent.grant.id, member, body, await head(), now); + return { purge, deleted }; + }); + return ok({ ...preview, dryRun: true }); + } + + const purgeId = uuidv7(); + const opts = withInlineTrailers( + buildTransactionOptions({ + request, + session: caller.session, + action: 'person.purge', + subjectType: 'person', + subjectId: member.id, + subjectSlug: member.slug, + responseCode: 200, + summary: body.reason, + extraTrailers: { 'Purge-Id': purgeId }, + }), + body.evidence.map((e) => ['Evidence', evidenceTrailer(e)] as const), + ); + const result = await fastify.store.transact(opts, async (tx) => + service.purge(tx, caller.session, member, body, purgeId, head), + ); + result.value.stateApply.apply(fastify.inMemoryState, fastify.fts); + await fastify.sessionMetadata.removeAllFor(member.id, fastify.store.private); + return ok({ purge: result.value.purge, deleted: result.value.deleted, dryRun: false }); + }, + ); + // POST /api/admin/private-data/purge-orphans (administrator only) // Spec: specs/api/moderation.md → POST /api/admin/private-data/purge-orphans fastify.post( diff --git a/apps/api/src/services/agent-purge.ts b/apps/api/src/services/agent-purge.ts new file mode 100644 index 0000000..6fa33e8 --- /dev/null +++ b/apps/api/src/services/agent-purge.ts @@ -0,0 +1,429 @@ +/** + * Agent purge — specs/api/moderation.md → `POST /api/admin/members/:slug/purge` + * and "Agent purges"; specs/behaviors/person-lifecycle.md → Agent purge. + * + * An agent holding `moderation:purge` purges a brand-new member who has + * *posted* spam, at once, citing the records that are the spam. The site + * checks the evidence is the member's, not empty, and goes with the purge, + * and that the eight agent purge rules hold; it does not judge the content. + * The cascade is the administrator's purge; one commit per purge, with a + * `spam-purges` record and the agent's `spam` vote as the durable "why". + */ +import { + PersonEvaluationSchema, + SPAM_EVIDENCE_RECORDS, + SpamPurgeSchema, + agentEvaluatorFor, + type Person, + type PersonEvaluation, + type Project, + type SpamEvidenceRecord, + type SpamPurge, + type SpamPurgeEvidence, +} from '@cfp/shared/schemas'; + +import { isAgentAccount, isGrantActive } from '../auth/agent-grant.js'; +import type { SessionContext } from '../auth/middleware.js'; +import { ApiNotFoundError, ApiValidationError, ConflictError, RateLimitedError } from '../lib/errors.js'; +import { projectBySlug, projectChildKey, type InMemoryState } from '../store/memory/state.js'; +import type { PrivateStore } from '../store/private/index.js'; +import { StateApply } from '../store/state-apply.js'; +import type { DualStoreTx } from '../store/store.js'; +import { validateFlags } from './moderation.js'; +import { purgePeople } from './person-purge.js'; +import { isTouchedByOthers, projectsCreatedBy, purgeableProjects } from './project-purge.js'; +import { agentVotesOnAccount, hasHumanVote } from './votes.js'; + +/** Agent purge limits — specs/api/moderation.md → Agent purges. */ +export const AGENT_PURGES_PER_DAY = 5; +export const AGENT_PURGE_MAX_AGE_DAYS = 7; +const DAY_MS = 24 * 60 * 60 * 1000; +const MAX_EVIDENCE = 5; + +/** One cited record, as the agent sends it. */ +export interface EvidenceInput { + readonly record: string; + readonly field?: string; + readonly project?: string; + readonly number?: number; + readonly slug?: string; + readonly roleId?: string; +} + +export interface AgentPurgeInput { + readonly reason: string; + readonly flags?: readonly string[]; + readonly evidence: readonly EvidenceInput[]; + readonly dryRun?: boolean; +} + +export interface PurgeDeleted { + readonly projects: Array<{ slug: string; title: string }>; + readonly memberships: number; + readonly updates: number; + readonly buzz: number; + readonly helpWantedRoles: number; + readonly rsvps: number; +} + +/** A validated request: the reason, flags, and each evidence item with its data-repo path. */ +export interface ValidatedPurge { + readonly reason: string; + readonly flags: string[]; + readonly evidence: Array; +} + +/** + * Whether `text` carries a URL: a scheme, a `www.` host, or a bare domain + * followed by a path (`shop.example/deals`). A purge reason is committed + * forever; it must cite the record, never republish the spam link. + */ +export function containsUrl(text: string): boolean { + return ( + /[a-z][a-z0-9+.-]*:\/\//i.test(text) || + /\bwww\./i.test(text) || + /\b[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*\.[a-z]{2,}\/\S*/i.test(text) + ); +} + +const TEXT_FIELDS: Readonly> = { + person: ['bio'], + project: ['title', 'summary', 'overview'], + 'project-update': null, + 'project-buzz': null, + 'help-wanted-role': null, +}; + +/** + * Validate the body and give each evidence item its data-repo path — a pure + * function of the request and the member's slug, so the commit's `Evidence:` + * trailers are known before the transaction opens. + */ +export function validatePurgeBody(memberSlug: string, body: AgentPurgeInput): ValidatedPurge { + const fields: Record = {}; + const reason = typeof body.reason === 'string' ? body.reason.trim() : ''; + if (reason.length < 1 || reason.length > 1000) fields['reason'] = 'required, 1–1000 characters'; + else if (containsUrl(reason)) fields['reason'] = 'must not contain a URL: cite the record as evidence instead'; + let flags: string[] = []; + try { + flags = validateFlags(body.flags); + } catch { + fields['flags'] = '0–10 tags matching ^[a-z0-9][a-z0-9:-]{0,39}$'; + } + const items = Array.isArray(body.evidence) ? body.evidence : []; + if (items.length < 1 || items.length > MAX_EVIDENCE) fields['evidence'] = `1–${MAX_EVIDENCE} items`; + if (Object.keys(fields).length > 0) throw new ApiValidationError('Invalid purge request', fields); + + const evidence = items.map((item, i) => { + const bad = (why: string): never => { + throw new ApiValidationError(`Evidence item ${i}: ${why}`, { [`evidence.${i}`]: why }, 'evidence_invalid'); + }; + if (!(SPAM_EVIDENCE_RECORDS as readonly string[]).includes(item.record)) { + bad(`record must be one of ${SPAM_EVIDENCE_RECORDS.join(', ')}`); + } + const record = item.record as SpamEvidenceRecord; + const allowed = TEXT_FIELDS[record]; + if (item.field !== undefined && (allowed === null || !allowed.includes(item.field))) { + bad(allowed === null ? 'this record takes no field' : `field must be one of ${allowed.join(', ')}`); + } + if (record === 'person' && item.field !== 'bio') bad('a person is cited by field "bio"'); + let path: string; + switch (record) { + case 'person': + path = `people/${memberSlug}.toml`; + break; + case 'project': + if (!item.project) bad('project (slug) is required'); + path = `projects/${item.project}.toml`; + break; + case 'project-update': + if (!item.project || !Number.isInteger(item.number)) bad('project and number are required'); + path = `project-updates/${item.project}/${item.number}.toml`; + break; + case 'project-buzz': + if (!item.project || !item.slug) bad('project and slug are required'); + path = `project-buzz/${item.project}/${item.slug}.toml`; + break; + case 'help-wanted-role': + if (!item.project || !item.roleId) bad('project and roleId are required'); + path = `help-wanted-roles/${item.project}/${item.roleId}.toml`; + break; + } + return { record, ...(item.field ? { field: item.field } : {}), path, input: item }; + }); + return { reason, flags, evidence }; +} + +/** The `Evidence:` trailer value for one item: `[#]`. */ +export function evidenceTrailer(e: Pick): string { + return e.field ? `${e.path}#${e.field}` : e.path; +} + +function nonEmpty(value: unknown): boolean { + return typeof value === 'string' && value.trim() !== ''; +} + +export class AgentPurgeService { + readonly #state: InMemoryState; + readonly #privateStore: PrivateStore; + + constructor(state: InMemoryState, privateStore: PrivateStore) { + this.#state = state; + this.#privateStore = privateStore; + } + + /** The member `slug`, or 404. */ + memberOr404(slug: string): Person { + const id = this.#state.personIdBySlug.get(slug); + const person = id ? this.#state.people.get(id) : undefined; + if (!person) throw new ApiNotFoundError(`Person '${slug}' not found`); + return person; + } + + /** + * Every check, in the spec's order, against the current state — call it + * inside the write mutex. Returns what the purge would delete. + */ + async check( + agent: Person, + grantId: string, + member: Person, + body: ValidatedPurge, + now: Date, + ): Promise<{ projects: Project[]; deleted: PurgeDeleted }> { + // The grant may have been revoked since the request began. + const grant = this.#state.agentGrants.get(grantId); + if (!grant || !isGrantActive(grant, now) || !grant.scopes.includes('moderation:purge')) { + throw new ApiNotFoundError('Not found'); + } + if (member.id === agent.id) throw new ApiValidationError('An agent cannot purge itself', { slug: 'self' }); + if (isAgentAccount(this.#state, member.slug)) { + throw new ApiValidationError('Nobody purges an agent account here', { slug: 'agent' }); + } + + // The evidence: each record exists, is the member's, and is not empty. + this.#checkEvidence(member, body); + + // 1. Only ordinary, signed-up members. + if (member.accountLevel !== 'user' || typeof member.legacyId === 'number') { + throw new ConflictError('Only a signed-up ordinary member can be purged by an agent', 'protected_account'); + } + // 2. Only brand-new members. + if (now.getTime() - Date.parse(member.createdAt) > AGENT_PURGE_MAX_AGE_DAYS * DAY_MS) { + throw new ConflictError( + `An agent purges only members who joined in the last ${AGENT_PURGE_MAX_AGE_DAYS} days`, + 'member_too_old', + ); + } + // 3. Humans are final: any human vote on this account. + if (hasHumanVote(this.#state, member)) { + throw new ConflictError('A person has voted on this member', 'human_verdict_exists'); + } + // 4. No legit vote standing (any agent's, its own included). + if (agentVotesOnAccount(this.#state, member).some((v) => v.verdict === 'legit')) { + throw new ConflictError('An agent has voted this member not spam', 'legit_vote_exists'); + } + // 5. Not established: signed into Slack through the site. + const profile = await this.#privateStore.getProfile(member.id); + if (profile?.lastSlackSsoAt) { + throw new ConflictError('This member has signed into Slack; a person decides', 'established_member'); + } + // 6. Nothing shared: no created project anyone else touched, no + // maintainership of a project with another member. A plain membership of + // someone else's project does not block. + for (const project of projectsCreatedBy(this.#state, [member])) { + if (isTouchedByOthers(this.#state, project, [member])) { + throw new ConflictError(`This member created '${project.slug}', which others have touched`, 'shared_project'); + } + } + for (const project of this.#maintained(member)) { + if (this.#hasOtherMember(project, member)) { + throw new ConflictError(`This member maintains '${project.slug}', which has other members`, 'shared_project'); + } + } + // 7. The evidence goes with the purge. + const projects = purgeableProjects(this.#state, [member]); + const purgedSlugs = new Set(projects.map((p) => p.slug)); + for (const [i, e] of body.evidence.entries()) { + if ((e.record === 'project' || e.record === 'help-wanted-role') && !purgedSlugs.has(e.input.project ?? '')) { + throw new ConflictError(`Evidence item ${i} (${e.path}) would survive the purge`, 'evidence_survives'); + } + } + // 8. Volume: five purges per agent in any rolling 24 hours. + const recent = this.#recentPurgeTimes(agent.slug, now.getTime()); + if (recent.length >= AGENT_PURGES_PER_DAY) { + const frees = recent[recent.length - AGENT_PURGES_PER_DAY]! + DAY_MS; + throw new RateLimitedError(Math.max(1, Math.ceil((frees - now.getTime()) / 1000))); + } + + return { projects, deleted: this.#deletedCounts(member, projects) }; + } + + #checkEvidence(member: Person, body: ValidatedPurge): void { + const s = this.#state; + body.evidence.forEach((e, i) => { + const bad = (why: string): never => { + throw new ApiValidationError(`Evidence item ${i} (${e.path}): ${why}`, { [`evidence.${i}`]: why }, 'evidence_invalid'); + }; + const { input } = e; + switch (e.record) { + case 'person': + if (!nonEmpty(member.bio)) bad('the bio is empty'); + return; + case 'project': { + const project = projectBySlug(s, input.project!); + if (!project) bad('no such project'); + if (project!.createdById !== member.id) bad('not created by this member'); + const text = e.field ? (project as Record)[e.field] : project!.title; + if (!nonEmpty(text)) bad('the cited field is empty'); + return; + } + case 'project-update': { + const id = s.updateByProjectAndNumber.get(projectChildKey(input.project!, input.number!)); + const update = id ? s.projectUpdates.get(id) : undefined; + if (!update) bad('no such update'); + if (update!.authorId !== member.id) bad('not posted by this member'); + if (!nonEmpty(update!.body)) bad('the update is empty'); + return; + } + case 'project-buzz': { + const id = s.buzzByProjectAndSlug.get(projectChildKey(input.project!, input.slug!)); + const buzz = id ? s.projectBuzz.get(id) : undefined; + if (!buzz) bad('no such buzz item'); + if (buzz!.postedById !== member.id) bad('not posted by this member'); + if (!nonEmpty(buzz!.headline) && !nonEmpty(buzz!.summary)) bad('the buzz item is empty'); + return; + } + case 'help-wanted-role': { + const role = s.helpWantedRoles.get(input.roleId!); + if (!role || role.projectSlug !== input.project) bad('no such role'); + if (role!.postedById !== member.id) bad('not posted by this member'); + if (!nonEmpty(role!.title) && !nonEmpty(role!.description)) bad('the role is empty'); + return; + } + } + }); + } + + /** Projects `member` maintains: by membership flag or as the project's maintainer. */ + #maintained(member: Person): Project[] { + const out = new Map(); + for (const p of this.#state.projects.values()) if (p.maintainerId === member.id) out.set(p.id, p); + for (const mid of this.#state.membershipsByPerson.get(member.slug) ?? []) { + const m = this.#state.projectMemberships.get(mid); + const p = m?.isMaintainer ? projectBySlug(this.#state, m.projectSlug) : undefined; + if (p) out.set(p.id, p); + } + return [...out.values()]; + } + + #hasOtherMember(project: Project, member: Person): boolean { + for (const mid of this.#state.membershipsByProject.get(project.slug) ?? []) { + const m = this.#state.projectMemberships.get(mid); + if (m && m.personSlug !== member.slug) return true; + } + return false; + } + + #recentPurgeTimes(agentSlug: string, now: number): number[] { + const by = agentEvaluatorFor(agentSlug); + return [...this.#state.spamPurges.values()] + .filter((p) => p.by === by && now - Date.parse(p.purgedAt) < DAY_MS) + .map((p) => Date.parse(p.purgedAt)) + .sort((a, b) => a - b); + } + + /** What a purge of `member` (with `projects`) deletes, counted. */ + #deletedCounts(member: Person, projects: readonly Project[]): PurgeDeleted { + const s = this.#state; + const onPurged = new Set(projects.map((p) => p.slug)); + const purgedIds = new Set(projects.map((p) => p.id)); + let memberships = 0; + for (const m of s.projectMemberships.values()) if (m.personSlug === member.slug || onPurged.has(m.projectSlug)) memberships++; + let updates = 0; + for (const u of s.projectUpdates.values()) if (u.authorId === member.id || onPurged.has(u.projectSlug)) updates++; + let buzz = 0; + for (const b of s.projectBuzz.values()) if (b.postedById === member.id || onPurged.has(b.projectSlug)) buzz++; + let helpWantedRoles = 0; + for (const r of s.helpWantedRoles.values()) if (onPurged.has(r.projectSlug)) helpWantedRoles++; + let rsvps = 0; + for (const r of s.eventRsvps.values()) { + if (r.personSlug === member.slug || (r.projectId && purgedIds.has(r.projectId))) rsvps++; + } + return { + projects: projects.map((p) => ({ slug: p.slug, title: p.title })), + memberships, + updates, + buzz, + helpWantedRoles, + rsvps, + }; + } + + /** The `spam-purges` record this purge writes. */ + record( + purgeId: string, + agent: Person, + grantId: string, + member: Person, + body: ValidatedPurge, + beforeCommit: string, + now: Date, + ): SpamPurge { + return SpamPurgeSchema.parse({ + id: purgeId, + personSlug: member.slug, + personId: member.id, + ...(typeof member.githubUserId === 'number' ? { githubUserId: member.githubUserId } : {}), + purgedAt: now.toISOString(), + by: agentEvaluatorFor(agent.slug), + grantId, + reason: body.reason, + flags: body.flags, + evidence: body.evidence.map(({ record, field, path }) => ({ record, ...(field ? { field } : {}), path })), + beforeCommit, + blocksSignIn: true, + }); + } + + /** + * Run the purge inside `tx`: re-check everything, then the cascade, the + * agent's `spam` vote (flags `agent`, `purged`) and the `spam-purges` + * record, in one commit. + */ + async purge( + tx: DualStoreTx, + session: SessionContext, + member: Person, + body: ValidatedPurge, + purgeId: string, + beforeCommit: () => Promise, + ): Promise<{ purge: SpamPurge; deleted: PurgeDeleted; vote: PersonEvaluation; stateApply: StateApply }> { + const agent = session.person; + const grantId = session.agent?.grantId; + if (!agent || !grantId) throw new ApiNotFoundError('Not found'); + const now = new Date(); + const current = this.memberOr404(member.slug); + if (current.id !== member.id) throw new ApiNotFoundError(`Person '${member.slug}' not found`); + const { deleted } = await this.check(agent, grantId, current, body, now); + + const stateApply = new StateApply(); + await purgePeople(tx, this.#state, stateApply, [current]); + + const vote = PersonEvaluationSchema.parse({ + personSlug: current.slug, + evaluator: agentEvaluatorFor(agent.slug), + verdict: 'spam', + flags: ['agent', 'purged', ...body.flags.filter((f) => f !== 'agent' && f !== 'purged')], + reasoning: body.reason, + evaluatedAt: now.toISOString(), + }); + await tx.public['person-evaluations'].upsert(vote); + stateApply.upsertPersonEvaluation(vote); + + const purge = this.record(purgeId, agent, grantId, current, body, await beforeCommit(), now); + await tx.public['spam-purges'].upsert(purge); + stateApply.upsertSpamPurge(purge); + return { purge, deleted, vote, stateApply }; + } +} diff --git a/apps/api/src/services/project-purge.ts b/apps/api/src/services/project-purge.ts index 55c7b68..d503910 100644 --- a/apps/api/src/services/project-purge.ts +++ b/apps/api/src/services/project-purge.ts @@ -55,6 +55,17 @@ function touchedByOthers(state: InMemoryState, project: Project, batch: Batch): return false; } +/** Projects someone in `people` created (the field, else the maintainer for older records). */ +export function projectsCreatedBy(state: InMemoryState, people: readonly Person[]): Project[] { + const batch = batchOf(people); + return [...state.projects.values()].filter((p) => createdBy(p, batch)); +} + +/** Whether anyone outside `people` has a record on `project`. */ +export function isTouchedByOthers(state: InMemoryState, project: Project, people: readonly Person[]): boolean { + return touchedByOthers(state, project, batchOf(people)); +} + /** Live or soft-deleted projects a purge of `people` deletes, by title. */ export function purgeableProjects(state: InMemoryState, people: readonly Person[]): Project[] { const batch = batchOf(people); diff --git a/apps/api/tests/agent-purge.test.ts b/apps/api/tests/agent-purge.test.ts new file mode 100644 index 0000000..ad28932 --- /dev/null +++ b/apps/api/tests/agent-purge.test.ts @@ -0,0 +1,414 @@ +/** + * Agent purge — specs/api/moderation.md → POST /api/admin/members/:slug/purge + * and "Agent purges" (rules 1–8, effects, the commit); + * specs/behaviors/person-lifecycle.md → Agent purge. + * + * The two shapes the feature was designed against, described generically: + * - case 1: a business name and, within two minutes, a promotional bio with + * a link; the agent purges citing the bio; + * - case 2: a site name that self-joined someone else's project as a plain + * member and set a promotional bio; the purge takes their membership and + * leaves the project and everyone else's records alone. + */ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +import { containsUrl } from '../src/services/agent-purge.js'; +import { + bearer, + createAgent, + daysAgo, + fid, + minutesAgo, + personRecord, + privateProfile, + startAgentTestEnv, + type AgentTestEnv, + type Rec, +} from './helpers/agent-fixtures.js'; + +const ADMIN = 1; +const STAFF = 2; +const MAE = 3; +const HAL = 4; +const N = { + promo: 10, + site: 11, + tooOld: 12, + humanVoted: 13, + agentLegit: 14, + slacker: 15, + sharedCreator: 16, + coMaintainer: 17, + emptyBio: 18, + rolePoster: 19, + imported: 20, + ownProject: 21, +}; +const BURST = 40; +const GH_ID = 987_654; +const PROMO_BIO = 'Promo Shop: the best deals in town. Order now at [our store](https://promo-shop.example/deals)!'; +const NOW_ISO = new Date().toISOString(); +const ROLE_ID = fid(700); +const AVATAR = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]); + +const membership = (n: number, projectSlug: string, personSlug: string, isMaintainer = false): Rec => ({ + id: fid(n), + projectSlug, + personSlug, + role: isMaintainer ? 'Maintainer' : 'Member', + isMaintainer, + joinedAt: minutesAgo(1), + createdAt: minutesAgo(1), + updatedAt: minutesAgo(1), +}); +const project = (n: number, slug: string, creator: number, over: Rec = {}): Rec => ({ + id: fid(n), + slug, + title: `Project ${slug}`, + summary: `Summary of ${slug}`, + stage: 'commenting', + createdById: fid(creator), + maintainerId: fid(creator), + createdAt: daysAgo(30), + updatedAt: daysAgo(30), + ...over, +}); + +describe('agent purge', () => { + let env: AgentTestEnv; + let admin: string; + let staff: string; + let bot: { token: string; grantId: string }; + let voter: { token: string; grantId: string }; + + beforeAll(async () => { + const bio = { bio: 'Cheap streaming, all channels, no subscription. Ask me how.' }; + env = await startAgentTestEnv({ + records: { + people: [ + personRecord(ADMIN, 'boss', { accountLevel: 'administrator' }), + personRecord(STAFF, 'staffer', { accountLevel: 'staff' }), + personRecord(MAE, 'maintainer-mae', { createdAt: daysAgo(300) }), + personRecord(HAL, 'helper-hal', { createdAt: daysAgo(200) }), + personRecord(N.promo, 'promo-shop', { + fullName: 'Promo Shop LLC', + createdAt: minutesAgo(2), + githubUserId: GH_ID, + githubLogin: 'promo-shop', + bio: PROMO_BIO, + avatarKey: 'people/promo-shop/avatar.jpg', + }), + personRecord(N.site, 'site-name', { fullName: 'Stream Site', createdAt: minutesAgo(1), githubUserId: GH_ID + 1, ...bio }), + personRecord(N.tooOld, 'too-old', { createdAt: daysAgo(8), ...bio }), + personRecord(N.humanVoted, 'human-voted', { createdAt: daysAgo(1), ...bio }), + personRecord(N.agentLegit, 'agent-legit', { createdAt: daysAgo(1), ...bio }), + personRecord(N.slacker, 'slacker', { createdAt: daysAgo(1), ...bio }), + personRecord(N.sharedCreator, 'shared-creator', { createdAt: daysAgo(1), ...bio }), + personRecord(N.coMaintainer, 'co-maintainer', { createdAt: daysAgo(1), ...bio }), + personRecord(N.emptyBio, 'empty-bio', { createdAt: daysAgo(1) }), + personRecord(N.rolePoster, 'role-poster', { createdAt: daysAgo(1), ...bio }), + personRecord(N.imported, 'imported', { createdAt: daysAgo(1), legacyId: 1234, ...bio }), + personRecord(N.ownProject, 'own-project', { createdAt: daysAgo(1) }), + ...Array.from({ length: 6 }, (_, i) => personRecord(BURST + i, `burst-${i}`, { createdAt: minutesAgo(5), ...bio })), + ], + projects: [ + project(500, 'real-project', MAE), + project(501, 'shared-proj', N.sharedCreator), + project(502, 'spam-proj', N.ownProject, { summary: 'Buy followers cheap' }), + ], + 'project-memberships': [ + membership(600, 'real-project', 'maintainer-mae', true), + membership(601, 'real-project', 'helper-hal'), + membership(602, 'real-project', 'site-name'), + membership(603, 'shared-proj', 'shared-creator', true), + membership(604, 'shared-proj', 'helper-hal'), + membership(605, 'real-project', 'co-maintainer', true), + membership(606, 'real-project', 'role-poster'), + membership(607, 'spam-proj', 'own-project', true), + ], + 'project-updates': [ + { id: fid(800), projectSlug: 'real-project', authorId: fid(HAL), body: 'Sprint notes', number: 1, createdAt: NOW_ISO, updatedAt: NOW_ISO }, + { id: fid(801), projectSlug: 'real-project', authorId: fid(N.site), body: 'Watch every channel free!', number: 2, createdAt: NOW_ISO, updatedAt: NOW_ISO }, + { id: fid(802), projectSlug: 'spam-proj', authorId: fid(N.ownProject), body: 'Followers for sale', number: 1, createdAt: NOW_ISO, updatedAt: NOW_ISO }, + ], + 'help-wanted-roles': [ + { id: ROLE_ID, projectSlug: 'real-project', postedById: fid(N.rolePoster), title: 'Paid promoter', description: 'Promote my shop', status: 'open', createdAt: NOW_ISO, updatedAt: NOW_ISO }, + ], + 'person-evaluations': [ + { personSlug: 'human-voted', evaluator: 'human-staffer', verdict: 'spam', confidence: 1, flags: ['manual-override'], evaluatedAt: NOW_ISO }, + { personSlug: 'agent-legit', evaluator: 'agent-example-bot', verdict: 'legit', flags: ['agent'], reasoning: 'Looked real.', evaluatedAt: NOW_ISO }, + ], + }, + profiles: [privateProfile(fid(N.slacker), 'slacker@example.org', { lastSlackSsoAt: minutesAgo(10) })], + extra: async (tx) => { + const people = tx.sheet('people'); + const [promo] = (await people.queryAll()).filter((p) => (p as Rec)['slug'] === 'promo-shop'); + await people.setAttachment(promo!, 'avatar.jpg', AVATAR); + }, + }); + admin = await env.cookie(ADMIN, 'administrator'); + staff = await env.cookie(STAFF, 'staff'); + bot = await createAgent(env, admin, { + slug: 'example-bot', + scopes: ['moderation:read', 'moderation:vote', 'moderation:purge'], + }); + voter = await createAgent(env, admin, { slug: 'voter-bot', scopes: ['moderation:read', 'moderation:vote'] }); + }, 90_000); + + afterAll(async () => { + await env.close(); + }); + + const purge = (slug: string, payload: Rec, auth: Record = bearer(bot.token)) => + env.app.inject({ method: 'POST', url: `/api/admin/members/${slug}/purge`, payload, ...auth }); + const bioEvidence = [{ record: 'person', field: 'bio' }]; + const reason = 'Signed up under a business name; 90 s later set a bio advertising that business, with a link.'; + const head = async () => (await env.git('rev-parse', 'HEAD')).trim(); + const code = (res: { json: () => T }) => res.json<{ error: { code: string } }>().error.code; + + /** + * A refused purge answers `status`/`errCode` and writes nothing. Each test + * gets its own agent so the general per-account write cap (30/min) is not + * what answers. + */ + let checker = ''; + const refused = async (slug: string, payload: Rec, status: number, errCode: string) => { + const before = await head(); + const res = await purge(slug, payload, bearer(checker)); + expect(res.statusCode, res.body).toBe(status); + expect(code(res)).toBe(errCode); + expect(await head()).toBe(before); + const dry = await purge(slug, { ...payload, dryRun: true }, bearer(checker)); + expect(dry.statusCode, `dry run: ${dry.body}`).toBe(status); + expect(code(dry)).toBe(errCode); + }; + + describe('who may call it', () => { + it('only an agent with moderation:purge (others 404)', async () => { + const payload = { reason, evidence: bioEvidence }; + expect((await purge('promo-shop', payload, bearer(voter.token))).statusCode).toBe(404); + expect((await purge('promo-shop', payload, { cookies: { cfp_session: staff } })).statusCode).toBe(404); + expect((await purge('promo-shop', payload, { cookies: { cfp_session: admin } })).statusCode).toBe(404); + expect((await purge('promo-shop', payload, {})).statusCode).toBe(404); + }); + }); + + describe('the request', () => { + it('refuses a reason with a URL in any form', async () => { + for (const r of [ + 'Bio links https://promo-shop.example/deals', + 'Bio links www.promo-shop.example', + 'Bio links promo-shop.example/deals today', + 'see ftp://files.example', + ]) { + const res = await purge('promo-shop', { reason: r, evidence: bioEvidence }); + expect(res.statusCode, r).toBe(422); + expect(res.json<{ error: { fields: Record } }>().error.fields['reason']).toMatch(/URL/); + } + expect(containsUrl('Advertises Promo Shop, a business unrelated to civic tech.')).toBe(false); + expect(containsUrl('Sells via promo-shop.example')).toBe(false); + }); + + it('needs 1–5 evidence items, a reason, and good flags', async () => { + expect((await purge('promo-shop', { reason, evidence: [] })).statusCode).toBe(422); + expect((await purge('promo-shop', { reason, evidence: Array(6).fill(bioEvidence[0]) })).statusCode).toBe(422); + expect((await purge('promo-shop', { reason: ' ', evidence: bioEvidence })).statusCode).toBe(422); + expect((await purge('promo-shop', { reason, evidence: bioEvidence, flags: ['BAD FLAG'] })).statusCode).toBe(422); + const noField = await purge('promo-shop', { reason, evidence: [{ record: 'person' }] }); + expect(noField.statusCode).toBe(422); + expect(code(noField)).toBe('evidence_invalid'); + }); + }); + + describe('case 1: a promotional bio, two minutes after sign-up', () => { + it('a dry run passes, says what would go, and writes nothing', async () => { + const before = await head(); + const res = await purge('promo-shop', { reason, flags: ['bio-promo-link'], evidence: bioEvidence, dryRun: true }); + expect(res.statusCode, res.body).toBe(200); + const d = res.json<{ data: { dryRun: boolean; purge: Rec; deleted: Rec } }>().data; + expect(d.dryRun).toBe(true); + expect(d.purge).toMatchObject({ personSlug: 'promo-shop', by: 'agent-example-bot', githubUserId: GH_ID, blocksSignIn: true }); + expect(d.deleted).toEqual({ projects: [], memberships: 0, updates: 0, buzz: 0, helpWantedRoles: 0, rsvps: 0 }); + expect(await head()).toBe(before); + expect(env.app.inMemoryState.personIdBySlug.has('promo-shop')).toBe(true); + }); + + it('purges in one commit, as the agent, with the reason and trailers', async () => { + const before = await head(); + const res = await purge('promo-shop', { reason, flags: ['bio-promo-link', 'business-name'], evidence: bioEvidence }); + expect(res.statusCode, res.body).toBe(200); + const d = res.json<{ data: { purge: Rec; dryRun: boolean } }>().data; + expect(d.dryRun).toBe(false); + expect(Number((await env.git('rev-list', '--count', `${before}..HEAD`)).trim())).toBe(1); + + const author = (await env.git('log', '-1', '--format=%an <%ae>')).trim(); + expect(author).toBe('Example example-bot '); + const msg = await env.git('log', '-1', '--format=%B'); + expect(msg.split('\n')[0]).toBe('example-bot: POST /api/admin/members/promo-shop/purge'); + expect(msg).toContain(`\n${reason}\n`); + const trailers = ( + await (async () => { + const { execFile } = await import('node:child_process'); + return new Promise((resolve, reject) => { + const child = execFile('git', ['interpret-trailers', '--parse'], (err, out) => (err ? reject(err) : resolve(out))); + child.stdin!.end(msg); + }); + })() + ).split('\n'); + expect(trailers).toEqual( + expect.arrayContaining([ + 'Action: person.purge', + 'Actor-Kind: agent', + `Grant-Id: ${bot.grantId}`, + `Purge-Id: ${String(d.purge['id'])}`, + 'Subject-Type: person', + `Subject-Id: ${fid(N.promo)}`, + 'Subject-Slug: promo-shop', + 'Evidence: people/promo-shop.toml#bio', + ]), + ); + expect(msg).not.toContain(bot.token); + expect(msg).not.toContain('https://'); + + // The spam as it stood is one `git show ^:` away. + expect(await env.git('show', 'HEAD^:people/promo-shop.toml')).toContain('Promo Shop: the best deals'); + expect(await env.git('ls-tree', '-r', '--name-only', 'HEAD', 'people/promo-shop')).toBe(''); + expect(await env.git('show', 'HEAD^:people/promo-shop/avatar.jpg')).toBeTruthy(); + }); + + it('leaves a spam-purges record that blocks the GitHub account, and the agent vote as the why', async () => { + const state = env.app.inMemoryState; + expect(state.personIdBySlug.has('promo-shop')).toBe(false); + const [id] = [...(state.spamPurgesByGithubUserId.get(GH_ID) ?? [])]; + const record = state.spamPurges.get(id!)!; + expect(record).toMatchObject({ + personSlug: 'promo-shop', + personId: fid(N.promo), + githubUserId: GH_ID, + by: 'agent-example-bot', + grantId: bot.grantId, + reason, + flags: ['bio-promo-link', 'business-name'], + evidence: [{ record: 'person', field: 'bio', path: 'people/promo-shop.toml' }], + blocksSignIn: true, + }); + expect(record.beforeCommit).toBe((await env.git('rev-parse', 'HEAD^')).trim()); + expect(await env.git('ls-tree', '--name-only', 'HEAD', `spam-purges/promo-shop/${record.id}.toml`)).toContain(record.id); + const vote = state.personEvaluations.get('promo-shop/agent-example-bot')!; + expect(vote).toMatchObject({ verdict: 'spam', flags: ['agent', 'purged', 'bio-promo-link', 'business-name'], reasoning: reason }); + }); + }); + + describe('case 2: a site name that self-joined a real project', () => { + it('purges them and their own records; the project and everyone else stay', async () => { + const res = await purge('site-name', { + reason: 'Site name as member name; bio advertises a streaming service.', + evidence: [...bioEvidence, { record: 'project-update', project: 'real-project', number: 2 }], + }); + expect(res.statusCode, res.body).toBe(200); + expect(res.json<{ data: { deleted: Rec } }>().data.deleted).toMatchObject({ projects: [], memberships: 1, updates: 1 }); + const state = env.app.inMemoryState; + expect(state.personIdBySlug.has('site-name')).toBe(false); + expect(state.projectIdBySlug.has('real-project')).toBe(true); + const left = [...(state.membershipsByProject.get('real-project') ?? [])].map((m) => state.projectMemberships.get(m)!.personSlug).sort(); + expect(left).toEqual(['co-maintainer', 'helper-hal', 'maintainer-mae', 'role-poster']); + expect(state.updateByProjectAndNumber.has('real-project:1')).toBe(true); + expect(state.updateByProjectAndNumber.has('real-project:2')).toBe(false); + const msg = await env.git('log', '-1', '--format=%B'); + expect(msg).toMatch(/^Evidence: people\/site-name\.toml#bio$/m); + expect(msg).toMatch(/^Evidence: project-updates\/real-project\/2\.toml$/m); + }); + }); + + describe('a project the member made and nobody else touched', () => { + it('can be cited, and goes with the purge', async () => { + const res = await purge('own-project', { + reason: 'Created a project selling followers.', + evidence: [{ record: 'project', project: 'spam-proj', field: 'summary' }], + }); + expect(res.statusCode, res.body).toBe(200); + expect(res.json<{ data: { deleted: { projects: Rec[] } } }>().data.deleted.projects).toEqual([ + { slug: 'spam-proj', title: 'Project spam-proj' }, + ]); + expect(env.app.inMemoryState.projectIdBySlug.has('spam-proj')).toBe(false); + }); + }); + + describe('refusals (each writes nothing; a dry run refuses the same way)', () => { + const payload = { reason: 'Promotional bio.', evidence: bioEvidence }; + let n = 0; + beforeEach(async () => { + n += 1; + checker = (await createAgent(env, admin, { slug: `check-bot-${n}`, scopes: ['moderation:read', 'moderation:purge'] })).token; + }); + + it('rule 1: an imported member (409 protected_account), staff (409), an agent account or itself (422)', async () => { + await refused('imported', payload, 409, 'protected_account'); + await refused('staffer', { reason: 'x', evidence: [{ record: 'project-update', project: 'real-project', number: 1 }] }, 422, 'evidence_invalid'); + expect((await purge('voter-bot', payload, bearer(checker))).statusCode).toBe(422); + expect((await purge('check-bot-1', payload, bearer(checker))).statusCode).toBe(422); + }); + + it('rule 2: joined more than 7 days ago (409 member_too_old)', async () => { + await refused('too-old', payload, 409, 'member_too_old'); + }); + + it('rule 3: any human vote (409 human_verdict_exists)', async () => { + await refused('human-voted', payload, 409, 'human_verdict_exists'); + }); + + it('rule 4: a standing agent legit vote (409 legit_vote_exists)', async () => { + await refused('agent-legit', payload, 409, 'legit_vote_exists'); + }); + + it('rule 5: signed into Slack through the site (409 established_member)', async () => { + await refused('slacker', payload, 409, 'established_member'); + }); + + it('rule 6: created a project someone else joined, or maintains one with other members (409 shared_project)', async () => { + await refused('shared-creator', payload, 409, 'shared_project'); + await refused('co-maintainer', payload, 409, 'shared_project'); + }); + + it('evidence that is empty or not theirs (422 evidence_invalid)', async () => { + await refused('empty-bio', payload, 422, 'evidence_invalid'); + await refused( + 'role-poster', + { reason: 'Promo update.', evidence: [{ record: 'project-update', project: 'real-project', number: 1 }] }, + 422, + 'evidence_invalid', + ); + await refused('role-poster', { reason: 'x', evidence: [{ record: 'project', project: 'real-project' }] }, 422, 'evidence_invalid'); + }); + + it('rule 7: evidence on a project the purge keeps (409 evidence_survives)', async () => { + await refused( + 'role-poster', + { reason: 'Posted a role advertising a shop.', evidence: [{ record: 'help-wanted-role', project: 'real-project', roleId: ROLE_ID }] }, + 409, + 'evidence_survives', + ); + }); + }); + + describe('rule 8: volume', () => { + it('five purges per agent per 24 hours; the sixth is 429 with Retry-After', async () => { + const burst = await createAgent(env, admin, { + slug: 'burst-bot', + scopes: ['moderation:read', 'moderation:purge'], + }); + for (let i = 0; i < 5; i++) { + const res = await purge(`burst-${i}`, { reason: 'Promotional bio.', evidence: bioEvidence }, bearer(burst.token)); + expect(res.statusCode, res.body).toBe(200); + } + const before = await head(); + const res = await purge('burst-5', { reason: 'Promotional bio.', evidence: bioEvidence }, bearer(burst.token)); + expect(res.statusCode).toBe(429); + expect(Number(res.headers['retry-after'])).toBeGreaterThan(23 * 3600); + expect(await head()).toBe(before); + const list = await env.app.inject({ method: 'GET', url: '/api/admin/agents', cookies: { cfp_session: staff } }); + const row = list.json<{ data: Array<{ person: { slug: string }; purges: Rec }> }>().data.find((a) => a.person.slug === 'burst-bot')!; + expect(row.purges).toEqual({ last24h: 5, last30d: 5 }); + }, 60_000); + }); +}); diff --git a/apps/api/tests/helpers/agent-fixtures.ts b/apps/api/tests/helpers/agent-fixtures.ts index 38cd5bb..45465ee 100644 --- a/apps/api/tests/helpers/agent-fixtures.ts +++ b/apps/api/tests/helpers/agent-fixtures.ts @@ -59,6 +59,8 @@ export async function startAgentTestEnv(opts: { records: Record; profiles?: Rec[]; env?: Record; + /** More seeding in the same commit (attachments, raw files). */ + extra?: (tx: Parameters>['transact']>[1]>[0]) => Promise; }): Promise { const dataRepo = await createFullDataRepo(); const privateStore = await createPrivateStorageDir(); @@ -67,6 +69,7 @@ export async function startAgentTestEnv(opts: { for (const [sheet, records] of Object.entries(opts.records)) { for (const r of records) await tx.sheet(sheet).upsert(r, { allowMissingBody: true }); } + if (opts.extra) await opts.extra(tx); }); if (opts.profiles?.length) { const { writeFile } = await import('node:fs/promises'); From 2d8f6d217420e17cb65c422feb0e46c69d860865 Mon Sep 17 00:00:00 2001 From: GrittyBot <30755786+codeforphilly-bot@users.noreply.github.com> Date: Thu, 12 Nov 2026 19:18:03 +0000 Subject: [PATCH 10/23] feat(api): spam purge log, sign-up block, unblock, staff this-was-spam A purged spammer's GitHub id is refused at sign-up and account linking until staff unblock it. The sweep and staff purges write the same record. Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_01S8dthiZS2vwh674eh2DDQs --- apps/api/src/auth/github-oauth.ts | 22 +- apps/api/src/plugins/services.ts | 6 + apps/api/src/plugins/spam-purge.ts | 1 + apps/api/src/routes/moderation.ts | 80 +++++- apps/api/src/routes/people.ts | 24 +- apps/api/src/services/account-matching.ts | 14 +- apps/api/src/services/person.write.ts | 23 +- apps/api/src/services/spam-purge.ts | 28 ++- apps/api/src/services/spam-purges.ts | 181 ++++++++++++++ apps/api/tests/spam-purge-block.test.ts | 288 ++++++++++++++++++++++ 10 files changed, 658 insertions(+), 9 deletions(-) create mode 100644 apps/api/src/services/spam-purges.ts create mode 100644 apps/api/tests/spam-purge-block.test.ts diff --git a/apps/api/src/auth/github-oauth.ts b/apps/api/src/auth/github-oauth.ts index 52be5a8..a6cb97c 100644 --- a/apps/api/src/auth/github-oauth.ts +++ b/apps/api/src/auth/github-oauth.ts @@ -23,7 +23,7 @@ import { GitHubApiError, type ResolvedGitHubIdentity, } from './github-client.js'; -import { resolveIdentity } from '../services/account-matching.js'; +import { isGitHubIdBlocked, resolveIdentity } from '../services/account-matching.js'; import { buildTransactionOptions } from '../store/commit-meta.js'; import type { FastifyRequest } from 'fastify'; @@ -55,7 +55,8 @@ export type CallbackOutcome = export type CallbackErrorCode = | 'github_unreachable' - | 'email_unverified'; + | 'email_unverified' + | 'signup_blocked'; /** * Outcomes for the link-github callback. Mirrors `CallbackOutcome` but @@ -68,7 +69,8 @@ export type LinkCallbackOutcome = export type LinkCallbackErrorCode = | 'github_unreachable' | 'github_already_linked' - | 'github_id_in_use_elsewhere'; + | 'github_id_in_use_elsewhere' + | 'github_blocked'; export interface CompleteLinkCallbackParams { readonly fastify: FastifyInstance; @@ -145,6 +147,12 @@ export async function completeLinkCallback( } } + // A GitHub account purged as spam can't come back by linking either + // (specs/api/auth.md → link-github). + if (isGitHubIdBlocked(fastify.inMemoryState, identity.id)) { + return { kind: 'error', code: 'github_blocked' }; + } + const result = await fastify.store.transact( buildTransactionOptions({ request, @@ -223,6 +231,14 @@ export async function completeCallback( const match = await resolveIdentity(identity, fastify.inMemoryState, fastify.store.private); + // Purged as spam: before (b) or (c), a GitHub id a spam-purges record + // blocks writes nothing (specs/api/auth.md). Case (a) — a live Person holds + // the id — is a member staff restored, and is never blocked. + if (match.kind !== 'existing' && isGitHubIdBlocked(fastify.inMemoryState, identity.id)) { + fastify.log.info({ githubUserId: identity.id }, 'github sign-up refused: purged as spam'); + return { kind: 'error', code: 'signup_blocked' }; + } + if (match.kind === 'existing') { const profile = await fastify.store.private.getProfile(match.personId); const result = await fastify.store.transact( diff --git a/apps/api/src/plugins/services.ts b/apps/api/src/plugins/services.ts index f9cc8a5..92d8b31 100644 --- a/apps/api/src/plugins/services.ts +++ b/apps/api/src/plugins/services.ts @@ -34,6 +34,7 @@ import { AccountClaimService } from '../services/account-claim.js'; import { ModerationService, ModerationWriteService } from '../services/moderation.js'; import { AgentService } from '../services/agents.js'; import { AgentPurgeService } from '../services/agent-purge.js'; +import { SpamPurgeLogService, dataRepoWebUrl } from '../services/spam-purges.js'; import { probeGitHubUser } from '../auth/github-client.js'; import { LoggingNotifier, type Notifier } from '../notify/index.js'; import { EmailNotifier } from '../notify/email-notifier.js'; @@ -68,6 +69,7 @@ declare module 'fastify' { moderationWrite: ModerationWriteService; agents: AgentService; agentPurge: AgentPurgeService; + spamPurges: SpamPurgeLogService; }; /** Shared in-memory state — write routes call StateApply.apply against this. */ inMemoryState: InMemoryState; @@ -159,6 +161,10 @@ async function servicesPlugin(fastify: FastifyInstance): Promise { moderationWrite: new ModerationWriteService(state), agents: new AgentService(state, fastify.store.private), agentPurge: new AgentPurgeService(state, fastify.store.private), + spamPurges: new SpamPurgeLogService(state, { + webUrl: dataRepoWebUrl(fastify.config.CFP_DATA_REMOTE), + branch: fastify.config.CFP_DATA_BRANCH ?? 'published', + }), }); } diff --git a/apps/api/src/plugins/spam-purge.ts b/apps/api/src/plugins/spam-purge.ts index 11beb8e..ef1307c 100644 --- a/apps/api/src/plugins/spam-purge.ts +++ b/apps/api/src/plugins/spam-purge.ts @@ -24,6 +24,7 @@ declare module 'fastify' { async function spamPurgePlugin(fastify: FastifyInstance): Promise { const service = new SpamPurgeService({ + headCommit: async () => (await fastify.publicRepo.resolveRef('HEAD')) ?? 'unknown', state: fastify.inMemoryState, fts: fastify.fts, store: fastify.store, diff --git a/apps/api/src/routes/moderation.ts b/apps/api/src/routes/moderation.ts index 7c545b3..c860de7 100644 --- a/apps/api/src/routes/moderation.ts +++ b/apps/api/src/routes/moderation.ts @@ -6,6 +6,8 @@ * POST /api/admin/members/:slug/vote — staff | agent:moderation:vote * DELETE /api/admin/members/:slug/vote — agent:moderation:vote * POST /api/admin/members/:slug/purge — agent:moderation:purge + * GET /api/admin/spam-purges — staff | agent:moderation:read + * POST /api/admin/spam-purges/:id/unblock — staff * * Anyone else gets 404: like the other staff surfaces, the endpoints' * existence is not a signal. An agent reads the roster without emails and @@ -18,7 +20,7 @@ import { ApiNotFoundError, ApiValidationError } from '../lib/errors.js'; import { getCallerSession } from '../services/permissions.js'; import { buildTransactionOptions, withInlineTrailers } from '../store/commit-meta.js'; import { evidenceTrailer, validatePurgeBody, type AgentPurgeInput } from '../services/agent-purge.js'; -import { agentCaller, moderationCaller } from '../auth/caller.js'; +import { agentCaller, moderationCaller, staffCaller } from '../auth/caller.js'; import type { MemberOrigin, VoteFilter } from '../services/moderation.js'; import { findOrphanPrivateData, purgeOrphanPrivateData } from '../services/private-orphans.js'; @@ -270,6 +272,82 @@ export async function moderationRoutes(fastify: FastifyInstance): Promise }, ); + // GET /api/admin/spam-purges — the purge log, newest first. + fastify.get( + '/api/admin/spam-purges', + { + schema: { + tags: ['moderation'], + summary: 'The spam-purge log, newest first', + querystring: { + type: 'object', + properties: { + by: { type: 'string' }, + slug: { type: 'string' }, + since: { type: 'string' }, + blocking: { type: 'boolean' }, + page: { type: 'integer', minimum: 1 }, + perPage: { type: 'integer', minimum: 1, maximum: 200 }, + }, + additionalProperties: false, + }, + }, + }, + async (request) => { + moderationCaller(request, 'moderation:read'); + const q = request.query as { by?: string; slug?: string; since?: string; blocking?: boolean; page?: number; perPage?: number }; + if (q.since !== undefined && Number.isNaN(Date.parse(q.since))) { + throw new ApiValidationError('Invalid date', { since: 'ISO date' }); + } + const result = fastify.services.spamPurges.list(q); + return paginated(result.items, { + page: result.page, + perPage: result.perPage, + totalItems: result.totalItems, + totalPages: Math.max(1, Math.ceil(result.totalItems / result.perPage)), + }); + }, + ); + + // POST /api/admin/spam-purges/:id/unblock — staff lift a sign-up block. + fastify.post( + '/api/admin/spam-purges/:id/unblock', + { + schema: { + tags: ['moderation'], + summary: 'Let a spam-purged GitHub account sign up again (staff)', + params: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] }, + body: { + type: ['object', 'null'], + properties: { reason: { type: 'string', maxLength: 1000 } }, + additionalProperties: false, + }, + }, + }, + async (request) => { + const session = staffCaller(request); + const { id } = request.params as { id: string }; + const reason = (request.body as { reason?: string } | null)?.reason?.trim() || null; + const service = fastify.services.spamPurges; + const existing = service.recordOr404(id); + if (existing.unblockedAt) return ok({ purge: service.view(existing) }); + const result = await fastify.store.transact( + buildTransactionOptions({ + request, + action: 'spam-purge.unblock', + subjectType: 'spam-purge', + subjectId: id, + subjectSlug: existing.personSlug, + responseCode: 200, + ...(reason ? { summary: reason } : {}), + }), + async (tx) => service.unblock(tx, id, session.person!.slug, reason), + ); + result.value.stateApply.apply(fastify.inMemoryState, fastify.fts); + return ok({ purge: service.view(result.value.record) }); + }, + ); + // POST /api/admin/private-data/purge-orphans (administrator only) // Spec: specs/api/moderation.md → POST /api/admin/private-data/purge-orphans fastify.post( diff --git a/apps/api/src/routes/people.ts b/apps/api/src/routes/people.ts index ba3de3b..ba15a5e 100644 --- a/apps/api/src/routes/people.ts +++ b/apps/api/src/routes/people.ts @@ -15,6 +15,7 @@ import { ok, paginated } from '../lib/response.js'; import { ApiNotFoundError, ApiValidationError, ForbiddenError } from '../lib/errors.js'; import { computePersonPermissions, getCallerSession } from '../services/permissions.js'; import { buildTransactionOptions } from '../store/commit-meta.js'; +import { containsUrl } from '../services/agent-purge.js'; import type { UpdatePersonInput } from '../services/person.write.js'; import { AVATAR_ALLOWED_MIME, processAvatar } from '../lib/avatar.js'; import type { Person } from '@cfp/shared/schemas'; @@ -231,14 +232,29 @@ export async function peopleRoutes(fastify: FastifyInstance): Promise { // POST /api/people/:slug/purge (administrator only) // Spec: specs/behaviors/person-lifecycle.md, specs/api/people.md + // Body (optional): { spam, reason } — a spam purge writes a spam-purges + // record in the same commit, blocking the GitHub account from signing up. fastify.post('/api/people/:slug/purge', { schema: { tags: ['people'], summary: 'Purge a person and all their content (admin only)', params: { type: 'object', properties: { slug: { type: 'string' } }, required: ['slug'] }, + body: { + type: ['object', 'null'], + properties: { + spam: { type: 'boolean' }, + reason: { type: 'string', maxLength: 1000 }, + }, + additionalProperties: false, + }, }, }, async (request, reply) => { const { slug } = request.params as { slug: string }; + const body = (request.body ?? {}) as { spam?: boolean; reason?: string }; + const reason = body.reason?.trim() || null; + if (reason && containsUrl(reason)) { + throw new ApiValidationError('A purge reason must not contain a URL', { reason: 'must not contain a URL' }); + } const personId = fastify.inMemoryState.personIdBySlug.get(slug); const result = await fastify.store.transact( buildTransactionOptions({ @@ -247,8 +263,14 @@ export async function peopleRoutes(fastify: FastifyInstance): Promise { subjectType: 'person', subjectSlug: slug, responseCode: 204, + ...(body.spam ? { summary: reason ? `spam: ${reason}` : 'spam' } : {}), }), - async (tx) => fastify.services.peopleWrite.purge(tx, slug, request.session), + async (tx) => + fastify.services.peopleWrite.purge(tx, slug, request.session, { + ...(body.spam + ? { spam: { reason, beforeCommit: async () => (await fastify.publicRepo.resolveRef('HEAD')) ?? 'unknown' } } + : {}), + }), ); result.value.stateApply.apply(fastify.inMemoryState, fastify.fts); if (personId) await fastify.sessionMetadata.removeAllFor(personId, fastify.store.private); diff --git a/apps/api/src/services/account-matching.ts b/apps/api/src/services/account-matching.ts index aac70d5..050931d 100644 --- a/apps/api/src/services/account-matching.ts +++ b/apps/api/src/services/account-matching.ts @@ -11,7 +11,7 @@ * candidate: email-match seeds the candidate set, username-match adds to it, * and the user is the one who confirms at /account-claim. */ -import type { Person } from '@cfp/shared/schemas'; +import { spamPurgeBlocks, type Person } from '@cfp/shared/schemas'; import type { InMemoryState } from '../store/memory/state.js'; import type { PrivateStore } from '../store/private/index.js'; import type { ResolvedGitHubIdentity } from '../auth/github-client.js'; @@ -63,3 +63,15 @@ export async function resolveIdentity( if (candidates.size === 0) return { kind: 'create-fresh' }; return { kind: 'candidates', candidates: [...candidates] }; } + +/** + * Whether a `spam-purges` record blocks this GitHub id from signing up or + * linking (specs/api/auth.md → "Purged as spam"): an in-memory index lookup. + */ +export function isGitHubIdBlocked(state: InMemoryState, ghUserId: number): boolean { + for (const id of state.spamPurgesByGithubUserId.get(ghUserId) ?? []) { + const record = state.spamPurges.get(id); + if (record && spamPurgeBlocks(record)) return true; + } + return false; +} diff --git a/apps/api/src/services/person.write.ts b/apps/api/src/services/person.write.ts index a28d520..540bfc2 100644 --- a/apps/api/src/services/person.write.ts +++ b/apps/api/src/services/person.write.ts @@ -15,8 +15,10 @@ import { uuidv7 } from 'uuidv7'; import { PersonSchema, PrivateProfileSchema, + humanEvaluatorFor, type Person, type PrivateProfile, + type SpamPurge, } from '@cfp/shared/schemas'; import type { DualStoreTx } from '../store/store.js'; import type { InMemoryState } from '../store/memory/state.js'; @@ -37,6 +39,7 @@ import { applyTagsForEntity, type TagAssignmentInput, type TagNamespace } from ' import type { PrivateStore } from '../store/private/index.js'; import { cascadeRename } from './reference-cascade.js'; import { purgePeople } from './person-purge.js'; +import { spamPurgeRecord } from './spam-purges.js'; import { purgeableProjects } from './project-purge.js'; import { rekeyAttachment, upsertMoved } from './slug-rename.js'; @@ -275,7 +278,11 @@ export class PersonWriteService { tx: DualStoreTx, slug: string, session: SessionContext, - ): Promise<{ stateApply: StateApply }> { + opts: { + /** "This was spam": write a spam-purges record that blocks re-sign-up (specs/api/people.md). */ + readonly spam?: { readonly reason: string | null; readonly beforeCommit: () => Promise }; + } = {}, + ): Promise<{ stateApply: StateApply; spamPurge: SpamPurge | null }> { requireAuth('administrator', { session }); const id = this.#state.personIdBySlug.get(slug); @@ -286,7 +293,19 @@ export class PersonWriteService { const stateApply = new StateApply(); await purgePeople(tx, this.#state, stateApply, [existing]); - return { stateApply }; + let spamPurge: SpamPurge | null = null; + if (opts.spam) { + spamPurge = spamPurgeRecord({ + person: existing, + by: humanEvaluatorFor(session.person!.slug), + reason: opts.spam.reason, + beforeCommit: await opts.spam.beforeCommit(), + }); + await tx.public['spam-purges'].upsert(spamPurge); + stateApply.upsertSpamPurge(spamPurge); + } + + return { stateApply, spamPurge }; } /** diff --git a/apps/api/src/services/spam-purge.ts b/apps/api/src/services/spam-purge.ts index 0a602a0..6f3ec7a 100644 --- a/apps/api/src/services/spam-purge.ts +++ b/apps/api/src/services/spam-purge.ts @@ -4,7 +4,7 @@ * stood unopposed for three days, a daily sweep purges every queued person in * one commit. */ -import { evaluatorKind, type Person } from '@cfp/shared/schemas'; +import { SCHEDULED_PURGER, evaluatorKind, type Person } from '@cfp/shared/schemas'; import type { FastifyBaseLogger } from 'fastify'; import type { FtsEngine } from '../store/fts.js'; @@ -14,6 +14,7 @@ import type { Store } from '../store/store.js'; import type { SessionMetadataStore } from '../auth/session-metadata.js'; import { purgePeople } from './person-purge.js'; import { decidingVotes } from './votes.js'; +import { spamPurgeRecord } from './spam-purges.js'; import { isAgentAccount } from '../auth/agent-grant.js'; /** How long a spam vote stands before the sweep acts on it. */ @@ -59,6 +60,12 @@ export function stagedBy(state: InMemoryState, person: Person, now: Date): strin return [...new Set(votes.map((v) => v.evaluator))].sort(); } +/** The `spam-purges` reason for a swept person: the deciding voters, by evaluator. */ +export function scheduledReason(state: InMemoryState, person: Person, now: Date): string { + const voters = [...new Set(decidingVotes(state, person, now).map((v) => v.evaluator))].sort(); + return `Spam votes by ${voters.join(', ')} stood unopposed for three days.`; +} + /** The sweep commit's slug lines: `` or ` (staged by agent-)`. */ export function sweepLines(state: InMemoryState, people: readonly Person[], now: Date): { lines: string[]; agentStaged: number } { let agentStaged = 0; @@ -72,6 +79,8 @@ export function sweepLines(state: InMemoryState, people: readonly Person[], now: } export interface SpamPurgeDeps { + /** The data repo commit a purge is applied on top of (the spam-purges record's `beforeCommit`). */ + readonly headCommit: () => Promise; readonly state: InMemoryState; readonly fts: FtsEngine; readonly store: Store; @@ -108,7 +117,24 @@ export class SpamPurgeService { // since the queue was read wins, and the batch waits for the next run. const current = queuedForSpamPurge(this.#d.state, now).map((p) => p.slug); if (current.join() !== slugs.join()) throw new QueueChanged(); + // Who decided, read before the purge removes nothing of theirs: the + // votes stay, but read them while the people are still in memory. + const reasons = queued.map((p) => scheduledReason(this.#d.state, p, now)); await purgePeople(tx, this.#d.state, stateApply, queued); + // Three days unopposed is a spam verdict: each purged GitHub account + // is blocked from signing straight back up (person-lifecycle.md). + const beforeCommit = await this.#d.headCommit(); + for (const [i, person] of queued.entries()) { + const record = spamPurgeRecord({ + person, + by: SCHEDULED_PURGER, + reason: reasons[i]!, + beforeCommit, + purgedAt: now.toISOString(), + }); + await tx.public['spam-purges'].upsert(record); + stateApply.upsertSpamPurge(record); + } }, ); } catch (err) { diff --git a/apps/api/src/services/spam-purges.ts b/apps/api/src/services/spam-purges.ts new file mode 100644 index 0000000..6cd4761 --- /dev/null +++ b/apps/api/src/services/spam-purges.ts @@ -0,0 +1,181 @@ +/** + * The spam-purge log and sign-up block — specs/data-model.md#spampurge, + * specs/api/moderation.md → `GET /api/admin/spam-purges`, + * `POST /api/admin/spam-purges/:id/unblock`. + * + * Every purge *as spam* — an agent purge, the daily sweep, or an + * administrator's purge marked "This was spam" — leaves one public record + * naming the purged slug and GitHub id, which keeps that GitHub account from + * signing up again until staff unblock it. + */ +import { uuidv7 } from 'uuidv7'; +import { + SCHEDULED_PURGER, + SpamPurgeSchema, + evaluatorKind, + evaluatorSlug, + spamPurgeBlocks, + type Person, + type SpamPurge, +} from '@cfp/shared/schemas'; + +import { ApiNotFoundError } from '../lib/errors.js'; +import type { InMemoryState } from '../store/memory/state.js'; +import { StateApply } from '../store/state-apply.js'; +import type { DualStoreTx } from '../store/store.js'; + +/** A spam purge by an administrator or the sweep: no grant, no evidence. */ +export function spamPurgeRecord(input: { + readonly person: Person; + readonly by: string; + readonly reason: string | null; + readonly beforeCommit: string; + readonly purgedAt?: string; +}): SpamPurge { + return SpamPurgeSchema.parse({ + id: uuidv7(), + personSlug: input.person.slug, + personId: input.person.id, + ...(typeof input.person.githubUserId === 'number' ? { githubUserId: input.person.githubUserId } : {}), + purgedAt: input.purgedAt ?? new Date().toISOString(), + by: input.by, + ...(input.reason ? { reason: input.reason } : {}), + flags: [], + evidence: [], + beforeCommit: input.beforeCommit, + blocksSignIn: true, + }); +} + +export interface SpamPurgeFilters { + /** `agent` | `human` | `scheduled`, or one evaluator (`agent-`, `human-`). */ + readonly by?: string; + readonly slug?: string; + readonly since?: string; + readonly blocking?: boolean; + readonly page?: number; + readonly perPage?: number; +} + +export interface PurgerRef { + readonly kind: 'agent' | 'human' | 'scheduled'; + readonly slug: string | null; + readonly fullName: string; +} + +export interface SpamPurgeView extends SpamPurge { + /** `by`, resolved. */ + readonly byRef: PurgerRef; + readonly evidence: Array; + /** The history of the record's own path; its first entry is the purge commit. */ + readonly commitUrl: string; + readonly blocking: boolean; + readonly unblockedBy: { readonly slug: string; readonly fullName: string } | null; +} + +/** Where purge-log links point: the data repo on GitHub, and the served branch. */ +export interface DataRepoLinks { + readonly webUrl: string; + readonly branch: string; +} + +/** + * The data repo's GitHub web URL from `CFP_DATA_REMOTE` (ssh or https), else + * the public `CodeForPhilly/codeforphilly-data`. + */ +export function dataRepoWebUrl(remote: string | undefined): string { + const fallback = 'https://github.com/CodeForPhilly/codeforphilly-data'; + if (!remote) return fallback; + const ssh = /^git@github\.com:(.+?)(?:\.git)?$/.exec(remote); + if (ssh) return `https://github.com/${ssh[1]}`; + const https = /^https:\/\/(?:[^@/]+@)?github\.com\/(.+?)(?:\.git)?\/?$/.exec(remote); + if (https) return `https://github.com/${https[1]}`; + return fallback; +} + +export class SpamPurgeLogService { + readonly #state: InMemoryState; + readonly #links: DataRepoLinks; + + constructor(state: InMemoryState, links: DataRepoLinks) { + this.#state = state; + this.#links = links; + } + + #fullName(slug: string): string { + const id = this.#state.personIdBySlug.get(slug); + return (id ? this.#state.people.get(id)?.fullName : undefined) ?? slug; + } + + byRef(by: string): PurgerRef { + if (by === SCHEDULED_PURGER) return { kind: 'scheduled', slug: null, fullName: 'Daily sweep' }; + const slug = evaluatorSlug(by) ?? by; + return { kind: evaluatorKind(by) === 'agent' ? 'agent' : 'human', slug, fullName: this.#fullName(slug) }; + } + + view(record: SpamPurge): SpamPurgeView { + const { webUrl, branch } = this.#links; + return { + ...record, + byRef: this.byRef(record.by), + evidence: record.evidence.map((e) => ({ ...e, url: `${webUrl}/blob/${record.beforeCommit}/${e.path}` })), + commitUrl: `${webUrl}/commits/${branch}/spam-purges/${record.personSlug}/${record.id}.toml`, + blocking: spamPurgeBlocks(record), + unblockedBy: record.unblockedBySlug + ? { slug: record.unblockedBySlug, fullName: this.#fullName(record.unblockedBySlug) } + : null, + }; + } + + list(filters: SpamPurgeFilters): { items: SpamPurgeView[]; totalItems: number; page: number; perPage: number } { + let records = [...this.#state.spamPurges.values()]; + const by = filters.by; + if (by) { + records = records.filter((r) => { + if (by === 'scheduled') return r.by === SCHEDULED_PURGER; + if (by === 'agent' || by === 'human') return r.by !== SCHEDULED_PURGER && evaluatorKind(r.by) === by; + return r.by === by; + }); + } + if (filters.slug) records = records.filter((r) => r.personSlug === filters.slug); + if (filters.since) { + const since = Date.parse(filters.since); + records = records.filter((r) => Date.parse(r.purgedAt) >= since); + } + if (filters.blocking) records = records.filter((r) => spamPurgeBlocks(r)); + records.sort((a, b) => b.purgedAt.localeCompare(a.purgedAt) || b.id.localeCompare(a.id)); + const page = Math.max(1, filters.page ?? 1); + const perPage = Math.min(200, Math.max(1, filters.perPage ?? 50)); + return { + items: records.slice((page - 1) * perPage, page * perPage).map((r) => this.view(r)), + totalItems: records.length, + page, + perPage, + }; + } + + recordOr404(id: string): SpamPurge { + const record = this.#state.spamPurges.get(id); + if (!record) throw new ApiNotFoundError(`No spam purge '${id}'`); + return record; + } + + /** Lift the sign-up block; the record stays. Unblocking an unblocked record is a no-op. */ + async unblock( + tx: DualStoreTx, + id: string, + staffSlug: string, + reason: string | null, + ): Promise<{ record: SpamPurge; stateApply: StateApply }> { + const existing = this.recordOr404(id); + if (existing.unblockedAt) return { record: existing, stateApply: new StateApply() }; + const record = SpamPurgeSchema.parse({ + ...existing, + unblockedAt: new Date().toISOString(), + unblockedBySlug: staffSlug, + ...(reason ? { unblockReason: reason } : {}), + }); + await tx.public['spam-purges'].upsert(record); + return { record, stateApply: new StateApply().upsertSpamPurge(record) }; + } +} diff --git a/apps/api/tests/spam-purge-block.test.ts b/apps/api/tests/spam-purge-block.test.ts new file mode 100644 index 0000000..7a1f788 --- /dev/null +++ b/apps/api/tests/spam-purge-block.test.ts @@ -0,0 +1,288 @@ +/** + * The re-registration block and the purge log — specs/api/auth.md ("Purged as + * spam", `signup_blocked`, `github_blocked`), specs/api/people.md (purge + * `{ spam, reason }`), specs/api/moderation.md (`GET /api/admin/spam-purges`, + * `POST …/unblock`), specs/behaviors/person-lifecycle.md (the sweep writes + * `spam-purges`). + * + * Case 1 of the plan: a purged GitHub account signed straight back up, got the + * same slug, and the earlier account's staff vote showed as its own. + */ +import { http, HttpResponse } from 'msw'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { issueSession } from '../src/auth/jwt.js'; +import { + JWT_KEY, + bearer, + createAgent, + daysAgo, + fid, + personRecord, + privateProfile, + startAgentTestEnv, + type AgentTestEnv, + type Rec, +} from './helpers/agent-fixtures.js'; +import { createGitHubMock } from './helpers/mocks.js'; + +const ADMIN = 1; +const STAFF = 2; +const USER = 3; +const LINKER = 4; +const N = { blocked: 10, spamPurged: 11, erased: 12, swept: 13, restored: 14 }; +const GH = { blocked: 31_001, purgedByStaff: 31_002, erased: 31_003, swept: 31_004, restored: 31_005 }; +const OLD_PURGE_ID = fid(900); +const RESTORED_PURGE_ID = fid(901); + +let ipCounter = 0; +const nextIp = () => `10.9.${Math.floor(++ipCounter / 250)}.${ipCounter % 250}`; + +const oldPurge: Rec = { + id: OLD_PURGE_ID, + personSlug: 'promo-shop', + personId: fid(800), + githubUserId: GH.blocked, + purgedAt: daysAgo(2), + by: 'human-staffer', + reason: 'Promotional bio.', + flags: [], + evidence: [], + beforeCommit: '0123456789abcdef0123456789abcdef01234567', + blocksSignIn: true, +}; + +describe('spam purges block re-sign-up', () => { + let env: AgentTestEnv; + let admin: string; + let staff: string; + let user: string; + const mock = createGitHubMock(); + + beforeAll(async () => { + mock.server.listen({ onUnhandledRequest: 'error' }); + // The roster's GitHub re-probe (OAuth app configured): every account is "gone". + mock.server.use(http.get(/^https:\/\/api\.github\.com\/user\/\d+$/, () => new HttpResponse(null, { status: 404 }))); + env = await startAgentTestEnv({ + env: { GITHUB_OAUTH_CLIENT_ID: 'test-client-id', GITHUB_OAUTH_CLIENT_SECRET: 'test-client-secret' }, + records: { + people: [ + personRecord(ADMIN, 'boss', { accountLevel: 'administrator' }), + personRecord(STAFF, 'staffer', { accountLevel: 'staff' }), + personRecord(USER, 'regular'), + personRecord(LINKER, 'linker', { createdAt: daysAgo(100) }), + personRecord(N.spamPurged, 'staff-target', { createdAt: daysAgo(1), githubUserId: GH.purgedByStaff, bio: 'Deals deals deals' }), + personRecord(N.erased, 'erase-me', { createdAt: daysAgo(400), githubUserId: GH.erased }), + personRecord(N.swept, 'swept', { createdAt: daysAgo(10), deletedAt: daysAgo(5), githubUserId: GH.swept }), + // Purged as spam, then restored by staff: the live record holds the id. + personRecord(N.restored, 'restored', { createdAt: daysAgo(30), githubUserId: GH.restored, githubLogin: 'restored' }), + ], + 'spam-purges': [ + oldPurge, + { ...oldPurge, id: RESTORED_PURGE_ID, personSlug: 'restored', personId: fid(N.restored), githubUserId: GH.restored }, + ], + 'person-evaluations': [ + // The earlier promo-shop account's staff vote, before the purge. + { personSlug: 'promo-shop', evaluator: 'human-staffer', verdict: 'spam', confidence: 1, flags: ['manual-override'], reasoning: 'Promo bio', evaluatedAt: daysAgo(2.1) }, + { personSlug: 'swept', evaluator: 'human-boss', verdict: 'spam', confidence: 1, flags: ['manual-override'], evaluatedAt: daysAgo(4) }, + ], + }, + profiles: [ + privateProfile(fid(LINKER), 'linker@example.org'), + privateProfile(fid(N.restored), 'restored@example.org'), + ], + }); + admin = await env.cookie(ADMIN, 'administrator'); + staff = await env.cookie(STAFF, 'staff'); + user = await env.cookie(USER, 'user'); + mock.setTokenResponse({ access_token: 'gho_test', token_type: 'bearer', scope: 'read:user,user:email' }); + }, 90_000); + + afterAll(async () => { + mock.server.close(); + await env.close(); + }); + + const head = async () => (await env.git('rev-parse', 'HEAD')).trim(); + + async function githubCallback(ghId: number, login: string): Promise<{ status: number; location: string }> { + mock.setGitHubUser({ id: ghId, login, name: `GH ${login}`, avatar_url: 'x' }); + mock.setGitHubEmails([{ email: `${login}@mail.example`, primary: true, verified: true, visibility: 'public' }]); + const ip = nextIp(); + const start = await env.app.inject({ method: 'GET', url: '/api/auth/github/start', remoteAddress: ip }); + const state = new URL(String(start.headers['location'])).searchParams.get('state')!; + const cookies = ([] as string[]).concat(start.headers['set-cookie'] as string[]); + const value = (name: string) => cookies.find((c) => c.startsWith(`${name}=`))!.split(';')[0]!.slice(name.length + 1); + const res = await env.app.inject({ + method: 'GET', + url: `/api/auth/github/callback?code=abc&state=${encodeURIComponent(state)}`, + remoteAddress: ip, + cookies: { cfp_oauth_state: value('cfp_oauth_state'), cfp_oauth_session: value('cfp_oauth_session') }, + }); + return { status: res.statusCode, location: String(res.headers['location']) }; + } + + describe('the GitHub callback', () => { + it('refuses a blocked GitHub id with signup_blocked, writing nothing', async () => { + const before = await head(); + const res = await githubCallback(GH.blocked, 'promo-shop'); + expect(res).toEqual({ status: 302, location: '/login?error=signup_blocked' }); + expect(await head()).toBe(before); + expect(env.app.inMemoryState.personIdBySlug.has('promo-shop')).toBe(false); + }); + + it('never blocks a live person holding the id (a member staff restored)', async () => { + const res = await githubCallback(GH.restored, 'restored'); + expect(res.status).toBe(302); + expect(res.location).toBe('/'); + }); + + it('link-GitHub refuses a blocked id with github_blocked', async () => { + const { access } = await issueSession(fid(LINKER), 'user', JWT_KEY, { loginMethod: 'legacy_password' }); + mock.setGitHubUser({ id: GH.blocked, login: 'promo-shop', name: 'x', avatar_url: 'x' }); + mock.setGitHubEmails([{ email: 'promo@mail.example', primary: true, verified: true, visibility: 'public' }]); + const ip = nextIp(); + const start = await env.app.inject({ method: 'POST', url: '/api/auth/link-github', remoteAddress: ip, cookies: { cfp_session: access } }); + expect(start.statusCode).toBe(302); + const state = new URL(String(start.headers['location'])).searchParams.get('state')!; + const cookies = ([] as string[]).concat(start.headers['set-cookie'] as string[]); + const value = (name: string) => cookies.find((c) => c.startsWith(`${name}=`))!.split(';')[0]!.slice(name.length + 1); + const res = await env.app.inject({ + method: 'GET', + url: `/api/auth/github/callback?code=abc&state=${encodeURIComponent(state)}`, + remoteAddress: ip, + cookies: { cfp_oauth_state: value('cfp_oauth_state'), cfp_oauth_session: value('cfp_oauth_session') }, + }); + expect(String(res.headers['location'])).toBe('/account?error=github_blocked'); + expect(env.app.inMemoryState.people.get(fid(LINKER))?.githubUserId).toBeUndefined(); + }); + }); + + describe('the purge log', () => { + it('lists records newest first with byRef, evidence urls and commitUrl (staff and reading agents)', async () => { + const res = await env.app.inject({ method: 'GET', url: '/api/admin/spam-purges', cookies: { cfp_session: staff } }); + expect(res.statusCode).toBe(200); + const items = res.json<{ data: Array }>().data; + const first = items.find((i) => i['id'] === OLD_PURGE_ID)!; + expect(first.byRef).toEqual({ kind: 'human', slug: 'staffer', fullName: 'Test staffer' }); + expect(first.blocking).toBe(true); + expect(first.commitUrl).toBe( + `https://github.com/CodeForPhilly/codeforphilly-data/commits/published/spam-purges/promo-shop/${OLD_PURGE_ID}.toml`, + ); + + const agent = await createAgent(env, admin, { slug: 'log-reader', scopes: ['moderation:read'] }); + const asAgent = await env.app.inject({ method: 'GET', url: '/api/admin/spam-purges?by=human&blocking=true', ...bearer(agent.token) }); + expect(asAgent.statusCode).toBe(200); + expect(asAgent.json<{ data: Rec[] }>().data.map((r) => r['id']).sort()).toEqual([OLD_PURGE_ID, RESTORED_PURGE_ID].sort()); + expect((await env.app.inject({ method: 'GET', url: '/api/admin/spam-purges?by=agent', ...bearer(agent.token) })).json<{ data: Rec[] }>().data).toEqual([]); + expect((await env.app.inject({ method: 'GET', url: '/api/admin/spam-purges', cookies: { cfp_session: user } })).statusCode).toBe(404); + const unblockAsAgent = await env.app.inject({ method: 'POST', url: `/api/admin/spam-purges/${OLD_PURGE_ID}/unblock`, ...bearer(agent.token) }); + expect(unblockAsAgent.statusCode).toBe(404); + }); + }); + + describe('unblocking', () => { + it('lifts the block, keeps the record, and the same GitHub account can sign up again', async () => { + const res = await env.app.inject({ + method: 'POST', + url: `/api/admin/spam-purges/${OLD_PURGE_ID}/unblock`, + cookies: { cfp_session: staff }, + payload: { reason: 'Person wrote in; a mistake.' }, + }); + expect(res.statusCode, res.body).toBe(200); + expect(res.json<{ data: { purge: Rec } }>().data.purge).toMatchObject({ + unblockedBySlug: 'staffer', + unblockReason: 'Person wrote in; a mistake.', + blocking: false, + unblockedBy: { slug: 'staffer', fullName: 'Test staffer' }, + }); + const msg = await env.git('log', '-1', '--format=%B'); + expect(msg).toContain('Action: spam-purge.unblock'); + + const before = await head(); + const again = await env.app.inject({ method: 'POST', url: `/api/admin/spam-purges/${OLD_PURGE_ID}/unblock`, cookies: { cfp_session: staff } }); + expect(again.statusCode).toBe(200); + expect(await head()).toBe(before); + + const signup = await githubCallback(GH.blocked, 'promo-shop'); + expect(signup).toEqual({ status: 302, location: '/' }); + expect(env.app.inMemoryState.personIdBySlug.has('promo-shop')).toBe(true); + }); + + it('the new account shows slug-purged-before, no latest vote, and the old vote as an earlier one', async () => { + const list = await env.app.inject({ method: 'GET', url: '/api/admin/members?q=promo-shop', cookies: { cfp_session: staff } }); + const row = list.json<{ data: Array<{ slug: string; signals: string[]; latestVote: unknown; purgeAfter: unknown }> }>().data[0]!; + expect(row.slug).toBe('promo-shop'); + expect(row.signals).toContain('slug-purged-before'); + expect(row.latestVote).toBeNull(); + const detail = await env.app.inject({ method: 'GET', url: '/api/admin/members/promo-shop', cookies: { cfp_session: staff } }); + const d = detail.json<{ data: { votes: unknown[]; earlierVotes: Array<{ kind: string; verdict: string }>; earlierPurges: Array<{ id: string }> } }>().data; + expect(d.votes).toEqual([]); + expect(d.earlierVotes).toMatchObject([{ kind: 'human', verdict: 'spam' }]); + expect(d.earlierPurges.map((p) => p.id)).toEqual([OLD_PURGE_ID]); + }); + + it('is staff only', async () => { + const res = await env.app.inject({ method: 'POST', url: `/api/admin/spam-purges/${RESTORED_PURGE_ID}/unblock`, cookies: { cfp_session: user } }); + expect(res.statusCode).toBe(404); + }); + }); + + describe("an administrator's purge", () => { + it('marked "This was spam" writes a blocking record in the same commit', async () => { + const before = await head(); + const res = await env.app.inject({ + method: 'POST', + url: '/api/people/staff-target/purge', + cookies: { cfp_session: admin }, + payload: { spam: true, reason: 'Deals bio, no civic interest.' }, + }); + expect(res.statusCode, res.body).toBe(204); + expect(Number((await env.git('rev-list', '--count', `${before}..HEAD`)).trim())).toBe(1); + const files = await env.git('show', '--name-status', '--format=', 'HEAD'); + expect(files).toMatch(/^A\tspam-purges\/staff-target\/.+\.toml$/m); + expect(files).toMatch(/^D\tpeople\/staff-target\.toml$/m); + const [id] = [...(env.app.inMemoryState.spamPurgesByGithubUserId.get(GH.purgedByStaff) ?? [])]; + expect(env.app.inMemoryState.spamPurges.get(id!)).toMatchObject({ + by: 'human-boss', + reason: 'Deals bio, no civic interest.', + blocksSignIn: true, + beforeCommit: before, + }); + expect(await githubCallback(GH.purgedByStaff, 'staff-target')).toEqual({ status: 302, location: '/login?error=signup_blocked' }); + }); + + it('not marked as spam writes no record and blocks nothing', async () => { + const res = await env.app.inject({ method: 'POST', url: '/api/people/erase-me/purge', cookies: { cfp_session: admin } }); + expect(res.statusCode).toBe(204); + expect(env.app.inMemoryState.spamPurgesByGithubUserId.has(GH.erased)).toBe(false); + expect((await githubCallback(GH.erased, 'erase-me')).location).toBe('/'); + }); + + it('refuses a reason with a URL', async () => { + const res = await env.app.inject({ + method: 'POST', + url: '/api/people/regular/purge', + cookies: { cfp_session: admin }, + payload: { spam: true, reason: 'see https://spam.example/x' }, + }); + expect(res.statusCode).toBe(422); + expect(env.app.inMemoryState.personIdBySlug.has('regular')).toBe(true); + }); + }); + + describe('the scheduled sweep', () => { + it('writes a scheduled record naming the deciding voters, blocking the GitHub account', async () => { + const now = new Date(Date.now() + 60_000); + expect(await env.app.spamPurge.sweep(now)).toEqual(['swept']); + const [id] = [...(env.app.inMemoryState.spamPurgesByGithubUserId.get(GH.swept) ?? [])]; + const record = env.app.inMemoryState.spamPurges.get(id!)!; + expect(record).toMatchObject({ by: 'scheduled', personSlug: 'swept', blocksSignIn: true, evidence: [] }); + expect(record.reason).toContain('human-boss'); + const files = await env.git('show', '--name-status', '--format=', 'HEAD'); + expect(files).toMatch(/^A\tspam-purges\/swept\/.+\.toml$/m); + const log = await env.app.inject({ method: 'GET', url: '/api/admin/spam-purges?by=scheduled', cookies: { cfp_session: staff } }); + expect(log.json<{ data: Array<{ byRef: Rec }> }>().data[0]!.byRef).toEqual({ kind: 'scheduled', slug: null, fullName: 'Daily sweep' }); + }); + }); +}); From 4d47c07b28c02b99da4ed3553b19c2c5c44dd11f Mon Sep 17 00:00:00 2001 From: GrittyBot <30755786+codeforphilly-bot@users.noreply.github.com> Date: Thu, 12 Nov 2026 19:18:03 +0000 Subject: [PATCH 11/23] feat(web): staged votes, agents panel, purge log, unblock, blocked sign-in Roster badges and filter, the Agents panel with grants and the purge log, the week-long banner, and the messages a blocked sign-in sees. Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_01S8dthiZS2vwh674eh2DDQs --- apps/web/src/components/admin/AgentsPanel.tsx | 626 ++++++++++++++++++ apps/web/src/lib/api.ts | 106 ++- apps/web/src/pages/AdminMembers.tsx | 185 +++++- apps/web/src/pages/LoginPlaceholder.tsx | 13 +- apps/web/src/screens/Account.tsx | 2 + apps/web/src/screens/PersonDetail.tsx | 49 +- apps/web/tests/LoginPlaceholder.test.tsx | 16 + 7 files changed, 969 insertions(+), 28 deletions(-) create mode 100644 apps/web/src/components/admin/AgentsPanel.tsx diff --git a/apps/web/src/components/admin/AgentsPanel.tsx b/apps/web/src/components/admin/AgentsPanel.tsx new file mode 100644 index 0000000..a63b00b --- /dev/null +++ b/apps/web/src/components/admin/AgentsPanel.tsx @@ -0,0 +1,626 @@ +/** + * The admin members screen's agent surfaces — specs/screens/admin-members.md + * → "Recent agent purges banner", "Agents panel", "Purge log". + * + * Staff read the agents, their grants, what they staged and purged, withdraw + * an agent's staged votes, and unblock a purged GitHub account. Minting, + * revoking and creating agents are administrators' only; a minted token is + * shown once and dropped from the page when its dialog closes. + */ +import { useState, type FormEvent } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; + +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { api, ApiError, type AgentScope, type AgentView, type SpamPurgeView } from '@/lib/api'; +import { formatAbsoluteDate, formatRelativeTime } from '@/lib/time'; + +const WEEK_MS = 7 * 24 * 60 * 60 * 1000; +const RESTORE_DOC = + 'https://github.com/CodeForPhilly/codeforphilly-ng/blob/develop/docs/operations/runbook.md#restore-a-purged-member'; + +function errorText(err: unknown, fallback: string): string { + return err instanceof ApiError ? err.message : fallback; +} + +/** Who purged, as the log says it: `{agent} (AI)`, a staff member, or the daily sweep. */ +function purgerLabel(p: SpamPurgeView): string { + if (p.byRef.kind === 'scheduled') return 'daily sweep'; + return p.byRef.kind === 'agent' ? `${p.byRef.fullName} (AI)` : p.byRef.fullName; +} + +/** + * "{n} members purged by {agent} (AI) in the last 7 days" — not dismissable; + * it goes when the purges age out. + */ +export function RecentAgentPurgesBanner({ onOpenLog }: { onOpenLog: (by: string) => void }) { + const [since] = useState(() => new Date(Date.now() - WEEK_MS).toISOString()); + const q = useQuery({ + queryKey: ['spam-purges', 'recent-agent', since], + queryFn: () => api.admin.spamPurges({ by: 'agent', since, perPage: 200 }), + }); + const purges = q.data?.data ?? []; + if (purges.length === 0) return null; + const byAgent = new Map(); + for (const p of purges) { + const entry = byAgent.get(p.by) ?? { name: p.byRef.fullName, count: 0 }; + entry.count += 1; + byAgent.set(p.by, entry); + } + return ( +
+ {[...byAgent.entries()].map(([by, { name, count }]) => ( +

+ {count} member{count === 1 ? '' : 's'} purged by {name} (AI) in the last 7 days.{' '} + +

+ ))} +
+ ); +} + +/** The purge log: one line per spam purge, newest first. */ +export function PurgeLog({ by, onByChange }: { by: string; onByChange: (by: string) => void }) { + const queryClient = useQueryClient(); + const q = useQuery({ + queryKey: ['spam-purges', 'log', by], + queryFn: () => api.admin.spamPurges({ ...(by ? { by } : {}), perPage: 100 }), + }); + const [unblocking, setUnblocking] = useState(null); + const [reason, setReason] = useState(''); + const unblock = useMutation({ + mutationFn: ({ id, why }: { id: string; why: string }) => api.admin.unblock(id, why.trim() || undefined), + onSuccess: async () => { + toast.success('Unblocked: that GitHub account can sign up again'); + setUnblocking(null); + setReason(''); + await queryClient.invalidateQueries({ queryKey: ['spam-purges'] }); + }, + onError: (err) => toast.error(errorText(err, 'Unblock failed')), + }); + const purges = q.data?.data ?? []; + + return ( +
+
+

+ Purge log +

+
+ + +
+
+ {q.isLoading &&

Loading…

} + {q.isError &&

Could not load the purge log.

} + {q.data && purges.length === 0 &&

No spam purges.

} +
    + {purges.map((p) => ( +
  • +
    + {p.personSlug} + + purged {formatRelativeTime(p.purgedAt)} by {purgerLabel(p)} + +
    + {p.reason &&

    {p.reason}

    } + {p.flags.length > 0 && ( +
    + {p.flags.map((f) => ( + + {f} + + ))} +
    + )} + {p.evidence.length > 0 && ( + + )} +
    + + Commit + + + How to restore + + {p.blocking ? ( + <> + Blocks sign-up + + + ) : p.unblockedAt ? ( + + Unblocked by {p.unblockedBy?.fullName ?? 'staff'} · {formatRelativeTime(p.unblockedAt)} + + ) : null} +
    +
  • + ))} +
+ + !open && setUnblocking(null)}> + + + Unblock {unblocking?.personSlug}? + + The GitHub account purged under this slug will be able to sign up again. The purge stays in the log, marked + unblocked. To bring the member back too, follow the restore runbook. + + +
{ + e.preventDefault(); + if (unblocking) unblock.mutate({ id: unblocking.id, why: reason }); + }} + className="space-y-3" + > +
+ +