@helyx/bot 1.4.0 → 1.5.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 (37) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/channel-audit-configuration.d.ts +7 -0
  3. package/dist/channel-audit-configuration.js +40 -0
  4. package/dist/channel-audit-control.d.ts +14 -0
  5. package/dist/channel-audit-control.js +61 -0
  6. package/dist/channel-audit-permissions.d.ts +56 -0
  7. package/dist/channel-audit-permissions.js +417 -0
  8. package/dist/channel-audit-routes.d.ts +4 -0
  9. package/dist/channel-audit-routes.js +42 -0
  10. package/dist/cloud-connector.js +2 -0
  11. package/dist/cloud-control-dispatcher.js +22 -0
  12. package/dist/command-registration-policy.js +1 -0
  13. package/dist/control-errors.d.ts +8 -0
  14. package/dist/control-errors.js +24 -0
  15. package/dist/control-server-schemas.d.ts +2 -2
  16. package/dist/control-server.d.ts +4 -1
  17. package/dist/control-server.js +18 -25
  18. package/dist/diagnostic-command.d.ts +76 -0
  19. package/dist/diagnostic-command.js +1962 -0
  20. package/dist/discord-channel-audit.d.ts +9 -0
  21. package/dist/discord-channel-audit.js +195 -0
  22. package/dist/discord-guild-health.d.ts +2 -2
  23. package/dist/discord-role-safety.d.ts +4 -0
  24. package/dist/discord-role-safety.js +30 -0
  25. package/dist/events.d.ts +2 -0
  26. package/dist/events.js +5 -20
  27. package/dist/hosted-server-moderation.d.ts +2 -2
  28. package/dist/index.js +30 -1
  29. package/dist/shard-contracts.d.ts +87 -0
  30. package/dist/shard-contracts.js +39 -0
  31. package/dist/shard-router.d.ts +2 -0
  32. package/dist/shard-router.js +12 -0
  33. package/dist/shard-supervisor.js +7 -0
  34. package/dist/shard-worker.d.ts +1 -0
  35. package/dist/shard-worker.js +38 -1
  36. package/documentation/reference/environment.md +1 -1
  37. package/package.json +7 -7
package/CHANGELOG.md CHANGED
@@ -1,5 +1,38 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.5.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Add the server-scoped `/diag` command with administrator access, one active
8
+ run per server, guarded repair passes and fresh checks before reporting fixes.
9
+ Preserve records and configuration outside the explicitly supported repairs.
10
+ - Require PostgreSQL adapter 2.4.1 or MySQL adapter 0.2.1 for the active,
11
+ non-blacklisted server lookup used by diagnostic admission checks.
12
+
13
+ ## 1.5.0
14
+
15
+ ### Minor Changes
16
+
17
+ - 7034a9c: Add bounded read-only Channel audit contracts and Discord inspection for Access
18
+ Control and hosted Verification. The dashboard can explicitly load a narrow
19
+ channel/role configuration projection and inspect combined role assumptions
20
+ without enabling the module, changing Discord or saving audit results.
21
+
22
+ Cloud Connect uses two explicit negotiated read capabilities for Access Control.
23
+ Update the hosted API/gateway and self-hosted bot as a coordinated compatible
24
+ release; older connectors give an explicit unsupported response. No database
25
+ migration is required. This changeset records future package impact only;
26
+ versions and publication remain separately controlled.
27
+
28
+ ### Patch Changes
29
+
30
+ - Updated dependencies [7034a9c]
31
+ - @helyx/sdk@0.16.0
32
+ - @helyx/control-protocol@0.8.0
33
+ - @helyx/module-automod@1.1.1
34
+ - @helyx/module-moderation@1.1.1
35
+
3
36
  ## 1.4.0
4
37
 
5
38
  ### Minor Changes
