Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
7d9f525
docs(specs): agent accounts that stage spam votes
codeforphilly-bot Nov 11, 2026
58c0738
chore(plans): add bot-spam-votes, planned
codeforphilly-bot Nov 11, 2026
c0b9625
docs(specs): agent purges for posted spam, block re-signup after purge
codeforphilly-bot Nov 12, 2026
991756b
docs(specs): agents have no private profile, byRef on the purge log, …
codeforphilly-bot Nov 12, 2026
49bb3cb
fix(api): votes belong to one account
codeforphilly-bot Nov 12, 2026
71bf951
feat(shared): agent-grants and spam-purges schemas, contracts and sheets
codeforphilly-bot Nov 12, 2026
200a2cd
feat(api): agent grants with bearer auth, scopes and admin endpoints
codeforphilly-bot Nov 12, 2026
5e34a18
feat(api): staged agent votes, agent reads, withdraw
codeforphilly-bot Nov 12, 2026
2296f2b
feat(api): agent purge of new members who posted spam
codeforphilly-bot Nov 12, 2026
2d8f6d2
feat(api): spam purge log, sign-up block, unblock, staff this-was-spam
codeforphilly-bot Nov 12, 2026
4d47c07
feat(web): staged votes, agents panel, purge log, unblock, blocked si…
codeforphilly-bot Nov 12, 2026
b6cab48
docs(ops): runbook for agents and restoring a purged member
codeforphilly-bot Nov 12, 2026
84afe0c
docs(specs): agent review fixes
codeforphilly-bot Nov 12, 2026
70b57bd
fix(api): a purged account's votes never pass to a renamed member
codeforphilly-bot Nov 12, 2026
a5a39ad
fix(api): check, commit and apply agent purges and votes under one st…
codeforphilly-bot Nov 12, 2026
505070c
fix(api): agent accounts are never claim candidates
codeforphilly-bot Nov 12, 2026
bac1eed
feat(api,web): require a first grant; revoke can withdraw staged votes
codeforphilly-bot Nov 12, 2026
a55d51a
fix(api): stricter URL check, no control characters, evidence validated
codeforphilly-bot Nov 12, 2026
8f4ce58
fix(api): serialize agent-grant-secrets writes
codeforphilly-bot Nov 12, 2026
dcc7aae
chore(api): mirror contract sources for agent-grants and spam-purges
codeforphilly-bot Nov 12, 2026
db51d86
docs(ops): correct the restore-a-purged-member steps from a rehearsal
codeforphilly-bot Nov 12, 2026
2f52c49
test(api): withdraw-votes skips purged members; sweep logs agent-stag…
codeforphilly-bot Nov 12, 2026
1c9dba2
chore(plans): mark bot-spam-votes done (PR #262)
codeforphilly-bot Nov 12, 2026
8a5f24d
Merge pull request #262 from CodeForPhilly/feat/bot-spam-votes
themightychris Nov 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions apps/api/gitsheets/agent-grants.toml
Original file line number Diff line number Diff line change
@@ -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"]
Original file line number Diff line number Diff line change
@@ -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"
87 changes: 87 additions & 0 deletions apps/api/gitsheets/contracts/codeforphilly.org/spam-purges/v1.toml
Original file line number Diff line number Diff line change
@@ -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"
8 changes: 8 additions & 0 deletions apps/api/gitsheets/contracts/sources.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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"
Expand Down
11 changes: 11 additions & 0 deletions apps/api/gitsheets/spam-purges.toml
Original file line number Diff line number Diff line change
@@ -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"]
60 changes: 57 additions & 3 deletions apps/api/scripts/prune-spam.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -149,11 +149,18 @@ async function openAnyRepo(path: string): Promise<Repository> {
* 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<string, PersonVerdict>,
record: Readonly<Record<string, unknown>>,
threshold: number,
createdAtBySlug?: ReadonlyMap<string, string>,
): void {
const personSlug = record['personSlug'];
const evaluator = record['evaluator'];
Expand All @@ -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)) {
Expand All @@ -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<Map<string, string>> {
const repo = await openAnyRepo(repoPath);
const out = new Map<string, string>();
let records: readonly Record<string, unknown>[];
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
Expand All @@ -198,6 +247,7 @@ export async function aggregateVerdicts(
threshold: number,
log: (msg: string) => void,
verdictMap: Map<string, PersonVerdict> = new Map(),
createdAtBySlug?: ReadonlyMap<string, string>,
): Promise<Map<string, PersonVerdict>> {
log(`[prune-spam] reading person-evaluations in ${repoPath} at ref=${ref}`);
const repo = await openAnyRepo(repoPath);
Expand All @@ -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;
}

Expand Down Expand Up @@ -347,13 +397,17 @@ export async function pruneSpam(args: CliArgs): Promise<PruneSummary> {
`[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(
Expand Down
24 changes: 20 additions & 4 deletions apps/api/scripts/scrub-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,15 +148,31 @@ export function rewritePersonReferences(
slugMap: Map<string, string>,
): Record<string, unknown> {
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<Record<string, readonly string[]>> = {
'agent-grants': ['createdBySlug'],
'spam-purges': ['personSlug', 'unblockedBySlug'],
};

/**
* Replace `@mention` occurrences of real person slugs in markdown-like text.
*/
Expand Down
10 changes: 8 additions & 2 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -91,10 +92,14 @@ export async function buildApp(opts: BuildAppOptions = {}): Promise<FastifyInsta

// Default logger: pretty in dev, JSON in prod.
// Callers can override via serverOptions.logger.
// Credentials never reach a log line: an agent grant travels in the
// Authorization header (specs/behaviors/authorization.md → Agent grants),
// sessions in cookies.
const redact = ['req.headers.authorization', 'req.headers.cookie', 'res.headers["set-cookie"]'];
const defaultLogger: FastifyServerOptions['logger'] =
process.env['NODE_ENV'] === 'production'
? true
: { transport: { target: 'pino-pretty' } };
? { redact }
: { redact, transport: { target: 'pino-pretty' } };

const fastify = Fastify({
logger: defaultLogger,
Expand Down Expand Up @@ -213,6 +218,7 @@ export async function buildApp(opts: BuildAppOptions = {}): Promise<FastifyInsta
await fastify.register(attachmentRoutes);
await fastify.register(chatRoutes);
await fastify.register(moderationRoutes);
await fastify.register(agentRoutes);
await fastify.register(webhookRoutes);
await fastify.register(samlRoutes);
await fastify.register(internalRoutes);
Expand Down
Loading
Loading