@helyx/module-account-gate 2.0.4 → 2.1.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 CHANGED
@@ -1,5 +1,47 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.1.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [7034a9c]
8
+ - @helyx/sdk@0.16.0
9
+
10
+ ## 2.1.0
11
+
12
+ ### Minor Changes
13
+
14
+ - Prepare the coordinated Phase 8 minor release and align internal package dependencies. Existing deployment and database-support restrictions are unchanged.
15
+
16
+ Use a bounded generation identifier for durable Access Control revocation tasks,
17
+ while preserving legacy payload keys and stale-generation protection.
18
+
19
+ Give Emoji Entry panels the existing bound Start check control, explain the
20
+ start-first step for Password and Emoji guides, and reject stale Emoji panel
21
+ controls after the configured gate changes without starting sessions from reactions.
22
+
23
+ Align the panel editor hint and dashboard defaults with the start-first flow,
24
+ and document the actual Settings > Publish gate route for new panels.
25
+
26
+ Run terminal role delivery only through the existing leased durable worker,
27
+ so concurrent completion paths cannot remove a successfully granted access role.
28
+
29
+ Update challenge progress in the same private message, replacing outdated
30
+ buttons so members can complete multi-step checks without selecting old controls.
31
+
32
+ ### Patch Changes
33
+
34
+ - Updated dependencies
35
+ - @helyx/module-manifest@0.13.0
36
+ - @helyx/sdk@0.15.0
37
+
38
+ ## 2.0.5
39
+
40
+ ### Patch Changes
41
+
42
+ - Bound internal dependencies to compatible releases while preserving each
43
+ consumer's required minimum API version.
44
+
3
45
  ## 2.0.4
4
46
 
5
47
  ### Patch Changes
@@ -120,25 +162,7 @@
120
162
 
121
163
  ### Patch Changes
122
164
 
123
- - Accept compatible internal Helyx releases within explicit minor-safe pre-1.0 or major-safe stable ranges, preventing dependency-only patch cascades.---
124
- "@helyx/bot": patch
125
- "@helyx/cli": patch
126
- "@helyx/core": patch
127
- "@helyx/framework": patch
128
- "@helyx/platform": patch
129
- "@helyx/sdk": patch
130
- "@helyx/module-account-gate": patch
131
- "@helyx/module-feedback": patch
132
- "@helyx/module-giveaways": patch
133
- "@helyx/module-levels": patch
134
- "@helyx/module-logging": patch
135
- "@helyx/module-polls": patch
136
- "@helyx/module-reactionroles": patch
137
- "@helyx/module-suggestions": patch
138
- "@helyx/module-welcome": patch
139
- ---
140
-
141
- Accept compatible internal Helyx releases within explicit minor-safe pre-1.0 or major-safe stable ranges, preventing dependency-only patch cascades.
165
+ - Accept compatible internal Helyx releases within explicit minor-safe pre-1.0 or major-safe stable ranges, preventing dependency-only patch cascades.
142
166
 
143
167
  ## 1.0.8
144
168
 
package/README.md CHANGED
@@ -28,7 +28,7 @@ Every slash-command path has its own permission identity and dashboard override.
28
28
 
29
29
  ## Dashboard configuration
30
30
 
31
- Settings cover access type, roles, gate and staff-review channels, attempt and timing controls, account age, returning-member behavior, final-failure action, and optional strict mode. Managed resources publish, edit, repair, or repost gate panels and maintain server questions. The Members resource exposes bounded current state and guarded review or revoke actions.
31
+ Settings cover access type, roles, gate and staff-review channels, attempt and timing controls, account age, returning-member behavior, final-failure action, and optional strict mode. In Settings, **Publish gate** saves the configuration and publishes a panel with the entered instructions. Resources edits, repairs or reposts existing panels and maintains server questions. The Members resource exposes bounded current state and guarded review or revoke actions.
32
32
 
33
33
  Activation validates the selected roles, channels, Discord capabilities, published panel, and at least one enabled question when Server Questions is used. Helyx does not maintain a second allowlist for every channel visible to a pending role; managers configure those Discord overwrites directly.
34
34
 
@@ -4,7 +4,14 @@ import { recordAccountGateAttempt } from "./member-restart.js";
4
4
  import { boundComponentId, parseComponentBinding, replyError, secureRandom, } from "./runtime-support.js";
5
5
  import { challengeMessage, requiredRuntimeConfiguration, } from "./setup-presentation.js";
6
6
  import { scheduleSessionExpiry } from "./task-scheduling.js";
