@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.
- package/CHANGELOG.md +41 -0
- package/LICENSE +725 -0
- package/README.md +107 -0
- package/dist/action-support.d.ts +48 -0
- package/dist/action-support.js +201 -0
- package/dist/case-actions.d.ts +9 -0
- package/dist/case-actions.js +311 -0
- package/dist/case-naming.d.ts +4 -0
- package/dist/case-naming.js +9 -0
- package/dist/case-repository.d.ts +29 -0
- package/dist/case-repository.js +199 -0
- package/dist/channel-actions.d.ts +3 -0
- package/dist/channel-actions.js +267 -0
- package/dist/commands.d.ts +4 -0
- package/dist/commands.js +309 -0
- package/dist/components.d.ts +15 -0
- package/dist/components.js +381 -0
- package/dist/configuration.d.ts +16 -0
- package/dist/configuration.js +94 -0
- package/dist/constants.d.ts +53 -0
- package/dist/constants.js +53 -0
- package/dist/contracts.d.ts +109 -0
- package/dist/contracts.js +84 -0
- package/dist/domain.d.ts +29 -0
- package/dist/domain.js +101 -0
- package/dist/events.d.ts +3 -0
- package/dist/events.js +60 -0
- package/dist/health.d.ts +10 -0
- package/dist/health.js +56 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +92 -0
- package/dist/moderation-cases-resource.d.ts +14 -0
- package/dist/moderation-cases-resource.js +226 -0
- package/dist/presentation.d.ts +8 -0
- package/dist/presentation.js +69 -0
- package/dist/privacy.d.ts +8 -0
- package/dist/privacy.js +38 -0
- package/dist/provider.d.ts +4 -0
- package/dist/provider.js +335 -0
- package/dist/receipt-repository.d.ts +9 -0
- package/dist/receipt-repository.js +36 -0
- package/dist/records.d.ts +399 -0
- package/dist/records.js +303 -0
- package/dist/repository-model.d.ts +131 -0
- package/dist/repository-model.js +318 -0
- package/dist/repository.d.ts +21 -0
- package/dist/repository.js +41 -0
- package/dist/service.d.ts +75 -0
- package/dist/service.js +329 -0
- package/dist/tasks.d.ts +49 -0
- package/dist/tasks.js +391 -0
- package/dist/thread-controls.d.ts +23 -0
- package/dist/thread-controls.js +376 -0
- package/dist/thread-deletion-recovery.d.ts +13 -0
- package/dist/thread-deletion-recovery.js +46 -0
- package/dist/thread-delivery.d.ts +11 -0
- package/dist/thread-delivery.js +427 -0
- package/dist/thread-reconciliation.d.ts +24 -0
- package/dist/thread-reconciliation.js +181 -0
- package/dist/thread-repository.d.ts +16 -0
- package/dist/thread-repository.js +70 -0
- package/manifest.json +999 -0
- package/migrations/0001_moderation_foundation.sql +552 -0
- package/migrations/0002_staff_attempt_parameters.sql +27 -0
- 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
|
+
}
|