@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 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 the signed render time the `timing` rule reads.
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
- async handler(ctx) {
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.16",
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/result": "2026.10.13"
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": "1e547d8431568f5ed9bdb079ca3046c936240fde",
29
+ "gitHead": "4f4bce453e9afe69f53194368af9e9739d73a876",
26
30
  "publishConfig": {
27
31
  "access": "public"
28
32
  },