@helyx/module-moderation 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/LICENSE +725 -0
  3. package/README.md +107 -0
  4. package/dist/action-support.d.ts +48 -0
  5. package/dist/action-support.js +201 -0
  6. package/dist/case-actions.d.ts +9 -0
  7. package/dist/case-actions.js +311 -0
  8. package/dist/case-naming.d.ts +4 -0
  9. package/dist/case-naming.js +9 -0
  10. package/dist/case-repository.d.ts +29 -0
  11. package/dist/case-repository.js +199 -0
  12. package/dist/channel-actions.d.ts +3 -0
  13. package/dist/channel-actions.js +267 -0
  14. package/dist/commands.d.ts +4 -0
  15. package/dist/commands.js +309 -0
  16. package/dist/components.d.ts +15 -0
  17. package/dist/components.js +381 -0
  18. package/dist/configuration.d.ts +16 -0
  19. package/dist/configuration.js +94 -0
  20. package/dist/constants.d.ts +53 -0
  21. package/dist/constants.js +53 -0
  22. package/dist/contracts.d.ts +109 -0
  23. package/dist/contracts.js +84 -0
  24. package/dist/domain.d.ts +29 -0
  25. package/dist/domain.js +101 -0
  26. package/dist/events.d.ts +3 -0
  27. package/dist/events.js +60 -0
  28. package/dist/health.d.ts +10 -0
  29. package/dist/health.js +56 -0
  30. package/dist/index.d.ts +9 -0
  31. package/dist/index.js +92 -0
  32. package/dist/moderation-cases-resource.d.ts +14 -0
  33. package/dist/moderation-cases-resource.js +226 -0
  34. package/dist/presentation.d.ts +8 -0
  35. package/dist/presentation.js +69 -0
  36. package/dist/privacy.d.ts +8 -0
  37. package/dist/privacy.js +38 -0
  38. package/dist/provider.d.ts +4 -0
  39. package/dist/provider.js +335 -0
  40. package/dist/receipt-repository.d.ts +9 -0
  41. package/dist/receipt-repository.js +36 -0
  42. package/dist/records.d.ts +399 -0
  43. package/dist/records.js +303 -0
  44. package/dist/repository-model.d.ts +131 -0
  45. package/dist/repository-model.js +318 -0
  46. package/dist/repository.d.ts +21 -0
  47. package/dist/repository.js +41 -0
  48. package/dist/service.d.ts +75 -0
  49. package/dist/service.js +329 -0
  50. package/dist/tasks.d.ts +49 -0
  51. package/dist/tasks.js +391 -0
  52. package/dist/thread-controls.d.ts +23 -0
  53. package/dist/thread-controls.js +376 -0
  54. package/dist/thread-deletion-recovery.d.ts +13 -0
  55. package/dist/thread-deletion-recovery.js +46 -0
  56. package/dist/thread-delivery.d.ts +11 -0
  57. package/dist/thread-delivery.js +427 -0
  58. package/dist/thread-reconciliation.d.ts +24 -0
  59. package/dist/thread-reconciliation.js +181 -0
  60. package/dist/thread-repository.d.ts +16 -0
  61. package/dist/thread-repository.js +70 -0
  62. package/manifest.json +999 -0
  63. package/migrations/0001_moderation_foundation.sql +552 -0
  64. package/migrations/0002_staff_attempt_parameters.sql +27 -0
  65. package/package.json +56 -0
