@helyx/bot 1.3.1 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +100 -28
- package/README.md +65 -11
- package/dist/channel-audit-configuration.d.ts +7 -0
- package/dist/channel-audit-configuration.js +40 -0
- package/dist/channel-audit-control.d.ts +14 -0
- package/dist/channel-audit-control.js +61 -0
- package/dist/channel-audit-permissions.d.ts +56 -0
- package/dist/channel-audit-permissions.js +417 -0
- package/dist/channel-audit-routes.d.ts +4 -0
- package/dist/channel-audit-routes.js +42 -0
- package/dist/cloud-connector.d.ts +11 -4
- package/dist/cloud-connector.js +54 -15
- package/dist/cloud-control-dispatcher.d.ts +8 -3
- package/dist/cloud-control-dispatcher.js +84 -4
- package/dist/control-errors.d.ts +12 -0
- package/dist/control-errors.js +32 -0
- package/dist/control-server-schemas.d.ts +84 -0
- package/dist/control-server-schemas.js +101 -0
- package/dist/control-server.d.ts +25 -8
- package/dist/control-server.js +101 -152
- package/dist/core-retention.d.ts +26 -0
- package/dist/core-retention.js +49 -0
- package/dist/creator-guild-join-service.d.ts +25 -0
- package/dist/creator-guild-join-service.js +218 -0
- package/dist/creator-invite-service.d.ts +37 -0
- package/dist/creator-invite-service.js +686 -0
- package/dist/creator-onboarding-control.d.ts +12 -0
- package/dist/creator-onboarding-control.js +65 -0
- package/dist/creator-onboarding-platform.d.ts +20 -0
- package/dist/creator-onboarding-platform.js +58 -0
- package/dist/dashboard-action-control.d.ts +2 -2
- package/dist/dashboard-action-control.js +2 -3
- package/dist/database-bootstrap.d.ts +36 -0
- package/dist/database-bootstrap.js +101 -0
- package/dist/developer-operations-module.d.ts +1 -0
- package/dist/developer-operations-module.js +34 -16
- package/dist/discord-channel-audit.d.ts +9 -0
- package/dist/discord-channel-audit.js +195 -0
- package/dist/discord-guild-health.d.ts +2 -2
- package/dist/discord-owned-message-service.d.ts +1 -0
- package/dist/discord-owned-message-service.js +3 -0
- package/dist/discord-role-safety.d.ts +4 -0
- package/dist/discord-role-safety.js +30 -0
- package/dist/events.d.ts +2 -0
- package/dist/events.js +5 -20
- package/dist/guild-command-read-service.d.ts +8 -3
- package/dist/guild-command-read-service.js +14 -7
- package/dist/guild-command-release.d.ts +15 -8
- package/dist/guild-command-release.js +10 -3
- package/dist/guild-command-worker.d.ts +17 -4
- package/dist/guild-command-worker.js +17 -6
- package/dist/guilds.d.ts +24 -3
- package/dist/guilds.js +185 -64
- package/dist/hosted-server-moderation.js +10 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.js +200 -82
- package/dist/interactions.js +16 -3
- package/dist/module-configuration.d.ts +1 -1
- package/dist/module-configuration.js +5 -2
- package/dist/module-logging.d.ts +8 -3
- package/dist/module-logging.js +13 -7
- package/dist/modules.d.ts +1 -0
- package/dist/modules.js +8 -1
- package/dist/mysql-runtime-persistence.d.ts +46 -0
- package/dist/mysql-runtime-persistence.js +91 -0
- package/dist/oauth-module.js +2 -2
- package/dist/oauth-service.d.ts +7 -0
- package/dist/oauth-service.js +3 -0
- package/dist/scheduled-task-runner.d.ts +9 -85
- package/dist/scheduled-task-runner.js +1 -1
- package/dist/shard-contracts.d.ts +222 -0
- package/dist/shard-contracts.js +52 -1
- package/dist/shard-router.d.ts +4 -1
- package/dist/shard-router.js +19 -0
- package/dist/shard-supervisor.js +43 -9
- package/dist/shard-worker.d.ts +5 -2
- package/dist/shard-worker.js +41 -5
- package/dist/supervisor-cloud-directory.d.ts +9 -3
- package/dist/supervisor-cloud-directory.js +8 -12
- package/dist/trivia-data-provider.d.ts +11 -0
- package/dist/trivia-data-provider.js +46 -26
- package/dist/verification-service.d.ts +2 -2
- package/dist/verification-service.js +6 -3
- package/documentation/README.md +5 -3
- package/documentation/examples/.env.example +15 -11
- package/documentation/guides/getting-started.md +29 -12
- package/documentation/guides/privacy-and-support.md +6 -5
- package/documentation/guides/self-hosting.md +16 -11
- package/documentation/modules/auto-moderation.md +7 -5
- package/documentation/modules/building-a-module.md +48 -2
- package/documentation/modules/moderation.md +5 -5
- package/documentation/modules/publishing-a-module.md +6 -4
- package/documentation/modules/testing-a-module.md +39 -4
- package/documentation/reference/environment.md +43 -9
- package/documentation/runbooks/backup-and-recovery.md +26 -7
- package/documentation/runbooks/installation-and-upgrades.md +97 -27
- package/package.json +20 -16
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
import { CHANNEL_AUDIT_LIMITS } from "@helyx/control-protocol/channel-audit";
|
|
2
|
+
export const AUDIT_PERMISSIONS = {
|
|
3
|
+
Administrator: 1n << 3n,
|
|
4
|
+
ManageGuild: 1n << 5n,
|
|
5
|
+
ViewChannel: 1n << 10n,
|
|
6
|
+
SendMessages: 1n << 11n,
|
|
7
|
+
ReadMessageHistory: 1n << 16n,
|
|
8
|
+
Connect: 1n << 20n,
|
|
9
|
+
Speak: 1n << 21n,
|
|
10
|
+
ManageRoles: 1n << 28n,
|
|
11
|
+
ManageChannels: 1n << 4n,
|
|
12
|
+
KickMembers: 1n << 1n,
|
|
13
|
+
BanMembers: 1n << 2n,
|
|
14
|
+
RequestToSpeak: 1n << 32n,
|
|
15
|
+
SendMessagesInThreads: 1n << 38n,
|
|
16
|
+
};
|
|
17
|
+
/** Discord's role union, everyone overwrite, then aggregate role deny/allow. */
|
|
18
|
+
export function effectiveAuditPermissions(guildId, roles, selectedRoleIds, channel) {
|
|
19
|
+
if (channel.obfuscated)
|
|
20
|
+
return null;
|
|
21
|
+
const selected = new Set([guildId, ...selectedRoleIds]);
|
|
22
|
+
const roleMap = new Map(roles.map((role) => [role.id, role]));
|
|
23
|
+
let permissions = 0n;
|
|
24
|
+
for (const id of selected) {
|
|
25
|
+
const role = roleMap.get(id);
|
|
26
|
+
if (!role)
|
|
27
|
+
return null;
|
|
28
|
+
permissions |= role.permissions;
|
|
29
|
+
}
|
|
30
|
+
if (has(permissions, AUDIT_PERMISSIONS.Administrator))
|
|
31
|
+
return (1n << 64n) - 1n;
|
|
32
|
+
const everyone = channel.overwrites.find((overwrite) => overwrite.type === 0 && overwrite.id === guildId);
|
|
33
|
+
if (everyone)
|
|
34
|
+
permissions = (permissions & ~everyone.deny) | everyone.allow;
|
|
35
|
+
let allow = 0n, deny = 0n;
|
|
36
|
+
for (const overwrite of channel.overwrites) {
|
|
37
|
+
if (overwrite.type !== 0 ||
|
|
38
|
+
overwrite.id === guildId ||
|
|
39
|
+
!selected.has(overwrite.id))
|
|
40
|
+
continue;
|
|
41
|
+
allow |= overwrite.allow;
|
|
42
|
+
deny |= overwrite.deny;
|
|
43
|
+
}
|
|
44
|
+
return (permissions & ~deny) | allow;
|
|
45
|
+
}
|
|
46
|
+
function has(permissions, permission) {
|
|
47
|
+
return permissions !== null && (permissions & permission) === permission;
|
|
48
|
+
}
|
|
49
|
+
const channelTypes = new Map([
|
|
50
|
+
[0, "Text"],
|
|
51
|
+
[5, "Announcement"],
|
|
52
|
+
[15, "Forum"],
|
|
53
|
+
[16, "Media"],
|
|
54
|
+
[2, "Voice"],
|
|
55
|
+
[13, "Stage"],
|
|
56
|
+
[4, "Category"],
|
|
57
|
+
[10, "Announcement thread"],
|
|
58
|
+
[11, "Public thread"],
|
|
59
|
+
[12, "Private thread"],
|
|
60
|
+
]);
|
|
61
|
+
const activityTypes = new Set([0, 5, 15, 16, 2, 13]);
|
|
62
|
+
/** Raw bit presence is insufficient: view/connect prerequisites gate actions. */
|
|
63
|
+
export function auditActions(permissions, type) {
|
|
64
|
+
if (!activityTypes.has(type) ||
|
|
65
|
+
!has(permissions, AUDIT_PERMISSIONS.ViewChannel))
|
|
66
|
+
return [];
|
|
67
|
+
const actions = ["View"];
|
|
68
|
+
if ([0, 5].includes(type)) {
|
|
69
|
+
if (has(permissions, AUDIT_PERMISSIONS.ReadMessageHistory))
|
|
70
|
+
actions.push("Read existing panel");
|
|
71
|
+
if (has(permissions, AUDIT_PERMISSIONS.SendMessages))
|
|
72
|
+
actions.push("Post messages");
|
|
73
|
+
}
|
|
74
|
+
else if ([15, 16].includes(type)) {
|
|
75
|
+
if (has(permissions, AUDIT_PERMISSIONS.SendMessages))
|
|
76
|
+
actions.push("Create posts");
|
|
77
|
+
if (has(permissions, AUDIT_PERMISSIONS.SendMessagesInThreads))
|
|
78
|
+
actions.push("Reply in accessible threads");
|
|
79
|
+
}
|
|
80
|
+
else if (has(permissions, AUDIT_PERMISSIONS.Connect)) {
|
|
81
|
+
actions.push("Connect");
|
|
82
|
+
if (type === 2 && has(permissions, AUDIT_PERMISSIONS.Speak))
|
|
83
|
+
actions.push("Speak");
|
|
84
|
+
if (type === 13 && has(permissions, AUDIT_PERMISSIONS.RequestToSpeak))
|
|
85
|
+
actions.push("Request to speak");
|
|
86
|
+
}
|
|
87
|
+
return actions;
|
|
88
|
+
}
|
|
89
|
+
const panelUsable = (permissions, type) => [0, 5].includes(type) &&
|
|
90
|
+
has(permissions, AUDIT_PERMISSIONS.ViewChannel | AUDIT_PERMISSIONS.ReadMessageHistory);
|
|
91
|
+
export function buildChannelAuditReport(snapshot, moduleId, assumptions, observedAt) {
|
|
92
|
+
const findings = [];
|
|
93
|
+
const channels = [];
|
|
94
|
+
const add = (finding) => findings.push(finding);
|
|
95
|
+
const roles = new Map(snapshot.roles.map((role) => [role.id, role]));
|
|
96
|
+
const requiredRoles = new Set([
|
|
97
|
+
...assumptions.beforeRoleIds,
|
|
98
|
+
assumptions.afterRoleId,
|
|
99
|
+
]);
|
|
100
|
+
for (const roleId of requiredRoles) {
|
|
101
|
+
if (!roles.has(roleId))
|
|
102
|
+
add(snapshot.rolesComplete
|
|
103
|
+
? {
|
|
104
|
+
code: "role_not_in_guild",
|
|
105
|
+
category: "blocked",
|
|
106
|
+
title: "Selected role is not in this server",
|
|
107
|
+
detail: "The fresh server role list does not contain this role. Select a current role from this server.",
|
|
108
|
+
roleId,
|
|
109
|
+
}
|
|
110
|
+
: {
|
|
111
|
+
code: "role_unknown",
|
|
112
|
+
category: "unknown",
|
|
113
|
+
title: "Could not inspect a selected role",
|
|
114
|
+
detail: "The current server role list could not be fetched. This is not evidence that the role was deleted.",
|
|
115
|
+
roleId,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
const afterRole = roles.get(assumptions.afterRoleId);
|
|
119
|
+
if (afterRole?.assignmentPolicyReason)
|
|
120
|
+
add({
|
|
121
|
+
code: "after_role_policy_blocked",
|
|
122
|
+
category: "blocked",
|
|
123
|
+
title: "Helyx's safety policy blocks the after-check role",
|
|
124
|
+
detail: afterRole.assignmentPolicyReason,
|
|
125
|
+
roleId: afterRole.id,
|
|
126
|
+
});
|
|
127
|
+
for (const roleId of assumptions.beforeRoleIds) {
|
|
128
|
+
const beforeRole = roles.get(roleId);
|
|
129
|
+
if (beforeRole?.assignmentPolicyReason)
|
|
130
|
+
add({
|
|
131
|
+
code: "before_role_removal_policy_risk",
|
|
132
|
+
category: "review",
|
|
133
|
+
title: "Safety policy may prevent removing a before-check role",
|
|
134
|
+
detail: `${beforeRole.assignmentPolicyReason} If the entry workflow removes this role, that removal is also blocked.`,
|
|
135
|
+
roleId,
|
|
136
|
+
});
|
|
137
|
+
if (beforeRole?.assignable === false)
|
|
138
|
+
add({
|
|
139
|
+
code: "before_role_removal_risk",
|
|
140
|
+
category: "review",
|
|
141
|
+
title: "Review removal of a selected before-check role",
|
|
142
|
+
detail: `${beforeRole.assignmentReason ?? "Helyx cannot currently manage this role."} If the entry workflow is configured to remove it, that transition may fail.`,
|
|
143
|
+
roleId,
|
|
144
|
+
});
|
|
145
|
+
else if (beforeRole?.assignable === null)
|
|
146
|
+
add({
|
|
147
|
+
code: "before_role_removal_unknown",
|
|
148
|
+
category: "unknown",
|
|
149
|
+
title: "Before-role removal capability is unknown",
|
|
150
|
+
detail: "Helyx's current role-management capability could not be established. No removal is attempted by this audit.",
|
|
151
|
+
roleId,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
if (afterRole &&
|
|
155
|
+
(afterRole.assignable === false ||
|
|
156
|
+
afterRole.managed ||
|
|
157
|
+
afterRole.id === snapshot.guildId))
|
|
158
|
+
add({
|
|
159
|
+
code: "after_role_unassignable",
|
|
160
|
+
category: "blocked",
|
|
161
|
+
title: "Helyx cannot assign the after-check role",
|
|
162
|
+
detail: afterRole.assignmentReason ??
|
|
163
|
+
"The role is managed, everyone, above Helyx, or Helyx lacks Manage Roles. This audit changes none of them.",
|
|
164
|
+
roleId: afterRole.id,
|
|
165
|
+
});
|
|
166
|
+
else if (afterRole?.assignable === null)
|
|
167
|
+
add({
|
|
168
|
+
code: "after_role_unknown",
|
|
169
|
+
category: "unknown",
|
|
170
|
+
title: "Role assignment capability could not be checked",
|
|
171
|
+
detail: "Helyx's current member and role-management permissions are unavailable.",
|
|
172
|
+
roleId: afterRole.id,
|
|
173
|
+
});
|
|
174
|
+
const dangerous = Object.entries(AUDIT_PERMISSIONS).filter(([name]) => [
|
|
175
|
+
"Administrator",
|
|
176
|
+
"ManageGuild",
|
|
177
|
+
"ManageRoles",
|
|
178
|
+
"ManageChannels",
|
|
179
|
+
"KickMembers",
|
|
180
|
+
"BanMembers",
|
|
181
|
+
].includes(name));
|
|
182
|
+
for (const roleId of new Set([snapshot.guildId, ...requiredRoles])) {
|
|
183
|
+
const role = roles.get(roleId);
|
|
184
|
+
const names = role
|
|
185
|
+
? dangerous
|
|
186
|
+
.filter(([, bit]) => has(role.permissions, bit))
|
|
187
|
+
.map(([name]) => name)
|
|
188
|
+
: [];
|
|
189
|
+
if (names.length)
|
|
190
|
+
add({
|
|
191
|
+
code: "high_impact_role",
|
|
192
|
+
category: "review",
|
|
193
|
+
title: "Review elevated role permissions",
|
|
194
|
+
detail: `Role grants ${names.join(", ")}. These permissions can undermine a restricted entrance.`,
|
|
195
|
+
roleId,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
const selectedIds = new Set([
|
|
199
|
+
assumptions.entryChannelId,
|
|
200
|
+
...assumptions.allowedChannelIds,
|
|
201
|
+
...(assumptions.reviewChannelId ? [assumptions.reviewChannelId] : []),
|
|
202
|
+
]);
|
|
203
|
+
const ordered = [...snapshot.channels].sort((left, right) => Number(selectedIds.has(right.id)) - Number(selectedIds.has(left.id)) ||
|
|
204
|
+
left.id.localeCompare(right.id));
|
|
205
|
+
const inspected = ordered.slice(0, CHANNEL_AUDIT_LIMITS.channels);
|
|
206
|
+
let unknownChannels = 0, unsupportedChannels = 0, broadAccess = 0;
|
|
207
|
+
const rolePaths = assumptions.beforeRoleIds.map((roleId) => ({
|
|
208
|
+
roleId,
|
|
209
|
+
entranceUsable: null,
|
|
210
|
+
visibleChannels: 0,
|
|
211
|
+
}));
|
|
212
|
+
for (const channel of inspected) {
|
|
213
|
+
if (channel.obfuscated) {
|
|
214
|
+
unknownChannels += 1;
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
if (!activityTypes.has(channel.type)) {
|
|
218
|
+
unsupportedChannels += 1;
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
const before = effectiveAuditPermissions(snapshot.guildId, snapshot.roles, assumptions.beforeRoleIds, channel);
|
|
222
|
+
const everyone = effectiveAuditPermissions(snapshot.guildId, snapshot.roles, [], channel);
|
|
223
|
+
const after = effectiveAuditPermissions(snapshot.guildId, snapshot.roles, [assumptions.afterRoleId], channel);
|
|
224
|
+
const retained = effectiveAuditPermissions(snapshot.guildId, snapshot.roles, [...assumptions.beforeRoleIds, assumptions.afterRoleId], channel);
|
|
225
|
+
const row = {
|
|
226
|
+
id: channel.id,
|
|
227
|
+
name: channel.name.slice(0, 100),
|
|
228
|
+
type: channelTypes.get(channel.type) ?? "Unsupported",
|
|
229
|
+
beforeActions: auditActions(before, channel.type),
|
|
230
|
+
afterActions: auditActions(after, channel.type),
|
|
231
|
+
retainedActions: auditActions(retained, channel.type),
|
|
232
|
+
limitations: [],
|
|
233
|
+
};
|
|
234
|
+
if (before === null || after === null)
|
|
235
|
+
row.limitations.push("Selected role assumptions could not all be evaluated.");
|
|
236
|
+
if (channel.overwrites.some((overwrite) => overwrite.type === 1))
|
|
237
|
+
row.limitations.push("Member-specific overwrites can change an individual's access.");
|
|
238
|
+
if ([15, 16].includes(channel.type))
|
|
239
|
+
row.limitations.push("Thread membership, archive/lock and thread-specific state are not inspected.");
|
|
240
|
+
if (channels.length < CHANNEL_AUDIT_LIMITS.reportChannels)
|
|
241
|
+
channels.push(row);
|
|
242
|
+
for (const path of rolePaths) {
|
|
243
|
+
const permissions = effectiveAuditPermissions(snapshot.guildId, snapshot.roles, [path.roleId], channel);
|
|
244
|
+
if (has(permissions, AUDIT_PERMISSIONS.ViewChannel))
|
|
245
|
+
path.visibleChannels += 1;
|
|
246
|
+
if (channel.id === assumptions.entryChannelId)
|
|
247
|
+
path.entranceUsable =
|
|
248
|
+
permissions === null ? null : panelUsable(permissions, channel.type);
|
|
249
|
+
}
|
|
250
|
+
if (channel.id === assumptions.entryChannelId) {
|
|
251
|
+
if (before === null || everyone === null)
|
|
252
|
+
add({
|
|
253
|
+
code: "entrance_unknown",
|
|
254
|
+
category: "unknown",
|
|
255
|
+
title: "Could not evaluate the entrance",
|
|
256
|
+
detail: "One or more required role assumptions are unavailable.",
|
|
257
|
+
channelId: channel.id,
|
|
258
|
+
});
|
|
259
|
+
else if (!panelUsable(before, channel.type))
|
|
260
|
+
add({
|
|
261
|
+
code: "entrance_unusable",
|
|
262
|
+
category: "blocked",
|
|
263
|
+
title: "Assumed entrants cannot reliably reach the panel",
|
|
264
|
+
detail: "A supported text or announcement entrance needs View Channel and Read Message History. Send Messages is not required to press a panel button.",
|
|
265
|
+
channelId: channel.id,
|
|
266
|
+
});
|
|
267
|
+
else if (assumptions.entrance === "public" &&
|
|
268
|
+
!panelUsable(everyone, channel.type))
|
|
269
|
+
add({
|
|
270
|
+
code: "public_entrance_unusable",
|
|
271
|
+
category: "blocked",
|
|
272
|
+
title: "Everyone cannot use the intended public entrance",
|
|
273
|
+
detail: "The public path needs View Channel and Read Message History without relying on selected extra roles.",
|
|
274
|
+
channelId: channel.id,
|
|
275
|
+
});
|
|
276
|
+
else {
|
|
277
|
+
add({
|
|
278
|
+
code: "entrance_working",
|
|
279
|
+
category: "working",
|
|
280
|
+
title: "Entrance is reachable under the selected assumptions",
|
|
281
|
+
detail: "Role-only permissions allow reading the entrance. This does not prove a panel exists, is active or will accept a particular member.",
|
|
282
|
+
channelId: channel.id,
|
|
283
|
+
});
|
|
284
|
+
if (assumptions.entrance === "restricted" &&
|
|
285
|
+
has(everyone, AUDIT_PERMISSIONS.ViewChannel))
|
|
286
|
+
add({
|
|
287
|
+
code: "entrance_public",
|
|
288
|
+
category: "review",
|
|
289
|
+
title: "The restricted entrance is visible to everyone",
|
|
290
|
+
detail: "Review whether the selected public/restricted intent matches your server design. No lockdown or permission change is applied.",
|
|
291
|
+
channelId: channel.id,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
const publishing = AUDIT_PERMISSIONS.ViewChannel | AUDIT_PERMISSIONS.SendMessages;
|
|
295
|
+
if (channel.botPermissions === null)
|
|
296
|
+
add({
|
|
297
|
+
code: "bot_publish_unknown",
|
|
298
|
+
category: "unknown",
|
|
299
|
+
title: "Helyx publishing access could not be checked",
|
|
300
|
+
detail: "Current bot-specific permissions are unavailable.",
|
|
301
|
+
channelId: channel.id,
|
|
302
|
+
});
|
|
303
|
+
else if (!has(channel.botPermissions, publishing))
|
|
304
|
+
add({
|
|
305
|
+
code: "bot_cannot_publish",
|
|
306
|
+
category: "blocked",
|
|
307
|
+
title: "Helyx cannot publish an entrance panel here",
|
|
308
|
+
detail: "Helyx needs View Channel and Send Messages in the selected entrance. Existing publication safeguards still apply.",
|
|
309
|
+
channelId: channel.id,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
else if (has(before, AUDIT_PERMISSIONS.ViewChannel) &&
|
|
313
|
+
!assumptions.allowedChannelIds.includes(channel.id))
|
|
314
|
+
broadAccess += 1;
|
|
315
|
+
if (channel.id === assumptions.reviewChannelId &&
|
|
316
|
+
has(before, AUDIT_PERMISSIONS.ViewChannel))
|
|
317
|
+
add({
|
|
318
|
+
code: "review_exposure",
|
|
319
|
+
category: "review",
|
|
320
|
+
title: "Assumed entrants can view the selected staff-review channel",
|
|
321
|
+
detail: "Review this explicitly selected staff-review destination separately from entrance visibility.",
|
|
322
|
+
channelId: channel.id,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
for (const channelId of selectedIds) {
|
|
326
|
+
const invalid = snapshot.invalidSelectedChannels?.find((item) => item.id === channelId);
|
|
327
|
+
if (invalid) {
|
|
328
|
+
add({
|
|
329
|
+
code: "selected_channel_invalid",
|
|
330
|
+
category: "blocked",
|
|
331
|
+
title: "Selected channel is unavailable in this server",
|
|
332
|
+
detail: invalid.reason === "wrong_guild"
|
|
333
|
+
? "The fresh channel lookup identified a different server. Choose a channel from this server."
|
|
334
|
+
: "Discord returned Unknown Channel for this selected ID. It may be deleted or invalid; this does not establish when or why it became unavailable.",
|
|
335
|
+
channelId,
|
|
336
|
+
});
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
const channel = inspected.find((item) => item.id === channelId);
|
|
340
|
+
if (!channel || channel.obfuscated)
|
|
341
|
+
add({
|
|
342
|
+
code: "selected_channel_unknown",
|
|
343
|
+
category: "unknown",
|
|
344
|
+
title: "A selected channel could not be inspected",
|
|
345
|
+
detail: "Discord may omit or obfuscate channels Helyx cannot view. Absence from this inspection is not proof of deletion or of entrant denial.",
|
|
346
|
+
channelId,
|
|
347
|
+
});
|
|
348
|
+
else if (!activityTypes.has(channel.type))
|
|
349
|
+
add({
|
|
350
|
+
code: "selected_channel_unsupported",
|
|
351
|
+
category: "unknown",
|
|
352
|
+
title: "Selected channel type is outside this audit",
|
|
353
|
+
detail: "Categories, individual threads and other unsupported types are not counted as a usable entrance or as a pass.",
|
|
354
|
+
channelId,
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
if (broadAccess)
|
|
358
|
+
add({
|
|
359
|
+
code: "wider_access",
|
|
360
|
+
category: "review",
|
|
361
|
+
title: "Review wider before-check access",
|
|
362
|
+
detail: `The combined before-role assumption can view ${broadAccess} inspected channels beyond the entrance and allowed exceptions. Wider access is advisory.`,
|
|
363
|
+
});
|
|
364
|
+
if (!assumptions.reviewChannelId)
|
|
365
|
+
add({
|
|
366
|
+
code: "review_context_unknown",
|
|
367
|
+
category: "unknown",
|
|
368
|
+
title: "Staff-review exposure has no selected context",
|
|
369
|
+
detail: "Select a staff-review channel or explicitly Load config to assess that destination. Hidden channels are not assumed to be staff-only.",
|
|
370
|
+
});
|
|
371
|
+
const limitations = [
|
|
372
|
+
"Role assumptions exclude member overwrites, screening, timeouts and actual member assignment; no member directory or messages are read.",
|
|
373
|
+
moduleId === "helyx.account-gate"
|
|
374
|
+
? "Access Control grants the access role, then attempts removal of its configured temporary roles. Selected before roles are assumptions, and delivery can be partial."
|
|
375
|
+
: "Verification grants the verified role and removes its configured removal role when present. It does not assign the before roles, and other member roles can remain.",
|
|
376
|
+
"After-check view assumes selected before roles were removed; the retained view shows their combination with the after role. Actual transitions can differ or partially complete.",
|
|
377
|
+
"The retained view includes all selected before roles; it does not enumerate every partially retained role combination.",
|
|
378
|
+
...snapshot.fetchLimitations,
|
|
379
|
+
];
|
|
380
|
+
if (!snapshot.totalChannelsKnown)
|
|
381
|
+
limitations.push("Coverage is limited to Discord's bot-visible channel list and known inventory; other inaccessible channels may be omitted and their total is unknown.");
|
|
382
|
+
const channelsTruncated = snapshot.channels.length > inspected.length ||
|
|
383
|
+
inspected.filter((channel) => !channel.obfuscated && activityTypes.has(channel.type)).length > channels.length;
|
|
384
|
+
if (unknownChannels || unsupportedChannels || channelsTruncated)
|
|
385
|
+
add({
|
|
386
|
+
code: "coverage_incomplete",
|
|
387
|
+
category: "unknown",
|
|
388
|
+
title: "Some channel coverage is incomplete",
|
|
389
|
+
detail: `${unknownChannels} observed channels are unknown/obfuscated; ${unsupportedChannels} types are unsupported. ${channelsTruncated ? "Inspection or displayed channel rows were capped." : "Unsupported channels are not passed."}`,
|
|
390
|
+
});
|
|
391
|
+
// Keep failures and uncertainty before successful findings if the output cap is reached.
|
|
392
|
+
const priority = { blocked: 0, unknown: 1, review: 2, working: 3 };
|
|
393
|
+
findings.sort((left, right) => priority[left.category] - priority[right.category]);
|
|
394
|
+
return {
|
|
395
|
+
guildId: snapshot.guildId,
|
|
396
|
+
moduleId,
|
|
397
|
+
observedAt,
|
|
398
|
+
assumptions: structuredClone(assumptions),
|
|
399
|
+
simulation: false,
|
|
400
|
+
findings: findings.slice(0, CHANNEL_AUDIT_LIMITS.findings),
|
|
401
|
+
channels,
|
|
402
|
+
rolePaths,
|
|
403
|
+
coverage: {
|
|
404
|
+
fetchedChannels: snapshot.fetchedChannels,
|
|
405
|
+
totalChannelsKnown: snapshot.totalChannelsKnown,
|
|
406
|
+
inspectedChannels: inspected.length,
|
|
407
|
+
returnedChannels: channels.length,
|
|
408
|
+
unknownChannels,
|
|
409
|
+
unsupportedChannels,
|
|
410
|
+
channelsTruncated,
|
|
411
|
+
findingsTruncated: findings.length > CHANNEL_AUDIT_LIMITS.findings,
|
|
412
|
+
roleOnly: true,
|
|
413
|
+
limitations: limitations.slice(0, 12),
|
|
414
|
+
},
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
//# sourceMappingURL=channel-audit-permissions.js.map
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { channelAuditModuleIdSchema, channelAuditInputSchema, channelAuditConfigurationSchema, channelAuditReportSchema, } from "@helyx/control-protocol/channel-audit";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { BotControlTransportError } from "./control-errors.js";
|
|
4
|
+
const parameters = z
|
|
5
|
+
.object({
|
|
6
|
+
guildId: z.string().regex(/^\d{17,20}$/u),
|
|
7
|
+
moduleId: channelAuditModuleIdSchema,
|
|
8
|
+
})
|
|
9
|
+
.strict();
|
|
10
|
+
const actor = z
|
|
11
|
+
.object({
|
|
12
|
+
actorUserId: z.string().regex(/^\d{17,20}$/u),
|
|
13
|
+
correlationId: z.uuid(),
|
|
14
|
+
})
|
|
15
|
+
.strict();
|
|
16
|
+
export function registerChannelAuditRoutes(server, control) {
|
|
17
|
+
server.post("/v1/guilds/:guildId/modules/:moduleId/channel-audit/config", async (request) => {
|
|
18
|
+
const input = {
|
|
19
|
+
...parameters.parse(request.params),
|
|
20
|
+
...actor.parse(request.body),
|
|
21
|
+
};
|
|
22
|
+
if (!control.readChannelAuditConfiguration)
|
|
23
|
+
throw unsupported();
|
|
24
|
+
return channelAuditConfigurationSchema.parse(await control.readChannelAuditConfiguration(input));
|
|
25
|
+
});
|
|
26
|
+
server.post("/v1/guilds/:guildId/modules/:moduleId/channel-audit/check", async (request) => {
|
|
27
|
+
const input = {
|
|
28
|
+
...parameters.parse(request.params),
|
|
29
|
+
...actor
|
|
30
|
+
.extend({ assumptions: channelAuditInputSchema })
|
|
31
|
+
.strict()
|
|
32
|
+
.parse(request.body),
|
|
33
|
+
};
|
|
34
|
+
if (!control.checkChannelAudit)
|
|
35
|
+
throw unsupported();
|
|
36
|
+
return channelAuditReportSchema.parse(await control.checkChannelAudit(input));
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
function unsupported() {
|
|
40
|
+
return new BotControlTransportError(503, "This bot version does not support Channel audit. Update the bot before trying again.");
|
|
41
|
+
}
|
|
42
|
+
//# sourceMappingURL=channel-audit-routes.js.map
|
|
@@ -4,6 +4,8 @@ import { type CloudConnectorConsentRecord, type DatabasePool } from "@helyx/data
|
|
|
4
4
|
import type { Client } from "discord.js";
|
|
5
5
|
import type { Logger } from "pino";
|
|
6
6
|
import type { ConfiguredModulePackage } from "./modules.js";
|
|
7
|
+
import type { GuildCommandReadHandler } from "./guild-command-read-service.js";
|
|
8
|
+
import { type CloudConnectorStore } from "./cloud-control-dispatcher.js";
|
|
7
9
|
import { type BotControlHandler } from "./control-server.js";
|
|
8
10
|
type ConnectorState = "disabled" | "unpaired" | "connecting" | "online" | "offline" | "recovery_required" | "revoked" | "stopped";
|
|
9
11
|
export interface CloudConnectorStatus {
|
|
@@ -27,7 +29,7 @@ export interface CloudDiscordDirectory {
|
|
|
27
29
|
snapshot(): Promise<CloudDiscordSnapshot>;
|
|
28
30
|
}
|
|
29
31
|
export interface CloudConnector {
|
|
30
|
-
setControlHandler(handler: BotControlHandler): void;
|
|
32
|
+
setControlHandler(handler: BotControlHandler, commands?: GuildCommandReadHandler): void;
|
|
31
33
|
start(): Promise<void>;
|
|
32
34
|
status(): CloudConnectorStatus;
|
|
33
35
|
getSupportConsent(): Promise<CloudConnectorConsentRecord>;
|
|
@@ -54,13 +56,18 @@ export declare class CloudConnectorController implements CloudConnector {
|
|
|
54
56
|
private readonly input;
|
|
55
57
|
constructor(input: {
|
|
56
58
|
environment: BotEnvironment;
|
|
57
|
-
database: DatabasePool;
|
|
58
59
|
discord: CloudDiscordDirectory;
|
|
59
60
|
modules: readonly ConfiguredModulePackage[];
|
|
60
61
|
coreVersion: string;
|
|
61
62
|
logger: Logger;
|
|
62
|
-
}
|
|
63
|
-
|
|
63
|
+
} & ({
|
|
64
|
+
database: DatabasePool;
|
|
65
|
+
repository?: never;
|
|
66
|
+
} | {
|
|
67
|
+
repository: CloudConnectorStore;
|
|
68
|
+
database?: never;
|
|
69
|
+
}));
|
|
70
|
+
setControlHandler(handler: BotControlHandler, commands?: GuildCommandReadHandler): void;
|
|
64
71
|
start(): Promise<void>;
|
|
65
72
|
status(): CloudConnectorStatus;
|
|
66
73
|
getSupportConsent(): Promise<CloudConnectorConsentRecord>;
|
package/dist/cloud-connector.js
CHANGED
|
@@ -87,7 +87,8 @@ export class CloudConnectorController {
|
|
|
87
87
|
#pendingSupport = new Map();
|
|
88
88
|
constructor(input) {
|
|
89
89
|
this.input = input;
|
|
90
|
-
this.#repository =
|
|
90
|
+
this.#repository =
|
|
91
|
+
input.repository ?? new CloudConnectorRepository(input.database);
|
|
91
92
|
this.#state = input.environment.HELYX_CLOUD_CONNECT_ENABLED
|
|
92
93
|
? "unpaired"
|
|
93
94
|
: "disabled";
|
|
@@ -95,8 +96,10 @@ export class CloudConnectorController {
|
|
|
95
96
|
? Buffer.from(input.environment.HELYX_DATA_ENCRYPTION_KEY, "base64")
|
|
96
97
|
: undefined;
|
|
97
98
|
}
|
|
98
|
-
setControlHandler(handler) {
|
|
99
|
-
this.#dispatcher =
|
|
99
|
+
setControlHandler(handler, commands) {
|
|
100
|
+
this.#dispatcher = this.input.database
|
|
101
|
+
? new CloudControlDispatcher(this.input.database, handler, commands)
|
|
102
|
+
: new CloudControlDispatcher({ repository: this.#repository }, handler, commands);
|
|
100
103
|
}
|
|
101
104
|
async start() {
|
|
102
105
|
if (!this.input.environment.HELYX_CLOUD_CONNECT_ENABLED)
|
|
@@ -414,6 +417,8 @@ export class CloudConnectorController {
|
|
|
414
417
|
let outgoingSequence = 0;
|
|
415
418
|
let heartbeat;
|
|
416
419
|
let plannedReconnect;
|
|
420
|
+
let activeControlRequests = 0;
|
|
421
|
+
let draining = false;
|
|
417
422
|
const privateKey = decryptPrivateKey(identity.privateKey, this.#dataKey, identity.installationId);
|
|
418
423
|
const finished = new Promise((resolveFinished, rejectFinished) => {
|
|
419
424
|
const fail = (error) => {
|
|
@@ -475,10 +480,32 @@ export class CloudConnectorController {
|
|
|
475
480
|
installationId: identity.installationId,
|
|
476
481
|
connectionEpoch: accepted.data.connectionEpoch,
|
|
477
482
|
sequence: ++outgoingSequence,
|
|
478
|
-
payload: { state: "online" },
|
|
483
|
+
payload: { state: draining ? "draining" : "online" },
|
|
479
484
|
})));
|
|
480
485
|
}, accepted.data.heartbeatIntervalSeconds * 1_000);
|
|
481
|
-
plannedReconnect = setTimeout(() =>
|
|
486
|
+
plannedReconnect = setTimeout(() => {
|
|
487
|
+
if (socket.readyState !== WebSocket.OPEN)
|
|
488
|
+
return;
|
|
489
|
+
draining = true;
|
|
490
|
+
socket.send(JSON.stringify(connectedMessage({
|
|
491
|
+
type: "connector.draining",
|
|
492
|
+
installationId: identity.installationId,
|
|
493
|
+
connectionEpoch: accepted.data.connectionEpoch,
|
|
494
|
+
sequence: ++outgoingSequence,
|
|
495
|
+
payload: { reason: "session_rotation" },
|
|
496
|
+
})));
|
|
497
|
+
const deadline = Date.now() + 45_000;
|
|
498
|
+
const closeWhenIdle = () => {
|
|
499
|
+
if (socket.readyState !== WebSocket.OPEN)
|
|
500
|
+
return;
|
|
501
|
+
if (activeControlRequests === 0 || Date.now() >= deadline) {
|
|
502
|
+
socket.close(1012, "Planned Cloud Connect session rotation");
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
plannedReconnect = setTimeout(closeWhenIdle, 250);
|
|
506
|
+
};
|
|
507
|
+
plannedReconnect = setTimeout(closeWhenIdle, 1_000);
|
|
508
|
+
}, accepted.data.reconnectBeforeSeconds * 1_000);
|
|
482
509
|
return;
|
|
483
510
|
}
|
|
484
511
|
const rejected = connectorRejectedSchema.safeParse(payload);
|
|
@@ -518,16 +545,22 @@ export class CloudConnectorController {
|
|
|
518
545
|
}
|
|
519
546
|
if (message.type !== "control.request")
|
|
520
547
|
return;
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
548
|
+
activeControlRequests += 1;
|
|
549
|
+
try {
|
|
550
|
+
const response = await this.#dispatch(message.payload, message.correlationId);
|
|
551
|
+
if (socket.readyState === WebSocket.OPEN)
|
|
552
|
+
socket.send(JSON.stringify(connectedMessage({
|
|
553
|
+
type: "control.response",
|
|
554
|
+
installationId: identity.installationId,
|
|
555
|
+
connectionEpoch: message.connectionEpoch,
|
|
556
|
+
sequence: ++outgoingSequence,
|
|
557
|
+
correlationId: message.correlationId,
|
|
558
|
+
payload: response,
|
|
559
|
+
})));
|
|
560
|
+
}
|
|
561
|
+
finally {
|
|
562
|
+
activeControlRequests -= 1;
|
|
563
|
+
}
|
|
531
564
|
}
|
|
532
565
|
catch (error) {
|
|
533
566
|
this.input.logger.warn({
|
|
@@ -722,10 +755,16 @@ function connectorCapabilities() {
|
|
|
722
755
|
"guild.dashboard.access.read.v1",
|
|
723
756
|
"guild.access-policy.read.v1",
|
|
724
757
|
"guild.access-policy.write.v1",
|
|
758
|
+
"guild.commands.status.read.v1",
|
|
759
|
+
"guild.commands.history.read.v1",
|
|
760
|
+
"guild.commands.status.refresh.v1",
|
|
761
|
+
"guild.commands.rebuild.v1",
|
|
725
762
|
"guild.module.public.read.v1",
|
|
726
763
|
"guild.module.status.write.v1",
|
|
727
764
|
"guild.module.configuration.write.v1",
|
|
728
765
|
"guild.module.configuration.preview.v1",
|
|
766
|
+
"guild.module.channel-audit.config.read.v1",
|
|
767
|
+
"guild.module.channel-audit.check.v1",
|
|
729
768
|
"guild.module.permissions.write.v1",
|
|
730
769
|
"guild.module.logging.write.v1",
|
|
731
770
|
"guild.module.action.execute.v1",
|
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import { type CloudControlRequestPayload } from "@helyx/control-protocol";
|
|
2
|
-
import { type DatabasePool } from "@helyx/database";
|
|
2
|
+
import { CloudConnectorRepository, type DatabasePool } from "@helyx/database";
|
|
3
3
|
import type { BotControlHandler, BotControlState } from "./control-server.js";
|
|
4
|
+
import type { GuildCommandReadHandler } from "./guild-command-read-service.js";
|
|
5
|
+
export type CloudConnectorStore = Pick<CloudConnectorRepository, "getIdentity" | "createIdentity" | "rotateIdentity" | "markConnected" | "revoke" | "getConsent" | "updateConsent" | "appendAudit" | "cleanupAudit">;
|
|
4
6
|
export declare class CloudControlDispatcher {
|
|
5
7
|
#private;
|
|
6
|
-
private readonly database;
|
|
7
8
|
private readonly control;
|
|
8
|
-
|
|
9
|
+
private readonly commands?;
|
|
10
|
+
constructor(database: DatabasePool, control: BotControlHandler, commands?: GuildCommandReadHandler);
|
|
11
|
+
constructor(input: {
|
|
12
|
+
repository: CloudConnectorStore;
|
|
13
|
+
}, control: BotControlHandler, commands?: GuildCommandReadHandler);
|
|
9
14
|
execute(request: CloudControlRequestPayload, correlationId: string): Promise<unknown>;
|
|
10
15
|
}
|
|
11
16
|
export declare class CloudControlDeniedError extends Error {
|