@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
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
import { DashboardActionValidationError, HELYX_SERVICE_NAMES, } from "@helyx/sdk";
|
|
2
|
+
import { ACCOUNT_GATE_COMPONENTS, ACCOUNT_GATE_IDENTITY } from "./contracts.js";
|
|
3
|
+
import { parseAccountGateConfiguration } from "./configuration.js";
|
|
4
|
+
import { managedResourceSummary } from "./managed-resource-summary.js";
|
|
5
|
+
import { hashPasswordEntryPhrase } from "./password-entry.js";
|
|
6
|
+
import { accountGateRecords } from "./records.js";
|
|
7
|
+
const VIOLET = 0x7c3aed;
|
|
8
|
+
const MAX_GATE_PANELS = 10;
|
|
9
|
+
const DEFAULT_PANEL_MESSAGES = {
|
|
10
|
+
challenges: "Complete a short human-interaction check to access the server. Press the button below when you are ready.",
|
|
11
|
+
password: "Enter the password for this gate in the Password Entry channel. Never enter your Discord password or authentication code.",
|
|
12
|
+
emoji: "React with the configured emoji on the Emoji Entry message to continue.",
|
|
13
|
+
};
|
|
14
|
+
export const gatePanelResource = {
|
|
15
|
+
id: "gate-panel",
|
|
16
|
+
availableWhileDisabled: true,
|
|
17
|
+
async list(context) {
|
|
18
|
+
const page = await records(context).findMany({
|
|
19
|
+
moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
|
|
20
|
+
collection: "gate_panels",
|
|
21
|
+
select: accountGateRecords.gate_panels.readableFields,
|
|
22
|
+
where: { guild_id: context.guildId },
|
|
23
|
+
orderBy: [
|
|
24
|
+
{ field: "guild_id", direction: "asc" },
|
|
25
|
+
{ field: "created_at", direction: "asc" },
|
|
26
|
+
{ field: "panel_id", direction: "asc" },
|
|
27
|
+
],
|
|
28
|
+
limit: MAX_GATE_PANELS,
|
|
29
|
+
});
|
|
30
|
+
const details = await Promise.all(page.records.map((row) => inspectedDetail(context, row)));
|
|
31
|
+
return { items: details.map(managedResourceSummary) };
|
|
32
|
+
},
|
|
33
|
+
async read(context, resourceId) {
|
|
34
|
+
const row = await records(context).findOne({
|
|
35
|
+
moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
|
|
36
|
+
collection: "gate_panels",
|
|
37
|
+
select: accountGateRecords.gate_panels.readableFields,
|
|
38
|
+
where: { guild_id: context.guildId, panel_id: resourceId },
|
|
39
|
+
});
|
|
40
|
+
return row ? inspectedDetail(context, row) : null;
|
|
41
|
+
},
|
|
42
|
+
async preview(context, value) {
|
|
43
|
+
const accessType = await configuredAccessType(context);
|
|
44
|
+
return panelMessage(panelMessageFromValue(value, DEFAULT_PANEL_MESSAGES[accessType]), accessType, "preview");
|
|
45
|
+
},
|
|
46
|
+
async create(context, value) {
|
|
47
|
+
return post(context, value);
|
|
48
|
+
},
|
|
49
|
+
async update(context, input) {
|
|
50
|
+
const current = await currentPanel(context, input.resourceId);
|
|
51
|
+
assertRevision(current, input.expectedRevision);
|
|
52
|
+
const password = await panelPasswordForUpdate(context, current, input.value);
|
|
53
|
+
return writePanelMessage(context, current, panelMessageFromValue(input.value, savedPanelMessage(current)), panelAccessType(current), password, "updated");
|
|
54
|
+
},
|
|
55
|
+
async delete(context, input) {
|
|
56
|
+
const row = await currentPanelOrNull(context, input.resourceId);
|
|
57
|
+
if (!row)
|
|
58
|
+
return { deleted: false };
|
|
59
|
+
assertRevision(row, input.expectedRevision);
|
|
60
|
+
await context.deleteChannelMessage(stringValue(row.channel_id), stringValue(row.message_id));
|
|
61
|
+
const result = await records(context).deleteMany({
|
|
62
|
+
moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
|
|
63
|
+
collection: "gate_panels",
|
|
64
|
+
where: { guild_id: context.guildId, panel_id: input.resourceId },
|
|
65
|
+
limit: 1,
|
|
66
|
+
});
|
|
67
|
+
return { deleted: result.deleted === 1 };
|
|
68
|
+
},
|
|
69
|
+
async executeAction(context, input) {
|
|
70
|
+
if (input.actionId !== "repair" && input.actionId !== "repost") {
|
|
71
|
+
throw new DashboardActionValidationError("That Gate panel action is unavailable.");
|
|
72
|
+
}
|
|
73
|
+
const current = await currentPanel(context, input.resourceId);
|
|
74
|
+
assertRevision(current, input.expectedRevision);
|
|
75
|
+
if (input.actionId === "repost") {
|
|
76
|
+
return replacePanel(context, current, "reposted", savedPanelMessage(current), panelAccessType(current), panelPasswordFromRow(current));
|
|
77
|
+
}
|
|
78
|
+
return repairPanel(context, current);
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
async function repairPanel(context, current) {
|
|
82
|
+
return writePanelMessage(context, current, savedPanelMessage(current), panelAccessType(current), panelPasswordFromRow(current), "repaired");
|
|
83
|
+
}
|
|
84
|
+
async function writePanelMessage(context, current, messageBody, accessType, password, action) {
|
|
85
|
+
const inspection = await context.services
|
|
86
|
+
.get(HELYX_SERVICE_NAMES.discordResources)
|
|
87
|
+
.inspectMessage({
|
|
88
|
+
guildId: context.guildId,
|
|
89
|
+
channelId: stringValue(current.channel_id),
|
|
90
|
+
messageId: stringValue(current.message_id),
|
|
91
|
+
});
|
|
92
|
+
if (!inspection.exists ||
|
|
93
|
+
!inspection.guildMatches ||
|
|
94
|
+
!inspection.channelMatches) {
|
|
95
|
+
return replacePanel(context, current, action, messageBody, accessType, password);
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
await context.editChannelMessage(stringValue(current.channel_id), stringValue(current.message_id), panelMessage(messageBody, accessType, stringValue(current.panel_id)));
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return replacePanel(context, current, action, messageBody, accessType, password);
|
|
102
|
+
}
|
|
103
|
+
return persistPanelRevision(context, current, {
|
|
104
|
+
channelId: stringValue(current.channel_id),
|
|
105
|
+
messageId: stringValue(current.message_id),
|
|
106
|
+
messageBody,
|
|
107
|
+
accessType,
|
|
108
|
+
password,
|
|
109
|
+
action,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
async function replacePanel(context, current, action, messageBody, accessType, password) {
|
|
113
|
+
const configured = await context.services
|
|
114
|
+
.get(HELYX_SERVICE_NAMES.configuration)
|
|
115
|
+
.get(context.guildId, ACCOUNT_GATE_IDENTITY.moduleId);
|
|
116
|
+
if (!configured) {
|
|
117
|
+
throw new DashboardActionValidationError("Save Access Control settings before posting the panel.");
|
|
118
|
+
}
|
|
119
|
+
const configuration = parseAccountGateConfiguration(configured.value);
|
|
120
|
+
const sent = await context.sendToChannel(configuration.gateChannelId, panelMessage(messageBody, accessType, stringValue(current.panel_id)));
|
|
121
|
+
try {
|
|
122
|
+
const detail = await persistPanelRevision(context, current, {
|
|
123
|
+
channelId: configuration.gateChannelId,
|
|
124
|
+
messageId: sent.messageId,
|
|
125
|
+
messageBody,
|
|
126
|
+
accessType,
|
|
127
|
+
password,
|
|
128
|
+
action,
|
|
129
|
+
});
|
|
130
|
+
try {
|
|
131
|
+
await context.deleteChannelMessage(stringValue(current.channel_id), stringValue(current.message_id));
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
// The replacement is authoritative even when the old message is gone.
|
|
135
|
+
}
|
|
136
|
+
return detail;
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
await context
|
|
140
|
+
.deleteChannelMessage(configuration.gateChannelId, sent.messageId)
|
|
141
|
+
.catch(() => undefined);
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
async function persistPanelRevision(context, current, input) {
|
|
146
|
+
const revision = numberValue(current.revision) + 1;
|
|
147
|
+
const updatedAt = new Date();
|
|
148
|
+
const result = await records(context).updateOne({
|
|
149
|
+
moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
|
|
150
|
+
collection: "gate_panels",
|
|
151
|
+
where: {
|
|
152
|
+
guild_id: context.guildId,
|
|
153
|
+
panel_id: stringValue(current.panel_id),
|
|
154
|
+
},
|
|
155
|
+
expected: { field: "revision", value: numberValue(current.revision) },
|
|
156
|
+
values: {
|
|
157
|
+
channel_id: input.channelId,
|
|
158
|
+
message_id: input.messageId,
|
|
159
|
+
message_body: input.messageBody,
|
|
160
|
+
access_type: input.accessType,
|
|
161
|
+
...(input.password
|
|
162
|
+
? {
|
|
163
|
+
password_hash: input.password.encodedHash,
|
|
164
|
+
password_ignore_case: input.password.ignoreCase,
|
|
165
|
+
password_trim_whitespace: input.password.trimWhitespace,
|
|
166
|
+
}
|
|
167
|
+
: {}),
|
|
168
|
+
revision,
|
|
169
|
+
updated_at: updatedAt,
|
|
170
|
+
},
|
|
171
|
+
audit: {
|
|
172
|
+
guildId: context.guildId,
|
|
173
|
+
actorUserId: context.actor.userId,
|
|
174
|
+
action: `account_gate.panel_${input.action}`,
|
|
175
|
+
source: "dashboard",
|
|
176
|
+
correlationId: context.correlationId,
|
|
177
|
+
idempotencyKey: context.correlationId,
|
|
178
|
+
targetType: "account_gate_panel",
|
|
179
|
+
targetId: stringValue(current.panel_id),
|
|
180
|
+
metadata: { revision },
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
if (!result.updated) {
|
|
184
|
+
throw new DashboardActionValidationError("The Gate panel changed in another session. Reload and try again.");
|
|
185
|
+
}
|
|
186
|
+
return detail({
|
|
187
|
+
...current,
|
|
188
|
+
channel_id: input.channelId,
|
|
189
|
+
message_id: input.messageId,
|
|
190
|
+
message_body: input.messageBody,
|
|
191
|
+
access_type: input.accessType,
|
|
192
|
+
password_hash: input.password?.encodedHash ?? null,
|
|
193
|
+
password_ignore_case: input.password?.ignoreCase ?? true,
|
|
194
|
+
password_trim_whitespace: input.password?.trimWhitespace ?? true,
|
|
195
|
+
revision,
|
|
196
|
+
updated_at: updatedAt,
|
|
197
|
+
}, "posted");
|
|
198
|
+
}
|
|
199
|
+
async function currentPanel(context, resourceId) {
|
|
200
|
+
const current = await currentPanelOrNull(context, resourceId);
|
|
201
|
+
if (!current) {
|
|
202
|
+
throw new DashboardActionValidationError("The Access Control panel was not found.");
|
|
203
|
+
}
|
|
204
|
+
return current;
|
|
205
|
+
}
|
|
206
|
+
async function currentPanelOrNull(context, resourceId) {
|
|
207
|
+
return records(context).findOne({
|
|
208
|
+
moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
|
|
209
|
+
collection: "gate_panels",
|
|
210
|
+
select: accountGateRecords.gate_panels.readableFields,
|
|
211
|
+
where: { guild_id: context.guildId, panel_id: resourceId },
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
function assertRevision(current, expectedRevision) {
|
|
215
|
+
if (numberValue(current.revision) !== expectedRevision) {
|
|
216
|
+
throw new DashboardActionValidationError("The Gate panel changed in another session. Reload and try again.");
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
async function post(context, value = {}) {
|
|
220
|
+
const configured = await context.services
|
|
221
|
+
.get(HELYX_SERVICE_NAMES.configuration)
|
|
222
|
+
.get(context.guildId, ACCOUNT_GATE_IDENTITY.moduleId);
|
|
223
|
+
if (!configured)
|
|
224
|
+
throw new DashboardActionValidationError("Save Access Control settings before posting the panel.");
|
|
225
|
+
const configuration = parseAccountGateConfiguration(configured.value);
|
|
226
|
+
const store = records(context);
|
|
227
|
+
const existing = await currentPanelOrNull(context, context.correlationId);
|
|
228
|
+
if (existing)
|
|
229
|
+
return inspectedDetail(context, existing);
|
|
230
|
+
const panelCount = await store.count({
|
|
231
|
+
moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
|
|
232
|
+
collection: "gate_panels",
|
|
233
|
+
where: { guild_id: context.guildId },
|
|
234
|
+
});
|
|
235
|
+
if (panelCount >= MAX_GATE_PANELS) {
|
|
236
|
+
throw panelLimitError();
|
|
237
|
+
}
|
|
238
|
+
const accessType = configuration.accessType;
|
|
239
|
+
const messageBody = panelMessageFromValue(value, DEFAULT_PANEL_MESSAGES[accessType]);
|
|
240
|
+
const password = await panelPasswordForCreate(configuration, accessType, value);
|
|
241
|
+
const panelId = context.correlationId;
|
|
242
|
+
const sent = await context.sendToChannel(configuration.gateChannelId, panelMessage(messageBody, accessType, panelId));
|
|
243
|
+
const now = new Date();
|
|
244
|
+
const row = {
|
|
245
|
+
panel_id: panelId,
|
|
246
|
+
guild_id: context.guildId,
|
|
247
|
+
channel_id: configuration.gateChannelId,
|
|
248
|
+
message_id: sent.messageId,
|
|
249
|
+
message_body: messageBody,
|
|
250
|
+
access_type: accessType,
|
|
251
|
+
...(password
|
|
252
|
+
? {
|
|
253
|
+
password_hash: password.encodedHash,
|
|
254
|
+
password_ignore_case: password.ignoreCase,
|
|
255
|
+
password_trim_whitespace: password.trimWhitespace,
|
|
256
|
+
}
|
|
257
|
+
: {}),
|
|
258
|
+
revision: 1,
|
|
259
|
+
created_at: now,
|
|
260
|
+
updated_at: now,
|
|
261
|
+
};
|
|
262
|
+
try {
|
|
263
|
+
const created = await store.appendUnique({
|
|
264
|
+
moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
|
|
265
|
+
collection: "gate_panels",
|
|
266
|
+
uniqueBy: ["panel_id"],
|
|
267
|
+
values: row,
|
|
268
|
+
});
|
|
269
|
+
if (!created.created) {
|
|
270
|
+
await context
|
|
271
|
+
.deleteChannelMessage(configuration.gateChannelId, sent.messageId)
|
|
272
|
+
.catch(() => undefined);
|
|
273
|
+
const concurrent = await currentPanelOrNull(context, context.correlationId);
|
|
274
|
+
if (concurrent)
|
|
275
|
+
return inspectedDetail(context, concurrent);
|
|
276
|
+
throw new DashboardActionValidationError("That gate-panel request was already handled. Reload the resources list.");
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
catch (error) {
|
|
280
|
+
await context
|
|
281
|
+
.deleteChannelMessage(configuration.gateChannelId, sent.messageId)
|
|
282
|
+
.catch(() => undefined);
|
|
283
|
+
const concurrent = await currentPanelOrNull(context, context.correlationId);
|
|
284
|
+
if (concurrent)
|
|
285
|
+
return inspectedDetail(context, concurrent);
|
|
286
|
+
if ((await store.count({
|
|
287
|
+
moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
|
|
288
|
+
collection: "gate_panels",
|
|
289
|
+
where: { guild_id: context.guildId },
|
|
290
|
+
})) >= MAX_GATE_PANELS) {
|
|
291
|
+
throw panelLimitError();
|
|
292
|
+
}
|
|
293
|
+
throw error;
|
|
294
|
+
}
|
|
295
|
+
return detail({
|
|
296
|
+
...row,
|
|
297
|
+
guild_id: context.guildId,
|
|
298
|
+
channel_id: configuration.gateChannelId,
|
|
299
|
+
message_id: sent.messageId,
|
|
300
|
+
message_body: messageBody,
|
|
301
|
+
}, "posted");
|
|
302
|
+
}
|
|
303
|
+
function panelMessage(messageBody, accessType, panelId) {
|
|
304
|
+
if (accessType === "emoji") {
|
|
305
|
+
return {
|
|
306
|
+
allowedUserMentionIds: [],
|
|
307
|
+
componentsV2: {
|
|
308
|
+
text: [
|
|
309
|
+
{
|
|
310
|
+
content: messageBody,
|
|
311
|
+
markdown: false,
|
|
312
|
+
},
|
|
313
|
+
],
|
|
314
|
+
},
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
return {
|
|
318
|
+
allowedUserMentionIds: [],
|
|
319
|
+
componentsV2: {
|
|
320
|
+
...(accessType === "challenges" ? { accentColor: VIOLET } : {}),
|
|
321
|
+
text: [
|
|
322
|
+
...(accessType === "challenges"
|
|
323
|
+
? [{ content: "## Complete Access Control" }]
|
|
324
|
+
: []),
|
|
325
|
+
{
|
|
326
|
+
content: messageBody,
|
|
327
|
+
markdown: false,
|
|
328
|
+
},
|
|
329
|
+
],
|
|
330
|
+
},
|
|
331
|
+
buttons: [
|
|
332
|
+
{
|
|
333
|
+
type: "button",
|
|
334
|
+
customId: `${ACCOUNT_GATE_COMPONENTS.startPrefix}${panelId}`,
|
|
335
|
+
label: "Start check",
|
|
336
|
+
style: "primary",
|
|
337
|
+
},
|
|
338
|
+
],
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
async function configuredAccessType(context) {
|
|
342
|
+
const configured = await context.services
|
|
343
|
+
.get(HELYX_SERVICE_NAMES.configuration)
|
|
344
|
+
.get(context.guildId, ACCOUNT_GATE_IDENTITY.moduleId);
|
|
345
|
+
if (!configured)
|
|
346
|
+
return "challenges";
|
|
347
|
+
return parseAccountGateConfiguration(configured.value).accessType;
|
|
348
|
+
}
|
|
349
|
+
async function inspectedDetail(context, row) {
|
|
350
|
+
const inspected = await context.services
|
|
351
|
+
.get(HELYX_SERVICE_NAMES.discordResources)
|
|
352
|
+
.inspectMessage({
|
|
353
|
+
guildId: context.guildId,
|
|
354
|
+
channelId: stringValue(row.channel_id),
|
|
355
|
+
messageId: stringValue(row.message_id),
|
|
356
|
+
});
|
|
357
|
+
return detail(row, inspected.exists && inspected.guildMatches && inspected.channelMatches
|
|
358
|
+
? "posted"
|
|
359
|
+
: "missing");
|
|
360
|
+
}
|
|
361
|
+
function detail(row, status) {
|
|
362
|
+
const updatedAt = row.updated_at instanceof Date ? row.updated_at : new Date(0);
|
|
363
|
+
const accessType = panelAccessType(row);
|
|
364
|
+
const panelTitles = {
|
|
365
|
+
challenges: "Challenge gate panel",
|
|
366
|
+
password: "Password Entry instructions",
|
|
367
|
+
emoji: "Emoji Entry instructions",
|
|
368
|
+
};
|
|
369
|
+
return {
|
|
370
|
+
id: stringValue(row.panel_id),
|
|
371
|
+
revision: numberValue(row.revision),
|
|
372
|
+
status,
|
|
373
|
+
title: panelTitles[accessType],
|
|
374
|
+
description: status === "posted"
|
|
375
|
+
? "Published and currently available."
|
|
376
|
+
: "The saved panel message is missing. Repost it to repair the panel.",
|
|
377
|
+
attributes: {
|
|
378
|
+
channelId: stringValue(row.channel_id),
|
|
379
|
+
messageId: stringValue(row.message_id),
|
|
380
|
+
accessType,
|
|
381
|
+
passwordConfigured: accessType === "password" && panelPasswordFromRow(row) !== null,
|
|
382
|
+
},
|
|
383
|
+
value: {
|
|
384
|
+
channelId: stringValue(row.channel_id),
|
|
385
|
+
messageId: stringValue(row.message_id),
|
|
386
|
+
accessType,
|
|
387
|
+
messageBody: savedPanelMessage(row),
|
|
388
|
+
replacementPhrase: "",
|
|
389
|
+
},
|
|
390
|
+
updatedAt: updatedAt.toISOString(),
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
function panelAccessType(row) {
|
|
394
|
+
const value = row.access_type;
|
|
395
|
+
if (value === "password" || value === "emoji")
|
|
396
|
+
return value;
|
|
397
|
+
return "challenges";
|
|
398
|
+
}
|
|
399
|
+
function panelMessageFromValue(value, fallback) {
|
|
400
|
+
const candidate = value.messageBody;
|
|
401
|
+
if (candidate === undefined)
|
|
402
|
+
return fallback;
|
|
403
|
+
if (typeof candidate !== "string") {
|
|
404
|
+
throw new DashboardActionValidationError("Enter a valid Access Control panel message.");
|
|
405
|
+
}
|
|
406
|
+
const normalized = candidate.trim();
|
|
407
|
+
if (normalized.length < 1 || normalized.length > 1000) {
|
|
408
|
+
throw new DashboardActionValidationError("The Access Control panel message must be between 1 and 1000 characters.");
|
|
409
|
+
}
|
|
410
|
+
return normalized;
|
|
411
|
+
}
|
|
412
|
+
function savedPanelMessage(row) {
|
|
413
|
+
const message = row.message_body;
|
|
414
|
+
return typeof message === "string" && message.length > 0
|
|
415
|
+
? message
|
|
416
|
+
: DEFAULT_PANEL_MESSAGES[panelAccessType(row)];
|
|
417
|
+
}
|
|
418
|
+
export async function getGatePanelRuntime(store, guildId, panelId) {
|
|
419
|
+
const row = await store.findOne({
|
|
420
|
+
moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
|
|
421
|
+
collection: "gate_panels",
|
|
422
|
+
select: accountGateRecords.gate_panels.readableFields,
|
|
423
|
+
where: { guild_id: guildId, panel_id: panelId },
|
|
424
|
+
});
|
|
425
|
+
if (!row)
|
|
426
|
+
return null;
|
|
427
|
+
return {
|
|
428
|
+
accessType: panelAccessType(row),
|
|
429
|
+
password: panelPasswordFromRow(row),
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
export async function hasPasswordGatePanel(store, guildId) {
|
|
433
|
+
const page = await store.findMany({
|
|
434
|
+
moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
|
|
435
|
+
collection: "gate_panels",
|
|
436
|
+
select: accountGateRecords.gate_panels.readableFields,
|
|
437
|
+
where: { guild_id: guildId, access_type: "password" },
|
|
438
|
+
orderBy: [
|
|
439
|
+
{ field: "guild_id", direction: "asc" },
|
|
440
|
+
{ field: "created_at", direction: "asc" },
|
|
441
|
+
{ field: "panel_id", direction: "asc" },
|
|
442
|
+
],
|
|
443
|
+
limit: MAX_GATE_PANELS,
|
|
444
|
+
});
|
|
445
|
+
return page.records.some((row) => panelPasswordFromRow(row) !== null);
|
|
446
|
+
}
|
|
447
|
+
async function panelPasswordForCreate(configuration, accessType, value) {
|
|
448
|
+
if (accessType !== "password")
|
|
449
|
+
return null;
|
|
450
|
+
const phrase = replacementPhrase(value, true);
|
|
451
|
+
return {
|
|
452
|
+
encodedHash: await hashPasswordEntryPhrase(phrase, {
|
|
453
|
+
ignoreCase: configuration.passwordIgnoreCase,
|
|
454
|
+
trimWhitespace: configuration.passwordTrimWhitespace,
|
|
455
|
+
}),
|
|
456
|
+
ignoreCase: configuration.passwordIgnoreCase,
|
|
457
|
+
trimWhitespace: configuration.passwordTrimWhitespace,
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
async function panelPasswordForUpdate(context, current, value) {
|
|
461
|
+
if (panelAccessType(current) !== "password")
|
|
462
|
+
return null;
|
|
463
|
+
const existing = panelPasswordFromRow(current);
|
|
464
|
+
const phrase = replacementPhrase(value, false);
|
|
465
|
+
if (!phrase) {
|
|
466
|
+
if (existing)
|
|
467
|
+
return existing;
|
|
468
|
+
throw new DashboardActionValidationError("Enter a password containing 4 to 200 characters before publishing this panel.");
|
|
469
|
+
}
|
|
470
|
+
const configured = await context.services
|
|
471
|
+
.get(HELYX_SERVICE_NAMES.configuration)
|
|
472
|
+
.get(context.guildId, ACCOUNT_GATE_IDENTITY.moduleId);
|
|
473
|
+
if (!configured) {
|
|
474
|
+
throw new DashboardActionValidationError("Save Access Control settings before changing this password.");
|
|
475
|
+
}
|
|
476
|
+
const configuration = parseAccountGateConfiguration(configured.value);
|
|
477
|
+
return {
|
|
478
|
+
encodedHash: await hashPasswordEntryPhrase(phrase, {
|
|
479
|
+
ignoreCase: configuration.passwordIgnoreCase,
|
|
480
|
+
trimWhitespace: configuration.passwordTrimWhitespace,
|
|
481
|
+
}),
|
|
482
|
+
ignoreCase: configuration.passwordIgnoreCase,
|
|
483
|
+
trimWhitespace: configuration.passwordTrimWhitespace,
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
function panelPasswordFromRow(row) {
|
|
487
|
+
if (typeof row.password_hash !== "string" || row.password_hash.length < 1) {
|
|
488
|
+
return null;
|
|
489
|
+
}
|
|
490
|
+
return {
|
|
491
|
+
encodedHash: row.password_hash,
|
|
492
|
+
ignoreCase: typeof row.password_ignore_case === "boolean"
|
|
493
|
+
? row.password_ignore_case
|
|
494
|
+
: true,
|
|
495
|
+
trimWhitespace: typeof row.password_trim_whitespace === "boolean"
|
|
496
|
+
? row.password_trim_whitespace
|
|
497
|
+
: true,
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
function replacementPhrase(value, required) {
|
|
501
|
+
const candidate = value.replacementPhrase;
|
|
502
|
+
if ((candidate === undefined || candidate === "") && !required)
|
|
503
|
+
return "";
|
|
504
|
+
if (typeof candidate !== "string" ||
|
|
505
|
+
candidate.length < 4 ||
|
|
506
|
+
candidate.length > 200) {
|
|
507
|
+
throw new DashboardActionValidationError("Enter a password containing 4 to 200 characters.");
|
|
508
|
+
}
|
|
509
|
+
return candidate;
|
|
510
|
+
}
|
|
511
|
+
function panelLimitError() {
|
|
512
|
+
return new DashboardActionValidationError("You have reached your limit of 10 gate panels. Delete an unused gate before publishing another.");
|
|
513
|
+
}
|
|
514
|
+
function records(context) {
|
|
515
|
+
return context.services.get(HELYX_SERVICE_NAMES.records);
|
|
516
|
+
}
|
|
517
|
+
function stringValue(value) {
|
|
518
|
+
if (typeof value !== "string")
|
|
519
|
+
throw new Error("Access Control panel record is invalid");
|
|
520
|
+
return value;
|
|
521
|
+
}
|
|
522
|
+
function numberValue(value) {
|
|
523
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value))
|
|
524
|
+
throw new Error("Access Control panel record is invalid");
|
|
525
|
+
return value;
|
|
526
|
+
}
|
|
527
|
+
//# sourceMappingURL=panel-resource.js.map
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export interface PasswordComparisonRules {
|
|
2
|
+
ignoreCase: boolean;
|
|
3
|
+
trimWhitespace: boolean;
|
|
4
|
+
}
|
|
5
|
+
export declare function hashPasswordEntryPhrase(phrase: string, rules: PasswordComparisonRules): Promise<string>;
|
|
6
|
+
export declare function verifyPasswordEntry(input: string, encodedHash: string, rules: PasswordComparisonRules): Promise<boolean>;
|
|
7
|
+
export declare function normalizePasswordEntry(phrase: string, rules: PasswordComparisonRules): string;
|
|
8
|
+
//# sourceMappingURL=password-entry.d.ts.map
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { randomBytes, scrypt, timingSafeEqual } from "node:crypto";
|
|
2
|
+
const KEY_LENGTH = 32;
|
|
3
|
+
const MAXIMUM_PHRASE_LENGTH = 200;
|
|
4
|
+
export async function hashPasswordEntryPhrase(phrase, rules) {
|
|
5
|
+
const normalized = normalizePasswordEntry(phrase, rules);
|
|
6
|
+
validatePhrase(normalized);
|
|
7
|
+
const salt = randomBytes(16);
|
|
8
|
+
const key = await deriveKey(normalized, salt);
|
|
9
|
+
return `scrypt$16384$8$1$${salt.toString("base64url")}$${key.toString("base64url")}`;
|
|
10
|
+
}
|
|
11
|
+
export async function verifyPasswordEntry(input, encodedHash, rules) {
|
|
12
|
+
const parts = encodedHash.split("$");
|
|
13
|
+
if (parts.length !== 6 ||
|
|
14
|
+
parts[0] !== "scrypt" ||
|
|
15
|
+
parts[1] !== "16384" ||
|
|
16
|
+
parts[2] !== "8" ||
|
|
17
|
+
parts[3] !== "1") {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
const normalized = normalizePasswordEntry(input, rules);
|
|
21
|
+
if (normalized.length < 1 || normalized.length > MAXIMUM_PHRASE_LENGTH) {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
const salt = Buffer.from(parts[4] ?? "", "base64url");
|
|
26
|
+
const expected = Buffer.from(parts[5] ?? "", "base64url");
|
|
27
|
+
if (salt.length !== 16 || expected.length !== KEY_LENGTH)
|
|
28
|
+
return false;
|
|
29
|
+
const actual = await deriveKey(normalized, salt);
|
|
30
|
+
return timingSafeEqual(actual, expected);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export function normalizePasswordEntry(phrase, rules) {
|
|
37
|
+
let normalized = phrase.normalize("NFKC");
|
|
38
|
+
if (rules.trimWhitespace)
|
|
39
|
+
normalized = normalized.trim();
|
|
40
|
+
if (rules.ignoreCase)
|
|
41
|
+
normalized = normalized.toLocaleLowerCase("en-GB");
|
|
42
|
+
return normalized;
|
|
43
|
+
}
|
|
44
|
+
function validatePhrase(phrase) {
|
|
45
|
+
if (phrase.length < 4 || phrase.length > MAXIMUM_PHRASE_LENGTH) {
|
|
46
|
+
throw new Error("Shared phrase must contain 4 to 200 characters");
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function deriveKey(input, salt) {
|
|
50
|
+
return new Promise((resolve, reject) => {
|
|
51
|
+
scrypt(input, salt, KEY_LENGTH, { N: 16_384, r: 8, p: 1 }, (error, derivedKey) => {
|
|
52
|
+
if (error)
|
|
53
|
+
reject(error);
|
|
54
|
+
else
|
|
55
|
+
resolve(derivedKey);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
//# sourceMappingURL=password-entry.js.map
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ModuleManagedResource, ModuleRecordService } from "@helyx/sdk";
|
|
2
|
+
export declare const passwordEntryPhraseResource: ModuleManagedResource;
|
|
3
|
+
export declare function getPasswordSecret(records: ModuleRecordService, guildId: string): Promise<{
|
|
4
|
+
encodedHash: string;
|
|
5
|
+
ignoreCase: boolean;
|
|
6
|
+
trimWhitespace: boolean;
|
|
7
|
+
} | null>;
|
|
8
|
+
//# sourceMappingURL=password-resource.d.ts.map
|