7
- import { ensureTerminalFinalization, finalizeTerminalFinalization, } from "./terminal-finalization.js";
7
+ import { ensureTerminalFinalization } from "./terminal-finalization.js";
8
+ async function presentChallengeResponse(context, response) {
9
+ if (context.updateResponse) {
10
+ await context.updateResponse(response);
11
+ return;
12
+ }
13
+ await context.reply(response);
14
+ }
8
15
  export async function processAction(context, action, binding) {
9
16
  if (!context.serverId) {
10
17
  await replyError(context, "This Access Control interaction is unavailable.");
@@ -46,7 +53,7 @@ export async function processAction(context, action, binding) {
46
53
  sessionId: session.sessionId,
47
54
  expiredAt: session.expiresAt,
48
55
  });
49
- await context.reply(challengeMessage(result.state, {
56
+ await presentChallengeResponse(context, challengeMessage(result.state, {
50
57
  sessionId: session.sessionId,
51
58
  version: updated.version,
52
59
  }));
@@ -69,7 +76,7 @@ export async function processAction(context, action, binding) {
69
76
  expiredAt: session.expiresAt,
70
77
  });
71
78
  if (result.outcome === "advanced") {
72
- await context.reply(challengeMessage(result.state, {
79
+ await presentChallengeResponse(context, challengeMessage(result.state, {
73
80
  sessionId: session.sessionId,
74
81
  version: advanced.version,
75
82
  }));
@@ -91,8 +98,7 @@ export async function finaliseAttempt(context, sessionId, result, challengeType)
91
98
  await recordAccountGateAttempt(records, guildId, context.userId, sessionId, result.state, challengeType, result.outcome);
92
99
  if (!(await ensureTerminalFinalization(context.services, guildId, context.userId, sessionId, result.state, result.outcome)))
93
100
  return;
94
- await finalizeTerminalFinalization(context.services, guildId, sessionId);
95
- await context.reply({
101
+ await presentChallengeResponse(context, {
96
102
  ephemeral: true,
97
103
  componentsV2: {
98
104
  accentColor: result.outcome === "passed" ? GREEN : RED,
@@ -153,7 +159,7 @@ export async function rerollChallenge(context) {
153
159
  sessionId: session.sessionId,
154
160
  expiredAt: session.expiresAt,
155
161
  });
156
- await context.reply(challengeMessage(state, {
162
+ await presentChallengeResponse(context, challengeMessage(state, {
157
163
  sessionId: session.sessionId,
158
164
  version: updated.version,
159
165
  }));
@@ -2,7 +2,7 @@ export declare const ACCOUNT_GATE_IDENTITY: {
2
2
  readonly packageName: "@helyx/module-account-gate";
3
3
  readonly moduleId: "helyx.account-gate";
4
4
  readonly name: "Access Control";
5
- readonly version: "2.0.4";
5
+ readonly version: "2.1.1";
6
6
  readonly releaseStatus: "live";
7
7
  readonly category: "safety";
8
8
  readonly icon: "shield-check";
package/dist/contracts.js CHANGED
@@ -2,7 +2,7 @@ export const ACCOUNT_GATE_IDENTITY = {
2
2
  packageName: "@helyx/module-account-gate",
3
3
  moduleId: "helyx.account-gate",
4
4
  name: "Access Control",
5
- version: "2.0.4",
5
+ version: "2.1.1",
6
6
  releaseStatus: "live",
7
7
  category: "safety",
8
8
  icon: "shield-check",
@@ -4,7 +4,7 @@ import { createReview } from "./review-store.js";
4
4
  import { dateValue, numberValue } from "./runtime-support.js";
5
5
  import { requiredRuntimeConfiguration } from "./setup-presentation.js";
6
6
  import { armRecordRetention } from "./task-scheduling.js";
7
- import { ensureTerminalFinalization, finalizeTerminalFinalization, } from "./terminal-finalization.js";
7
+ import { ensureTerminalFinalization } from "./terminal-finalization.js";
8
8
  export function validateJoinDeadlinePayload(input) {
9
9
  if (!input || typeof input !== "object" || Array.isArray(input))
10
10
  throw new Error("Account Gate join-deadline payload is invalid");
@@ -215,7 +215,6 @@ export async function expireSession(context, task) {
215
215
  if (expiredState.status === "failed") {
216
216
  if (!(await ensureTerminalFinalization(context.services, guildId, userId, sessionId, expiredState, "failed")))
217
217
  return { outcome: "completed" };
218
- await finalizeTerminalFinalization(context.services, guildId, sessionId);
219
218
  return { outcome: "completed" };
220
219
  }
221
220
  if (expiredState.status === "passed") {
@@ -247,7 +246,6 @@ export async function expireSession(context, task) {
247
246
  if (recorded.created)
248
247
  await armRecordRetention(records, guildId, "attempts", "attempt_id", attemptId, retentionUntil);
249
248
  }
250
- await finalizeTerminalFinalization(context.services, guildId, sessionId);
251
249
  return { outcome: "completed" };
252
250
  }
253
251
  const challengeType = expiredState.chain[expiredState.currentStep]?.type;
@@ -4,7 +4,7 @@ import { recordAccountGateAttempt } from "./member-restart.js";
4
4
  import { canonicalEmojiKey, staticResumeMessage } from "./runtime-support.js";
5
5
  import { configurationForPanel, getConfiguration, requiredRuntimeConfiguration, validateAccountGateSetup, } from "./setup-presentation.js";
6
6
  import { scheduleSessionExpiry } from "./task-scheduling.js";
7
- import { ensureTerminalFinalization, finalizeTerminalFinalization, } from "./terminal-finalization.js";
7
+ import { ensureTerminalFinalization } from "./terminal-finalization.js";
8
8
  export async function handlePasswordEntry(context) {
9
9
  if (context.userIsBot || context.webhookId !== null)
10
10
  return;
@@ -119,6 +119,5 @@ export async function processGatewayAction(services, guildId, userId, session, a
119
119
  });
120
120
  return;
121
121
  }
122
- await finalizeTerminalFinalization(services, guildId, session.sessionId);
123
122
  }
124
123
  //# sourceMappingURL=gateway-interactions.js.map
package/dist/index.js CHANGED
@@ -41,6 +41,14 @@ const everyonePolicy = {
41
41
  };
42
42
  export const accountGateModule = defineModule({
43
43
  manifest,
44
+ async admissionGate(input) {
45
+ const configuration = await getConfiguration(input.services, input.guildId);
46
+ if (!configuration)
47
+ return "ready";
48
+ return input.roleIds.includes(configuration.verifiedRoleId)
49
+ ? "ready"
50
+ : "pending";
51
+ },
44
52
  exclusiveCapabilities: ["entry-role-workflow"],
45
53
  async validateActivation(context) {
46
54
  const configuration = await getConfiguration(context.services, context.guildId);
@@ -130,7 +138,7 @@ export const accountGateModule = defineModule({
130
138
  modals: [
131
139
  {
132
140
  customIdPrefix: ACCOUNT_GATE_COMPONENTS.recallSubmitPrefix,
133
- acknowledgement: "deferred-ephemeral",
141
+ acknowledgement: "deferred-update",
134
142
  execute: submitRecallModal,
135
143
  },
136
144
  ],
@@ -33,7 +33,7 @@ export async function revokeMemberAccess(input) {
33
33
  payload: {
34
34
  guildId: input.guildId,
35
35
  memberUserId: input.memberUserId,
36
- generation: String(generation),
36
+ generationId: String(generation),
37
37
  },
38
38
  scheduledFor: now,
39
39
  },
@@ -68,17 +68,24 @@ export function validateRevocationPayload(input) {
68
68
  if (!input || typeof input !== "object" || Array.isArray(input))
69
69
  throw new Error("Account Gate revocation payload is invalid");
70
70
  const value = input;
71
- if (typeof value.guildId !== "string" ||
71
+ const hasGenerationId = Object.hasOwn(value, "generationId");
72
+ const hasLegacyGeneration = Object.hasOwn(value, "generation");
73
+ const generationKey = hasGenerationId ? "generationId" : "generation";
74
+ const generation = value[generationKey];
75
+ if (Object.keys(value).length !== 3 ||
76
+ hasGenerationId === hasLegacyGeneration ||
77
+ typeof value.guildId !== "string" ||
72
78
  !/^\d{17,20}$/u.test(value.guildId) ||
73
79
  typeof value.memberUserId !== "string" ||
74
80
  !/^\d{17,20}$/u.test(value.memberUserId) ||
75
- typeof value.generation !== "string" ||
76
- !/^[1-9]\d{0,9}$/u.test(value.generation))
81
+ typeof generation !== "string" ||
82
+ !/^[1-9]\d{0,9}$/u.test(generation))
77
83
  throw new Error("Account Gate revocation payload is invalid");
78
84
  return {
79
85
  guildId: value.guildId,
80
86
  memberUserId: value.memberUserId,
81
- generation: value.generation,
87
+ // Keep legacy keys intact: the runner compares validated and stored payloads.
88
+ [generationKey]: generation,
82
89
  };
83
90
  }
84
91
  export async function reconcileRevocationTask(context, task) {
@@ -90,7 +97,7 @@ export async function reconcileRevocationTask(context, task) {
90
97
  outcome: "requires_review",
91
98
  safeCode: "revocation-configuration-missing",
92
99
  };
93
- await reconcileRevokedMember(context.services, task.payload.guildId, task.payload.memberUserId, Number(task.payload.generation), parseAccountGateConfiguration(configurationRecord.value));
100
+ await reconcileRevokedMember(context.services, task.payload.guildId, task.payload.memberUserId, Number(task.payload.generationId ?? task.payload.generation), parseAccountGateConfiguration(configurationRecord.value));
94
101
  return { outcome: "completed" };
95
102
  }
96
103
  async function reconcileRevokedMember(services, guildId, memberUserId, generation, configuration) {
@@ -1,15 +1,33 @@
1
- import { HELYX_SERVICE_NAMES, generateChallengeChain, ACCOUNT_GATE_COMPONENTS, ACCOUNT_GATE_SESSION_PURPOSE, validateRuntimeCapabilities, getGatePanelRuntime, createAccountGateSessionState, parseAccountGateSessionState, VIOLET, GREEN, RED, } from "./runtime-dependencies.js";
1
+ import { HELYX_SERVICE_NAMES, ACCOUNT_GATE_IDENTITY, generateChallengeChain, ACCOUNT_GATE_COMPONENTS, ACCOUNT_GATE_SESSION_PURPOSE, validateRuntimeCapabilities, getGatePanelRuntime, createAccountGateSessionState, parseAccountGateSessionState, VIOLET, GREEN, RED, } from "./runtime-dependencies.js";
2
2
  import { resumeRevokedMember } from "./member-restart.js";
3
3
  import { loadMemberSummary } from "./member-summary.js";
4
4
  import { dateValue, numberValue, replyError, secureRandom, } from "./runtime-support.js";
5
5
  import { challengeMessage, configurationForPanel, currentStrictMode, getConfiguration, loadQuestions, validateAccountGateSetup, } from "./setup-presentation.js";
6
6
  import { scheduleSessionExpiry } from "./task-scheduling.js";
7
- import { ensureTerminalFinalization, finalizeTerminalFinalization, } from "./terminal-finalization.js";
7
+ import { ensureTerminalFinalization } from "./terminal-finalization.js";
8
8
  export async function startCheck(context) {
9
9
  if (!context.serverId) {
10
10
  await replyError(context, "Access Control only works in a Discord server.");
11
11
  return;
12
12
  }
13
+ const authorised = await context.services
14
+ .get(HELYX_SERVICE_NAMES.resourceAuthorization)
15
+ .canUse({
16
+ guildId: context.serverId,
17
+ moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
18
+ permissionId: "account-gate.start",
19
+ actor: {
20
+ userId: context.userId,
21
+ roleIds: context.userRoleIds,
22
+ permissions: context.userPermissions ?? [],
23
+ isServerOwner: context.isServerOwner ?? false,
24
+ isAdministrator: context.isAdministrator,
25
+ },
26
+ });
27
+ if (!authorised) {
28
+ await replyError(context, "You do not have permission to start an Access Control check.");
29
+ return;
30
+ }
13
31
  const configuration = await getConfiguration(context.services, context.serverId);
14
32
  if (!configuration) {
15
33
  await replyError(context, "Access Control has not been configured by the server team.");
@@ -37,6 +55,10 @@ export async function startCheck(context) {
37
55
  await replyError(context, "This Challenge panel is not currently active. Ask the server team to publish the current Access Control setup.");
38
56
  return;
39
57
  }
58
+ if (panel?.accessType === "emoji" && configuration.accessType !== "emoji") {
59
+ await replyError(context, "This Emoji Entry panel is not currently active. Ask the server team to publish the current Access Control setup.");
60
+ return;
61
+ }
40
62
  const activeConfiguration = panel
41
63
  ? configurationForPanel(configuration, panel.accessType)
42
64
  : configuration;
@@ -92,8 +114,7 @@ export async function startCheck(context) {
92
114
  const existingState = parseAccountGateSessionState(existing.state);
93
115
  if (existingState.status === "passed" ||
94
116
  existingState.status === "failed") {
95
- if (await ensureTerminalFinalization(context.services, context.serverId, context.userId, existing.sessionId, existingState, existingState.status))
96
- await finalizeTerminalFinalization(context.services, context.serverId, existing.sessionId);
117
+ await ensureTerminalFinalization(context.services, context.serverId, context.userId, existing.sessionId, existingState, existingState.status);
97
118
  const finalizedSummary = await loadMemberSummary(context.services.get(HELYX_SERVICE_NAMES.records), context.serverId, context.userId);
98
119
  await context.reply(terminalRecoveryMessage(existingState.status, typeof finalizedSummary?.last_decision === "string"
99
120
  ? finalizedSummary.last_decision
@@ -8,15 +8,6 @@ export interface PanelPassword {
8
8
  }
9
9
  export declare const DEFAULT_PANEL_MESSAGES: Readonly<Record<GatePanelAccessType, string>>;
10
10
  export declare function panelMessage(messageBody: string, accessType: GatePanelAccessType, panelId: string): {
11
- allowedUserMentionIds: never[];
12
- componentsV2: {
13
- text: {
14
- content: string;
15
- markdown: boolean;
16
- }[];
17
- };
18
- buttons?: never;
19
- } | {
20
11
  allowedUserMentionIds: never[];
21
12
  componentsV2: {
22
13
  text: ({
@@ -3,16 +3,10 @@ import { ACCOUNT_GATE_COMPONENTS } from "./contracts.js";
3
3
  const VIOLET = 0x7c3aed;
4
4
  export const DEFAULT_PANEL_MESSAGES = {
5
5
  challenges: "Complete a short human-interaction check to access the server. Press the button below when you are ready.",
6
- password: "Enter the password for this gate in the Password Entry channel. Never enter your Discord password or authentication code.",
7
- emoji: "React with the configured emoji on the Emoji Entry message to continue.",
6
+ password: "Press Start check, then enter this gate's password in the Password Entry channel. Never enter your Discord password or authentication code.",
7
+ emoji: "Press Start check, then react with the configured emoji on the Emoji Entry message to continue.",
8
8
  };
9
9
  export function panelMessage(messageBody, accessType, panelId) {
10
- if (accessType === "emoji") {
11
- return {
12
- allowedUserMentionIds: [],
13
- componentsV2: { text: [{ content: messageBody, markdown: false }] },
14
- };
15
- }
16
10
  return {
17
11
  allowedUserMentionIds: [],
18
12
  componentsV2: {
@@ -21,6 +15,15 @@ export function panelMessage(messageBody, accessType, panelId) {
21
15
  ...(accessType === "challenges"
22
16
  ? [{ content: "## Complete Access Control" }]
23
17
  : []),
18
+ ...(accessType !== "challenges" &&
19
+ messageBody !== DEFAULT_PANEL_MESSAGES[accessType]
20
+ ? [
21
+ {
22
+ content: "Press Start check below before following these instructions. Your check opens privately.",
23
+ markdown: false,
24
+ },
25
+ ]
26
+ : []),
24
27
  { content: messageBody, markdown: false },
25
28
  ],
26
29
  },
@@ -7,6 +7,7 @@ import { DEFAULT_PANEL_MESSAGES, panelAccessType, panelDetail as detail, panelLi
7
7
  import { claimPanelMutation, finalizePanelMutation, } from "./panel-mutations.js";
8
8
  import { configuredPanelAccessType as configuredAccessType, inspectedPanelDetail as inspectedDetail, panelPasswordForCreate, panelPasswordForUpdate, } from "./panel-presentation.js";
9
9
  import { accountGateRecords } from "./records.js";
10
+ import { gateChannelAudienceError } from "./setup-presentation.js";
10
11
  export { getGatePanelRuntime, hasPasswordGatePanel } from "./panel-runtime.js";
11
12
  const MAX_GATE_PANELS = 10;
12
13
  export const gatePanelResource = {
@@ -162,6 +163,7 @@ async function replacePanel(context, current, action, messageBody, accessType, p
162
163
  throw new DashboardActionValidationError("Save Access Control settings before posting the panel.");
163
164
  }
164
165
  const configuration = parseAccountGateConfiguration(configured.value);
166
+ await assertGatePanelAudience(context, configuration.gateChannelId, configuration.temporaryRoleIds);
165
167
  const store = records(context);
166
168
  const claimed = await claimPanelMutation(store, current, {
167
169
  ...auditContext(context),
@@ -231,6 +233,18 @@ function assertRevision(current, expectedRevision) {
231
233
  if (numberValue(current.revision) !== expectedRevision)
232
234
  throw stalePanel();
233
235
  }
236
+ async function assertGatePanelAudience(context, channelId, temporaryRoleIds) {
237
+ const audience = await context.services
238
+ .get(HELYX_SERVICE_NAMES.discordResources)
239
+ .inspectChannelAudience({
240
+ guildId: context.guildId,
241
+ channelId,
242
+ roleIds: temporaryRoleIds,
243
+ });
244
+ const error = gateChannelAudienceError(audience);
245
+ if (error)
246
+ throw new DashboardActionValidationError(error);
247
+ }
234
248
  async function post(context, value = {}) {
235
249
  const configured = await context.services
236
250
  .get(HELYX_SERVICE_NAMES.configuration)
@@ -238,6 +252,7 @@ async function post(context, value = {}) {
238
252
  if (!configured)
239
253
  throw new DashboardActionValidationError("Save Access Control settings before posting the panel.");
240
254
  const configuration = parseAccountGateConfiguration(configured.value);
255
+ await assertGatePanelAudience(context, configuration.gateChannelId, configuration.temporaryRoleIds);
241
256
  const store = records(context);
242
257
  const existing = await currentPanelOrNull(context, context.correlationId);
243
258
  if (existing)
@@ -281,6 +296,13 @@ async function post(context, value = {}) {
281
296
  collection: "gate_panels",
282
297
  uniqueBy: ["panel_id"],
283
298
  values: row,
299
+ quotaGuards: [
300
+ {
301
+ collection: "gate_panels",
302
+ where: { guild_id: context.guildId },
303
+ maximum: MAX_GATE_PANELS,
304
+ },
305
+ ],
284
306
  });
285
307
  if (!created.created) {
286
308
  throw new DashboardActionValidationError("That gate-panel request was already handled. Reload the resources list.");
@@ -1,7 +1,9 @@
1
+ import type { DiscordChannelAudienceInspection } from "@helyx/sdk";
1
2
  import type { MessageResponse, ServiceAccess, AccountGateSessionState, ServerQuestion, AccountGateConfiguration } from "./runtime-dependencies.js";
2
3
  export declare function configurationForPanel(configuration: AccountGateConfiguration, accessType: "challenges" | "password" | "emoji"): AccountGateConfiguration;
3
4
  export declare function reviewMessage(memberUserId: string, reasonCode: string): MessageResponse;
4
5
  export declare function validateAccountGateSetup(services: ServiceAccess, guildId: string, configuration: AccountGateConfiguration, throwOnError?: boolean, allowMissingPanel?: boolean): Promise<readonly string[]>;
6
+ export declare function gateChannelAudienceError(audience: DiscordChannelAudienceInspection): string | null;
5
7
  export declare function challengeMessage(state: AccountGateSessionState, binding: {
6
8
  sessionId: string;
7
9
  version: bigint;
@@ -106,10 +106,9 @@ export async function validateAccountGateSetup(services, guildId, configuration,
106
106
  errors.push("Helyx Verification already owns this server's entry-role workflow. Disable it before enabling Access Control.");
107
107
  if (!gateChannel.viewable || !gateChannel.sendMessages)
108
108
  errors.push("Helyx cannot view and send messages in the gate channel.");
109
- if (!gateAudience.everyoneCanView &&
110
- (gateAudience.roles.length === 0 ||
111
- gateAudience.roles.some(({ exists, canView }) => !exists || !canView)))
112
- errors.push("The gate channel must be visible to @everyone or every configured temporary member role.");
109
+ const gateAudienceError = gateChannelAudienceError(gateAudience);
110
+ if (gateAudienceError)
111
+ errors.push(gateAudienceError);
113
112
  if (!guildCapabilities.manageRoles)
114
113
  errors.push("Access Control requires Manage Roles.");
115
114
  if (!verifiedRole.assignable)
@@ -203,6 +202,13 @@ export async function validateAccountGateSetup(services, guildId, configuration,
203
202
  throw new DashboardActionValidationError(errors[0]);
204
203
  return errors;
205
204
  }
205
+ export function gateChannelAudienceError(audience) {
206
+ return !audience.everyoneCanView &&
207
+ (audience.roles.length === 0 ||
208
+ audience.roles.some(({ exists, canView }) => !exists || !canView))
209
+ ? "The gate channel must be visible to @everyone or every configured temporary member role."
210
+ : null;
211
+ }
206
212
  export function challengeMessage(state, binding, options = {}) {
207
213
  const challenge = state.chain[state.currentStep];
208
214
  if (!challenge) {
package/manifest.json CHANGED
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "manifestVersion": "0.4",
3
+ "databaseEngines": ["postgresql", "mysql"],
3
4
  "releaseStatus": "live",
4
5
  "deploymentSupport": "both",
5
6
  "credentialRisk": "none",
@@ -10,10 +11,10 @@
10
11
  "healthExposure": "direct",
11
12
  "id": "helyx.account-gate",
12
13
  "name": "Access Control",
13
- "version": "2.0.4",
14
+ "version": "2.1.1",
14
15
  "description": "Require new members to complete configurable Discord interaction checks before gaining access.",
15
16
  "entry": "./dist/index.js",
16
- "framework": "^0.1.0",
17
+ "framework": "^0.2.0",
17
18
  "dependencies": [],
18
19
  "permissions": [
19
20
  "ADD_REACTIONS",
@@ -595,7 +596,7 @@
595
596
  "key": "messageBody",
596
597
  "type": "string",
597
598
  "label": "Message",
598
- "description": "Instructions shown to members. Challenge and Password panels add a Start check button; Emoji panels remain instruction-only.",
599
+ "description": "Instructions shown to members. Challenge, Password and Emoji panels include Start check. Members start first, then follow their private instructions; Emoji reactions still belong on the exact configured message.",
599
600
  "required": true,
600
601
  "minLength": 1,
601
602
  "maxLength": 1000,
@@ -722,6 +723,7 @@
722
723
  "description": "Enter two to four incorrect answers, one per line.",
723
724
  "required": true,
724
725
  "group": "Answers",
726
+ "multiline": true,
725
727
  "minLength": 3,
726
728
  "maxLength": 323
727
729
  },
@@ -0,0 +1,249 @@
1
+ -- MySQL-compatible Account Gate baseline. PostgreSQL migration bytes stay immutable.
2
+ -- Hosted Request My Data erasure, including its SECURITY DEFINER suppression
3
+ -- triggers, remains on PostgreSQL. Ordinary module reads/writes use this schema.
4
+
5
+ CREATE TABLE account_gate_gate_panels (
6
+ panel_id VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin PRIMARY KEY,
7
+ guild_id VARCHAR(20) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
8
+ channel_id VARCHAR(20) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
9
+ message_id VARCHAR(20) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
10
+ message_body VARCHAR(1000) COLLATE utf8mb4_bin NOT NULL DEFAULT
11
+ 'Complete a short human-interaction check to access the server. Press the button below when you are ready.',
12
+ access_type VARCHAR(10) CHARACTER SET ascii COLLATE ascii_bin NOT NULL DEFAULT 'challenges',
13
+ password_hash VARCHAR(255) CHARACTER SET ascii COLLATE ascii_bin NULL,
14
+ password_ignore_case BOOLEAN NOT NULL DEFAULT TRUE,
15
+ password_trim_whitespace BOOLEAN NOT NULL DEFAULT TRUE,
16
+ status VARCHAR(7) CHARACTER SET ascii COLLATE ascii_bin NOT NULL DEFAULT 'posted',
17
+ revision INT NOT NULL,
18
+ created_at DATETIME(3) NOT NULL,
19
+ updated_at DATETIME(3) NOT NULL,
20
+ KEY account_gate_gate_panels_guild_idx (guild_id, created_at, panel_id),
21
+ CHECK (CHAR_LENGTH(message_body) BETWEEN 1 AND 1000),
22
+ CHECK (access_type IN ('challenges', 'password', 'emoji')),
23
+ CHECK (password_ignore_case IN (0, 1)),
24
+ CHECK (password_trim_whitespace IN (0, 1)),
25
+ CHECK (status IN ('posted', 'missing')),
26
+ CHECK (revision > 0),
27
+ CHECK (updated_at >= created_at)
28
+ ) ENGINE=InnoDB DEFAULT CHARACTER SET=utf8mb4 COLLATE=utf8mb4_bin;
29
+
30
+ CREATE TABLE account_gate_questions (
31
+ question_id VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin PRIMARY KEY,
32
+ guild_id VARCHAR(20) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
33
+ question_text VARCHAR(200) COLLATE utf8mb4_bin NOT NULL,
34
+ answers_json TEXT COLLATE utf8mb4_bin NOT NULL,
35
+ correct_option_index INT NOT NULL,
36
+ enabled BOOLEAN NOT NULL,
37
+ sort_order INT NOT NULL,
38
+ revision INT NOT NULL,
39
+ created_at DATETIME(3) NOT NULL,
40
+ updated_at DATETIME(3) NOT NULL,
41
+ CONSTRAINT account_gate_questions_guild_question_unique UNIQUE (guild_id, question_id),
42
+ KEY account_gate_questions_guild_order_idx (guild_id, sort_order, question_id),
43
+ CHECK (CHAR_LENGTH(question_text) BETWEEN 1 AND 200),
44
+ CHECK (correct_option_index BETWEEN 0 AND 4),
45
+ CHECK (enabled IN (0, 1)),
46
+ CHECK (sort_order BETWEEN 0 AND 1000),
47
+ CHECK (revision > 0),
48
+ CHECK (updated_at >= created_at)
49
+ ) ENGINE=InnoDB DEFAULT CHARACTER SET=utf8mb4 COLLATE=utf8mb4_bin;
50
+
51
+ CREATE TABLE account_gate_question_creators (
52
+ attribution_id VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin PRIMARY KEY,
53
+ question_id VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
54
+ guild_id VARCHAR(20) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
55
+ creator_user_id VARCHAR(20) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
56
+ created_at DATETIME(3) NOT NULL,
57
+ CONSTRAINT account_gate_question_creator_question_unique UNIQUE (question_id),
58
+ KEY account_gate_question_creator_privacy_idx (creator_user_id, created_at, attribution_id)
59
+ ) ENGINE=InnoDB DEFAULT CHARACTER SET=utf8mb4 COLLATE=utf8mb4_bin;
60
+
61
+ CREATE TABLE account_gate_attempts (
62
+ attempt_id VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin PRIMARY KEY,
63
+ guild_id VARCHAR(20) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
64
+ member_user_id VARCHAR(20) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
65
+ session_id VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
66
+ challenge_type VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
67
+ outcome VARCHAR(7) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
68
+ reason_code VARCHAR(255) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
69
+ duration_ms BIGINT NOT NULL,
70
+ retention_until DATETIME(3) NOT NULL,
71
+ created_at DATETIME(3) NOT NULL,
72
+ CONSTRAINT account_gate_attempts_guild_attempt_unique UNIQUE (guild_id, attempt_id),
73
+ CONSTRAINT account_gate_attempts_session_challenge_unique UNIQUE (guild_id, session_id, challenge_type),
74
+ KEY account_gate_attempts_member_privacy_idx (member_user_id, created_at, attempt_id),
75
+ KEY account_gate_attempts_retention_idx (retention_until, attempt_id),
76
+ CHECK (challenge_type IN (
77
+ 'quick_sum', 'button_sequence', 'number_order', 'symbol_match',
78
+ 'modal_recall', 'server_question', 'password_entry', 'emoji_entry'
79
+ )),
80
+ CHECK (outcome IN ('passed', 'failed', 'expired')),
81
+ CHECK (duration_ms >= 0)
82
+ ) ENGINE=InnoDB DEFAULT CHARACTER SET=utf8mb4 COLLATE=utf8mb4_bin;
83
+
84
+ CREATE TABLE account_gate_member_summaries (
85
+ summary_id VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
86
+ guild_id VARCHAR(20) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
87
+ member_user_id VARCHAR(20) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
88
+ previous_success_at DATETIME(3) NULL,
89
+ failure_count INT NOT NULL,
90
+ lockout_until DATETIME(3) NULL,
91
+ rejoin_count INT NOT NULL,
92
+ join_generation INT NOT NULL,
93
+ account_created_at DATETIME(3) NOT NULL,
94
+ joined_at DATETIME(3) NOT NULL,
95
+ deadline_at DATETIME(3) NOT NULL,
96
+ pending_reason VARCHAR(20) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
97
+ last_failure_terminal_id VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NULL,
98
+ left_at DATETIME(3) NULL,
99
+ last_decision VARCHAR(20) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
100
+ revision INT NOT NULL,
101
+ retention_until DATETIME(3) NOT NULL,
102
+ created_at DATETIME(3) NOT NULL,
103
+ updated_at DATETIME(3) NOT NULL,
104
+ PRIMARY KEY (guild_id, member_user_id),
105
+ CONSTRAINT account_gate_member_summaries_id_unique UNIQUE (summary_id),
106
+ KEY account_gate_member_summary_privacy_idx (member_user_id, created_at, guild_id),
107
+ KEY account_gate_member_summary_retention_idx (retention_until, guild_id, member_user_id),
108
+ CHECK (failure_count >= 0),
109
+ CHECK (rejoin_count >= 0),
110
+ CHECK (join_generation > 0),
111
+ CHECK (pending_reason IN ('none', 'account_too_new')),
112
+ CHECK (last_decision IN (
113
+ 'pending', 'finalizing', 'passed', 'failed', 'review', 'kick_pending',
114
+ 'kicked', 'dismissed', 'revoked'
115
+ )),
116
+ CHECK (revision > 0),
117
+ CHECK (updated_at >= created_at)
118
+ ) ENGINE=InnoDB DEFAULT CHARACTER SET=utf8mb4 COLLATE=utf8mb4_bin;
119
+
120
+ CREATE TABLE account_gate_reviews (
121
+ review_id VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin PRIMARY KEY,
122
+ pending_key VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
123
+ guild_id VARCHAR(20) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
124
+ member_user_id VARCHAR(20) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
125
+ join_generation INT NOT NULL,
126
+ status VARCHAR(20) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
127
+ pending_member_user_id VARCHAR(20) CHARACTER SET ascii COLLATE ascii_bin
128
+ GENERATED ALWAYS AS (IF(status = 'pending', member_user_id, NULL)) STORED,
129
+ delivery_status VARCHAR(7) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
130
+ claim_action VARCHAR(20) CHARACTER SET ascii COLLATE ascii_bin NULL,
131
+ claim_expires_at DATETIME(3) NULL,
132
+ reason_code VARCHAR(255) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
133
+ challenge_types_json TEXT COLLATE utf8mb4_bin NOT NULL,
134
+ failed_attempts INT NOT NULL,
135
+ expired_attempts INT NOT NULL,
136
+ review_channel_id VARCHAR(20) CHARACTER SET ascii COLLATE ascii_bin NULL,
137
+ review_message_id VARCHAR(20) CHARACTER SET ascii COLLATE ascii_bin NULL,
138
+ decided_by_user_id VARCHAR(20) CHARACTER SET ascii COLLATE ascii_bin NULL,
139
+ decided_at DATETIME(3) NULL,
140
+ revision INT NOT NULL,
141
+ retention_until DATETIME(3) NOT NULL,
142
+ created_at DATETIME(3) NOT NULL,
143
+ updated_at DATETIME(3) NOT NULL,
144
+ CONSTRAINT account_gate_reviews_pending_key_unique UNIQUE (pending_key),
145
+ CONSTRAINT account_gate_reviews_guild_review_unique UNIQUE (guild_id, review_id),
146
+ CONSTRAINT account_gate_reviews_guild_message_unique UNIQUE (guild_id, review_message_id),
147
+ CONSTRAINT account_gate_one_pending_review_unique UNIQUE (guild_id, pending_member_user_id),
148
+ KEY account_gate_reviews_member_privacy_idx (member_user_id, created_at, review_id),
149
+ KEY account_gate_reviews_actor_privacy_idx (decided_by_user_id, created_at, review_id),
150
+ KEY account_gate_reviews_retention_idx (retention_until, review_id),
151
+ CHECK (join_generation > 0),
152
+ CHECK (status IN (
153
+ 'pending', 'claim_approve', 'claim_retry', 'claim_kick',
154
+ 'claim_dismiss', 'approved', 'retry', 'kicked', 'dismissed'
155
+ )),
156
+ CHECK (delivery_status IN ('pending', 'sent', 'failed')),
157
+ CHECK (failed_attempts >= 0),
158
+ CHECK (expired_attempts >= 0),
159
+ CHECK (revision > 0),
160
+ CHECK (updated_at >= created_at)
161
+ ) ENGINE=InnoDB DEFAULT CHARACTER SET=utf8mb4 COLLATE=utf8mb4_bin;
162
+
163
+ CREATE TABLE account_gate_terminal_finalizations (
164
+ finalization_id VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin PRIMARY KEY,
165
+ guild_id VARCHAR(20) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
166
+ member_user_id VARCHAR(20) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
167
+ session_id VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
168
+ join_generation INT NOT NULL,
169
+ outcome VARCHAR(7) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
170
+ challenge_type VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
171
+ challenge_types_json TEXT COLLATE utf8mb4_bin NOT NULL,
172
+ work_created_at DATETIME(3) NOT NULL,
173
+ status VARCHAR(20) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
174
+ revision INT NOT NULL,
175
+ retention_until DATETIME(3) NOT NULL,
176
+ created_at DATETIME(3) NOT NULL,
177
+ updated_at DATETIME(3) NOT NULL,
178
+ CONSTRAINT account_gate_terminal_session_unique UNIQUE (guild_id, session_id),
179
+ KEY account_gate_terminal_finalization_status_idx (status, created_at, finalization_id),
180
+ CHECK (join_generation > 0),
181
+ CHECK (outcome IN ('passed', 'failed')),
182
+ CHECK (status IN ('pending', 'failure_recorded', 'completed', 'cancelled')),
183
+ CHECK (revision > 0),
184
+ CHECK (updated_at >= created_at)
185
+ ) ENGINE=InnoDB DEFAULT CHARACTER SET=utf8mb4 COLLATE=utf8mb4_bin;
186
+
187
+ CREATE TABLE account_gate_role_transitions (
188
+ transition_id VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin PRIMARY KEY,
189
+ guild_id VARCHAR(20) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
190
+ member_user_id VARCHAR(20) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
191
+ verified_role_id VARCHAR(20) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
192
+ pending_remove_role_ids_json TEXT COLLATE utf8mb4_bin NOT NULL,
193
+ status VARCHAR(10) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
194
+ revision INT NOT NULL,
195
+ next_retry_at DATETIME(3) NULL,
196
+ completed_at DATETIME(3) NULL,
197
+ created_at DATETIME(3) NOT NULL,
198
+ updated_at DATETIME(3) NOT NULL,
199
+ CONSTRAINT account_gate_role_transition_guild_id_unique UNIQUE (guild_id, transition_id),
200
+ CONSTRAINT account_gate_role_transition_member_unique UNIQUE (guild_id, member_user_id),
201
+ KEY account_gate_role_transition_privacy_idx (member_user_id, created_at, transition_id),
202
+ KEY account_gate_role_transition_retry_idx (status, next_retry_at, transition_id),
203
+ CHECK (status IN ('pending', 'partial', 'completed', 'review')),
204
+ CHECK (revision > 0),
205
+ CHECK (updated_at >= created_at)
206
+ ) ENGINE=InnoDB DEFAULT CHARACTER SET=utf8mb4 COLLATE=utf8mb4_bin;
207
+
208
+ CREATE TABLE account_gate_strict_modes (
209
+ guild_id VARCHAR(20) CHARACTER SET ascii COLLATE ascii_bin PRIMARY KEY,
210
+ status VARCHAR(8) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
211
+ ends_at DATETIME(3) NOT NULL,
212
+ generation INT NOT NULL,
213
+ activated_by_user_id VARCHAR(20) CHARACTER SET ascii COLLATE ascii_bin NULL,
214
+ revision INT NOT NULL,
215
+ created_at DATETIME(3) NOT NULL,
216
+ updated_at DATETIME(3) NOT NULL,
217
+ KEY account_gate_strict_mode_expiry_idx (status, ends_at, guild_id),
218
+ KEY account_gate_strict_mode_actor_privacy_idx (activated_by_user_id, created_at, guild_id),
219
+ CHECK (status IN ('active', 'expired', 'disabled')),
220
+ CHECK (generation > 0),
221
+ CHECK (revision > 0),
222
+ CHECK (updated_at >= created_at)
223
+ ) ENGINE=InnoDB DEFAULT CHARACTER SET=utf8mb4 COLLATE=utf8mb4_bin;
224
+
225
+ CREATE TABLE account_gate_privacy_suppressions (
226
+ suppression_id VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
227
+ guild_id VARCHAR(20) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
228
+ member_user_id VARCHAR(20) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
229
+ suppressed_at DATETIME(3) NOT NULL,
230
+ created_at DATETIME(3) NOT NULL,
231
+ PRIMARY KEY (guild_id, member_user_id),
232
+ CONSTRAINT account_gate_privacy_suppression_id_unique UNIQUE (suppression_id),
233
+ KEY account_gate_privacy_suppression_subject_idx (member_user_id, created_at, guild_id)
234
+ ) ENGINE=InnoDB DEFAULT CHARACTER SET=utf8mb4 COLLATE=utf8mb4_bin;
235
+
236
+ CREATE TABLE account_gate_panel_message_cleanup (
237
+ cleanup_id VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin PRIMARY KEY,
238
+ guild_id VARCHAR(20) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
239
+ panel_id VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
240
+ channel_id VARCHAR(20) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
241
+ message_id VARCHAR(20) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
242
+ attempts INT NOT NULL DEFAULT 0,
243
+ next_attempt_at DATETIME(3) NOT NULL,
244
+ completed_at DATETIME(3) NULL,
245
+ created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
246
+ CONSTRAINT account_gate_panel_cleanup_message_unique UNIQUE (guild_id, channel_id, message_id),
247
+ KEY account_gate_panel_cleanup_pending_idx (next_attempt_at, cleanup_id),
248
+ CHECK (attempts >= 0)
249
+ ) ENGINE=InnoDB DEFAULT CHARACTER SET=utf8mb4 COLLATE=utf8mb4_bin;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@helyx/module-account-gate",
3
- "version": "2.0.4",
3
+ "version": "2.1.1",
4
4
  "description": "Require configurable Discord interaction checks before new members gain access.",
5
5
  "keywords": [
6
6
  "helyx",
@@ -24,7 +24,7 @@
24
24
  },
25
25
  "publishConfig": {
26
26
  "access": "public",
27
- "provenance": true
27
+ "provenance": false
28
28
  },
29
29
  "type": "module",
30
30
  "files": [
@@ -32,6 +32,7 @@
32
32
  "!dist/*.tsbuildinfo",
33
33
  "!dist/**/*.map",
34
34
  "migrations",
35
+ "migrations-mysql",
35
36
  "manifest.json",
36
37
  "README.md",
37
38
  "CHANGELOG.md",
@@ -49,7 +50,7 @@
49
50
  "test": "vitest run --root ../.. modules/account-gate/tests"
50
51
  },
51
52
  "dependencies": {
52
- "@helyx/module-manifest": ">=0.11.0 <0.12.0",
53
- "@helyx/sdk": ">=0.14.0"
53
+ "@helyx/module-manifest": ">=0.13.0 <0.14.0",
54
+ "@helyx/sdk": ">=0.16.0 <0.17.0"
54
55
  }
55
56
  }