@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,552 @@
1
+ CREATE TABLE IF NOT EXISTS moderation_case_sequences (
2
+ guild_id text PRIMARY KEY,
3
+ next_case_number bigint NOT NULL CHECK (next_case_number >= 1),
4
+ updated_at timestamptz NOT NULL
5
+ );
6
+
7
+ CREATE TABLE IF NOT EXISTS moderation_cases (
8
+ case_id uuid PRIMARY KEY,
9
+ guild_id text NOT NULL,
10
+ case_number bigint NOT NULL CHECK (case_number >= 1),
11
+ operation_key text NOT NULL CHECK (char_length(operation_key) BETWEEN 1 AND 200),
12
+ subject_user_id text,
13
+ actor_user_id text,
14
+ source text NOT NULL CHECK (source IN ('staff_command', 'auto_moderation')),
15
+ source_module_id text NOT NULL CHECK (char_length(source_module_id) BETWEEN 1 AND 100),
16
+ source_event_id text CHECK (source_event_id IS NULL OR char_length(source_event_id) BETWEEN 1 AND 200),
17
+ action text NOT NULL CHECK (action IN (
18
+ 'warn', 'timeout', 'timeout_remove', 'kick', 'ban', 'demote',
19
+ 'unban', 'delete_message', 'case_only'
20
+ )),
21
+ reason text NOT NULL CHECK (char_length(reason) BETWEEN 1 AND 500),
22
+ private_note text CHECK (private_note IS NULL OR char_length(private_note) BETWEEN 1 AND 1000),
23
+ evidence_channel_id text,
24
+ evidence_message_id text,
25
+ related_case_id uuid REFERENCES moderation_cases(case_id) ON DELETE RESTRICT,
26
+ requested_duration_seconds integer CHECK (
27
+ requested_duration_seconds IS NULL OR requested_duration_seconds BETWEEN 60 AND 2419200
28
+ ),
29
+ delete_message_seconds integer CHECK (
30
+ delete_message_seconds IS NULL OR delete_message_seconds IN (0, 3600, 21600, 86400, 259200, 604800)
31
+ ),
32
+ demote_role_ids jsonb NOT NULL DEFAULT '[]'::jsonb CHECK (jsonb_typeof(demote_role_ids) = 'array'),
33
+ observed_action_type text CHECK (
34
+ observed_action_type IS NULL OR observed_action_type IN (
35
+ 'discord_native_block_message', 'discord_native_timeout'
36
+ )
37
+ ),
38
+ observed_timeout_duration_seconds integer CHECK (
39
+ observed_timeout_duration_seconds IS NULL OR observed_timeout_duration_seconds IN (
40
+ 60, 300, 600, 3600, 86400, 604800
41
+ )
42
+ ),
43
+ repeat_rule_id text,
44
+ repeat_rule_revision integer CHECK (repeat_rule_revision IS NULL OR repeat_rule_revision >= 1),
45
+ repeat_configuration_revision text,
46
+ repeat_policy_fingerprint text,
47
+ repeat_window_starts_at timestamptz,
48
+ repeat_offence_ordinal smallint CHECK (repeat_offence_ordinal IS NULL OR repeat_offence_ordinal BETWEEN 1 AND 3),
49
+ repeat_offence_count integer CHECK (repeat_offence_count IS NULL OR repeat_offence_count >= 1),
50
+ open_violation_thread boolean NOT NULL DEFAULT false,
51
+ state text NOT NULL CHECK (state IN (
52
+ 'pending', 'applied', 'failed', 'review', 'rejected', 'corrected', 'void'
53
+ )),
54
+ safe_outcome text CHECK (safe_outcome IS NULL OR char_length(safe_outcome) BETWEEN 1 AND 100),
55
+ revision integer NOT NULL DEFAULT 1 CHECK (revision >= 1),
56
+ disassociated_at timestamptz,
57
+ settled_at timestamptz,
58
+ retention_expires_at timestamptz,
59
+ retention_processed_at timestamptz,
60
+ created_at timestamptz NOT NULL,
61
+ updated_at timestamptz NOT NULL,
62
+ UNIQUE (guild_id, case_id),
63
+ UNIQUE (guild_id, case_number),
64
+ UNIQUE (guild_id, source_module_id, operation_key),
65
+ CHECK (subject_user_id IS NULL OR subject_user_id ~ '^[0-9]{17,20}$'),
66
+ CHECK (actor_user_id IS NULL OR actor_user_id ~ '^[0-9]{17,20}$'),
67
+ CHECK ((evidence_channel_id IS NULL) = (evidence_message_id IS NULL)),
68
+ CHECK (evidence_channel_id IS NULL OR evidence_channel_id ~ '^[0-9]{17,20}$'),
69
+ CHECK (evidence_message_id IS NULL OR evidence_message_id ~ '^[0-9]{17,20}$'),
70
+ CHECK (
71
+ (observed_action_type IS NULL AND observed_timeout_duration_seconds IS NULL)
72
+ OR (observed_action_type = 'discord_native_block_message' AND observed_timeout_duration_seconds IS NULL)
73
+ OR (observed_action_type = 'discord_native_timeout' AND observed_timeout_duration_seconds IS NOT NULL)
74
+ ),
75
+ CHECK (
76
+ (repeat_rule_id IS NULL AND repeat_rule_revision IS NULL AND repeat_configuration_revision IS NULL
77
+ AND repeat_policy_fingerprint IS NULL AND repeat_window_starts_at IS NULL
78
+ AND repeat_offence_ordinal IS NULL AND repeat_offence_count IS NULL)
79
+ OR (repeat_rule_id IS NOT NULL AND repeat_rule_revision IS NOT NULL
80
+ AND repeat_configuration_revision IS NOT NULL AND repeat_policy_fingerprint IS NOT NULL
81
+ AND repeat_window_starts_at IS NOT NULL AND repeat_offence_ordinal IS NOT NULL
82
+ AND repeat_offence_count IS NOT NULL)
83
+ ),
84
+ CHECK (disassociated_at IS NULL OR subject_user_id IS NULL),
85
+ CHECK (
86
+ retention_expires_at IS NULL OR (
87
+ settled_at IS NOT NULL
88
+ AND retention_expires_at >= settled_at + INTERVAL '30 days'
89
+ AND retention_expires_at <= settled_at + INTERVAL '730 days'
90
+ )
91
+ ),
92
+ CHECK (
93
+ retention_processed_at IS NULL OR (
94
+ retention_expires_at IS NOT NULL AND retention_processed_at >= retention_expires_at
95
+ )
96
+ ),
97
+ CHECK (updated_at >= created_at)
98
+ );
99
+
100
+ CREATE INDEX IF NOT EXISTS moderation_cases_guild_created_idx
101
+ ON moderation_cases (guild_id, created_at DESC, case_id DESC);
102
+ CREATE INDEX IF NOT EXISTS moderation_cases_subject_created_idx
103
+ ON moderation_cases (guild_id, subject_user_id, created_at DESC, case_id DESC)
104
+ WHERE subject_user_id IS NOT NULL;
105
+ CREATE INDEX IF NOT EXISTS moderation_cases_actor_created_idx
106
+ ON moderation_cases (guild_id, actor_user_id, created_at DESC, case_id DESC)
107
+ WHERE actor_user_id IS NOT NULL;
108
+ CREATE INDEX IF NOT EXISTS moderation_cases_rule_created_idx
109
+ ON moderation_cases (guild_id, repeat_rule_id, created_at DESC, case_id DESC)
110
+ WHERE repeat_rule_id IS NOT NULL;
111
+ CREATE INDEX IF NOT EXISTS moderation_cases_action_state_created_idx
112
+ ON moderation_cases (guild_id, action, state, created_at DESC, case_id DESC);
113
+ CREATE INDEX IF NOT EXISTS moderation_cases_retention_due_idx
114
+ ON moderation_cases (retention_expires_at, case_id)
115
+ WHERE retention_expires_at IS NOT NULL AND retention_processed_at IS NULL;
116
+
117
+ CREATE TABLE IF NOT EXISTS moderation_action_attempts (
118
+ attempt_id uuid PRIMARY KEY,
119
+ guild_id text NOT NULL,
120
+ case_id uuid NOT NULL,
121
+ operation_key text NOT NULL CHECK (char_length(operation_key) BETWEEN 1 AND 200),
122
+ actor_user_id text,
123
+ attempt_number integer NOT NULL CHECK (attempt_number >= 1),
124
+ action text NOT NULL CHECK (action IN (
125
+ 'warn', 'timeout', 'timeout_remove', 'kick', 'ban', 'demote',
126
+ 'unban', 'delete_message', 'case_only'
127
+ )),
128
+ reason text NOT NULL CHECK (char_length(reason) BETWEEN 1 AND 500),
129
+ outcome text NOT NULL CHECK (outcome IN (
130
+ 'pending', 'succeeded', 'already_applied', 'failed', 'ambiguous',
131
+ 'rejected', 'no_longer_required', 'member_missing', 'missing_permission',
132
+ 'hierarchy_blocked', 'review_required'
133
+ )),
134
+ safe_code text CHECK (safe_code IS NULL OR char_length(safe_code) BETWEEN 1 AND 100),
135
+ removed_role_ids jsonb NOT NULL DEFAULT '[]'::jsonb CHECK (jsonb_typeof(removed_role_ids) = 'array'),
136
+ created_at timestamptz NOT NULL,
137
+ updated_at timestamptz NOT NULL,
138
+ FOREIGN KEY (guild_id, case_id)
139
+ REFERENCES moderation_cases(guild_id, case_id) ON DELETE RESTRICT,
140
+ UNIQUE (attempt_id, attempt_number),
141
+ UNIQUE (case_id, attempt_number),
142
+ UNIQUE (case_id, operation_key),
143
+ CHECK (actor_user_id IS NULL OR actor_user_id ~ '^[0-9]{17,20}$'),
144
+ CHECK (updated_at >= created_at)
145
+ );
146
+
147
+ CREATE INDEX IF NOT EXISTS moderation_action_attempts_case_created_idx
148
+ ON moderation_action_attempts (case_id, created_at, attempt_id);
149
+
150
+ CREATE TABLE IF NOT EXISTS moderation_case_operations (
151
+ operation_id uuid PRIMARY KEY,
152
+ guild_id text NOT NULL,
153
+ operation_key text NOT NULL CHECK (char_length(operation_key) BETWEEN 1 AND 200),
154
+ case_id uuid,
155
+ operation_type text NOT NULL CHECK (operation_type IN (
156
+ 'create_case', 'open_attempt', 'settle_attempt', 'disassociate',
157
+ 'reconcile', 'correct', 'thread_control', 'record_observation', 'retention'
158
+ )),
159
+ input_hash text NOT NULL CHECK (input_hash ~ '^[a-f0-9]{64}$'),
160
+ outcome_code text NOT NULL CHECK (char_length(outcome_code) BETWEEN 1 AND 100),
161
+ outcome_revision integer CHECK (outcome_revision IS NULL OR outcome_revision >= 1),
162
+ outcome_attempt_id uuid,
163
+ outcome_attempt_number integer CHECK (outcome_attempt_number IS NULL OR outcome_attempt_number >= 1),
164
+ created_at timestamptz NOT NULL,
165
+ FOREIGN KEY (guild_id, case_id)
166
+ REFERENCES moderation_cases(guild_id, case_id) ON DELETE RESTRICT,
167
+ FOREIGN KEY (outcome_attempt_id, outcome_attempt_number)
168
+ REFERENCES moderation_action_attempts(attempt_id, attempt_number) ON DELETE RESTRICT,
169
+ UNIQUE (guild_id, operation_key),
170
+ CHECK ((outcome_attempt_id IS NULL) = (outcome_attempt_number IS NULL))
171
+ );
172
+
173
+ CREATE INDEX IF NOT EXISTS moderation_case_operations_case_created_idx
174
+ ON moderation_case_operations (case_id, created_at, operation_id)
175
+ WHERE case_id IS NOT NULL;
176
+
177
+ CREATE TABLE IF NOT EXISTS moderation_dm_receipts (
178
+ receipt_id uuid PRIMARY KEY,
179
+ guild_id text NOT NULL,
180
+ case_id uuid NOT NULL,
181
+ template_revision integer NOT NULL CHECK (template_revision >= 1),
182
+ delivery_state text NOT NULL CHECK (delivery_state IN (
183
+ 'not_requested', 'pending', 'succeeded', 'failed', 'retry_pending', 'review_required'
184
+ )),
185
+ safe_code text CHECK (safe_code IS NULL OR char_length(safe_code) BETWEEN 1 AND 100),
186
+ message_id text CHECK (message_id IS NULL OR message_id ~ '^[0-9]{17,20}$'),
187
+ attempted_at timestamptz,
188
+ created_at timestamptz NOT NULL,
189
+ updated_at timestamptz NOT NULL,
190
+ FOREIGN KEY (guild_id, case_id)
191
+ REFERENCES moderation_cases(guild_id, case_id) ON DELETE RESTRICT,
192
+ UNIQUE (guild_id, case_id),
193
+ CHECK (updated_at >= created_at)
194
+ );
195
+
196
+ CREATE INDEX IF NOT EXISTS moderation_dm_receipts_case_idx
197
+ ON moderation_dm_receipts (case_id, created_at, receipt_id);
198
+
199
+ CREATE TABLE IF NOT EXISTS moderation_thread_deliveries (
200
+ delivery_id uuid PRIMARY KEY,
201
+ guild_id text NOT NULL,
202
+ case_id uuid NOT NULL,
203
+ case_revision integer NOT NULL CHECK (case_revision >= 1),
204
+ operation_key text NOT NULL CHECK (char_length(operation_key) BETWEEN 1 AND 200),
205
+ parent_channel_id text CHECK (parent_channel_id IS NULL OR parent_channel_id ~ '^[0-9]{17,20}$'),
206
+ access_role_id text CHECK (access_role_id IS NULL OR access_role_id ~ '^[0-9]{17,20}$'),
207
+ thread_id text CHECK (thread_id IS NULL OR thread_id ~ '^[0-9]{17,20}$'),
208
+ control_token text CHECK (control_token IS NULL OR char_length(control_token) BETWEEN 32 AND 200),
209
+ control_message_id text CHECK (control_message_id IS NULL OR control_message_id ~ '^[0-9]{17,20}$'),
210
+ member_state text NOT NULL CHECK (member_state IN (
211
+ 'not_requested', 'pending', 'succeeded', 'failed', 'retry_pending', 'review_required'
212
+ )),
213
+ evidence_state text NOT NULL CHECK (evidence_state IN (
214
+ 'not_requested', 'pending', 'succeeded', 'failed', 'retry_pending', 'review_required'
215
+ )),
216
+ opening_state text NOT NULL CHECK (opening_state IN (
217
+ 'not_requested', 'pending', 'succeeded', 'failed', 'retry_pending', 'review_required'
218
+ )),
219
+ notification_state text NOT NULL CHECK (notification_state IN (
220
+ 'not_requested', 'pending', 'succeeded', 'failed', 'retry_pending', 'review_required'
221
+ )),
222
+ close_state text NOT NULL CHECK (close_state IN (
223
+ 'not_requested', 'pending', 'succeeded', 'failed', 'retry_pending', 'review_required'
224
+ )),
225
+ resolution_state text NOT NULL CHECK (resolution_state IN (
226
+ 'awaiting_staff', 'resolved', 'closed'
227
+ )),
228
+ attempt_count integer NOT NULL DEFAULT 0 CHECK (attempt_count >= 0),
229
+ fence_token bigint NOT NULL DEFAULT 0 CHECK (fence_token >= 0),
230
+ safe_code text CHECK (safe_code IS NULL OR char_length(safe_code) BETWEEN 1 AND 100),
231
+ retry_at timestamptz,
232
+ created_at timestamptz NOT NULL,
233
+ updated_at timestamptz NOT NULL,
234
+ FOREIGN KEY (guild_id, case_id)
235
+ REFERENCES moderation_cases(guild_id, case_id) ON DELETE RESTRICT,
236
+ UNIQUE (guild_id, case_id),
237
+ UNIQUE (guild_id, operation_key),
238
+ UNIQUE (control_token),
239
+ CHECK (updated_at >= created_at)
240
+ );
241
+
242
+ CREATE INDEX IF NOT EXISTS moderation_thread_deliveries_retry_idx
243
+ ON moderation_thread_deliveries (retry_at, delivery_id)
244
+ WHERE retry_at IS NOT NULL;
245
+ CREATE INDEX IF NOT EXISTS moderation_thread_deliveries_state_idx
246
+ ON moderation_thread_deliveries (guild_id, resolution_state, updated_at, delivery_id);
247
+
248
+ CREATE OR REPLACE FUNCTION moderation_protect_case_immutability()
249
+ RETURNS trigger LANGUAGE plpgsql AS $$
250
+ DECLARE
251
+ subject_detachment boolean := (
252
+ OLD.subject_user_id IS NOT NULL
253
+ AND NEW.subject_user_id IS NULL
254
+ AND OLD.disassociated_at IS NULL
255
+ AND NEW.disassociated_at IS NOT NULL
256
+ );
257
+ retention_transition boolean := (
258
+ OLD.retention_processed_at IS NULL
259
+ AND NEW.retention_processed_at IS NOT NULL
260
+ AND OLD.retention_expires_at IS NOT NULL
261
+ AND NEW.retention_expires_at = OLD.retention_expires_at
262
+ AND NEW.retention_processed_at >= OLD.retention_expires_at
263
+ );
264
+ BEGIN
265
+ IF retention_transition AND OLD.state = 'review' THEN
266
+ RAISE EXCEPTION 'open review cases cannot be retention processed';
267
+ END IF;
268
+ IF retention_transition AND OLD.action = 'ban' AND OLD.state = 'applied'
269
+ AND NOT EXISTS (
270
+ SELECT 1 FROM moderation_cases unban_case
271
+ WHERE unban_case.related_case_id = OLD.case_id
272
+ AND unban_case.action = 'unban'
273
+ AND unban_case.state = 'applied'
274
+ ) THEN
275
+ RAISE EXCEPTION 'active ban cases cannot be retention processed';
276
+ END IF;
277
+ IF retention_transition AND (
278
+ NEW.subject_user_id IS NOT NULL OR NEW.actor_user_id IS NOT NULL
279
+ OR NEW.private_note IS NOT NULL OR NEW.evidence_channel_id IS NOT NULL
280
+ OR NEW.evidence_message_id IS NOT NULL OR NEW.repeat_rule_id IS NOT NULL
281
+ OR NEW.repeat_rule_revision IS NOT NULL
282
+ OR NEW.repeat_configuration_revision IS NOT NULL
283
+ OR NEW.repeat_policy_fingerprint IS NOT NULL
284
+ OR NEW.repeat_window_starts_at IS NOT NULL
285
+ OR NEW.repeat_offence_ordinal IS NOT NULL
286
+ OR NEW.repeat_offence_count IS NOT NULL
287
+ OR NEW.disassociated_at IS NULL
288
+ OR NEW.operation_key <> 'retained:' || OLD.case_id::text
289
+ ) THEN
290
+ RAISE EXCEPTION 'moderation case retention redaction is incomplete';
291
+ END IF;
292
+ IF subject_detachment AND (
293
+ NEW.repeat_rule_id IS NOT NULL OR NEW.repeat_rule_revision IS NOT NULL
294
+ OR NEW.repeat_configuration_revision IS NOT NULL
295
+ OR NEW.repeat_policy_fingerprint IS NOT NULL
296
+ OR NEW.repeat_window_starts_at IS NOT NULL
297
+ OR NEW.repeat_offence_ordinal IS NOT NULL
298
+ OR NEW.repeat_offence_count IS NOT NULL
299
+ ) THEN
300
+ RAISE EXCEPTION 'moderation case disassociation must remove repeat linkage';
301
+ END IF;
302
+ IF ROW(
303
+ NEW.case_id, NEW.guild_id, NEW.case_number,
304
+ NEW.source, NEW.source_module_id, NEW.source_event_id, NEW.action,
305
+ NEW.reason, NEW.related_case_id,
306
+ NEW.requested_duration_seconds, NEW.delete_message_seconds,
307
+ NEW.demote_role_ids, NEW.open_violation_thread,
308
+ NEW.created_at
309
+ ) IS DISTINCT FROM ROW(
310
+ OLD.case_id, OLD.guild_id, OLD.case_number,
311
+ OLD.source, OLD.source_module_id, OLD.source_event_id, OLD.action,
312
+ OLD.reason, OLD.related_case_id,
313
+ OLD.requested_duration_seconds, OLD.delete_message_seconds,
314
+ OLD.demote_role_ids, OLD.open_violation_thread,
315
+ OLD.created_at
316
+ ) THEN
317
+ RAISE EXCEPTION 'moderation case immutable fields cannot change';
318
+ END IF;
319
+ IF NEW.operation_key IS DISTINCT FROM OLD.operation_key
320
+ AND NOT (
321
+ retention_transition
322
+ AND NEW.operation_key = 'retained:' || OLD.case_id::text
323
+ ) THEN
324
+ RAISE EXCEPTION 'moderation case operation key is immutable';
325
+ END IF;
326
+ IF NEW.private_note IS DISTINCT FROM OLD.private_note
327
+ AND NOT (
328
+ (retention_transition OR subject_detachment)
329
+ AND OLD.private_note IS NOT NULL
330
+ AND NEW.private_note IS NULL
331
+ ) THEN
332
+ RAISE EXCEPTION 'moderation case private note is immutable';
333
+ END IF;
334
+ IF ROW(NEW.evidence_channel_id, NEW.evidence_message_id)
335
+ IS DISTINCT FROM ROW(OLD.evidence_channel_id, OLD.evidence_message_id)
336
+ AND NOT (
337
+ retention_transition
338
+ AND NEW.evidence_channel_id IS NULL
339
+ AND NEW.evidence_message_id IS NULL
340
+ ) THEN
341
+ RAISE EXCEPTION 'moderation case evidence reference is immutable';
342
+ END IF;
343
+ IF NEW.actor_user_id IS DISTINCT FROM OLD.actor_user_id
344
+ AND NOT (OLD.actor_user_id IS NOT NULL AND NEW.actor_user_id IS NULL) THEN
345
+ RAISE EXCEPTION 'moderation case actor can only be detached';
346
+ END IF;
347
+ IF OLD.observed_action_type IS NOT NULL AND ROW(
348
+ NEW.observed_action_type, NEW.observed_timeout_duration_seconds
349
+ ) IS DISTINCT FROM ROW(
350
+ OLD.observed_action_type, OLD.observed_timeout_duration_seconds
351
+ ) THEN
352
+ RAISE EXCEPTION 'recorded native moderation observation is immutable';
353
+ END IF;
354
+ IF NEW.subject_user_id IS DISTINCT FROM OLD.subject_user_id
355
+ AND NOT subject_detachment THEN
356
+ RAISE EXCEPTION 'moderation case subject can only be disassociated';
357
+ END IF;
358
+ IF OLD.disassociated_at IS NOT NULL
359
+ AND NEW.disassociated_at IS DISTINCT FROM OLD.disassociated_at THEN
360
+ RAISE EXCEPTION 'moderation case disassociation is immutable';
361
+ END IF;
362
+ IF OLD.retention_processed_at IS NOT NULL
363
+ AND NEW.retention_expires_at IS DISTINCT FROM OLD.retention_expires_at THEN
364
+ RAISE EXCEPTION 'processed moderation case retention expiry is immutable';
365
+ END IF;
366
+ IF OLD.retention_expires_at IS NOT NULL AND (
367
+ NEW.retention_expires_at IS NULL
368
+ OR NEW.retention_expires_at < OLD.retention_expires_at
369
+ OR NEW.settled_at IS NULL
370
+ OR NEW.retention_expires_at < NEW.settled_at + INTERVAL '30 days'
371
+ OR NEW.retention_expires_at > NEW.settled_at + INTERVAL '730 days'
372
+ ) THEN
373
+ RAISE EXCEPTION 'moderation case retention expiry cannot shorten';
374
+ END IF;
375
+ IF NEW.retention_processed_at IS DISTINCT FROM OLD.retention_processed_at
376
+ AND NOT retention_transition THEN
377
+ RAISE EXCEPTION 'moderation case retention processing is immutable';
378
+ END IF;
379
+ IF ROW(
380
+ NEW.repeat_rule_id, NEW.repeat_rule_revision,
381
+ NEW.repeat_configuration_revision, NEW.repeat_policy_fingerprint,
382
+ NEW.repeat_window_starts_at, NEW.repeat_offence_ordinal,
383
+ NEW.repeat_offence_count
384
+ ) IS DISTINCT FROM ROW(
385
+ OLD.repeat_rule_id, OLD.repeat_rule_revision,
386
+ OLD.repeat_configuration_revision, OLD.repeat_policy_fingerprint,
387
+ OLD.repeat_window_starts_at, OLD.repeat_offence_ordinal,
388
+ OLD.repeat_offence_count
389
+ ) AND NOT (
390
+ NEW.subject_user_id IS NULL AND NEW.disassociated_at IS NOT NULL
391
+ AND NEW.repeat_rule_id IS NULL AND NEW.repeat_rule_revision IS NULL
392
+ AND NEW.repeat_configuration_revision IS NULL
393
+ AND NEW.repeat_policy_fingerprint IS NULL
394
+ AND NEW.repeat_window_starts_at IS NULL
395
+ AND NEW.repeat_offence_ordinal IS NULL
396
+ AND NEW.repeat_offence_count IS NULL
397
+ ) THEN
398
+ RAISE EXCEPTION 'moderation repeat association is immutable';
399
+ END IF;
400
+ IF NEW.revision <= OLD.revision THEN
401
+ RAISE EXCEPTION 'moderation case revision must increase';
402
+ END IF;
403
+ RETURN NEW;
404
+ END;
405
+ $$;
406
+
407
+ CREATE TRIGGER moderation_cases_immutable_update
408
+ BEFORE UPDATE ON moderation_cases
409
+ FOR EACH ROW EXECUTE FUNCTION moderation_protect_case_immutability();
410
+
411
+ CREATE OR REPLACE FUNCTION moderation_protect_attempt_immutability()
412
+ RETURNS trigger LANGUAGE plpgsql AS $$
413
+ DECLARE
414
+ retention_processed boolean;
415
+ BEGIN
416
+ SELECT c.retention_processed_at IS NOT NULL INTO retention_processed
417
+ FROM moderation_cases c WHERE c.case_id = OLD.case_id;
418
+ IF ROW(
419
+ NEW.attempt_id, NEW.guild_id, NEW.case_id,
420
+ NEW.attempt_number, NEW.action,
421
+ NEW.created_at
422
+ ) IS DISTINCT FROM ROW(
423
+ OLD.attempt_id, OLD.guild_id, OLD.case_id,
424
+ OLD.attempt_number, OLD.action,
425
+ OLD.created_at
426
+ ) THEN
427
+ RAISE EXCEPTION 'moderation attempt immutable fields cannot change';
428
+ END IF;
429
+ IF NEW.operation_key IS DISTINCT FROM OLD.operation_key
430
+ AND NOT (
431
+ retention_processed
432
+ AND NEW.operation_key = 'retained:' || OLD.attempt_id::text
433
+ ) THEN
434
+ RAISE EXCEPTION 'moderation attempt operation key is immutable';
435
+ END IF;
436
+ IF NEW.reason IS DISTINCT FROM OLD.reason
437
+ AND NOT (
438
+ retention_processed AND NEW.reason = '[redacted after retention]'
439
+ ) THEN
440
+ RAISE EXCEPTION 'moderation attempt reason is immutable';
441
+ END IF;
442
+ IF NEW.actor_user_id IS DISTINCT FROM OLD.actor_user_id
443
+ AND NOT (OLD.actor_user_id IS NOT NULL AND NEW.actor_user_id IS NULL) THEN
444
+ RAISE EXCEPTION 'moderation attempt actor can only be detached';
445
+ END IF;
446
+ IF OLD.outcome <> 'pending' AND ROW(
447
+ NEW.outcome, NEW.safe_code, NEW.removed_role_ids
448
+ ) IS DISTINCT FROM ROW(
449
+ OLD.outcome, OLD.safe_code, OLD.removed_role_ids
450
+ ) THEN
451
+ RAISE EXCEPTION 'settled moderation attempts are immutable';
452
+ END IF;
453
+ RETURN NEW;
454
+ END;
455
+ $$;
456
+
457
+ CREATE TRIGGER moderation_action_attempts_immutable_update
458
+ BEFORE UPDATE ON moderation_action_attempts
459
+ FOR EACH ROW EXECUTE FUNCTION moderation_protect_attempt_immutability();
460
+
461
+ CREATE OR REPLACE FUNCTION moderation_reject_immutable_delete()
462
+ RETURNS trigger LANGUAGE plpgsql AS $$
463
+ BEGIN
464
+ RAISE EXCEPTION 'immutable moderation audit rows cannot be deleted';
465
+ END;
466
+ $$;
467
+
468
+ CREATE TRIGGER moderation_action_attempts_immutable_delete
469
+ BEFORE DELETE ON moderation_action_attempts
470
+ FOR EACH ROW EXECUTE FUNCTION moderation_reject_immutable_delete();
471
+
472
+ CREATE OR REPLACE FUNCTION moderation_protect_case_operation_immutability()
473
+ RETURNS trigger LANGUAGE plpgsql AS $$
474
+ DECLARE
475
+ retention_processed boolean;
476
+ BEGIN
477
+ SELECT c.retention_processed_at IS NOT NULL INTO retention_processed
478
+ FROM moderation_cases c WHERE c.case_id = OLD.case_id;
479
+ IF ROW(
480
+ NEW.operation_id, NEW.guild_id, NEW.case_id, NEW.operation_type,
481
+ NEW.input_hash, NEW.outcome_code, NEW.outcome_revision,
482
+ NEW.outcome_attempt_id, NEW.outcome_attempt_number, NEW.created_at
483
+ ) IS DISTINCT FROM ROW(
484
+ OLD.operation_id, OLD.guild_id, OLD.case_id, OLD.operation_type,
485
+ OLD.input_hash, OLD.outcome_code, OLD.outcome_revision,
486
+ OLD.outcome_attempt_id, OLD.outcome_attempt_number, OLD.created_at
487
+ ) OR (
488
+ NEW.operation_key IS DISTINCT FROM OLD.operation_key AND NOT (
489
+ retention_processed
490
+ AND NEW.operation_key = 'retained:' || OLD.operation_id::text
491
+ )
492
+ ) THEN
493
+ RAISE EXCEPTION 'moderation case operation is immutable';
494
+ END IF;
495
+ RETURN NEW;
496
+ END;
497
+ $$;
498
+
499
+ CREATE TRIGGER moderation_case_operations_immutable_update
500
+ BEFORE UPDATE ON moderation_case_operations
501
+ FOR EACH ROW EXECUTE FUNCTION moderation_protect_case_operation_immutability();
502
+
503
+ CREATE TRIGGER moderation_case_operations_immutable_delete
504
+ BEFORE DELETE ON moderation_case_operations
505
+ FOR EACH ROW EXECUTE FUNCTION moderation_reject_immutable_delete();
506
+
507
+ CREATE OR REPLACE FUNCTION moderation_protect_dm_receipt_retention()
508
+ RETURNS trigger LANGUAGE plpgsql AS $$
509
+ DECLARE
510
+ retention_processed boolean;
511
+ BEGIN
512
+ SELECT c.retention_processed_at IS NOT NULL INTO retention_processed
513
+ FROM moderation_cases c WHERE c.case_id = OLD.case_id;
514
+ IF retention_processed AND NEW.message_id IS NOT NULL THEN
515
+ RAISE EXCEPTION 'retained moderation DM identifiers must stay redacted';
516
+ END IF;
517
+ RETURN NEW;
518
+ END;
519
+ $$;
520
+
521
+ CREATE TRIGGER moderation_dm_receipts_retention_update
522
+ BEFORE UPDATE ON moderation_dm_receipts
523
+ FOR EACH ROW EXECUTE FUNCTION moderation_protect_dm_receipt_retention();
524
+
525
+ CREATE OR REPLACE FUNCTION moderation_protect_thread_delivery_retention()
526
+ RETURNS trigger LANGUAGE plpgsql AS $$
527
+ DECLARE
528
+ retention_processed boolean;
529
+ BEGIN
530
+ SELECT c.retention_processed_at IS NOT NULL INTO retention_processed
531
+ FROM moderation_cases c WHERE c.case_id = OLD.case_id;
532
+ IF retention_processed AND (
533
+ NEW.parent_channel_id IS NOT NULL OR NEW.access_role_id IS NOT NULL
534
+ OR NEW.thread_id IS NOT NULL OR NEW.control_token IS NOT NULL
535
+ OR NEW.control_message_id IS NOT NULL
536
+ ) THEN
537
+ RAISE EXCEPTION 'retained moderation thread identifiers must stay redacted';
538
+ END IF;
539
+ IF NEW.operation_key IS DISTINCT FROM OLD.operation_key
540
+ AND NOT (
541
+ retention_processed
542
+ AND NEW.operation_key = 'retained:' || OLD.delivery_id::text
543
+ ) THEN
544
+ RAISE EXCEPTION 'moderation thread operation key is immutable';
545
+ END IF;
546
+ RETURN NEW;
547
+ END;
548
+ $$;
549
+
550
+ CREATE TRIGGER moderation_thread_deliveries_retention_update
551
+ BEFORE UPDATE ON moderation_thread_deliveries
552
+ FOR EACH ROW EXECUTE FUNCTION moderation_protect_thread_delivery_retention();
@@ -0,0 +1,27 @@
1
+ ALTER TABLE moderation_action_attempts
2
+ ADD COLUMN requested_duration_seconds integer CHECK (
3
+ requested_duration_seconds IS NULL OR (
4
+ action = 'timeout' AND requested_duration_seconds BETWEEN 60 AND 2419200
5
+ )
6
+ ),
7
+ ADD COLUMN requested_demote_role_ids jsonb CHECK (
8
+ requested_demote_role_ids IS NULL OR (
9
+ action = 'demote' AND jsonb_typeof(requested_demote_role_ids) = 'array'
10
+ AND jsonb_array_length(requested_demote_role_ids) BETWEEN 1 AND 10
11
+ )
12
+ );
13
+
14
+ CREATE FUNCTION moderation_protect_attempt_parameters()
15
+ RETURNS trigger LANGUAGE plpgsql AS $$
16
+ BEGIN
17
+ IF ROW(NEW.requested_duration_seconds, NEW.requested_demote_role_ids)
18
+ IS DISTINCT FROM ROW(OLD.requested_duration_seconds, OLD.requested_demote_role_ids) THEN
19
+ RAISE EXCEPTION 'moderation attempt request parameters are immutable';
20
+ END IF;
21
+ RETURN NEW;
22
+ END;
23
+ $$;
24
+
25
+ CREATE TRIGGER moderation_action_attempts_parameters_immutable_update
26
+ BEFORE UPDATE ON moderation_action_attempts
27
+ FOR EACH ROW EXECUTE FUNCTION moderation_protect_attempt_parameters();
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@helyx/module-moderation",
3
+ "version": "1.0.1",
4
+ "description": "Apply accountable staff moderation actions and review durable cases.",
5
+ "keywords": [
6
+ "helyx",
7
+ "discord",
8
+ "moderation",
9
+ "module"
10
+ ],
11
+ "private": false,
12
+ "license": "HSAL 1.0",
13
+ "homepage": "https://helyx.gg/modules/",
14
+ "bugs": {
15
+ "url": "https://github.com/ZyC0R3/Helyx/issues"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/ZyC0R3/Helyx.git",
20
+ "directory": "modules/moderation"
21
+ },
22
+ "engines": {
23
+ "node": ">=24 <27",
24
+ "npm": ">=11"
25
+ },
26
+ "type": "module",
27
+ "publishConfig": {
28
+ "access": "public",
29
+ "provenance": true
30
+ },
31
+ "files": [
32
+ "dist",
33
+ "!dist/*.tsbuildinfo",
34
+ "!dist/**/*.map",
35
+ "migrations",
36
+ "manifest.json",
37
+ "README.md",
38
+ "CHANGELOG.md",
39
+ "LICENSE"
40
+ ],
41
+ "exports": {
42
+ ".": {
43
+ "types": "./dist/index.d.ts",
44
+ "default": "./dist/index.js"
45
+ },
46
+ "./package.json": "./package.json"
47
+ },
48
+ "scripts": {
49
+ "build": "tsc -b",
50
+ "test": "vitest run --root ../.. modules/moderation/tests"
51
+ },
52
+ "dependencies": {
53
+ "@helyx/module-manifest": ">=0.11.0 <0.12.0",
54
+ "@helyx/sdk": ">=0.13.0 <0.14.0"
55
+ }
56
+ }