@@ -0,0 +1,329 @@
1
+ import { HELYX_SERVICE_NAMES, } from "@helyx/sdk";
2
+ import { MODERATION_MODULE_ID } from "./constants.js";
3
+ import { appendModerationAudit, atomicCases as cases, moderationApplied as isApplied, moderationAuditReason as auditReason, moderationConfiguration as getConfiguration, moderationEnabled as moduleEnabled, emitModerationActionLog, moderationEnforcement as memberEnforcement, moderationRetentionExpiresAt as retentionExpiresAt, scheduleActionReconciliation, sendWarningDirectMessage, staffAction, } from "./action-support.js";
4
+ export class ModerationService {
5
+ #threads = null;
6
+ attachThreadRuntime(runtime) {
7
+ this.#threads = runtime;
8
+ }
9
+ canExecuteStaffAction(services, draft) {
10
+ return this.#revalidateStaffAction(services, draft);
11
+ }
12
+ async executeStaffAction(services, draft) {
13
+ if (!(await this.#revalidateStaffAction(services, draft)))
14
+ return { outcome: "rejected", safeCode: "authorization_changed" };
15
+ const configuration = await getConfiguration(services, draft.guildId);
16
+ const occurredAt = new Date();
17
+ const pending = await cases(services).createPendingCase({
18
+ operationKey: draft.operationKey,
19
+ guildId: draft.guildId,
20
+ subjectUserId: draft.subjectUserId,
21
+ actorUserId: draft.actor.userId,
22
+ source: "staff_command",
23
+ sourceModuleId: MODERATION_MODULE_ID,
24
+ action: draft.action,
25
+ reason: draft.reason,
26
+ occurredAt,
27
+ ...(draft.privateNote ? { privateNote: draft.privateNote } : {}),
28
+ ...(draft.evidenceMessage
29
+ ? { evidenceMessage: draft.evidenceMessage }
30
+ : {}),
31
+ ...(draft.requestedDurationSeconds
32
+ ? { requestedDurationSeconds: draft.requestedDurationSeconds }
33
+ : {}),
34
+ ...(draft.deleteMessageSeconds !== undefined
35
+ ? { deleteMessageSeconds: draft.deleteMessageSeconds }
36
+ : {}),
37
+ ...(draft.demoteRoleIds ? { demoteRoleIds: draft.demoteRoleIds } : {}),
38
+ });
39
+ if (pending.outcome === "rejected")
40
+ return { outcome: "rejected", safeCode: pending.code };
41
+ if (pending.outcome === "replayed") {
42
+ if (pending.attemptOutcome === "pending" ||
43
+ pending.attemptOutcome === "ambiguous")
44
+ await scheduleActionReconciliation(services, {
45
+ guildId: draft.guildId,
46
+ caseId: pending.caseId,
47
+ attemptId: pending.attemptId,
48
+ operationKey: draft.operationKey,
49
+ });
50
+ return {
51
+ outcome: pending.attemptOutcome === "succeeded" ||
52
+ pending.attemptOutcome === "already_applied"
53
+ ? "applied"
54
+ : pending.attemptOutcome === "pending" ||
55
+ pending.attemptOutcome === "ambiguous" ||
56
+ pending.attemptOutcome === "review_required"
57
+ ? "review_required"
58
+ : "failed",
59
+ caseId: pending.caseId,
60
+ caseNumber: pending.caseNumber,
61
+ revision: pending.revision,
62
+ safeCode: `replayed_${pending.attemptOutcome}`,
63
+ };
64
+ }
65
+ try {
66
+ await appendModerationAudit(services, {
67
+ guildId: draft.guildId,
68
+ actorUserId: draft.actor.userId,
69
+ action: "moderation.action-attempt",
70
+ source: "discord",
71
+ correlationId: draft.operationKey,
72
+ idempotencyKey: `${draft.operationKey}:audit`,
73
+ targetType: "moderation_case",
74
+ targetId: pending.caseId,
75
+ metadata: { action: draft.action, caseNumber: pending.caseNumber },
76
+ });
77
+ }
78
+ catch {
79
+ const settledAt = new Date();
80
+ await cases(services).settleActionAttempt({
81
+ operationKey: `${draft.operationKey}:audit-failed`,
82
+ guildId: draft.guildId,
83
+ caseId: pending.caseId,
84
+ attemptId: pending.attemptId,
85
+ expectedRevision: pending.revision,
86
+ outcome: "failed",
87
+ safeCode: "audit_unavailable",
88
+ retentionExpiresAt: retentionExpiresAt(settledAt, configuration.caseRetentionDays),
89
+ occurredAt: settledAt,
90
+ });
91
+ return {
92
+ outcome: "failed",
93
+ caseId: pending.caseId,
94
+ caseNumber: pending.caseNumber,
95
+ safeCode: "audit_unavailable",
96
+ };
97
+ }
98
+ const effect = await this.dispatchAction(services, {
99
+ guildId: draft.guildId,
100
+ subjectUserId: draft.subjectUserId,
101
+ actorUserId: draft.actor.userId,
102
+ action: staffAction(draft),
103
+ reason: auditReason(pending.caseNumber, draft.reason),
104
+ operationKey: `${draft.operationKey}:discord`,
105
+ protectedRoleIds: configuration.violationAccessRoleId
106
+ ? [configuration.violationAccessRoleId]
107
+ : [],
108
+ revalidate: () => this.#revalidateStaffAction(services, draft),
109
+ }).catch(() => ({
110
+ outcome: "ambiguous",
111
+ safeCode: "enforcement_unavailable",
112
+ }));
113
+ const settledAt = new Date();
114
+ const settlement = await cases(services)
115
+ .settleActionAttempt({
116
+ operationKey: `${draft.operationKey}:settle`,
117
+ guildId: draft.guildId,
118
+ caseId: pending.caseId,
119
+ attemptId: pending.attemptId,
120
+ expectedRevision: pending.revision,
121
+ outcome: effect.outcome,
122
+ ...(effect.safeCode ? { safeCode: effect.safeCode } : {}),
123
+ ...("removedRoleIds" in effect && effect.removedRoleIds
124
+ ? { removedRoleIds: effect.removedRoleIds }
125
+ : {}),
126
+ retentionExpiresAt: retentionExpiresAt(settledAt, configuration.caseRetentionDays),
127
+ occurredAt: settledAt,
128
+ })
129
+ .catch(() => null);
130
+ if (!settlement) {
131
+ await scheduleActionReconciliation(services, {
132
+ guildId: draft.guildId,
133
+ caseId: pending.caseId,
134
+ attemptId: pending.attemptId,
135
+ operationKey: draft.operationKey,
136
+ }).catch(() => undefined);
137
+ return {
138
+ outcome: "review_required",
139
+ caseId: pending.caseId,
140
+ caseNumber: pending.caseNumber,
141
+ safeCode: "settlement_unavailable",
142
+ };
143
+ }
144
+ if (settlement.outcome === "rejected")
145
+ return {
146
+ outcome: "review_required",
147
+ caseId: pending.caseId,
148
+ caseNumber: pending.caseNumber,
149
+ safeCode: `settlement_${settlement.code}`,
150
+ };
151
+ if (effect.outcome === "ambiguous")
152
+ await scheduleActionReconciliation(services, {
153
+ guildId: draft.guildId,
154
+ caseId: pending.caseId,
155
+ attemptId: pending.attemptId,
156
+ operationKey: draft.operationKey,
157
+ });
158
+ if (draft.action === "warn" && isApplied(effect.outcome))
159
+ await sendWarningDirectMessage(services, {
160
+ guildId: draft.guildId,
161
+ caseId: pending.caseId,
162
+ caseNumber: pending.caseNumber,
163
+ subjectUserId: draft.subjectUserId,
164
+ }).catch(() => undefined);
165
+ if (isApplied(effect.outcome)) {
166
+ await emitModerationActionLog(services, {
167
+ guildId: draft.guildId,
168
+ action: draft.action,
169
+ caseId: pending.caseId,
170
+ caseNumber: pending.caseNumber,
171
+ subjectUserId: draft.subjectUserId,
172
+ actorUserId: draft.actor.userId,
173
+ ...(draft.requestedDurationSeconds
174
+ ? { durationSeconds: draft.requestedDurationSeconds }
175
+ : {}),
176
+ operationKey: draft.operationKey,
177
+ });
178
+ await this.deliverViolationThread({
179
+ services,
180
+ guildId: draft.guildId,
181
+ caseId: pending.caseId,
182
+ caseNumber: pending.caseNumber,
183
+ caseRevision: settlement.revision,
184
+ subjectUserId: draft.subjectUserId,
185
+ severity: "medium",
186
+ operationKey: draft.operationKey,
187
+ }).catch(() => "review_required");
188
+ }
189
+ return {
190
+ outcome: isApplied(effect.outcome)
191
+ ? "applied"
192
+ : effect.outcome === "ambiguous" || effect.outcome === "review_required"
193
+ ? "review_required"
194
+ : "failed",
195
+ caseId: pending.caseId,
196
+ caseNumber: pending.caseNumber,
197
+ revision: settlement.revision,
198
+ safeCode: effect.safeCode ?? effect.outcome,
199
+ };
200
+ }
201
+ async dispatchAction(services, input) {
202
+ const enforcement = memberEnforcement(services);
203
+ const common = {
204
+ guildId: input.guildId,
205
+ memberUserId: input.subjectUserId,
206
+ ...(input.actorUserId ? { actorUserId: input.actorUserId } : {}),
207
+ moduleId: MODERATION_MODULE_ID,
208
+ reason: input.reason,
209
+ operationKey: input.operationKey,
210
+ revalidate: () => input.revalidate(),
211
+ };
212
+ if (input.action.type === "case_only" || input.action.type === "warn")
213
+ return { outcome: "succeeded" };
214
+ if (input.action.type === "timeout")
215
+ return enforcement.timeout({
216
+ ...common,
217
+ durationSeconds: input.action.durationSeconds,
218
+ });
219
+ if (input.action.type === "timeout_remove")
220
+ return enforcement.removeTimeout(common);
221
+ if (input.action.type === "kick")
222
+ return enforcement.kickMember(common);
223
+ if (input.action.type === "ban")
224
+ return enforcement.banMember({
225
+ ...common,
226
+ deleteMessageSeconds: input.action.deleteMessageSeconds,
227
+ });
228
+ if (input.action.type === "unban")
229
+ return enforcement.unban({
230
+ guildId: input.guildId,
231
+ userId: input.subjectUserId,
232
+ ...(input.actorUserId ? { actorUserId: input.actorUserId } : {}),
233
+ moduleId: MODERATION_MODULE_ID,
234
+ reason: input.reason,
235
+ operationKey: input.operationKey,
236
+ revalidate: () => input.revalidate(),
237
+ });
238
+ if (input.action.type === "demote") {
239
+ const result = await enforcement.removeConfiguredRoles({
240
+ ...common,
241
+ roleIds: input.action.roleIds,
242
+ protectedRoleIds: [...new Set(input.protectedRoleIds)],
243
+ });
244
+ return "removedRoleIds" in result
245
+ ? { outcome: result.outcome, removedRoleIds: result.removedRoleIds }
246
+ : result;
247
+ }
248
+ if (input.action.type === "delete_message") {
249
+ const source = input.sourceMessage;
250
+ if (!source?.messageId)
251
+ return { outcome: "review_required", safeCode: "source_missing" };
252
+ const result = await services
253
+ .get(HELYX_SERVICE_NAMES.messageEnforcement)
254
+ .deleteMemberMessage({
255
+ guildId: input.guildId,
256
+ channelId: source.channelId,
257
+ messageId: source.messageId,
258
+ expectedAuthorUserId: input.subjectUserId,
259
+ callerModuleId: MODERATION_MODULE_ID,
260
+ reason: input.reason,
261
+ operationKey: input.operationKey,
262
+ revalidate: () => input.revalidate(),
263
+ });
264
+ return {
265
+ outcome: result.outcome === "deleted"
266
+ ? "succeeded"
267
+ : result.outcome === "already_absent"
268
+ ? "already_applied"
269
+ : result.outcome === "ambiguous"
270
+ ? "ambiguous"
271
+ : "failed",
272
+ ...(result.safeCode ? { safeCode: result.safeCode } : {}),
273
+ };
274
+ }
275
+ return { outcome: "review_required", safeCode: "unsupported_action" };
276
+ }
277
+ async #revalidateStaffAction(services, draft) {
278
+ if (!(await moduleEnabled(services, draft.guildId)))
279
+ return false;
280
+ const enforcement = memberEnforcement(services);
281
+ const actor = await enforcement.inspectMember({
282
+ guildId: draft.guildId,
283
+ memberUserId: draft.actor.userId,
284
+ });
285
+ if (actor.outcome !== "present")
286
+ return false;
287
+ const authorised = await services
288
+ .get(HELYX_SERVICE_NAMES.resourceAuthorization)
289
+ .canUse({
290
+ guildId: draft.guildId,
291
+ moduleId: MODERATION_MODULE_ID,
292
+ permissionId: draft.permissionId,
293
+ actor: {
294
+ userId: draft.actor.userId,
295
+ roleIds: actor.roleIds,
296
+ permissions: actor.effectivePermissionNames,
297
+ isServerOwner: actor.isGuildOwner,
298
+ isAdministrator: actor.isAdministrator,
299
+ },
300
+ });
301
+ if (!authorised || draft.subjectUserId === draft.actor.userId)
302
+ return false;
303
+ if (draft.action === "unban")
304
+ return true;
305
+ const subject = await enforcement.inspectMember({
306
+ guildId: draft.guildId,
307
+ memberUserId: draft.subjectUserId,
308
+ actorUserId: draft.actor.userId,
309
+ });
310
+ if (draft.action === "ban" && subject.outcome === "member_missing")
311
+ return true;
312
+ if (subject.outcome !== "present")
313
+ return false;
314
+ if (subject.isGuildOwner ||
315
+ subject.helyxHierarchy !== "above" ||
316
+ subject.actorHierarchy !== "above")
317
+ return false;
318
+ if (draft.action === "timeout" &&
319
+ (subject.isAdministrator || subject.isBot))
320
+ return false;
321
+ return true;
322
+ }
323
+ deliverViolationThread(input) {
324
+ return this.#threads
325
+ ? this.#threads.deliver(input)
326
+ : Promise.resolve("review_required");
327
+ }
328
+ }
329
+ //# sourceMappingURL=service.js.map
@@ -0,0 +1,49 @@
1
+ import type { ModuleScheduledTaskContribution, ScheduledTaskExecutionResult, ServiceAccess } from "@helyx/sdk";
2
+ export interface ModerationTaskRuntime {
3
+ deliverPendingThread(services: ServiceAccess, input: {
4
+ guildId: string;
5
+ caseId: string;
6
+ }): Promise<ScheduledTaskExecutionResult>;
7
+ reconcileAction(services: ServiceAccess, input: {
8
+ guildId: string;
9
+ caseId: string;
10
+ attemptId: string;
11
+ }): Promise<ScheduledTaskExecutionResult>;
12
+ reconcileThreadAccess(services: ServiceAccess, input: {
13
+ guildId: string;
14
+ }): Promise<ScheduledTaskExecutionResult>;
15
+ }
16
+ export interface ModerationTaskHealth {
17
+ recordTaskSuccess(taskKind: string): void;
18
+ recordTaskFailure(taskKind: string, safeCode: string): void;
19
+ }
20
+ export declare function createModerationTasks(runtime: ModerationTaskRuntime, health?: ModerationTaskHealth): readonly ModuleScheduledTaskContribution[];
21
+ export declare function validateThreadDeliveryPayload(input: unknown): {
22
+ caseId: string;
23
+ };
24
+ export declare function validateActionReconcilePayload(input: unknown): {
25
+ caseId: string;
26
+ attemptId: string;
27
+ };
28
+ export declare function validateEmptyPayload(input: unknown): {};
29
+ export declare function reconcileModerationAction(services: ServiceAccess, input: {
30
+ guildId: string;
31
+ caseId: string;
32
+ attemptId: string;
33
+ actorUserId?: string | null;
34
+ operationKey?: string;
35
+ }): Promise<ScheduledTaskExecutionResult>;
36
+ export declare function scheduleModerationThreadRetry(services: ServiceAccess, input: {
37
+ guildId: string;
38
+ caseId: string;
39
+ operationKey: string;
40
+ }): Promise<void>;
41
+ export declare function reconcileDashboardCase(input: {
42
+ guildId: string;
43
+ caseId: string;
44
+ expectedRevision: number;
45
+ actorUserId: string;
46
+ operationKey: string;
47
+ services: ServiceAccess;
48
+ }): Promise<void>;
49
+ //# sourceMappingURL=tasks.d.ts.map