@sdxc/spam 0.0.0-pre.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +382 -0
  3. package/dist/akismet.d.ts +63 -0
  4. package/dist/akismet.js +127 -0
  5. package/dist/author-email.d.ts +16 -0
  6. package/dist/author-email.js +42 -0
  7. package/dist/bayes/classify.d.ts +39 -0
  8. package/dist/bayes/classify.js +72 -0
  9. package/dist/bayes/data-table.d.ts +58 -0
  10. package/dist/bayes/data-table.js +123 -0
  11. package/dist/bayes/memory.d.ts +18 -0
  12. package/dist/bayes/memory.js +26 -0
  13. package/dist/bayes/store.d.ts +41 -0
  14. package/dist/bayes/store.js +0 -0
  15. package/dist/bayes/tokens.d.ts +31 -0
  16. package/dist/bayes/tokens.js +42 -0
  17. package/dist/bayes.d.ts +43 -0
  18. package/dist/bayes.js +67 -0
  19. package/dist/check.d.ts +116 -0
  20. package/dist/check.js +20 -0
  21. package/dist/corpus.fixtures.d.ts +20 -0
  22. package/dist/corpus.fixtures.js +233 -0
  23. package/dist/filter.d.ts +70 -0
  24. package/dist/filter.js +122 -0
  25. package/dist/index.d.ts +13 -0
  26. package/dist/index.js +11 -0
  27. package/dist/lib/text.d.ts +16 -0
  28. package/dist/lib/text.js +43 -0
  29. package/dist/link-blocklist.d.ts +64 -0
  30. package/dist/link-blocklist.js +202 -0
  31. package/dist/memory.d.ts +71 -0
  32. package/dist/memory.js +73 -0
  33. package/dist/rules/author-name.d.ts +29 -0
  34. package/dist/rules/author-name.js +39 -0
  35. package/dist/rules/contact-bait.d.ts +28 -0
  36. package/dist/rules/contact-bait.js +43 -0
  37. package/dist/rules/index.d.ts +24 -0
  38. package/dist/rules/index.js +26 -0
  39. package/dist/rules/language.d.ts +29 -0
  40. package/dist/rules/language.js +84 -0
  41. package/dist/rules/link-syntax.d.ts +28 -0
  42. package/dist/rules/link-syntax.js +48 -0
  43. package/dist/rules/link-targets.d.ts +41 -0
  44. package/dist/rules/link-targets.js +87 -0
  45. package/dist/rules/links.d.ts +32 -0
  46. package/dist/rules/links.js +39 -0
  47. package/dist/rules/shouting.d.ts +31 -0
  48. package/dist/rules/shouting.js +60 -0
  49. package/dist/rules/timing.d.ts +29 -0
  50. package/dist/rules/timing.js +46 -0
  51. package/dist/rules/unicode.d.ts +31 -0
  52. package/dist/rules/unicode.js +66 -0
  53. package/dist/stop-forum-spam.d.ts +52 -0
  54. package/dist/stop-forum-spam.js +135 -0
  55. package/dist/workers-ai.d.ts +68 -0
  56. package/dist/workers-ai.js +164 -0
  57. package/package.json +32 -0
