@bitsocial/wordfilter-challenge 0.1.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.
package/README.md ADDED
@@ -0,0 +1,250 @@
1
+ # @bitsocial/wordfilter-challenge
2
+
3
+ A [pkc-js](https://github.com/pkcprotocol/pkc-js) challenge that makes community wordfilters a real rule instead of a cosmetic display filter.
4
+
5
+ > **Status: implemented.** This README is the spec, and `src/` follows it. Tracked in [pkc-js#281](https://github.com/pkcprotocol/pkc-js/issues/281).
6
+
7
+ ## What it does
8
+
9
+ A community configures a list of replacements, for example `plebbit` becomes `bitcoin`. Publishing clients apply those replacements before signing, so the signed comment, its CID, and what every client renders all contain `bitcoin`. The original text does not exist anywhere.
10
+
11
+ The community node does not do the replacing. It only checks: if a publication still contains a filtered word, it is rejected.
12
+
13
+ ## Why clients have to do the replacing
14
+
15
+ This is the part that surprises people coming from imageboard software, so it is worth being explicit.
16
+
17
+ On a traditional imageboard the server owns the post text and rewrites it freely at submission time. [jschan](https://gitgud.io/fatchan/jschan/-/blob/master/lib/post/filteractions.js) mutates `req.body` before the insert; vichan calls `wordfilters($post['body'])` in `post.php` before `body_nomarkup` is even derived. Neither keeps the pre-filter original.
18
+
19
+ pkc-js cannot work that way. The author signs the publication, and the signature covers `content`. If the community rewrote `content` afterwards, the signature would no longer verify and every client would reject the comment as forged.
20
+
21
+ So the replacement has to happen on the side holding the signing key, which is the publishing client. The community's only available action is to accept or reject. In other words this package does not implement a wordfilter, it implements **a proof that a wordfilter was applied**.
22
+
23
+ A client that does not implement the replacement cannot publish unfiltered text. It simply gets rejected, which is the intended failure mode.
24
+
25
+ ## For UI client developers
26
+
27
+ This is the section that matters if you are building a frontend that publishes to communities using this challenge.
28
+
29
+ ### 1. Read the rules off the community
30
+
31
+ The rules are published in the community record, in the challenge's `publicOptions`. Collect them from every wordfilter challenge the community has configured, in challenge order.
32
+
33
+ The challenge's `name` is **not** in the published record: `path`, `name` and `options` are all stripped when `community.settings.challenges[i]` becomes `community.challenges[i]`. The presence of a `wordfilters` public option is the only thing a client can key off, which is exactly why the challenge requires it to be published.
34
+
35
+ ```js
36
+ function getWordfilterConfigs(community) {
37
+ return (community.challenges ?? [])
38
+ .filter((challenge) => challenge.publicOptions?.wordfilters)
39
+ .map((challenge) => {
40
+ try {
41
+ return {
42
+ rules: JSON.parse(challenge.publicOptions.wordfilters),
43
+ fieldNames: challenge.publicOptions.fieldNames
44
+ ? JSON.parse(challenge.publicOptions.fieldNames)
45
+ : ["content", "title", "author.displayName"]
46
+ };
47
+ } catch {
48
+ // A rule set you cannot parse is skipped, never fatal. Worst case the community
49
+ // rejects the publication with a readable error.
50
+ return undefined;
51
+ }
52
+ })
53
+ .filter(Boolean);
54
+ }
55
+ ```
56
+
57
+ Skip anything you cannot parse. Never throw, and never refuse to load the community: a malformed rule set must cost the filter, not the whole board.
58
+
59
+ ### 2. Apply the rules, looping until the text stops changing
60
+
61
+ **Do not apply the rules once.** A replacement can create a new match by joining with the text around it. With rules `[{src: "ab", dst: "c"}, {src: "x", dst: "b"}]`, the input `"ax"` becomes `"ab"` in a single pass, which still contains a filtered word, and the community will reject it.
62
+
63
+ Loop until the output is stable:
64
+
65
+ ```js
66
+ const escapeRegExp = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
67
+
68
+ // The `() => dst` replacer, not a plain `dst`: `String.replace` reads `$&` and friends out of a string
69
+ // replacement, and rules are literal on both sides.
70
+ const applyOnce = (text, rules) =>
71
+ rules.reduce((acc, { src, dst }) => acc.replace(new RegExp(escapeRegExp(src), "gi"), () => dst), text);
72
+
73
+ export function applyWordfilters(text, rules, maxPasses = 8) {
74
+ let out = text;
75
+ for (let pass = 0; pass < maxPasses; pass++) {
76
+ const next = applyOnce(out, rules);
77
+ if (next === out) return out;
78
+ out = next;
79
+ }
80
+ throw new Error("wordfilter rules did not stabilise");
81
+ }
82
+ ```
83
+
84
+ Stable output contains no filtered word by definition, which is exactly what the community checks for.
85
+
86
+ Copying that function is the expected path: your client sees the challenge through `community.challenges` and has no reason to depend on the package that produced it. If you do already bundle npm packages, the same function is exported as `applyWordfilters` from `@bitsocial/wordfilter-challenge`, with nothing from pkc-js behind it.
87
+
88
+ ### 3. Apply it before creating the publication
89
+
90
+ Filter the text before it reaches `pkc.createComment()`, so the transformation happens before any signature exists. Nothing needs to re-sign, and there is no window where an unfiltered draft is signed.
91
+
92
+ ```js
93
+ const configs = getWordfilterConfigs(community);
94
+ for (const { rules, fieldNames } of configs) {
95
+ if (fieldNames.includes("content") && content) content = applyWordfilters(content, rules);
96
+ if (fieldNames.includes("title") && title) title = applyWordfilters(title, rules);
97
+ }
98
+ const comment = await pkc.createComment({ content, title, communityAddress, signer });
99
+ await comment.publish();
100
+ ```
101
+
102
+ `fieldNames` entries are dot-notation paths on the publication, the same convention `publication-match` uses for `propertyName`. So `content` covers both a comment and a comment edit without the config naming publication types.
103
+
104
+ ### 4. Show the user what happened
105
+
106
+ Nothing in the protocol requires it, but the author is signing text they did not type. Showing the filtered result before publishing, or a note afterwards, avoids the surprise. This is a UX decision, not a correctness one.
107
+
108
+ ### 5. Handle rejection
109
+
110
+ Your copy of the community record can be older than the community's current settings. An owner who adds a rule starts enforcing it immediately, while your client keeps publishing against the rules it last saw.
111
+
112
+ When that happens the publication is rejected with the challenge's `error` message. Refresh the community, re-apply, and let the author retry. **Do not re-sign automatically:** that would silently publish text the author never reviewed, which is the exact failure this design exists to prevent.
113
+
114
+ ## Requirements
115
+
116
+ - pkc-js `>=0.0.85`, for `publicOptions` ([pkc-js#282](https://github.com/pkcprotocol/pkc-js/issues/282)) and `validateChallengeSettings` ([pkc-js#283](https://github.com/pkcprotocol/pkc-js/issues/283))
117
+ - Node.js `>=22`
118
+ - ESM-only environment
119
+
120
+ ## Install
121
+
122
+ ### With bitsocial-cli
123
+
124
+ ```bash
125
+ bitsocial challenge install @bitsocial/wordfilter-challenge
126
+ ```
127
+
128
+ ```bash
129
+ bitsocial community edit your-community.bso \
130
+ '--settings.challenges[0].name' @bitsocial/wordfilter-challenge \
131
+ '--settings.challenges[0].options.wordfilters' '[{"src":"plebbit","dst":"bitcoin"}]' \
132
+ '--settings.challenges[0].publicOptions[0]' wordfilters
133
+ ```
134
+
135
+ ### With pkc-js over RPC
136
+
137
+ Install the challenge on the RPC server, then set it on your community by name. Nothing has to be installed on the client side:
138
+
139
+ ```bash
140
+ bitsocial challenge install @bitsocial/wordfilter-challenge
141
+ ```
142
+
143
+ ### With pkc-js (TypeScript)
144
+
145
+ Running your own node locally, without RPC:
146
+
147
+ ```bash
148
+ npm install @bitsocial/wordfilter-challenge
149
+ ```
150
+
151
+ ```ts
152
+ import PKC from "@pkcprotocol/pkc-js";
153
+ import { wordfilterChallenge } from "@bitsocial/wordfilter-challenge";
154
+
155
+ PKC.challenges["@bitsocial/wordfilter-challenge"] = wordfilterChallenge;
156
+ ```
157
+
158
+ ## Configuration
159
+
160
+ For community owners, set in `settings.challenges`:
161
+
162
+ ```js
163
+ await community.edit({
164
+ settings: {
165
+ challenges: [
166
+ {
167
+ name: "@bitsocial/wordfilter-challenge",
168
+ options: {
169
+ wordfilters: JSON.stringify([
170
+ { src: "plebbit", dst: "bitcoin" },
171
+ { src: "spamword", dst: "" }
172
+ ]),
173
+ fieldNames: JSON.stringify(["content", "title", "author.displayName"]),
174
+ error: "This board replaces certain words. Please repost with the replacements applied."
175
+ },
176
+ publicOptions: ["wordfilters", "fieldNames", "error"]
177
+ }
178
+ ]
179
+ }
180
+ });
181
+ ```
182
+
183
+ `publicOptions` is **required**, not decorative. Options are private by default in pkc-js, and a client that cannot read the rules cannot satisfy them. The challenge's `validateChallengeSettings` hook rejects the edit if `wordfilters` is missing from `publicOptions`, so the failure surfaces when you save rather than when every author starts getting rejected.
184
+
185
+ ### Options
186
+
187
+ | Option | Required | Must be in `publicOptions` | Description |
188
+ |---|---|---|---|
189
+ | `wordfilters` | yes | yes | JSON array of `{ src, dst }`, applied in array order, cascading |
190
+ | `fieldNames` | no | when set | JSON array of dot-notation paths. Defaults to `content`, `title`, `author.displayName` |
191
+ | `error` | no | owner's call | Message shown to the author when a publication is rejected |
192
+
193
+ An option has to be public when a client cannot satisfy the challenge without reading it. That covers `wordfilters` always, and `fieldNames` whenever the owner sets it: a client filtering the three default fields cannot satisfy a community checking a different set, and nothing in the published record would explain the rejections. `error` is returned in the rejection itself, so publishing it is transparency rather than a requirement.
194
+
195
+ ### Validation
196
+
197
+ `validateChallengeSettings` rejects at edit time:
198
+
199
+ - `wordfilters` missing from `publicOptions`, or unparseable JSON, or not an array of `{ src, dst }` strings
200
+ - an empty `src`
201
+ - `src === dst`
202
+ - the same `src` in more than one rule
203
+ - any `dst` containing any `src`
204
+ - more than 64 rules, or a `src` or `dst` longer than 128 characters
205
+ - `fieldNames`, when set, unparseable, not an array of non-empty strings, or missing from `publicOptions`
206
+
207
+ The `src === dst`, duplicate `src` and `dst`-contains-`src` checks all compare case-insensitively, because matching is case-insensitive: `{src: "LOL", dst: "lol"}` replaces nothing just as surely as `{src: "lol", dst: "lol"}` does.
208
+
209
+ The `dst` containing `src` rule is what guarantees the client's loop terminates. Without it a rule like `lol` becomes `lolol` produces output that always contains a filtered word, making every post permanently unpublishable.
210
+
211
+ ## Matching semantics
212
+
213
+ - **Literal, never regex.** vichan supports arbitrary PCRE, but only because its config is a server-local file the operator wrote. Here the rules ship inside a signed record that every browser client downloads and executes, so patterns are literal strings.
214
+ - **Case-insensitive**, following vichan's `str_ireplace`. `plebbit`, `Plebbit`, and `PLEBBIT` are all replaced. Casing is not preserved: all three become `bitcoin`.
215
+ - **Cascading in array order**, and rules from multiple wordfilter challenges compose in challenge order.
216
+ - **Absent fields pass cleanly.** A vote has no `content`, and that is not a failure. (`publication-match` treats a missing property as a failure, which would reject every vote. Do not copy that.)
217
+
218
+ ## What this does not catch
219
+
220
+ Deliberate evasion. `p l e b b i t`, `plebbıt`, and zero-width-joined variants pass straight through.
221
+
222
+ That is a hard limit, not an oversight. jschan has a `strictFiltering` mode that matches against NFD-stripped, zero-width-stripped and alphanumeric-only permutations of the post, and it is necessarily **detect-only**: normalisation is not invertible, so once you have matched a normalised form you no longer know where in the original text to splice a replacement. Evasion-resistant matching and replacement are mutually exclusive.
223
+
224
+ For blocking evasive spam and slurs outright, use pkc-js's built-in `publication-match` challenge. Rejecting a publication does not require knowing where the match was. Note that `exclude` can skip a challenge for moderators or high-karma authors, which applies to this package too.
225
+
226
+ ## Prior art
227
+
228
+ - jschan, which powers soyjak.party: [`checkfilters.js`](https://gitgud.io/fatchan/jschan/-/blob/master/lib/post/checkfilters.js), [`filteractions.js`](https://gitgud.io/fatchan/jschan/-/blob/master/lib/post/filteractions.js), [`getfilterstrings.js`](https://gitgud.io/fatchan/jschan/-/blob/master/lib/post/getfilterstrings.js)
229
+ - vichan: [`wordfilters()`](https://github.com/vichan-devel/vichan/blob/master/inc/functions.php#L1798)
230
+
231
+ Both rewrite server-side at post time and keep no copy of the original.
232
+
233
+ ## Development
234
+
235
+ ```bash
236
+ npm install
237
+ npm run typecheck
238
+ npm test
239
+ npm run build
240
+ ```
241
+
242
+ | Path | What it is |
243
+ |---|---|
244
+ | `src/wordfilter-challenge.ts` | The `ChallengeFileFactory`: `optionInputs`, `getChallenge`, `validateChallengeSettings` |
245
+ | `src/apply-wordfilters.ts` | The client-side replacement loop. Imports nothing, so it bundles for a browser |
246
+ | `src/types.ts` | Re-exports of the pkc-js challenge types |
247
+
248
+ ## License
249
+
250
+ GPL-3.0-or-later, the same as pkc-js.
@@ -0,0 +1,22 @@
1
+ export interface WordfilterRule {
2
+ /** The literal string to look for. Never a regex, and matched case-insensitively. */
3
+ src: string;
4
+ /** What to replace it with. May be empty, which deletes the match. */
5
+ dst: string;
6
+ }
7
+ export declare const DEFAULT_FIELD_NAMES: readonly string[];
8
+ export declare const DEFAULT_MAX_PASSES = 8;
9
+ export declare const escapeRegExp: (str: string) => string;
10
+ /**
11
+ * Apply `rules` to `text` until the output stops changing.
12
+ *
13
+ * A single pass is not enough: a replacement can join with the text around it to create a new match,
14
+ * so `[{src: "ab", dst: "c"}, {src: "x", dst: "b"}]` turns `"ax"` into `"ab"` in one pass, which the
15
+ * community still rejects. Stable output contains no filtered word by definition, which is exactly what
16
+ * the community checks for.
17
+ *
18
+ * @throws if the rules never stabilise. `validateChallengeSettings` rejects the rule sets that can cause
19
+ * this (no `dst` may contain any `src`), so a community configured through pkc-js 0.0.85+ cannot produce
20
+ * one, but a client should still not spin forever on a hostile record.
21
+ */
22
+ export declare function applyWordfilters(text: string, rules: readonly WordfilterRule[], maxPasses?: number): string;
@@ -0,0 +1,36 @@
1
+ // The client side of the challenge. A community can only accept or reject, so the replacement has to
2
+ // happen on the side holding the signing key. This file is the loop that side has to run, kept free of
3
+ // any pkc-js import so it can be pulled into a browser bundle on its own.
4
+ //
5
+ // Most frontends will never install this package: they discover the challenge through
6
+ // `community.challenges[i].publicOptions` and have no reason to know which package produced it. The
7
+ // README carries this same loop as a copy-pasteable snippet for them, and that snippet is the contract.
8
+ // This export exists for the minority that does bundle the package, and must stay behaviourally
9
+ // identical to the README's version.
10
+ export const DEFAULT_FIELD_NAMES = ["content", "title", "author.displayName"];
11
+ export const DEFAULT_MAX_PASSES = 8;
12
+ export const escapeRegExp = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
13
+ const applyOnce = (text, rules) => rules.reduce((acc, { src, dst }) => acc.replace(new RegExp(escapeRegExp(src), "gi"), () => dst), text);
14
+ /**
15
+ * Apply `rules` to `text` until the output stops changing.
16
+ *
17
+ * A single pass is not enough: a replacement can join with the text around it to create a new match,
18
+ * so `[{src: "ab", dst: "c"}, {src: "x", dst: "b"}]` turns `"ax"` into `"ab"` in one pass, which the
19
+ * community still rejects. Stable output contains no filtered word by definition, which is exactly what
20
+ * the community checks for.
21
+ *
22
+ * @throws if the rules never stabilise. `validateChallengeSettings` rejects the rule sets that can cause
23
+ * this (no `dst` may contain any `src`), so a community configured through pkc-js 0.0.85+ cannot produce
24
+ * one, but a client should still not spin forever on a hostile record.
25
+ */
26
+ export function applyWordfilters(text, rules, maxPasses = DEFAULT_MAX_PASSES) {
27
+ let out = text;
28
+ for (let pass = 0; pass < maxPasses; pass++) {
29
+ const next = applyOnce(out, rules);
30
+ if (next === out)
31
+ return out;
32
+ out = next;
33
+ }
34
+ throw new Error("wordfilter rules did not stabilise");
35
+ }
36
+ //# sourceMappingURL=apply-wordfilters.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"apply-wordfilters.js","sourceRoot":"","sources":["../src/apply-wordfilters.ts"],"names":[],"mappings":"AAAA,qGAAqG;AACrG,uGAAuG;AACvG,0EAA0E;AAC1E,EAAE;AACF,sFAAsF;AACtF,oGAAoG;AACpG,wGAAwG;AACxG,gGAAgG;AAChG,qCAAqC;AASrC,MAAM,CAAC,MAAM,mBAAmB,GAAsB,CAAC,SAAS,EAAE,OAAO,EAAE,oBAAoB,CAAC,CAAC;AAEjG,MAAM,CAAC,MAAM,kBAAkB,GAAG,CAAC,CAAC;AAEpC,MAAM,CAAC,MAAM,YAAY,GAAG,CAAC,GAAW,EAAU,EAAE,CAAC,GAAG,CAAC,OAAO,CAAC,qBAAqB,EAAE,MAAM,CAAC,CAAC;AAEhG,MAAM,SAAS,GAAG,CAAC,IAAY,EAAE,KAAgC,EAAU,EAAE,CAC3E,KAAK,CAAC,MAAM,CACV,CAAC,GAAG,EAAE,EAAE,GAAG,EAAE,GAAG,EAAE,EAAE,EAAE,CAAC,GAAG,CAAC,OAAO,CAAC,IAAI,MAAM,CAAC,YAAY,CAAC,GAAG,CAAC,EAAE,IAAI,CAAC,EAAE,GAAG,EAAE,CAAC,GAAG,CAAC,EAClF,IAAI,CACL,CAAC;AAEJ;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,gBAAgB,CAC9B,IAAY,EACZ,KAAgC,EAChC,YAAoB,kBAAkB;IAEtC,IAAI,GAAG,GAAG,IAAI,CAAC;IACf,KAAK,IAAI,IAAI,GAAG,CAAC,EAAE,IAAI,GAAG,SAAS,EAAE,IAAI,EAAE,EAAE,CAAC;QAC5C,MAAM,IAAI,GAAG,SAAS,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;QACnC,IAAI,IAAI,KAAK,GAAG;YAAE,OAAO,GAAG,CAAC;QAC7B,GAAG,GAAG,IAAI,CAAC;IACb,CAAC;IACD,MAAM,IAAI,KAAK,CAAC,oCAAoC,CAAC,CAAC;AACxD,CAAC"}
@@ -0,0 +1,5 @@
1
+ export { default as wordfilterChallenge } from "./wordfilter-challenge.js";
2
+ export { default } from "./wordfilter-challenge.js";
3
+ export * from "./wordfilter-challenge.js";
4
+ export * from "./apply-wordfilters.js";
5
+ export * from "./types.js";
package/dist/index.js ADDED
@@ -0,0 +1,6 @@
1
+ export { default as wordfilterChallenge } from "./wordfilter-challenge.js";
2
+ export { default } from "./wordfilter-challenge.js";
3
+ export * from "./wordfilter-challenge.js";
4
+ export * from "./apply-wordfilters.js";
5
+ export * from "./types.js";
6
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,IAAI,mBAAmB,EAAE,MAAM,2BAA2B,CAAC;AAC3E,OAAO,EAAE,OAAO,EAAE,MAAM,2BAA2B,CAAC;AACpD,cAAc,2BAA2B,CAAC;AAC1C,cAAc,wBAAwB,CAAC;AACvC,cAAc,YAAY,CAAC"}
@@ -0,0 +1,6 @@
1
+ export type { ChallengeFileInput, ChallengeInput, ChallengeResultInput, GetChallengeArgsInput, CommunityChallengeSetting } from "@pkcprotocol/pkc-js/challenges";
2
+ import type { GetChallengeArgsInput } from "@pkcprotocol/pkc-js/challenges";
3
+ export type LocalCommunity = GetChallengeArgsInput["community"];
4
+ export type PKC = LocalCommunity["_pkc"];
5
+ type ChallengeRequestMessage = GetChallengeArgsInput["challengeRequestMessage"];
6
+ export type PublicationWithCommunityAuthorFromDecryptedChallengeRequest = NonNullable<ChallengeRequestMessage["vote"] | ChallengeRequestMessage["comment"] | ChallengeRequestMessage["commentEdit"] | ChallengeRequestMessage["commentModeration"] | ChallengeRequestMessage["communityEdit"]>;
package/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""}
@@ -0,0 +1,12 @@
1
+ import type { ChallengeFileInput, ChallengeResultInput, CommunityChallengeSetting, GetChallengeArgsInput } from "./types.js";
2
+ declare const description = "Publications must already have the community's word replacements applied. The community does not rewrite text, it only rejects text that still contains a filtered word.";
3
+ declare const optionInputs: NonNullable<ChallengeFileInput["optionInputs"]>;
4
+ declare const getChallenge: ({ challengeSettings, challengeRequestMessage }: GetChallengeArgsInput) => Promise<ChallengeResultInput>;
5
+ declare const validateChallengeSettings: ({ challengeSettings }: {
6
+ challengeSettings: CommunityChallengeSetting;
7
+ }) => void;
8
+ declare function ChallengeFileFactory(_args: {
9
+ challengeSettings: CommunityChallengeSetting;
10
+ }): ChallengeFileInput;
11
+ export { description, optionInputs, getChallenge, validateChallengeSettings };
12
+ export default ChallengeFileFactory;
@@ -0,0 +1,206 @@
1
+ import { DEFAULT_FIELD_NAMES } from "./apply-wordfilters.js";
2
+ const MAX_RULES = 64;
3
+ const MAX_RULE_STRING_LENGTH = 128;
4
+ const DEFAULT_ERROR = "This community replaces certain words. Please repost with the replacements applied.";
5
+ const description = "Publications must already have the community's word replacements applied. The community does not rewrite text, it only rejects text that still contains a filtered word.";
6
+ const type = "text/plain";
7
+ const optionInputs = [
8
+ {
9
+ option: "wordfilters",
10
+ label: "Wordfilters",
11
+ default: "[]",
12
+ description: "JSON array of {src, dst} replacements, applied in array order, cascading. Matched literally and case-insensitively. Must be listed in publicOptions so publishing clients can read and apply it.",
13
+ placeholder: '[{"src":"plebbit","dst":"bitcoin"},{"src":"spamword","dst":""}]',
14
+ required: true
15
+ },
16
+ {
17
+ option: "fieldNames",
18
+ label: "Field Names",
19
+ default: JSON.stringify(DEFAULT_FIELD_NAMES),
20
+ description: "JSON array of dot-notation paths on the publication to check, the same convention publication-match uses for propertyName. Must be listed in publicOptions when set. Defaults to content, title and author.displayName.",
21
+ placeholder: '["content","title","author.displayName"]'
22
+ },
23
+ {
24
+ option: "error",
25
+ label: "Error",
26
+ default: DEFAULT_ERROR,
27
+ description: "The error to display to the author when a publication still contains a filtered word.",
28
+ placeholder: DEFAULT_ERROR
29
+ }
30
+ ];
31
+ const publicationFieldNames = [
32
+ "comment",
33
+ "vote",
34
+ "commentEdit",
35
+ "commentModeration",
36
+ "communityEdit"
37
+ ];
38
+ const derivePublicationFromChallengeRequest = (challengeRequestMessage) => {
39
+ for (const fieldName of publicationFieldNames) {
40
+ const publication = challengeRequestMessage[fieldName];
41
+ if (publication) {
42
+ return publication;
43
+ }
44
+ }
45
+ return undefined;
46
+ };
47
+ // Dot-notation lookup, the same convention publication-match uses for `propertyName`, so `content`
48
+ // covers a comment and a comment edit without the config naming publication types.
49
+ const getValueAtPath = (publication, path) => {
50
+ let current = publication;
51
+ for (const segment of path.split(".")) {
52
+ if (typeof current !== "object" || current === null) {
53
+ return undefined;
54
+ }
55
+ current = current[segment];
56
+ }
57
+ return current;
58
+ };
59
+ const parseRules = (wordfilters) => {
60
+ // Only an absent option means "no rules". An empty string satisfies core's `required` check but is not
61
+ // valid JSON, so it falls through to the parse error rather than silently disabling the filter.
62
+ if (wordfilters === undefined)
63
+ return [];
64
+ let parsed;
65
+ try {
66
+ parsed = JSON.parse(wordfilters);
67
+ }
68
+ catch (e) {
69
+ throw new Error(`wordfilters is not valid JSON: ${e.message}`);
70
+ }
71
+ if (!Array.isArray(parsed)) {
72
+ throw new Error("wordfilters must be a JSON array of {src, dst} objects");
73
+ }
74
+ return parsed.map((rule, index) => {
75
+ if (typeof rule !== "object" || rule === null || Array.isArray(rule)) {
76
+ throw new Error(`wordfilters[${index}] must be an object with src and dst`);
77
+ }
78
+ const { src, dst } = rule;
79
+ if (typeof src !== "string") {
80
+ throw new Error(`wordfilters[${index}].src must be a string`);
81
+ }
82
+ if (typeof dst !== "string") {
83
+ throw new Error(`wordfilters[${index}].dst must be a string`);
84
+ }
85
+ return { src, dst };
86
+ });
87
+ };
88
+ const parseFieldNames = (fieldNames) => {
89
+ if (fieldNames === undefined)
90
+ return [...DEFAULT_FIELD_NAMES];
91
+ let parsed;
92
+ try {
93
+ parsed = JSON.parse(fieldNames);
94
+ }
95
+ catch (e) {
96
+ throw new Error(`fieldNames is not valid JSON: ${e.message}`);
97
+ }
98
+ if (!Array.isArray(parsed)) {
99
+ throw new Error("fieldNames must be a JSON array of dot-notation property paths");
100
+ }
101
+ return parsed.map((fieldName, index) => {
102
+ if (typeof fieldName !== "string" || !fieldName) {
103
+ throw new Error(`fieldNames[${index}] must be a non-empty string`);
104
+ }
105
+ return fieldName;
106
+ });
107
+ };
108
+ const getChallenge = async ({ challengeSettings, challengeRequestMessage }) => {
109
+ const publication = derivePublicationFromChallengeRequest(challengeRequestMessage);
110
+ if (!publication) {
111
+ return { success: false, error: "Could not derive publication from challenge request." };
112
+ }
113
+ // validateChallengeSettings rejects an unparseable config at edit time, so reaching this catch means a
114
+ // config that predates the hook. Fail loudly rather than passing everything: a wordfilter that silently
115
+ // stops filtering is worse than one that visibly breaks.
116
+ let rules;
117
+ let fieldNames;
118
+ try {
119
+ rules = parseRules(challengeSettings?.options?.["wordfilters"]);
120
+ fieldNames = parseFieldNames(challengeSettings?.options?.["fieldNames"]);
121
+ }
122
+ catch (e) {
123
+ return { success: false, error: `Invalid wordfilter challenge settings: ${e.message}` };
124
+ }
125
+ if (!rules.length) {
126
+ return { success: true };
127
+ }
128
+ const error = challengeSettings?.options?.["error"] || DEFAULT_ERROR;
129
+ for (const fieldName of fieldNames) {
130
+ const value = getValueAtPath(publication, fieldName);
131
+ // Absent fields pass cleanly. A vote has no content, and that is not a failure. publication-match
132
+ // treats a missing property as a failure, which would reject every vote. Do not copy that.
133
+ if (typeof value !== "string") {
134
+ continue;
135
+ }
136
+ const lowerCasedValue = value.toLowerCase();
137
+ for (const rule of rules) {
138
+ if (lowerCasedValue.includes(rule.src.toLowerCase())) {
139
+ return { success: false, error };
140
+ }
141
+ }
142
+ }
143
+ return { success: true };
144
+ };
145
+ // Everything here is a mistake that would otherwise surface as every author being rejected, long after the
146
+ // owner saved the config. Sync and no network, per docs/protocol/challenge-authoring.md.
147
+ const validateChallengeSettings = ({ challengeSettings }) => {
148
+ // A client that cannot read the rules cannot satisfy them, so publication is required rather than the
149
+ // owner's call. See "Require publication when clients need to read it" in challenge-authoring.md.
150
+ if (!challengeSettings.publicOptions?.includes("wordfilters")) {
151
+ throw new Error("wordfilters must be listed in publicOptions: publishing clients apply the replacements before signing, and a client that cannot read the rules cannot satisfy them");
152
+ }
153
+ const rules = parseRules(challengeSettings.options?.["wordfilters"]);
154
+ if (rules.length > MAX_RULES) {
155
+ throw new Error(`wordfilters has ${rules.length} rules, the maximum is ${MAX_RULES}`);
156
+ }
157
+ const seenSources = new Map();
158
+ for (const [index, { src, dst }] of rules.entries()) {
159
+ if (!src) {
160
+ throw new Error(`wordfilters[${index}].src must not be empty`);
161
+ }
162
+ if (src.length > MAX_RULE_STRING_LENGTH) {
163
+ throw new Error(`wordfilters[${index}].src is ${src.length} characters, the maximum is ${MAX_RULE_STRING_LENGTH}`);
164
+ }
165
+ if (dst.length > MAX_RULE_STRING_LENGTH) {
166
+ throw new Error(`wordfilters[${index}].dst is ${dst.length} characters, the maximum is ${MAX_RULE_STRING_LENGTH}`);
167
+ }
168
+ // Matching is case-insensitive, so "AB" and "ab" are the same rule and the same no-op.
169
+ const lowerCasedSrc = src.toLowerCase();
170
+ if (lowerCasedSrc === dst.toLowerCase()) {
171
+ throw new Error(`wordfilters[${index}].src and dst are both '${src}', which replaces nothing`);
172
+ }
173
+ const firstIndex = seenSources.get(lowerCasedSrc);
174
+ if (firstIndex !== undefined) {
175
+ throw new Error(`wordfilters[${index}].src ('${src}') is already the src of wordfilters[${firstIndex}]: the second rule can never match`);
176
+ }
177
+ seenSources.set(lowerCasedSrc, index);
178
+ }
179
+ // The rule that guarantees the client's replacement loop terminates. Without it a rule like
180
+ // lol -> lolol produces output that always contains a filtered word, making every post permanently
181
+ // unpublishable. Cross-rule, because rule A's dst is fed to rule B on the next pass.
182
+ for (const [index, { dst }] of rules.entries()) {
183
+ const lowerCasedDst = dst.toLowerCase();
184
+ for (const [otherIndex, { src }] of rules.entries()) {
185
+ if (lowerCasedDst.includes(src.toLowerCase())) {
186
+ throw new Error(`wordfilters[${index}].dst ('${dst}') contains wordfilters[${otherIndex}].src ('${src}'): the replacement would itself be filtered, so no text could ever pass`);
187
+ }
188
+ }
189
+ }
190
+ // Same reasoning as wordfilters: a client that checks the default fields while the community checks a
191
+ // different set rejects every author, with nothing in the record explaining why.
192
+ if (challengeSettings.options?.["fieldNames"] !== undefined) {
193
+ parseFieldNames(challengeSettings.options["fieldNames"]);
194
+ if (!challengeSettings.publicOptions?.includes("fieldNames")) {
195
+ throw new Error("fieldNames must be listed in publicOptions when it is set: a client filtering the default fields cannot satisfy a community checking different ones");
196
+ }
197
+ }
198
+ };
199
+ function ChallengeFileFactory(_args) {
200
+ // Deliberately cheap and non-throwing: the factory runs on load as well as on edit, so throwing here
201
+ // would fail community startup rather than the offending edit.
202
+ return { getChallenge, optionInputs, type, description, validateChallengeSettings };
203
+ }
204
+ export { description, optionInputs, getChallenge, validateChallengeSettings };
205
+ export default ChallengeFileFactory;
206
+ //# sourceMappingURL=wordfilter-challenge.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"wordfilter-challenge.js","sourceRoot":"","sources":["../src/wordfilter-challenge.ts"],"names":[],"mappings":"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"}