Skip to content

Commit 8c40ea7

Browse files
Merge pull request #256 from CodeForPhilly/develop
Release: v0.10.3
2 parents 334067e + c1f5cc5 commit 8c40ea7

18 files changed

Lines changed: 482 additions & 10 deletions

File tree

‎apps/api/src/app.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,12 +158,13 @@ export async function buildApp(opts: BuildAppOptions = {}): Promise<FastifyInsta
158158
await fastify.register(slugRedirectPlugin);
159159
await fastify.register(legacyRedirectPlugin);
160160
await fastify.register(eventRemindersPlugin);
161-
await fastify.register(spamPurgePlugin);
162161

163162
// ----- 7. Session middleware (JWT auth) -----
164163
// Before rate-limit and idempotency: all three use onRequest hooks, which run
165164
// in registration order, and the other two key on request.session.person.
166165
await fastify.register(sessionMiddlewarePlugin);
166+
// After the session middleware: the sweep clears purged people's session metadata.
167+
await fastify.register(spamPurgePlugin);
167168

168169
// ----- 8. Rate limiting -----
169170
await fastify.register(rateLimitPlugin);

‎apps/api/src/auth/session-metadata.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,19 @@ export class SessionMetadataStore {
5454
await this.flush(privateStore);
5555
}
5656

