@bitsocial/wordfilter-challenge 0.1.2 → 0.3.0
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/AGENTS.md +17 -2
- package/README.md +169 -31
- package/dist/apply-wordfilters.d.ts +17 -2
- package/dist/apply-wordfilters.js +42 -11
- package/dist/apply-wordfilters.js.map +1 -1
- package/dist/wordfilter-challenge.js +58 -44
- package/dist/wordfilter-challenge.js.map +1 -1
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -10,21 +10,36 @@ Reference source files for this repository:
|
|
|
10
10
|
Workflow:
|
|
11
11
|
|
|
12
12
|
- Before every commit, run `npm run typecheck` and ensure it passes with no errors.
|
|
13
|
-
- Before every commit, run `npm test` and ensure it passes.
|
|
13
|
+
- Before every commit, run `npm test` (vitest) and ensure it passes.
|
|
14
|
+
- Never push to a PR branch without first running `npm run typecheck` and `npm test` locally and seeing
|
|
15
|
+
them pass. CI runs the same checks, but a red PR is a round trip that a local run avoids.
|
|
14
16
|
|
|
15
17
|
Conventions specific to this challenge:
|
|
16
18
|
|
|
17
19
|
- `README.md` is the spec. Behaviour changes belong there first.
|
|
18
20
|
- `src/apply-wordfilters.ts` imports nothing. It runs in a browser, in a publishing client, and its
|
|
19
21
|
behaviour must stay identical to the snippet in the README, which is what most frontends will actually
|
|
20
|
-
copy. They discover the
|
|
22
|
+
copy. They discover the contract through `community.challenges[i].publicOptions` and never install this
|
|
21
23
|
package.
|
|
24
|
+
- `WORDFILTER_V1_RULES_OPTION` and `WORDFILTER_V1_FIELD_NAMES_OPTION` are the wire contract, not internal
|
|
25
|
+
names. Clients recognise `wordfilter/v1` by the presence of the rules key in a community's
|
|
26
|
+
`publicOptions`, and most of them never install this package, so renaming either string silently stops
|
|
27
|
+
every such client from filtering. A rename is a new contract version, never an edit to v1. The literals
|
|
28
|
+
are pinned in `test/wordfilter-challenge.test.ts` so this fails a test rather than a live board.
|
|
29
|
+
- The `wordfilter/v1` namespace covers exactly the options a publishing client must read. Anything a
|
|
30
|
+
client never reads, `error` today, keeps its plain name and stays free to differ between
|
|
31
|
+
implementations. This package is one implementation of the contract, not its owner.
|
|
22
32
|
- `validateChallengeSettings` is sync and does no network. It runs on every community start, so an async
|
|
23
33
|
validator would turn a third party's outage into a startup problem.
|
|
24
34
|
- `ChallengeFileFactory` never throws. It runs on load as well as on edit, so a throw fails community
|
|
25
35
|
startup rather than the offending edit.
|
|
26
36
|
- Absent publication fields pass. `publication-match` treats a missing property as a failure, which would
|
|
27
37
|
reject every vote. Do not copy that.
|
|
38
|
+
- Test fixtures are typed against pkc-js's own challenge-time publication types
|
|
39
|
+
(`GetChallengeArgsInput["challengeRequestMessage"][K]`) through the `build*` helpers in
|
|
40
|
+
`test/wordfilter-challenge.test.ts`. Do not cast a publication through `as unknown as`: that is how a
|
|
41
|
+
`content` on a commentModeration, a field no client can send, once passed as a test. Each publication
|
|
42
|
+
type gets its own test, with the shape it actually has on the wire.
|
|
28
43
|
|
|
29
44
|
Releasing:
|
|
30
45
|
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @bitsocial/wordfilter-challenge
|
|
2
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.
|
|
3
|
+
A [pkc-js](https://github.com/pkcprotocol/pkc-js) challenge that makes community wordfilters a real rule instead of a cosmetic display filter. It implements the [`wordfilter/v1` contract](#the-wordfilterv1-contract), which is what publishing clients actually code against.
|
|
4
4
|
|
|
5
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
6
|
|
|
@@ -22,28 +22,72 @@ So the replacement has to happen on the side holding the signing key, which is t
|
|
|
22
22
|
|
|
23
23
|
A client that does not implement the replacement cannot publish unfiltered text. It simply gets rejected, which is the intended failure mode.
|
|
24
24
|
|
|
25
|
+
## The `wordfilter/v1` contract
|
|
26
|
+
|
|
27
|
+
A publishing client has to recognise "this community wants word replacements applied before I sign" from the published community record alone. The record deliberately does not say which challenge produced it: `path`, `name` and `options` are all stripped when `community.settings.challenges[i]` becomes the public `community.challenges[i]`. Only `publicOptions`, the subset the owner opted into publishing, survives.
|
|
28
|
+
|
|
29
|
+
So the signal a client keys off is an option key that names a **contract** rather than a package:
|
|
30
|
+
|
|
31
|
+
| Public option | Required | Meaning |
|
|
32
|
+
|---|---|---|
|
|
33
|
+
| `wordfilter/v1/rules` | yes | JSON array of `{ src, dst }`. Its presence is what identifies the contract |
|
|
34
|
+
| `wordfilter/v1/fieldNames` | no | JSON array of dot-notation paths, each starting with the publication type. Defaults to the list under [Default fields](#default-fields) |
|
|
35
|
+
|
|
36
|
+
This package is one implementation. Anything that publishes `wordfilter/v1/rules` with the semantics below claims the contract, and a client written against it keeps working with implementations that did not exist when the client was written. Keying on a package name instead, whether through a hardcoded `@bitsocial/wordfilter-challenge` or a generic `wordfilters` option, would enshrine one implementation in every UI and lock out every fork, competitor and in-house variant that behaves identically. See [issue #1](https://github.com/bitsocialnet/wordfilter-challenge/issues/1).
|
|
37
|
+
|
|
38
|
+
The namespace covers exactly what a publishing client must read, and nothing else. `error` is not in it, because no client reads `error`: the community returns it in the rejection. An implementation is free to name or shape that option however it likes.
|
|
39
|
+
|
|
40
|
+
`wordfilter/v2/rules` would be a different key. A client that understands both reads both, and a community can publish both during a transition without either side guessing.
|
|
41
|
+
|
|
42
|
+
### Default fields
|
|
43
|
+
|
|
44
|
+
When a community does not publish `wordfilter/v1/fieldNames`, both sides check this list. Every path starts with the publication type, followed by the field's path inside that publication exactly as it is on the wire.
|
|
45
|
+
|
|
46
|
+
| Publication type | Default paths |
|
|
47
|
+
|---|---|
|
|
48
|
+
| `comment` | `comment.content`, `comment.title`, `comment.author.displayName` |
|
|
49
|
+
| `commentEdit` | `commentEdit.content`, `commentEdit.reason`, `commentEdit.author.displayName` |
|
|
50
|
+
| `vote` | `vote.author.displayName` |
|
|
51
|
+
|
|
52
|
+
The defaults cover what an ordinary author publishes. Moderator and owner text is not in them: a moderation's reason and a community edit's title and description can be filtered by naming their paths in `wordfilter/v1/fieldNames`, as `commentModeration.commentModeration.reason`, `communityEdit.communityEdit.title` and `communityEdit.communityEdit.description`. The doubled segment is the wire shape, not a typo: the publication type is `commentModeration`, and pkc-js puts the moderation's fields under a `commentModeration` property of it; likewise for `communityEdit`.
|
|
53
|
+
|
|
54
|
+
Naming the publication type in every path is deliberate. `content` on a comment and `content` on a comment edit are the same field today, but nothing guarantees they stay that way, and a bare `content` could not tell them apart. A path whose first segment is not one of the five publication types is rejected by `validateChallengeSettings`, and by `getChallenge` for a config that predates that check, because it would match nothing on any request and the wordfilter would have quietly stopped filtering.
|
|
55
|
+
|
|
56
|
+
|
|
25
57
|
## For UI client developers
|
|
26
58
|
|
|
27
|
-
This is the section that matters if you are building a frontend that publishes to communities using this
|
|
59
|
+
This is the section that matters if you are building a frontend that publishes to communities using this contract.
|
|
28
60
|
|
|
29
61
|
### 1. Read the rules off the community
|
|
30
62
|
|
|
31
|
-
|
|
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.
|
|
63
|
+
Collect the rules from every challenge on the community that publishes `wordfilter/v1/rules`, in challenge order. A community may have more than one.
|
|
34
64
|
|
|
35
65
|
```js
|
|
66
|
+
const isRule = (rule) => typeof rule?.src === "string" && typeof rule?.dst === "string";
|
|
67
|
+
const isFieldName = (name) => typeof name === "string";
|
|
68
|
+
|
|
36
69
|
function getWordfilterConfigs(community) {
|
|
37
70
|
return (community.challenges ?? [])
|
|
38
|
-
.filter((challenge) => challenge.publicOptions?.
|
|
71
|
+
.filter((challenge) => challenge.publicOptions?.["wordfilter/v1/rules"])
|
|
39
72
|
.map((challenge) => {
|
|
40
73
|
try {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
74
|
+
const rules = JSON.parse(challenge.publicOptions["wordfilter/v1/rules"]);
|
|
75
|
+
const fieldNames = challenge.publicOptions["wordfilter/v1/fieldNames"]
|
|
76
|
+
? JSON.parse(challenge.publicOptions["wordfilter/v1/fieldNames"])
|
|
77
|
+
: [
|
|
78
|
+
"comment.content",
|
|
79
|
+
"comment.title",
|
|
80
|
+
"comment.author.displayName",
|
|
81
|
+
"commentEdit.content",
|
|
82
|
+
"commentEdit.reason",
|
|
83
|
+
"commentEdit.author.displayName",
|
|
84
|
+
"vote.author.displayName"
|
|
85
|
+
];
|
|
86
|
+
// JSON.parse is happy to hand back null, an object or an array of numbers, and
|
|
87
|
+
// applyWordfilters would throw on any of them. Treat the wrong shape like bad JSON.
|
|
88
|
+
if (!Array.isArray(rules) || !rules.every(isRule)) return undefined;
|
|
89
|
+
if (!Array.isArray(fieldNames) || !fieldNames.every(isFieldName)) return undefined;
|
|
90
|
+
return { rules, fieldNames };
|
|
47
91
|
} catch {
|
|
48
92
|
// A rule set you cannot parse is skipped, never fatal. Worst case the community
|
|
49
93
|
// rejects the publication with a readable error.
|
|
@@ -54,7 +98,9 @@ function getWordfilterConfigs(community) {
|
|
|
54
98
|
}
|
|
55
99
|
```
|
|
56
100
|
|
|
57
|
-
|
|
101
|
+
Note what this function does not do: it never looks at which challenge implementation produced the options, because the record does not say and it does not need to.
|
|
102
|
+
|
|
103
|
+
Skip anything you cannot parse, and anything that parses to the wrong shape. Never throw, and never refuse to load the community: a malformed rule set must cost the filter, not the whole board. This package's own `validateChallengeSettings` will not let a community publish a malformed rule set, but the contract is open to other implementations and your client cannot know which one it is talking to.
|
|
58
104
|
|
|
59
105
|
### 2. Apply the rules, looping until the text stops changing
|
|
60
106
|
|
|
@@ -91,15 +137,75 @@ Filter the text before it reaches `pkc.createComment()`, so the transformation h
|
|
|
91
137
|
|
|
92
138
|
```js
|
|
93
139
|
const configs = getWordfilterConfigs(community);
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
140
|
+
|
|
141
|
+
// One call per field, carrying the rules of every challenge that covers that field. Do not apply the
|
|
142
|
+
// challenges one at a time: a later challenge's replacement can reintroduce an earlier challenge's
|
|
143
|
+
// src, and the earlier challenge would then reject the publication. The loop inside applyWordfilters
|
|
144
|
+
// only sees that interaction when both rule sets are in the same call.
|
|
145
|
+
const rulesFor = (fieldName) =>
|
|
146
|
+
configs.filter(({ fieldNames }) => fieldNames.includes(fieldName)).flatMap(({ rules }) => rules);
|
|
147
|
+
|
|
148
|
+
if (content) content = applyWordfilters(content, rulesFor("comment.content"));
|
|
149
|
+
if (title) title = applyWordfilters(title, rulesFor("comment.title"));
|
|
150
|
+
if (displayName) displayName = applyWordfilters(displayName, rulesFor("comment.author.displayName"));
|
|
151
|
+
|
|
152
|
+
const comment = await pkc.createComment({ content, title, author: { displayName }, communityAddress, signer });
|
|
99
153
|
await comment.publish();
|
|
100
154
|
```
|
|
101
155
|
|
|
102
|
-
|
|
156
|
+
The challenge runs on every publication type, not only new comments. The [default field list](#default-fields) also names the text of a comment edit and the display name on a vote, so the same `rulesFor` call applies before each of these, with the path prefixed by the publication type:
|
|
157
|
+
|
|
158
|
+
```js
|
|
159
|
+
// Comment edit: content and reason are top-level on the edit, displayName is under author.
|
|
160
|
+
const edit = await pkc.createCommentEdit({
|
|
161
|
+
commentCid,
|
|
162
|
+
content: applyWordfilters(newContent, rulesFor("commentEdit.content")),
|
|
163
|
+
reason: applyWordfilters(reason, rulesFor("commentEdit.reason")),
|
|
164
|
+
author: { displayName: applyWordfilters(displayName, rulesFor("commentEdit.author.displayName")) },
|
|
165
|
+
communityAddress,
|
|
166
|
+
signer
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// Vote: the only text on it is the display name.
|
|
170
|
+
const vote = await pkc.createVote({
|
|
171
|
+
commentCid,
|
|
172
|
+
vote: 1,
|
|
173
|
+
author: { displayName: applyWordfilters(displayName, rulesFor("vote.author.displayName")) },
|
|
174
|
+
communityAddress,
|
|
175
|
+
signer
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Those paths are the defaults, so they are the minimum. If a community publishes other paths in `wordfilter/v1/fieldNames`, run the same call on whatever your client puts at those paths; the community checks exactly the configured paths and nothing else, so an unfiltered custom field is a rejection just like an unfiltered display name. The paths a community is most likely to add are moderator and owner text, which `rulesFor` returns empty for unless the community names them:
|
|
181
|
+
|
|
182
|
+
```js
|
|
183
|
+
// Comment moderation: the moderator's reason lives under the publication's commentModeration property,
|
|
184
|
+
// hence the doubled segment.
|
|
185
|
+
const moderation = await pkc.createCommentModeration({
|
|
186
|
+
commentCid,
|
|
187
|
+
commentModeration: {
|
|
188
|
+
removed: true,
|
|
189
|
+
reason: applyWordfilters(reason, rulesFor("commentModeration.commentModeration.reason"))
|
|
190
|
+
},
|
|
191
|
+
communityAddress,
|
|
192
|
+
signer
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// Community edit: title and description live under the publication's communityEdit property.
|
|
196
|
+
const communityEdit = await pkc.createCommunityEdit({
|
|
197
|
+
communityEdit: {
|
|
198
|
+
title: applyWordfilters(title, rulesFor("communityEdit.communityEdit.title")),
|
|
199
|
+
description: applyWordfilters(description, rulesFor("communityEdit.communityEdit.description"))
|
|
200
|
+
},
|
|
201
|
+
communityAddress,
|
|
202
|
+
signer
|
|
203
|
+
});
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
The `wordfilter/v1/fieldNames` entries are dot-notation paths, the same convention `publication-match` uses for `propertyName`, resolved against the challenge request's publication map rather than the publication itself. So the first segment names the publication type, and a path is simply absent, and passes, on a request carrying a different type: `commentEdit.content` never sees a new comment, and `comment.content` never sees an edit.
|
|
207
|
+
|
|
208
|
+
Two challenges on the same community whose rules undo each other, `foo → bar` in one and `bar → foo` in another, cannot be caught by either challenge's `validateChallengeSettings`, which sees only its own settings. The merged call then either throws `did not stabilise` or settles on text that one of the challenges still rejects, and the community returns that challenge's `error`. Both are the right outcome: no text containing either word can satisfy both challenges, so the board is misconfigured and the owner has to fix it. Handle it the same way as any other rejection, in step 5.
|
|
103
209
|
|
|
104
210
|
### 4. Show the user what happened
|
|
105
211
|
|
|
@@ -128,8 +234,8 @@ bitsocial challenge install @bitsocial/wordfilter-challenge
|
|
|
128
234
|
```bash
|
|
129
235
|
bitsocial community edit your-community.bso \
|
|
130
236
|
'--settings.challenges[0].name' @bitsocial/wordfilter-challenge \
|
|
131
|
-
'--settings.challenges[0].options.
|
|
132
|
-
'--settings.challenges[0].publicOptions[0]'
|
|
237
|
+
'--settings.challenges[0].options.wordfilter/v1/rules' '[{"src":"cloud","dst":"butt"}]' \
|
|
238
|
+
'--settings.challenges[0].publicOptions[0]' wordfilter/v1/rules
|
|
133
239
|
```
|
|
134
240
|
|
|
135
241
|
### With pkc-js over RPC
|
|
@@ -166,49 +272,81 @@ await community.edit({
|
|
|
166
272
|
{
|
|
167
273
|
name: "@bitsocial/wordfilter-challenge",
|
|
168
274
|
options: {
|
|
169
|
-
|
|
275
|
+
"wordfilter/v1/rules": JSON.stringify([
|
|
170
276
|
{ src: "cloud", dst: "butt" },
|
|
171
277
|
{ src: "millennials", dst: "snake people" },
|
|
172
278
|
{ src: "spamword", dst: "" }
|
|
173
279
|
]),
|
|
174
|
-
fieldNames: JSON.stringify([
|
|
280
|
+
"wordfilter/v1/fieldNames": JSON.stringify([
|
|
281
|
+
"comment.content",
|
|
282
|
+
"comment.title",
|
|
283
|
+
"comment.author.displayName",
|
|
284
|
+
"commentEdit.content",
|
|
285
|
+
"commentEdit.reason",
|
|
286
|
+
"commentEdit.author.displayName",
|
|
287
|
+
"vote.author.displayName"
|
|
288
|
+
]),
|
|
175
289
|
error: "This board replaces certain words. Please repost with the replacements applied."
|
|
176
290
|
},
|
|
177
|
-
publicOptions: ["
|
|
291
|
+
publicOptions: ["wordfilter/v1/rules", "wordfilter/v1/fieldNames", "error"]
|
|
178
292
|
}
|
|
179
293
|
]
|
|
180
294
|
}
|
|
181
295
|
});
|
|
182
296
|
```
|
|
183
297
|
|
|
184
|
-
`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 `
|
|
298
|
+
`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 `wordfilter/v1/rules` is missing from `publicOptions`, so the failure surfaces when you save rather than when every author starts getting rejected.
|
|
185
299
|
|
|
186
300
|
### Options
|
|
187
301
|
|
|
188
302
|
| Option | Required | Must be in `publicOptions` | Description |
|
|
189
303
|
|---|---|---|---|
|
|
190
|
-
| `
|
|
191
|
-
| `fieldNames` | no | when set | JSON array of dot-notation paths. Defaults to
|
|
304
|
+
| `wordfilter/v1/rules` | yes | yes | JSON array of `{ src, dst }`, applied in array order, cascading |
|
|
305
|
+
| `wordfilter/v1/fieldNames` | no | when set | JSON array of dot-notation paths, each starting with the publication type. Defaults to the list under [Default fields](#default-fields) |
|
|
192
306
|
| `error` | no | owner's call | Message shown to the author when a publication is rejected |
|
|
193
307
|
|
|
194
|
-
An option has to be public when a client cannot satisfy the challenge without reading it. That covers `
|
|
308
|
+
An option has to be public when a client cannot satisfy the challenge without reading it. That covers `wordfilter/v1/rules` always, and `wordfilter/v1/fieldNames` whenever the owner sets it: a client filtering the default fields cannot satisfy a community checking a different set, and nothing in the published record would explain the rejections. Those two are also exactly the options the contract namespaces, for the same reason. `error` is returned in the rejection itself, so publishing it is transparency rather than a requirement, and it keeps its plain name.
|
|
195
309
|
|
|
196
310
|
### Validation
|
|
197
311
|
|
|
198
312
|
`validateChallengeSettings` rejects at edit time:
|
|
199
313
|
|
|
200
|
-
- `
|
|
314
|
+
- `wordfilter/v1/rules` missing from `publicOptions`, or unparseable JSON, or not an array of `{ src, dst }` strings
|
|
201
315
|
- an empty `src`
|
|
202
316
|
- `src === dst`
|
|
203
317
|
- the same `src` in more than one rule
|
|
204
318
|
- any `dst` containing any `src`
|
|
205
319
|
- more than 64 rules, or a `src` or `dst` longer than 128 characters
|
|
206
|
-
- `fieldNames`, when set, unparseable, not an array of non-empty strings, or missing from `publicOptions`
|
|
320
|
+
- `wordfilter/v1/fieldNames`, when set, unparseable, not an array of non-empty strings, containing a path whose first segment is not a publication type (`comment`, `vote`, `commentEdit`, `commentModeration`, `communityEdit`), or missing from `publicOptions`
|
|
207
321
|
|
|
208
322
|
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.
|
|
209
323
|
|
|
210
324
|
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.
|
|
211
325
|
|
|
326
|
+
## Migrating from 0.1.x and 0.2.0
|
|
327
|
+
|
|
328
|
+
0.2.0 already uses the `wordfilter/v1` option keys; only the paths inside `wordfilter/v1/fieldNames` change for it, see below. 0.1.x used bare `wordfilters` and `fieldNames` option names, which made the package rather than the contract the thing clients keyed off. Rename both, in `options` and in `publicOptions`:
|
|
329
|
+
|
|
330
|
+
| 0.1.x | Now |
|
|
331
|
+
|---|---|
|
|
332
|
+
| `wordfilters` | `wordfilter/v1/rules` |
|
|
333
|
+
| `fieldNames` | `wordfilter/v1/fieldNames` |
|
|
334
|
+
| `error` | `error`, unchanged |
|
|
335
|
+
|
|
336
|
+
The paths inside `fieldNames` change too, for 0.2.0 as much as 0.1.x. Both resolved them against the publication, so `content` meant "content on whatever was published". `wordfilter/v1/fieldNames` now resolves them against the challenge request's publication map, so every path starts with the publication type, and a bare `content` is rejected rather than silently matching nothing:
|
|
337
|
+
|
|
338
|
+
| 0.1.x / 0.2.0 path | Now |
|
|
339
|
+
|---|---|
|
|
340
|
+
| `content` | `comment.content`, and `commentEdit.content` if edits should be filtered too |
|
|
341
|
+
| `title` | `comment.title` |
|
|
342
|
+
| `author.displayName` | one entry per publication type: `comment.author.displayName`, `commentEdit.author.displayName`, `vote.author.displayName` |
|
|
343
|
+
|
|
344
|
+
If 0.1.x or 0.2.0 ran with the default field list, dropping `fieldNames` entirely and taking the new defaults is the closest equivalent. A 0.2.0 community that set `wordfilter/v1/fieldNames` with bare paths fails loudly at the next start, `validateChallengeSettings` naming the offending path, until the paths are prefixed.
|
|
345
|
+
|
|
346
|
+
There is no fallback to the old names, on purpose. A wordfilter that quietly stops filtering is the exact failure this package exists to prevent, so an unmigrated community fails loudly instead: pkc-js rejects the now-undeclared option at community start with `ERR_CHALLENGE_OPTION_NOT_DECLARED_IN_OPTION_INPUTS`, naming the offending option, and `validateChallengeSettings` rejects the edit if you try to save it.
|
|
347
|
+
|
|
348
|
+
Clients written against 0.1.x need the same rename in whatever they copied from this README.
|
|
349
|
+
|
|
212
350
|
## Matching semantics
|
|
213
351
|
|
|
214
352
|
- **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.
|
|
@@ -243,7 +381,7 @@ npm run build
|
|
|
243
381
|
| Path | What it is |
|
|
244
382
|
|---|---|
|
|
245
383
|
| `src/wordfilter-challenge.ts` | The `ChallengeFileFactory`: `optionInputs`, `getChallenge`, `validateChallengeSettings` |
|
|
246
|
-
| `src/apply-wordfilters.ts` | The client-side replacement loop. Imports nothing, so it bundles for a browser |
|
|
384
|
+
| `src/apply-wordfilters.ts` | The client-side replacement loop and the `wordfilter/v1` option keys. Imports nothing, so it bundles for a browser |
|
|
247
385
|
| `src/types.ts` | Re-exports of the pkc-js challenge types |
|
|
248
386
|
|
|
249
387
|
## License
|
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The option key carrying the rules, and the only thing a client keys off to recognise the contract.
|
|
3
|
+
*
|
|
4
|
+
* Namespaced by contract rather than by package on purpose. A client that matched on the package name,
|
|
5
|
+
* or on a generic `wordfilters` key, would lock every other implementation of the same behaviour out of
|
|
6
|
+
* every UI. Anything that publishes this key claims to implement `wordfilter/v1`, whoever wrote it.
|
|
7
|
+
*/
|
|
8
|
+
export declare const WORDFILTER_V1_RULES_OPTION = "wordfilter/v1/rules";
|
|
9
|
+
/** The option key carrying the fields to filter. Optional, and defaults to {@link DEFAULT_FIELD_NAMES}. */
|
|
10
|
+
export declare const WORDFILTER_V1_FIELD_NAMES_OPTION = "wordfilter/v1/fieldNames";
|
|
1
11
|
export interface WordfilterRule {
|
|
2
12
|
/** The literal string to look for. Never a regex, and matched case-insensitively. */
|
|
3
13
|
src: string;
|
|
@@ -16,7 +26,12 @@ export declare const escapeRegExp: (str: string) => string;
|
|
|
16
26
|
* the community checks for.
|
|
17
27
|
*
|
|
18
28
|
* @throws if the rules never stabilise. `validateChallengeSettings` rejects the rule sets that can cause
|
|
19
|
-
* this (no `dst` may contain any `src`), so a
|
|
20
|
-
* one, but a client should still not spin forever on a hostile record.
|
|
29
|
+
* this within one challenge (no `dst` may contain any `src`), so a single challenge configured through
|
|
30
|
+
* pkc-js 0.0.85+ cannot produce one, but a client should still not spin forever on a hostile record.
|
|
31
|
+
*
|
|
32
|
+
* Two challenges on the same community can undo each other, which no per-challenge validator can see.
|
|
33
|
+
* The README's client snippet merges the rules of every challenge covering a field into one call so the
|
|
34
|
+
* loop sees the interaction; the result is then either this throw or a fixed point one challenge still
|
|
35
|
+
* rejects, and either way the board is misconfigured.
|
|
21
36
|
*/
|
|
22
37
|
export declare function applyWordfilters(text: string, rules: readonly WordfilterRule[], maxPasses?: number): string;
|
|
@@ -1,13 +1,39 @@
|
|
|
1
|
-
// The client side of the
|
|
2
|
-
// happen on the side holding the signing key. This file is the loop that side has to
|
|
3
|
-
// any pkc-js import so it can be pulled into a browser bundle on its own.
|
|
1
|
+
// The client side of the `wordfilter/v1` contract. A community can only accept or reject, so the
|
|
2
|
+
// replacement has to happen on the side holding the signing key. This file is the loop that side has to
|
|
3
|
+
// run, kept free of any pkc-js import so it can be pulled into a browser bundle on its own.
|
|
4
4
|
//
|
|
5
|
-
// Most frontends will never install this package: they discover the
|
|
6
|
-
// `community.challenges[i].publicOptions` and have no reason to know which package produced it
|
|
7
|
-
// README carries this same loop as a copy-pasteable snippet for them, and that
|
|
8
|
-
// This export exists for the minority that does bundle the package, and must
|
|
9
|
-
// identical to the README's version.
|
|
10
|
-
|
|
5
|
+
// Most frontends will never install this package: they discover the contract through
|
|
6
|
+
// `community.challenges[i].publicOptions` and have no reason to know which package produced it, or that
|
|
7
|
+
// this one exists. The README carries this same loop as a copy-pasteable snippet for them, and that
|
|
8
|
+
// snippet is the contract. This export exists for the minority that does bundle the package, and must
|
|
9
|
+
// stay behaviourally identical to the README's version.
|
|
10
|
+
/**
|
|
11
|
+
* The option key carrying the rules, and the only thing a client keys off to recognise the contract.
|
|
12
|
+
*
|
|
13
|
+
* Namespaced by contract rather than by package on purpose. A client that matched on the package name,
|
|
14
|
+
* or on a generic `wordfilters` key, would lock every other implementation of the same behaviour out of
|
|
15
|
+
* every UI. Anything that publishes this key claims to implement `wordfilter/v1`, whoever wrote it.
|
|
16
|
+
*/
|
|
17
|
+
export const WORDFILTER_V1_RULES_OPTION = "wordfilter/v1/rules";
|
|
18
|
+
/** The option key carrying the fields to filter. Optional, and defaults to {@link DEFAULT_FIELD_NAMES}. */
|
|
19
|
+
export const WORDFILTER_V1_FIELD_NAMES_OPTION = "wordfilter/v1/fieldNames";
|
|
20
|
+
// The free-text fields an ordinary author publishes. Paths are rooted at the challenge request's
|
|
21
|
+
// publication map, so the first segment is the publication type (comment, vote, commentEdit,
|
|
22
|
+
// commentModeration, communityEdit) and the rest is the path inside that publication as it is on the wire.
|
|
23
|
+
// Naming the type keeps `content` on a comment and `content` on a comment edit separately addressable, in
|
|
24
|
+
// case they ever diverge. Moderator and owner text (commentModeration.commentModeration.reason,
|
|
25
|
+
// communityEdit.communityEdit.title, ...) is opt-in through wordfilter/v1/fieldNames, not default. This
|
|
26
|
+
// list is part of the wordfilter/v1 contract: a client filtering fewer fields than a community checks gets
|
|
27
|
+
// rejected, so the README client snippet carries the same list.
|
|
28
|
+
export const DEFAULT_FIELD_NAMES = [
|
|
29
|
+
"comment.content",
|
|
30
|
+
"comment.title",
|
|
31
|
+
"comment.author.displayName",
|
|
32
|
+
"commentEdit.content",
|
|
33
|
+
"commentEdit.reason",
|
|
34
|
+
"commentEdit.author.displayName",
|
|
35
|
+
"vote.author.displayName"
|
|
36
|
+
];
|
|
11
37
|
export const DEFAULT_MAX_PASSES = 8;
|
|
12
38
|
export const escapeRegExp = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
13
39
|
const applyOnce = (text, rules) => rules.reduce((acc, { src, dst }) => acc.replace(new RegExp(escapeRegExp(src), "gi"), () => dst), text);
|
|
@@ -20,8 +46,13 @@ const applyOnce = (text, rules) => rules.reduce((acc, { src, dst }) => acc.repla
|
|
|
20
46
|
* the community checks for.
|
|
21
47
|
*
|
|
22
48
|
* @throws if the rules never stabilise. `validateChallengeSettings` rejects the rule sets that can cause
|
|
23
|
-
* this (no `dst` may contain any `src`), so a
|
|
24
|
-
* one, but a client should still not spin forever on a hostile record.
|
|
49
|
+
* this within one challenge (no `dst` may contain any `src`), so a single challenge configured through
|
|
50
|
+
* pkc-js 0.0.85+ cannot produce one, but a client should still not spin forever on a hostile record.
|
|
51
|
+
*
|
|
52
|
+
* Two challenges on the same community can undo each other, which no per-challenge validator can see.
|
|
53
|
+
* The README's client snippet merges the rules of every challenge covering a field into one call so the
|
|
54
|
+
* loop sees the interaction; the result is then either this throw or a fixed point one challenge still
|
|
55
|
+
* rejects, and either way the board is misconfigured.
|
|
25
56
|
*/
|
|
26
57
|
export function applyWordfilters(text, rules, maxPasses = DEFAULT_MAX_PASSES) {
|
|
27
58
|
let out = text;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"apply-wordfilters.js","sourceRoot":"","sources":["../src/apply-wordfilters.ts"],"names":[],"mappings":"AAAA,
|
|
1
|
+
{"version":3,"file":"apply-wordfilters.js","sourceRoot":"","sources":["../src/apply-wordfilters.ts"],"names":[],"mappings":"AAAA,iGAAiG;AACjG,wGAAwG;AACxG,4FAA4F;AAC5F,EAAE;AACF,qFAAqF;AACrF,wGAAwG;AACxG,oGAAoG;AACpG,sGAAsG;AACtG,wDAAwD;AAExD;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,0BAA0B,GAAG,qBAAqB,CAAC;AAEhE,2GAA2G;AAC3G,MAAM,CAAC,MAAM,gCAAgC,GAAG,0BAA0B,CAAC;AAS3E,iGAAiG;AACjG,6FAA6F;AAC7F,2GAA2G;AAC3G,0GAA0G;AAC1G,gGAAgG;AAChG,wGAAwG;AACxG,2GAA2G;AAC3G,gEAAgE;AAChE,MAAM,CAAC,MAAM,mBAAmB,GAAsB;IACpD,iBAAiB;IACjB,eAAe;IACf,4BAA4B;IAC5B,qBAAqB;IACrB,oBAAoB;IACpB,gCAAgC;IAChC,yBAAyB;CAC1B,CAAC;AAEF,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;;;;;;;;;;;;;;;;GAgBG;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"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { DEFAULT_FIELD_NAMES } from "./apply-wordfilters.js";
|
|
1
|
+
import { DEFAULT_FIELD_NAMES, WORDFILTER_V1_FIELD_NAMES_OPTION, WORDFILTER_V1_RULES_OPTION } from "./apply-wordfilters.js";
|
|
2
2
|
const MAX_RULES = 64;
|
|
3
3
|
const MAX_RULE_STRING_LENGTH = 128;
|
|
4
4
|
const DEFAULT_ERROR = "This community replaces certain words. Please repost with the replacements applied.";
|
|
@@ -6,21 +6,24 @@ const description = "Publications must already have the community's word replace
|
|
|
6
6
|
const type = "text/plain";
|
|
7
7
|
const optionInputs = [
|
|
8
8
|
{
|
|
9
|
-
option:
|
|
9
|
+
option: WORDFILTER_V1_RULES_OPTION,
|
|
10
10
|
label: "Wordfilters",
|
|
11
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.",
|
|
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. The key names the wordfilter/v1 contract rather than this package, so a client written against it works with any implementation.",
|
|
13
13
|
placeholder: '[{"src":"cloud","dst":"butt"},{"src":"spamword","dst":""}]',
|
|
14
14
|
required: true
|
|
15
15
|
},
|
|
16
16
|
{
|
|
17
|
-
option:
|
|
17
|
+
option: WORDFILTER_V1_FIELD_NAMES_OPTION,
|
|
18
18
|
label: "Field Names",
|
|
19
19
|
default: JSON.stringify(DEFAULT_FIELD_NAMES),
|
|
20
|
-
description: "JSON array of dot-notation paths
|
|
21
|
-
placeholder:
|
|
20
|
+
description: "JSON array of dot-notation paths to check, each starting with the publication type: comment.content, commentEdit.reason, commentModeration.commentModeration.reason and so on. Must be listed in publicOptions when set. Defaults to the text of comments and comment edits plus author.displayName on comments, comment edits and votes.",
|
|
21
|
+
placeholder: JSON.stringify(DEFAULT_FIELD_NAMES)
|
|
22
22
|
},
|
|
23
23
|
{
|
|
24
|
+
// Not namespaced, unlike the two above: the wordfilter/v1 namespace covers exactly what a publishing
|
|
25
|
+
// client has to read, and no client ever reads this. It comes back in the rejection itself, so an
|
|
26
|
+
// implementation is free to name or shape it differently.
|
|
24
27
|
option: "error",
|
|
25
28
|
label: "Error",
|
|
26
29
|
default: DEFAULT_ERROR,
|
|
@@ -35,17 +38,20 @@ const publicationFieldNames = [
|
|
|
35
38
|
"commentModeration",
|
|
36
39
|
"communityEdit"
|
|
37
40
|
];
|
|
38
|
-
|
|
41
|
+
// The object field paths are resolved against: the challenge request's publication map, keyed by type. A
|
|
42
|
+
// request carries exactly one of these, so `comment.content` is absent, and passes, on a comment edit.
|
|
43
|
+
const derivePublicationsFromChallengeRequest = (challengeRequestMessage) => {
|
|
44
|
+
const publications = {};
|
|
39
45
|
for (const fieldName of publicationFieldNames) {
|
|
40
46
|
const publication = challengeRequestMessage[fieldName];
|
|
41
47
|
if (publication) {
|
|
42
|
-
|
|
48
|
+
publications[fieldName] = publication;
|
|
43
49
|
}
|
|
44
50
|
}
|
|
45
|
-
return undefined;
|
|
51
|
+
return Object.keys(publications).length ? publications : undefined;
|
|
46
52
|
};
|
|
47
|
-
// Dot-notation lookup, the same convention publication-match uses for `propertyName`,
|
|
48
|
-
//
|
|
53
|
+
// Dot-notation lookup, the same convention publication-match uses for `propertyName`, except that the
|
|
54
|
+
// first segment names the publication type.
|
|
49
55
|
const getValueAtPath = (publication, path) => {
|
|
50
56
|
let current = publication;
|
|
51
57
|
for (const segment of path.split(".")) {
|
|
@@ -56,31 +62,31 @@ const getValueAtPath = (publication, path) => {
|
|
|
56
62
|
}
|
|
57
63
|
return current;
|
|
58
64
|
};
|
|
59
|
-
const parseRules = (
|
|
65
|
+
const parseRules = (rules) => {
|
|
60
66
|
// Only an absent option means "no rules". An empty string satisfies core's `required` check but is not
|
|
61
67
|
// valid JSON, so it falls through to the parse error rather than silently disabling the filter.
|
|
62
|
-
if (
|
|
68
|
+
if (rules === undefined)
|
|
63
69
|
return [];
|
|
64
70
|
let parsed;
|
|
65
71
|
try {
|
|
66
|
-
parsed = JSON.parse(
|
|
72
|
+
parsed = JSON.parse(rules);
|
|
67
73
|
}
|
|
68
74
|
catch (e) {
|
|
69
|
-
throw new Error(
|
|
75
|
+
throw new Error(`${WORDFILTER_V1_RULES_OPTION} is not valid JSON: ${e.message}`);
|
|
70
76
|
}
|
|
71
77
|
if (!Array.isArray(parsed)) {
|
|
72
|
-
throw new Error(
|
|
78
|
+
throw new Error(`${WORDFILTER_V1_RULES_OPTION} must be a JSON array of {src, dst} objects`);
|
|
73
79
|
}
|
|
74
80
|
return parsed.map((rule, index) => {
|
|
75
81
|
if (typeof rule !== "object" || rule === null || Array.isArray(rule)) {
|
|
76
|
-
throw new Error(
|
|
82
|
+
throw new Error(`${WORDFILTER_V1_RULES_OPTION}[${index}] must be an object with src and dst`);
|
|
77
83
|
}
|
|
78
84
|
const { src, dst } = rule;
|
|
79
85
|
if (typeof src !== "string") {
|
|
80
|
-
throw new Error(
|
|
86
|
+
throw new Error(`${WORDFILTER_V1_RULES_OPTION}[${index}].src must be a string`);
|
|
81
87
|
}
|
|
82
88
|
if (typeof dst !== "string") {
|
|
83
|
-
throw new Error(
|
|
89
|
+
throw new Error(`${WORDFILTER_V1_RULES_OPTION}[${index}].dst must be a string`);
|
|
84
90
|
}
|
|
85
91
|
return { src, dst };
|
|
86
92
|
});
|
|
@@ -93,21 +99,28 @@ const parseFieldNames = (fieldNames) => {
|
|
|
93
99
|
parsed = JSON.parse(fieldNames);
|
|
94
100
|
}
|
|
95
101
|
catch (e) {
|
|
96
|
-
throw new Error(
|
|
102
|
+
throw new Error(`${WORDFILTER_V1_FIELD_NAMES_OPTION} is not valid JSON: ${e.message}`);
|
|
97
103
|
}
|
|
98
104
|
if (!Array.isArray(parsed)) {
|
|
99
|
-
throw new Error(
|
|
105
|
+
throw new Error(`${WORDFILTER_V1_FIELD_NAMES_OPTION} must be a JSON array of dot-notation property paths`);
|
|
100
106
|
}
|
|
101
107
|
return parsed.map((fieldName, index) => {
|
|
102
108
|
if (typeof fieldName !== "string" || !fieldName) {
|
|
103
|
-
throw new Error(
|
|
109
|
+
throw new Error(`${WORDFILTER_V1_FIELD_NAMES_OPTION}[${index}] must be a non-empty string`);
|
|
110
|
+
}
|
|
111
|
+
// A path that names no publication type resolves to nothing on every request, which is a wordfilter
|
|
112
|
+
// that has quietly stopped filtering. That is the failure this package exists to prevent, so it is
|
|
113
|
+
// rejected here and, for a config that predates this check, by getChallenge.
|
|
114
|
+
const publicationType = fieldName.split(".")[0] ?? "";
|
|
115
|
+
if (!publicationFieldNames.includes(publicationType)) {
|
|
116
|
+
throw new Error(`${WORDFILTER_V1_FIELD_NAMES_OPTION}[${index}] must start with a publication type (${publicationFieldNames.join(", ")}), got "${fieldName}"`);
|
|
104
117
|
}
|
|
105
118
|
return fieldName;
|
|
106
119
|
});
|
|
107
120
|
};
|
|
108
121
|
const getChallenge = async ({ challengeSettings, challengeRequestMessage }) => {
|
|
109
|
-
const
|
|
110
|
-
if (!
|
|
122
|
+
const publications = derivePublicationsFromChallengeRequest(challengeRequestMessage);
|
|
123
|
+
if (!publications) {
|
|
111
124
|
return { success: false, error: "Could not derive publication from challenge request." };
|
|
112
125
|
}
|
|
113
126
|
// validateChallengeSettings rejects an unparseable config at edit time, so reaching this catch means a
|
|
@@ -116,8 +129,8 @@ const getChallenge = async ({ challengeSettings, challengeRequestMessage }) => {
|
|
|
116
129
|
let rules;
|
|
117
130
|
let fieldNames;
|
|
118
131
|
try {
|
|
119
|
-
rules = parseRules(challengeSettings?.options?.[
|
|
120
|
-
fieldNames = parseFieldNames(challengeSettings?.options?.[
|
|
132
|
+
rules = parseRules(challengeSettings?.options?.[WORDFILTER_V1_RULES_OPTION]);
|
|
133
|
+
fieldNames = parseFieldNames(challengeSettings?.options?.[WORDFILTER_V1_FIELD_NAMES_OPTION]);
|
|
121
134
|
}
|
|
122
135
|
catch (e) {
|
|
123
136
|
return { success: false, error: `Invalid wordfilter challenge settings: ${e.message}` };
|
|
@@ -127,9 +140,10 @@ const getChallenge = async ({ challengeSettings, challengeRequestMessage }) => {
|
|
|
127
140
|
}
|
|
128
141
|
const error = challengeSettings?.options?.["error"] || DEFAULT_ERROR;
|
|
129
142
|
for (const fieldName of fieldNames) {
|
|
130
|
-
const value = getValueAtPath(
|
|
131
|
-
// Absent fields pass cleanly. A vote has no content, and
|
|
132
|
-
//
|
|
143
|
+
const value = getValueAtPath(publications, fieldName);
|
|
144
|
+
// Absent fields pass cleanly. A vote has no content, and every path that names another publication
|
|
145
|
+
// type is absent on this request. Neither is a failure. publication-match treats a missing property as
|
|
146
|
+
// a failure, which would reject every vote. Do not copy that.
|
|
133
147
|
if (typeof value !== "string") {
|
|
134
148
|
continue;
|
|
135
149
|
}
|
|
@@ -147,32 +161,32 @@ const getChallenge = async ({ challengeSettings, challengeRequestMessage }) => {
|
|
|
147
161
|
const validateChallengeSettings = ({ challengeSettings }) => {
|
|
148
162
|
// A client that cannot read the rules cannot satisfy them, so publication is required rather than the
|
|
149
163
|
// owner's call. See "Require publication when clients need to read it" in challenge-authoring.md.
|
|
150
|
-
if (!challengeSettings.publicOptions?.includes(
|
|
151
|
-
throw new Error(
|
|
164
|
+
if (!challengeSettings.publicOptions?.includes(WORDFILTER_V1_RULES_OPTION)) {
|
|
165
|
+
throw new Error(`${WORDFILTER_V1_RULES_OPTION} must be listed in publicOptions: publishing clients apply the replacements before signing, and a client that cannot read the rules cannot satisfy them`);
|
|
152
166
|
}
|
|
153
|
-
const rules = parseRules(challengeSettings.options?.[
|
|
167
|
+
const rules = parseRules(challengeSettings.options?.[WORDFILTER_V1_RULES_OPTION]);
|
|
154
168
|
if (rules.length > MAX_RULES) {
|
|
155
|
-
throw new Error(
|
|
169
|
+
throw new Error(`${WORDFILTER_V1_RULES_OPTION} has ${rules.length} rules, the maximum is ${MAX_RULES}`);
|
|
156
170
|
}
|
|
157
171
|
const seenSources = new Map();
|
|
158
172
|
for (const [index, { src, dst }] of rules.entries()) {
|
|
159
173
|
if (!src) {
|
|
160
|
-
throw new Error(
|
|
174
|
+
throw new Error(`${WORDFILTER_V1_RULES_OPTION}[${index}].src must not be empty`);
|
|
161
175
|
}
|
|
162
176
|
if (src.length > MAX_RULE_STRING_LENGTH) {
|
|
163
|
-
throw new Error(
|
|
177
|
+
throw new Error(`${WORDFILTER_V1_RULES_OPTION}[${index}].src is ${src.length} characters, the maximum is ${MAX_RULE_STRING_LENGTH}`);
|
|
164
178
|
}
|
|
165
179
|
if (dst.length > MAX_RULE_STRING_LENGTH) {
|
|
166
|
-
throw new Error(
|
|
180
|
+
throw new Error(`${WORDFILTER_V1_RULES_OPTION}[${index}].dst is ${dst.length} characters, the maximum is ${MAX_RULE_STRING_LENGTH}`);
|
|
167
181
|
}
|
|
168
182
|
// Matching is case-insensitive, so "AB" and "ab" are the same rule and the same no-op.
|
|
169
183
|
const lowerCasedSrc = src.toLowerCase();
|
|
170
184
|
if (lowerCasedSrc === dst.toLowerCase()) {
|
|
171
|
-
throw new Error(
|
|
185
|
+
throw new Error(`${WORDFILTER_V1_RULES_OPTION}[${index}].src and dst are both '${src}', which replaces nothing`);
|
|
172
186
|
}
|
|
173
187
|
const firstIndex = seenSources.get(lowerCasedSrc);
|
|
174
188
|
if (firstIndex !== undefined) {
|
|
175
|
-
throw new Error(
|
|
189
|
+
throw new Error(`${WORDFILTER_V1_RULES_OPTION}[${index}].src ('${src}') is already the src of rule ${firstIndex}: the second rule can never match`);
|
|
176
190
|
}
|
|
177
191
|
seenSources.set(lowerCasedSrc, index);
|
|
178
192
|
}
|
|
@@ -183,16 +197,16 @@ const validateChallengeSettings = ({ challengeSettings }) => {
|
|
|
183
197
|
const lowerCasedDst = dst.toLowerCase();
|
|
184
198
|
for (const [otherIndex, { src }] of rules.entries()) {
|
|
185
199
|
if (lowerCasedDst.includes(src.toLowerCase())) {
|
|
186
|
-
throw new Error(
|
|
200
|
+
throw new Error(`${WORDFILTER_V1_RULES_OPTION}[${index}].dst ('${dst}') contains rule ${otherIndex}'s src ('${src}'): the replacement would itself be filtered, so no text could ever pass`);
|
|
187
201
|
}
|
|
188
202
|
}
|
|
189
203
|
}
|
|
190
|
-
// Same reasoning as
|
|
204
|
+
// Same reasoning as the rules: a client that checks the default fields while the community checks a
|
|
191
205
|
// different set rejects every author, with nothing in the record explaining why.
|
|
192
|
-
if (challengeSettings.options?.[
|
|
193
|
-
parseFieldNames(challengeSettings.options[
|
|
194
|
-
if (!challengeSettings.publicOptions?.includes(
|
|
195
|
-
throw new Error(
|
|
206
|
+
if (challengeSettings.options?.[WORDFILTER_V1_FIELD_NAMES_OPTION] !== undefined) {
|
|
207
|
+
parseFieldNames(challengeSettings.options[WORDFILTER_V1_FIELD_NAMES_OPTION]);
|
|
208
|
+
if (!challengeSettings.publicOptions?.includes(WORDFILTER_V1_FIELD_NAMES_OPTION)) {
|
|
209
|
+
throw new Error(`${WORDFILTER_V1_FIELD_NAMES_OPTION} must be listed in publicOptions when it is set: a client filtering the default fields cannot satisfy a community checking different ones`);
|
|
196
210
|
}
|
|
197
211
|
}
|
|
198
212
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"wordfilter-challenge.js","sourceRoot":"","sources":["../src/wordfilter-challenge.ts"],"names":[],"mappings":"AAQA,OAAO,
|
|
1
|
+
{"version":3,"file":"wordfilter-challenge.js","sourceRoot":"","sources":["../src/wordfilter-challenge.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bitsocial/wordfilter-challenge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Standalone wordfilter challenge for pkc-js: proof that a community's word replacements were applied before signing",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|