@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.
Files changed (143) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1271 -1
  3. package/dist/blobs.d.ts +249 -0
  4. package/dist/blobs.d.ts.map +1 -0
  5. package/dist/blobs.js +193 -0
  6. package/dist/blobs.js.map +1 -0
  7. package/dist/budgets.d.ts +255 -0
  8. package/dist/budgets.d.ts.map +1 -0
  9. package/dist/budgets.js +211 -0
  10. package/dist/budgets.js.map +1 -0
  11. package/dist/cache.d.ts +154 -0
  12. package/dist/cache.d.ts.map +1 -0
  13. package/dist/cache.js +118 -0
  14. package/dist/cache.js.map +1 -0
  15. package/dist/collections.d.ts +242 -0
  16. package/dist/collections.d.ts.map +1 -0
  17. package/dist/collections.js +215 -0
  18. package/dist/collections.js.map +1 -0
  19. package/dist/comments.d.ts +212 -0
  20. package/dist/comments.d.ts.map +1 -0
  21. package/dist/comments.js +192 -0
  22. package/dist/comments.js.map +1 -0
  23. package/dist/content.d.ts +231 -0
  24. package/dist/content.d.ts.map +1 -0
  25. package/dist/content.js +204 -0
  26. package/dist/content.js.map +1 -0
  27. package/dist/documents.d.ts +184 -0
  28. package/dist/documents.d.ts.map +1 -0
  29. package/dist/documents.js +190 -0
  30. package/dist/documents.js.map +1 -0
  31. package/dist/edges.d.ts +645 -0
  32. package/dist/edges.d.ts.map +1 -0
  33. package/dist/edges.js +558 -0
  34. package/dist/edges.js.map +1 -0
  35. package/dist/filter.d.ts +461 -0
  36. package/dist/filter.d.ts.map +1 -0
  37. package/dist/filter.js +387 -0
  38. package/dist/filter.js.map +1 -0
  39. package/dist/index.d.ts +133 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +133 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/internal/filter-columns.d.ts +8 -0
  44. package/dist/internal/filter-columns.d.ts.map +1 -0
  45. package/dist/internal/filter-columns.js +43 -0
  46. package/dist/internal/filter-columns.js.map +1 -0
  47. package/dist/internal/list-order.d.ts +26 -0
  48. package/dist/internal/list-order.d.ts.map +1 -0
  49. package/dist/internal/list-order.js +92 -0
  50. package/dist/internal/list-order.js.map +1 -0
  51. package/dist/internal/owned-columns.d.ts +93 -0
  52. package/dist/internal/owned-columns.d.ts.map +1 -0
  53. package/dist/internal/owned-columns.js +98 -0
  54. package/dist/internal/owned-columns.js.map +1 -0
  55. package/dist/internal/plain-rows.d.ts +32 -0
  56. package/dist/internal/plain-rows.d.ts.map +1 -0
  57. package/dist/internal/plain-rows.js +32 -0
  58. package/dist/internal/plain-rows.js.map +1 -0
  59. package/dist/internal/reference-columns.d.ts +80 -0
  60. package/dist/internal/reference-columns.d.ts.map +1 -0
  61. package/dist/internal/reference-columns.js +102 -0
  62. package/dist/internal/reference-columns.js.map +1 -0
  63. package/dist/moderation.d.ts +392 -0
  64. package/dist/moderation.d.ts.map +1 -0
  65. package/dist/moderation.js +375 -0
  66. package/dist/moderation.js.map +1 -0
  67. package/dist/paths.d.ts +204 -0
  68. package/dist/paths.d.ts.map +1 -0
  69. package/dist/paths.js +165 -0
  70. package/dist/paths.js.map +1 -0
  71. package/dist/proposals.d.ts +523 -0
  72. package/dist/proposals.d.ts.map +1 -0
  73. package/dist/proposals.js +505 -0
  74. package/dist/proposals.js.map +1 -0
  75. package/dist/publication.d.ts +148 -0
  76. package/dist/publication.d.ts.map +1 -0
  77. package/dist/publication.js +183 -0
  78. package/dist/publication.js.map +1 -0
  79. package/dist/purge.d.ts +224 -0
  80. package/dist/purge.d.ts.map +1 -0
  81. package/dist/purge.js +151 -0
  82. package/dist/purge.js.map +1 -0
  83. package/dist/reactions.d.ts +246 -0
  84. package/dist/reactions.d.ts.map +1 -0
  85. package/dist/reactions.js +183 -0
  86. package/dist/reactions.js.map +1 -0
  87. package/dist/registry.d.ts +316 -0
  88. package/dist/registry.d.ts.map +1 -0
  89. package/dist/registry.js +305 -0
  90. package/dist/registry.js.map +1 -0
  91. package/dist/search.d.ts +366 -0
  92. package/dist/search.d.ts.map +1 -0
  93. package/dist/search.js +273 -0
  94. package/dist/search.js.map +1 -0
  95. package/dist/social.d.ts +311 -0
  96. package/dist/social.d.ts.map +1 -0
  97. package/dist/social.js +205 -0
  98. package/dist/social.js.map +1 -0
  99. package/dist/subtype.d.ts +1613 -0
  100. package/dist/subtype.d.ts.map +1 -0
  101. package/dist/subtype.js +1300 -0
  102. package/dist/subtype.js.map +1 -0
  103. package/dist/testing.d.ts +675 -0
  104. package/dist/testing.d.ts.map +1 -0
  105. package/dist/testing.js +1022 -0
  106. package/dist/testing.js.map +1 -0
  107. package/dist/version-chain.d.ts +702 -0
  108. package/dist/version-chain.d.ts.map +1 -0
  109. package/dist/version-chain.js +489 -0
  110. package/dist/version-chain.js.map +1 -0
  111. package/dist/working-copy.d.ts +116 -0
  112. package/dist/working-copy.d.ts.map +1 -0
  113. package/dist/working-copy.js +95 -0
  114. package/dist/working-copy.js.map +1 -0
  115. package/package.json +138 -4
  116. package/src/blobs.ts +384 -0
  117. package/src/budgets.ts +338 -0
  118. package/src/cache.ts +218 -0
  119. package/src/collections.ts +427 -0
  120. package/src/comments.ts +392 -0
  121. package/src/content.ts +238 -0
  122. package/src/documents.ts +284 -0
  123. package/src/edges.ts +1185 -0
  124. package/src/filter.ts +523 -0
  125. package/src/index.ts +153 -0
  126. package/src/internal/filter-columns.ts +50 -0
  127. package/src/internal/list-order.ts +117 -0
  128. package/src/internal/owned-columns.ts +140 -0
  129. package/src/internal/plain-rows.ts +54 -0
  130. package/src/internal/reference-columns.ts +156 -0
  131. package/src/moderation.ts +728 -0
  132. package/src/paths.ts +267 -0
  133. package/src/proposals.ts +1014 -0
  134. package/src/publication.ts +225 -0
  135. package/src/purge.ts +313 -0
  136. package/src/reactions.ts +347 -0
  137. package/src/registry.ts +524 -0
  138. package/src/search.ts +540 -0
  139. package/src/social.ts +469 -0
  140. package/src/subtype.ts +3095 -0
  141. package/src/testing.ts +1873 -0
  142. package/src/version-chain.ts +1093 -0
  143. 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
+ };