@helyx/module-account-gate 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/LICENSE +725 -0
  3. package/README.md +90 -0
  4. package/dist/challenges.d.ts +16 -0
  5. package/dist/challenges.js +233 -0
  6. package/dist/configuration.d.ts +41 -0
  7. package/dist/configuration.js +224 -0
  8. package/dist/contracts.d.ts +82 -0
  9. package/dist/contracts.js +56 -0
  10. package/dist/enforcement.d.ts +19 -0
  11. package/dist/enforcement.js +110 -0
  12. package/dist/index.d.ts +27 -0
  13. package/dist/index.js +3324 -0
  14. package/dist/managed-resource-summary.d.ts +3 -0
  15. package/dist/managed-resource-summary.js +6 -0
  16. package/dist/member-resource.d.ts +3 -0
  17. package/dist/member-resource.js +348 -0
  18. package/dist/panel-resource.d.ts +15 -0
  19. package/dist/panel-resource.js +527 -0
  20. package/dist/password-entry.d.ts +8 -0
  21. package/dist/password-entry.js +59 -0
  22. package/dist/password-resource.d.ts +8 -0
  23. package/dist/password-resource.js +208 -0
  24. package/dist/privacy.d.ts +4 -0
  25. package/dist/privacy.js +20 -0
  26. package/dist/public-channel-resource.d.ts +4 -0
  27. package/dist/public-channel-resource.js +135 -0
  28. package/dist/question-resource.d.ts +3 -0
  29. package/dist/question-resource.js +246 -0
  30. package/dist/records.d.ts +508 -0
  31. package/dist/records.js +456 -0
  32. package/dist/workflow.d.ts +43 -0
  33. package/dist/workflow.js +167 -0
  34. package/manifest.json +750 -0
  35. package/migrations/0001_account_gate_foundation.sql +303 -0
  36. package/migrations/0002_account_gate_panel_message.sql +27 -0
  37. package/migrations/0003_account_gate_revoked_member_status.sql +17 -0
  38. package/migrations/0004_account_gate_multiple_panels.sql +23 -0
  39. package/migrations/0005_account_gate_panel_passwords.sql +45 -0
  40. package/package.json +54 -0
