@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
@@ -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