@sdxc/spam 2026.10.16 → 2026.10.17
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.
- package/README.md +33 -11
- package/dist/middleware.d.ts +70 -0
- package/dist/middleware.js +84 -0
- package/package.json +8 -4
package/README.md
CHANGED
|
@@ -76,6 +76,21 @@ Local checks run first, in order. Remote checks run in parallel only while the s
|
|
|
76
76
|
the spam threshold, and a check that fails or overruns `timeout` adds nothing and is listed in
|
|
77
77
|
`assessment.failures`.
|
|
78
78
|
|
|
79
|
+
### Score a form before the handler
|
|
80
|
+
|
|
81
|
+
```typescript
|
|
82
|
+
import { spam } from "@sdxc/spam/middleware";
|
|
83
|
+
|
|
84
|
+
router.post(routes.contact.action, {
|
|
85
|
+
middleware: [spam(filter, { fields: { content: "message", email: "email" } })],
|
|
86
|
+
handler: (ctx) => saveMessage(ctx.formData, ctx.spamAssessment.verdict === "unsure"),
|
|
87
|
+
});
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Each entry in `fields` names a form field, and the middleware reads its value from the submitted
|
|
91
|
+
form. A spam verdict is answered with a 422 before the handler runs; the handler reads every other
|
|
92
|
+
assessment from `ctx.spamAssessment`.
|
|
93
|
+
|
|
79
94
|
### Learn from moderators
|
|
80
95
|
|
|
81
96
|
```typescript
|
|
@@ -238,6 +253,20 @@ a ham answer `workers-ai.ham` at `-(hamScore × confidence)` (default 5), with t
|
|
|
238
253
|
cut to `maxReasonLength` (200) as the detail. Content is cut to `maxContentLength` (4000).
|
|
239
254
|
`model` defaults to `DEFAULT_WORKERS_AI_MODEL`, and takes any text model with JSON mode.
|
|
240
255
|
|
|
256
|
+
### `@sdxc/spam/middleware`
|
|
257
|
+
|
|
258
|
+
#### `spam(filter: SpamFilter, options: SpamMiddlewareOptions): Middleware`
|
|
259
|
+
|
|
260
|
+
Router middleware scoring every request other than GET, HEAD and OPTIONS before the handler.
|
|
261
|
+
`fields` names the form fields to read: `content` (one name, or several joined by line breaks),
|
|
262
|
+
and the author's `name`, `email` and `url`. `format` and `languages` pass through. The
|
|
263
|
+
submission also carries `ctx.ip` from `@sdxc/get-client-ip/middleware`, the raw header from
|
|
264
|
+
`ctx.userAgent` (`@sdxc/user-agent/middleware`) and the render time `honeypot()` from
|
|
265
|
+
`@sdxc/honeypot/middleware` verified, each when that middleware runs earlier.
|
|
266
|
+
A `spam` verdict answers a plain 422 unless `onSpam(assessment, ctx)` returns its own `Response`,
|
|
267
|
+
or `null` to continue. Every assessment is published as `ctx.spamAssessment`
|
|
268
|
+
(`SpamAssessmentKey`), and the body stays readable.
|
|
269
|
+
|
|
241
270
|
### `@sdxc/spam/memory`
|
|
242
271
|
|
|
243
272
|
#### `new MemoryCheck(options?: MemoryCheck.Options)`
|
|
@@ -253,27 +282,20 @@ say otherwise. A call answers the next queued outcome, else `signals`.
|
|
|
253
282
|
## Pattern: Refuse bots before scoring
|
|
254
283
|
|
|
255
284
|
[`@sdxc/honeypot`](https://www.npmjs.com/package/@sdxc/honeypot) refuses what no person
|
|
256
|
-
produces before any rule runs, and hands
|
|
285
|
+
produces before any rule runs, and `spam()` hands its signed render time to the `timing` rule.
|
|
257
286
|
|
|
258
287
|
```typescript
|
|
259
288
|
import { Honeypot } from "@sdxc/honeypot";
|
|
260
289
|
import { honeypot } from "@sdxc/honeypot/middleware";
|
|
261
|
-
import { unwrap } from "@sdxc/result";
|
|
262
290
|
import { createSpamFilter, DEFAULT_RULES } from "@sdxc/spam";
|
|
291
|
+
import { spam } from "@sdxc/spam/middleware";
|
|
263
292
|
|
|
264
293
|
let trap = new Honeypot({ secret: env.HONEYPOT_SECRET });
|
|
265
294
|
let filter = createSpamFilter({ checks: DEFAULT_RULES });
|
|
266
295
|
|
|
267
296
|
router.post(routes.comments.create, {
|
|
268
|
-
middleware: [honeypot(trap)],
|
|
269
|
-
|
|
270
|
-
let form = await ctx.request.formData();
|
|
271
|
-
let assessment = await filter.check({
|
|
272
|
-
content: String(form.get("content") ?? ""),
|
|
273
|
-
renderedAt: unwrap(ctx.honeypotOutcome).renderedAt,
|
|
274
|
-
});
|
|
275
|
-
return saveComment(form, assessment);
|
|
276
|
-
},
|
|
297
|
+
middleware: [honeypot(trap), spam(filter, { fields: { content: "content" } })],
|
|
298
|
+
handler: (ctx) => saveComment(ctx.formData, ctx.spamAssessment),
|
|
277
299
|
});
|
|
278
300
|
```
|
|
279
301
|
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Router middleware that scores every form submission with a spam filter before the handler
|
|
3
|
+
* runs, refusing a spam verdict and publishing the assessment as `ctx.spamAssessment`, so a
|
|
4
|
+
* handler only saves what it received and holds the `unsure` ones.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import type { Middleware, RequestContext } from "remix/router";
|
|
10
|
+
import type { Submission } from "./check.js";
|
|
11
|
+
import type { SpamFilter } from "./filter.js";
|
|
12
|
+
/**
|
|
13
|
+
* Declared in an imported module, so a project types `ctx.spamAssessment` by importing this
|
|
14
|
+
* middleware.
|
|
15
|
+
*/
|
|
16
|
+
declare module "remix/router" {
|
|
17
|
+
interface RequestContext {
|
|
18
|
+
/**
|
|
19
|
+
* The filter's assessment of this submission, published for every method other than GET,
|
|
20
|
+
* HEAD and OPTIONS. A `spam` verdict reaches the handler only when `onSpam` answered `null`.
|
|
21
|
+
*/
|
|
22
|
+
spamAssessment: SpamFilter.Assessment;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The submission's assessment, for code that reads it by key rather than `ctx.spamAssessment`.
|
|
27
|
+
* The type is written out because an exported key needs a nameable type to reach a declaration file.
|
|
28
|
+
*/
|
|
29
|
+
export declare const SpamAssessmentKey: {
|
|
30
|
+
defaultValue?: SpamFilter.Assessment;
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Form field names, each value read from the submitted form. A missing field or a file reads as
|
|
34
|
+
* empty, so a form that leaves an author field out scores on its content alone.
|
|
35
|
+
*/
|
|
36
|
+
export interface SpamMiddlewareFields {
|
|
37
|
+
/** What the visitor wrote; several names are scored as one text, joined by line breaks. */
|
|
38
|
+
content: string | readonly string[];
|
|
39
|
+
name?: string;
|
|
40
|
+
email?: string;
|
|
41
|
+
/** The website the author entered, as a comment form's "website" field collects it. */
|
|
42
|
+
url?: string;
|
|
43
|
+
}
|
|
44
|
+
/** Options for {@link spam}. */
|
|
45
|
+
export interface SpamMiddlewareOptions {
|
|
46
|
+
/** The names of the form fields the submission is read from. */
|
|
47
|
+
fields: SpamMiddlewareFields;
|
|
48
|
+
/** @default "text" */
|
|
49
|
+
format?: Submission["format"];
|
|
50
|
+
/** The languages the form expects, as BCP 47 tags. */
|
|
51
|
+
languages?: string[];
|
|
52
|
+
/**
|
|
53
|
+
* Answers a `spam` verdict: a `Response` refuses the request with it, `null` continues to the
|
|
54
|
+
* handler with the assessment published on `ctx.spamAssessment`.
|
|
55
|
+
*
|
|
56
|
+
* @default a plain-text 422 response
|
|
57
|
+
*/
|
|
58
|
+
onSpam?: (assessment: SpamFilter.Assessment, ctx: RequestContext) => Response | null | Promise<Response | null>;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Scores every submission with `filter`: the form fields named in `options.fields`, plus
|
|
62
|
+
* `ctx.ip`, `ctx.userAgent` and the honeypot's verified render time when their middleware ran
|
|
63
|
+
* earlier. The body stays readable; with `formData()` installed, the parsed form is reused.
|
|
64
|
+
*
|
|
65
|
+
* @param filter - The filter that scores each submission
|
|
66
|
+
* @param options - Where the submission's fields are and how a spam verdict is answered
|
|
67
|
+
* @returns The middleware
|
|
68
|
+
* @example router.post(routes.contact.action, { middleware: [spam(filter, { fields: { content: "message" } })], handler })
|
|
69
|
+
*/
|
|
70
|
+
export declare function spam(filter: SpamFilter, options: SpamMiddlewareOptions): Middleware;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { ClientIP } from "@sdxc/get-client-ip/middleware";
|
|
2
|
+
import { HoneypotOutcomeKey } from "@sdxc/honeypot/middleware";
|
|
3
|
+
import { isSuccess } from "@sdxc/result";
|
|
4
|
+
import { CurrentUserAgent } from "@sdxc/user-agent/middleware";
|
|
5
|
+
import { createContextKey } from "remix/router";
|
|
6
|
+
/**
|
|
7
|
+
* The submission's assessment, for code that reads it by key rather than `ctx.spamAssessment`.
|
|
8
|
+
* The type is written out because an exported key needs a nameable type to reach a declaration file.
|
|
9
|
+
*/
|
|
10
|
+
export const SpamAssessmentKey = createContextKey();
|
|
11
|
+
/** Methods that render a form rather than submit one, which pass through unscored. */
|
|
12
|
+
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
|
13
|
+
/** Content types a browser form submits. */
|
|
14
|
+
const FORM_CONTENT_TYPES = ["application/x-www-form-urlencoded", "multipart/form-data"];
|
|
15
|
+
/**
|
|
16
|
+
* Scores every submission with `filter`: the form fields named in `options.fields`, plus
|
|
17
|
+
* `ctx.ip`, `ctx.userAgent` and the honeypot's verified render time when their middleware ran
|
|
18
|
+
* earlier. The body stays readable; with `formData()` installed, the parsed form is reused.
|
|
19
|
+
*
|
|
20
|
+
* @param filter - The filter that scores each submission
|
|
21
|
+
* @param options - Where the submission's fields are and how a spam verdict is answered
|
|
22
|
+
* @returns The middleware
|
|
23
|
+
* @example router.post(routes.contact.action, { middleware: [spam(filter, { fields: { content: "message" } })], handler })
|
|
24
|
+
*/
|
|
25
|
+
export function spam(filter, options) {
|
|
26
|
+
return async (ctx, next) => {
|
|
27
|
+
if (SAFE_METHODS.has(ctx.request.method))
|
|
28
|
+
return next();
|
|
29
|
+
let form = (await readForm(ctx)) ?? new FormData();
|
|
30
|
+
let assessment = await filter.check({
|
|
31
|
+
content: [options.fields.content]
|
|
32
|
+
.flat()
|
|
33
|
+
.map((name) => field(form, name) ?? "")
|
|
34
|
+
.join("\n"),
|
|
35
|
+
format: options.format,
|
|
36
|
+
languages: options.languages,
|
|
37
|
+
renderedAt: renderedAt(ctx),
|
|
38
|
+
author: {
|
|
39
|
+
name: field(form, options.fields.name),
|
|
40
|
+
email: field(form, options.fields.email),
|
|
41
|
+
url: field(form, options.fields.url),
|
|
42
|
+
ip: ctx.get(ClientIP)?.toString(),
|
|
43
|
+
userAgent: ctx.get(CurrentUserAgent).raw || undefined,
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
ctx.set(SpamAssessmentKey, assessment, { property: "spamAssessment" });
|
|
47
|
+
if (assessment.verdict !== "spam")
|
|
48
|
+
return next();
|
|
49
|
+
let answer = await (options.onSpam ?? refuse)(assessment, ctx);
|
|
50
|
+
return answer ?? next();
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/** The text of the field named `name`, or `undefined` when it is unnamed, missing or a file. */
|
|
54
|
+
function field(form, name) {
|
|
55
|
+
let value = name === undefined ? null : form.get(name);
|
|
56
|
+
return typeof value === "string" ? value : undefined;
|
|
57
|
+
}
|
|
58
|
+
/** When the honeypot middleware verified this form was rendered, if it ran and passed. */
|
|
59
|
+
function renderedAt(ctx) {
|
|
60
|
+
let outcome = ctx.has(HoneypotOutcomeKey) ? ctx.get(HoneypotOutcomeKey) : undefined;
|
|
61
|
+
return outcome !== undefined && isSuccess(outcome) ? outcome.data.renderedAt : undefined;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The submitted form, or `null` when the body is not one. Reads `formData()`'s parsed form when
|
|
65
|
+
* present, and otherwise a clone of the request, leaving the original body for the handler.
|
|
66
|
+
*/
|
|
67
|
+
async function readForm(ctx) {
|
|
68
|
+
let parsed = ctx.has(FormData) ? ctx.get(FormData) : undefined;
|
|
69
|
+
if (parsed !== undefined)
|
|
70
|
+
return parsed;
|
|
71
|
+
let contentType = ctx.request.headers.get("Content-Type") ?? "";
|
|
72
|
+
if (!FORM_CONTENT_TYPES.some((type) => contentType.includes(type)))
|
|
73
|
+
return null;
|
|
74
|
+
try {
|
|
75
|
+
return await ctx.request.clone().formData();
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** The default answer to spam: a 422, since the submission was understood and refused. */
|
|
82
|
+
function refuse() {
|
|
83
|
+
return new Response("Submission refused as spam", { status: 422 });
|
|
84
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sdxc/spam",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.10.17",
|
|
4
4
|
"description": "Spam scoring for user content: local rules, reputation checks and a classifier",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -12,17 +12,21 @@
|
|
|
12
12
|
"./link-blocklist": "./dist/link-blocklist.js",
|
|
13
13
|
"./akismet": "./dist/akismet.js",
|
|
14
14
|
"./bayes": "./dist/bayes.js",
|
|
15
|
-
"./workers-ai": "./dist/workers-ai.js"
|
|
15
|
+
"./workers-ai": "./dist/workers-ai.js",
|
|
16
|
+
"./middleware": "./dist/middleware.js"
|
|
16
17
|
},
|
|
17
18
|
"dependencies": {
|
|
18
19
|
"@sdxc/doh": "2026.10.16",
|
|
19
20
|
"@sdxc/email-address": "2026.10.16",
|
|
20
|
-
"@sdxc/
|
|
21
|
+
"@sdxc/get-client-ip": "2026.10.16",
|
|
22
|
+
"@sdxc/honeypot": "2026.10.16",
|
|
23
|
+
"@sdxc/result": "2026.10.13",
|
|
24
|
+
"@sdxc/user-agent": "2026.10.17"
|
|
21
25
|
},
|
|
22
26
|
"peerDependencies": {
|
|
23
27
|
"remix": "3.0.0"
|
|
24
28
|
},
|
|
25
|
-
"gitHead": "
|
|
29
|
+
"gitHead": "4f4bce453e9afe69f53194368af9e9739d73a876",
|
|
26
30
|
"publishConfig": {
|
|
27
31
|
"access": "public"
|
|
28
32
|
},
|