@thomasfosterau/pulpit 0.0.0 → 0.1.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/LICENSE +21 -0
- package/README.md +1271 -1
- package/dist/blobs.d.ts +249 -0
- package/dist/blobs.d.ts.map +1 -0
- package/dist/blobs.js +193 -0
- package/dist/blobs.js.map +1 -0
- package/dist/budgets.d.ts +255 -0
- package/dist/budgets.d.ts.map +1 -0
- package/dist/budgets.js +211 -0
- package/dist/budgets.js.map +1 -0
- package/dist/cache.d.ts +154 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +118 -0
- package/dist/cache.js.map +1 -0
- package/dist/collections.d.ts +242 -0
- package/dist/collections.d.ts.map +1 -0
- package/dist/collections.js +215 -0
- package/dist/collections.js.map +1 -0
- package/dist/comments.d.ts +212 -0
- package/dist/comments.d.ts.map +1 -0
- package/dist/comments.js +192 -0
- package/dist/comments.js.map +1 -0
- package/dist/content.d.ts +231 -0
- package/dist/content.d.ts.map +1 -0
- package/dist/content.js +204 -0
- package/dist/content.js.map +1 -0
- package/dist/documents.d.ts +184 -0
- package/dist/documents.d.ts.map +1 -0
- package/dist/documents.js +190 -0
- package/dist/documents.js.map +1 -0
- package/dist/edges.d.ts +645 -0
- package/dist/edges.d.ts.map +1 -0
- package/dist/edges.js +558 -0
- package/dist/edges.js.map +1 -0
- package/dist/filter.d.ts +461 -0
- package/dist/filter.d.ts.map +1 -0
- package/dist/filter.js +387 -0
- package/dist/filter.js.map +1 -0
- package/dist/index.d.ts +133 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +133 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/filter-columns.d.ts +8 -0
- package/dist/internal/filter-columns.d.ts.map +1 -0
- package/dist/internal/filter-columns.js +43 -0
- package/dist/internal/filter-columns.js.map +1 -0
- package/dist/internal/list-order.d.ts +26 -0
- package/dist/internal/list-order.d.ts.map +1 -0
- package/dist/internal/list-order.js +92 -0
- package/dist/internal/list-order.js.map +1 -0
- package/dist/internal/owned-columns.d.ts +93 -0
- package/dist/internal/owned-columns.d.ts.map +1 -0
- package/dist/internal/owned-columns.js +98 -0
- package/dist/internal/owned-columns.js.map +1 -0
- package/dist/internal/plain-rows.d.ts +32 -0
- package/dist/internal/plain-rows.d.ts.map +1 -0
- package/dist/internal/plain-rows.js +32 -0
- package/dist/internal/plain-rows.js.map +1 -0
- package/dist/internal/reference-columns.d.ts +80 -0
- package/dist/internal/reference-columns.d.ts.map +1 -0
- package/dist/internal/reference-columns.js +102 -0
- package/dist/internal/reference-columns.js.map +1 -0
- package/dist/moderation.d.ts +392 -0
- package/dist/moderation.d.ts.map +1 -0
- package/dist/moderation.js +375 -0
- package/dist/moderation.js.map +1 -0
- package/dist/paths.d.ts +204 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +165 -0
- package/dist/paths.js.map +1 -0
- package/dist/proposals.d.ts +523 -0
- package/dist/proposals.d.ts.map +1 -0
- package/dist/proposals.js +505 -0
- package/dist/proposals.js.map +1 -0
- package/dist/publication.d.ts +148 -0
- package/dist/publication.d.ts.map +1 -0
- package/dist/publication.js +183 -0
- package/dist/publication.js.map +1 -0
- package/dist/purge.d.ts +224 -0
- package/dist/purge.d.ts.map +1 -0
- package/dist/purge.js +151 -0
- package/dist/purge.js.map +1 -0
- package/dist/reactions.d.ts +246 -0
- package/dist/reactions.d.ts.map +1 -0
- package/dist/reactions.js +183 -0
- package/dist/reactions.js.map +1 -0
- package/dist/registry.d.ts +316 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +305 -0
- package/dist/registry.js.map +1 -0
- package/dist/search.d.ts +366 -0
- package/dist/search.d.ts.map +1 -0
- package/dist/search.js +273 -0
- package/dist/search.js.map +1 -0
- package/dist/social.d.ts +311 -0
- package/dist/social.d.ts.map +1 -0
- package/dist/social.js +205 -0
- package/dist/social.js.map +1 -0
- package/dist/subtype.d.ts +1613 -0
- package/dist/subtype.d.ts.map +1 -0
- package/dist/subtype.js +1300 -0
- package/dist/subtype.js.map +1 -0
- package/dist/testing.d.ts +675 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +1022 -0
- package/dist/testing.js.map +1 -0
- package/dist/version-chain.d.ts +702 -0
- package/dist/version-chain.d.ts.map +1 -0
- package/dist/version-chain.js +489 -0
- package/dist/version-chain.js.map +1 -0
- package/dist/working-copy.d.ts +116 -0
- package/dist/working-copy.d.ts.map +1 -0
- package/dist/working-copy.js +95 -0
- package/dist/working-copy.js.map +1 -0
- package/package.json +138 -4
- package/src/blobs.ts +384 -0
- package/src/budgets.ts +338 -0
- package/src/cache.ts +218 -0
- package/src/collections.ts +427 -0
- package/src/comments.ts +392 -0
- package/src/content.ts +238 -0
- package/src/documents.ts +284 -0
- package/src/edges.ts +1185 -0
- package/src/filter.ts +523 -0
- package/src/index.ts +153 -0
- package/src/internal/filter-columns.ts +50 -0
- package/src/internal/list-order.ts +117 -0
- package/src/internal/owned-columns.ts +140 -0
- package/src/internal/plain-rows.ts +54 -0
- package/src/internal/reference-columns.ts +156 -0
- package/src/moderation.ts +728 -0
- package/src/paths.ts +267 -0
- package/src/proposals.ts +1014 -0
- package/src/publication.ts +225 -0
- package/src/purge.ts +313 -0
- package/src/reactions.ts +347 -0
- package/src/registry.ts +524 -0
- package/src/search.ts +540 -0
- package/src/social.ts +469 -0
- package/src/subtype.ts +3095 -0
- package/src/testing.ts +1873 -0
- package/src/version-chain.ts +1093 -0
- package/src/working-copy.ts +192 -0
|
@@ -0,0 +1,728 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* **The moderation workflow** — the rules that move a leaf's row between
|
|
3
|
+
* moderation states, the threshold grading of a classifier's scores, the
|
|
4
|
+
* review-queue filter, and member reports.
|
|
5
|
+
*
|
|
6
|
+
* The behavioural spec is gardeneria's comment moderation
|
|
7
|
+
* (`apps/api/src/operations/comments.ts`' `moderateCommentE`), its moderation
|
|
8
|
+
* plane (`apps/api/src/operations/moderation.ts`) and its threshold grader
|
|
9
|
+
* (`packages/jobs/src/moderation/thresholds.ts`), with their tests; a
|
|
10
|
+
* `#NNN` here names a gardeneria issue.
|
|
11
|
+
*
|
|
12
|
+
* ## The model (owner decisions on pulpit#51)
|
|
13
|
+
*
|
|
14
|
+
* The kernel owns the rules; the transport (a queue, the classifier run, the
|
|
15
|
+
* API-call ledger, moderator notifications) stays the application's.
|
|
16
|
+
*
|
|
17
|
+
* - **The state lives on the version row**, in the leaf's
|
|
18
|
+
* `moderationState` column ({@link ModerationState}): a decision is an
|
|
19
|
+
* appended version, so history shows who decided what, and when.
|
|
20
|
+
* - **Transitions.** Every new row starts `pending`. Only the moderate rung
|
|
21
|
+
* changes the state: `pending → approved | rejected`, re-moderation
|
|
22
|
+
* between `approved` and `rejected`, and re-review back to `pending`
|
|
23
|
+
* ({@link isModerationTransition}). A write by anyone else that changes the
|
|
24
|
+
* state fails `WriteRejected`.
|
|
25
|
+
* - **Re-review triggers are the leaf's**: an edit that changes one of its
|
|
26
|
+
* declared `reReviewOn` attributes sends an `approved` row back to
|
|
27
|
+
* `pending` ({@link ModerationDeclaration}).
|
|
28
|
+
* - **Owners withdraw their own content** by deleting it: the owned write
|
|
29
|
+
* gate admits its owner in every moderation state, and a tombstone needs
|
|
30
|
+
* no decision.
|
|
31
|
+
*
|
|
32
|
+
* {@link moderationHook} enforces the rules as a before hook (`./subtype`'s
|
|
33
|
+
* lifecycle seam), so every write path honours them, and
|
|
34
|
+
* {@link defineModeration}'s `moderate` is the decision itself.
|
|
35
|
+
*
|
|
36
|
+
* ## Grading and the queue
|
|
37
|
+
*
|
|
38
|
+
* {@link gradeScores} turns a classifier's per-category scores (shaped like
|
|
39
|
+
* effect-ai-ext's `ModerationModel` output; pulpit doesn't import it) into a
|
|
40
|
+
* verdict against the application's thresholds. Classification itself runs
|
|
41
|
+
* from an application's after hook. The review queue is the leaf's pending
|
|
42
|
+
* rows ({@link reviewQueueFilter}) plus the open member reports
|
|
43
|
+
* ({@link defineReports}), both the moderate rung's alone.
|
|
44
|
+
*
|
|
45
|
+
* @copyright Thomas Foster
|
|
46
|
+
* @module pulpit/moderation
|
|
47
|
+
* @since 0.1.0
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
import * as Arr from "effect/Array";
|
|
51
|
+
import * as Effect from "effect/Effect";
|
|
52
|
+
import * as Equal from "effect/Equal";
|
|
53
|
+
import * as Formatter from "effect/Formatter";
|
|
54
|
+
import * as Option from "effect/Option";
|
|
55
|
+
import * as Order from "effect/Order";
|
|
56
|
+
import * as Predicate from "effect/Predicate";
|
|
57
|
+
import * as Record from "effect/Record";
|
|
58
|
+
import * as Result from "effect/Result";
|
|
59
|
+
import * as Schema from "effect/Schema";
|
|
60
|
+
import type { BoundBudget } from "./budgets.ts";
|
|
61
|
+
import { always, eq, never, type ListFilter } from "./filter.ts";
|
|
62
|
+
import { nowDate, plainRowQuery, signedIn as signedInAs } from "./internal/plain-rows.ts";
|
|
63
|
+
import { ModerationState } from "./publication.ts";
|
|
64
|
+
import {
|
|
65
|
+
Forbidden,
|
|
66
|
+
Unauthorized,
|
|
67
|
+
WriteRejected,
|
|
68
|
+
type AccessPolicy,
|
|
69
|
+
type BeforeWriteEvent,
|
|
70
|
+
type BeforeWriteHook,
|
|
71
|
+
type Page,
|
|
72
|
+
type SubtypeOps,
|
|
73
|
+
type Viewer,
|
|
74
|
+
type ViewerAccount,
|
|
75
|
+
} from "./subtype.ts";
|
|
76
|
+
import type {
|
|
77
|
+
ChainPatch,
|
|
78
|
+
ChainWrite,
|
|
79
|
+
ColumnValue,
|
|
80
|
+
NodeNotFound,
|
|
81
|
+
Precondition,
|
|
82
|
+
StaleVersion,
|
|
83
|
+
VersionConflict,
|
|
84
|
+
} from "./version-chain.ts";
|
|
85
|
+
|
|
86
|
+
// ── Transitions ───────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Whether a row may move from `from` (none for a row being created) to
|
|
90
|
+
* `to`: a new row only to `pending`; an existing one to any other state
|
|
91
|
+
* (decision, re-moderation, re-review). Staying put is no transition.
|
|
92
|
+
*
|
|
93
|
+
* @example
|
|
94
|
+
* ```ts
|
|
95
|
+
* import { strictEqual } from "node:assert";
|
|
96
|
+
* import * as Option from "effect/Option";
|
|
97
|
+
* import { isModerationTransition } from "@thomasfosterau/pulpit/moderation";
|
|
98
|
+
*
|
|
99
|
+
* strictEqual(isModerationTransition(Option.none(), "pending"), true);
|
|
100
|
+
* strictEqual(isModerationTransition(Option.none(), "approved"), false);
|
|
101
|
+
* strictEqual(isModerationTransition(Option.some("pending"), "approved"), true);
|
|
102
|
+
* strictEqual(isModerationTransition(Option.some("approved"), "approved"), false);
|
|
103
|
+
* ```
|
|
104
|
+
*
|
|
105
|
+
* @since 0.1.0
|
|
106
|
+
*/
|
|
107
|
+
export const isModerationTransition = (
|
|
108
|
+
from: Option.Option<ModerationState>,
|
|
109
|
+
to: ModerationState,
|
|
110
|
+
): boolean =>
|
|
111
|
+
Option.match(from, { onNone: () => to === "pending", onSome: (state) => state !== to });
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* A leaf's moderation columns and its re-review triggers.
|
|
115
|
+
*
|
|
116
|
+
* @since 0.1.0
|
|
117
|
+
*/
|
|
118
|
+
export interface ModerationDeclaration {
|
|
119
|
+
/** The state column ({@link ModerationState}); conventionally `moderationState`. */
|
|
120
|
+
readonly column: string;
|
|
121
|
+
/** The column a decision stamps with its instant, when the leaf has one. */
|
|
122
|
+
readonly moderatedAtColumn: Option.Option<string>;
|
|
123
|
+
/** The column a decision stamps with the deciding account's id, when the leaf has one. */
|
|
124
|
+
readonly moderatedByColumn: Option.Option<string>;
|
|
125
|
+
/**
|
|
126
|
+
* The attributes whose change sends an `approved` row back to `pending`
|
|
127
|
+
* (a comment's `body`, an image's file). Empty, an approval survives
|
|
128
|
+
* every edit.
|
|
129
|
+
*/
|
|
130
|
+
readonly reReviewOn: ReadonlyArray<string>;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The declaration with the conventional column names.
|
|
135
|
+
*
|
|
136
|
+
* @since 0.1.0
|
|
137
|
+
*/
|
|
138
|
+
export const moderatedColumns = (reReviewOn: ReadonlyArray<string>): ModerationDeclaration => ({
|
|
139
|
+
column: "moderationState",
|
|
140
|
+
moderatedAtColumn: Option.some("moderatedAt"),
|
|
141
|
+
moderatedByColumn: Option.some("moderatedBy"),
|
|
142
|
+
reReviewOn,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const isModerationState = Schema.is(ModerationState);
|
|
146
|
+
|
|
147
|
+
/** A column's value in a state or patch, absent (or `undefined`) as none. */
|
|
148
|
+
const valueOf = (values: ChainPatch, column: string): Option.Option<ColumnValue> =>
|
|
149
|
+
Option.fromUndefinedOr(values[column]);
|
|
150
|
+
|
|
151
|
+
/** The prior state's moderation state, or none (a create, or a value outside the vocabulary). */
|
|
152
|
+
const priorState = (event: BeforeWriteEvent, column: string): Option.Option<ModerationState> =>
|
|
153
|
+
Option.flatMap(event.prior, (prior) =>
|
|
154
|
+
Option.filter(valueOf(prior.state, column), isModerationState),
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
/** The decision stamps for a patch, by the leaf's declared columns. */
|
|
158
|
+
const stamps = (
|
|
159
|
+
declaration: ModerationDeclaration,
|
|
160
|
+
account: ViewerAccount,
|
|
161
|
+
now: Date,
|
|
162
|
+
): ChainPatch => ({
|
|
163
|
+
...Option.match(declaration.moderatedAtColumn, {
|
|
164
|
+
onNone: () => ({}),
|
|
165
|
+
onSome: (column) => Record.singleton(column, now),
|
|
166
|
+
}),
|
|
167
|
+
...Option.match(declaration.moderatedByColumn, {
|
|
168
|
+
onNone: () => ({}),
|
|
169
|
+
onSome: (column) => Record.singleton(column, account.id),
|
|
170
|
+
}),
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
/** The prior row's moderation columns, carried over a restored version's. */
|
|
174
|
+
const carried = (event: BeforeWriteEvent, declaration: ModerationDeclaration): ChainPatch =>
|
|
175
|
+
Option.match(event.prior, {
|
|
176
|
+
onNone: () => ({}),
|
|
177
|
+
onSome: (prior) =>
|
|
178
|
+
Record.fromEntries(
|
|
179
|
+
Arr.map(
|
|
180
|
+
[
|
|
181
|
+
declaration.column,
|
|
182
|
+
...Option.toArray(declaration.moderatedAtColumn),
|
|
183
|
+
...Option.toArray(declaration.moderatedByColumn),
|
|
184
|
+
],
|
|
185
|
+
(column) => [column, prior.state[column] ?? null] as const,
|
|
186
|
+
),
|
|
187
|
+
),
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
/** Whether the patch changes a re-review attribute from its prior value. */
|
|
191
|
+
const touchesReview = (event: BeforeWriteEvent, declaration: ModerationDeclaration): boolean =>
|
|
192
|
+
Arr.some(declaration.reReviewOn, (column) =>
|
|
193
|
+
Option.match(valueOf(event.patch, column), {
|
|
194
|
+
onNone: () => false,
|
|
195
|
+
onSome: (next) =>
|
|
196
|
+
!Option.exists(event.prior, (prior) =>
|
|
197
|
+
Option.exists(valueOf(prior.state, column), (before) => Equal.equals(before, next)),
|
|
198
|
+
),
|
|
199
|
+
}),
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The moderation rules applied to one write: the patch the write should
|
|
204
|
+
* apply, or the reason it is refused. Pure, so every case is testable
|
|
205
|
+
* without a chain.
|
|
206
|
+
*
|
|
207
|
+
* - A **create** stores `pending`; a patch asking for another state is refused.
|
|
208
|
+
* - A **delete** is untouched (withdrawal needs no decision).
|
|
209
|
+
* - A **`restoreVersion`** by anyone but the moderate rung carries the
|
|
210
|
+
* prior row's moderation columns over the restored version's, so an old
|
|
211
|
+
* approval never comes back with old content.
|
|
212
|
+
* - A patch that **changes the state** is a decision: refused below the
|
|
213
|
+
* moderate rung or outside {@link isModerationTransition}, and otherwise
|
|
214
|
+
* stamped (`moderatedAtColumn`, `moderatedByColumn`). A patch that sets
|
|
215
|
+
* the state it already has changes nothing.
|
|
216
|
+
* - Otherwise, an edit that changes a `reReviewOn` attribute of an
|
|
217
|
+
* `approved` row sends it back to `pending`.
|
|
218
|
+
*
|
|
219
|
+
* @since 0.1.0
|
|
220
|
+
*/
|
|
221
|
+
export const moderatePatch = (
|
|
222
|
+
event: BeforeWriteEvent,
|
|
223
|
+
declaration: ModerationDeclaration,
|
|
224
|
+
policy: AccessPolicy,
|
|
225
|
+
now: Date,
|
|
226
|
+
): Result.Result<ChainPatch, string> => {
|
|
227
|
+
const moderator = Option.filter(event.viewer, policy.canModerate);
|
|
228
|
+
if (event.action === "delete") return Result.succeed(event.patch);
|
|
229
|
+
if (event.action === "create") return created(event.patch, declaration.column);
|
|
230
|
+
const patch =
|
|
231
|
+
event.action === "restoreVersion" && Option.isNone(moderator)
|
|
232
|
+
? { ...event.patch, ...carried(event, declaration) }
|
|
233
|
+
: event.patch;
|
|
234
|
+
const from = priorState(event, declaration.column);
|
|
235
|
+
const asked = Option.filter(
|
|
236
|
+
valueOf(patch, declaration.column),
|
|
237
|
+
(value) => !Option.exists(from, (state) => state === value),
|
|
238
|
+
);
|
|
239
|
+
return Option.isSome(asked)
|
|
240
|
+
? decided({ event, patch, from, asked: asked.value, moderator, declaration, now })
|
|
241
|
+
: Result.succeed(reReviewed({ ...event, patch }, from, declaration));
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
/** A create's patch: `pending`, whatever it asked for, unless it asked for another state. */
|
|
245
|
+
const created = (patch: ChainPatch, column: string): Result.Result<ChainPatch, string> =>
|
|
246
|
+
Option.exists(valueOf(patch, column), (asked) => asked !== "pending")
|
|
247
|
+
? Result.fail(`a new row starts pending; ${column} is the moderate rung's to change`)
|
|
248
|
+
: Result.succeed({ ...patch, [column]: "pending" });
|
|
249
|
+
|
|
250
|
+
/** A patch that changes the state: the rung's decision, stamped (a restored version unstamped). */
|
|
251
|
+
const decided = (decision: {
|
|
252
|
+
readonly event: BeforeWriteEvent;
|
|
253
|
+
readonly patch: ChainPatch;
|
|
254
|
+
readonly from: Option.Option<ModerationState>;
|
|
255
|
+
readonly asked: ColumnValue;
|
|
256
|
+
readonly moderator: Option.Option<ViewerAccount>;
|
|
257
|
+
readonly declaration: ModerationDeclaration;
|
|
258
|
+
readonly now: Date;
|
|
259
|
+
}): Result.Result<ChainPatch, string> => {
|
|
260
|
+
const { patch, asked, moderator, declaration } = decision;
|
|
261
|
+
const { column } = declaration;
|
|
262
|
+
if (Option.isNone(moderator)) return Result.fail(`${column} is the moderate rung's to change`);
|
|
263
|
+
if (!isModerationState(asked) || !isModerationTransition(decision.from, asked)) {
|
|
264
|
+
return Result.fail(
|
|
265
|
+
`${column} can't move to ${Predicate.isString(asked) ? asked : Formatter.format(asked)}`,
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
return Result.succeed(
|
|
269
|
+
decision.event.action === "restoreVersion"
|
|
270
|
+
? patch
|
|
271
|
+
: { ...patch, ...stamps(declaration, moderator.value, decision.now) },
|
|
272
|
+
);
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
/** An edit that leaves the state alone: back to `pending` when it touches a re-review attribute of an approved row. */
|
|
276
|
+
const reReviewed = (
|
|
277
|
+
event: BeforeWriteEvent,
|
|
278
|
+
from: Option.Option<ModerationState>,
|
|
279
|
+
declaration: ModerationDeclaration,
|
|
280
|
+
): ChainPatch =>
|
|
281
|
+
Option.exists(from, (state) => state === "approved") && touchesReview(event, declaration)
|
|
282
|
+
? { ...event.patch, [declaration.column]: "pending" }
|
|
283
|
+
: event.patch;
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* The moderation rules ({@link moderatePatch}) as a before hook for the
|
|
287
|
+
* leaf's `LifecycleHooks`: every write applies them, and a refusal
|
|
288
|
+
* fails the write with `WriteRejected`, appending nothing. Compose it first,
|
|
289
|
+
* so later hooks see the state it settled.
|
|
290
|
+
*
|
|
291
|
+
* @since 0.1.0
|
|
292
|
+
*/
|
|
293
|
+
export const moderationHook = <E = never, R = never>(
|
|
294
|
+
declaration: ModerationDeclaration,
|
|
295
|
+
policy: AccessPolicy,
|
|
296
|
+
): BeforeWriteHook<E, R> => ({
|
|
297
|
+
name: "moderation",
|
|
298
|
+
run: (event) =>
|
|
299
|
+
Effect.flatMap(nowDate, (now) => {
|
|
300
|
+
return Result.match(moderatePatch(event, declaration, policy, now), {
|
|
301
|
+
onSuccess: Effect.succeed,
|
|
302
|
+
onFailure: (reason) =>
|
|
303
|
+
Effect.fail(
|
|
304
|
+
new WriteRejected({ type: event.type, id: event.id, action: event.action, reason }),
|
|
305
|
+
),
|
|
306
|
+
});
|
|
307
|
+
}),
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// ── The decision ──────────────────────────────────────────────────────
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* The operations {@link defineModeration} produces.
|
|
314
|
+
*
|
|
315
|
+
* @since 0.1.0
|
|
316
|
+
*/
|
|
317
|
+
export interface ModerationOps<E, R> {
|
|
318
|
+
/**
|
|
319
|
+
* Record a decision: append the state through the target's own `update`,
|
|
320
|
+
* which runs the {@link moderationHook} (stamping it). Fails
|
|
321
|
+
* `Unauthorized` when anonymous and `Forbidden` below the moderate rung,
|
|
322
|
+
* before anything is read, then as the target's `update` does
|
|
323
|
+
* (`NodeNotFound` for a missing or tombstoned row, `StaleVersion`, and
|
|
324
|
+
* `WriteRejected` from its hooks).
|
|
325
|
+
*/
|
|
326
|
+
readonly moderate: (
|
|
327
|
+
id: string,
|
|
328
|
+
decision: ModerationState,
|
|
329
|
+
precondition?: Precondition,
|
|
330
|
+
) => Effect.Effect<
|
|
331
|
+
ChainWrite,
|
|
332
|
+
Unauthorized | Forbidden | NodeNotFound | StaleVersion | VersionConflict | WriteRejected | E,
|
|
333
|
+
R
|
|
334
|
+
>;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Bind a leaf's moderation decision. The leaf's hooks must include
|
|
339
|
+
* {@link moderationHook} with the same declaration: it is what admits the
|
|
340
|
+
* decision and stamps it.
|
|
341
|
+
*
|
|
342
|
+
* @since 0.1.0
|
|
343
|
+
*/
|
|
344
|
+
export const defineModeration = <Resource, E, R>(definition: {
|
|
345
|
+
readonly target: SubtypeOps<Resource, E, R>;
|
|
346
|
+
readonly declaration: ModerationDeclaration;
|
|
347
|
+
readonly viewer: Effect.Effect<Viewer, never, R>;
|
|
348
|
+
readonly policy: AccessPolicy;
|
|
349
|
+
}): ModerationOps<E, R> => ({
|
|
350
|
+
moderate: Effect.fn(`${definition.target.type}.moderate`)(function* (
|
|
351
|
+
id: string,
|
|
352
|
+
decision: ModerationState,
|
|
353
|
+
precondition: Precondition = {},
|
|
354
|
+
) {
|
|
355
|
+
const viewer = yield* definition.viewer;
|
|
356
|
+
if (Option.isNone(viewer)) return yield* new Unauthorized();
|
|
357
|
+
if (!definition.policy.canModerate(viewer.value)) return yield* new Forbidden();
|
|
358
|
+
return yield* definition.target.update({
|
|
359
|
+
id,
|
|
360
|
+
patch: { [definition.declaration.column]: decision },
|
|
361
|
+
expectedVersion: precondition.expectedVersion,
|
|
362
|
+
});
|
|
363
|
+
}),
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* The review queue's leaf half as a list filter: the `pending` rows, for
|
|
368
|
+
* the moderate rung only (`never` for anyone else). Pass it as a list
|
|
369
|
+
* request's `where`; the leaf's own read arms still apply.
|
|
370
|
+
*
|
|
371
|
+
* @since 0.1.0
|
|
372
|
+
*/
|
|
373
|
+
export const reviewQueueFilter = (
|
|
374
|
+
viewer: Viewer,
|
|
375
|
+
policy: AccessPolicy,
|
|
376
|
+
column: string = "moderationState",
|
|
377
|
+
): ListFilter<never> => (Option.exists(viewer, policy.canModerate) ? eq(column, "pending") : never);
|
|
378
|
+
|
|
379
|
+
// ── Threshold grading ─────────────────────────────────────────────────
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* A classifier's report, shaped like effect-ai-ext's `ModerationModel`
|
|
383
|
+
* output: its own flag, and a score in `[0, 1]` per category it assessed.
|
|
384
|
+
* A category it didn't assess is absent, never `0`.
|
|
385
|
+
*
|
|
386
|
+
* @since 0.1.0
|
|
387
|
+
*/
|
|
388
|
+
export interface ModerationScores {
|
|
389
|
+
readonly flagged: boolean;
|
|
390
|
+
readonly categoryScores: { readonly [category: string]: number };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* The band a flagged row lands in, from its worst breach.
|
|
395
|
+
*
|
|
396
|
+
* @since 0.1.0
|
|
397
|
+
*/
|
|
398
|
+
export const ModerationPriority = Schema.Literals(["low", "medium", "high", "urgent"]);
|
|
399
|
+
/**
|
|
400
|
+
* The {@link ModerationPriority} literal union.
|
|
401
|
+
*
|
|
402
|
+
* @since 0.1.0
|
|
403
|
+
*/
|
|
404
|
+
export type ModerationPriority = typeof ModerationPriority.Type;
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* A graded report.
|
|
408
|
+
*
|
|
409
|
+
* @since 0.1.0
|
|
410
|
+
*/
|
|
411
|
+
export interface ModerationGrade {
|
|
412
|
+
/** The classifier's own flag, or a threshold breach. */
|
|
413
|
+
readonly isFlagged: boolean;
|
|
414
|
+
/** The categories that cleared their threshold, sorted. */
|
|
415
|
+
readonly exceeded: ReadonlyArray<string>;
|
|
416
|
+
/** The highest score across every category, or `0` when none was scored. */
|
|
417
|
+
readonly topScore: number;
|
|
418
|
+
readonly priority: ModerationPriority;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* The bar a category with no configured threshold is graded against;
|
|
423
|
+
* strictly above it flags. A starting point to tune, not a measured value.
|
|
424
|
+
*
|
|
425
|
+
* @since 0.1.0
|
|
426
|
+
*/
|
|
427
|
+
export const DEFAULT_MODERATION_THRESHOLD = 0.5;
|
|
428
|
+
|
|
429
|
+
/** The band for a breach: round cut points, a starting policy. */
|
|
430
|
+
const bandFor = (score: number): ModerationPriority =>
|
|
431
|
+
score >= 0.9 ? "urgent" : score >= 0.75 ? "high" : score >= 0.5 ? "medium" : "low";
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Grade a classifier's scores against the leaf's thresholds (a category
|
|
435
|
+
* missing from `thresholds` grades against `defaultThreshold`). A category
|
|
436
|
+
* breaches when its score is strictly above its bar; an unassessed one is
|
|
437
|
+
* absent and can't breach. The classifier's own flag is never overridden
|
|
438
|
+
* downward, only added to (raise a category's bar to `1` to ignore it). The
|
|
439
|
+
* band is the worst breaching score's, or the top score's for a flag with
|
|
440
|
+
* no breach. Pure.
|
|
441
|
+
*
|
|
442
|
+
* @example
|
|
443
|
+
* ```ts
|
|
444
|
+
* import { deepStrictEqual } from "node:assert";
|
|
445
|
+
* import { gradeScores } from "@thomasfosterau/pulpit/moderation";
|
|
446
|
+
*
|
|
447
|
+
* deepStrictEqual(
|
|
448
|
+
* gradeScores({ flagged: false, categoryScores: { hate: 0.8, spam: 0.3 } }, { spam: 0.2 }),
|
|
449
|
+
* { isFlagged: true, exceeded: ["hate", "spam"], topScore: 0.8, priority: "high" },
|
|
450
|
+
* );
|
|
451
|
+
* ```
|
|
452
|
+
*
|
|
453
|
+
* @since 0.1.0
|
|
454
|
+
*/
|
|
455
|
+
export const gradeScores = (
|
|
456
|
+
scores: ModerationScores,
|
|
457
|
+
thresholds: { readonly [category: string]: number },
|
|
458
|
+
defaultThreshold: number = DEFAULT_MODERATION_THRESHOLD,
|
|
459
|
+
): ModerationGrade => {
|
|
460
|
+
const entries = Record.toEntries(scores.categoryScores);
|
|
461
|
+
const breaches = Arr.filter(
|
|
462
|
+
entries,
|
|
463
|
+
([category, score]) => score > (thresholds[category] ?? defaultThreshold),
|
|
464
|
+
);
|
|
465
|
+
const top = (values: ReadonlyArray<readonly [string, number]>) =>
|
|
466
|
+
Arr.reduce(values, 0, (worst, [, score]) => Math.max(worst, score));
|
|
467
|
+
const topScore = top(entries);
|
|
468
|
+
const topExceeded = top(breaches);
|
|
469
|
+
return {
|
|
470
|
+
isFlagged: scores.flagged || Arr.isReadonlyArrayNonEmpty(breaches),
|
|
471
|
+
exceeded: Arr.sort(
|
|
472
|
+
Arr.map(breaches, ([category]) => category),
|
|
473
|
+
Order.String,
|
|
474
|
+
),
|
|
475
|
+
topScore,
|
|
476
|
+
priority: bandFor(topExceeded > 0 ? topExceeded : scores.flagged ? topScore : 0),
|
|
477
|
+
};
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
// ── Member reports ────────────────────────────────────────────────────
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* A report's status: `open` until the moderate rung resolves it (acted on)
|
|
484
|
+
* or dismisses it.
|
|
485
|
+
*
|
|
486
|
+
* @since 0.1.0
|
|
487
|
+
*/
|
|
488
|
+
export const ReportStatus = Schema.Literals(["open", "resolved", "dismissed"]);
|
|
489
|
+
/**
|
|
490
|
+
* The {@link ReportStatus} literal union.
|
|
491
|
+
*
|
|
492
|
+
* @since 0.1.0
|
|
493
|
+
*/
|
|
494
|
+
export type ReportStatus = typeof ReportStatus.Type;
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* One member report, filed against a node.
|
|
498
|
+
*
|
|
499
|
+
* @since 0.1.0
|
|
500
|
+
*/
|
|
501
|
+
export interface ReportRow {
|
|
502
|
+
readonly id: string;
|
|
503
|
+
readonly targetId: string;
|
|
504
|
+
readonly targetType: string;
|
|
505
|
+
readonly reporterId: string;
|
|
506
|
+
/** The application's reason code (`spam`, `harassment`, …). */
|
|
507
|
+
readonly reason: string;
|
|
508
|
+
readonly details: Option.Option<string>;
|
|
509
|
+
readonly status: ReportStatus;
|
|
510
|
+
readonly filedAt: Date;
|
|
511
|
+
readonly resolvedAt: Option.Option<Date>;
|
|
512
|
+
readonly resolvedBy: Option.Option<string>;
|
|
513
|
+
readonly resolutionNote: Option.Option<string>;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* The write that closes an open report.
|
|
518
|
+
*
|
|
519
|
+
* @since 0.1.0
|
|
520
|
+
*/
|
|
521
|
+
export interface ReportResolution {
|
|
522
|
+
readonly status: Exclude<ReportStatus, "open">;
|
|
523
|
+
readonly resolvedAt: Date;
|
|
524
|
+
readonly resolvedBy: string;
|
|
525
|
+
readonly resolutionNote: Option.Option<string>;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* The report table an application supplies. It applies no policy, never
|
|
530
|
+
* opens a transaction, and fails only with its `E` (README → Seams).
|
|
531
|
+
*
|
|
532
|
+
* @since 0.1.0
|
|
533
|
+
*/
|
|
534
|
+
export interface ReportStore<E, R> {
|
|
535
|
+
readonly mintId: Effect.Effect<string, E, R>;
|
|
536
|
+
readonly insert: (row: ReportRow) => Effect.Effect<void, E, R>;
|
|
537
|
+
readonly get: (id: string) => Effect.Effect<Option.Option<ReportRow>, E, R>;
|
|
538
|
+
/** One row, `FOR UPDATE`, so two resolutions serialise. */
|
|
539
|
+
readonly lock: (id: string) => Effect.Effect<Option.Option<ReportRow>, E, R>;
|
|
540
|
+
readonly close: (id: string, resolution: ReportResolution) => Effect.Effect<void, E, R>;
|
|
541
|
+
/**
|
|
542
|
+
* The page under `where`, newest first (`filed_at DESC, id DESC`), with
|
|
543
|
+
* the unpaged total. `where` reads `reporterId`, `targetId`, `targetType`
|
|
544
|
+
* and `status`.
|
|
545
|
+
*/
|
|
546
|
+
readonly list: (query: {
|
|
547
|
+
readonly where: ListFilter<never>;
|
|
548
|
+
readonly limit: number;
|
|
549
|
+
readonly offset: number;
|
|
550
|
+
}) => Effect.Effect<Page<ReportRow>, E, R>;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* No report at that id the viewer may read: the wire's 404.
|
|
555
|
+
*
|
|
556
|
+
* @since 0.1.0
|
|
557
|
+
*/
|
|
558
|
+
export class ReportNotFound extends Schema.TaggedError<
|
|
559
|
+
ReportNotFound,
|
|
560
|
+
{ readonly brand: unique symbol }
|
|
561
|
+
>()("ReportNotFound", {
|
|
562
|
+
id: Schema.String,
|
|
563
|
+
}) {}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* The report is already resolved or dismissed: the wire's 409.
|
|
567
|
+
*
|
|
568
|
+
* @since 0.1.0
|
|
569
|
+
*/
|
|
570
|
+
export class ReportClosed extends Schema.TaggedError<
|
|
571
|
+
ReportClosed,
|
|
572
|
+
{ readonly brand: unique symbol }
|
|
573
|
+
>()("ReportClosed", {
|
|
574
|
+
id: Schema.String,
|
|
575
|
+
status: ReportStatus,
|
|
576
|
+
}) {}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* The operations {@link defineReports} produces.
|
|
580
|
+
*
|
|
581
|
+
* @since 0.1.0
|
|
582
|
+
*/
|
|
583
|
+
export interface ReportOps<E, R> {
|
|
584
|
+
/**
|
|
585
|
+
* File a report against `targetId`. Fails `Unauthorized` when anonymous
|
|
586
|
+
* and `NodeNotFound` when the reporter can't `get` the target, never
|
|
587
|
+
* `Forbidden` (`SEC-AUTH-ENUM-001`), and `WriteRejected` (`RATE_LIMITED`)
|
|
588
|
+
* when the declared budget is spent. The answer is the reporter's own
|
|
589
|
+
* report: never whether others reported the same node.
|
|
590
|
+
*/
|
|
591
|
+
readonly file: (input: {
|
|
592
|
+
readonly targetId: string;
|
|
593
|
+
readonly reason: string;
|
|
594
|
+
readonly details: Option.Option<string>;
|
|
595
|
+
}) => Effect.Effect<{ readonly id: string }, Unauthorized | NodeNotFound | WriteRejected | E, R>;
|
|
596
|
+
/** One report, for its reporter or the moderate rung; `ReportNotFound` otherwise. */
|
|
597
|
+
readonly get: (id: string) => Effect.Effect<ReportRow, ReportNotFound | E, R>;
|
|
598
|
+
/**
|
|
599
|
+
* One page, newest first: every report for the moderate rung (the queue's
|
|
600
|
+
* report half, by `status: open`), the viewer's own for anyone else.
|
|
601
|
+
*/
|
|
602
|
+
readonly list: (request: {
|
|
603
|
+
readonly targetId: Option.Option<string>;
|
|
604
|
+
readonly status: Option.Option<ReportStatus>;
|
|
605
|
+
readonly limit: number;
|
|
606
|
+
readonly offset: number;
|
|
607
|
+
}) => Effect.Effect<Page<ReportRow>, E, R>;
|
|
608
|
+
/**
|
|
609
|
+
* Close an open report as `resolved` or `dismissed`. Fails `Unauthorized`
|
|
610
|
+
* / `Forbidden` below the moderate rung, before the report is read, then
|
|
611
|
+
* `ReportNotFound` and `ReportClosed`. Acting on the node is the target's
|
|
612
|
+
* own operation (`moderate`, a delete), called separately.
|
|
613
|
+
*/
|
|
614
|
+
readonly resolve: (
|
|
615
|
+
id: string,
|
|
616
|
+
outcome: Exclude<ReportStatus, "open">,
|
|
617
|
+
note: Option.Option<string>,
|
|
618
|
+
) => Effect.Effect<void, Unauthorized | Forbidden | ReportNotFound | ReportClosed | E, R>;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Bind member reports against one leaf: filed by a signed-in account that
|
|
623
|
+
* can read the target, read by their reporter and the moderate rung, and
|
|
624
|
+
* resolved by the moderate rung.
|
|
625
|
+
*
|
|
626
|
+
* @since 0.1.0
|
|
627
|
+
*/
|
|
628
|
+
export const defineReports = <Resource, E, R>(definition: {
|
|
629
|
+
readonly target: SubtypeOps<Resource, E, R>;
|
|
630
|
+
readonly reports: ReportStore<E, R>;
|
|
631
|
+
readonly viewer: Effect.Effect<Viewer, never, R>;
|
|
632
|
+
readonly policy: AccessPolicy;
|
|
633
|
+
/**
|
|
634
|
+
* A write budget on `file` (pulpit#199, `./budgets`' `bindBudget`), spent
|
|
635
|
+
* as the `file` action once the reporter can read the target and before
|
|
636
|
+
* the report is stored. Omitted, reports are unthrottled.
|
|
637
|
+
*/
|
|
638
|
+
// oxlint-disable-next-line effect/prefer-option-over-null -- an optional field is `?: T | undefined` (AGENTS.md → Conventions); the omitted key is "no budget"
|
|
639
|
+
readonly budget?: BoundBudget<E, R> | undefined;
|
|
640
|
+
}): ReportOps<E, R> => {
|
|
641
|
+
const { target, reports, policy } = definition;
|
|
642
|
+
const type = target.type;
|
|
643
|
+
|
|
644
|
+
const signedIn = signedInAs(definition.viewer);
|
|
645
|
+
|
|
646
|
+
const file = Effect.fn(`${type}.reports.file`)(function* (input: {
|
|
647
|
+
readonly targetId: string;
|
|
648
|
+
readonly reason: string;
|
|
649
|
+
readonly details: Option.Option<string>;
|
|
650
|
+
}) {
|
|
651
|
+
const account = yield* signedIn;
|
|
652
|
+
// A target the reporter can't read is missing to them (SEC-AUTH-ENUM-001).
|
|
653
|
+
yield* target.get(input.targetId);
|
|
654
|
+
if (definition.budget !== undefined) {
|
|
655
|
+
yield* definition.budget.spend(Option.some(account), "file", input.targetId);
|
|
656
|
+
}
|
|
657
|
+
const id = yield* reports.mintId;
|
|
658
|
+
yield* reports.insert({
|
|
659
|
+
id,
|
|
660
|
+
targetId: input.targetId,
|
|
661
|
+
targetType: type,
|
|
662
|
+
reporterId: account.id,
|
|
663
|
+
reason: input.reason,
|
|
664
|
+
details: input.details,
|
|
665
|
+
status: "open",
|
|
666
|
+
filedAt: yield* nowDate,
|
|
667
|
+
resolvedAt: Option.none(),
|
|
668
|
+
resolvedBy: Option.none(),
|
|
669
|
+
resolutionNote: Option.none(),
|
|
670
|
+
});
|
|
671
|
+
return { id };
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
const readable = (viewer: Viewer, row: ReportRow) =>
|
|
675
|
+
row.targetType === type &&
|
|
676
|
+
Option.exists(
|
|
677
|
+
viewer,
|
|
678
|
+
(account) => policy.canModerate(account) || account.id === row.reporterId,
|
|
679
|
+
);
|
|
680
|
+
|
|
681
|
+
const get = Effect.fn(`${type}.reports.get`)(function* (id: string) {
|
|
682
|
+
const viewer = yield* definition.viewer;
|
|
683
|
+
const row = yield* reports.get(id);
|
|
684
|
+
if (Option.isNone(row) || !readable(viewer, row.value)) {
|
|
685
|
+
return yield* new ReportNotFound({ id });
|
|
686
|
+
}
|
|
687
|
+
return row.value;
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
const list = Effect.fn(`${type}.reports.list`)(function* (request: {
|
|
691
|
+
readonly targetId: Option.Option<string>;
|
|
692
|
+
readonly status: Option.Option<ReportStatus>;
|
|
693
|
+
readonly limit: number;
|
|
694
|
+
readonly offset: number;
|
|
695
|
+
}) {
|
|
696
|
+
const viewer = yield* definition.viewer;
|
|
697
|
+
const scope = Option.match(viewer, {
|
|
698
|
+
onNone: (): ListFilter<never> => never,
|
|
699
|
+
onSome: (account): ListFilter<never> =>
|
|
700
|
+
policy.canModerate(account) ? always : eq("reporterId", account.id),
|
|
701
|
+
});
|
|
702
|
+
return yield* reports.list(plainRowQuery(type, scope, request));
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
const resolve = Effect.fn(`${type}.reports.resolve`)(function* (
|
|
706
|
+
id: string,
|
|
707
|
+
outcome: Exclude<ReportStatus, "open">,
|
|
708
|
+
note: Option.Option<string>,
|
|
709
|
+
) {
|
|
710
|
+
const account = yield* signedIn;
|
|
711
|
+
if (!policy.canModerate(account)) return yield* new Forbidden();
|
|
712
|
+
const row = yield* reports.lock(id);
|
|
713
|
+
if (Option.isNone(row) || row.value.targetType !== type) {
|
|
714
|
+
return yield* new ReportNotFound({ id });
|
|
715
|
+
}
|
|
716
|
+
if (row.value.status !== "open") {
|
|
717
|
+
return yield* new ReportClosed({ id, status: row.value.status });
|
|
718
|
+
}
|
|
719
|
+
yield* reports.close(id, {
|
|
720
|
+
status: outcome,
|
|
721
|
+
resolvedAt: yield* nowDate,
|
|
722
|
+
resolvedBy: account.id,
|
|
723
|
+
resolutionNote: note,
|
|
724
|
+
});
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
return { file, get, list, resolve };
|
|
728
|
+
};
|