package/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sergio Xalambrí
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,382 @@
1
+ # @sdxc/spam
2
+
3
+ Spam scoring for user-generated submissions: local rules, reputation providers, a trainable
4
+ classifier and an LLM second opinion add weighted signals, and the total becomes a `ham`,
5
+ `unsure` or `spam` verdict with every reason attached.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ npm add @sdxc/spam
11
+ ```
12
+
13
+ Remote checks and moderator reports return [`@sdxc/result`](https://www.npmjs.com/package/@sdxc/result)
14
+ values, and the classifier's database store runs on
15
+ [`remix/data-table`](https://www.npmjs.com/package/remix). Both install alongside this package.
16
+
17
+ ## Usage
18
+
19
+ ### Score a submission
20
+
21
+ ```ts
22
+ import { createSpamFilter, DEFAULT_RULES } from "@sdxc/spam";
23
+
24
+ let filter = createSpamFilter({ checks: DEFAULT_RULES });
25
+
26
+ let assessment = await filter.check({
27
+ content: "cheap watches https://a.example https://b.example",
28
+ });
29
+ assessment.verdict; // "ham" | "unsure" | "spam"
30
+ assessment.score; // 5
31
+ assessment.signals; // [{ check: "links.count", score: 2, detail: "2 links" }, { check: "links.density", score: 3, … }]
32
+ ```
33
+
34
+ ### Give the rules more to work with
35
+
36
+ Only `content` is required. Every other field lets more rules fire.
37
+
38
+ ```ts
39
+ let assessment = await filter.check({
40
+ content: form.get("comment"),
41
+ format: "markdown",
42
+ author: {
43
+ name: form.get("name"),
44
+ email: form.get("email"),
45
+ ip: request.headers.get("CF-Connecting-IP"),
46
+ },
47
+ renderedAt: verifiedRenderTime, // signed at render, as @sdxc/honeypot does; never a plain hidden input
48
+ languages: ["en", "es"],
49
+ });
50
+ ```
51
+
52
+ ### Add reputation and a second opinion
53
+
54
+ ```ts
55
+ import { createSpamFilter, DEFAULT_RULES } from "@sdxc/spam";
56
+ import { akismet } from "@sdxc/spam/akismet";
57
+ import { authorEmail } from "@sdxc/spam/author-email";
58
+
59
+ let filter = createSpamFilter({
60
+ checks: [
61
+ ...DEFAULT_RULES,
62
+ authorEmail(),
63
+ akismet({ apiKey: AKISMET_KEY, blog: "https://example.com" }),
64
+ ],
65
+ thresholds: { unsure: 5, spam: 10 },
66
+ timeout: 1500,
67
+ });
68
+ ```
69
+
70
+ Local rules run first. When they already reach the spam threshold, no request leaves the Worker.
71
+
72
+ ### Learn from moderators
73
+
74
+ ```ts
75
+ let failures = await filter.report(submission, "ham"); // a moderator approved a held comment
76
+ ```
77
+
78
+ Every check that learns receives the decision; `failures` lists the ones that could not record it.
79
+
80
+ ## API
81
+
82
+ ### `@sdxc/spam`
83
+
84
+ #### `createSpamFilter({ checks, thresholds?, timeout? })`
85
+
86
+ Returns a `SpamFilter` with two methods:
87
+
88
+ - `check(submission)` resolves a `SpamFilter.Assessment`: `{ verdict, score, signals, failures }`.
89
+ Checks run in three stages: `local` in order; `remote` in parallel, only while the score is below
90
+ the spam threshold; `escalation` in parallel, only while the score is inside the unsure band.
91
+ A check that fails, throws or overruns `timeout` (default 1500 ms, remote and escalation only)
92
+ adds nothing and is listed in `failures`, so an outage never decides a verdict.
93
+ - `report(submission, label)` sends `"spam"` or `"ham"` to every check with a `report` method, in
94
+ parallel, and resolves the ones that failed.
95
+
96
+ `thresholds` defaults to `{ unsure: 5, spam: 10 }` (`DEFAULT_THRESHOLDS`). A score at or above
97
+ `spam` is spam, at or above `unsure` is unsure, and anything lower is ham.
98
+
99
+ #### `verdictFor(score, thresholds)`
100
+
101
+ The mapping `check` uses, for re-scoring stored signals under new thresholds.
102
+
103
+ #### `SpamCheck`
104
+
105
+ The contract every check implements: `name`, `stage` (`"local" | "remote" | "escalation"`),
106
+ `check(submission, { signal, score })` and an optional `report(submission, label)`. A local check
107
+ returns `Signal[]`; any other returns `Promise<Result<Signal[], SpamCheckError>>`. `signal` aborts
108
+ at the timeout, and `score` is the total earlier stages reached.
109
+
110
+ #### `Signal`
111
+
112
+ `{ check, score, detail? }`. `check` is a stable identifier such as `links.count`. A positive score
113
+ points to spam and a negative one to ham, so a provider can vouch for an author.
114
+
115
+ #### `Submission`
116
+
117
+ `{ content, format?, author?, renderedAt?, submittedAt?, languages? }`. `format` is `"text"` (the
118
+ default), `"markdown"` or `"html"`; `author` holds `name`, `email`, `url`, `ip` and `userAgent`;
119
+ `submittedAt` defaults to the time of the check; `languages` are BCP 47 tags.
120
+
121
+ #### `SpamCheckError`
122
+
123
+ A check's failure. `code` is `unavailable`, `timeout`, `misconfigured` or `invalid-response`.
124
+
125
+ #### Rules
126
+
127
+ Each rule is a function returning a local `SpamCheck`; every weight and limit is an option with a
128
+ documented default. `DEFAULT_RULES` holds all of them at their defaults, tuned together so a
129
+ submission needs more than one kind of evidence to reach the spam threshold.
130
+
131
+ | Rule | Signals | Fires on |
132
+ | --------------- | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
133
+ | `timing()` | `timing.fast`, `timing.future`, `timing.stale` | Sent under 3 s after render, rendered in the future, or rendered over a day ago |
134
+ | `links()` | `links.count`, `links.density` | Each link past the first; fewer than 4 words per link |
135
+ | `linkSyntax()` | `link-syntax.bbcode`, `link-syntax.html`, `link-syntax.markdown` | BBCode anywhere; HTML anchors outside `html`; Markdown links in `text` |
136
+ | `linkTargets()` | `link-targets.shortener`, `.ip-address`, `.punycode`, `.abused-tld` | Links (and `author.url`) to shorteners, IP literals, punycode hosts, abused TLDs |
137
+ | `unicode()` | `unicode.mixed-script`, `.hidden`, `.styled`, `.mark-stack` | Latin mixed with Cyrillic or Greek in one word, invisible separators, styled letters |
138
+ | `contactBait()` | `contact-bait.messaging`, `.phone`, `.wallet` | Telegram or WhatsApp handles, international phone numbers, crypto wallet addresses |
139
+ | `shouting()` | `shouting.caps`, `.run`, `.repetition`, `.emoji` | Capitals, one character six times, a phrase three times, emoji outnumbering words |
140
+ | `language()` | `language.script` | Most letters in a script none of `languages` is written in |
141
+ | `authorName()` | `author-name.domain`, `author-name.length` | A domain in the name, or a name longer than a person's |
142
+
143
+ `URL_SHORTENERS` and `ABUSED_TLDS` are the lists `linkTargets()` uses by default.
144
+
145
+ ### `@sdxc/spam/author-email`
146
+
147
+ `authorEmail({ disposableScore? })` scores an `author.email` on a disposable-mail domain
148
+ (`author-email.disposable`, default 3), subdomains included. It ships separately because the
149
+ domain list it bundles is large, so it is not in `DEFAULT_RULES`. An address that does not parse
150
+ draws nothing.
151
+
152
+ ### `@sdxc/spam/stop-forum-spam`
153
+
154
+ `stopForumSpam(options?)` looks the author's IP, email and name up in
155
+ [StopForumSpam](https://www.stopforumspam.com/usage), a free community database of addresses
156
+ reported for forum and blog spam. It sends only the fields the submission has, and nothing when it
157
+ has none. Each reported field scores its weight times the reported confidence (`ipScore` 5,
158
+ `emailScore` 6, `usernameScore` 2), reports last seen over `maxAgeDays` (90) ago are ignored, and
159
+ the total is capped at `maxScore` (10). Signals are `stop-forum-spam.ip`, `.email` and
160
+ `.username`. `endpoint` pins a regional mirror. The service has a daily query limit per IP and
161
+ receives the author's IP, email and name.
162
+
163
+ ### `@sdxc/spam/link-blocklist`
164
+
165
+ `linkBlocklist(options?)` looks every linked domain and the author's website up in DNS URI
166
+ blocklists over DNS-over-HTTPS, so it runs where no DNS socket exists. Zones are `spamhaus`,
167
+ `surbl` and `uribl`; the default is `["surbl", "uribl"]`, and a `dqsKey` adds Spamhaus. A listing
168
+ scores `listedScore` (6); a low-confidence one (URIBL grey, a Spamhaus "abused legitimate" domain)
169
+ scores `weakScore` (2). At most `maxHosts` (10) domains are looked up, IP literals are skipped, and
170
+ the total is capped at `maxScore` (12). Signals are `link-blocklist.spamhaus`, `.surbl` and
171
+ `.uribl`. `resolver` routes queries through another DoH endpoint.
172
+
173
+ A blocklist's "query refused" or rate-limit answer fails as `misconfigured`, never as a listing.
174
+ Spamhaus refuses public resolvers, so it needs a
175
+ [Data Query Service](https://www.spamhaus.com/product/data-query-service/) key, which travels in the
176
+ query name. SURBL and URIBL are free for low-volume, non-commercial use and may refuse queries
177
+ relayed through large public resolvers; when they do, the check reports `misconfigured`, and a
178
+ private resolver or a commercial feed is the fix.
179
+
180
+ ### `@sdxc/spam/akismet`
181
+
182
+ `akismet({ apiKey, blog })` sends each submission to Akismet's `comment-check` and turns the answer
183
+ into a signal: `akismet.spam` (default 8, the unsure band), `akismet.discard` when Akismet marks it
184
+ blatant (default 12, spam on its own), and `akismet.ham` when Akismet vouches for it (default -2).
185
+ Akismet requires the author's IP, so a submission without `author.ip` sends nothing and draws
186
+ nothing. `commentType` (default `"comment"`), `blogLang` and `isTest` pass through; the weights are
187
+ `spamScore`, `discardScore` and `hamScore`. An invalid key fails as `misconfigured`, an HTTP or
188
+ network error as `unavailable`. `report` forwards a moderator's decision to `submit-spam` or
189
+ `submit-ham`, bounded by `reportTimeout` (default 10 s).
190
+
191
+ Akismet is free for personal, non-commercial sites and paid otherwise. Each call sends the author's
192
+ IP, user agent, name, email, website and the full content.
193
+
194
+ ### `@sdxc/spam/bayes`
195
+
196
+ `bayes({ store })` is a naive Bayes classifier that learns from moderators: every
197
+ `filter.report(submission, label)` trains it. It tokenizes content words, a `host:` token per
198
+ linked host and the author's website, and an `email-domain:` token, then combines the most telling
199
+ tokens (Robinson's smoothed probabilities, Fisher's method) into a spam probability. At or above
200
+ `spamCut` (0.9) it adds `bayes.spam` (`spamScore`, 6); at or below `hamCut` (0.2) it adds
201
+ `bayes.ham` (`hamScore`, -4). Until the store holds `minDocuments` (20) reports of each label it
202
+ adds nothing, so a new install relies on the rules alone. The signal's detail names the tokens
203
+ that decided it. `tokenize(submission, options?)` is exported for inspecting what it learns.
204
+
205
+ Counts live behind `TokenStore` (`read(tokens)` and `increment(tokens, label)`, both `Result`s):
206
+
207
+ - `MemoryTokenStore` keeps counts in the instance, for tests.
208
+ - `new DataTableTokenStore(db)` stores them in a `remix/data-table` database, D1 and SQLite
209
+ included. Create its table from `SPAM_TOKENS_SCHEMA_SQL` in a migration; `spamTokens` is the
210
+ table definition. Every write is one upsert statement, so concurrent reports never lose a count.
211
+
212
+ In a multi-tenant app, build one store per tenant database, so one tenant's training never changes
213
+ another's verdicts. The check runs in the local stage, since it reads the app's own database.
214
+
215
+ ### `@sdxc/spam/workers-ai`
216
+
217
+ `workersAi({ ai, model?, site?, spamScore?, hamScore?, maxContentLength?, maxReasonLength? })` is
218
+ an escalation check: the filter calls it only for submissions the other checks left unsure, so the
219
+ model runs on the few cases rules cannot settle. Pass the Workers AI binding (`env.AI`) as `ai`,
220
+ and describe the site in `site` (`"comments on a cooking blog"`) so the model judges relevance for
221
+ the right audience.
222
+
223
+ The check sends the content (cut to `maxContentLength`, default 4000 characters), the author's name
224
+ and URL, and up to 20 of the signals earlier checks found, as escaped JSON inside delimiters, under
225
+ a system prompt that treats everything inside as data to classify. The model answers `spam`, `ham`
226
+ or `unsure` with a confidence and a reason: spam adds `workers-ai.spam` at `spamScore × confidence`
227
+ (default 5, enough to carry an unsure 5 to 10), ham adds `workers-ai.ham` at
228
+ `-(hamScore × confidence)` (default 5), and unsure adds nothing. The reason, cut to
229
+ `maxReasonLength` (200), is the signal's detail. The default model is
230
+ `@cf/meta/llama-3.1-8b-instruct-fp8` (`DEFAULT_WORKERS_AI_MODEL`); `model` takes any Workers AI
231
+ text model with JSON mode. `ai` is typed as any object with a matching `run()`, so a test passes a
232
+ plain object.
233
+
234
+ ### `@sdxc/spam/memory`
235
+
236
+ `new MemoryCheck({ name?, stage?, signals? })` is a scripted check for tests. A call answers the
237
+ next queued outcome, else `signals`.
238
+
239
+ - `signalNext(signals)` / `failNext(code, message?)` queue the next answer.
240
+ - `failReports(code)` makes every later `report` fail.
241
+ - `calls`, `last` and `reports` record what the check received.
242
+ - `reset()` forgets calls, reports and queued outcomes.
243
+
244
+ `name` defaults to `"memory"` and `stage` to `"remote"`.
245
+
246
+ ## Pattern: Refuse bots before scoring
247
+
248
+ A honeypot refuses what no person produces, before the body is read into a submission, so no
249
+ rule, lookup or model call runs for it. [`@sdxc/honeypot`](https://www.npmjs.com/package/@sdxc/honeypot)
250
+ does that, and what it lets through carries a signed render time for the `timing` rule.
251
+
252
+ ```ts
253
+ import { Honeypot } from "@sdxc/honeypot";
254
+ import { honeypot } from "@sdxc/honeypot/middleware";
255
+ import { unwrap } from "@sdxc/result";
256
+ import { createSpamFilter, DEFAULT_RULES } from "@sdxc/spam";
257
+
258
+ let trap = new Honeypot({ secret: HONEYPOT_SECRET });
259
+ let filter = createSpamFilter({ checks: DEFAULT_RULES });
260
+
261
+ router.post("/comments", {
262
+ middleware: [honeypot(trap)],
263
+ async handler(ctx) {
264
+ let form = await ctx.request.formData();
265
+ let assessment = await filter.check({
266
+ content: String(form.get("content") ?? ""),
267
+ renderedAt: unwrap(ctx.honeypot).renderedAt,
268
+ });
269
+ return saveComment(form, assessment);
270
+ },
271
+ });
272
+ ```
273
+
274
+ ## Pattern: Hold, discard or publish
275
+
276
+ The verdict is a recommendation; the app decides what each one does. Answering spam like a success
277
+ teaches a bot nothing.
278
+
279
+ ```ts
280
+ import type { Submission } from "@sdxc/spam";
281
+
282
+ import { createSpamFilter, DEFAULT_RULES } from "@sdxc/spam";
283
+
284
+ let filter = createSpamFilter({ checks: DEFAULT_RULES });
285
+
286
+ async function submitComment(submission: Submission) {
287
+ let assessment = await filter.check(submission);
288
+ if (assessment.verdict === "spam") return { status: "received" };
289
+ let comment = await saveComment(submission, {
290
+ published: assessment.verdict === "ham",
291
+ reasons: assessment.signals,
292
+ });
293
+ return { status: comment.published ? "published" : "received" };
294
+ }
295
+ ```
296
+
297
+ ## Pattern: Writing a rule for your site
298
+
299
+ A rule is an object with a name, a stage and a `check` function. Signals from it add to every
300
+ other rule's.
301
+
302
+ ```ts
303
+ import type { SpamCheck } from "@sdxc/spam";
304
+
305
+ import { createSpamFilter, DEFAULT_RULES } from "@sdxc/spam";
306
+
307
+ let competitorLinks: SpamCheck = {
308
+ name: "competitor-links",
309
+ stage: "local",
310
+ check: (submission) =>
311
+ /rival\.example/i.test(submission.content)
312
+ ? [{ check: "competitor-links", score: 4, detail: "links to rival.example" }]
313
+ : [],
314
+ };
315
+
316
+ let filter = createSpamFilter({ checks: [...DEFAULT_RULES, competitorLinks] });
317
+ ```
318
+
319
+ ## Pattern: Reweighting a rule
320
+
321
+ `DEFAULT_RULES` is a list of rule calls at their defaults. Build your own list to change one.
322
+
323
+ ```ts
324
+ import { contactBait, createSpamFilter, DEFAULT_RULES } from "@sdxc/spam";
325
+
326
+ let filter = createSpamFilter({
327
+ checks: [
328
+ ...DEFAULT_RULES.filter((rule) => rule.name !== "contact-bait"),
329
+ contactBait({ phoneScore: 0 }), // this form asks for a phone number
330
+ ],
331
+ });
332
+ ```
333
+
334
+ ## Pattern: Testing an action that filters
335
+
336
+ `MemoryCheck` fixes the evidence, so a test exercises each verdict without a provider.
337
+
338
+ ```ts
339
+ import { createSpamFilter } from "@sdxc/spam";
340
+ import { MemoryCheck } from "@sdxc/spam/memory";
341
+ import { expect, test } from "vitest";
342
+
343
+ test("holds an unsure comment for moderation", async () => {
344
+ let check = new MemoryCheck({ signals: [{ check: "test", score: 6 }] });
345
+ let filter = createSpamFilter({ checks: [check] });
346
+
347
+ let assessment = await filter.check({ content: "hello" });
348
+
349
+ expect(assessment.verdict).toBe("unsure");
350
+ expect(check.last).toEqual({ content: "hello" });
351
+ });
352
+ ```
353
+
354
+ ## Versioning
355
+
356
+ Releases are dated rather than semantic. A version is the UTC date it was published,
357
+ written `YYYY.M.D`, so `2026.9.4` is the release from 4 September 2026. At most one
358
+ release goes out per day.
359
+
360
+ Those numbers say when, not what: a later date means a later release and carries no
361
+ compatibility promise. Any release may change or remove an export.
362
+
363
+ Depend on one exact date, and move it when you are ready to take the change:
364
+
365
+ ```json
366
+ {
367
+ "dependencies": {
368
+ "@sdxc/spam": "2026.9.4"
369
+ }
370
+ }
371
+ ```
372
+
373
+ A caret or tilde range reads the date as major, minor and patch, so it accepts every
374
+ later release in the same year. An exact version keeps the upgrade yours to schedule.
375
+
376
+ ## License
377
+
378
+ MIT
379
+
380
+ ## Author
381
+
382
+ [Sergio Xalambrí](https://sergiodxa.com)
@@ -0,0 +1,63 @@
1
+ import type { SpamCheck } from "./check.js";
2
+ /**
3
+ * Checks submissions against Akismet's cross-site reputation network. Akismet requires the
4
+ * author's IP, so a submission without `author.ip` answers no signals and makes no request.
5
+ * `true` is spam, `true` with `X-akismet-pro-tip: discard` is blatant spam that clears the
6
+ * default spam threshold alone, and `false` is a small ham signal, since Akismet vouches for it.
7
+ *
8
+ * @example let check = akismet({ apiKey: env.AKISMET_API_KEY, blog: "https://example.com" });
9
+ */
10
+ export declare function akismet(options: akismet.Options): SpamCheck;
11
+ /** The options {@link akismet} takes. */
12
+ export declare namespace akismet {
13
+ /** The Akismet account, the site it is registered for, and the weight of each answer. */
14
+ interface Options {
15
+ /** The Akismet API key; it never reaches the browser. */
16
+ apiKey: string;
17
+ /** The front page of the site the key is registered for, such as `https://example.com`. */
18
+ blog: string;
19
+ /**
20
+ * What kind of content is checked, in Akismet's vocabulary: `comment`, `reply`,
21
+ * `forum-post`, `blog-post`, `contact-form`, `signup` or `message`.
22
+ *
23
+ * @default "comment"
24
+ */
25
+ commentType?: string;
26
+ /** The languages the site is written in, comma-separated ISO 639-1 codes such as `en, fr`. */
27
+ blogLang?: string;
28
+ /**
29
+ * Sends `is_test=1`, so Akismet answers without learning from the call; for development
30
+ * and automated tests.
31
+ *
32
+ * @default false
33
+ */
34
+ isTest?: boolean;
35
+ /**
36
+ * The score when Akismet answers spam. It lands in the default unsure band, so a moderator
37
+ * sees it unless other signals push it over.
38
+ *
39
+ * @default 8
40
+ */
41
+ spamScore?: number;
42
+ /**
43
+ * The score when Akismet marks spam as blatant with `X-akismet-pro-tip: discard`; it clears
44
+ * the default spam threshold on its own.
45
+ *
46
+ * @default 12
47
+ */
48
+ discardScore?: number;
49
+ /**
50
+ * The score when Akismet answers ham. It is negative, so Akismet's vouching offsets a weak
51
+ * local signal without cancelling a strong one.
52
+ *
53
+ * @default -2
54
+ */
55
+ hamScore?: number;
56
+ /**
57
+ * Milliseconds a `submit-spam` or `submit-ham` call may take before it fails `unavailable`.
58
+ *
59
+ * @default 10000
60
+ */
61
+ reportTimeout?: number;
62
+ }
63
+ }
@@ -0,0 +1,127 @@
1
+ import { failure, isFailure, success } from "@sdxc/result";
2
+ import * as s from "remix/data-schema";
3
+ import { SpamCheckError } from "./check.js";
4
+ /** The REST API root every Akismet call goes to. */
5
+ const AKISMET_API_URL = "https://rest.akismet.com/1.1";
6
+ /** The only `comment-check` bodies that carry a verdict; `invalid` is handled before them. */
7
+ const COMMENT_CHECK_ANSWER = s.enum_(["true", "false"]);
8
+ /**
9
+ * Checks submissions against Akismet's cross-site reputation network. Akismet requires the
10
+ * author's IP, so a submission without `author.ip` answers no signals and makes no request.
11
+ * `true` is spam, `true` with `X-akismet-pro-tip: discard` is blatant spam that clears the
12
+ * default spam threshold alone, and `false` is a small ham signal, since Akismet vouches for it.
13
+ *
14
+ * @example let check = akismet({ apiKey: env.AKISMET_API_KEY, blog: "https://example.com" });
15
+ */
16
+ export function akismet(options) {
17
+ let spamScore = options.spamScore ?? 8;
18
+ let discardScore = options.discardScore ?? 12;
19
+ let hamScore = options.hamScore ?? -2;
20
+ let reportTimeout = options.reportTimeout ?? 10_000;
21
+ return {
22
+ name: "akismet",
23
+ stage: "remote",
24
+ /**
25
+ * Asks `comment-check` for a verdict under the filter's abort signal. A bad key is
26
+ * `misconfigured`, a network failure or non-OK status `unavailable`, and any body other
27
+ * than `true`, `false` or `invalid` is `invalid-response`.
28
+ */
29
+ async check(submission, { signal }) {
30
+ let body = formFields(options, submission);
31
+ if (body === null)
32
+ return success([]);
33
+ let answer = await post("comment-check", body, signal);
34
+ if (isFailure(answer))
35
+ return answer;
36
+ let { text, headers } = answer.data;
37
+ let parsed = s.parseSafe(COMMENT_CHECK_ANSWER, text);
38
+ if (!parsed.success) {
39
+ return failure(new SpamCheckError("invalid-response", `comment-check answered "${text}"`));
40
+ }
41
+ if (parsed.value === "false") {
42
+ return success([
43
+ { check: "akismet.ham", score: hamScore, detail: "Akismet considers it legitimate" },
44
+ ]);
45
+ }
46
+ if (headers.get("x-akismet-pro-tip")?.toLowerCase() === "discard") {
47
+ return success([
48
+ { check: "akismet.discard", score: discardScore, detail: "Akismet flagged blatant spam" },
49
+ ]);
50
+ }
51
+ return success([{ check: "akismet.spam", score: spamScore, detail: "Akismet flagged spam" }]);
52
+ },
53
+ /**
54
+ * Sends a moderator's decision as `submit-spam` or `submit-ham` with the fields
55
+ * `comment-check` received, so Akismet can correct itself. A submission without
56
+ * `author.ip` was never checked by Akismet, so its report succeeds without a request.
57
+ */
58
+ async report(submission, label) {
59
+ let body = formFields(options, submission);
60
+ if (body === null)
61
+ return success(undefined);
62
+ let endpoint = label === "spam" ? "submit-spam" : "submit-ham";
63
+ let answer = await post(endpoint, body, AbortSignal.timeout(reportTimeout));
64
+ if (isFailure(answer))
65
+ return answer;
66
+ return success(undefined);
67
+ },
68
+ };
69
+ }
70
+ /**
71
+ * The form fields Akismet reads, from the options and the submission. Absent fields stay out of
72
+ * the form, so Akismet sees them as unknown rather than empty.
73
+ *
74
+ * @returns The form, or `null` when the submission has no author IP, which Akismet requires
75
+ */
76
+ function formFields(options, submission) {
77
+ let author = submission.author;
78
+ if (author?.ip === undefined || author.ip === "")
79
+ return null;
80
+ let body = new URLSearchParams({
81
+ api_key: options.apiKey,
82
+ blog: options.blog,
83
+ user_ip: author.ip,
84
+ comment_type: options.commentType ?? "comment",
85
+ comment_content: submission.content,
86
+ });
87
+ let optional = [
88
+ ["user_agent", author.userAgent],
89
+ ["comment_author", author.name],
90
+ ["comment_author_email", author.email],
91
+ ["comment_author_url", author.url],
92
+ ["blog_lang", options.blogLang],
93
+ ["comment_date_gmt", submission.submittedAt?.toISOString()],
94
+ ];
95
+ for (let [name, value] of optional) {
96
+ if (value !== undefined && value !== "")
97
+ body.set(name, value);
98
+ }
99
+ if (options.isTest)
100
+ body.set("is_test", "1");
101
+ return body;
102
+ }
103
+ /**
104
+ * Posts `body` to one Akismet endpoint. Akismet reports a rejected key with a 200 whose body is
105
+ * `invalid` or with an `X-akismet-debug-help` header, and both become `misconfigured` carrying
106
+ * Akismet's explanation; a network failure or non-OK status becomes `unavailable`.
107
+ */
108
+ async function post(endpoint, body, signal) {
109
+ let response;
110
+ let text;
111
+ try {
112
+ response = await fetch(`${AKISMET_API_URL}/${endpoint}`, { method: "POST", body, signal });
113
+ text = (await response.text()).trim();
114
+ }
115
+ catch (error) {
116
+ let message = error instanceof Error ? error.message : String(error);
117
+ return failure(new SpamCheckError("unavailable", message));
118
+ }
119
+ if (!response.ok) {
120
+ return failure(new SpamCheckError("unavailable", `${endpoint} answered HTTP ${response.status}`));
121
+ }
122
+ let debugHelp = response.headers.get("x-akismet-debug-help");
123
+ if (text === "invalid" || debugHelp !== null) {
124
+ return failure(new SpamCheckError("misconfigured", debugHelp ?? `${endpoint} refused the API key`));
125
+ }
126
+ return success({ text, headers: response.headers });
127
+ }
@@ -0,0 +1,16 @@
1
+ import type { SpamCheck } from "./check.js";
2
+ /**
3
+ * Scores an author email on a disposable domain. An address that fails to parse draws nothing,
4
+ * since validating the field is the form's job.
5
+ *
6
+ * @example createSpamFilter({ checks: [...DEFAULT_RULES, authorEmail()] })
7
+ */
8
+ export declare function authorEmail(options?: authorEmail.Options): SpamCheck;
9
+ /** The options {@link authorEmail} takes. */
10
+ export declare namespace authorEmail {
11
+ /** Weights for the author-email rule. */
12
+ interface Options {
13
+ /** @default 3 */
14
+ disposableScore?: number;
15
+ }
16
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The author-email rule: an address on a disposable-mail domain. It lives under its own export
3
+ * path because the domain list it reads is large, so a consumer who never checks email never
4
+ * bundles it.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import { parseEmailAddress } from "@sdxc/email-address";
10
+ import { findDisposableDomain } from "@sdxc/email-address/disposable";
11
+ import { isSuccess } from "@sdxc/result";
12
+ /**
13
+ * Scores an author email on a disposable domain. An address that fails to parse draws nothing,
14
+ * since validating the field is the form's job.
15
+ *
16
+ * @example createSpamFilter({ checks: [...DEFAULT_RULES, authorEmail()] })
17
+ */
18
+ export function authorEmail(options = {}) {
19
+ let disposableScore = options.disposableScore ?? 3;
20
+ return {
21
+ name: "author-email",
22
+ stage: "local",
23
+ check(submission) {
24
+ let email = submission.author?.email;
25
+ if (!email)
26
+ return [];
27
+ let parsed = parseEmailAddress(email);
28
+ if (!isSuccess(parsed))
29
+ return [];
30
+ let listed = findDisposableDomain(parsed.data.domain);
31
+ if (listed === null)
32
+ return [];
33
+ return [
34
+ {
35
+ check: "author-email.disposable",
36
+ score: disposableScore,
37
+ detail: `${listed} is a disposable email domain`,
38
+ },
39
+ ];
40
+ },
41
+ };
42
+ }