@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.
- package/CHANGELOG.md +22 -0
- package/LICENSE +725 -0
- package/README.md +90 -0
- package/dist/challenges.d.ts +16 -0
- package/dist/challenges.js +233 -0
- package/dist/configuration.d.ts +41 -0
- package/dist/configuration.js +224 -0
- package/dist/contracts.d.ts +82 -0
- package/dist/contracts.js +56 -0
- package/dist/enforcement.d.ts +19 -0
- package/dist/enforcement.js +110 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +3324 -0
- package/dist/managed-resource-summary.d.ts +3 -0
- package/dist/managed-resource-summary.js +6 -0
- package/dist/member-resource.d.ts +3 -0
- package/dist/member-resource.js +348 -0
- package/dist/panel-resource.d.ts +15 -0
- package/dist/panel-resource.js +527 -0
- package/dist/password-entry.d.ts +8 -0
- package/dist/password-entry.js +59 -0
- package/dist/password-resource.d.ts +8 -0
- package/dist/password-resource.js +208 -0
- package/dist/privacy.d.ts +4 -0
- package/dist/privacy.js +20 -0
- package/dist/public-channel-resource.d.ts +4 -0
- package/dist/public-channel-resource.js +135 -0
- package/dist/question-resource.d.ts +3 -0
- package/dist/question-resource.js +246 -0
- package/dist/records.d.ts +508 -0
- package/dist/records.js +456 -0
- package/dist/workflow.d.ts +43 -0
- package/dist/workflow.js +167 -0
- package/manifest.json +750 -0
- package/migrations/0001_account_gate_foundation.sql +303 -0
- package/migrations/0002_account_gate_panel_message.sql +27 -0
- package/migrations/0003_account_gate_revoked_member_status.sql +17 -0
- package/migrations/0004_account_gate_multiple_panels.sql +23 -0
- package/migrations/0005_account_gate_panel_passwords.sql +45 -0
- 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
|