57+
/** Forget every session of a purged person. */
58+
async removeAllFor(personId: string, privateStore: PrivateStore): Promise<number> {
59+
let removed = 0;
60+
for (const [jti, meta] of this.#map) {
61+
if (meta.personId === personId) {
62+
this.#map.delete(jti);
63+
removed++;
64+
}
65+
}
66+
if (removed > 0) await this.flush(privateStore);
67+
return removed;
68+
}
69+
5770
getAll(personId: string): SessionMeta[] {
5871
const result: SessionMeta[] = [];
5972
for (const meta of this.#map.values()) {

‎apps/api/src/env.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,13 @@ export const EnvSchema = z.object({
7474
* image; unset in dev (Vite owns 5173).
7575
*/
7676
CFP_WEB_DIST_PATH: z.string().optional(),
77+
/**
78+
* Whether this pod runs the daily scheduled spam purge
79+
* (specs/behaviors/person-lifecycle.md#scheduled-spam-purge). Off by default:
80+
* sandbox and production share `published`, so exactly one deployment —
81+
* production — may run a destructive sweep against it.
82+
*/
83+
CFP_SCHEDULED_PURGE: z.enum(['true', 'false']).default('false'),
7784
/**
7885
* Host of the public-facing site (e.g. `codeforphilly.org` in prod,
7986
* `next-v2.codeforphilly.org` in sandbox). Used by the server-side
@@ -147,6 +154,7 @@ export const envJsonSchema = {
147154
SAML_ENTITY_ID: { type: 'string', default: SAML_ENTITY_ID_DEFAULT },
148155
SLACK_TEAM_HOST: { type: 'string', default: 'codeforphilly.slack.com' },
149156
CFP_WEB_DIST_PATH: { type: 'string' },
157+
CFP_SCHEDULED_PURGE: { type: 'string', enum: ['true', 'false'], default: 'false' },
150158
CFP_SITE_HOST: { type: 'string', default: 'codeforphilly.org' },
151159
POSTMARK_SERVER_TOKEN: { type: 'string' },
152160
POSTMARK_MESSAGE_STREAM: { type: 'string', default: 'outbound' },

‎apps/api/src/plugins/spam-purge.ts‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,14 @@ import fp from 'fastify-plugin';
88

99
import { SPAM_PURGE_INTERVAL_MS, SpamPurgeService } from '../services/spam-purge.js';
1010

11+
/**
12+
* Whether this pod runs the sweep: only when CFP_SCHEDULED_PURGE is "true",
13+
* and never under tests (they call the sweep directly).
14+
*/
15+
export function scheduledPurgeEnabled(config: { NODE_ENV: string; CFP_SCHEDULED_PURGE: string }): boolean {
16+
return config.NODE_ENV !== 'test' && config.CFP_SCHEDULED_PURGE === 'true';
17+
}
18+
1119
declare module 'fastify' {
1220
interface FastifyInstance {
1321
spamPurge: SpamPurgeService;
@@ -20,6 +28,7 @@ async function spamPurgePlugin(fastify: FastifyInstance): Promise<void> {
2028
fts: fastify.fts,
2129
store: fastify.store,
2230
log: fastify.log,
31+
sessionMetadata: fastify.sessionMetadata,
2332
});
2433
fastify.decorate('spamPurge', service);
2534

@@ -31,8 +40,10 @@ async function spamPurgePlugin(fastify: FastifyInstance): Promise<void> {
3140

3241
let interval: ReturnType<typeof setInterval> | undefined;
3342
fastify.addHook('onReady', async () => {
34-
// Tests boot many apps; the sweep is exercised directly there.
35-
if (fastify.config.NODE_ENV === 'test') return;
43+
if (!scheduledPurgeEnabled(fastify.config)) {
44+
fastify.log.info('scheduled spam purge disabled (CFP_SCHEDULED_PURGE is not "true")');
45+
return;
46+
}
3647
run();
3748
interval = setInterval(run, SPAM_PURGE_INTERVAL_MS);
3849
});
@@ -44,5 +55,5 @@ async function spamPurgePlugin(fastify: FastifyInstance): Promise<void> {
4455
export default fp(spamPurgePlugin, {
4556
name: 'spam-purge',
4657
fastify: '5.x',
47-
dependencies: ['services'],
58+
dependencies: ['services', 'session-middleware'],
4859
});

‎apps/api/src/routes/moderation.ts‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { ApiNotFoundError, ApiValidationError } from '../lib/errors.js';
1414
import { getCallerSession } from '../services/permissions.js';
1515
import { buildTransactionOptions } from '../store/commit-meta.js';
1616
import type { MemberOrigin, VoteFilter } from '../services/moderation.js';
17+
import { findOrphanPrivateData, purgeOrphanPrivateData } from '../services/private-orphans.js';
1718

1819
function requireStaffOr404(request: FastifyRequest): void {
1920
const level = request.session.accountLevel;
@@ -139,4 +140,39 @@ export async function moderationRoutes(fastify: FastifyInstance): Promise<void>
139140
});
140141
},
141142
);
143+
144+
// POST /api/admin/private-data/purge-orphans (administrator only)
145+
// Spec: specs/api/moderation.md → POST /api/admin/private-data/purge-orphans
146+
fastify.post(
147+
'/api/admin/private-data/purge-orphans',
148+
{
149+
schema: {
150+
tags: ['admin'],
151+
summary: 'Delete private data of people with no public record (admin only)',
152+
body: {
153+
type: 'object',
154+
properties: { apply: { type: 'boolean' } },
155+
required: ['apply'],
156+
additionalProperties: false,
157+
},
158+
},
159+
},
160+
async (request) => {
161+
if (request.session.accountLevel !== 'administrator') throw new ApiNotFoundError('Not found');
162+
const { apply } = request.body as { apply: boolean };
163+
// Under the write lock: account creation writes the private profile
164+
// before the public record, and must never look orphaned mid-write.
165+
const found = await fastify.publicRepo.withLock(async () => {
166+
const publicIds = new Set(fastify.inMemoryState.people.keys());
167+
const result = await findOrphanPrivateData(publicIds, fastify.store.private, fastify.sessionMetadata);
168+
if (apply && result.personIds.length > 0) {
169+
await purgeOrphanPrivateData(result, fastify.store.private, fastify.sessionMetadata);
170+
}
171+
return result;
172+
});
173+
const { personIds, ...counts } = found;
174+
request.log.info({ apply, orphanPeople: personIds.length, ...counts }, 'private orphan purge');
175+
return ok({ applied: apply, orphanPeople: personIds.length, ...counts });
176+
},
177+
);
142178
}

‎apps/api/src/routes/people.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,7 @@ export async function peopleRoutes(fastify: FastifyInstance): Promise<void> {
239239
},
240240
}, async (request, reply) => {
241241
const { slug } = request.params as { slug: string };
242+
const personId = fastify.inMemoryState.personIdBySlug.get(slug);
242243
const result = await fastify.store.transact(
243244
buildTransactionOptions({
244245
request,
@@ -250,6 +251,7 @@ export async function peopleRoutes(fastify: FastifyInstance): Promise<void> {
250251
async (tx) => fastify.services.peopleWrite.purge(tx, slug, request.session),
251252
);
252253
result.value.stateApply.apply(fastify.inMemoryState, fastify.fts);
254+
if (personId) await fastify.sessionMetadata.removeAllFor(personId, fastify.store.private);
253255
return reply.code(204).send();
254256
});
255257

‎apps/api/src/services/person-purge.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ async function purgePersonRecords(
4343
// 1. Delete the person record.
4444
await tx.public.people.delete(existing);
4545
stateApply.removePerson(personId, existing.slug);
46+
// Their private data goes in the same transaction (profile, legacy password,
47+
// reset tokens, claim requests).
48+
tx.private.deletePersonData(personId);
4649

4750
// 2. Cascade-delete project-memberships.
4851
const membershipIds = state.membershipsByPerson.get(existing.slug) ?? new Set<string>();
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/**
2+
* Private data left by people who no longer exist publicly — the cleanup
3+
* behind `POST /api/admin/private-data/purge-orphans`
4+
* (specs/api/moderation.md). Purge deletes private data in the same
5+
* transaction now; everyone purged or pruned before that left a private
6+
* profile, legacy password, claim requests and session metadata behind.
7+
*
8+
* Runs inside the API on purpose: the API holds the private store in memory
9+
* and rewrites whole files on each private write, so an out-of-process
10+
* cleanup would be undone by the next write.
11+
*/
12+
import type { SessionMetadataStore } from '../auth/session-metadata.js';
13+
import type { PrivateStore } from '../store/private/index.js';
14+
15+
export interface OrphanPrivateData {
16+
/** Person ids with private data and no public record. */
17+
readonly personIds: string[];
18+
readonly profiles: number;
19+
readonly legacyPasswords: number;
20+
readonly claimRequests: number;
21+
readonly sessions: number;
22+
}
23+
24+
/** Find private data whose person is not in `publicIds`. */
25+
export async function findOrphanPrivateData(
26+
publicIds: ReadonlySet<string>,
27+
privateStore: PrivateStore,
28+
sessions: SessionMetadataStore,
29+
): Promise<OrphanPrivateData> {
30+
const orphans = new Set<string>();
31+
let profiles = 0;
32+
let legacyPasswords = 0;
33+
let claimRequests = 0;
34+
let sessionCount = 0;
35+
36+
for await (const profile of privateStore.listAllProfiles()) {
37+
if (!publicIds.has(profile.personId)) {
38+
orphans.add(profile.personId);
39+
profiles++;
40+
}
41+
}
42+
for (const personId of privateStore.indices.legacyPasswordByPersonId.keys()) {
43+
if (!publicIds.has(personId)) {
44+
orphans.add(personId);
45+
legacyPasswords++;
46+
}
47+
}
48+
for (const req of await privateStore.listAllClaimRequests()) {
49+
const ids = [req.claimedPersonId, req.requesterPersonId].filter((id): id is string => !!id);
50+
const gone = ids.filter((id) => !publicIds.has(id));
51+
if (gone.length > 0) {
52+
gone.forEach((id) => orphans.add(id));
53+
claimRequests++;
54+
}
55+
}
56+
for (const personId of orphans) sessionCount += sessions.getAll(personId).length;
57+
58+
return {
59+
personIds: [...orphans].sort(),
60+
profiles,
61+
legacyPasswords,
62+
claimRequests,
63+
sessions: sessionCount,
64+
};
65+
}
66+
67+
/** Delete the private data of `found.personIds`. */
68+
export async function purgeOrphanPrivateData(
69+
found: OrphanPrivateData,
70+
privateStore: PrivateStore,
71+
sessions: SessionMetadataStore,
72+
): Promise<void> {
73+
await privateStore.transact(async (tx) => {
74+
for (const personId of found.personIds) tx.deletePersonData(personId);
75+
});
76+
for (const personId of found.personIds) await sessions.removeAllFor(personId, privateStore);
77+
}
78+

‎apps/api/src/services/spam-purge.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type { FtsEngine } from '../store/fts.js';
1111
import type { InMemoryState } from '../store/memory/state.js';
1212
import { StateApply } from '../store/state-apply.js';
1313
import type { Store } from '../store/store.js';
14+
import type { SessionMetadataStore } from '../auth/session-metadata.js';
1415
import { purgePeople } from './person-purge.js';
1516

1617
/** How long a spam vote stands before the sweep acts on it. */
@@ -48,6 +49,7 @@ export interface SpamPurgeDeps {
4849
readonly fts: FtsEngine;
4950
readonly store: Store;
5051
readonly log: FastifyBaseLogger;
52+
readonly sessionMetadata: SessionMetadataStore;
5153
}
5254

5355
export class SpamPurgeService {
@@ -86,6 +88,9 @@ export class SpamPurgeService {
8688
throw err;
8789
}
8890
stateApply.apply(this.#d.state, this.#d.fts);
91+
for (const person of queued) {
92+
await this.#d.sessionMetadata.removeAllFor(person.id, this.#d.store.private);
93+
}
8994
this.#d.log.info({ purged: slugs }, 'scheduled spam purge');
9095
return slugs;
9196
}

‎apps/api/src/store/private/base.ts‎

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -174,13 +174,15 @@ export abstract class BasePrivateStore implements PrivateStore {
174174
const profilesSnapshot = new Map(this.profiles);
175175
const legacySnapshot = new Map(this.legacyPasswords);
176176
const claimRequestsSnapshot = new Map(this.claimRequests);
177+
const passwordTokensSnapshot = new Map(this.passwordTokens);
177178

178179
// Staged mutations applied only in-memory during the handler
179180
const stagedProfilePuts: Map<string, PrivateProfile> = new Map();
180181
const stagedProfileDeletes: Set<string> = new Set();
181182
const stagedLegacyPuts: Map<string, LegacyPasswordCredential> = new Map();
182183
const stagedLegacyDeletes: Set<string> = new Set();
183184
const stagedClaimRequestPuts: Map<string, AccountClaimRequest> = new Map();
185+
const stagedPersonPurges: Set<string> = new Set();
184186

185187
const tx: PrivateStoreTx = {
186188
putProfile: (profile) => {
@@ -205,6 +207,11 @@ export abstract class BasePrivateStore implements PrivateStore {
205207
const parsed = AccountClaimRequestSchema.parse(req);
206208
stagedClaimRequestPuts.set(parsed.id, parsed);
207209
},
210+
deletePersonData: (personId) => {
211+
stagedPersonPurges.add(personId);
212+
stagedProfilePuts.delete(personId);
213+
stagedLegacyPuts.delete(personId);
214+
},
208215
};
209216

210217
let result: T;
@@ -215,6 +222,7 @@ export abstract class BasePrivateStore implements PrivateStore {
215222
this.profiles = profilesSnapshot;
216223
this.legacyPasswords = legacySnapshot;
217224
this.claimRequests = claimRequestsSnapshot;
225+
this.passwordTokens = passwordTokensSnapshot;
218226
this.rebuildIndices();
219227
throw err;
220228
}
@@ -235,19 +243,33 @@ export abstract class BasePrivateStore implements PrivateStore {
235243
for (const [id, req] of stagedClaimRequestPuts) {
236244
this.claimRequests.set(id, req);
237245
}
246+
for (const personId of stagedPersonPurges) {
247+
this.profiles.delete(personId);
248+
this.legacyPasswords.delete(personId);
249+
for (const [id, req] of this.claimRequests) {
250+
if (req.claimedPersonId === personId || req.requesterPersonId === personId) this.claimRequests.delete(id);
251+
}
252+
for (const [hash, token] of this.passwordTokens) {
253+
if (token.personId === personId) this.passwordTokens.delete(hash);
254+
}
255+
}
238256
this.rebuildIndices();
239257

240258
// Flush to backend
241259
const flushOps: Promise<void>[] = [];
242-
if (stagedProfilePuts.size > 0 || stagedProfileDeletes.size > 0) {
260+
const purging = stagedPersonPurges.size > 0;
261+
if (purging || stagedProfilePuts.size > 0 || stagedProfileDeletes.size > 0) {
243262
flushOps.push(this.flushProfiles());
244263
}
245-
if (stagedLegacyPuts.size > 0 || stagedLegacyDeletes.size > 0) {
264+
if (purging || stagedLegacyPuts.size > 0 || stagedLegacyDeletes.size > 0) {
246265
flushOps.push(this.flushLegacyPasswords());
247266
}
248-
if (stagedClaimRequestPuts.size > 0) {
267+
if (purging || stagedClaimRequestPuts.size > 0) {
249268
flushOps.push(this.flushClaimRequests());
250269
}
270+
if (purging) {
271+
flushOps.push(this.flushPasswordTokens());
272+
}
251273

252274
try {
253275
await Promise.all(flushOps);
@@ -257,6 +279,7 @@ export abstract class BasePrivateStore implements PrivateStore {
257279
this.profiles = profilesSnapshot;
258280
this.legacyPasswords = legacySnapshot;
259281
this.claimRequests = claimRequestsSnapshot;
282+
this.passwordTokens = passwordTokensSnapshot;
260283
this.rebuildIndices();
261284
throw new PrivateStoreError(
262285
'private_store_unavailable',

0 commit comments

Comments
 (0)