spamscanner 7.0.2 → 7.0.4

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.
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Parse a message with mailparser, which does not make text of HTML here:
3
+ * `text` is that of the plain text parts only (empty for a message of HTML
4
+ * alone), and the text HTML shows is read by getFeatures.
5
+ * @param {Buffer|string} raw
6
+ * @returns {Promise<object>} the parsed message
7
+ */
8
+ export function parseMessage(raw: Buffer | string): Promise<object>;
9
+ /**
10
+ * A message mailparser can finish: it never finishes one that ends in the
11
+ * header of an attached message (however that header names or folds its
12
+ * type, or only a file name like "fw.eml") without the blank line after it,
13
+ * so every message gets one.
14
+ * @param {Buffer|string} raw
15
+ * @returns {Buffer}
16
+ */
17
+ export function finishable(raw: Buffer | string): Buffer;
18
+ /**
19
+ * Parse a message, reading what can be read of one beyond mailparser's
20
+ * limits (more than 1000 MIME parts, a part's header over 1 MB): its parts
21
+ * before the 990th line that may start one (or its first half, again and
22
+ * again, when attached messages inline make the parts), and, when a header
23
+ * is too large, the message with its header lines cut down, or its first
24
+ * megabyte when the header is that of a later part. `error` is the limit,
25
+ * or null. Other errors are thrown, as earlier versions did (servers defer
26
+ * the message).
27
+ * @param {Buffer} raw
28
+ * @param {(raw: Buffer) => Promise<object>} [parse] - for tests
29
+ * @returns {Promise<{mail: object, error: string|null}>}
30
+ */
31
+ export function parseWithin(raw: Buffer, parse?: (raw: Buffer) => Promise<object>): Promise<{
32
+ mail: object;
33
+ error: string | null;
34
+ }>;
35
+ /**
36
+ * A message's attachments, with those of the messages attached to it (as a
37
+ * forwarded message is), named after the message that carries them:
38
+ * "fw.eml/invoice.exe". An attached message that cannot be read (beyond the
39
+ * parser's limits, or failing it) never fails the scan: why goes to `errors`.
40
+ * @param {Array<object>} attachments - mailparser attachments
41
+ * @param {string[]} [errors] - gets why attached messages were not read whole
42
+ * @param {(raw: Buffer) => Promise<object>} [parse] - for tests
43
+ * @returns {Promise<Array<object>>}
44
+ */
45
+ export function expandAttachments(attachments?: Array<object>, errors?: string[], parse?: (raw: Buffer) => Promise<object>): Promise<Array<object>>;
46
+ import { Buffer } from 'node:buffer';
@@ -1,11 +1,47 @@
1
+ /**
2
+ * A domain as a hostname in ASCII ("Bücher.example." is
3
+ * "xn--bcher-kva.example"), or null when it is not one: only letters, digits,
4
+ * hyphens and underscores in dot-separated labels of at most 63 characters,
5
+ * 253 in all. (A URL such as "bank.com/x.attacker.example" is not a
6
+ * hostname, though URL parsers read "bank.com" from it.)
7
+ * @param {string} value
8
+ * @returns {string|null}
9
+ */
10
+ export function asciiHostname(value: string): string | null;
1
11
  /**
2
12
  * Whether a value (IP, hostname or email address) matches a list entry. A
3
13
  * domain entry matches the domain, its subdomains and addresses at either.
14
+ *
15
+ * With `hostnamesOnly` (for an allowlist, which must trust nothing it cannot
16
+ * read), only a hostname matches a parent domain. Without it (for a
17
+ * denylist, which anything should match) any name does, such as
18
+ * "a_b!.evil.example" or one with a label of 64 characters, read from its
19
+ * last 127 labels and 253 characters, so the work is bounded however long
20
+ * the name.
4
21
  * @param {string} value
5
22
  * @param {Set<string>} list
23
+ * @param {object} [options]
24
+ * @param {boolean} [options.hostnamesOnly]
6
25
  * @returns {string|null} the entry that matched
7
26
  */
