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.{' '} + +

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

+ Purge log +

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

Loading…

} + {q.isError &&

Could not load the purge log.

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

No spam purges.

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

    {p.reason}

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