Skip to content

Commit abacae1

Browse files
authored
refactor(forms-api): declarative per-form spam policy (#85)
* refactor(forms-api): make spam-protection policy declarative per form Extract the spam-protection pipeline (recaptcha + honeypot + gibberish + timing) into a single declarative SpamPolicy passed to runSpamChecks, and extract the email-then-persist sequence (with emailSent/warning fallback) into sendAndPersist. Contact form gets the full policy; doula-match form gets recaptcha-only, matching observed spam volume. Records the reactive-per-form decision in ADR-0001 so future reviews do not "fix" the asymmetry by unifying the two policies. HTTP contract preserved; all 25 route tests pass unchanged. * refactor(forms-api): harden sendAndPersist and spam policy logging - Wrap persist() in try/catch; log CRITICAL with emailSent+persistFailed when email succeeded but Firestore persist failed, then re-throw - Tighten SendAndPersistResult to a discriminated union and document the persist callback's idempotency contract - Use static log message prefixes in run-spam-checks (formType moved to structured metadata) and emit debug logs when honeypot/gibberish/ timing policy sections are not configured - Add route-level test exercising multi-field gibberish flagging * chore(functions): remove unnecessary type assertions Auto-fix from eslint --fix to clear pre-existing @typescript-eslint/no-unnecessary-type-assertion errors that were failing CI.
1 parent 1e28611 commit abacae1

16 files changed

Lines changed: 309 additions & 128 deletions

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# ADR-0001: Form spam protection is reactive and per-form
2+
3+
## Status
4+
5+
Accepted — 2026-05-06
6+
7+
## Context
8+
9+
The site has two public form intake endpoints: the contact form and the
10+
doula-match form. Both share a baseline of reCAPTCHA verification and
11+
score-threshold rejection. The contact form additionally runs a honeypot
12+
field check, gibberish detection on name and message, and a "submitted
13+
too fast" timing check. The doula-match form runs none of these extras.
14+
15+
## Decision
16+
17+
We apply spam-protection layers reactively, per form, based on observed
18+
spam volume. The contact form has accumulated honeypot/gibberish/timing
19+
checks because we have observed real spam against it. The doula-match
20+
form has not, because we have not observed spam against it — likely due
21+
to its larger field surface (phone, due date, dropdowns) being unattractive
22+
to common spam scripts.
23+
24+
A future increase in match-form spam should be answered by adding the
25+
relevant check(s) to that form, not by uniformly applying every check
26+
to every form.
27+
28+
## Consequences
29+
30+
- The doula-match form is a softer target than the contact form. We
31+
accept this risk in exchange for lower friction for legitimate
32+
submissions.
33+
- The form-intake substrate exposes spam policy as a declarative input
34+
per form, so adding/removing a check on one form does not affect the
35+
other.
36+
- Architecture reviews should not "fix" the asymmetry by unifying
37+
policies without checking this ADR.

functions/src/admin-match-requests-api/plugins/admin-match-requests-plugin.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ export function createAdminMatchRequestsPlugin(services?: PartialServices) {
8585
logger,
8686
set,
8787
}) => {
88-
const typedBody = body as { sent: boolean };
88+
const typedBody = body;
8989
return updateMatchRequestLogic({
9090
requestId: params.requestId,
9191
sent: typedBody.sent,

functions/src/admin-members-api/plugins/admin-members-plugin.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { Elysia } from "elysia";
22
import { logger as firebaseLogger } from "firebase-functions/v2";
3-
import type { ProfileDataBody } from "../../profiles-api/schemas/profile-schemas.js";
43
import { ProfileDataBodySchema } from "../../profiles-api/schemas/profile-schemas.js";
54
import { AuthService } from "../../shared-api/services/auth/index.js";
65
import { EmailService } from "../../shared-api/services/email/index.js";
@@ -254,7 +253,7 @@ export function createAdminMembersPlugin(services?: PartialServices) {
254253
logger,
255254
set,
256255
}) => {
257-
const typedBody = body as ProfileDataBody;
256+
const typedBody = body;
258257
return updateProfileLogic({
259258
memberId: params.memberId,
260259
profileData: typedBody,
@@ -280,7 +279,7 @@ export function createAdminMembersPlugin(services?: PartialServices) {
280279
logger,
281280
set,
282281
}) => {
283-
const typedBody = body as { slug: string };
282+
const typedBody = body;
284283
return linkProfileLogic({
285284
memberId: params.memberId,
286285
slug: typedBody.slug,
@@ -366,7 +365,7 @@ export function createAdminMembersPlugin(services?: PartialServices) {
366365
logger,
367366
set,
368367
}) => {
369-
const typedBody = body as { newExpirationDate: string };
368+
const typedBody = body;
370369
return extendMembershipLogic({
371370
memberId: params.memberId,
372371
newExpirationDate: typedBody.newExpirationDate,
@@ -423,7 +422,7 @@ export function createAdminMembersPlugin(services?: PartialServices) {
423422
logger,
424423
set,
425424
}) => {
426-
const typedBody = body as { admin?: boolean };
425+
const typedBody = body;
427426
return updateClaimsLogic({
428427
uid: params.memberId,
429428
claims: typedBody,

functions/src/admin-members-api/routes/approve-profile.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,6 @@ export async function approveProfileLogic({
4747
logger,
4848
set,
4949
context: { memberId, adminUid },
50-
}) as ApproveProfileApiResponse;
50+
});
5151
}
5252
}

functions/src/admin-members-api/services/update-profile.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,6 @@ function isFirestoreNotFoundError(error: unknown): boolean {
100100
typeof error === "object" &&
101101
error !== null &&
102102
"code" in error &&
103-
(error as { code: unknown }).code === NOT_FOUND_CODE
103+
error.code === NOT_FOUND_CODE
104104
);
105105
}

functions/src/admin-members-api/test-utils/create-admin-test-plugin.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { mock } from "bun:test";
22
import type { DecodedIdToken } from "firebase-admin/auth";
3-
import type { ProfileDocument } from "../../collections/index.js";
43
import type { AuthService } from "../../shared-api/services/auth/interface.js";
54
import type { EmailServiceInterface } from "../../shared-api/services/email/index.js";
65
import type { Logger } from "../../shared-api/types/logger.js";
@@ -95,7 +94,7 @@ export function createAdminTestPlugin(overrides?: {
9594
draft: false,
9695
createdAt: "2024-01-01T00:00:00.000Z",
9796
updatedAt: "2024-01-01T00:00:00.000Z",
98-
} as ProfileDocument,
97+
},
9998
}),
10099
),
101100
updateProfile: mock(() =>
@@ -107,7 +106,7 @@ export function createAdminTestPlugin(overrides?: {
107106
draft: false,
108107
createdAt: "2024-01-01T00:00:00.000Z",
109108
updatedAt: "2024-02-01T00:00:00.000Z",
110-
} as ProfileDocument,
109+
},
111110
}),
112111
),
113112
listUnlinkedProfiles: mock(() =>

functions/src/admin-messages-api/plugins/admin-messages-plugin.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ export function createAdminMessagesPlugin(services?: PartialServices) {
8181
logger,
8282
set,
8383
}) => {
84-
const typedBody = body as { sent: boolean };
84+
const typedBody = body;
8585
return updateMessageLogic({
8686
messageId: params.messageId,
8787
sent: typedBody.sent,

functions/src/forms-api/routes/handle-contact-form.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,24 @@ describe("POST /contact-us", () => {
245245
expect(body.error).toBe("Invalid form submission");
246246
});
247247

248+
it("should return 400 when both contact name and message look like gibberish", async () => {
249+
const { plugin, request } = setup({
250+
body: {
251+
contactName: "AisqdChFmjmacpKsLgjGNo",
252+
email: "john@example.com",
253+
message: "XzqkfBpwLmNvCxRtYuIoAsDfGhJk",
254+
recaptchaToken: "valid-token",
255+
formLoadedAt: Date.now() - 5000,
256+
},
257+
});
258+
259+
const response = await handleRequest(plugin, request);
260+
261+
expect(response.status).toBe(400);
262+
const body = (await response.json()) as { error?: string };
263+
expect(body.error).toBe("Invalid form submission");
264+
});
265+
248266
it("should return 400 when submitted too quickly", async () => {
249267
const { plugin, request } = setup({
250268
body: {

functions/src/forms-api/routes/handle-contact-form.ts

Lines changed: 29 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ import { buildContactFormNotification } from "../services/build-contact-form-not
77
import type { FormStorageService } from "../services/form-storage/interface.js";
88
import type { ContactFormData } from "../services/form-storage/types.js";
99
import type { RecaptchaService } from "../services/recaptcha/interface.js";
10-
import { checkRecaptchaScore } from "../utils/check-recaptcha-score.js";
11-
import { detectGibberish } from "../utils/detect-gibberish.js";
10+
import { runSpamChecks } from "../utils/run-spam-checks.js";
11+
import { sendAndPersist } from "../utils/send-and-persist.js";
1212

1313
export async function handleContactFormLogic({
1414
formData,
@@ -34,7 +34,6 @@ export async function handleContactFormLogic({
3434
set: { status?: number | string };
3535
}): Promise<FormResponse> {
3636
try {
37-
// Verify reCAPTCHA token
3837
const verification = await recaptchaService.verifyToken({
3938
token: recaptchaToken,
4039
secretKey: recaptchaSecretKey,
@@ -50,93 +49,45 @@ export async function handleContactFormLogic({
5049
return { success: false, error: "reCAPTCHA verification failed" };
5150
}
5251

53-
// Check reCAPTCHA score threshold to block bots
54-
const scoreRejection = checkRecaptchaScore({
55-
score: verification.score,
52+
const rejection = runSpamChecks({
53+
policy: {
54+
recaptcha: { score: verification.score },
55+
honeypot: { value: honeypotValue },
56+
gibberish: [
57+
{ fieldName: "contactName", text: formData.contactName },
58+
{ fieldName: "message", text: formData.message },
59+
],
60+
timing: { formLoadedAt, minMillis: 3000 },
61+
},
5662
submitterEmail: formData.email,
5763
submitterName: formData.contactName,
5864
formType: "contact form",
65+
errorId: ERROR_IDS.CONTACT_FORM_PROCESSING_FAILED,
5966
logger,
6067
set,
6168
});
62-
if (scoreRejection !== undefined) {
63-
return scoreRejection;
69+
if (rejection !== undefined) {
70+
return rejection;
6471
}
6572

66-
if (honeypotValue !== undefined && honeypotValue.trim() !== "") {
67-
logger.warn("Contact form submission rejected by honeypot", {
68-
errorId: ERROR_IDS.CONTACT_FORM_PROCESSING_FAILED,
69-
reason: "honeypot_filled",
70-
submitterEmail: formData.email,
71-
submitterName: formData.contactName,
72-
});
73-
set.status = 400;
74-
return { success: false, error: "Invalid form submission" };
75-
}
76-
77-
const nameLooksLikeGibberish = detectGibberish({
78-
text: formData.contactName,
79-
});
80-
const messageLooksLikeGibberish = detectGibberish({
81-
text: formData.message,
82-
});
83-
84-
if (nameLooksLikeGibberish || messageLooksLikeGibberish) {
85-
logger.warn("Contact form submission rejected as gibberish", {
86-
errorId: ERROR_IDS.CONTACT_FORM_PROCESSING_FAILED,
87-
reason: "gibberish_detected",
88-
submitterEmail: formData.email,
89-
submitterName: formData.contactName,
90-
nameLooksLikeGibberish,
91-
messageLooksLikeGibberish,
92-
});
93-
set.status = 400;
94-
return { success: false, error: "Invalid form submission" };
95-
}
96-
97-
if (formLoadedAt !== undefined && Date.now() - formLoadedAt < 3000) {
98-
logger.warn("Contact form submission rejected as too fast", {
99-
errorId: ERROR_IDS.CONTACT_FORM_PROCESSING_FAILED,
100-
reason: "submitted_too_fast",
101-
submitterEmail: formData.email,
102-
submitterName: formData.contactName,
103-
formLoadedAt,
104-
});
105-
set.status = 400;
106-
return { success: false, error: "Invalid form submission" };
107-
}
108-
109-
// Try to send notification email first
110-
let emailSent = false;
111-
let warning: string | undefined;
112-
113-
try {
114-
const emailMessage = buildContactFormNotification(formData);
115-
await emailService.sendEmail({ message: emailMessage }, logger);
116-
emailSent = true;
117-
} catch (emailError: unknown) {
118-
logger.error("CRITICAL: Failed to send contact form notification email", {
73+
const { emailSent, warning } = await sendAndPersist({
74+
buildEmail: () => buildContactFormNotification(formData),
75+
persist: ({ emailSent: sent }) =>
76+
formStorageService.saveContactForm({
77+
data: formData,
78+
recaptchaScore: verification.score,
79+
emailSent: sent,
80+
}),
81+
emailService,
82+
logger,
83+
formContext: {
84+
formType: "contact form",
85+
formTypeKey: "contact_form",
11986
errorId: ERROR_IDS.CONTACT_FORM_PROCESSING_FAILED,
120-
severity: "CRITICAL",
121-
error: emailError,
122-
errorMessage:
123-
emailError instanceof Error ? emailError.message : "Unknown error",
124-
errorStack: emailError instanceof Error ? emailError.stack : undefined,
125-
formType: "contact_form",
12687
submitterEmail: formData.email,
12788
submitterName: formData.contactName,
12889
recaptchaScore: verification.score,
129-
timestamp: new Date().toISOString(),
130-
});
131-
132-
warning = "Form saved but notification email failed to send";
133-
}
134-
135-
// Save form to Firestore with email send status
136-
await formStorageService.saveContactForm({
137-
data: formData,
138-
recaptchaScore: verification.score,
139-
emailSent,
90+
},
14091
});
14192

14293
logger.info("Contact form submitted successfully", {

functions/src/forms-api/routes/handle-match-form.ts

Lines changed: 23 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ import { buildDoulaMatchNotification } from "../services/build-doula-match-notif
77
import type { FormStorageService } from "../services/form-storage/interface.js";
88
import type { DoulaMatchData } from "../services/form-storage/types.js";
99
import type { RecaptchaService } from "../services/recaptcha/interface.js";
10-
import { checkRecaptchaScore } from "../utils/check-recaptcha-score.js";
10+
import { runSpamChecks } from "../utils/run-spam-checks.js";
11+
import { sendAndPersist } from "../utils/send-and-persist.js";
1112

1213
export async function handleMatchFormLogic({
1314
formData,
@@ -29,7 +30,6 @@ export async function handleMatchFormLogic({
2930
set: { status?: number | string };
3031
}): Promise<FormResponse> {
3132
try {
32-
// Verify reCAPTCHA token
3333
const verification = await recaptchaService.verifyToken({
3434
token: recaptchaToken,
3535
secretKey: recaptchaSecretKey,
@@ -45,50 +45,39 @@ export async function handleMatchFormLogic({
4545
return { success: false, error: "reCAPTCHA verification failed" };
4646
}
4747

48-
// Check reCAPTCHA score threshold to block bots
49-
const scoreRejection = checkRecaptchaScore({
50-
score: verification.score,
48+
const rejection = runSpamChecks({
49+
policy: {
50+
recaptcha: { score: verification.score },
51+
},
5152
submitterEmail: formData.email,
5253
submitterName: formData.name,
5354
formType: "doula match form",
55+
errorId: ERROR_IDS.DOULA_MATCH_FORM_PROCESSING_FAILED,
5456
logger,
5557
set,
5658
});
57-
if (scoreRejection !== undefined) {
58-
return scoreRejection;
59+
if (rejection !== undefined) {
60+
return rejection;
5961
}
6062

61-
// Try to send notification email first
62-
let emailSent = false;
63-
let warning: string | undefined;
64-
65-
try {
66-
const emailMessage = buildDoulaMatchNotification(formData);
67-
await emailService.sendEmail({ message: emailMessage }, logger);
68-
emailSent = true;
69-
} catch (emailError: unknown) {
70-
logger.error("CRITICAL: Failed to send doula match notification email", {
63+
const { emailSent, warning } = await sendAndPersist({
64+
buildEmail: () => buildDoulaMatchNotification(formData),
65+
persist: ({ emailSent: sent }) =>
66+
formStorageService.saveMatchRequest({
67+
data: formData,
68+
recaptchaScore: verification.score,
69+
emailSent: sent,
70+
}),
71+
emailService,
72+
logger,
73+
formContext: {
74+
formType: "doula match form",
75+
formTypeKey: "doula_match",
7176
errorId: ERROR_IDS.DOULA_MATCH_FORM_PROCESSING_FAILED,
72-
severity: "CRITICAL",
73-
error: emailError,
74-
errorMessage:
75-
emailError instanceof Error ? emailError.message : "Unknown error",
76-
errorStack: emailError instanceof Error ? emailError.stack : undefined,
77-
formType: "doula_match",
7877
submitterEmail: formData.email,
7978
submitterName: formData.name,
8079
recaptchaScore: verification.score,
81-
timestamp: new Date().toISOString(),
82-
});
83-
84-
warning = "Form saved but notification email failed to send";
85-
}
86-
87-
// Save form to Firestore with email send status
88-
await formStorageService.saveMatchRequest({
89-
data: formData,
90-
recaptchaScore: verification.score,
91-
emailSent,
80+
},
9281
});
9382

9483
logger.info("Doula match form submitted successfully", {

0 commit comments

Comments
 (0)