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/contracts/sources.toml b/apps/api/gitsheets/contracts/sources.toml
index 0b8b25c..8c25e2e 100644
--- a/apps/api/gitsheets/contracts/sources.toml
+++ b/apps/api/gitsheets/contracts/sources.toml
@@ -1,3 +1,7 @@
+["codeforphilly.org/agent-grants/v1"]
+adopted = 2026-11-12T19:53:17.251Z
+source = "https://raw.githubusercontent.com/CodeForPhilly/codeforphilly-ng/71bf951824e93b7537025fcc1cd479cd88fb1331/contracts/codeforphilly.org/agent-grants/v1.json"
+
["codeforphilly.org/blog-posts/v1"]
adopted = 2026-10-14T03:31:18.13Z
source = "https://raw.githubusercontent.com/CodeForPhilly/codeforphilly-ng/7fe6b81a0bbd05b7a4e6896d55e181fdb61cfe42/contracts/codeforphilly.org/blog-posts/v1.json"
@@ -82,6 +86,10 @@ source = "https://raw.githubusercontent.com/CodeForPhilly/codeforphilly-ng/7fe6b
adopted = 2026-10-14T03:31:33.684Z
source = "https://raw.githubusercontent.com/CodeForPhilly/codeforphilly-ng/7fe6b81a0bbd05b7a4e6896d55e181fdb61cfe42/contracts/codeforphilly.org/slug-history/v1.json"
+["codeforphilly.org/spam-purges/v1"]
+adopted = 2026-11-12T19:53:17.88Z
+source = "https://raw.githubusercontent.com/CodeForPhilly/codeforphilly-ng/71bf951824e93b7537025fcc1cd479cd88fb1331/contracts/codeforphilly.org/spam-purges/v1.json"
+
["codeforphilly.org/tag-assignments/v1"]
adopted = 2026-10-14T03:31:35.046Z
source = "https://raw.githubusercontent.com/CodeForPhilly/codeforphilly-ng/7fe6b81a0bbd05b7a4e6896d55e181fdb61cfe42/contracts/codeforphilly.org/tag-assignments/v1.json"
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/prune-spam.ts b/apps/api/scripts/prune-spam.ts
index 0e0266c..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';
/**
@@ -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,11 +175,25 @@ 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 };
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)) {
@@ -187,6 +208,34 @@ export function foldEvaluation(
}
}
+/** 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) || evaluator.startsWith(AGENT_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 +247,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 +262,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 +397,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/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/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/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/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..92d8b31 100644
--- a/apps/api/src/plugins/services.ts
+++ b/apps/api/src/plugins/services.ts
@@ -32,6 +32,9 @@ 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 { 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';
@@ -64,6 +67,9 @@ declare module 'fastify' {
accountClaim: AccountClaimService;
moderation: ModerationService;
moderationWrite: ModerationWriteService;
+ agents: AgentService;
+ agentPurge: AgentPurgeService;
+ spamPurges: SpamPurgeLogService;
};
/** Shared in-memory state — write routes call StateApply.apply against this. */
inMemoryState: InMemoryState;
@@ -153,6 +159,12 @@ 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/account-claim.ts b/apps/api/src/routes/account-claim.ts
index 15ead2f..cad3dcc 100644
--- a/apps/api/src/routes/account-claim.ts
+++ b/apps/api/src/routes/account-claim.ts
@@ -512,6 +512,9 @@ export async function accountClaimRoutes(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 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);
+ // `grant` is required; the service says so by name (422 fields.grant).
+ 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,
+ 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),
+ (value) => 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: fastify.services.agents.grantView(grant),
+ 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),
+ (value) => 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, optionally withdrawing the agent's staged votes (administrator)",
+ params: {
+ type: 'object',
+ properties: { slug: { type: 'string' }, id: { type: 'string' } },
+ required: ['slug', 'id'],
+ },
+ body: {
+ type: ['object', 'null'],
+ properties: { withdrawVotes: { type: 'boolean' } },
+ additionalProperties: false,
+ },
+ },
+ },
+ async (request) => {
+ administratorCaller(request);
+ const { slug, id } = request.params as { slug: string; id: string };
+ const withdrawVotes = (request.body as { withdrawVotes?: boolean } | null)?.withdrawVotes === true;
+ const existing = fastify.services.agents.grantOr404(slug, id);
+ if (existing.revokedAt && !withdrawVotes) 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, ...(withdrawVotes ? { 'Withdraw-Votes': 'true' } : {}) },
+ }),
+ async (tx) => {
+ // A token that may be compromised: revoke, and withdraw what it
+ // staged, in one commit (specs/api/agents.md → revoke).
+ const revoked = await fastify.services.agents.revoke(tx, slug, id);
+ if (!withdrawVotes) return { ...revoked, withdrawn: null };
+ const withdrawn = await fastify.services.moderationWrite.withdrawAll(tx, slug, null, revoked.stateApply);
+ return { ...revoked, withdrawn };
+ },
+ // Immediate: the next request with this token is refused.
+ (value) => value.stateApply.apply(fastify.inMemoryState, fastify.fts),
+ );
+ const { grant, withdrawn } = result.value;
+ return ok({
+ grant: fastify.services.agents.grantView(grant),
+ ...(withdrawn ? { withdrawn: withdrawn.withdrawn, reactivated: withdrawn.reactivated } : {}),
+ });
+ },
+ );
+
+ 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),
+ (value) => value.stateApply.apply(fastify.inMemoryState, fastify.fts),
+ );
+ return ok({ withdrawn: result.value.withdrawn, reactivated: result.value.reactivated });
+ },
+ );
+}
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/routes/moderation.ts b/apps/api/src/routes/moderation.ts
index 35212fc..49112fe 100644
--- a/apps/api/src/routes/moderation.ts
+++ b/apps/api/src/routes/moderation.ts
@@ -1,28 +1,29 @@
/**
* 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
+ * 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
*
- * 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 { 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, staffCaller } 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 +35,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 +49,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 +61,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,21 +80,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),
+ votes: moderation.votes(slug),
+ earlierVotes: moderation.earlierVotes(slug),
+ earlierPurges: moderation.earlierPurges(slug),
});
},
);
@@ -109,6 +118,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,
@@ -116,31 +126,238 @@ 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),
+ // Applied under the store lock: the next vote or purge is checked
+ // against this one (moderation.md → Agent votes).
+ (value) => value.stateApply.apply(fastify.inMemoryState, fastify.fts),
);
- 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 };
+ 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),
+ (value) => 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),
});
},
);
+ // 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 store lock and the write lock, against the
+ // member as it is then, and nothing written.
+ const preview = await fastify.store.exclusive(() =>
+ fastify.publicRepo.withLock(async () => {
+ const now = new Date();
+ const current = service.memberOr404(slug);
+ if (current.id !== member.id) throw new ApiNotFoundError(`Person '${slug}' not found`);
+ const { deleted } = await service.check(caller.agent.person, caller.agent.grant.id, current, body, now);
+ const purge = service.record(uuidv7(), caller.agent.person, caller.agent.grant.id, current, 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),
+ );
+ // Checked, committed and applied under one lock: the next purge or vote
+ // sees this one (moderation.md → Agent purges).
+ const result = await fastify.store.transact(
+ opts,
+ async (tx) => service.purge(tx, caller.session, member, body, purgeId, head),
+ (value) => 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 });
+ },
+ );
+
+ // 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-claim.ts b/apps/api/src/services/account-claim.ts
index 6419481..bb9a8f7 100644
--- a/apps/api/src/services/account-claim.ts
+++ b/apps/api/src/services/account-claim.ts
@@ -43,6 +43,7 @@ import type {
GhIdentitySnapshot,
} from '../auth/jwt.js';
import { verifyLegacyPassword } from '../auth/legacy-password.js';
+import { isAgentAccount } from '../auth/agent-grant.js';
import { GitHubAccountService } from './github-account.js';
import type { ResolvedGitHubIdentity } from '../auth/github-client.js';
@@ -156,6 +157,7 @@ export class AccountClaimService {
for (const personId of claims.candidates) {
const person = this.#state.people.get(personId);
if (!person || person.deletedAt || person.githubUserId) continue;
+ if (isAgentAccount(this.#state, person.slug)) continue;
const profile = await this.#privateStore.getProfile(personId);
const matchedEmail =
@@ -202,7 +204,7 @@ export class AccountClaimService {
return { ok: false, code: 'not_a_candidate' };
}
const person = this.#state.people.get(personId);
- if (!person || person.deletedAt) {
+ if (!person || person.deletedAt || isAgentAccount(this.#state, person.slug)) {
return { ok: false, code: 'not_a_candidate' };
}
if (person.githubUserId) {
@@ -264,7 +266,7 @@ export class AccountClaimService {
return { ok: false, code: 'invalid', reason: 'no_slug' };
}
const person = this.#state.people.get(personId);
- if (!person || person.deletedAt) {
+ if (!person || person.deletedAt || isAgentAccount(this.#state, person.slug)) {
return { ok: false, code: 'invalid', reason: 'no_slug' };
}
if (person.githubUserId) {
@@ -357,6 +359,7 @@ export class AccountClaimService {
const person = this.#state.people.get(personId);
if (!person || person.deletedAt || person.githubUserId) return null;
+ if (isAgentAccount(this.#state, person.slug)) return null;
return {
personId: person.id,
@@ -439,7 +442,10 @@ export class AccountClaimService {
reason: string | null,
): Promise<
| { ok: true; result: StaffApproveResult }
- | { ok: false; code: 'not_found' | 'already_reviewed' | 'no_claimed_person' | 'requester_missing' | 'already_claimed' }
+ | {
+ ok: false;
+ code: 'not_found' | 'already_reviewed' | 'no_claimed_person' | 'requester_missing' | 'already_claimed' | 'agent_account';
+ }
> {
const existing = await this.#privateStore.getClaimRequest(requestId);
if (!existing) return { ok: false, code: 'not_found' };
@@ -448,6 +454,8 @@ export class AccountClaimService {
const claimed = this.#state.people.get(existing.claimedPersonId);
if (!claimed || claimed.deletedAt) return { ok: false, code: 'no_claimed_person' };
+ // Nobody takes over an agent account, staff approval or not (specs/api/agents.md).
+ if (isAgentAccount(this.#state, claimed.slug)) return { ok: false, code: 'agent_account' };
const stateApply = new StateApply();
const now = nowIso();
diff --git a/apps/api/src/services/account-matching.ts b/apps/api/src/services/account-matching.ts
index aac70d5..27e6154 100644
--- a/apps/api/src/services/account-matching.ts
+++ b/apps/api/src/services/account-matching.ts
@@ -11,10 +11,11 @@
* 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';
+import { isAgentAccount } from '../auth/agent-grant.js';
export type MatchResult =
| { kind: 'existing'; personId: string; person: Person }
@@ -45,7 +46,8 @@ export async function resolveIdentity(
if (!pid) continue;
const person = state.people.get(pid);
// Skip already-linked persons — they belong to someone else's GH account.
- if (person && !person.githubUserId && !person.deletedAt) {
+ // An agent account is never a candidate (it has no email, but be sure).
+ if (person && !person.githubUserId && !person.deletedAt && !isAgentAccount(state, person.slug)) {
candidates.add(pid);
}
}
@@ -55,7 +57,9 @@ export async function resolveIdentity(
const usernameMatchId = state.personIdBySlug.get(usernameSlug);
if (usernameMatchId) {
const person = state.people.get(usernameMatchId);
- if (person && !person.githubUserId && !person.deletedAt) {
+ // An agent account's slug is not a GitHub login to be matched
+ // (specs/behaviors/account-migration.md → Identity proofs).
+ if (person && !person.githubUserId && !person.deletedAt && !isAgentAccount(state, person.slug)) {
candidates.add(usernameMatchId);
}
}
@@ -63,3 +67,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/agent-purge.ts b/apps/api/src/services/agent-purge.ts
new file mode 100644
index 0000000..7a45bb3
--- /dev/null
+++ b/apps/api/src/services/agent-purge.ts
@@ -0,0 +1,432 @@
+/**
+ * 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 { isValidBuzzSlug, isValidProjectSlug } from '../lib/slug.js';
+import { agentTextProblem } from './agent-text.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;
+
+export { containsUrl } from './agent-text.js';
+const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+
+/** 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;
+}
+
+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 {
+ const problem = agentTextProblem(typeof body.reason === 'string' ? body.reason : '');
+ if (problem) fields['reason'] = problem;
+ }
+ 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"');
+ // Identifiers are checked for shape before anything is looked up or
+ // written into a path or a trailer.
+ if (item.project !== undefined && (typeof item.project !== 'string' || !isValidProjectSlug(item.project))) {
+ bad('project must be a project slug');
+ }
+ if (item.slug !== undefined && (typeof item.slug !== 'string' || !isValidBuzzSlug(item.slug))) bad('slug must be a buzz slug');
+ if (item.roleId !== undefined && (typeof item.roleId !== 'string' || !UUID_RE.test(item.roleId))) bad('roleId must be a UUID');
+ if (item.number !== undefined && (!Number.isInteger(item.number) || item.number < 1)) bad('number must be a positive integer');
+ 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/agent-text.ts b/apps/api/src/services/agent-text.ts
new file mode 100644
index 0000000..d0f9cd5
--- /dev/null
+++ b/apps/api/src/services/agent-text.ts
@@ -0,0 +1,69 @@
+/**
+ * Checks on an agent's free text bound for a permanent public commit: a purge
+ * `reason` (the commit body) and a vote `reasoning` (the commit summary) —
+ * specs/api/moderation.md → the purge request, Agent votes. No URL in any
+ * form, so the spam link is never republished; no control characters, so a
+ * line break cannot forge a commit trailer.
+ */
+
+/** Common top-level domains, matched in any case (`Cheap-Pills.SHOP`). */
+const COMMON_TLDS = new Set([
+ 'com', 'net', 'org', 'info', 'biz', 'io', 'co', 'me', 'app', 'dev', 'xyz', 'top', 'shop', 'store', 'online',
+ 'site', 'club', 'live', 'pro', 'tech', 'link', 'click', 'buzz', 'vip', 'win', 'icu', 'cc', 'tv', 'ru', 'cn',
+ 'uk', 'us', 'de', 'in', 'ly', 'gg', 'ai', 'to', 'ws', 'su', 'tk', 'ml', 'ga', 'cf', 'gq', 'pw', 'sbs', 'cfd',
+ 'example',
+]);
+
+/** A defanged dot between word characters: `[.]`, `(.)`, `{.}`, `[dot]`, `(dot)`. */
+const DEFANGED_DOT = /\w\s*[[({]\s*(?:\.|dot)\s*[\])}]\s*\w/i;
+/** Any scheme, `hxxp://` and `hxxps://` included. */
+const SCHEME = /\b[a-z][a-z0-9+.-]*:\/\//i;
+const WWW = /\bwww\./i;
+/**
+ * A bare domain: labels joined by dots with a word character on each side of
+ * every dot, ending in a TLD of two or more letters. Labels any case; the TLD
+ * is checked separately (lowercase, or a common TLD in any case).
+ */
+const BARE_DOMAIN = /(? l.length === 1)) continue;
+ if (tld === tld.toLowerCase() || COMMON_TLDS.has(tld.toLowerCase())) return true;
+ }
+ return false;
+}
+
+/**
+ * Whether `text` holds a control character (a line break included): C0 and
+ * C1 controls, DEL, and the Unicode line and paragraph separators.
+ */
+export function containsControlChars(text: string): boolean {
+ for (let i = 0; i < text.length; i++) {
+ const c = text.charCodeAt(i);
+ if (c <= 0x1f || (c >= 0x7f && c <= 0x9f) || c === 0x2028 || c === 0x2029) return true;
+ }
+ return false;
+}
+
+/**
+ * The problem with an agent's free text bound for a public commit (a purge
+ * `reason`, a vote `reasoning`), or null: a control character or a URL.
+ */
+export function agentTextProblem(text: string): string | null {
+ if (containsControlChars(text)) return 'must not contain control characters (line breaks included)';
+ if (containsUrl(text)) return 'must not contain a URL: cite the record as evidence instead';
+ return null;
+}
diff --git a/apps/api/src/services/agents.ts b/apps/api/src/services/agents.ts
new file mode 100644
index 0000000..a7d9d2b
--- /dev/null
+++ b/apps/api/src/services/agents.ts
@@ -0,0 +1,298 @@
+/**
+ * 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 { slugHasModerationHistory, 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;
+ /** Required: an account is an agent because it holds a grant (specs/api/agents.md). */
+ 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 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; token: string; 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';
+ else if (slugHasModerationHistory(this.#state, slug)) fields['slug'] = 'slug_retired';
+ 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';
+ let grantInput: ReturnType | null = null;
+ if (input.grant === undefined || input.grant === null || typeof input.grant !== 'object') {
+ fields['grant'] = 'required: an agent is created with its first grant';
+ } else {
+ grantInput = validateGrantInput(input.grant);
+ }
+ if (Object.keys(fields).length > 0 || !grantInput) 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);
+
+ const { grant, token } = await this.#writeGrant(tx, stateApply, person, createdBySlug, grantInput, now, ids.grantId);
+ 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 changes nothing), recording it
+ * on `stateApply`.
+ */
+ async revoke(
+ tx: DualStoreTx,
+ agentSlug: string,
+ grantId: string,
+ stateApply: StateApply = new StateApply(),
+ ): Promise<{ grant: AgentGrant; stateApply: StateApply }> {
+ const existing = this.grantOr404(agentSlug, grantId);
+ if (existing.revokedAt) return { grant: existing, stateApply };
+ const grant = AgentGrantSchema.parse({ ...existing, revokedAt: nowIso() });
+ await tx.public['agent-grants'].upsert(grant);
+ return { grant, stateApply: stateApply.upsertAgentGrant(grant) };
+ }
+}
diff --git a/apps/api/src/services/moderation.ts b/apps/api/src/services/moderation.ts
index 1dc97b5..9a78a44 100644
--- a/apps/api/src/services/moderation.ts
+++ b/apps/api/src/services/moderation.ts
@@ -1,32 +1,51 @@
/**
- * 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 {
+ 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 { agentTextProblem } from './agent-text.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;
@@ -38,6 +57,7 @@ export interface MemberListOptions {
readonly sort?: string;
readonly page?: number;
readonly perPage?: number;
+ readonly viewer?: RosterViewer;
}
export interface VoterRef {
@@ -46,12 +66,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;
@@ -81,7 +124,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;
@@ -90,7 +135,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`. */
@@ -181,6 +230,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[] = [];
@@ -207,6 +258,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');
@@ -237,6 +289,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;
@@ -302,23 +366,76 @@ 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 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 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(humanVotesOnAccount(this.#state, person));
}
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) => 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 {
+ const id = this.#state.personIdBySlug.get(slug);
+ 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));
+ }
+
async listMembers(opts: MemberListOptions): Promise {
const sort = parseSort(opts.sort);
if (!sort) return { error: 'invalid_sort' };
@@ -337,11 +454,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();
@@ -361,8 +479,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;
}
@@ -390,24 +510,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 =
@@ -442,6 +563,7 @@ export class ModerationService {
emailBounce,
lastSlackSsoAt,
footprint,
+ slugPurgedBefore: (this.#state.spamPurgesBySlug.get(p.slug)?.size ?? 0) > 0,
});
return {
id: p.id,
@@ -451,7 +573,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,
@@ -461,6 +584,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,
@@ -526,13 +651,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,
};
}
@@ -556,6 +681,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 {
@@ -565,11 +712,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,
@@ -579,36 +749,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, slug);
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) {
@@ -618,16 +795,148 @@ export class ModerationWriteService {
return { person, vote, stateApply };
}
-}
-function latestHumanVerdict(state: InMemoryState, personSlug: string): PersonEvaluation['verdict'] | null {
- const keys = state.evaluationsByPerson.get(personSlug);
- if (!keys) return null;
- let latest: PersonEvaluation | null = null;
- for (const key of keys) {
- const rec = state.personEvaluations.get(key);
- if (!rec || !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' });
+ }
+ // Committed for good: no URL in any form, no control characters.
+ const problem = agentTextProblem(input.reasoning ?? '');
+ if (problem) throw new ApiValidationError(`Reasoning ${problem}`, { reasoning: problem });
+ 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,
+ stateApply: StateApply = new StateApply(),
+ ): Promise<{ withdrawn: number; reactivated: number; stateApply: StateApply }> {
+ const evaluator = agentEvaluatorFor(agentSlug);
+ 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-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.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/person.write.ts b/apps/api/src/services/person.write.ts
index f84d02e..542a19d 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';
@@ -31,13 +33,16 @@ 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';
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';
+import { slugHasModerationHistory } from './votes.js';
function nowIso(): string {
return new Date().toISOString();
@@ -81,6 +86,11 @@ export class PersonWriteService {
let newSlug = existing.slug;
if (input.slug !== undefined && input.slug !== existing.slug) {
+ // An agent's votes and purges are recorded under `agent-`, which a
+ // rename would not follow (specs/api/agents.md → The agent account).
+ if (isAgentAccount(this.#state, existing.slug)) {
+ throw new ConflictError('An agent account cannot be renamed', 'agent_account');
+ }
const candidate = input.slug.toLowerCase();
if (!isValidPersonSlug(candidate)) {
throw new ApiValidationError('Invalid slug format', { slug: 'invalid format' });
@@ -91,6 +101,13 @@ export class PersonWriteService {
if (this.#state.personIdBySlug.has(candidate)) {
throw new ConflictError(`Slug '${candidate}' is already taken`, 'slug_taken');
}
+ // specs/behaviors/slug-handles.md → Moderation history retires a slug.
+ if (slugHasModerationHistory(this.#state, candidate)) {
+ throw new ApiValidationError(
+ `Slug '${candidate}' belonged to an earlier account with moderation history; choose another`,
+ { slug: 'slug_retired' },
+ );
+ }
newSlug = candidate;
}
@@ -274,7 +291,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);
@@ -285,7 +306,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 };
}
/**
@@ -325,6 +358,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/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/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/services/spam-purge.ts b/apps/api/src/services/spam-purge.ts
index 94e3ac5..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 { isHumanEvaluator, 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';
@@ -13,6 +13,9 @@ 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 { 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. */
export const SPAM_PURGE_GRACE_MS = 3 * 24 * 60 * 60 * 1000;
@@ -20,17 +23,20 @@ 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 them is `spam`, they are still deactivated and not staff, and the date is
- * three days after the newest vote.
+ * 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 = [...(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)));
+ 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,13 +44,43 @@ 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 `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;
+ 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 {
+ /** 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;
@@ -64,20 +100,41 @@ 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
// 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/src/services/votes.ts b/apps/api/src/services/votes.ts
new file mode 100644
index 0000000..365a661
--- /dev/null
+++ b/apps/api/src/services/votes.ts
@@ -0,0 +1,114 @@
+/**
+ * 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 so
+ * was a vote dated at or before the newest `spam-purges` record for the slug
+ * that names another person (the purge ended that account). Such a vote counts
+ * for nothing on this one: not in the roster's vote fields, not in the
+ * reversal rule, not in the scheduled purge.
+ */
+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';
+
+type AccountRef = Pick;
+
+/**
+ * The time (ms) before which, inclusive, votes under this person's slug were
+ * cast on an earlier account: the later of just before `createdAt` and the
+ * newest `spam-purges` record for the slug that names another person.
+ */
+export function accountVoteCutoff(state: InMemoryState, person: AccountRef): number {
+ let cutoff = Date.parse(person.createdAt) - 1;
+ for (const id of state.spamPurgesBySlug.get(person.slug) ?? []) {
+ const record = state.spamPurges.get(id);
+ if (!record || record.personId === person.id) continue;
+ cutoff = Math.max(cutoff, Date.parse(record.purgedAt));
+ }
+ return cutoff;
+}
+
+/** Whether `vote` was cast on this account (after its cutoff). */
+export function isOnAccount(state: InMemoryState, vote: Pick, person: AccountRef): boolean {
+ return Date.parse(vote.evaluatedAt) > accountVoteCutoff(state, person);
+}
+
+/** Whether any `spam-purges` or `person-evaluations` record is keyed by `slug` (slug-handles.md). */
+export function slugHasModerationHistory(state: InMemoryState, slug: string): boolean {
+ return (state.spamPurgesBySlug.get(slug)?.size ?? 0) > 0 || (state.evaluationsByPerson.get(slug)?.size ?? 0) > 0;
+}
+
+/** 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: AccountRef): PersonEvaluation[] {
+ const cutoff = accountVoteCutoff(state, person);
+ return evaluationsForSlug(state, person.slug).filter((v) => Date.parse(v.evaluatedAt) > cutoff);
+}
+
+/** Evaluations under this slug cast on an earlier account (at or before the cutoff). */
+export function votesOnEarlierAccounts(state: InMemoryState, person: AccountRef): PersonEvaluation[] {
+ const cutoff = accountVoteCutoff(state, person);
+ return evaluationsForSlug(state, person.slug).filter((v) => Date.parse(v.evaluatedAt) <= cutoff);
+}
+
+/** Votes on this account by staff (`human-*`). */
+export function humanVotesOnAccount(state: InMemoryState, person: AccountRef): PersonEvaluation[] {
+ return votesOnAccount(state, person).filter((v) => evaluatorKind(v.evaluator) === 'human');
+}
+
+/** Votes on this account by agents (`agent-*`). */
+export function agentVotesOnAccount(state: InMemoryState, person: AccountRef): 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: AccountRef): 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: AccountRef,
+ 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: AccountRef): PersonEvaluation | null {
+ if (hasHumanVote(state, person)) return null;
+ return newestVote(agentVotesOnAccount(state, person));
+}
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/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..d91fc48 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,26 @@ export abstract class BasePrivateStore implements PrivateStore {
await this.writeRaw('account-claim-requests.jsonl', lines ? lines + '\n' : '');
}
+ /** Tail of the queue of writes to `agent-grant-secrets.jsonl`. */
+ #grantSecretsWrite: Promise = Promise.resolve();
+
+ /**
+ * Write `agent-grant-secrets.jsonl`, one write at a time: `lastUsedAt`
+ * updates land outside any transaction, and the filesystem backend writes
+ * through one fixed temp path. Each write serializes the map as it is when
+ * its turn comes, so the last write carries the latest state.
+ */
+ protected async flushGrantSecrets(): Promise {
+ const write = this.#grantSecretsWrite
+ .catch(() => undefined)
+ .then(async () => {
+ const lines = [...this.grantSecrets.values()].map((t) => JSON.stringify(t)).join('\n');
+ await this.writeRaw('agent-grant-secrets.jsonl', lines ? lines + '\n' : '');
+ });
+ this.#grantSecretsWrite = write;
+ await write;
+ }
+
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 +389,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..462027b 100644
--- a/apps/api/src/store/store.ts
+++ b/apps/api/src/store/store.ts
@@ -1,5 +1,12 @@
+import { AsyncLocalStorage } from 'node:async_hooks';
+
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';
@@ -40,6 +47,10 @@ export interface StoreTransactOptions extends TransactionOptions {
export class Store {
readonly #public: PublicStore;
readonly #private: PrivateStore;
+ /** Tail of the FIFO queue behind {@link exclusive}. */
+ #tail: Promise = Promise.resolve();
+ /** Set inside {@link exclusive}, to refuse re-entry instead of deadlocking. */
+ readonly #held = new AsyncLocalStorage();
constructor(publicStore: PublicStore, privateStore: PrivateStore) {
this.#public = publicStore;
@@ -54,6 +65,31 @@ export class Store {
return this.#private;
}
+ /**
+ * Run `fn` holding the store's own lock: the one every `transact` holds
+ * from before its handler runs until after its `onCommit` has applied the
+ * result to the in-memory state. Reads made under it see every earlier
+ * write already applied (specs/api/moderation.md → Agent purges: the
+ * check, the commit and the state apply under one lock). FIFO, not
+ * re-entrant: a nested call throws rather than deadlocking.
+ */
+ async exclusive(fn: () => Promise): Promise {
+ if (this.#held.getStore()) {
+ throw new Error('Store.exclusive / Store.transact inside the store lock would deadlock: it is not re-entrant');
+ }
+ const previous = this.#tail;
+ let release!: () => void;
+ this.#tail = new Promise((resolve) => {
+ release = resolve;
+ });
+ await previous;
+ try {
+ return await this.#held.run(true, fn);
+ } finally {
+ release();
+ }
+ }
+
/**
* Execute a handler inside a cross-store transaction.
*
@@ -66,10 +102,26 @@ export class Store {
* the public commit, the error is thrown loud and a reconciliation script
* should be run to align state. There is no automatic git-revert of the
* public commit.
+ *
+ * `onCommit`, when given, runs after both stores are written and before
+ * the store lock is released — the place to apply the handler's
+ * `StateApply`, so the next transaction's handler reads it applied.
*/
async transact(
opts: StoreTransactOptions,
handler: (tx: DualStoreTx) => Promise,
+ onCommit?: (value: T) => void,
+ ): Promise> {
+ return this.exclusive(async () => {
+ const result = await this.#transact(opts, handler);
+ onCommit?.(result.value);
+ return result;
+ });
+ }
+
+ async #transact(
+ opts: StoreTransactOptions,
+ handler: (tx: DualStoreTx) => Promise,
): Promise> {
const writeOrder = opts.writeOrder ?? 'public-first';
@@ -80,6 +132,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 +140,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 +150,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 +161,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/account-claim.test.ts b/apps/api/tests/account-claim.test.ts
index f14ce95..fe9ee23 100644
--- a/apps/api/tests/account-claim.test.ts
+++ b/apps/api/tests/account-claim.test.ts
@@ -777,3 +777,141 @@ describe('Anti-PII commit trailers', () => {
expect(log.stdout).not.toContain('pii-test@private-domain.example');
});
});
+
+// ---------------------------------------------------------------------------
+// Agent accounts are never claimed (specs/behaviors/account-migration.md,
+// specs/api/agents.md → The agent account)
+// ---------------------------------------------------------------------------
+
+describe('an agent account is never a claim candidate', () => {
+ let dataRepo: { path: string; cleanup: () => Promise };
+ let privateStore: { path: string; cleanup: () => Promise };
+ let app: FastifyInstance;
+ const agentId = '01951a3c-0000-7000-8000-0000abab0001';
+ const staffId = '01951a3c-0000-7000-8000-0000abab0002';
+ const freshId = '01951a3c-0000-7000-8000-0000abab0003';
+ const grantId = '01951a3c-0000-7000-8000-0000abab0004';
+
+ beforeAll(async () => {
+ dataRepo = await createFullDataRepo();
+ privateStore = await createPrivateStorageDir();
+ await seedPerson(dataRepo.path, 'helper-bot', agentId);
+ // Its only grant has been revoked: still an agent account.
+ await seedRawToml(
+ dataRepo.path,
+ `agent-grants/helper-bot/${grantId}.toml`,
+ [
+ `id = "${grantId}"`,
+ `personSlug = "helper-bot"`,
+ `label = "test host"`,
+ `scopes = ["moderation:read"]`,
+ `createdAt = "2026-05-01T00:00:00Z"`,
+ `createdBySlug = "staff3"`,
+ `expiresAt = "2026-08-01T00:00:00Z"`,
+ `revokedAt = "2026-05-02T00:00:00Z"`,
+ ].join('\n'),
+ 'seed agent grant',
+ );
+ await seedPerson(dataRepo.path, 'staff3', staffId, {
+ accountLevel: 'staff',
+ githubUserId: 9101,
+ githubLogin: 'staff3',
+ githubLinkedAt: '2026-04-01T00:00:00Z',
+ });
+ await seedPrivateProfile(privateStore.path, staffId, 'staff3@example.com');
+ await seedPerson(dataRepo.path, 'fresh-three', freshId, {
+ githubUserId: 9102,
+ githubLogin: 'fresh-three',
+ githubLinkedAt: '2026-05-15T00:00:00Z',
+ });
+ await seedPrivateProfile(privateStore.path, freshId, 'fresh-three@example.com');
+ app = await buildTestApp(dataRepo.path, privateStore.path);
+ }, 60_000);
+
+ afterAll(async () => {
+ await app.close();
+ await dataRepo.cleanup();
+ await privateStore.cleanup();
+ });
+
+ it('a GitHub login equal to its slug is not matched to it', async () => {
+ const { resolveIdentity } = await import('../src/services/account-matching.js');
+ const result = await resolveIdentity(
+ { id: 9200, login: 'helper-bot', name: null, emails: [], primaryEmail: null },
+ app.inMemoryState,
+ app.store.private,
+ );
+ expect(result).toEqual({ kind: 'create-fresh' });
+ });
+
+ it('is dropped from a claim token that names it', async () => {
+ const token = await mintClaim('9201', 'helper-bot', ['someone@example.com'], [agentId]);
+ const res = await app.inject({
+ method: 'GET',
+ url: '/api/account-claim/candidates',
+ remoteAddress: nextTestIp(),
+ cookies: { cfp_claim: token },
+ });
+ expect(res.statusCode).toBe(200);
+ expect((res.json() as { data: { candidates: unknown[] } }).data.candidates).toEqual([]);
+ });
+
+ it('is not found by the post-sign-up search', async () => {
+ const { access } = await issueSession(freshId, 'user', JWT_KEY);
+ const res = await app.inject({
+ method: 'GET',
+ url: '/api/account-claim/legacy?q=helper-bot',
+ remoteAddress: nextTestIp(),
+ cookies: { cfp_session: access },
+ });
+ expect(res.statusCode).toBe(200);
+ expect((res.json() as { data: { candidates: unknown[] } }).data.candidates).toEqual([]);
+ });
+
+ it('staff cannot approve a claim on it, pre- or post-onboarding (409 agent_account)', async () => {
+ const { access: staffAccess } = await issueSession(staffId, 'staff', JWT_KEY);
+ const claimToken = await mintClaim('9203', 'helper-bot', ['claimer@example.com'], []);
+ const pre = await app.inject({
+ method: 'POST',
+ url: '/api/account-claim/request-staff-review',
+ remoteAddress: nextTestIp(),
+ cookies: { cfp_claim: claimToken },
+ payload: { claimedSlug: 'helper-bot', evidence: 'It is my bot.' },
+ });
+ expect(pre.statusCode).toBe(202);
+ const { access: freshAccess } = await issueSession(freshId, 'user', JWT_KEY);
+ const post = await app.inject({
+ method: 'POST',
+ url: '/api/account-claim/legacy/request',
+ remoteAddress: nextTestIp(),
+ cookies: { cfp_session: freshAccess },
+ payload: { claimedSlug: 'helper-bot', evidence: 'It is my bot.' },
+ });
+ expect(post.statusCode, post.body).toBeLessThan(300);
+
+ const queue = await app.inject({
+ method: 'GET',
+ url: '/api/staff/account-claim/queue',
+ remoteAddress: nextTestIp(),
+ cookies: { cfp_session: staffAccess },
+ });
+ const ids = (queue.json() as { data: Array<{ requestId: string; claimedSlug: string }> }).data
+ .filter((r) => r.claimedSlug === 'helper-bot')
+ .map((r) => r.requestId);
+ expect(ids).toHaveLength(2);
+ for (const requestId of ids) {
+ const res = await app.inject({
+ method: 'POST',
+ url: `/api/staff/account-claim/${requestId}/approve`,
+ remoteAddress: nextTestIp(),
+ cookies: { cfp_session: staffAccess },
+ payload: { reason: 'looks fine' },
+ });
+ expect(res.statusCode, res.body).toBe(409);
+ expect((res.json() as { error: { code: string } }).error.code).toBe('agent_account');
+ }
+ const agent = app.inMemoryState.people.get(agentId);
+ expect(agent?.githubUserId).toBeUndefined();
+ expect(app.inMemoryState.people.get(freshId)).toBeDefined();
+ });
+});
diff --git a/apps/api/tests/agent-concurrency.test.ts b/apps/api/tests/agent-concurrency.test.ts
new file mode 100644
index 0000000..ea5aa2a
--- /dev/null
+++ b/apps/api/tests/agent-concurrency.test.ts
@@ -0,0 +1,111 @@
+/**
+ * Agent purges and votes fired at once — specs/api/moderation.md → "Agent
+ * purges" and "Agent votes": the check, the commit and the update of the
+ * in-memory state happen under one lock, so a purge or vote queued behind
+ * another is checked against its result.
+ */
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+
+import {
+ bearer,
+ createAgent,
+ minutesAgo,
+ personRecord,
+ startAgentTestEnv,
+ type AgentTestEnv,
+} from './helpers/agent-fixtures.js';
+
+const ADMIN = 1;
+const STAFF = 2;
+const BIO = { bio: 'Cheap streaming, all channels, no subscription. Ask me how.' };
+const reason = 'Promotional bio.';
+const evidence = [{ record: 'person', field: 'bio' }];
+
+describe('agent purges and votes at once', () => {
+ let env: AgentTestEnv;
+ let admin: string;
+ let staff: string;
+
+ beforeAll(async () => {
+ env = await startAgentTestEnv({
+ records: {
+ people: [
+ personRecord(ADMIN, 'boss', { accountLevel: 'administrator' }),
+ personRecord(STAFF, 'staffer', { accountLevel: 'staff' }),
+ ...Array.from({ length: 8 }, (_, i) => personRecord(100 + i, `rush-${i}`, { createdAt: minutesAgo(5), ...BIO })),
+ personRecord(200, 'twice', { createdAt: minutesAgo(5), ...BIO }),
+ personRecord(201, 'contested', { createdAt: minutesAgo(5), ...BIO }),
+ ...Array.from({ length: 22 }, (_, i) => personRecord(300 + i, `vote-rush-${i}`, { createdAt: minutesAgo(5), ...BIO })),
+ ],
+ },
+ });
+ admin = await env.cookie(ADMIN, 'administrator');
+ staff = await env.cookie(STAFF, 'staff');
+ }, 60_000);
+
+ afterAll(async () => {
+ await env.close();
+ });
+
+ const purge = (slug: string, token: string) =>
+ env.app.inject({ method: 'POST', url: `/api/admin/members/${slug}/purge`, payload: { reason, evidence }, ...bearer(token) });
+ const commitCount = async () => Number((await env.git('rev-list', '--count', 'HEAD')).trim());
+ const purgesBy = (agentSlug: string) =>
+ [...env.app.inMemoryState.spamPurges.values()].filter((p) => p.by === `agent-${agentSlug}`);
+
+ it('eight purges at once never pass the five-a-day allowance', async () => {
+ const bot = await createAgent(env, admin, { slug: 'rush-bot', scopes: ['moderation:read', 'moderation:purge'] });
+ const before = await commitCount();
+ const results = await Promise.all(Array.from({ length: 8 }, (_, i) => purge(`rush-${i}`, bot.token)));
+ const codes = results.map((r) => r.statusCode).sort();
+ expect(codes).toEqual([200, 200, 200, 200, 200, 429, 429, 429]);
+ expect(purgesBy('rush-bot')).toHaveLength(5);
+ expect(await commitCount()).toBe(before + 5);
+ }, 60_000);
+
+ it('two purges of one member at once make one commit and one record', async () => {
+ const one = await createAgent(env, admin, { slug: 'twin-bot', scopes: ['moderation:read', 'moderation:purge'] });
+ const before = await commitCount();
+ const results = await Promise.all([purge('twice', one.token), purge('twice', one.token)]);
+ expect(results.map((r) => r.statusCode).sort()).toEqual([200, 404]);
+ expect(await commitCount()).toBe(before + 1);
+ expect([...env.app.inMemoryState.spamPurges.values()].filter((p) => p.personSlug === 'twice')).toHaveLength(1);
+ }, 60_000);
+
+ it('a staff Not spam vote and a purge at once: never both', async () => {
+ const bot = await createAgent(env, admin, { slug: 'race-bot', scopes: ['moderation:read', 'moderation:purge'] });
+ const [vote, purged] = await Promise.all([
+ env.app.inject({
+ method: 'POST',
+ url: '/api/admin/members/contested/vote',
+ payload: { verdict: 'legit' },
+ cookies: { cfp_session: staff },
+ }),
+ purge('contested', bot.token),
+ ]);
+ const outcome = [vote.statusCode, purged.statusCode];
+ // Vote first → the purge sees the human verdict (409); purge first → the
+ // vote finds no member (404).
+ expect([
+ [200, 409],
+ [404, 200],
+ ]).toContainEqual(outcome);
+ }, 60_000);
+
+ it('22 spam votes at once never pass the twenty-a-day allowance', async () => {
+ const bot = await createAgent(env, admin, { slug: 'vote-bot', scopes: ['moderation:read', 'moderation:vote'] });
+ const results = await Promise.all(
+ Array.from({ length: 22 }, (_, i) =>
+ env.app.inject({
+ method: 'POST',
+ url: `/api/admin/members/vote-rush-${i}/vote`,
+ payload: { verdict: 'spam', reasoning: 'Business name.' },
+ ...bearer(bot.token),
+ }),
+ ),
+ );
+ const codes = results.map((r) => r.statusCode);
+ expect(codes.filter((c) => c === 200)).toHaveLength(20);
+ expect(codes.filter((c) => c === 429)).toHaveLength(2);
+ }, 60_000);
+});
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..14b5d66
--- /dev/null
+++ b/apps/api/tests/agent-grant-secrets.test.ts
@@ -0,0 +1,87 @@
+/**
+ * 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('concurrent lastUsedAt writes and a transaction never collide on the file, and the last state lands', async () => {
+ const store = new FilesystemPrivateStore({ CFP_PRIVATE_STORAGE_PATH: dir.path });
+ await store.load();
+ const g1 = store.getGrantSecret(G1)!;
+ const g2 = store.getGrantSecret(G2)!;
+ const stamps = Array.from({ length: 20 }, (_, i) => `2026-11-12T04:${String(i).padStart(2, '0')}:00Z`);
+ await Promise.all([
+ ...stamps.map((at, i) => store.putGrantSecret({ ...(i % 2 ? g1 : g2), lastUsedAt: at })),
+ store.transact(async (tx) => {
+ tx.putGrantSecret({ ...g2, lastUsedAt: '2026-11-12T05:00:00Z' });
+ }),
+ ]);
+ const reloaded = new FilesystemPrivateStore({ CFP_PRIVATE_STORAGE_PATH: dir.path });
+ await reloaded.load();
+ expect(reloaded.getGrantSecret(G1)?.lastUsedAt).toBe(store.getGrantSecret(G1)?.lastUsedAt);
+ expect(reloaded.getGrantSecret(G2)?.lastUsedAt).toBe(store.getGrantSecret(G2)?.lastUsedAt);
+ });
+
+ 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/agent-purge.test.ts b/apps/api/tests/agent-purge.test.ts
new file mode 100644
index 0000000..c1b4d7b
--- /dev/null
+++ b/apps/api/tests/agent-purge.test.ts
@@ -0,0 +1,478 @@
+/**
+ * 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);
+ });
+
+ // Each of these gets its own agent: the general per-account write cap
+ // (30/min) must not be what answers the requests that follow.
+ const fresh = async (slug: string) =>
+ bearer((await createAgent(env, admin, { slug, scopes: ['moderation:read', 'moderation:purge'] })).token);
+
+ it('refuses a bare domain and defanged forms', async () => {
+ const as = await fresh('url-bot');
+ for (const r of [
+ 'Bio sells pills at cheap-pills.shop now',
+ 'Bio sells pills at Cheap-Pills.SHOP now',
+ 'Sells via promo-shop.example',
+ 'Bio links hxxps://promo-shop.example',
+ 'Bio links hxxp://promo-shop',
+ 'Bio links promo-shop[.]example',
+ 'Bio links promo-shop(.)example',
+ 'Bio links promo-shop(dot)example',
+ 'Bio links promo-shop [dot] example',
+ ]) {
+ expect(containsUrl(r), r).toBe(true);
+ const res = await purge('promo-shop', { reason: r, evidence: bioEvidence }, as);
+ expect(res.statusCode, r).toBe(422);
+ expect(res.json<{ error: { fields: Record } }>().error.fields['reason'], r).toMatch(/URL/);
+ }
+ });
+
+ it('lets ordinary prose through', () => {
+ for (const r of [
+ 'Business name, e.g. a shop; promotional bio.',
+ 'Promotional bio, i.e. an ad for a shop.',
+ 'Posted spam.Then edited the bio again.',
+ 'A U.S. phone number and a promo bio.',
+ 'Version 2.0 of the same ad, posted 3.5 minutes after sign-up.',
+ 'Bio is an ad. Then a second ad.',
+ ]) {
+ expect(containsUrl(r), r).toBe(false);
+ }
+ });
+
+ it('refuses control characters in the reason (422)', async () => {
+ const as = await fresh('ctl-bot');
+ for (const r of ['Promo bio.\nAction: person.restore', 'Promo bio.\r\nGrant-Id: x', 'Promo\tbio.', 'Promo bio.\u2028x', 'Promo\u0000bio']) {
+ const res = await purge('promo-shop', { reason: r, evidence: bioEvidence }, as);
+ expect(res.statusCode, JSON.stringify(r)).toBe(422);
+ expect(res.json<{ error: { fields: Record } }>().error.fields['reason']).toMatch(/control/);
+ }
+ });
+
+ it('refuses a malformed evidence identifier with 422 evidence_invalid, never a 500', async () => {
+ const as = await fresh('ev-bot');
+ for (const item of [
+ { record: 'project', project: 'Bad Slug!' },
+ { record: 'project', project: 'spam-proj\nEvidence: x' },
+ { record: 'project', project: '../people/boss' },
+ { record: 'project-update', project: 'real-project', number: 0 },
+ { record: 'project-update', project: 'real-project/../x', number: 1 },
+ { record: 'project-buzz', project: 'real-project', slug: '../x' },
+ { record: 'project-buzz', project: 'real-project', slug: 'UPPER CASE' },
+ { record: 'help-wanted-role', project: 'real-project', roleId: 'not-a-uuid' },
+ ]) {
+ const res = await purge('promo-shop', { reason, evidence: [item] }, as);
+ expect(res.statusCode, `${JSON.stringify(item)}: ${res.body}`).toBe(422);
+ expect(code(res), JSON.stringify(item)).toBe('evidence_invalid');
+ }
+ });
+
+ 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/agent-votes.test.ts b/apps/api/tests/agent-votes.test.ts
new file mode 100644
index 0000000..a6add2a
--- /dev/null
+++ b/apps/api/tests/agent-votes.test.ts
@@ -0,0 +1,461 @@
+/**
+ * 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,
+ mintGrant,
+ 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,
+ withdrawGone: 22,
+ sweep: 20,
+ late: 21,
+};
+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.withdrawGone, 'withdraw-gone', { createdAt: daysAgo(1) }),
+ personRecord(N.sweep, 'sweep-me', { createdAt: daysAgo(1) }),
+ personRecord(N.late, 'late-target', { 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);
+ // A public commit: no URL in any form, no control characters.
+ for (const reasoning of [
+ 'Bio links cheap-pills.shop',
+ 'Bio links promo-shop[.]example',
+ 'Bio links hxxps://promo-shop.example',
+ 'Business name.\nAction: person.purge',
+ 'Business\tname.',
+ ]) {
+ const res = await vote('fresh-two', { ...spam, reasoning });
+ expect(res.statusCode, JSON.stringify(reasoning)).toBe(422);
+ expect(res.json<{ error: { fields: Record } }>().error.fields['reasoning']).toBeDefined();
+ }
+ expect((await row('fresh-two')).stagedVote).toBeNull();
+ 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);
+ });
+
+ it('a new grant makes them count again (the rotation case)', async () => {
+ await mintGrant(env, admin, 'temp-bot', ['moderation:read', 'moderation:vote']);
+ const r = await row('staged-revoked');
+ expect(r.stagedVote?.counts).toBe(true);
+ expect(r.purgeAfter).not.toBeNull();
+ });
+
+ it('revoke with withdrawVotes also withdraws them, in the same commit (the compromise case)', async () => {
+ const grant = env.app.services.agents.list().find((a) => a.person.slug === 'temp-bot')!.grants.find((g) => g.active)!;
+ const before = Number((await env.git('rev-list', '--count', 'HEAD')).trim());
+ const res = await env.app.inject({
+ method: 'POST',
+ url: `/api/admin/agents/temp-bot/grants/${grant.id}/revoke`,
+ cookies: { cfp_session: admin },
+ payload: { withdrawVotes: true },
+ });
+ expect(res.statusCode, res.body).toBe(200);
+ expect(res.json<{ data: { grant: { active: boolean }; withdrawn: number; reactivated: number } }>().data).toMatchObject({
+ grant: { active: false },
+ withdrawn: 1,
+ reactivated: 1,
+ });
+ expect(Number((await env.git('rev-list', '--count', 'HEAD')).trim())).toBe(before + 1);
+ const msg = await env.git('log', '-1', '--format=%B');
+ expect(msg).toContain('Action: agent.revoke');
+ expect(msg).toContain(`Grant-Id: ${grant.id}`);
+ expect(msg).toContain('Withdraw-Votes: true');
+ const r = await row('staged-revoked');
+ expect(r.stagedVote).toBeNull();
+ expect(r.deletedAt).toBeNull();
+
+ // A later grant has nothing to revive.
+ await mintGrant(env, admin, 'temp-bot', ['moderation:read', 'moderation:vote']);
+ expect((await row('staged-revoked')).purgeAfter).toBeNull();
+ });
+
+ it('withdrawVotes on a grant already revoked still withdraws', async () => {
+ const late = await createAgent(env, admin, { slug: 'late-bot', scopes: ['moderation:read', 'moderation:vote'] });
+ expect((await vote('late-target', spam, bearer(late.token))).statusCode).toBe(200);
+ const url = `/api/admin/agents/late-bot/grants/${late.grantId}/revoke`;
+ expect((await env.app.inject({ method: 'POST', url, cookies: { cfp_session: admin } })).statusCode).toBe(200);
+ const res = await env.app.inject({ method: 'POST', url, cookies: { cfp_session: admin }, payload: { withdrawVotes: true } });
+ expect(res.statusCode, res.body).toBe(200);
+ expect(res.json<{ data: { withdrawn: number } }>().data.withdrawn).toBe(1);
+ expect((await row('late-target')).stagedVote).toBeNull();
+ });
+ });
+
+ 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']);
+ });
+
+ it('leaves a vote on a member purged since untouched', async () => {
+ const g = await createAgent(env, admin, { slug: 'gone-bot', scopes: ['moderation:read', 'moderation:vote'] });
+ expect((await vote('withdraw-gone', spam, bearer(g.token))).statusCode).toBe(200);
+ const purged = await env.app.inject({ method: 'POST', url: '/api/people/withdraw-gone/purge', cookies: { cfp_session: admin } });
+ expect(purged.statusCode, purged.body).toBe(204);
+ const path = 'person-evaluations/withdraw-gone/agent-gone-bot.toml';
+ expect(await env.git('ls-tree', '--name-only', 'HEAD', path)).toContain(path);
+
+ const before = Number((await env.git('rev-list', '--count', 'HEAD')).trim());
+ const res = await env.app.inject({ method: 'POST', url: '/api/admin/agents/gone-bot/withdraw-votes', cookies: { cfp_session: staff }, payload: {} });
+ expect(res.statusCode, res.body).toBe(200);
+ expect(res.json<{ data: { withdrawn: number; reactivated: number } }>().data).toEqual({ withdrawn: 0, reactivated: 0 });
+ expect(await env.git('ls-tree', '--name-only', 'HEAD', path)).toContain(path);
+ expect(Number((await env.git('rev-list', '--count', 'HEAD')).trim())).toBeLessThanOrEqual(before + 1);
+ });
+ });
+
+ 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);
+ // The sweep logs an agent-staged purge like any other: by "scheduled".
+ const state = env.app.inMemoryState;
+ const records = [...(state.spamPurgesBySlug.get('sweep-me') ?? [])].map((id) => state.spamPurges.get(id)!);
+ expect(records).toHaveLength(1);
+ expect(records[0]).toMatchObject({ by: 'scheduled', personSlug: 'sweep-me', blocksSignIn: true });
+ expect(records[0]!.reason).toContain('agent-example-bot');
+ });
+
+ 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/agents-auth.test.ts b/apps/api/tests/agents-auth.test.ts
new file mode 100644
index 0000000..c96b21f
--- /dev/null
+++ b/apps/api/tests/agents-auth.test.ts
@@ -0,0 +1,399 @@
+/**
+ * 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'),
+ ],
+ 'person-evaluations': [
+ { personSlug: 'gone-spammer', evaluator: 'human-staffer', verdict: 'spam', confidence: 1, flags: ['manual-override'], evaluatedAt: '2026-10-01T00:00:00Z' },
+ ],
+ },
+ });
+ 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('requires a first grant (422): an account with no grant is no agent', async () => {
+ const res = await env.app.inject({
+ method: 'POST',
+ url: '/api/admin/agents',
+ cookies: { cfp_session: admin },
+ payload: { slug: 'grantless-bot', fullName: 'Grantless', bio: 'An AI.' },
+ });
+ expect(res.statusCode).toBe(422);
+ expect(res.json<{ error: { fields: Record } }>().error.fields['grant']).toEqual(expect.any(String));
+ expect(env.app.inMemoryState.personIdBySlug.has('grantless-bot')).toBe(false);
+ });
+
+ it('refuses a slug with moderation history (422 slug_retired)', async () => {
+ const res = await env.app.inject({
+ method: 'POST',
+ url: '/api/admin/agents',
+ cookies: { cfp_session: admin },
+ payload: { slug: 'gone-spammer', fullName: 'Reused', bio: 'An AI.', grant: { label: 'x', scopes: ['moderation:read'] } },
+ });
+ expect(res.statusCode).toBe(422);
+ expect(res.json<{ error: { fields: Record } }>().error.fields['slug']).toBe('slug_retired');
+ });
+
+ 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('renaming', () => {
+ it('an agent account cannot be renamed, by an administrator either (409 agent_account)', async () => {
+ const res = await env.app.inject({
+ method: 'PATCH',
+ url: '/api/people/example-bot',
+ payload: { slug: 'renamed-bot' },
+ cookies: { cfp_session: admin },
+ });
+ expect(res.statusCode, res.body).toBe(409);
+ expect(res.json<{ error: { code: string } }>().error.code).toBe('agent_account');
+ expect(env.app.inMemoryState.personIdBySlug.has('example-bot')).toBe(true);
+ });
+
+ it('other edits to an agent account still work', async () => {
+ const res = await env.app.inject({
+ method: 'PATCH',
+ url: '/api/people/example-bot',
+ payload: { bio: 'An AI moderator run by the test suite, renamed never.' },
+ cookies: { cfp_session: admin },
+ });
+ expect(res.statusCode, res.body).toBe(200);
+ });
+ });
+
+ 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..45465ee
--- /dev/null
+++ b/apps/api/tests/helpers/agent-fixtures.ts
@@ -0,0 +1,161 @@
+/**
+ * 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;
+ /** 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();
+ 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.extra) await opts.extra(tx);
+ });
+ 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}` },
+});
diff --git a/apps/api/tests/prune-spam.test.ts b/apps/api/tests/prune-spam.test.ts
index f53bd56..34f972a 100644
--- a/apps/api/tests/prune-spam.test.ts
+++ b/apps/api/tests/prune-spam.test.ts
@@ -202,6 +202,47 @@ 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('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')],
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..1d45660 100644
--- a/apps/api/tests/rename-cascade.test.ts
+++ b/apps/api/tests/rename-cascade.test.ts
@@ -78,6 +78,14 @@ async function seedReferencingRecords(): Promise {
flags: [], evaluatedAt: NOW,
});
+ // A person holding a grant is an agent account, which is never renamed
+ // (specs/api/agents.md), so the renamed fixture holds none; another
+ // person's grant must be left alone.
+ await tx.sheet('agent-grants').upsert({
+ id: '01951a3c-0000-7000-8000-0000000000e7', personSlug: 'other-person', 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 +142,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}`);
@@ -192,6 +201,9 @@ async function rename(target: string, oldSlug: string, newSlug: string): Promise
expect(res.statusCode, res.body).toBe(200);
}
+/** References whose target is never renamed while they exist: an agent account (one with grants). */
+const NEVER_RENAMED_HOLDERS = new Set(['people:agent-grants.personSlug']);
+
describe('rename cascade', () => {
it('every declared reference targets a renameable sheet the cascade can walk', () => {
expect(SHEET_REFERENCES.length).toBeGreaterThan(0);
@@ -209,7 +221,9 @@ describe('rename cascade', () => {
const before = await referenceCounts(target, oldSlug);
for (const [ref, counts] of before) {
- // The fixture must exercise every referencing sheet.
+ // The fixture must exercise every referencing sheet, but for references
+ // whose holder is never renamed (an agent account's grants).
+ if (NEVER_RENAMED_HOLDERS.has(`${target}:${ref}`)) continue;
expect(counts.disk, `fixture has no ${ref} referencing ${target}/${oldSlug}`).toBeGreaterThan(0);
expect(counts.memory, ref).toBe(counts.disk);
}
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/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' });
+ });
+ });
+});
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..5397209
--- /dev/null
+++ b/apps/api/tests/votes-one-account.test.ts
@@ -0,0 +1,241 @@
+/**
+ * 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';
+/** A long-standing member who holds a slug a spam account was purged under. */
+const VETERAN_ID = '01951a3c-0000-7000-8000-0000000001c1';
+const PURGED_ID = '01951a3c-0000-7000-8000-0000000001c2';
+const PURGE_RECORD_ID = '01951a3c-0000-7000-8000-0000000001c3';
+const MOVER_ID = '01951a3c-0000-7000-8000-0000000001d1';
+const VETERAN_CREATED_AT = '2025-03-01T00:00:00Z';
+/** Cast on the purged account: after the veteran's createdAt, before the purge. */
+const PURGED_ACCOUNT_VOTE_AT = '2026-10-04T12:00:00Z';
+const PURGED_AT = '2026-10-05T00:00:00Z';
+
+/** 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',
+ );
+
+ // `veteran` signed up long ago; a spam account under the same slug was
+ // voted on and purged later (as it would be had the veteran renamed onto
+ // the freed slug before renames onto it were refused).
+ await seedRawToml(dataRepo.path, 'people/veteran.toml', person('veteran', VETERAN_ID, 'user', VETERAN_CREATED_AT), 'seed veteran');
+ await seedRawToml(
+ dataRepo.path,
+ 'person-evaluations/veteran/human-staffer.toml',
+ [
+ `personSlug = "veteran"`,
+ `evaluator = "human-staffer"`,
+ `verdict = "spam"`,
+ `confidence = 1`,
+ `flags = ["manual-override"]`,
+ `reasoning = "purged account: promo bio"`,
+ `evaluatedAt = "${PURGED_ACCOUNT_VOTE_AT}"`,
+ ].join('\n'),
+ 'seed vote on the purged account',
+ );
+ const purgeRecord = (slug: string, id: string) =>
+ [
+ `id = "${id}"`,
+ `personSlug = "${slug}"`,
+ `personId = "${PURGED_ID}"`,
+ `purgedAt = "${PURGED_AT}"`,
+ `by = "human-staffer"`,
+ `reason = "Promotional bio."`,
+ `flags = []`,
+ `evidence = []`,
+ `beforeCommit = "0000000000000000000000000000000000000000"`,
+ `blocksSignIn = true`,
+ ].join('\n');
+ await seedRawToml(dataRepo.path, `spam-purges/veteran/${PURGE_RECORD_ID}.toml`, purgeRecord('veteran', PURGE_RECORD_ID), 'seed purge record');
+ // Slugs with moderation history and no person: one with only a vote, one
+ // with only a purge record.
+ await seedRawToml(
+ dataRepo.path,
+ 'person-evaluations/voted-gone/human-staffer.toml',
+ [
+ `personSlug = "voted-gone"`,
+ `evaluator = "human-staffer"`,
+ `verdict = "spam"`,
+ `confidence = 1`,
+ `flags = ["manual-override"]`,
+ `evaluatedAt = "${PURGED_ACCOUNT_VOTE_AT}"`,
+ ].join('\n'),
+ 'seed vote on a purged slug',
+ );
+ await seedRawToml(
+ dataRepo.path,
+ 'spam-purges/purged-gone/01951a3c-0000-7000-8000-0000000001c4.toml',
+ purgeRecord('purged-gone', '01951a3c-0000-7000-8000-0000000001c4'),
+ 'seed purge record on a free slug',
+ );
+ await seedRawToml(dataRepo.path, 'people/mover.toml', person('mover', MOVER_ID, 'user', VETERAN_CREATED_AT), 'seed mover');
+
+ 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 } });
+
+ describe('a vote at or before a purge of the slug that named another person', () => {
+ it('does not count on a member who existed before the purge', async () => {
+ const row = (await app.inject({ method: 'GET', url: '/api/admin/members?vote=none', ...staff() }))
+ .json<{ data: Array<{ slug: string; latestVote: unknown }> }>()
+ .data.find((r) => r.slug === 'veteran');
+ expect(row).toBeDefined();
+ expect(row!.latestVote).toBeNull();
+
+ const detail = await app.inject({ method: 'GET', url: '/api/admin/members/veteran', ...staff() });
+ const d = detail.json<{ data: { votes: unknown[]; earlierVotes: Array<{ reasoning: string }> } }>().data;
+ expect(d.votes).toEqual([]);
+ expect(d.earlierVotes).toEqual([expect.objectContaining({ reasoning: 'purged account: promo bio' })]);
+ });
+
+ it('a vote after the purge counts', async () => {
+ const res = await app.inject({ method: 'POST', url: '/api/admin/members/veteran/vote', payload: { verdict: 'legit' }, ...staff() });
+ expect(res.statusCode, res.body).toBe(200);
+ const d = (await app.inject({ method: 'GET', url: '/api/admin/members/veteran', ...staff() })).json<{
+ data: { votes: Array<{ verdict: string }> };
+ }>().data;
+ expect(d.votes.map((v) => v.verdict)).toEqual(['legit']);
+ });
+ });
+
+ describe('a rename onto a slug with moderation history', () => {
+ const rename = async (cookie: string, to: string) =>
+ app.inject({ method: 'PATCH', url: '/api/people/mover', payload: { slug: to }, cookies: { cfp_session: cookie } });
+
+ it('is refused for the member and for staff (422 slug_retired)', async () => {
+ const self = (await mintSessionFor(MOVER_ID, 'user', JWT_KEY)).accessToken;
+ for (const cookie of [self, staffCookie]) {
+ for (const to of ['voted-gone', 'purged-gone']) {
+ const res = await rename(cookie, to);
+ expect(res.statusCode, `${to}: ${res.body}`).toBe(422);
+ expect(res.json<{ error: { fields: Record } }>().error.fields['slug']).toBe('slug_retired');
+ }
+ }
+ expect(app.inMemoryState.personIdBySlug.get('mover')).toBe(MOVER_ID);
+ });
+
+ it('a slug with no history is still free', async () => {
+ const res = await rename(staffCookie, 'mover-two');
+ expect(res.statusCode, res.body).toBe(200);
+ });
+ });
+
+ 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');
+ });
+});
diff --git a/apps/web/src/components/admin/AgentsPanel.tsx b/apps/web/src/components/admin/AgentsPanel.tsx
new file mode 100644
index 0000000..9902986
--- /dev/null
+++ b/apps/web/src/components/admin/AgentsPanel.tsx
@@ -0,0 +1,682 @@
+/**
+ * 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.{' '}
+ onOpenLog(by)}>
+ Open the purge log
+
+
+ ))}
+
+ );
+}
+
+/** 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
+
+
+
+ Purged by
+
+ onByChange(e.target.value === 'one' ? by : e.target.value)}
+ >
+ All
+ An agent
+ Staff
+ The daily sweep
+ {!['', 'agent', 'human', 'scheduled'].includes(by) && {by} }
+
+
+
+ {q.isLoading && Loading…
}
+ {q.isError && Could not load the purge log.
}
+ {q.data && purges.length === 0 && No spam purges.
}
+
+
+ !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.
+
+
+
+
+
+
+ );
+}
+
+const SCOPE_LABEL: Record = {
+ 'moderation:read': 'read roster',
+ 'moderation:vote': 'staged votes',
+ 'moderation:purge': 'purge',
+};
+
+/** Mint form + the purge second confirm + the token-once dialog. */
+function MintGrantDialog({
+ agentSlug,
+ open,
+ onOpenChange,
+ onMinted,
+}: {
+ agentSlug: string;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ onMinted: (token: string) => void;
+}) {
+ const [label, setLabel] = useState('');
+ const [days, setDays] = useState('90');
+ const [scopes, setScopes] = useState>({
+ 'moderation:read': true,
+ 'moderation:vote': true,
+ 'moderation:purge': false,
+ });
+ const [confirmPurge, setConfirmPurge] = useState(false);
+ const mint = useMutation({
+ mutationFn: () =>
+ api.admin.mintGrant(agentSlug, {
+ label: label.trim(),
+ scopes: (Object.keys(scopes) as AgentScope[]).filter((s) => scopes[s]),
+ expiresInDays: Number(days),
+ }),
+ onSuccess: (res) => {
+ setConfirmPurge(false);
+ onOpenChange(false);
+ setLabel('');
+ onMinted(res.data.token);
+ },
+ onError: (err) => toast.error(errorText(err, 'Could not mint the grant')),
+ });
+ const submit = (e: FormEvent) => {
+ e.preventDefault();
+ if (scopes['moderation:purge'] && !confirmPurge) {
+ setConfirmPurge(true);
+ return;
+ }
+ mint.mutate();
+ };
+ return (
+ {
+ if (!o) setConfirmPurge(false);
+ onOpenChange(o);
+ }}
+ >
+
+
+ {confirmPurge ? 'Allow this agent to purge?' : `Mint a grant for ${agentSlug}`}
+
+ {confirmPurge
+ ? 'A grant with the purge scope lets the agent immediately and permanently purge members under 7 days old who posted spam, up to 5 a day, citing the records it removes. Each purge is in the purge log and the public commit history.'
+ : 'Scopes are fixed for the grant’s life. To change them, mint a new grant and revoke the old one.'}
+
+
+
+ {!confirmPurge && (
+ <>
+
+ Label (what and where)
+ setLabel(e.target.value)} maxLength={80} required placeholder="ops bot, host vm-1" />
+
+
+ Scopes
+ {(Object.keys(SCOPE_LABEL) as AgentScope[]).map((s) => (
+
+ setScopes((cur) => ({ ...cur, [s]: v === true }))}
+ />
+
+ {s} — {SCOPE_LABEL[s]}
+
+
+ ))}
+ {scopes['moderation:purge'] && !scopes['moderation:read'] && (
+ The purge scope needs moderation:read too.
+ )}
+
+
+ Expires in (days, 1–180)
+ setDays(e.target.value)} className="w-28" />
+
+ >
+ )}
+
+ (confirmPurge ? setConfirmPurge(false) : onOpenChange(false))}>
+ {confirmPurge ? 'Back' : 'Cancel'}
+
+
+ {confirmPurge ? 'Yes, mint with purge' : 'Mint grant'}
+
+
+
+
+
+ );
+}
+
+/** The token, once. Closing the dialog drops it from the page. */
+function TokenDialog({ token, onClose }: { token: string | null; onClose: () => void }) {
+ return (
+ !open && onClose()}>
+
+
+ The agent’s token
+
+ This is the only time it is shown. Put it straight into the agent’s credential file on its host; never paste
+ it into chat, an issue or a repo.
+
+
+
+ {token}
+
+
+ {
+ if (token) void navigator.clipboard?.writeText(token).then(() => toast.success('Copied'));
+ }}
+ >
+ Copy
+
+ Done
+
+
+
+ );
+}
+
+function NewAgentDialog({ open, onOpenChange, onMinted }: { open: boolean; onOpenChange: (o: boolean) => void; onMinted: (token: string) => void }) {
+ const queryClient = useQueryClient();
+ const [slug, setSlug] = useState('');
+ const [fullName, setFullName] = useState('');
+ const [bio, setBio] = useState('');
+ const [label, setLabel] = useState('');
+ const create = useMutation({
+ mutationFn: () =>
+ api.admin.createAgent({
+ slug: slug.trim(),
+ fullName: fullName.trim(),
+ bio: bio.trim(),
+ grant: { label: label.trim(), scopes: ['moderation:read', 'moderation:vote'], expiresInDays: 90 },
+ }),
+ onSuccess: async (res) => {
+ onOpenChange(false);
+ setSlug('');
+ setFullName('');
+ setBio('');
+ setLabel('');
+ await queryClient.invalidateQueries({ queryKey: ['admin-agents'] });
+ onMinted(res.data.token);
+ },
+ onError: (err) => toast.error(errorText(err, 'Could not create the agent')),
+ });
+ return (
+
+
+
+ New agent account
+
+ An agent has no sign-in: only grants reach the API as it. Its bio is public and must say it is an AI and who
+ runs it. It is created with its first grant, which carries read and staged votes only.
+
+
+ {
+ e.preventDefault();
+ create.mutate();
+ }}
+ >
+
+ Slug
+ setSlug(e.target.value)} required />
+
+
+ Name
+ setFullName(e.target.value)} required />
+
+
+ Bio
+ setBio(e.target.value)} required rows={3} />
+
+
+ First grant label (what and where)
+ setLabel(e.target.value)}
+ maxLength={80}
+ required
+ placeholder="ops bot, host vm-1"
+ />
+
+
+ onOpenChange(false)}>
+ Cancel
+
+
+ Create agent
+
+
+
+
+
+ );
+}
+
+function AgentLine({
+ agent,
+ isAdmin,
+ onShowStaged,
+ onOpenLog,
+ onToken,
+ onRosterChanged,
+}: {
+ agent: AgentView;
+ isAdmin: boolean;
+ onShowStaged: () => void;
+ onOpenLog: () => void;
+ onToken: (token: string) => void;
+ onRosterChanged: () => Promise;
+}) {
+ const queryClient = useQueryClient();
+ const [minting, setMinting] = useState(false);
+ const [withdrawing, setWithdrawing] = useState(false);
+ const [revoking, setRevoking] = useState(null);
+ const [alsoWithdraw, setAlsoWithdraw] = useState(false);
+ const refreshAgents = () => queryClient.invalidateQueries({ queryKey: ['admin-agents'] });
+ const withdraw = useMutation({
+ mutationFn: () => api.admin.withdrawVotes(agent.person.slug),
+ onSuccess: async (res) => {
+ setWithdrawing(false);
+ toast.success(`Withdrew ${res.data.withdrawn} vote${res.data.withdrawn === 1 ? '' : 's'}; un-hid ${res.data.reactivated}`);
+ await refreshAgents();
+ await onRosterChanged();
+ },
+ onError: (err) => toast.error(errorText(err, 'Withdraw failed')),
+ });
+ const revoke = useMutation({
+ mutationFn: ({ id, withdrawVotes }: { id: string; withdrawVotes: boolean }) =>
+ api.admin.revokeGrant(agent.person.slug, id, { withdrawVotes }),
+ onSuccess: async (res) => {
+ setRevoking(null);
+ setAlsoWithdraw(false);
+ const w = res.data.withdrawn;
+ toast.success(w === undefined ? 'Grant revoked' : `Grant revoked; withdrew ${w} staged vote${w === 1 ? '' : 's'}`);
+ await refreshAgents();
+ await onRosterChanged();
+ },
+ onError: (err) => toast.error(errorText(err, 'Revoke failed')),
+ });
+ const staged = agent.stagedVotes.spam + agent.stagedVotes.legit;
+
+ return (
+
+
+
+ {agent.person.fullName}
+
+
AI agent
+
+ {staged} staged ({agent.stagedVotes.spam} spam, {agent.stagedVotes.legit} not spam) · purged {agent.purges.last24h} in 24h,{' '}
+ {agent.purges.last30d} in 30d
+
+
+
+ {agent.grants.map((g) => (
+
+ {g.label}
+ {g.scopes.map((s) => (
+
+ {s}
+
+ ))}
+
+ by {g.createdBy.fullName} · expires {formatAbsoluteDate(g.expiresAt, { dateStyle: 'medium' })} · last used{' '}
+ {g.lastUsedAt ? formatRelativeTime(g.lastUsedAt) : 'never'}
+
+ {g.active ? 'active' : g.revokedAt ? 'revoked' : 'expired'}
+ {isAdmin && g.active && (
+ setRevoking(g.id)}>
+ Revoke
+
+ )}
+
+ ))}
+
+
+
+ Show staged
+
+
+ Purge log
+
+ setWithdrawing(true)} disabled={staged === 0}>
+ Withdraw staged votes
+
+ {isAdmin && (
+ setMinting(true)}>
+ Mint grant
+
+ )}
+
+ {
+ void refreshAgents();
+ onToken(token);
+ }}
+ />
+ {
+ if (!o) {
+ setRevoking(null);
+ setAlsoWithdraw(false);
+ }
+ }}
+ >
+
+
+ Revoke this grant?
+
+ The next request with its token is refused. {agent.person.fullName}’s staged votes stop counting toward a purge
+ while it holds no active grant, and count again if a new grant is minted, unless you withdraw them now.
+
+
+
+ setAlsoWithdraw(v === true)} className="mt-0.5" />
+
+ This token may be compromised: also withdraw this agent’s staged votes
+ {staged > 0 ? ` (${staged} now, un-hiding the members its spam votes hid)` : ''}.
+
+
+
+ {
+ setRevoking(null);
+ setAlsoWithdraw(false);
+ }}
+ >
+ Cancel
+
+ revoking && revoke.mutate({ id: revoking, withdrawVotes: alsoWithdraw })}
+ >
+ {alsoWithdraw ? 'Revoke and withdraw' : 'Revoke'}
+
+
+
+
+
+
+
+ Withdraw {agent.person.fullName}’s staged votes?
+
+ This removes {staged} vote{staged === 1 ? '' : 's'} on members no person has voted on and un-hides the
+ members its spam votes hid ({agent.stagedVotes.spam} spam vote{agent.stagedVotes.spam === 1 ? '' : 's'}), in
+ one commit. It does not undo purges, and it does not revoke the grant.
+
+
+
+ setWithdrawing(false)}>
+ Cancel
+
+ withdraw.mutate()}>
+ Withdraw
+
+
+
+
+
+ );
+}
+
+/** The collapsed Agents section at the foot of the roster. */
+export function AgentsPanel({
+ isAdmin,
+ open,
+ onOpenChange,
+ logBy,
+ onLogByChange,
+ logOpen,
+ onShowStaged,
+ onRosterChanged,
+}: {
+ isAdmin: boolean;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ logBy: string;
+ onLogByChange: (by: string) => void;
+ logOpen: boolean;
+ onShowStaged: () => void;
+ onRosterChanged: () => Promise;
+}) {
+ const [token, setToken] = useState(null);
+ const [creating, setCreating] = useState(false);
+ const q = useQuery({ queryKey: ['admin-agents'], queryFn: () => api.admin.agents(), enabled: open });
+ const agents = q.data?.data ?? [];
+
+ return (
+
+ onOpenChange(!open)}
+ >
+ {open ? '▾' : '▸'} Agents
+
+ {open && (
+
+
+ AI agents staff granted access. Their votes are staged (any staff vote overrules them); an agent with the purge
+ scope may purge a member under 7 days old who posted spam, and every purge is in the log below.
+
+ {q.isLoading &&
Loading…
}
+ {q.data && agents.length === 0 &&
No agents.
}
+
+ {agents.map((a) => (
+ onLogByChange(`agent-${a.person.slug}`)}
+ onToken={setToken}
+ onRosterChanged={onRosterChanged}
+ />
+ ))}
+
+ {isAdmin && (
+
setCreating(true)}>
+ New agent
+
+ )}
+ {logOpen &&
}
+ {!logOpen && (
+
onLogByChange('')}>
+ Open the purge log
+
+ )}
+
+
setToken(null)} />
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts
index 3eb3443..5013485 100644
--- a/apps/web/src/lib/api.ts
+++ b/apps/web/src/lib/api.ts
@@ -765,12 +765,26 @@ export interface UpdateTagInput {
export type VoteVerdict = 'spam' | 'legit';
export interface VoteView {
+ /** A staff member's vote (`human`) or an AI agent's (`agent`). */
+ readonly kind?: 'human' | 'agent' | 'machine';
readonly verdict: 'spam' | 'legit' | 'uncertain';
readonly reasoning: string | null;
+ readonly flags?: string[];
readonly voter: { readonly slug: string; readonly fullName: string };
readonly evaluatedAt: string;
}
+/** The newest agent vote on a member no human has voted on. */
+export interface StagedVoteView {
+ readonly verdict: 'spam' | 'legit' | 'uncertain';
+ readonly agent: { readonly slug: string; readonly fullName: string };
+ readonly reasoning: string | null;
+ readonly flags: string[];
+ readonly evaluatedAt: string;
+ /** False once the agent holds no active grant: it no longer purges anyone. */
+ readonly counts: boolean;
+}
+
export type MemberOrigin = 'imported' | 'signed-up';
export interface MemberGitHub {
@@ -791,6 +805,7 @@ export interface MemberRow {
readonly deletedAt: string | null;
readonly origin: MemberOrigin;
readonly email: string | null;
+ readonly emailDomain?: string | null;
readonly hasGitHubLink: boolean;
readonly github: MemberGitHub | null;
readonly lastLoginAt: string | null;
@@ -807,6 +822,9 @@ export interface MemberRow {
readonly tags: number;
};
readonly latestVote: VoteView | null;
+ readonly stagedVote?: StagedVoteView | null;
+ /** An agent account: nobody votes on it. */
+ readonly isAgent?: boolean;
/** When the scheduled spam purge will take this member, or null. */
readonly purgeAfter: string | null;
readonly signals: string[];
@@ -815,7 +833,7 @@ export interface MemberRow {
export interface MemberListParams {
q?: string;
- vote?: 'none' | VoteVerdict;
+ vote?: 'none' | 'staged' | VoteVerdict;
origin?: MemberOrigin;
joinedAfter?: string;
joinedBefore?: string;
@@ -843,6 +861,64 @@ export interface MemberDetail {
readonly person: PersonDetail;
readonly footprint: MemberFootprint;
readonly votes: VoteView[];
+ /** Votes on an earlier account under this slug: they do not count for this member. */
+ readonly earlierVotes?: VoteView[];
+ readonly earlierPurges?: Array<{ id: string; purgedAt: string; by: string; reason: string | null }>;
+}
+
+// --- Agents and the spam-purge log (specs/api/agents.md) -------------------
+
+export type AgentScope = 'moderation:read' | 'moderation:vote' | 'moderation:purge';
+
+export interface AgentGrantView {
+ readonly id: string;
+ readonly label: string;
+ readonly scopes: AgentScope[];
+ readonly createdAt: string;
+ readonly createdBy: { readonly slug: string; readonly fullName: string };
+ 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: AgentGrantView[];
+ readonly stagedVotes: { readonly spam: number; readonly legit: number };
+ readonly purges: { readonly last24h: number; readonly last30d: number };
+}
+
+export interface GrantInput {
+ label: string;
+ scopes: AgentScope[];
+ expiresInDays?: number;
+}
+
+export interface SpamPurgeView {
+ readonly id: string;
+ readonly personSlug: string;
+ readonly purgedAt: string;
+ readonly by: string;
+ readonly byRef: { readonly kind: 'agent' | 'human' | 'scheduled'; readonly slug: string | null; readonly fullName: string };
+ readonly reason: string | null;
+ readonly flags: string[];
+ readonly evidence: Array<{ record: string; field?: string; path: string; url: string }>;
+ readonly beforeCommit: string;
+ readonly commitUrl: string;
+ readonly blocksSignIn: boolean;
+ readonly blocking: boolean;
+ readonly unblockedAt: string | null;
+ readonly unblockedBy: { readonly slug: string; readonly fullName: string } | null;
+}
+
+export interface SpamPurgeListParams {
+ by?: string;
+ slug?: string;
+ since?: string;
+ blocking?: boolean;
+ page?: number;
+ perPage?: number;
}
export const api = {
@@ -981,8 +1057,11 @@ export const api = {
slug: string,
): Promise }>> =>
request(`/api/people/${encodeURIComponent(slug)}/purge-preview`),
- purge: (slug: string): Promise =>
- request(`/api/people/${encodeURIComponent(slug)}/purge`, { method: 'POST' }),
+ purge: (slug: string, opts: { spam?: boolean; reason?: string } = {}): Promise =>
+ request(`/api/people/${encodeURIComponent(slug)}/purge`, {
+ method: 'POST',
+ body: JSON.stringify(opts.spam ? { spam: true, ...(opts.reason ? { reason: opts.reason } : {}) } : {}),
+ }),
},
tags: {
list: (params: TagListParams = {}): Promise> =>
@@ -1160,6 +1239,35 @@ export const api = {
method: 'POST',
body: JSON.stringify(reasoning ? { verdict, reasoning } : { verdict }),
}),
+ agents: (): Promise> => request(`/api/admin/agents`),
+ createAgent: (input: {
+ slug: string;
+ fullName: string;
+ bio: string;
+ grant: GrantInput;
+ }): Promise> =>
+ request(`/api/admin/agents`, { method: 'POST', body: JSON.stringify(input) }),
+ mintGrant: (slug: string, input: GrantInput): Promise> =>
+ request(`/api/admin/agents/${encodeURIComponent(slug)}/grants`, { method: 'POST', body: JSON.stringify(input) }),
+ /** `withdrawVotes`: the token may be compromised — also withdraw the agent's staged votes, in the same commit. */
+ revokeGrant: (
+ slug: string,
+ id: string,
+ opts: { withdrawVotes?: boolean } = {},
+ ): Promise> =>
+ request(`/api/admin/agents/${encodeURIComponent(slug)}/grants/${encodeURIComponent(id)}/revoke`, {
+ method: 'POST',
+ body: JSON.stringify(opts.withdrawVotes ? { withdrawVotes: true } : {}),
+ }),
+ withdrawVotes: (slug: string): Promise> =>
+ request(`/api/admin/agents/${encodeURIComponent(slug)}/withdraw-votes`, { method: 'POST', body: '{}' }),
+ spamPurges: (params: SpamPurgeListParams = {}): Promise> =>
+ request(`/api/admin/spam-purges${buildQuery(params)}`),
+ unblock: (id: string, reason?: string): Promise> =>
+ request(`/api/admin/spam-purges/${encodeURIComponent(id)}/unblock`, {
+ method: 'POST',
+ body: JSON.stringify(reason ? { reason } : {}),
+ }),
},
staffAccountClaim: {
queue: (): Promise> =>
diff --git a/apps/web/src/pages/AdminMembers.tsx b/apps/web/src/pages/AdminMembers.tsx
index 8550bad..c91c50b 100644
--- a/apps/web/src/pages/AdminMembers.tsx
+++ b/apps/web/src/pages/AdminMembers.tsx
@@ -11,7 +11,16 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { useAuth } from '@/hooks/useAuth';
-import { api, ApiError, type MemberListParams, type MemberRow, type VoteVerdict, type VoteView } from '@/lib/api';
+import { AgentsPanel, RecentAgentPurgesBanner } from '@/components/admin/AgentsPanel';
+import {
+ api,
+ ApiError,
+ type MemberListParams,
+ type MemberRow,
+ type StagedVoteView,
+ type VoteVerdict,
+ type VoteView,
+} from '@/lib/api';
import { formatAbsoluteDate, formatRelativeTime } from '@/lib/time';
const PER_PAGE = 50;
@@ -89,6 +98,12 @@ function Badges({ row }: { row: MemberRow }) {
{links && {links} link{links === '1' ? '' : 's'} in bio }
{has('no-bio') && no bio }
{has('no-avatar') && no avatar }
+ {has('slug-purged-before') && (
+
+ Slug purged before
+
+ )}
+ {row.isAgent && AI agent account }
);
}
@@ -132,21 +147,66 @@ function purgeLabel(purgeAfter: string, now: Date = new Date()): string {
: `Purges ${formatAbsoluteDate(purgeAfter, { dateStyle: 'medium' })}`;
}
-function VoteBadge({ vote, hiddenByVote }: { vote: VoteView; hiddenByVote: boolean }) {
+function VoteBadge({ vote, hiddenByVote, note }: { vote: VoteView; hiddenByVote: boolean; note?: string }) {
const spam = vote.verdict === 'spam';
+ const agent = vote.kind === 'agent';
+ const tone = agent
+ ? 'bg-violet-100 text-violet-900'
+ : spam
+ ? 'bg-destructive/10 text-destructive'
+ : 'bg-emerald-100 text-emerald-800';
return (
-
- {spam ? 'Spam' : 'Not spam'} · {vote.voter.fullName} · {formatRelativeTime(vote.evaluatedAt)}
+
+ {spam ? 'Spam' : 'Not spam'} · {vote.voter.fullName}
+ {agent ? ' (AI)' : ''} · {formatRelativeTime(vote.evaluatedAt)}
{hiddenByVote ? ' · hidden' : ''}
+ {note ? ` · ${note}` : ''}
);
}
+/**
+ * An agent's staged vote (specs/screens/admin-members.md): violet while it
+ * counts, grey once the agent's access is revoked; the first line of its
+ * reasoning beside it and its flags as chips.
+ */
+function StagedVoteBadge({ vote, purgeAfter }: { vote: StagedVoteView; purgeAfter: string | null }) {
+ const spam = vote.verdict === 'spam';
+ const firstLine = (vote.reasoning ?? '').split('\n')[0] ?? '';
+ const title = vote.counts
+ ? `${vote.reasoning ?? ''}\n\nStaged by an AI agent. Not spam overrules it; Spam confirms it.${
+ spam && purgeAfter ? ` Purges ${formatAbsoluteDate(purgeAfter, { dateStyle: 'medium' })} unless a person votes.` : ''
+ }`
+ : `${vote.reasoning ?? ''}\n\nThe agent's access was revoked: this vote is a record only and will not purge anyone.`;
+ return (
+
+
+ Staged {spam ? 'spam' : 'not spam'} · {vote.agent.fullName} (AI) · {formatRelativeTime(vote.evaluatedAt)}
+ {vote.counts ? '' : ' · access revoked'}
+
+ {firstLine && (
+
+ {firstLine}
+
+ )}
+ {vote.flags.length > 0 && (
+
+ {vote.flags.map((f) => (
+
+ {f}
+
+ ))}
+
+ )}
+
+ );
+}
+
function VoteButtons({
slug,
disabled,
@@ -260,8 +320,12 @@ function MemberRowView({
useEffect(() => {
if (selected) ref.current?.scrollIntoView({ block: 'nearest' });
}, [selected]);
- const hiddenByVote = row.deletedAt !== null && row.latestVote?.verdict === 'spam';
+ const staged = row.stagedVote ?? null;
+ const hiddenByVote =
+ row.deletedAt !== null && (row.latestVote ? row.latestVote.verdict === 'spam' : staged?.verdict === 'spam');
+ const hiddenBy = row.latestVote ? row.latestVote.voter.fullName : staged ? `${staged.agent.fullName} (AI)` : 'vote';
const isSelf = row.slug === selfSlug;
+ const noVoting = isSelf || row.isAgent === true;
const fp = row.footprint;
const counts = [
`${fp.memberships} project${fp.memberships === 1 ? '' : 's'}`,
@@ -298,13 +362,17 @@ function MemberRowView({
@{row.slug}
{row.deletedAt && (
- {hiddenByVote ? `Hidden by ${row.latestVote?.voter.fullName ?? 'vote'}` : 'Deactivated'}
+ {hiddenByVote ? `Hidden by ${hiddenBy}` : 'Deactivated'}
)}
{row.purgeAfter && (
{purgeLabel(row.purgeAfter)}
@@ -329,14 +397,16 @@ function MemberRowView({
{row.latestVote && !changing ? (
<>
- setChanging(true)} disabled={isSelf}>
+ setChanging(true)} disabled={noVoting}>
Change
>
) : (
+ <>
+ {staged && }
{
@@ -344,6 +414,7 @@ function MemberRowView({
await onChanged();
}}
/>
+ >
)}
{expanded ? 'Hide details' : 'Details'}
@@ -360,6 +431,10 @@ function MemberDetailView({ slug }: { slug: string }) {
if (q.isLoading) return Loading…
;
if (q.isError || !q.data) return Could not load details.
;
const { person, footprint, votes } = q.data.data;
+ const earlierVotes = q.data.data.earlierVotes ?? [];
+ const earlierPurges = q.data.data.earlierPurges ?? [];
+ const humanVoted = votes.some((v) => v.kind !== 'agent');
+ const latestHuman = votes.find((v) => v.kind !== 'agent');
const section = (title: string, items: React.ReactNode[]) =>
items.length > 0 ? (
@@ -419,19 +494,53 @@ function MemberDetailView({ slug }: { slug: string }) {
Votes
{votes.length === 0 ? (
-
No human votes yet.
+
No votes yet.
) : (
- {votes.map((v) => (
-
-
- {v.reasoning && {v.reasoning}
}
-
- ))}
+ {votes.map((v) => {
+ // Once a person has voted, an agent's vote is history: overruled or confirmed.
+ const note =
+ v.kind === 'agent' && humanVoted && latestHuman
+ ? latestHuman.verdict === v.verdict
+ ? 'confirmed'
+ : 'overruled'
+ : undefined;
+ return (
+
+
+ {v.kind === 'agent' && AI agent }
+ {v.flags && v.flags.length > 0 && (
+ [{v.flags.join(', ')}]
+ )}
+ {v.reasoning && {v.reasoning}
}
+
+ );
+ })}
)}
+ {(earlierVotes.length > 0 || earlierPurges.length > 0) && (
+
+
Earlier account
+
An earlier account under this slug. None of this counts for this member.
+
+ {earlierPurges.map((p) => (
+
+ Purged as spam {formatAbsoluteDate(p.purgedAt, { dateStyle: 'medium' })} by{' '}
+ {p.by === 'scheduled' ? 'the daily sweep' : p.by}
+ {p.reason ? ` — ${p.reason}` : ''}
+
+ ))}
+ {earlierVotes.map((v) => (
+
+
+ {v.reasoning && {v.reasoning} }
+
+ ))}
+
+
+ )}
- Purge lives on the person page (admins).
+ An administrator’s purge lives on the person page.
@@ -453,6 +562,9 @@ export function AdminMembers() {
const [expanded, setExpanded] = useState(params.slug ?? null);
const [selected, setSelected] = useState(params.slug ?? null);
const [pendingVote, setPendingVote] = useState<{ slug: string; verdict: VoteVerdict } | null>(null);
+ const [agentsOpen, setAgentsOpen] = useState(searchParams.has('agents'));
+ // The purge log's "by" filter; null while the log is closed.
+ const [logBy, setLogBy] = useState(searchParams.has('purges') ? (searchParams.get('purges') ?? '') : null);
const allowed = !!person && isStaff(person.accountLevel);
useEffect(() => {
@@ -533,6 +645,19 @@ export function AdminMembers() {
days later they're purged unless someone votes Not spam. Keys: j /k move, s spam, n not spam,{' '}
Enter details.
+
+ An AI agent staff granted access may vote too. Its votes are staged : they
+ hide the member at once and purge on the date shown unless a person votes; Not spam overrules,{' '}
+ Spam confirms. An agent with the purge scope may purge a member under 7 days old who posted spam;
+ those purges are in the Agents panel’s purge log at the foot of the page.
+
+ {
+ setAgentsOpen(true);
+ setLogBy(by);
+ requestAnimationFrame(() => document.getElementById('agents')?.scrollIntoView({ behavior: 'smooth' }));
+ }}
+ />
setParam('vote', e.target.value)}
>
Any
- No vote yet
+ No human vote yet
+ Staged by an agent
Voted spam
Voted not spam
@@ -644,6 +770,19 @@ export function AdminMembers() {
)}
>
)}
+ {
+ setParam('vote', 'staged');
+ window.scrollTo({ top: 0, behavior: 'smooth' });
+ }}
+ onRosterChanged={refresh}
+ />
);
}
diff --git a/apps/web/src/pages/LoginPlaceholder.tsx b/apps/web/src/pages/LoginPlaceholder.tsx
index 7ae1b67..574b731 100644
--- a/apps/web/src/pages/LoginPlaceholder.tsx
+++ b/apps/web/src/pages/LoginPlaceholder.tsx
@@ -20,7 +20,8 @@ type ErrorCode =
| 'oauth_state_mismatch'
| 'oauth_session_invalid'
| 'github_unreachable'
- | 'email_unverified';
+ | 'email_unverified'
+ | 'signup_blocked';
const ERROR_MESSAGES: Record = {
access_denied:
@@ -47,6 +48,16 @@ const ERROR_MESSAGES: Record = {
and ensure email visibility is enabled for our app.
>
),
+ 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{' '}
+
+ accounts@codeforphilly.org
+
+ .
+ >
+ ),
};
function WhyGitHub() {
diff --git a/apps/web/src/screens/Account.tsx b/apps/web/src/screens/Account.tsx
index 85cf981..1eb39dc 100644
--- a/apps/web/src/screens/Account.tsx
+++ b/apps/web/src/screens/Account.tsx
@@ -39,6 +39,8 @@ const LINK_GITHUB_ERROR_MESSAGES: Record = {
github_id_in_use_elsewhere:
'That GitHub account is already connected to a different Code for Philly account. Email accounts@codeforphilly.org if this is a mistake.',
github_unreachable: 'We could not reach GitHub. Please try again in a moment.',
+ github_blocked:
+ 'That GitHub account was removed from Code for Philly for spam and can’t be connected. Email accounts@codeforphilly.org if this is a mistake.',
oauth_state_mismatch: 'Something went wrong with the GitHub link flow. Please try again.',
oauth_session_invalid: 'Something went wrong with the GitHub link flow. Please try again.',
};
diff --git a/apps/web/src/screens/PersonDetail.tsx b/apps/web/src/screens/PersonDetail.tsx
index d657d00..e5d80f9 100644
--- a/apps/web/src/screens/PersonDetail.tsx
+++ b/apps/web/src/screens/PersonDetail.tsx
@@ -3,6 +3,9 @@ import { Link, useNavigate, useParams } from 'react-router';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
+import { Checkbox } from '@/components/ui/checkbox';
+import { Label } from '@/components/ui/label';
+import { Textarea } from '@/components/ui/textarea';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import {
Dialog,
@@ -45,6 +48,20 @@ export function PersonDetail() {
});
const purgeProjects = purgePreviewQ.data?.data.projects ?? [];
+ // "This was spam" (specs/api/people.md → purge): checked by default when the
+ // person has a spam vote on their current account, so an erase-me request
+ // never lands on the sign-up block list by default. Read from the roster
+ // detail, which an administrator (staff) can always see.
+ const moderationQ = useQuery({
+ queryKey: ['admin-member', slug],
+ queryFn: () => api.admin.member(slug),
+ enabled: confirmPurgeOpen,
+ });
+ const hasSpamVote = (moderationQ.data?.data.votes ?? []).some((v) => v.verdict === 'spam');
+ const [spamChoice, setSpamChoice] = useState(null);
+ const markSpam = spamChoice ?? hasSpamVote;
+ const [spamReason, setSpamReason] = useState('');
+
if (personQ.isLoading) {
return Loading member…
;
}
@@ -97,7 +114,7 @@ export function PersonDetail() {
const handlePurge = async () => {
setActionPending(true);
try {
- await api.people.purge(person.slug);
+ await api.people.purge(person.slug, markSpam ? { spam: true, reason: spamReason.trim() || undefined } : {});
toast.success(`${person.fullName} and all their content have been permanently purged.`);
void navigate('/members', { replace: true });
} catch (err) {
@@ -353,6 +370,36 @@ export function PersonDetail() {
No projects will be deleted.
)}
+
+
+ setSpamChoice(v === true)}
+ disabled={moderationQ.isLoading}
+ className="mt-0.5"
+ />
+
+ This was spam: block this GitHub account from signing up again
+
+ Leave unchecked when a member asked to be erased. Staff can unblock it from the purge log.
+
+
+
+ {markSpam && (
+
+
+ Why (optional, public; no links)
+
+ setSpamReason(e.target.value)}
+ maxLength={1000}
+ rows={2}
+ />
+
+ )}
+
setConfirmPurgeOpen(false)}>
Cancel
diff --git a/apps/web/tests/LoginPlaceholder.test.tsx b/apps/web/tests/LoginPlaceholder.test.tsx
index a118502..bf7ecef 100644
--- a/apps/web/tests/LoginPlaceholder.test.tsx
+++ b/apps/web/tests/LoginPlaceholder.test.tsx
@@ -43,6 +43,22 @@ describe('LoginPlaceholder', () => {
expect(screen.getByRole('link', { name: /forgot your password/i })).toBeInTheDocument();
});
+ it('explains a sign-up refused because the GitHub account was purged as spam', async () => {
+ renderScreen(
+
+
+ ,
+ { initialEntries: ['/login?error=signup_blocked'] },
+ );
+ await waitFor(() => {
+ expect(screen.getByText(/removed for spam, so it can.t sign up again/i)).toBeInTheDocument();
+ });
+ expect(screen.getByRole('link', { name: 'accounts@codeforphilly.org' })).toHaveAttribute(
+ 'href',
+ 'mailto:accounts@codeforphilly.org',
+ );
+ });
+
it('keeps submit disabled until both fields are filled', async () => {
render();
await waitFor(() => {
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/docs/operations/runbook.md b/docs/operations/runbook.md
index 19db209..48111fc 100644
--- a/docs/operations/runbook.md
+++ b/docs/operations/runbook.md
@@ -231,6 +231,139 @@ It answers counts only, never emails. Spec:
the flag on would delete production data. A pod logs
`scheduled spam purge disabled` at boot when the flag is off.
+## Agents
+
+An agent is an automated moderator (the first is Code for Philly's ops bot)
+with its own site account, reaching the API with a scoped bearer grant
+([api/agents.md](../../specs/api/agents.md)). Grants are minted and revoked by
+an administrator; every step is a public commit. Calls below run in the
+browser console on the site, signed in as an administrator (or staff, where
+noted), like the orphan cleanup above.
+
+```js
+const api = (method, path, body) => fetch(path, {
+ method, headers: { 'content-type': 'application/json' }, body: body && JSON.stringify(body),
+}).then((r) => r.json()).then((j) => (console.log(j), j.data));
+```
+
+- **Create the agent** (once), with its first grant: `grant` is required, since
+ an account is an agent because it holds one. Its bio says it is an AI and who
+ runs it. The first grant is read + vote only; purge comes later
+ ([plans/bot-spam-votes.md](../../plans/bot-spam-votes.md) → Rollout).
+
+ ```js
+ api('POST', '/api/admin/agents', {
+ slug: 'grittybot', fullName: 'GrittyBot',
+ bio: 'An AI ops bot run by Code for Philly. 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 ', scopes: ['moderation:read', 'moderation:vote'], expiresInDays: 90 },
+ });
+ ```
+
+- **Hand the token to its host.** The response's `token` (`cfpa_…`) is shown
+ once and stored nowhere but its hash. Put it straight into the agent's
+ credential file on its host (for the ops bot,
+ `~/.config/grittybot/cfp-agent-token`, mode 0600); never in a chat, issue,
+ repo or ticket. Lose it and mint a new one.
+- **Rotate / renew / change scopes** (nothing is wrong with the token). Scopes
+ are fixed for a grant's life, so rotating, renewing (grants last 1–180 days,
+ default 90) or adding `moderation:purge` is: mint the new grant first, then
+ revoke the old one **without** withdrawing votes. The agent always holds an
+ active grant, so its staged votes count throughout. Minting a grant with
+ `moderation:purge` requires `moderation:read`.
+
+ ```js
+ const { grant, token } = await api('POST', '/api/admin/agents/grittybot/grants',
+ { label: 'ops bot, host ', scopes: ['moderation:read', 'moderation:vote', 'moderation:purge'], expiresInDays: 90 });
+ // replace the token file on the host, then:
+ api('POST', '/api/admin/agents/grittybot/grants//revoke');
+ ```
+
+- **Compromise** (the token may be in someone else's hands, or the agent has
+ gone wrong): revoke **with** `withdrawVotes: true`. In one commit, the token
+ is dead (the next request with it gets `401 grant_invalid`) and every vote
+ the agent staged on members no human has voted on is withdrawn, un-hiding
+ the members its spam votes hid. Do this before minting a replacement: a plain
+ revoke only stops the staged votes counting while the agent holds no active
+ grant, and the next grant minted for it brings them all back. The Agents
+ panel's Revoke confirm has the checkbox "This token may be compromised: also
+ withdraw this agent's staged votes". Neither undoes purges: check the purge
+ log for the agent and restore any wrong one (below).
+
+ ```js
+ api('POST', '/api/admin/agents/grittybot/grants//revoke', { withdrawVotes: true });
+ ```
+- **Withdraw** (staff) every vote the agent staged on members no human has
+ voted on, un-hiding the members its votes hid, in one commit, without
+ revoking anything (for a run of bad votes from a token that is still sound;
+ use `since` to take back only the recent ones). Purges are not undone by it
+ (restore them one by one, below).
+
+ ```js
+ api('POST', '/api/admin/agents/grittybot/withdraw-votes', { since: '2026-11-01T00:00:00Z' });
+ ```
+
+The Agents panel at the foot of `/admin/members` does all of this with
+buttons, and shows each agent's grants, staged votes and purges.
+
+## Restore a purged member
+
+A purge (an agent's, an administrator's, or the daily sweep's) deletes from
+the data repo's tree; every deleted public record is in the purge commit's
+parent. **Private data does not come back**: the production private store is
+a filesystem volume with no versioning or backup, so the person's email,
+newsletter choice, Slack sign-in time and GitHub facts are gone and are
+rebuilt only from GitHub on their next sign-in
+([person-lifecycle.md → Restoring a purged member](../../specs/behaviors/person-lifecycle.md#restoring-a-purged-member)).
+
+1. **Find the commit.** An agent or staff spam purge: the purge log's
+ **Commit** link on `/admin/members` opens the history of the purge record;
+ the purge commit is the oldest entry (an unblock adds one above it). From a
+ clone: `git log --format=%H --grep='^Purge-Id: $'`. A non-spam staff purge:
+ `git log --grep='^Action: person.purge$' --grep='Subject-Slug: ' --all-match`.
+ The sweep: `git log --grep='scheduled spam purge'` and find the slug in the
+ message.
+2. **Lift the block** from the purge log (**Unblock**, with a reason), or
+ `api('POST', '/api/admin/spam-purges//unblock', { reason: '…' })`. The
+ record stays, marked unblocked.
+3. **Restore the records** in a working clone of `codeforphilly-data` on
+ `published`, pulled after step 2 so it holds the unblock commit:
+
+ ```bash
+ git pull --ff-only
+ # An agent or staff purge: one person per commit. Revert it, but keep the log.
+ # The unblock edited the purge record, so the revert stops with a
+ # modify/delete conflict on it and exits non-zero; the checkout below
+ # resolves it by keeping the record as it stands.
+ git revert --no-commit
+ git checkout HEAD -- spam-purges/
+ git status --short # expect only the person's records, and the purge's vote record reverted
+ git commit -m "people: restore " --trailer "Action: person.restore" --trailer "Subject-Slug: "
+
+ # A sweep commit purges a batch: take only this person's paths from the parent.
+ git checkout ^ -- people/.toml people// # plus their memberships, updates, … as needed
+ git commit -m "people: restore " --trailer "Action: person.restore" --trailer "Subject-Slug: "
+ ```
+
+ Commit as yourself with a pseudonymous email
+ (`@users.noreply.codeforphilly.org`). Push; the hot-reload webhook
+ rebuilds the in-memory state.
+4. **Vote Not spam** on the restored member's row, before the next daily
+ sweep. Votes are not deleted by a purge, so a member restored from the
+ sweep (or from a staff purge that followed a spam vote) comes back
+ deactivated with their spam votes still standing, and past the three
+ days: the next sweep would purge them again. An agent purge writes the agent's vote in the purge commit, so the
+ revert puts back whatever stood before: nothing, or the agent's earlier
+ staged spam vote, which hides them again. Not spam un-hides them, keeps
+ the sweep off them and puts a human verdict on record. The row keeps the
+ `Slug purged before` badge and lists the purge under Earlier account: the
+ purge record names the slug, and stays.
+5. **If the slug was taken since**, 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 (email from GitHub;
+ sign-in count starts again, newsletter choice and Slack link are gone).
+
## Helpful commands
```bash
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..a9d42a2
--- /dev/null
+++ b/packages/shared/src/schemas/agent-grant.ts
@@ -0,0 +1,54 @@
+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. A declared slug reference, so the rename cascade covers
+ * it, though an agent account is never renamed (specs/api/agents.md).
+ */
+ 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',
]);
});
diff --git a/plans/bot-spam-votes.md b/plans/bot-spam-votes.md
new file mode 100644
index 0000000..47d2236
--- /dev/null
+++ b/plans/bot-spam-votes.md
@@ -0,0 +1,673 @@
+---
+status: done
+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
+ - 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
+ - specs/screens/login.md
+pr: 262
+---
+
+# Plan: Agent moderation for the ops bot (staged spam votes, posted-spam purges)
+
+## 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."
+
+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). 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.
+- **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, 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; 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, `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 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 (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; purge is its own scope.
+- [behaviors/person-lifecycle.md](../specs/behaviors/person-lifecycle.md) —
+ 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; 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` (three scopes);
+ `PersonEvaluation` account rule; `SpamPurge`.
+- [screens/admin-members.md](../specs/screens/admin-members.md) — staged rows,
+ 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. **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).
+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.
+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. 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.
+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
+
+- [x] `npm run type-check && npm run lint && npm test` clean.
+- [x] 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; without `moderation:purge`, 404
+ on purge; minting purge without read → 422.
+- [x] The token never appears in the commit, the private store (only its
+ hash), or a captured request log line.
+- [x] 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; `DELETE` after a human vote →
+ 409.
+- [x] 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.
+- [x] **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.
+- [x] **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.
+- [x] 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.
+- [x] 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.
+- [x] Sweep: an agent-only spam member ≥ 3 days with an active grant is
+ 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.
+- [x] 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`.
+- [x] `withdraw-votes`: one commit; counts right; votes on human-voted and on
+ purged members untouched.
+- [x] account-level refuses to raise an agent (`409 agent_account`).
+- [x] 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` and `spam-purges` configs; boot fails loudly
+ without them.
+- [x] 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.
+- [x] 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 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.** 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` 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.)
+
+**Resolved 2026-11-12:** the operator said to build with the recommended
+answers, so every question below (1–17) was resolved with its recommended
+answer, as written. The reasoning stays as the record of why.
+
+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 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 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` 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` (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
+
+An independent review (2026-11-12) of the first build found ten gaps; the
+specs were corrected first, then each was fixed with a test:
+
+- votes dated at or before a `spam-purges` record for the slug that names
+ another person do not count (a long-standing member could otherwise take a
+ freed slug and inherit a spammer's votes), and a rename onto a slug with
+ moderation history is refused (`slug_retired`);
+- an agent purge or vote is checked, committed and applied to the in-memory
+ state under one lock (`Store.transact`'s `onCommit`), so concurrent purges
+ cannot pass the daily allowance or purge one slug twice; the dry run reads
+ the member under that lock;
+- agent accounts are never claim candidates and staff cannot approve a claim
+ on one;
+- `grant` is required when an agent is created (an agent is "has any grant
+ record", active or not);
+- agent accounts cannot be renamed;
+- revoke takes `withdrawVotes: true` for a compromised token; the spec now
+ says staged votes count again when a new grant is minted unless withdrawn;
+- the URL check catches bare domains and defanged forms, and applies to agent
+ vote `reasoning` too; control characters are refused in both;
+- evidence identifiers are pattern-checked before lookup;
+- writes of `agent-grant-secrets.jsonl` are serialized.
+
+Built as specified, with three spec edits made along the way: an agent
+account has no private profile; the purge log carries `byRef` (who purged,
+resolved to kind, slug and name) beside the stored `by`; and the data scrub
+pseudonymizes the new slug fields (`agent-grants`, `spam-purges`).
+
+- Repeated `Evidence:` trailers are written inline in the commit message,
+ because gitsheets trailers are a single-value map; `git interpret-trailers
+ --parse` still reads one `Evidence:` line per item.
+- The 20-a-day spam-vote cap counts the agent's current vote records, so a
+ re-vote on the same member within 24 hours replaces its record rather than
+ adding to the count.
+- The `Contract` box stays unchecked. `gitsheets contracts verify` is clean on
+ `empty`, `fixture` and `legacy-import` with both configs added, in
+ codeforphilly-data PRs 21, 22 and 23, merged on 2026-11-12; `published`
+ is adopted at release time per `docs/operations/releases.md` → "Releases
+ that change a schema contract". The box closes when the release adopts
+ `published`.
+ Without the configs the store refuses to open ("Store opens with
+ validators.agent-grants, but .gitsheets/agent-grants.toml is not
+ declared"), checked against `fixture` as it stood before the configs; no
+ test covers that missing-sheet case specifically.
+- Validation boxes ticked on tests: auth and scopes
+ (`apps/api/tests/agents-auth.test.ts`, `agent-votes.test.ts`,
+ `agent-purge.test.ts`); token nowhere (`agents-auth.test.ts` "keeps only the
+ hash" and "request logs", `agent-votes.test.ts` commit test); vote rules,
+ side effects, the vote commit, `withdraw-votes` and the sweep
+ (`agent-votes.test.ts`, with the human-decided sweep record in
+ `spam-purge-block.test.ts`); case 1 (`agent-purge.test.ts`,
+ `spam-purge-block.test.ts`, `votes-one-account.test.ts`); case 2, every
+ purge refusal with its dry run, and the purge commit
+ (`agent-purge.test.ts`); `agent_account` (`agents-auth.test.ts`);
+ prune-spam (`prune-spam.test.ts`). At closeout two assertions were missing
+ and were added to `agent-votes.test.ts`: a vote on a member purged since is
+ left alone by `withdraw-votes`, and the sweep writes a `by = "scheduled"`
+ record for an agent-staged purge. The full gate passed at `8f4ce58`; the
+ closeout re-ran `agent-votes.test.ts`, type-check and lint.
+- Restore and browser rehearsal (2026-11-12, devbox, invented personas only):
+ the dev API served a throwaway bare clone of `fixture` (configs added),
+ whose `origin` was a second local bare repo standing in for GitHub; pushes
+ from the API's clone to it stood in for the push daemon (no
+ `CFP_DATA_REMOTE`). GitHub OAuth was not real: the API process loaded an
+ msw interceptor for GitHub's token, `/user` and `/user/emails` endpoints, so
+ the site's own `/api/auth/github/start` and `/callback` ran end to end. An
+ invented sign-up set a promotional bio and self-joined a project; an agent
+ created in the New agent form (which refuses to submit without the first
+ grant's label), with a purge grant minted through the second confirm,
+ purged it with `curl`; sign-in as its GitHub id went to
+ `/login?error=signup_blocked` with nothing written; Unblock from the purge
+ log; the runbook's revert in a working clone, pushed to the stand-in
+ origin, then `POST /api/_internal/reload-data` (fast-forwarded, rebuilt);
+ Not spam from the roster; sign-in as the same GitHub id signed in as the
+ restored person (same id), rebuilding the private profile's email. Also in
+ the browser: staged badge with reasoning and flags in the default view,
+ overrule with Not spam (history reads "overruled"), the banner, the purge
+ log's evidence and commit links, withdraw, and revoke with the
+ "may be compromised" box (token then `401 grant_invalid`). Dialogs checked
+ at desktop and 390px; no console errors.
+- The rehearsal corrected the restore steps
+ (`docs/operations/runbook.md`, and the command in
+ `specs/behaviors/person-lifecycle.md`): after an unblock, `git revert
+ --no-commit` stops with a modify/delete conflict on the `spam-purges`
+ record and exits non-zero, so the spec's `revert && checkout` never ran the
+ checkout; the working clone must be pulled after the unblock; the
+ **Commit** link opens the record's history, whose oldest entry is the purge;
+ a purge leaves votes in place, so a member restored from the sweep comes
+ back hidden and past due, and Not spam must land before the next sweep.
+
+## Follow-ups
+
+No planned plan owns any of these, so none is `Deferred to`. Entries marked
+*(file an issue)* should become `Issue [#N]` entries once filed.
+
+- Tracked as: *(file an issue)* `castVote` still replaces an evaluator's
+ record on an earlier account under the same slug
+ (`person-evaluations//` has one slot per evaluator per
+ slug). Keeping both needs a path change (a new contract version), so it is
+ left; the `spam-purges` record still names the purge's reason, and git
+ history keeps the old vote.
+- Tracked as: *(file an issue)* `scripts/prune-spam.ts` applies only the
+ `createdAt` rule for earlier accounts, not the newer `spam-purges` cutoff.
+- Tracked as: *(file an issue)* routes outside moderation and agents still
+ apply their state after `store.transact` returns; moving them to `onCommit`
+ would close the same gap for every write.
+- Tracked as: *(file an issue)* `POST /api/admin/private-data/purge-orphans`
+ does not count or delete orphaned agent grant secrets.
+- Tracked as: *(file an issue)* the staff purge's "This was spam" reason gets
+ the URL check but not the control-character check that agent reasons get.
+- Tracked as: *(file an issue)* a restored member keeps the
+ `slug-purged-before` signal and shows their own purge under Earlier account,
+ since the rule is "a `spam-purges` record names this slug"; whether a record
+ naming the same person should count is a spec decision.
+- Tracked as: the general 30-writes-a-minute per-account cap also applies to
+ agents, purge dry runs included; revisit if the bot's dry-run-then-purge
+ pattern hits it.
+- Tracked as: a public "AI agent" chip on `/members/` (the agent's bio
+ says it for now); revisit after the bot's first weeks.
+- Tracked as: a `POST /api/admin/spam-purges/:id/restore` endpoint if restores
+ pass about one a month (Open question 13); the runbook covers them until
+ then.
+- Tracked as: the Revoke confirm's unchecked text says staged votes stop
+ counting "while it holds no active grant", which reads as if they stop even
+ when the agent keeps another active grant (the rotation case, where they
+ keep counting); a wording tweak.
+- Issue [CodeForPhilly/grittybot#22](https://github.com/CodeForPhilly/grittybot/issues/22)
+ — the bot-side `bin/member-vote` command and loop policy (Bot side above).
diff --git a/specs/api/agents.md b/specs/api/agents.md
new file mode 100644
index 0000000..b2fb211
--- /dev/null
+++ b/specs/api/agents.md
@@ -0,0 +1,232 @@
+# Agents API
+
+An **agent** is an automated moderator: a program, not a person, that staff
+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` | staff | Every agent account, its grants, and its recent purges. |
+| `POST` | `/api/admin/agents` | administrator | Create an agent account and 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, and optionally withdraw the agent's staged votes. |
+| `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 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
+ is and who runs it. It is an agent because the `agent-grants` sheet holds a
+ grant for it, active or not; nothing else marks it. A revoke or an expiry
+ only ends a grant, never deletes its record (only purging the agent account
+ itself does), and an agent is created with its first grant, so an agent
+ whose every grant has been revoked or has expired is still an agent account,
+ under every rule below.
+- 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`), 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.
+- Its slug is fixed: `PATCH /api/people/:slug` refuses to rename it (`409
+ conflict`, `error.code = "agent_account"`). Its votes are recorded as
+ `agent-` and its purges as `by = "agent-"`; a rename would not
+ follow those, and would reset the daily allowances counted from them.
+- No GitHub sign-in can take it over: account matching never offers it as a
+ candidate, a post-sign-up search does not find it, and staff approval of a
+ claim request naming it is refused (`409 conflict`, `error.code =
+ "agent_account"`)
+ ([account-migration.md](../behaviors/account-migration.md#matching-algorithm-at-oauth-callback)).
+
+## 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). |
+| `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
+ `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. An agent's staged votes count toward a purge only while it holds
+ an active grant
+ ([person-lifecycle.md](../behaviors/person-lifecycle.md#scheduled-spam-purge)):
+ once its last active grant is revoked (or expires) they stop counting, and
+ they stay on the record. **If a new grant is minted for the agent later, they
+ count again**, from the moment of the mint, unless they were withdrawn. So a
+ revoke for a compromised token passes `withdrawVotes: true`, which withdraws
+ them in the same commit; a routine rotation (mint the new grant, then revoke
+ the old one) does not, and the votes count throughout.
+ Revoking 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.
+
+## 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 },
+ "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
+
+```json
+{
+ "slug": "example-bot",
+ "fullName": "Example Bot",
+ "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 }
+}
+```
+
+`bio` is required (an agent says what it is), and so is `grant`: an account
+is an agent because it holds a grant, so an account created without one would
+be an ordinary member with no sign-in. Creates the person and the grant (its
+token hash in the private store) in one transaction; the slug follows
+[slug-handles.md](../behaviors/slug-handles.md), including the rule that a
+slug with moderation history is not reused
+([slug-handles.md](../behaviors/slug-handles.md#moderation-history-retires-a-slug)).
+
+Response `201`: `{ person, grant, token }` — `token` 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
+
+```json
+{ "withdrawVotes": true }
+```
+
+The body is optional. Sets `revokedAt = now()`. `200 { grant }`. Revoking a
+revoked grant without `withdrawVotes` is a no-op (no commit).
+
+`withdrawVotes: true` is for a token that may be compromised: in the same
+transaction and commit as the revoke, it withdraws every vote the agent has
+staged, exactly as [withdraw-votes](#post-apiadminagentsslugwithdraw-votes)
+does with no `since` (the agent's votes on people no human has voted on;
+the members its votes hid are reactivated). The response adds `{ withdrawn,
+reactivated }`. On a grant already revoked it still withdraws (the compromise
+may be found after the revoke). The commit carries `Withdraw-Votes: true`.
+
+## 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, 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; when the token itself is in doubt, revoke with
+`withdrawVotes: true`, which does both in one commit. 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
+
+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. (An agent's own votes and purges
+are committed as the agent: [moderation.md](moderation.md#agent-votes).)
+
+## Errors
+
+- `422 validation_failed` — unknown scope, `moderation:purge` without
+ `moderation:read`, `expiresInDays` out of range, missing `bio` or `grant`,
+ bad or taken slug, or a slug with moderation history (`slug_retired`)
+- `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 91a4812..4151d85 100644
--- a/specs/api/moderation.md
+++ b/specs/api/moderation.md
@@ -3,17 +3,61 @@
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, 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
+[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. |
+| `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 so was a vote dated **at or before the newest
+[`spam-purges`](../data-model.md#spampurge) record for this slug whose
+`personId` is another person's** (the purge ended that account; anything
+dated up to it was about the account it ended). Every rule in this file and in
+[person-lifecycle.md](../behaviors/person-lifecycle.md) ignores such a vote: 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.
+
+The purge rule covers what `createdAt` cannot: a person who existed before the
+purge and later took the slug. A rename can no longer do that (a person is
+never renamed onto a slug with a `spam-purges` record or any
+`person-evaluations` under it:
+[slug-handles.md](../behaviors/slug-handles.md#moderation-history-retires-a-slug)),
+but the rule holds for any way a slug changes hands.
+
+(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
@@ -21,8 +65,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 +85,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 +95,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
@@ -76,12 +122,34 @@ 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 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. 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`:
+
+```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,14 +169,22 @@ 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": "…" }
+ ],
+ "earlierVotes": [],
+ "earlierPurges": [ { "id": "…", "purgedAt": "…", "by": "human-jane", "reason": "…" } ]
}
}
```
-`votes` is newest first. Everything in `footprint` is drawn from the in-memory
-public state — nothing here is fetched from elsewhere.
+`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)).
+`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
@@ -117,9 +193,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 +204,28 @@ 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. An agent's `reasoning`
+goes into a public commit, so it is held to the purge `reason`'s rules: **no
+URL** and **no control characters** ([the request](#request-1)); `422`,
+`reasoning`. Describe a link or a domain (`a disposable email domain`, `a link
+to a pharmacy shop`) instead of writing 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 +235,279 @@ 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
+ or carrying a URL or a control character, 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.
+
+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
+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 (the check, the commit and the update of the in-memory state that
+the next write's checks read all happen under one lock, so a vote queued
+behind another vote or a purge sees its result):
+
+1. **Humans are final.** An agent may not vote on a member any human has voted
+ 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`.
+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`.
+
+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, **no URL** (`422`, `reason`: a purge
+ must not republish the spam link in a permanent public commit; cite the
+ record instead) and **no control characters**, line breaks included (`422`,
+ `reason`: the reason is the commit body, and a line break could forge a
+ trailer). A URL is any of:
+ - a scheme, `https://…` or any `name://`, including the defanged `hxxp://`
+ and `hxxps://`;
+ - `www.` at the start of a word;
+ - a bare domain: labels joined by dots with word characters on both sides of
+ each dot, ending in a top-level domain of two or more letters that is
+ either lowercase (`cheap-pills.shop`) or a common TLD in any case
+ (`Cheap-Pills.SHOP`);
+ - a defanged dot between word characters: `[.]`, `(.)`, `{.}`, `[dot]`,
+ `(dot)`.
+
+ Ordinary prose passes: `e.g.`, `i.e.`, `U.S.`, a sentence that runs into the
+ next without a space (`spam.Then`), a version number.
+- `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 | — |
+
+ Each identifier must be well formed before anything is looked up: `project`
+ a project slug, `slug` a buzz slug ([slug-handles.md](../behaviors/slug-handles.md)),
+ `roleId` a UUID, `number` a positive integer; otherwise `422
+ evidence_invalid` naming the item.
+
+ 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 `reason`, or one carrying a URL or a
+ control character, bad flags, no evidence or more than 5, `slug` is the
+ caller or an agent account
+- `422 evidence_invalid` — an evidence item with a malformed identifier, or a
+ 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 are checked inside
+the write mutex, in the order below, and the check, the commit and the update
+of the in-memory state happen under one lock: the next purge or vote, however
+close behind, is checked against this one's result, so concurrent purges never
+pass the daily allowance or purge one member twice. A dry run reads the member
+and runs its checks under the same lock.
+
+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 `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
+
+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
@@ -156,6 +518,10 @@ 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.
+- [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 fa3cad3..c92e246 100644
--- a/specs/api/people.md
+++ b/specs/api/people.md
@@ -149,8 +149,8 @@ Editable by staff additionally:
### Errors
-- `409 conflict` — slug or email taken
-- `422 validation_failed`
+- `409 conflict` — slug or email taken; or a rename of an [agent account](agents.md#the-agent-account) (`error.code = "agent_account"`)
+- `422 validation_failed` — including a rename onto a slug with moderation history (`fields.slug = "slug_retired"`, [slug-handles.md](../behaviors/slug-handles.md#moderation-history-retires-a-slug))
### Email change side-effects
@@ -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
@@ -255,6 +275,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/account-migration.md b/specs/behaviors/account-migration.md
index 09edb3b..10b4d71 100644
--- a/specs/behaviors/account-migration.md
+++ b/specs/behaviors/account-migration.md
@@ -85,6 +85,7 @@ From the laddr import we have, for every legacy Person:
2. Otherwise, look for legacy candidates:
a. Email-match: for each verified gh.email → PrivateProfile.email lookup
b. Username-match: gh.login === Person.slug AND Person.githubUserId is null
+ AND Person is not an agent account
3. Combine candidates, dedupe by Person.id
4. Route based on candidate count:
- 0 candidates → create fresh Person + PrivateProfile (no claim needed)
@@ -187,6 +188,15 @@ User submits a claim request with the old slug + free-form proof. Staff reviews
These three proofs no longer gate first sign-in — they're only relevant for the manual "I have a duplicate account, merge it" case.
+An [agent account](../api/agents.md#the-agent-account) (a person with any
+`agent-grants` record) is never a candidate, by any of the three: matching
+skips it, the post-sign-up search does not return it, a claim confirmation
+naming it is refused as not a candidate, and staff approval of a claim
+request naming it is refused (`409 conflict`, `error.code = "agent_account"`).
+An agent has no email or password to prove, and its slug is the only thing a
+GitHub login could match; a GitHub account that happens to share the login
+must not take it over.
+
## Pre-cutover auto-link sweep
Before cutover, an admin script can pre-link Persons whose GitHub
diff --git a/specs/behaviors/authorization.md b/specs/behaviors/authorization.md
index 07e1dfc..95b7089 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,52 @@ 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); 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.
+
+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.
+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
- 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..3cab005 100644
--- a/specs/behaviors/person-lifecycle.md
+++ b/specs/behaviors/person-lifecycle.md
@@ -5,8 +5,8 @@ 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`). |
-| **Purged** | admin only | Cascading hard delete of the person + their content, in a single commit. | Via git history only (revert the commit). |
+| **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; 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,36 +17,118 @@ 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. Only votes on **this account** count: a vote older than the person's `createdAt`, or dated at or before the newest `spam-purges` record for the slug that names another person, 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/` (the
+ revert stops with a conflict on the unblocked `spam-purges` record, which
+ the checkout resolves; `&&` would skip it), 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)). 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
- 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.
+- 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
@@ -56,6 +138,9 @@ A `spam` vote on the admin members screen deactivates the person at once; three
| 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. 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/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/slug-handles.md b/specs/behaviors/slug-handles.md
index 88678e2..559f83e 100644
--- a/specs/behaviors/slug-handles.md
+++ b/specs/behaviors/slug-handles.md
@@ -48,6 +48,23 @@ Project slugs allow underscores because legacy laddr handles used underscores (`
Uniqueness violation on create or rename → `409 conflict`.
+### Moderation history retires a slug
+
+A person slug that has moderation history is never given to another person
+by a rename or by creating an agent account: `422 validation_failed`,
+`fields.slug = "slug_retired"`. A slug has moderation history when the
+[`spam-purges`](../data-model.md#spampurge) sheet holds a record for it, or
+the `person-evaluations` sheet holds any record under it. Both outlive a purge
+on purpose (they are the record of why), and both are keyed by slug, so a
+person renamed onto such a slug would wear the earlier account's votes and
+purge record ([api/moderation.md → Votes belong to one account](../api/moderation.md#votes-belong-to-one-account)).
+A GitHub sign-up still takes its slug from the GitHub login, freed or not; the
+votes rule keeps the earlier account's votes off it.
+
+An [agent account](../api/agents.md#the-agent-account) is never renamed
+(`409 conflict`, `error.code = "agent_account"`): its votes and purges are
+recorded under `agent-`, which a rename does not follow.
+
## Mutability and redirects
Slugs **can** change. When they do:
diff --git a/specs/behaviors/spam-exclusion.md b/specs/behaviors/spam-exclusion.md
index 7e8b982..bdff65b 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,23 @@ 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.) 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
> `legit` verdict at any confidence, **and no `project-membership`** (real
@@ -105,10 +136,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..ce21403 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. 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 141d551..38f3f86 100644
--- a/specs/data-model.md
+++ b/specs/data-model.md
@@ -41,6 +41,9 @@ 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)
+SpamPurge ── outlives the Person it records (by slug + githubUserId)
SlugHistory ── points at any renamed entity by (entityType, oldSlug)
PRIVATE (S3-compatible bucket) ─────────────────────────────────────────
@@ -195,6 +198,92 @@ 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`, `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 or purged by an agent, 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).
+
+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 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.
+
+**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 c627f5a..ab97544 100644
--- a/specs/screens/admin-members.md
+++ b/specs/screens/admin-members.md
@@ -19,11 +19,30 @@ 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.
+
+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
- `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))
+- `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).
@@ -32,8 +51,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 +66,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).
@@ -57,12 +83,15 @@ 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.
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 +104,65 @@ 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". 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, 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`. 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.
+ **New agent** asks for the first grant's label along with the slug, name
+ and bio (an agent is created with its first grant, read and staged votes
+ only). **Revoke** opens a confirm with one checkbox, unchecked: "This token
+ may be compromised: also withdraw this agent's staged votes". Checked, the
+ revoke sends `withdrawVotes: true` and the confirm says how many staged votes
+ it removes; unchecked, it says the staged votes stop counting until a new
+ grant is minted (the rotation case).
+ 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
@@ -86,7 +172,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 +187,24 @@ 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 |
+| 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.
+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)
-- 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).
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