8
- export function matchList(value: string, list: Set<string>): string | null;
27
+ export function matchList(value: string, list: Set<string>, options?: {
28
+ hostnamesOnly?: boolean;
29
+ }): string | null;
30
+ /**
31
+ * Set isAllowlisted and isTruthSource of a check() result from the trusted
32
+ * values only (values that cannot be forged, see trustedReputationValues),
33
+ * in the order checked.
34
+ * @param {object} result - from ReputationChecker#check
35
+ * @param {Array<string|{value: string, signers: string[]}>} trusted -
36
+ * values, or values trusted through relaxed alignment with `signers` only
37
+ * (the aligned DKIM or SPF domains), whose entries one of them must vouch
38
+ * for
39
+ * @returns {object} a copy of the result
40
+ */
41
+ export function trustReputation(result: object, trusted?: Array<string | {
42
+ value: string;
43
+ signers: string[];
44
+ }>): object;
9
45
  /**
10
46
  * The values a message's reputation is checked by: the client IP address and
11
47
  * hostname, and the From, envelope sender and Reply-To addresses with their
@@ -15,6 +51,36 @@ export function matchList(value: string, list: Set<string>): string | null;
15
51
  * @returns {string[]}
16
52
  */
17
53
  export function reputationValues(mail?: object, session?: object): string[];
54
+ /**
55
+ * The reputation values that cannot be forged, which alone can allowlist a
56
+ * message or make it a truth source: the client IP address and its verified
57
+ * hostname (from the receiving server), a From address only when
58
+ * authentication shows its domain sent it (DMARC passes for it, or a passing
59
+ * DKIM signature is aligned with it), and the envelope sender only when SPF
60
+ * passes for its domain. Never Reply-To. A result that does not name its
61
+ * domain, or names one that is not a hostname, counts for none.
62
+ *
63
+ * Alignment is relaxed, as in DMARC (mailauth's DMARC domain is the From
64
+ * domain's registrable domain), so From values are returned as
65
+ * {value, signers}, with the aligned DKIM and SPF domains: an allowlist
66
+ * entry naming a subdomain counts only for a signer that is it, under it or
67
+ * a parent of it, of the same registrable domain. A DMARC pass for exactly
68
+ * the From domain, without alignment details, trusts it outright; one for
69
+ * its registrable domain only, registrable-domain entries. Under strict
70
+ * alignment (adkim=s, aspf=s in the domain's DMARC record) only the From
71
+ * domain itself vouches for it. A DKIM signature of part of the body (l=
72
+ * shorter than the body, mailauth's underSized) vouches for nothing, nor
73
+ * does a DMARC pass that rests on one alone, and no signature vouches for a
74
+ * From address when DMARC fails.
75
+ * @param {object} mail - parsed message
76
+ * @param {object} [session] - remoteAddress, resolvedClientHostname, envelope
77
+ * @param {object|null} [authentication] - SPF, DKIM and DMARC results
78
+ * @returns {Array<string|{value: string, signers: string[]}>}
79
+ */
80
+ export function trustedReputationValues(mail?: object, session?: object, authentication?: object | null): Array<string | {
81
+ value: string;
82
+ signers: string[];
83
+ }>;
18
84
  /**
19
85
  * Reputation lookups: local allowlists and denylists, and optionally an HTTP
20
86
  * reputation service.
@@ -24,6 +90,11 @@ export function reputationValues(mail?: object, session?: object): string[];
24
90
  * (and optional `truthSourceValue`, `allowlistValue`, `denylistValue`).
25
91
  * Values looked up: the client IP and hostname, the From, envelope sender and
26
92
  * Reply-To addresses and their domains.
93
+ *
94
+ * Any of these values can put a message on the denylist. Only values that
95
+ * cannot be forged can put it on the allowlist or make it a truth source
96
+ * (see trustedReputationValues): anyone can write any address in From or
97
+ * Reply-To.
27
98
  */