package/README.md ADDED
@@ -0,0 +1,90 @@
1
+ # Access Control
2
+
3
+ Access Control asks a joining member to complete short Discord interaction checks
4
+ before Helyx grants the configured access role. It is a local
5
+ human-interaction check: it can deter basic automated accounts, but it does not
6
+ detect devices, network risk, alternate accounts or ban evasion.
7
+
8
+ The default flow uses two different generated challenges and allows three
9
+ attempts. Eight challenge types are implemented: Quick Sum, Button Sequence,
10
+ Number Order, Symbol Match, Modal Recall, Server Question, Password Entry and
11
+ Emoji Entry. Password Entry and Emoji Entry are static gates and are excluded
12
+ from generated challenge allocation. A server selects one access type:
13
+ generated Challenges, Password Entry or Emoji Entry.
14
+
15
+ ## Permissions and Discord configuration
16
+
17
+ Access Control requires View Channel, Send Messages, Embed Links, Read Message
18
+ History and Manage Roles. Kick Members is required only when final failure is
19
+ configured to kick. Password Entry also requires Manage Messages, Guild
20
+ Messages and the privileged Message Content intent; its channel health check
21
+ explicitly requires View Channel, Read Message History and Manage Messages.
22
+ Emoji Entry may require Add Reactions and the Guild Message Reactions intent.
23
+
24
+ Helyx must be above the access role and every temporary role it removes. The
25
+ module adds the access role before removing temporary roles so a partial
26
+ Discord failure does not remove the member's only access route.
27
+
28
+ Discord channel permissions are the sole authority for which channels pending
29
+ members can see. Access Control validates its configured gate, Password Entry,
30
+ Emoji Entry and staff-review channels, but it does not maintain a second
31
+ dashboard allowlist of every other visible server channel. Administrators
32
+ should configure the temporary pre-gate role's Discord permissions directly.
33
+ The managed Start panel may be absent during the initial
34
+ activation-and-publish flow, but runtime joins fail closed until its saved
35
+ message exists. Enabling Server Question likewise requires at least one
36
+ enabled managed question.
37
+
38
+ The review-channel audience check rejects `@everyone` and every configured
39
+ temporary pre-gate role. The current Discord inspection contract only reports
40
+ roles explicitly requested by the module and Access Control has no configured
41
+ staff-role allowlist, so it cannot prove that every other visible role is a
42
+ staff role; administrators must keep that channel staff-only.
43
+
44
+ ## Failure and strict-mode behaviour
45
+
46
+ Staff review is the default final-failure action. When kick is deliberately
47
+ selected, Helyx first makes a best-effort attempt to send a neutral direct
48
+ message explaining that Access Control was not completed. It then rechecks
49
+ the current decision and kicks as soon as possible; a closed direct-message
50
+ channel does not prevent the kick. The target is within 30 seconds and there is
51
+ no deliberate delay.
52
+
53
+ Authorised Access Control managers may activate temporary strict mode. It lasts
54
+ 15 minutes by default, can be configured from 5 to 60 minutes, adds one
55
+ generated challenge without exceeding four, and expires automatically. It does
56
+ not reduce attempts or enable kicking, Password Entry or Emoji Entry.
57
+
58
+ Join deadlines and active-check expiry use durable one-shot tasks with exact
59
+ join generations and session versions. Restarted or duplicate workers cannot
60
+ enforce an old join or expire a newer check. A failed challenge applies the
61
+ configured retry cooldown monotonically; passing or an authorised Approve or
62
+ Retry decision clears it. Account age uses Discord's account-created and joined
63
+ timestamps, and accepts the exact configured age boundary.
64
+
65
+ Returning members are restored only when a prior success is retained, restore
66
+ is enabled, re-verification is disabled, and the current access role is still
67
+ assignable. Otherwise they enter a fresh pending generation. Adaptive chains
68
+ add a generated challenge for a prior-failure or young-account signal, while
69
+ temporary strict mode may add one more, capped at four.
70
+
71
+ Password Entry and Emoji Entry are single-step access types rather than members
72
+ of a generated challenge chain. Helyx sends answer-free private resume guidance
73
+ that identifies the Discord server and gate channel. Closed direct messages
74
+ never cause user-specific controls or answers to be posted publicly; the member
75
+ can use the panel or `/access-control start` to resume the persisted check.
76
+
77
+ ## Stored data
78
+
79
+ The module stores server questions, safe attempt outcomes, member summaries,
80
+ staff-review decisions, recoverable role transitions and strict-mode
81
+ activations. Raw Password Entry messages are never retained. Active challenge
82
+ answers use the shared expiring Helyx session service rather than a module-owned
83
+ session table. A versioned Privacy Service provider supports bounded access and
84
+ erasure.
85
+
86
+ ## Release state
87
+
88
+ Version 1.0.0 is private and in Testing. Live Discord validation has not yet
89
+ been performed. Runtime enforcement rechecks module state, member state, kick
90
+ permission and role hierarchy immediately before every kick.
@@ -0,0 +1,16 @@
1
+ import type { Challenge, ChallengeType, GeneratedChallengeType, RandomSource, ServerQuestion } from "./contracts.js";
2
+ import type { AccountGateConfiguration } from "./configuration.js";
3
+ export interface GenerateChainInput {
4
+ configuration: AccountGateConfiguration;
5
+ random: RandomSource;
6
+ operationContext: string;
7
+ strictModeActive: boolean;
8
+ adaptiveRisk?: boolean;
9
+ questions?: readonly ServerQuestion[];
10
+ }
11
+ export declare function allocateChallengeTypes(input: GenerateChainInput): readonly ChallengeType[];
12
+ export declare function generateChallengeChain(input: GenerateChainInput): readonly Challenge[];
13
+ export declare function generateChallenge(type: ChallengeType, random: RandomSource, operationContext: string, questions?: readonly ServerQuestion[]): Challenge;
14
+ export declare function validateServerQuestion(input: unknown): ServerQuestion;
15
+ export declare function isGeneratedChallengeType(type: ChallengeType): type is GeneratedChallengeType;
16
+ //# sourceMappingURL=challenges.d.ts.map
@@ -0,0 +1,233 @@
1
+ import { GENERATED_CHALLENGE_TYPES } from "./contracts.js";
2
+ import { effectiveChallengeCount, generatedChallengePool, } from "./configuration.js";
3
+ const SYMBOLS = [
4
+ { symbol: "★", name: "Star" },
5
+ { symbol: "●", name: "Circle" },
6
+ { symbol: "▲", name: "Triangle" },
7
+ { symbol: "◆", name: "Diamond" },
8
+ ];
9
+ const RECALL_ALPHABET = "ABCDEFGHJKMNPQRTUVWXYZ2346789";
10
+ const COLOURS = ["red", "blue", "green"];
11
+ export function allocateChallengeTypes(input) {
12
+ const { configuration, random, operationContext, strictModeActive } = input;
13
+ if (configuration.allocationMode === "fixed_chain") {
14
+ const fixed = [...configuration.fixedChallengeChain];
15
+ if (!strictModeActive || fixed.length >= 4)
16
+ return fixed;
17
+ const additional = generatedChallengePool(configuration).filter((type) => !fixed.includes(type));
18
+ if (additional.length > 0) {
19
+ const selected = additional[random.integer(additional.length, `${operationContext}:strict-additional`)];
20
+ const finalIsStatic = fixed.at(-1) === "password_entry" || fixed.at(-1) === "emoji_entry";
21
+ fixed.splice(finalIsStatic ? fixed.length - 1 : fixed.length, 0, selected);
22
+ }
23
+ return fixed;
24
+ }
25
+ const pool = [...generatedChallengePool(configuration)];
26
+ const count = configuration.allocationMode === "random_one"
27
+ ? Math.min(pool.length, strictModeActive ? 2 : 1)
28
+ : configuration.allocationMode === "adaptive_chain"
29
+ ? Math.min(pool.length, 4, 1 + (input.adaptiveRisk ? 1 : 0) + (strictModeActive ? 1 : 0))
30
+ : effectiveChallengeCount(configuration, strictModeActive);
31
+ const selected = [];
32
+ while (selected.length < count) {
33
+ const index = random.integer(pool.length, `${operationContext}:allocation:${selected.length}`);
34
+ selected.push(pool.splice(index, 1)[0]);
35
+ }
36
+ return selected;
37
+ }
38
+ export function generateChallengeChain(input) {
39
+ return allocateChallengeTypes(input).map((type, index) => generateChallenge(type, input.random, `${input.operationContext}:challenge:${index}`, input.questions ?? []));
40
+ }
41
+ export function generateChallenge(type, random, operationContext, questions = []) {
42
+ switch (type) {
43
+ case "quick_sum":
44
+ return quickSum(random, operationContext);
45
+ case "button_sequence":
46
+ return buttonSequence(random, operationContext);
47
+ case "number_order":
48
+ return numberOrder(random, operationContext);
49
+ case "symbol_match":
50
+ return symbolMatch(random, operationContext);
51
+ case "modal_recall":
52
+ return modalRecall(random, operationContext);
53
+ case "server_question":
54
+ return serverQuestion(random, operationContext, questions);
55
+ case "password_entry":
56
+ return {
57
+ type,
58
+ prompt: "Enter the password for this gate in the Password Entry channel. Never enter your Discord password or authentication code.",
59
+ };
60
+ case "emoji_entry":
61
+ return {
62
+ type,
63
+ prompt: "Add the configured emoji reaction to the exact server message to continue.",
64
+ };
65
+ }
66
+ }
67
+ export function validateServerQuestion(input) {
68
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
69
+ throw new Error("Server question must be an object");
70
+ }
71
+ const value = input;
72
+ if (typeof value.id !== "string" ||
73
+ value.id.length < 1 ||
74
+ value.id.length > 100) {
75
+ throw new Error("Server question ID is invalid");
76
+ }
77
+ if (typeof value.question !== "string" ||
78
+ value.question.trim().length < 1 ||
79
+ value.question.trim().length > 200) {
80
+ throw new Error("Server question must contain 1 to 200 characters");
81
+ }
82
+ if (!Array.isArray(value.answers) ||
83
+ value.answers.length < 3 ||
84
+ value.answers.length > 5) {
85
+ throw new Error("Server question must have three to five answers");
86
+ }
87
+ const answers = value.answers.map((answer) => {
88
+ if (typeof answer !== "string" ||
89
+ answer.trim().length < 1 ||
90
+ answer.trim().length > 80) {
91
+ throw new Error("Server question answers must contain 1 to 80 characters");
92
+ }
93
+ return answer.trim();
94
+ });
95
+ if (new Set(answers.map((answer) => answer.toLocaleLowerCase("en-GB"))).size !==
96
+ answers.length) {
97
+ throw new Error("Server question answers must be unique");
98
+ }
99
+ if (!Number.isInteger(value.correctOption) ||
100
+ value.correctOption < 0 ||
101
+ value.correctOption >= answers.length) {
102
+ throw new Error("Server question must have exactly one correct answer");
103
+ }
104
+ return {
105
+ id: value.id,
106
+ question: value.question.trim(),
107
+ answers,
108
+ correctOption: value.correctOption,
109
+ };
110
+ }
111
+ export function isGeneratedChallengeType(type) {
112
+ return GENERATED_CHALLENGE_TYPES.includes(type);
113
+ }
114
+ function quickSum(random, context) {
115
+ const subtract = random.integer(2, `${context}:operator`) === 1;
116
+ const first = random.integer(16, `${context}:first`) + 4;
117
+ const secondMaximum = subtract ? first + 1 : 16;
118
+ const second = random.integer(secondMaximum, `${context}:second`);
119
+ const answer = subtract ? first - second : first + second;
120
+ const candidates = uniquePlausibleAnswers(answer);
121
+ const shuffled = shuffle(candidates, random, `${context}:answers`);
122
+ return {
123
+ type: "quick_sum",
124
+ prompt: `What is ${first} ${subtract ? "−" : "+"} ${second}?`,
125
+ options: shuffled.map(String),
126
+ expectedOption: shuffled.indexOf(answer),
127
+ };
128
+ }
129
+ function buttonSequence(random, context) {
130
+ const length = random.integer(4, `${context}:length`) + 3;
131
+ const sequence = Array.from({ length }, (_, index) => {
132
+ return COLOURS[random.integer(3, `${context}:press:${index}`)];
133
+ });
134
+ const grouped = [];
135
+ for (let index = 0; index < sequence.length;) {
136
+ const colour = sequence[index];
137
+ let count = 1;
138
+ while (sequence[index + count] === colour)
139
+ count += 1;
140
+ grouped.push(`${title(colour)} ${count === 1 ? "once" : count === 2 ? "twice" : `${count} times`}`);
141
+ index += count;
142
+ }
143
+ return {
144
+ type: "button_sequence",
145
+ prompt: `Press ${grouped.join(", then ")}.`,
146
+ expectedSequence: sequence,
147
+ progress: 0,
148
+ };
149
+ }
150
+ function numberOrder(random, context) {
151
+ const count = random.integer(2, `${context}:count`) + 3;
152
+ const values = [];
153
+ while (values.length < count) {
154
+ const candidate = random.integer(21, `${context}:value:${values.length}`);
155
+ if (!values.includes(candidate))
156
+ values.push(candidate);
157
+ }
158
+ const descending = random.integer(2, `${context}:direction`) === 1;
159
+ const expected = [...values].sort((a, b) => (descending ? b - a : a - b));
160
+ const options = shuffle(values, random, `${context}:answers`);
161
+ return {
162
+ type: "number_order",
163
+ prompt: `Press the numbers from ${descending ? "highest to lowest" : "lowest to highest"}.`,
164
+ options: options.map(String),
165
+ expectedSequence: expected.map((value) => options.indexOf(value)),
166
+ progress: 0,
167
+ };
168
+ }
169
+ function symbolMatch(random, context) {
170
+ const target = random.integer(SYMBOLS.length, `${context}:target`);
171
+ const shuffled = shuffle([...SYMBOLS], random, `${context}:answers`);
172
+ const expected = shuffled.findIndex((candidate) => candidate === SYMBOLS[target]);
173
+ return {
174
+ type: "symbol_match",
175
+ prompt: `Select the ${SYMBOLS[target].name.toLocaleLowerCase("en-GB")}.`,
176
+ options: shuffled.map(({ symbol, name }) => `${symbol} ${name}`),
177
+ expectedOption: expected,
178
+ };
179
+ }
180
+ function modalRecall(random, context) {
181
+ const length = random.integer(3, `${context}:length`) + 5;
182
+ let code = "";
183
+ for (let index = 0; index < length; index += 1) {
184
+ code +=
185
+ RECALL_ALPHABET[random.integer(RECALL_ALPHABET.length, `${context}:character:${index}`)];
186
+ }
187
+ const display = `${code.slice(0, 2)}-${code.slice(2)}`;
188
+ return {
189
+ type: "modal_recall",
190
+ prompt: `Remember this code: ${display}`,
191
+ expectedText: display,
192
+ };
193
+ }
194
+ function serverQuestion(random, context, questions) {
195
+ if (questions.length < 1) {
196
+ throw new Error("No enabled Server Question is available");
197
+ }
198
+ const selected = questions[random.integer(questions.length, `${context}:question`)];
199
+ const indexed = selected.answers.map((label, originalIndex) => ({
200
+ label,
201
+ originalIndex,
202
+ }));
203
+ const shuffled = shuffle(indexed, random, `${context}:answers`);
204
+ return {
205
+ type: "server_question",
206
+ prompt: selected.question,
207
+ options: shuffled.map(({ label }) => label),
208
+ expectedOption: shuffled.findIndex(({ originalIndex }) => originalIndex === selected.correctOption),
209
+ };
210
+ }
211
+ function uniquePlausibleAnswers(answer) {
212
+ const values = [answer];
213
+ for (const delta of [1, -1, 2, -2, 3, -3]) {
214
+ const candidate = answer + delta;
215
+ if (candidate >= 0 && !values.includes(candidate))
216
+ values.push(candidate);
217
+ if (values.length === 3)
218
+ break;
219
+ }
220
+ return values;
221
+ }
222
+ function shuffle(values, random, context) {
223
+ const result = [...values];
224
+ for (let index = result.length - 1; index > 0; index -= 1) {
225
+ const other = random.integer(index + 1, `${context}:${index}`);
226
+ [result[index], result[other]] = [result[other], result[index]];
227
+ }
228
+ return result;
229
+ }
230
+ function title(value) {
231
+ return value.slice(0, 1).toLocaleUpperCase("en-GB") + value.slice(1);
232
+ }
233
+ //# sourceMappingURL=challenges.js.map
@@ -0,0 +1,41 @@
1
+ import type { ChallengeType, FailureAction, GeneratedChallengeType } from "./contracts.js";
2
+ export interface AccountGateConfiguration extends Record<string, unknown> {
3
+ accessType: "challenges" | "password" | "emoji";
4
+ gateChannelId: string;
5
+ verifiedRoleId: string;
6
+ temporaryRoleIds: readonly string[];
7
+ reviewChannelId: string;
8
+ preset: "light" | "standard" | "strict" | "custom";
9
+ allocationMode: "random_one" | "random_chain" | "fixed_chain" | "adaptive_chain";
10
+ enabledChallengeTypes: readonly ChallengeType[];
11
+ fixedChallengeChain: readonly ChallengeType[];
12
+ challengeCount: number;
13
+ attempts: number;
14
+ sessionExpirySeconds: number;
15
+ retryCooldownSeconds: number;
16
+ joinDeadlineSeconds: number;
17
+ failureAction: FailureAction;
18
+ strictModeDurationMinutes: number;
19
+ minimumAccountAgeDays: number;
20
+ reverifyReturningMembers: boolean;
21
+ restoreVerifiedMembers: boolean;
22
+ passwordChannelId: string;
23
+ passwordIgnoreCase: boolean;
24
+ passwordTrimWhitespace: boolean;
25
+ emojiChannelId: string;
26
+ emojiMessageId: string;
27
+ emojiIdentifier: string;
28
+ }
29
+ export interface RuntimeCapabilities {
30
+ guildMembers: boolean;
31
+ guildMessages: boolean;
32
+ guildMessageReactions: boolean;
33
+ messageContent: boolean;
34
+ }
35
+ export declare function parseAccountGateConfiguration(input: unknown): AccountGateConfiguration;
36
+ export declare function validateRuntimeCapabilities(configuration: AccountGateConfiguration, capabilities: RuntimeCapabilities): readonly string[];
37
+ export declare function generatedChallengePool(configuration: AccountGateConfiguration): readonly GeneratedChallengeType[];
38
+ export declare function effectiveChallengeCount(configuration: AccountGateConfiguration, strictModeActive: boolean): number;
39
+ export declare function isStrictModeActive(endsAt: Date | null, now?: Date): boolean;
40
+ export declare const STATIC_TYPES: readonly ["password_entry", "emoji_entry"];
41
+ //# sourceMappingURL=configuration.d.ts.map
@@ -0,0 +1,224 @@
1
+ import { CHALLENGE_TYPES, GENERATED_CHALLENGE_TYPES, STATIC_CHALLENGE_TYPES, } from "./contracts.js";
2
+ const SNOWFLAKE = /^\d{17,20}$/u;
3
+ export function parseAccountGateConfiguration(input) {
4
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
5
+ throw new Error("Access Control configuration must be an object");
6
+ }
7
+ const value = input;
8
+ let enabledChallengeTypes = parseChallengeList(value.enabledChallengeTypes, "enabledChallengeTypes", false);
9
+ let fixedChallengeChain = parseChallengeList(value.fixedChallengeChain ?? "", "fixedChallengeChain", true);
10
+ let preset = oneOf(value.preset, ["light", "standard", "strict", "custom"], "preset");
11
+ let allocationMode = oneOf(value.allocationMode, ["random_one", "random_chain", "fixed_chain", "adaptive_chain"], "allocationMode");
12
+ let challengeCount = integer(value.challengeCount, 1, 4, "challengeCount");
13
+ const accessType = value.accessType === undefined
14
+ ? inferLegacyAccessType(enabledChallengeTypes, fixedChallengeChain)
15
+ : oneOf(value.accessType, ["challenges", "password", "emoji"], "accessType");
16
+ const hasExplicitAccessType = value.accessType !== undefined;
17
+ if (hasExplicitAccessType && accessType === "challenges") {
18
+ if (enabledChallengeTypes.some((type) => STATIC_CHALLENGE_TYPES.includes(type))) {
19
+ throw new Error("Access Control challenge access cannot include Password Entry or Emoji Entry");
20
+ }
21
+ }
22
+ else if (hasExplicitAccessType && accessType !== "challenges") {
23
+ const staticType = accessType === "password" ? "password_entry" : "emoji_entry";
24
+ preset = "custom";
25
+ allocationMode = "fixed_chain";
26
+ enabledChallengeTypes = [staticType];
27
+ fixedChallengeChain = [staticType];
28
+ challengeCount = 1;
29
+ }
30
+ if (preset === "light") {
31
+ allocationMode = "random_one";
32
+ challengeCount = 1;
33
+ }
34
+ else if (preset === "standard") {
35
+ allocationMode = "random_chain";
36
+ challengeCount = 2;
37
+ }
38
+ else if (preset === "strict") {
39
+ allocationMode = "random_chain";
40
+ challengeCount = 3;
41
+ }
42
+ const generatedEnabled = enabledChallengeTypes.filter((type) => GENERATED_CHALLENGE_TYPES.includes(type));
43
+ if (allocationMode !== "fixed_chain" &&
44
+ challengeCount > generatedEnabled.length) {
45
+ throw new Error("Access Control challengeCount exceeds the enabled generated challenge pool");
46
+ }
47
+ if (allocationMode === "fixed_chain") {
48
+ if (fixedChallengeChain.length < 1 || fixedChallengeChain.length > 4) {
49
+ throw new Error("Access Control fixedChallengeChain must contain one to four challenges");
50
+ }
51
+ for (const type of fixedChallengeChain) {
52
+ if (!enabledChallengeTypes.includes(type)) {
53
+ throw new Error("Access Control fixedChallengeChain contains a disabled challenge");
54
+ }
55
+ }
56
+ }
57
+ const temporaryRoleIds = snowflakeList(value.temporaryRoleIds ?? [], "temporaryRoleIds");
58
+ const verifiedRoleId = snowflake(value.verifiedRoleId, "verifiedRoleId");
59
+ if (temporaryRoleIds.includes(verifiedRoleId)) {
60
+ throw new Error("Access Control access role cannot also be a temporary role");
61
+ }
62
+ const passwordEnabled = enabledChallengeTypes.includes("password_entry");
63
+ const passwordChannelId = optionalSnowflake(value.passwordChannelId, "passwordChannelId");
64
+ if (passwordEnabled && !passwordChannelId) {
65
+ throw new Error("Access Control Password Entry requires an exact channel");
66
+ }
67
+ const emojiEnabled = enabledChallengeTypes.includes("emoji_entry");
68
+ const emojiChannelId = optionalSnowflake(value.emojiChannelId, "emojiChannelId");
69
+ const emojiMessageId = optionalSnowflake(value.emojiMessageId, "emojiMessageId");
70
+ const emojiIdentifier = optionalString(value.emojiIdentifier, 100, "emojiIdentifier");
71
+ if (emojiEnabled &&
72
+ (!emojiChannelId || !emojiMessageId || !emojiIdentifier)) {
73
+ throw new Error("Access Control Emoji Entry requires an exact channel, message and emoji");
74
+ }
75
+ const failureAction = oneOf(value.failureAction, ["restricted", "review", "kick"], "failureAction");
76
+ const reviewChannelId = optionalSnowflake(value.reviewChannelId, "reviewChannelId");
77
+ if (!reviewChannelId)
78
+ throw new Error("Access Control requires a private reviewChannelId for recovery cases");
79
+ return {
80
+ accessType,
81
+ gateChannelId: snowflake(value.gateChannelId, "gateChannelId"),
82
+ verifiedRoleId,
83
+ temporaryRoleIds,
84
+ reviewChannelId,
85
+ preset,
86
+ allocationMode,
87
+ enabledChallengeTypes,
88
+ fixedChallengeChain,
89
+ challengeCount,
90
+ attempts: integer(value.attempts, 1, 10, "attempts"),
91
+ sessionExpirySeconds: integer(value.sessionExpirySeconds, 60, 600, "sessionExpirySeconds"),
92
+ retryCooldownSeconds: integer(value.retryCooldownSeconds, 0, 600, "retryCooldownSeconds"),
93
+ joinDeadlineSeconds: integer(value.joinDeadlineSeconds, 300, 3600, "joinDeadlineSeconds"),
94
+ failureAction,
95
+ strictModeDurationMinutes: integer(value.strictModeDurationMinutes, 5, 60, "strictModeDurationMinutes"),
96
+ minimumAccountAgeDays: integer(value.minimumAccountAgeDays, 0, 3650, "minimumAccountAgeDays"),
97
+ reverifyReturningMembers: boolean(value.reverifyReturningMembers, "reverifyReturningMembers"),
98
+ restoreVerifiedMembers: boolean(value.restoreVerifiedMembers, "restoreVerifiedMembers"),
99
+ passwordChannelId,
100
+ passwordIgnoreCase: boolean(value.passwordIgnoreCase, "passwordIgnoreCase"),
101
+ passwordTrimWhitespace: boolean(value.passwordTrimWhitespace, "passwordTrimWhitespace"),
102
+ emojiChannelId,
103
+ emojiMessageId,
104
+ emojiIdentifier,
105
+ };
106
+ }
107
+ function inferLegacyAccessType(enabledChallengeTypes, fixedChallengeChain) {
108
+ if (enabledChallengeTypes.length === 1 &&
109
+ fixedChallengeChain.length === 1 &&
110
+ enabledChallengeTypes[0] === fixedChallengeChain[0]) {
111
+ if (fixedChallengeChain[0] === "password_entry")
112
+ return "password";
113
+ if (fixedChallengeChain[0] === "emoji_entry")
114
+ return "emoji";
115
+ }
116
+ return "challenges";
117
+ }
118
+ export function validateRuntimeCapabilities(configuration, capabilities) {
119
+ const errors = [];
120
+ if (!capabilities.guildMembers) {
121
+ errors.push("Guild Members intent is unavailable.");
122
+ }
123
+ if (configuration.enabledChallengeTypes.includes("password_entry") &&
124
+ (!capabilities.guildMessages || !capabilities.messageContent)) {
125
+ errors.push("Password Entry requires Guild Messages and the privileged Message Content intent.");
126
+ }
127
+ if (configuration.enabledChallengeTypes.includes("emoji_entry") &&
128
+ !capabilities.guildMessageReactions) {
129
+ errors.push("Emoji Entry requires the Guild Message Reactions intent.");
130
+ }
131
+ return errors;
132
+ }
133
+ export function generatedChallengePool(configuration) {
134
+ return configuration.enabledChallengeTypes.filter((type) => GENERATED_CHALLENGE_TYPES.includes(type));
135
+ }
136
+ export function effectiveChallengeCount(configuration, strictModeActive) {
137
+ if (configuration.allocationMode === "fixed_chain") {
138
+ if (!strictModeActive)
139
+ return configuration.fixedChallengeChain.length;
140
+ const generatedOutsideChain = generatedChallengePool(configuration).some((type) => !configuration.fixedChallengeChain.includes(type));
141
+ return Math.min(4, configuration.fixedChallengeChain.length +
142
+ (generatedOutsideChain ? 1 : 0));
143
+ }
144
+ return Math.min(4, generatedChallengePool(configuration).length, configuration.challengeCount + (strictModeActive ? 1 : 0));
145
+ }
146
+ export function isStrictModeActive(endsAt, now = new Date()) {
147
+ return endsAt !== null && endsAt.getTime() > now.getTime();
148
+ }
149
+ function parseChallengeList(input, field, allowEmpty) {
150
+ const values = typeof input === "string"
151
+ ? input
152
+ .split(",")
153
+ .map((part) => part.trim())
154
+ .filter(Boolean)
155
+ : Array.isArray(input)
156
+ ? input
157
+ : null;
158
+ if (!values || (!allowEmpty && values.length < 1) || values.length > 8) {
159
+ throw new Error(`Access Control ${field} is invalid`);
160
+ }
161
+ const result = [];
162
+ for (const value of values) {
163
+ if (typeof value !== "string" ||
164
+ !CHALLENGE_TYPES.includes(value)) {
165
+ throw new Error(`Access Control ${field} has an unknown challenge type`);
166
+ }
167
+ if (result.includes(value)) {
168
+ throw new Error(`Access Control ${field} contains a duplicate challenge`);
169
+ }
170
+ result.push(value);
171
+ }
172
+ return result;
173
+ }
174
+ function snowflake(input, field) {
175
+ if (typeof input !== "string" || !SNOWFLAKE.test(input)) {
176
+ throw new Error(`Access Control ${field} must be a Discord snowflake`);
177
+ }
178
+ return input;
179
+ }
180
+ function optionalSnowflake(input, field) {
181
+ if (input === undefined || input === null || input === "")
182
+ return "";
183
+ return snowflake(input, field);
184
+ }
185
+ function snowflakeList(input, field) {
186
+ if (!Array.isArray(input) || input.length > 10) {
187
+ throw new Error(`Access Control ${field} must be a bounded role list`);
188
+ }
189
+ const result = input.map((value) => snowflake(value, field));
190
+ if (new Set(result).size !== result.length) {
191
+ throw new Error(`Access Control ${field} contains a duplicate role`);
192
+ }
193
+ return result;
194
+ }
195
+ function optionalString(input, maximum, field) {
196
+ if (input === undefined || input === null)
197
+ return "";
198
+ if (typeof input !== "string" || input.length > maximum) {
199
+ throw new Error(`Access Control ${field} must be bounded text`);
200
+ }
201
+ return input;
202
+ }
203
+ function integer(input, minimum, maximum, field) {
204
+ if (!Number.isInteger(input) ||
205
+ input < minimum ||
206
+ input > maximum) {
207
+ throw new Error(`Access Control ${field} must be a whole number from ${minimum} to ${maximum}`);
208
+ }
209
+ return input;
210
+ }
211
+ function boolean(input, field) {
212
+ if (typeof input !== "boolean") {
213
+ throw new Error(`Access Control ${field} must be boolean`);
214
+ }
215
+ return input;
216
+ }
217
+ function oneOf(input, values, field) {
218
+ if (typeof input !== "string" || !values.includes(input)) {
219
+ throw new Error(`Access Control ${field} is invalid`);
220
+ }
221
+ return input;
222
+ }
223
+ export const STATIC_TYPES = STATIC_CHALLENGE_TYPES;
224
+ //# sourceMappingURL=configuration.js.map