@@ -0,0 +1,7 @@
1
+ import type { ChannelAuditConfiguration, ChannelAuditModuleId } from "@helyx/sdk";
2
+ /** Deliberate allowlist: never return provider state, answers or the saved settings object. */
3
+ export declare function channelAuditConfiguration(moduleId: ChannelAuditModuleId, configuration: {
4
+ version: number;
5
+ value: Record<string, unknown>;
6
+ } | null): ChannelAuditConfiguration;
7
+ //# sourceMappingURL=channel-audit-configuration.d.ts.map
@@ -0,0 +1,40 @@
1
+ import { CHANNEL_AUDIT_LIMITS, channelAuditConfigurationSchema, } from "@helyx/control-protocol/channel-audit";
2
+ /** Deliberate allowlist: never return provider state, answers or the saved settings object. */
3
+ export function channelAuditConfiguration(moduleId, configuration) {
4
+ const value = configuration?.value ?? {};
5
+ const mappedFields = [];
6
+ const id = (field) => {
7
+ const candidate = value[field];
8
+ if (typeof candidate !== "string" || !/^\d{17,20}$/u.test(candidate))
9
+ return null;
10
+ mappedFields.push(field);
11
+ return candidate;
12
+ };
13
+ const entryChannelId = id(moduleId === "helyx.account-gate"
14
+ ? "gateChannelId"
15
+ : "verificationChannelId");
16
+ const afterRoleId = id("verifiedRoleId");
17
+ const reviewChannelId = moduleId === "helyx.account-gate" ? id("reviewChannelId") : null;
18
+ const configuredRoles = moduleId === "helyx.account-gate" && Array.isArray(value.temporaryRoleIds)
19
+ ? [
20
+ ...new Set(value.temporaryRoleIds.filter((role) => typeof role === "string" && /^\d{17,20}$/u.test(role))),
21
+ ]
22
+ : [];
23
+ if (configuredRoles.length)
24
+ mappedFields.push("temporaryRoleIds");
25
+ const suggested = moduleId === "helyx.verification" ? id("roleToRemoveId") : null;
26
+ const beforeRoleIds = suggested
27
+ ? [suggested]
28
+ : configuredRoles.slice(0, CHANNEL_AUDIT_LIMITS.beforeRoles);
29
+ return channelAuditConfigurationSchema.parse({
30
+ moduleId,
31
+ version: configuration?.version ?? null,
32
+ entryChannelId,
33
+ beforeRoleIds,
34
+ afterRoleId,
35
+ reviewChannelId,
36
+ mappedFields,
37
+ truncated: configuredRoles.length > CHANNEL_AUDIT_LIMITS.beforeRoles,
38
+ });
39
+ }
40
+ //# sourceMappingURL=channel-audit-configuration.js.map
@@ -0,0 +1,14 @@
1
+ import { type ChannelAuditControl, type GuildAccessPolicy, type ModuleConfigurationService, type ModuleDefinition, type PermissionPolicyService, type ServiceAccess } from "@helyx/sdk";
2
+ import type { BotControlGuildResourceDirectory, BotControlRepositories } from "./control-server.js";
3
+ /** Read-only component: no action, transaction, settings-save or Discord mutation helpers. */
4
+ export declare function createChannelAuditControl(input: {
5
+ modules: ReadonlyMap<string, ModuleDefinition>;
6
+ resources: Pick<BotControlGuildResourceDirectory, "resolveActor">;
7
+ configurations: ModuleConfigurationService;
8
+ permissions: PermissionPolicyService;
9
+ services: ServiceAccess;
10
+ listModules: BotControlRepositories["installations"]["listModules"];
11
+ readGuildPolicy(guildId: string): Promise<GuildAccessPolicy>;
12
+ requireAccessible(guildId: string, moduleId: string): Promise<void>;
13
+ }): Required<ChannelAuditControl>;
14
+ //# sourceMappingURL=channel-audit-control.d.ts.map
@@ -0,0 +1,61 @@
1
+ import { canAccessGuildDashboard, canUseCommand } from "@helyx/core";
2
+ import { HELYX_SERVICE_NAMES, } from "@helyx/sdk";
3
+ import { channelAuditInputSchema, channelAuditModuleIdSchema, channelAuditReportSchema, } from "@helyx/control-protocol/channel-audit";
4
+ import { BotControlForbiddenError, BotControlNotFoundError, BotControlTransportError, } from "./control-errors.js";
5
+ import { safeVersion } from "./control-permissions.js";
6
+ import { channelAuditConfiguration } from "./channel-audit-configuration.js";
7
+ /** Read-only component: no action, transaction, settings-save or Discord mutation helpers. */
8
+ export function createChannelAuditControl(input) {
9
+ const authorize = async (context) => {
10
+ channelAuditModuleIdSchema.parse(context.moduleId);
11
+ const module = input.modules.get(context.moduleId);
12
+ if (!module)
13
+ throw new BotControlNotFoundError("Module is not installed");
14
+ if (!module.manifest.dashboard)
15
+ throw new BotControlNotFoundError("Module is not dashboard configurable");
16
+ const [actor, guildAccess, resolvedPolicy, installed] = await Promise.all([
17
+ input.resources.resolveActor(context.guildId, context.actorUserId),
18
+ input.readGuildPolicy(context.guildId),
19
+ input.permissions.getPolicy(context.guildId, context.moduleId),
20
+ input.listModules(context.guildId),
21
+ ]);
22
+ if (!canAccessGuildDashboard(actor, guildAccess) ||
23
+ !canUseCommand(actor, resolvedPolicy.policy, guildAccess))
24
+ throw new BotControlForbiddenError("You no longer have permission to inspect this module's channel setup");
25
+ if (!installed.some((item) => item.moduleId === context.moduleId))
26
+ throw new BotControlNotFoundError("This module is not installed for this server");
27
+ await input.requireAccessible(context.guildId, context.moduleId);
28
+ };
29
+ return {
30
+ readChannelAuditConfiguration: async (context) => {
31
+ await authorize(context);
32
+ const configuration = await input.configurations.get(context.guildId, context.moduleId);
33
+ return channelAuditConfiguration(context.moduleId, configuration
34
+ ? {
35
+ version: safeVersion(configuration.version),
36
+ value: configuration.value,
37
+ }
38
+ : null);
39
+ },
40
+ checkChannelAudit: async (context) => {
41
+ const assumptions = channelAuditInputSchema.parse(context.assumptions);
42
+ await authorize(context);
43
+ if (!input.services.has(HELYX_SERVICE_NAMES.discordResources))
44
+ throw new BotControlTransportError(503, "Channel audit is not supported by this bot's Discord inspection service");
45
+ const inspection = input.services.get(HELYX_SERVICE_NAMES.discordResources);
46
+ if (!inspection.inspectChannelAudit)
47
+ throw new BotControlTransportError(503, "This bot version does not support Channel audit. Update the bot before trying again.");
48
+ const report = channelAuditReportSchema.parse(await inspection.inspectChannelAudit({
49
+ guildId: context.guildId,
50
+ moduleId: context.moduleId,
51
+ assumptions,
52
+ }));
53
+ if (report.guildId !== context.guildId ||
54
+ report.moduleId !== context.moduleId ||
55
+ JSON.stringify(report.assumptions) !== JSON.stringify(assumptions))
56
+ throw new BotControlTransportError(502, "Channel audit returned a different server or module context");
57
+ return report;
58
+ },
59
+ };
60
+ }
61
+ //# sourceMappingURL=channel-audit-control.js.map
@@ -0,0 +1,56 @@
1
+ import type { ChannelAuditInput, ChannelAuditModuleId, ChannelAuditReport } from "@helyx/sdk";
2
+ export declare const AUDIT_PERMISSIONS: {
3
+ readonly Administrator: bigint;
4
+ readonly ManageGuild: bigint;
5
+ readonly ViewChannel: bigint;
6
+ readonly SendMessages: bigint;
7
+ readonly ReadMessageHistory: bigint;
8
+ readonly Connect: bigint;
9
+ readonly Speak: bigint;
10
+ readonly ManageRoles: bigint;
11
+ readonly ManageChannels: bigint;
12
+ readonly KickMembers: bigint;
13
+ readonly BanMembers: bigint;
14
+ readonly RequestToSpeak: bigint;
15
+ readonly SendMessagesInThreads: bigint;
16
+ };
17
+ export interface AuditRoleSnapshot {
18
+ id: string;
19
+ permissions: bigint;
20
+ managed: boolean;
21
+ assignable: boolean | null;
22
+ assignmentReason?: string;
23
+ assignmentPolicyReason?: string;
24
+ }
25
+ export interface AuditChannelSnapshot {
26
+ id: string;
27
+ name: string;
28
+ type: number;
29
+ obfuscated: boolean;
30
+ overwrites: Array<{
31
+ id: string;
32
+ type: number;
33
+ allow: bigint;
34
+ deny: bigint;
35
+ }>;
36
+ botPermissions: bigint | null;
37
+ }
38
+ export interface AuditGuildSnapshot {
39
+ guildId: string;
40
+ roles: AuditRoleSnapshot[];
41
+ rolesComplete: boolean;
42
+ channels: AuditChannelSnapshot[];
43
+ fetchedChannels: number;
44
+ totalChannelsKnown: boolean;
45
+ fetchLimitations: string[];
46
+ invalidSelectedChannels?: Array<{
47
+ id: string;
48
+ reason: "not_found" | "wrong_guild";
49
+ }>;
50
+ }
51
+ /** Discord's role union, everyone overwrite, then aggregate role deny/allow. */
52
+ export declare function effectiveAuditPermissions(guildId: string, roles: readonly AuditRoleSnapshot[], selectedRoleIds: readonly string[], channel: AuditChannelSnapshot): bigint | null;
53
+ /** Raw bit presence is insufficient: view/connect prerequisites gate actions. */
54
+ export declare function auditActions(permissions: bigint | null, type: number): string[];
55
+ export declare function buildChannelAuditReport(snapshot: AuditGuildSnapshot, moduleId: ChannelAuditModuleId, assumptions: ChannelAuditInput, observedAt: string): ChannelAuditReport;
56
+ //# sourceMappingURL=channel-audit-permissions.d.ts.map
@@ -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,4 @@
1
+ import type { ChannelAuditControl } from "@helyx/sdk";
2
+ import type { FastifyInstance } from "fastify";
3
+ export declare function registerChannelAuditRoutes(server: FastifyInstance, control: ChannelAuditControl): void;
4
+ //# sourceMappingURL=channel-audit-routes.d.ts.map