28
99
  export class ReputationChecker {
29
100
  /**
@@ -36,6 +107,8 @@ export class ReputationChecker {
36
107
  * @param {number} [options.concurrency] - parallel service requests
37
108
  * @param {number} [options.cacheTtl] - milliseconds answers are kept
38
109
  * @param {number} [options.cacheSize]
110
+ * @param {Map} [options.cache] - answers kept (shared with another checker
111
+ * of the same service)
39
112
  */
40
113
  constructor(options?: {
41
114
  allowlist?: string[];
@@ -46,6 +119,7 @@ export class ReputationChecker {
46
119
  concurrency?: number;
47
120
  cacheTtl?: number;
48
121
  cacheSize?: number;
122
+ cache?: Map<any, any>;
49
123
  });
50
124
  allowlist: Set<string>;
51
125
  denylist: Set<string>;
@@ -66,8 +140,15 @@ export class ReputationChecker {
66
140
  /**
67
141
  * Check a message's sender values against the lists and the service.
68
142
  * @param {string[]} values - IPs, hostnames, addresses
143
+ * @param {object} [options]
144
+ * @param {string[]} [options.trusted] - the values that can allowlist the
145
+ * message or make it a truth source (default: all of them); see
146
+ * trustedReputationValues
69
147
  * @returns {Promise<object>} isTruthSource, isAllowlisted, isDenylisted with
70
- * the matching values, plus checkedValues and per-value details
148
+ * the matching values, plus checkedValues, per-value details, and
149
+ * allowlisted (each value on the allowlist, with its entry)
71
150
  */
72
- check(values: string[]): Promise<object>;
151
+ check(values: string[], options?: {
152
+ trusted?: string[];
153
+ }): Promise<object>;
73
154
  }
@@ -19,7 +19,7 @@ export function bayesTestName(probability: number): string;
19
19
  * @param {object} [options.authScores] - see calculateAuthScore
20
20
  * @returns {{score: number, threshold: number, rejectThreshold: number, isSpam: boolean, action: 'accept'|'tag'|'reject', tests: Array<{name: string, score: number, description: string}>}}
21
21
  */
22
- export function scoreResults(results?: object, options?: {
22
+ export function scoreResults(results: object, options?: {
23
23
  scores?: object;
24
24
  threshold?: number;
25
25
  rejectThreshold?: number;
@@ -56,6 +56,8 @@ export namespace DEFAULT_SCORES {
56
56
  let doubleExtension: number;
57
57
  let rtlOverride: number;
58
58
  let executableInArchive: number;
59
+ let scriptInArchive: number;
60
+ let riskyAttachment: number;
59
61
  let encryptedArchive: number;
60
62
  let macro: number;
61
63
  let pdfActive: number;
@@ -63,6 +65,7 @@ export namespace DEFAULT_SCORES {
63
65
  let htmlAttachment: number;
64
66
  let activeHtmlAttachment: number;
65
67
  let virus: number;
68
+ let mimeTooComplex: number;
66
69
  let invisibleCharacters: number;
67
70
  let mixedScriptWords: number;
68
71
  let styledLetters: number;
@@ -27,7 +27,8 @@ export function sessionFromQuery(query: URLSearchParams): object;
27
27
  * - POST /learn/spam, /learn/ham → teach the classifier (only with a token)
28
28
  *
29
29
  * The request body is the raw message. Query parameters: ip, hostname, helo,
30
- * from, to (see sessionFromQuery), and for /check, subjectTag.
30
+ * from, to (see sessionFromQuery), and for /check, subjectTag. Authentication
31
+ * (SPF, DKIM, DMARC, ARC) and IP blocklists run only when ip is given.
31
32
  *
32
33
  * @param {import('./index.js').SpamScanner} scanner
33
34
  * @param {object} [options]
@@ -45,15 +46,20 @@ export function createHttpServer(scanner: import("./index.js").SpamScanner, opti
45
46
  * A plain TCP server: a client sends a raw message and closes its side; the
46
47
  * server answers with the result as one line of JSON (or a short text line)
47
48
  * and closes the connection.
49
+ *
50
+ * The client's IP address is not known, so authentication (SPF, DKIM, DMARC,
51
+ * ARC) and IP blocklists do not run for these scans.
48
52
  * @param {import('./index.js').SpamScanner} scanner
49
53
  * @param {object} [options]
50
54
  * @param {boolean} [options.json] - answer in JSON (default true)
51
55
  * @param {number} [options.maxSize]
56
+ * @param {number} [options.timeout] - close a connection that sends nothing for this many milliseconds (default 60000)
52
57
  * @returns {net.Server}
53
58
  */
54
59
  export function createTcpServer(scanner: import("./index.js").SpamScanner, options?: {
55
60
  json?: boolean;
56
61
  maxSize?: number;
62
+ timeout?: number;
57
63
  }): net.Server;
58
64
  import http from 'node:http';
59
65
  import net from 'node:net';
@@ -19,7 +19,9 @@ export function parseCsvLine(line: string): string[] | null;
19
19
  */
20
20
  export function normalizeLabel(value: unknown): "spam" | "ham" | null;
21
21
  /**
22
- * Read labelled rows from a CSV file with a header row.
22
+ * Read labelled rows from a CSV file with a header row. A quoted field may
23
+ * span lines; a record still open after 10,000 lines or 1 MiB (a stray
24
+ * quote) is dropped, and reading goes on with the next line.
23
25
  * @param {string} file
24
26
  * @param {object} [options]
25
27
  * @param {string} [options.textColumn] - column holding the message (default: text, message, body, email text...)
@@ -37,9 +39,11 @@ export function readCsv(file: string, options?: {
37
39
  label: "spam" | "ham";
38
40
  }>;
39
41
  /**
40
- * Read labelled rows from a JSON Lines file: one object per line with a text
41
- * field ("text", "message", "body") and a label field ("label", "label_text",
42
- * "is_spam", "category").
42
+ * Read labelled rows from a JSON Lines file: one object per line, with a text
43
+ * field and a label field found by the same names as readCsv's columns, in
44
+ * any case ("label_text" before "label", and "content" before "email"), the
45
+ * first of them that holds text or a label. A line that is not a JSON object
46
+ * is skipped.
43
47
  * @param {string} file
44
48
  * @param {object} [options] - textColumn, labelColumn, subjectColumn as for readCsv
45
49
  * @returns {AsyncGenerator<{text: string, subject: string, label: 'spam'|'ham'}>}
@@ -52,7 +56,8 @@ export function readJsonl(file: string, options?: object): AsyncGenerator<{
52
56
  /**
53
57
  * Read raw messages from a path: an .eml file, an mbox file, or a directory
54
58
  * of messages (one per file, such as a Maildir or a folder of .eml files,
55
- * searched recursively; mbox files inside are read too).
59
+ * searched recursively; mbox files inside are read too). A gzip file is read
60
+ * only as an mbox file (named .mbox.gz); other .gz files are skipped.
56
61
  * @param {string} source
57
62
  * @returns {AsyncGenerator<string|Buffer>}
58
63
  */
@@ -33,12 +33,16 @@ export function answer(scanner: import("./index.js").SpamScanner, request: {
33
33
  * Supports CHECK, SYMBOLS, REPORT, REPORT_IFSPAM, PROCESS, HEADERS, PING,
34
34
  * SKIP and (with allowTell) TELL. Compressed requests are refused.
35
35
  *
36
+ * spamd clients do not send the client's IP address, so authentication
37
+ * (SPF, DKIM, DMARC, ARC) and IP blocklists do not run for these scans.
38
+ *
36
39
  * @param {import('./index.js').SpamScanner} scanner
37
40
  * @param {object} [options]
38
41
  * @param {number} [options.maxSize] - largest message accepted, in bytes
39
42
  * @param {boolean} [options.allowTell] - accept TELL (learning) requests
40
43
  * @param {string} [options.modelPath] - where TELL saves the classifier
41
44
  * @param {string|null} [options.subjectTag] - tag for spam in PROCESS and HEADERS answers
45
+ * @param {number} [options.timeout] - close a connection that sends nothing for this many milliseconds (default 60000)
42
46
  * @returns {net.Server}
43
47
  */
44
48
  export function createSpamdServer(scanner: import("./index.js").SpamScanner, options?: {
@@ -46,6 +50,7 @@ export function createSpamdServer(scanner: import("./index.js").SpamScanner, opt
46
50
  allowTell?: boolean;
47
51
  modelPath?: string;
48
52
  subjectTag?: string | null;
53
+ timeout?: number;
49
54
  }): net.Server;
50
55
  export namespace SPAMD_EXIT_CODES {
51
56
  export { EX_OK };
@@ -7,6 +7,14 @@
7
7
  * @returns {RegExp}
8
8
  */
9
9
  export function characterClass(ranges: number[][], flags?: string): RegExp;
10
+ /**
11
+ * The address a link goes to, as a URL: through a security gateway, the
12
+ * address it goes on to (see gatewayTarget), as many as three wrapped in
13
+ * one another; or null for an address that is no URL.
14
+ * @param {string} address
15
+ * @returns {URL|null}
16
+ */
17
+ export function unwrapLink(address: string): URL | null;
10
18
  /**
11
19
  * Split text into pieces of at most `size` characters, at whitespace or
12
20
  * punctuation where possible.
@@ -86,37 +94,40 @@ export function obfuscationOf(text: string, options?: {
86
94
  * @returns {string}
87
95
  */
88
96
  export function registrableDomain(host: string): string;
97
+ export function extractLinks(text: any, html: any, { maxLength }?: {
98
+ maxLength?: number;
99
+ }): any[];
89
100
  /**
90
- * Find the links in plain text and HTML. For each link, the host, and for
91
- * HTML links the visible text when it is itself a web address.
92
- * @param {string} text
93
- * @param {string} html
94
- * @returns {Array<{url: string, host: string, text: string|null}>}
101
+ * The value of the first href attribute of a tag ("<a ...>"), or null:
102
+ * read attribute by attribute, as browsers read them, so an "href=" inside
103
+ * the value of another attribute is not taken for one. In one pass.
104
+ * @param {string} tag
105
+ * @returns {string|null}
95
106
  */
96
- export function extractLinks(text: string, html: string): Array<{
97
- url: string;
98
- host: string;
99
- text: string | null;
100
- }>;
107
+ export function hrefOf(tag: string): string | null;
101
108
  /**
102
109
  * The end of the tag that starts at `start` (just after its ">"), or -1 when
103
110
  * no ">" follows. A quoted attribute may hold ">" ("?a=>b"); a "<" outside
104
- * quotes is part of the tag, as browsers read it. A quote not closed within
105
- * 6000 characters is not one: then the tag ends at the next ">".
111
+ * quotes is part of the tag, as browsers read it. A quote opens a value only
112
+ * after "=" (in `href=https://a.example/"x` it is part of the value). A quote
113
+ * not closed within 6000 characters is not one: then the tag ends at the next
114
+ * ">".
106
115
  * @param {string} html
107
116
  * @param {number} start
108
117
  * @returns {number}
109
118
  */
110
119
  export function tagEnd(html: string, start: number): number;
111
120
  /**
112
- * The links of HTML: each <a> tag with an href (up to 4000 characters, in a
113
- * tag of up to 6000), and its text without tags when "</a>" follows within
114
- * 2000 characters.
121
+ * The links of HTML: each <a> tag with an href, and the text it shows (see
122
+ * visibleText: without hidden text, and here without invisible characters)
123
+ * when "</a>" or another <a> tag follows within 2000 characters (otherwise
124
+ * no text: a mail client shows a link never closed, or around a long image
125
+ * or table, all the same).
115
126
  *
116
- * Read in about one pass: a tag is read up to its end, and the next tag
117
- * looked for after it, and the closing tags are found with one search, so
118
- * the HTML is not read again for each tag. (A tag whose quote is not closed
119
- * may read up to 6000 characters, but another such tag within them needs the
127
+ * Read in about one pass: a tag is read up to its end, the tags of a link's
128
+ * text up to its "</a>", and the next <a> tag looked for after them, so the
129
+ * HTML is not read again for each tag. (A tag whose quote is not closed may
130
+ * read up to 6000 characters, but another such tag within them needs the
120
131
  * other quote mark, so at most two do.)
121
132
  * @param {string} html
122
133
  * @returns {Array<{href: string, inner: string}>}
@@ -129,8 +140,9 @@ export function anchors(html: string): Array<{
129
140
  * Host names in text that are not links: written without "http://"
130
141
  * ("paypa1-secure.example") or the domain of an email address. Only names
131
142
  * under a real public suffix count ("node.js" and "file.txt" do not), written
132
- * in lowercase as addresses are (so "end.Click" in text missing a space is
133
- * not taken for one), not the local part of an address, and at most 50.
143
+ * in lowercase as addresses are or all in capitals ("PAYPAL-VERIFY.COM", read
144
+ * in lowercase; so "end.Click" in text missing a space is not taken for one),
145
+ * not the local part of an address, and at most 50.
134
146
  * These are for lookalike checks, not classifier features.
135
147
  * @param {string} text
136
148
  * @param {Set<string>} [known] - hosts of the links already found
@@ -139,13 +151,28 @@ export function anchors(html: string): Array<{
139
151
  export function extractHosts(text: string, known?: Set<string>): string[];
140
152
  /**
141
153
  * Whether the visible text of a link names a different site than the link
142
- * goes to ("https://paypal.com" linking to another domain).
143
- * @param {{host: string, text: string|null}} link
154
+ * goes to ("https://paypal.com" linking to another domain). A link through an
155
+ * email service's click tracking (see CLICK_TRACKERS) goes elsewhere by
156
+ * design, so it counts only when the sender is known (`from`, the domain of
157
+ * the From address, or "" for none), the text shows a whole address with
158
+ * "http://" or "https://" of a brand's own domain (see BRANDS; not one that
159
+ * newsletters link to, see LINKED_BRANDS) with no path or one that asks to
160
+ * sign in or pay, and the sender is not that brand (nor one of its sending
161
+ * domains, "facebookmail.com", see SENDING_DOMAINS): a hijacked account of a mailing service
162
+ * sending "https://www.paypal.com" to a phishing page, not a newsletter's
163
+ * links. A link through a security gateway is read as the address the
164
+ * gateway goes on to, where the gateway's link holds it (see unwrapLink).
165
+ * @param {{host: string, text: string|null, url?: string}} link
166
+ * @param {object} [options]
167
+ * @param {string} [options.from] - the sender's domain
144
168
  * @returns {boolean}
145
169
  */
146
170
  export function isDeceptiveLink(link: {
147
171
  host: string;
148
172
  text: string | null;
173
+ url?: string;
174
+ }, { from }?: {
175
+ from?: string;
149
176
  }): boolean;
150
177
  /**
151
178
  * Classifier features of a message parsed by mailparser.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "spamscanner",
3
3
  "description": "Spam, phishing and malware filter for email in any language: a trainable classifier, link and attachment checks, SPF/DKIM/DMARC, and an optional AI second opinion from any LLM, including local Ollama models. Works as a library, CLI, Postfix milter or HTTP API.",
4
- "version": "7.0.2",
4
+ "version": "7.0.4",
5
5
  "author": "Forward Email (https://forwardemail.net)",
6
6
  "bin": {
7
7
  "spamscanner": "./dist/esm/cli.js"
@@ -37,6 +37,7 @@
37
37
  "devDependencies": {
38
38
  "@commitlint/cli": "^19.8.1",
39
39
  "@commitlint/config-conventional": "^19.8.1",
40
+ "@types/node": "^18.19.130",
40
41
  "c8": "^10.1.3",
41
42
  "esbuild": "^0.28.2",
42
43
  "fixpack": "^4.0.0",
@@ -56,19 +57,31 @@
56
57
  },
57
58
  "exports": {
58
59
  ".": {
59
- "types": "./dist/types/index.d.ts",
60
- "import": "./dist/esm/index.js",
61
- "require": "./dist/cjs/index.cjs"
60
+ "import": {
61
+ "types": "./dist/types/index.d.ts",
62
+ "default": "./dist/esm/index.js"
63
+ },
64
+ "require": {
65
+ "types": "./dist/types/index.d.cts",
66
+ "default": "./dist/cjs/index.cjs"
67
+ }
62
68
  },
63
69
  "./arf": {
64
- "types": "./dist/types/arf.d.ts",
65
- "import": "./dist/esm/arf.js",
66
- "require": "./dist/cjs/arf.cjs"
70
+ "import": {
71
+ "types": "./dist/types/arf.d.ts",
72
+ "default": "./dist/esm/arf.js"
73
+ },
74
+ "require": {
75
+ "types": "./dist/types/arf.d.cts",
76
+ "default": "./dist/cjs/arf.cjs"
77
+ }
67
78
  },
68
79
  "./package.json": "./package.json"
69
80
  },
70
81
  "files": [
71
82
  "dist/",
83
+ "!dist/standalone/",
84
+ "!dist/types/cli.d.ts",
72
85
  "model/"
73
86
  ],
74
87
  "homepage": "https://spamscanner.net",
@@ -114,7 +127,7 @@
114
127
  "type": "module",
115
128
  "types": "./dist/types/index.d.ts",
116
129
  "scripts": {
117
- "build": "node build.mjs && tsc --project tsconfig.json",
130
+ "build": "node build.mjs",
118
131
  "i18n:check": "node scripts/i18n-check.js",
119
132
  "lint": "fixpack && remark . -qfo && xo --fix",
120
133
  "model:train": "node scripts/train-model.js",