@helyx/module-account-gate 1.0.15 → 1.1.3

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 (69) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +55 -90
  3. package/dist/challenge-interactions.d.ts +12 -0
  4. package/dist/challenge-interactions.js +238 -0
  5. package/dist/commands.js +3 -2
  6. package/dist/contracts.d.ts +1 -1
  7. package/dist/contracts.js +1 -1
  8. package/dist/expiry-workflows.d.ts +32 -0
  9. package/dist/expiry-workflows.js +281 -0
  10. package/dist/gateway-interactions.d.ts +5 -0
  11. package/dist/gateway-interactions.js +124 -0
  12. package/dist/health.d.ts +10 -0
  13. package/dist/health.js +55 -0
  14. package/dist/index.d.ts +8 -19
  15. package/dist/index.js +35 -3157
  16. package/dist/member-lifecycle.d.ts +21 -0
  17. package/dist/member-lifecycle.js +305 -0
  18. package/dist/member-resource.js +14 -74
  19. package/dist/member-restart.d.ts +4 -0
  20. package/dist/member-restart.js +76 -0
  21. package/dist/member-revocation.d.ts +21 -0
  22. package/dist/member-revocation.js +127 -0
  23. package/dist/member-start.d.ts +5 -0
  24. package/dist/member-start.js +199 -0
  25. package/dist/member-summary.d.ts +47 -0
  26. package/dist/member-summary.js +200 -0
  27. package/dist/panel-maintenance.d.ts +26 -0
  28. package/dist/panel-maintenance.js +143 -0
  29. package/dist/panel-model.d.ts +47 -0
  30. package/dist/panel-model.js +128 -0
  31. package/dist/panel-mutations.d.ts +24 -0
  32. package/dist/panel-mutations.js +89 -0
  33. package/dist/panel-presentation.d.ts +8 -0
  34. package/dist/panel-presentation.js +67 -0
  35. package/dist/panel-resource.d.ts +2 -13
  36. package/dist/panel-resource.js +122 -310
  37. package/dist/panel-runtime.d.ts +8 -0
  38. package/dist/panel-runtime.js +34 -0
  39. package/dist/records.d.ts +42 -2
  40. package/dist/records.js +31 -0
  41. package/dist/review-decisions.d.ts +3 -0
  42. package/dist/review-decisions.js +274 -0
  43. package/dist/review-delivery.d.ts +34 -0
  44. package/dist/review-delivery.js +252 -0
  45. package/dist/review-presentation.d.ts +3 -0
  46. package/dist/review-presentation.js +28 -0
  47. package/dist/review-store.d.ts +4 -0
  48. package/dist/review-store.js +94 -0
  49. package/dist/role-recovery.d.ts +23 -0
  50. package/dist/role-recovery.js +176 -0
  51. package/dist/runtime-dependencies.d.ts +32 -0
  52. package/dist/runtime-dependencies.js +27 -0
  53. package/dist/runtime-support.d.ts +19 -0
  54. package/dist/runtime-support.js +74 -0
  55. package/dist/setup-presentation.d.ts +15 -0
  56. package/dist/setup-presentation.js +378 -0
  57. package/dist/strict-mode.d.ts +17 -0
  58. package/dist/strict-mode.js +154 -0
  59. package/dist/task-contributions.d.ts +4 -0
  60. package/dist/task-contributions.js +113 -0
  61. package/dist/task-scheduling.d.ts +12 -0
  62. package/dist/task-scheduling.js +79 -0
  63. package/dist/terminal-finalization.d.ts +12 -0
  64. package/dist/terminal-finalization.js +238 -0
  65. package/dist/workflow-fences.d.ts +6 -0
  66. package/dist/workflow-fences.js +73 -0
  67. package/manifest.json +1 -1
  68. package/migrations/0002_panel_mutation_claims.sql +34 -0
  69. package/package.json +5 -4
package/dist/index.js CHANGED
@@ -1,33 +1,28 @@
1
- import { randomUUID } from "node:crypto";
2
1
  import { readFileSync } from "node:fs";
3
2
  import { parseModuleManifest } from "@helyx/module-manifest";
4
- import { createPrivacyProvider, DashboardActionValidationError, defineModule, getModulePrivacyDeclaration, HELYX_SERVICE_NAMES, ModuleStateValidationError, } from "@helyx/sdk";
3
+ import { createPrivacyProvider, defineModule, getModulePrivacyDeclaration, HELYX_SERVICE_NAMES, ModuleStateValidationError, } from "@helyx/sdk";
5
4
  import { generateChallengeChain, validateServerQuestion, } from "./challenges.js";
6
- import { ACCOUNT_GATE_COMPONENTS, ACCOUNT_GATE_IDENTITY, ACCOUNT_GATE_SESSION_PURPOSE, } from "./contracts.js";
7
- import { generatedChallengePool, isStrictModeActive, parseAccountGateConfiguration, validateRuntimeCapabilities, } from "./configuration.js";
8
- import { applyRoleTransition, neutralKickDirectMessage, } from "./enforcement.js";
9
- import { verifyPasswordEntry } from "./password-entry.js";
5
+ import { openRecallModal, processBoundOption, processBoundSequence, rerollChallenge, submitRecallModal, } from "./challenge-interactions.js";
6
+ import { handleEmojiEntry, handlePasswordEntry, } from "./gateway-interactions.js";
7
+ import { accountGateCommands } from "./commands.js";
8
+ import { parseAccountGateConfiguration, validateRuntimeCapabilities, } from "./configuration.js";
9
+ import { ACCOUNT_GATE_COMPONENTS, ACCOUNT_GATE_IDENTITY } from "./contracts.js";
10
+ import { AccountGateHealth } from "./health.js";
11
+ import { accountGateLogging } from "./logging.js";
10
12
  import { accountGateMemberResource } from "./member-resource.js";
11
- import { accountGateRecords } from "./records.js";
13
+ import { handleGuildMemberAdd, handleGuildMemberRemove, resetOwnAccess, } from "./member-lifecycle.js";
14
+ import { startCheck } from "./member-start.js";
15
+ import { gatePanelResource } from "./panel-resource.js";
12
16
  import { serverQuestionResource } from "./question-resource.js";
13
- import { gatePanelResource, getGatePanelRuntime, hasPasswordGatePanel, } from "./panel-resource.js";
14
- import { isAccountGateWorkSuppressed } from "./privacy.js";
15
- import { applyChallengeAction, createAccountGateSessionState, parseAccountGateSessionState, rerollForAccessibility, } from "./workflow.js";
16
- import { accountGateCommands } from "./commands.js";
17
- import { accountGateLogging, emitAccountGateCompletedLog, emitAccountGateKickLog, emitLog, successfulKickOutcome, } from "./logging.js";
17
+ import { accountGateRecords } from "./records.js";
18
+ import { handleReviewDecision } from "./review-decisions.js";
19
+ import { getConfiguration, requiredRuntimeConfiguration, validateAccountGateSetup, } from "./setup-presentation.js";
20
+ import { activateStrictMode, activateStrictModeCommand, showStatus, } from "./strict-mode.js";
21
+ import { createAccountGateTasks } from "./task-contributions.js";
22
+ import { ensureTerminalFinalization } from "./terminal-finalization.js";
23
+ import { applyChallengeAction, createAccountGateSessionState, } from "./workflow.js";
18
24
  const manifest = parseModuleManifest(JSON.parse(readFileSync(new URL("../manifest.json", import.meta.url), "utf8")));
19
- const VIOLET = 0x7c3aed;
20
- const GREEN = 0x16a34a;
21
- const RED = 0xdc2626;
22
- const STRICT_EXPIRY_TASK = "account-gate.strict-expiry";
23
- const ROLE_RETRY_TASK = "account-gate.role-transition-retry";
24
- const JOIN_DEADLINE_TASK = "account-gate.join-deadline";
25
- const SESSION_EXPIRY_TASK = "account-gate.session-expiry";
26
- const REVIEW_DELIVERY_TASK = "account-gate.review-delivery";
27
- const REVIEW_CLAIM_RECOVERY_TASK = "account-gate.review-claim-recovery";
28
- const TERMINAL_FINALIZATION_TASK = "account-gate.terminal-finalization";
29
- const LEAVE_RETENTION_TASK = "account-gate.leave-retention";
30
- const RETENTION_MS = 90 * 24 * 60 * 60 * 1_000;
25
+ const health = new AccountGateHealth();
31
26
  const managerPolicy = {
32
27
  everyone: false,
33
28
  requiredPermissions: ["MANAGE_ROLES"],
@@ -55,14 +50,14 @@ export const accountGateModule = defineModule({
55
50
  if (errors.length > 0)
56
51
  throw new ModuleStateValidationError(errors[0]);
57
52
  },
58
- configuration: {
59
- schemaVersion: 1,
60
- validate: parseAccountGateConfiguration,
61
- },
53
+ configuration: { schemaVersion: 1, validate: parseAccountGateConfiguration },
62
54
  defaultPermissions: {
63
55
  module: everyonePolicy,
64
56
  commands: {
57
+ "account-gate.start": everyonePolicy,
65
58
  "account-gate.reset": managerPolicy,
59
+ "account-gate.status": managerPolicy,
60
+ "account-gate.strict": managerPolicy,
66
61
  "account-gate.manage": managerPolicy,
67
62
  "account-gate.review": managerPolicy,
68
63
  },
@@ -159,22 +154,13 @@ export const accountGateModule = defineModule({
159
154
  },
160
155
  ],
161
156
  messageReactionAdd: [
162
- {
163
- type: "discord.message-reaction-add",
164
- execute: handleEmojiEntry,
165
- },
157
+ { type: "discord.message-reaction-add", execute: handleEmojiEntry },
166
158
  ],
167
159
  guildMemberAdd: [
168
- {
169
- type: "discord.guild-member-add",
170
- execute: handleGuildMemberAdd,
171
- },
160
+ { type: "discord.guild-member-add", execute: handleGuildMemberAdd },
172
161
  ],
173
162
  guildMemberRemove: [
174
- {
175
- type: "discord.guild-member-remove",
176
- execute: handleGuildMemberRemove,
177
- },
163
+ { type: "discord.guild-member-remove", execute: handleGuildMemberRemove },
178
164
  ],
179
165
  },
180
166
  dashboardActions: [
@@ -190,3132 +176,24 @@ export const accountGateModule = defineModule({
190
176
  },
191
177
  },
192
178
  ],
193
- scheduledTasks: [
194
- {
195
- kind: STRICT_EXPIRY_TASK,
196
- version: 1,
197
- delivery: "one-shot",
198
- attemptLimit: 5,
199
- validatePayload: validateStrictTaskPayload,
200
- execute: expireStrictMode,
201
- },
202
- {
203
- kind: ROLE_RETRY_TASK,
204
- version: 1,
205
- delivery: "one-shot",
206
- attemptLimit: 5,
207
- validatePayload: validateRoleRetryPayload,
208
- execute: retryRoleTransition,
209
- },
210
- {
211
- kind: JOIN_DEADLINE_TASK,
212
- version: 1,
213
- delivery: "one-shot",
214
- attemptLimit: 5,
215
- validatePayload: validateJoinDeadlinePayload,
216
- execute: enforceJoinDeadline,
217
- },
218
- {
219
- kind: SESSION_EXPIRY_TASK,
220
- version: 1,
221
- delivery: "one-shot",
222
- attemptLimit: 5,
223
- validatePayload: validateSessionExpiryPayload,
224
- execute: expireSession,
225
- },
226
- {
227
- kind: REVIEW_DELIVERY_TASK,
228
- version: 1,
229
- delivery: "one-shot",
230
- attemptLimit: 8,
231
- validatePayload: validateReviewDeliveryPayload,
232
- execute: deliverReview,
233
- },
234
- {
235
- kind: REVIEW_CLAIM_RECOVERY_TASK,
236
- version: 1,
237
- delivery: "one-shot",
238
- attemptLimit: 5,
239
- validatePayload: validateReviewClaimRecoveryPayload,
240
- execute: recoverReviewClaim,
241
- },
242
- {
243
- kind: TERMINAL_FINALIZATION_TASK,
244
- version: 1,
245
- delivery: "one-shot",
246
- attemptLimit: 10,
247
- validatePayload: validateTerminalFinalizationPayload,
248
- execute: runTerminalFinalizationTask,
249
- },
250
- {
251
- kind: LEAVE_RETENTION_TASK,
252
- version: 1,
253
- delivery: "one-shot",
254
- attemptLimit: 5,
255
- validatePayload: validateLeaveRetentionPayload,
256
- execute: runLeaveRetention,
257
- },
258
- ],
179
+ scheduledTasks: createAccountGateTasks(health),
259
180
  start(context) {
181
+ health.start(context);
260
182
  context.logger.info({ event: "account_gate.started" }, "Access Control module started");
261
183
  return Promise.resolve();
262
184
  },
263
185
  stop(context) {
186
+ health.stop();
264
187
  context.logger.info({ event: "account_gate.stopped" }, "Access Control module stopped");
265
188
  return Promise.resolve();
266
189
  },
267
- health() {
268
- return {
269
- status: "healthy",
270
- message: "Access Control contributions are loaded",
271
- };
272
- },
190
+ health: () => health.snapshot(),
273
191
  });
274
192
  export default accountGateModule;
275
193
  export { ACCOUNT_GATE_IDENTITY, accountGateRecords, applyChallengeAction, createAccountGateSessionState, generateChallengeChain, parseAccountGateConfiguration, validateRuntimeCapabilities, validateServerQuestion, };
276
- async function handleGuildMemberAdd(context) {
277
- if (context.memberIsBot)
278
- return;
279
- let configuration;
280
- try {
281
- configuration = await requiredRuntimeConfiguration(context.services, context.serverId);
282
- }
283
- catch {
284
- return;
285
- }
286
- if (await isAccountGateWorkSuppressed(context.services, context.serverId, context.memberId, context.joinedAt))
287
- return;
288
- const records = context.services.get(HELYX_SERVICE_NAMES.records);
289
- const previous = await loadMemberSummary(records, context.serverId, context.memberId);
290
- const generation = (previous ? numberValue(previous.join_generation) : 0) + 1;
291
- const accountTooYoung = isAccountTooYoung(context.accountCreatedAt, context.joinedAt, configuration.minimumAccountAgeDays);
292
- const returningSuccess = previous?.last_decision !== "revoked" &&
293
- hasPreviousSuccess(previous?.previous_success_at);
294
- const restore = returningSuccess &&
295
- configuration.restoreVerifiedMembers &&
296
- !configuration.reverifyReturningMembers &&
297
- !accountTooYoung;
298
- const now = context.joinedAt;
299
- const deadlineAt = accountTooYoung
300
- ? now
301
- : new Date(now.getTime() + configuration.joinDeadlineSeconds * 1_000);
302
- const values = summaryValues({
303
- guildId: context.serverId,
304
- memberUserId: context.memberId,
305
- now,
306
- previous,
307
- failureCount: 0,
308
- lockoutUntil: new Date(0),
309
- rejoinCount: previous ? numberValue(previous.rejoin_count) + 1 : 1,
310
- joinGeneration: generation,
311
- accountCreatedAt: context.accountCreatedAt,
312
- joinedAt: context.joinedAt,
313
- deadlineAt,
314
- pendingReason: accountTooYoung ? "account_too_new" : "none",
315
- lastDecision: restore ? "passed" : "pending",
316
- });
317
- const stored = previous
318
- ? await records.updateOne({
319
- moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
320
- collection: "member_summaries",
321
- where: {
322
- guild_id: context.serverId,
323
- member_user_id: context.memberId,
324
- },
325
- expected: {
326
- field: "revision",
327
- value: numberValue(previous.revision),
328
- },
329
- values: summaryUpdateValues(values),
330
- })
331
- : await records.appendUnique({
332
- moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
333
- collection: "member_summaries",
334
- uniqueBy: ["guild_id", "member_user_id"],
335
- values,
336
- });
337
- if (!("updated" in stored ? stored.updated : stored.created))
338
- return;
339
- if (restore) {
340
- const role = await context.services
341
- .get(HELYX_SERVICE_NAMES.discordResources)
342
- .inspectRole({
343
- guildId: context.serverId,
344
- roleId: configuration.verifiedRoleId,
345
- });
346
- if (role.exists && role.assignable) {
347
- await context.services
348
- .get(HELYX_SERVICE_NAMES.memberRoles)
349
- .addApprovedRole({
350
- guildId: context.serverId,
351
- memberUserId: context.memberId,
352
- roleId: configuration.verifiedRoleId,
353
- moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
354
- reason: "Restore retained Access Control success after rejoin",
355
- });
356
- return;
357
- }
358
- await setSummaryDecision(records, context.serverId, context.memberId, generation, "review");
359
- await createReview(context.services, context.serverId, context.memberId, "returning_role_restore_failed", null);
360
- return;
361
- }
362
- for (const roleId of configuration.temporaryRoleIds) {
363
- await context.services
364
- .get(HELYX_SERVICE_NAMES.memberRoles)
365
- .addApprovedRole({
366
- guildId: context.serverId,
367
- memberUserId: context.memberId,
368
- roleId,
369
- moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
370
- reason: "Apply Access Control temporary pre-gate role",
371
- });
372
- }
373
- await scheduleJoinDeadline(context.services, context.serverId, context.memberId, generation, deadlineAt);
374
- }
375
- async function handleGuildMemberRemove(context) {
376
- if (context.memberIsBot)
377
- return;
378
- const sessions = context.services.get(HELYX_SERVICE_NAMES.sessions);
379
- const active = await sessions.getFor(context.serverId, context.memberId, ACCOUNT_GATE_SESSION_PURPOSE);
380
- if (active)
381
- await sessions.delete(active.sessionId);
382
- const records = context.services.get(HELYX_SERVICE_NAMES.records);
383
- const row = await loadMemberSummary(records, context.serverId, context.memberId);
384
- if (!row)
385
- return;
386
- const now = new Date();
387
- const retentionUntil = new Date(now.getTime() + RETENTION_MS);
388
- const generation = numberValue(row.join_generation);
389
- await records.updateOne({
390
- moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
391
- collection: "member_summaries",
392
- where: {
393
- guild_id: context.serverId,
394
- member_user_id: context.memberId,
395
- },
396
- expected: { field: "revision", value: numberValue(row.revision) },
397
- values: {
398
- revision: numberValue(row.revision) + 1,
399
- left_at: now,
400
- retention_until: retentionUntil,
401
- updated_at: now,
402
- },
403
- schedule: {
404
- taskKind: LEAVE_RETENTION_TASK,
405
- guildId: context.serverId,
406
- idempotencyKey: `${context.serverId}:${context.memberId}:leave:${generation}`,
407
- payload: {
408
- guildId: context.serverId,
409
- memberUserId: context.memberId,
410
- summaryId: stringValue(row.summary_id),
411
- },
412
- scheduledFor: retentionUntil,
413
- },
414
- });
415
- }
416
- export function isAccountTooYoung(accountCreatedAt, joinedAt, minimumAccountAgeDays) {
417
- if (minimumAccountAgeDays <= 0)
418
- return false;
419
- return (joinedAt.getTime() - accountCreatedAt.getTime() <
420
- minimumAccountAgeDays * 24 * 60 * 60 * 1_000);
421
- }
422
- export function hasPreviousSuccess(value) {
423
- return value instanceof Date && value.getTime() > 0;
424
- }
425
- async function resetOwnAccess(context) {
426
- if (!context.serverId) {
427
- await replyError(context, "Access Control only works in a Discord server.");
428
- return;
429
- }
430
- const configuration = await getConfiguration(context.services, context.serverId);
431
- if (!configuration) {
432
- await replyError(context, "Access Control has not been configured by the server team.");
433
- return;
434
- }
435
- const records = context.services.get(HELYX_SERVICE_NAMES.records);
436
- const summary = await loadMemberSummary(records, context.serverId, context.userId);
437
- const [member] = await context.services
438
- .get(HELYX_SERVICE_NAMES.discordResources)
439
- .resolveMemberSnapshots({
440
- guildId: context.serverId,
441
- userIds: [context.userId],
442
- });
443
- if (!summary || !member?.exists || member.isBot) {
444
- await replyError(context, "There is no Access Control member state to reset.");
445
- return;
446
- }
447
- const sessions = context.services.get(HELYX_SERVICE_NAMES.sessions);
448
- const activeSession = await sessions.getFor(context.serverId, context.userId, ACCOUNT_GATE_SESSION_PURPOSE);
449
- if (activeSession)
450
- await sessions.delete(activeSession.sessionId);
451
- const now = new Date();
452
- const generation = numberValue(summary.join_generation) + 1;
453
- const accountCreatedAt = dateValue(summary.account_created_at) ?? new Date(0);
454
- const deadlineAt = new Date(now.getTime() + configuration.joinDeadlineSeconds * 1_000);
455
- const values = summaryValues({
456
- guildId: context.serverId,
457
- memberUserId: context.userId,
458
- now,
459
- previous: summary,
460
- previousSuccessAt: new Date(0),
461
- failureCount: 0,
462
- lockoutUntil: new Date(0),
463
- rejoinCount: numberValue(summary.rejoin_count),
464
- joinGeneration: generation,
465
- accountCreatedAt,
466
- joinedAt: dateValue(summary.joined_at) ?? member.joinedAt ?? now,
467
- deadlineAt,
468
- pendingReason: isAccountTooYoung(accountCreatedAt, now, configuration.minimumAccountAgeDays)
469
- ? "account_too_new"
470
- : "none",
471
- lastFailureTerminalId: "",
472
- leftAt: new Date(0),
473
- lastDecision: "pending",
474
- });
475
- const reset = await records.updateOne({
476
- moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
477
- collection: "member_summaries",
478
- where: {
479
- guild_id: context.serverId,
480
- member_user_id: context.userId,
481
- },
482
- expected: {
483
- field: "revision",
484
- value: numberValue(summary.revision),
485
- },
486
- requireModuleEnabledForGuildId: context.serverId,
487
- values: summaryUpdateValues(values),
488
- audit: {
489
- guildId: context.serverId,
490
- actorUserId: context.userId,
491
- action: "account_gate.self_reset",
492
- source: "discord",
493
- correlationId: context.interactionId,
494
- idempotencyKey: context.interactionId,
495
- targetType: "account_gate_member",
496
- targetId: context.userId,
497
- metadata: { generation },
498
- },
499
- });
500
- if (!reset.updated) {
501
- await replyError(context, "The Access Control state changed while it was being reset. Run the command again.");
502
- return;
503
- }
504
- await scheduleJoinDeadline(context.services, context.serverId, context.userId, generation, deadlineAt);
505
- try {
506
- await context.services
507
- .get(HELYX_SERVICE_NAMES.memberRoles)
508
- .reconcileApprovedRoles({
509
- guildId: context.serverId,
510
- memberUserId: context.userId,
511
- allowedRoleIds: [
512
- configuration.verifiedRoleId,
513
- ...configuration.temporaryRoleIds,
514
- ],
515
- desiredRoleIds: configuration.temporaryRoleIds,
516
- moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
517
- reason: "Access Control self reset",
518
- });
519
- }
520
- catch {
521
- await replyError(context, "The check was reset, but Discord did not apply all configured roles. Run the reset command again.");
522
- return;
523
- }
524
- await context.reply({
525
- ephemeral: true,
526
- componentsV2: {
527
- accentColor: VIOLET,
528
- text: [
529
- { content: "## Access Control reset" },
530
- {
531
- content: "Your access was revoked and a fresh Access Control check is ready. Press Start check when you are ready.",
532
- markdown: false,
533
- },
534
- ],
535
- },
536
- });
537
- }
538
- async function startCheck(context) {
539
- if (!context.serverId) {
540
- await replyError(context, "Access Control only works in a Discord server.");
541
- return;
542
- }
543
- const configuration = await getConfiguration(context.services, context.serverId);
544
- if (!configuration) {
545
- await replyError(context, "Access Control has not been configured by the server team.");
546
- return;
547
- }
548
- const panelId = startPanelId(context.customId);
549
- if (context.customId?.startsWith(ACCOUNT_GATE_COMPONENTS.startPrefix) &&
550
- panelId === null) {
551
- await replyError(context, "This Access Control panel control is invalid.");
552
- return;
553
- }
554
- const panel = panelId === null
555
- ? null
556
- : await getGatePanelRuntime(context.services.get(HELYX_SERVICE_NAMES.records), context.serverId, panelId);
557
- if (panelId !== null && !panel) {
558
- await replyError(context, "This Access Control panel is no longer available. Ask the server team to publish or repair it.");
559
- return;
560
- }
561
- if (panel?.accessType === "password" && !panel.password) {
562
- await replyError(context, "This Password Entry panel does not have a password. Ask the server team to update it.");
563
- return;
564
- }
565
- if (panel?.accessType === "challenges" &&
566
- configuration.accessType !== "challenges") {
567
- await replyError(context, "This Challenge panel is not currently active. Ask the server team to publish the current Access Control setup.");
568
- return;
569
- }
570
- const activeConfiguration = panel
571
- ? configurationForPanel(configuration, panel.accessType)
572
- : configuration;
573
- const setupErrors = await validateAccountGateSetup(context.services, context.serverId, activeConfiguration, false);
574
- if (setupErrors.length > 0) {
575
- await replyError(context, setupErrors[0]);
576
- return;
577
- }
578
- const resources = context.services.get(HELYX_SERVICE_NAMES.discordResources);
579
- let summary = await loadMemberSummary(context.services.get(HELYX_SERVICE_NAMES.records), context.serverId, context.userId);
580
- const [member] = await resources.resolveMemberSnapshots({
581
- guildId: context.serverId,
582
- userIds: [context.userId],
583
- });
584
- if (summary?.last_decision === "revoked" &&
585
- member?.exists &&
586
- !member.isBot &&
587
- !member.roleIds.includes(activeConfiguration.verifiedRoleId)) {
588
- summary = await resumeRevokedMember(context.services, context.serverId, context.userId, summary, activeConfiguration, context.interactionId);
589
- if (!summary) {
590
- await replyError(context, "The revoked Access Control state changed while this check was starting. Press Start check again.");
591
- return;
592
- }
593
- }
594
- if (!summary ||
595
- summary.last_decision !== "pending" ||
596
- !member?.exists ||
597
- member.isBot ||
598
- member.roleIds.includes(activeConfiguration.verifiedRoleId)) {
599
- await replyError(context, "There is no current pending Access Control check for this member.");
600
- return;
601
- }
602
- const capabilityErrors = validateRuntimeCapabilities(activeConfiguration, await resources.inspectRuntimeCapabilities());
603
- if (capabilityErrors.length > 0) {
604
- await replyError(context, capabilityErrors[0]);
605
- return;
606
- }
607
- const sessions = context.services.get(HELYX_SERVICE_NAMES.sessions);
608
- const lockoutUntil = dateValue(summary?.lockout_until);
609
- if (lockoutUntil && lockoutUntil.getTime() > Date.now()) {
610
- await replyError(context, `Please wait until <t:${Math.floor(lockoutUntil.getTime() / 1_000)}:R> before starting another check.`);
611
- return;
612
- }
613
- const existing = await sessions.getFor(context.serverId, context.userId, ACCOUNT_GATE_SESSION_PURPOSE);
614
- if (existing) {
615
- const existingState = parseAccountGateSessionState(existing.state);
616
- if (existingState.status === "passed" ||
617
- existingState.status === "failed") {
618
- if (await ensureTerminalFinalization(context.services, context.serverId, context.userId, existing.sessionId, existingState, existingState.status))
619
- await finalizeTerminalFinalization(context.services, context.serverId, existing.sessionId);
620
- const finalizedSummary = await loadMemberSummary(context.services.get(HELYX_SERVICE_NAMES.records), context.serverId, context.userId);
621
- await context.reply(terminalRecoveryMessage(existingState.status, typeof finalizedSummary?.last_decision === "string"
622
- ? finalizedSummary.last_decision
623
- : "pending"));
624
- return;
625
- }
626
- await context.reply(challengeMessage(existingState, {
627
- sessionId: existing.sessionId,
628
- version: existing.version,
629
- }));
630
- return;
631
- }
632
- const strictModeActive = await currentStrictMode(context.services, context.serverId);
633
- const random = secureRandom(context.services);
634
- const questions = activeConfiguration.enabledChallengeTypes.includes("server_question")
635
- ? await loadQuestions(context.services, context.serverId)
636
- : [];
637
- const state = createAccountGateSessionState(generateChallengeChain({
638
- configuration: activeConfiguration,
639
- random,
640
- operationContext: `${context.serverId}:${context.userId}:${context.interactionId}`,
641
- strictModeActive,
642
- adaptiveRisk: (summary ? numberValue(summary.failure_count) : 0) > 0 ||
643
- summary?.pending_reason === "account_too_new",
644
- questions,
645
- }), numberValue(summary.join_generation), new Date(), panelId ?? undefined);
646
- const expiresAt = new Date(Date.now() + activeConfiguration.sessionExpirySeconds * 1_000);
647
- const created = await sessions.create({
648
- guildId: context.serverId,
649
- userId: context.userId,
650
- purpose: ACCOUNT_GATE_SESSION_PURPOSE,
651
- state,
652
- expiresAt,
653
- });
654
- await scheduleSessionExpiry(context.services, {
655
- guildId: context.serverId,
656
- userId: context.userId,
657
- sessionId: created.sessionId,
658
- expiredAt: expiresAt,
659
- });
660
- await context.reply(challengeMessage(state, {
661
- sessionId: created.sessionId,
662
- version: created.version,
663
- }));
664
- }
665
- function startPanelId(customId) {
666
- if (!customId?.startsWith(ACCOUNT_GATE_COMPONENTS.startPrefix))
667
- return null;
668
- const panelId = customId.slice(ACCOUNT_GATE_COMPONENTS.startPrefix.length);
669
- return panelId.length >= 1 && panelId.length <= 100 && !panelId.includes(":")
670
- ? panelId
671
- : null;
672
- }
673
- function configurationForPanel(configuration, accessType) {
674
- if (accessType === "challenges")
675
- return configuration;
676
- const challengeType = accessType === "password" ? "password_entry" : "emoji_entry";
677
- return {
678
- ...configuration,
679
- accessType,
680
- preset: "custom",
681
- allocationMode: "fixed_chain",
682
- enabledChallengeTypes: [challengeType],
683
- fixedChallengeChain: [challengeType],
684
- challengeCount: 1,
685
- };
686
- }
687
- function terminalRecoveryMessage(outcome, decision) {
688
- if (outcome === "passed" && decision === "passed")
689
- return {
690
- ephemeral: true,
691
- componentsV2: {
692
- accentColor: GREEN,
693
- text: [
694
- { content: "## Access applied" },
695
- {
696
- content: "Your Access Control check is complete and your server access has been applied.",
697
- markdown: false,
698
- },
699
- ],
700
- },
701
- };
702
- if (decision === "review")
703
- return {
704
- ephemeral: true,
705
- componentsV2: {
706
- accentColor: VIOLET,
707
- text: [
708
- { content: "## Check sent for review" },
709
- {
710
- content: "Your check is complete, but Helyx could not safely apply the configured roles. The server team has been notified.",
711
- markdown: false,
712
- },
713
- ],
714
- },
715
- };
716
- return {
717
- ephemeral: true,
718
- componentsV2: {
719
- accentColor: outcome === "passed" ? VIOLET : RED,
720
- text: [
721
- {
722
- content: outcome === "passed"
723
- ? "## Access is being finalized"
724
- : "## Attempt submitted",
725
- },
726
- {
727
- content: outcome === "passed"
728
- ? "Your Access Control check is complete. Helyx is still applying the configured access changes."
729
- : "Your attempt has been recorded. Start a new check after the configured cooldown if another attempt is available.",
730
- markdown: false,
731
- },
732
- ],
733
- },
734
- };
735
- }
736
- async function processAction(context, action, binding) {
737
- if (!context.serverId) {
738
- await replyError(context, "This Access Control interaction is unavailable.");
739
- return;
740
- }
741
- const sessions = context.services.get(HELYX_SERVICE_NAMES.sessions);
742
- const session = await sessions.getFor(context.serverId, context.userId, ACCOUNT_GATE_SESSION_PURPOSE);
743
- if (!session) {
744
- await replyError(context, "This check has expired. Start a new check.");
745
- return;
746
- }
747
- if (session.sessionId !== binding.sessionId ||
748
- session.version !== binding.version ||
749
- parseAccountGateSessionState(session.state).step !== binding.step) {
750
- await replyError(context, "That control belongs to an older check or challenge step.");
751
- return;
752
- }
753
- const configuration = await requiredRuntimeConfiguration(context.services, context.serverId);
754
- if (!(await isCurrentPendingMember(context.services, context.serverId, context.userId, configuration.verifiedRoleId))) {
755
- await sessions.delete(session.sessionId);
756
- await replyError(context, "This pending Access Control check is no longer valid.");
757
- return;
758
- }
759
- const state = parseAccountGateSessionState(session.state);
760
- const result = applyChallengeAction(state, action);
761
- if (result.outcome === "stale") {
762
- await replyError(context, "That interaction is no longer part of your current check.");
763
- return;
764
- }
765
- if (result.outcome === "progress") {
766
- const updated = await sessions.update({
767
- sessionId: session.sessionId,
768
- expectedVersion: session.version,
769
- state: result.state,
770
- });
771
- await scheduleSessionExpiry(context.services, {
772
- guildId: context.serverId,
773
- userId: context.userId,
774
- sessionId: session.sessionId,
775
- expiredAt: session.expiresAt,
776
- });
777
- await context.reply(challengeMessage(result.state, {
778
- sessionId: session.sessionId,
779
- version: updated.version,
780
- }));
781
- return;
782
- }
783
- const advanced = await sessions.advance({
784
- sessionId: session.sessionId,
785
- guildId: context.serverId,
786
- userId: context.userId,
787
- purpose: ACCOUNT_GATE_SESSION_PURPOSE,
788
- expectedVersion: session.version,
789
- expectedStep: state.step,
790
- nextStep: state.step + 1,
791
- state: { ...result.state, step: state.step + 1 },
792
- });
793
- await scheduleSessionExpiry(context.services, {
794
- guildId: context.serverId,
795
- userId: context.userId,
796
- sessionId: session.sessionId,
797
- expiredAt: session.expiresAt,
798
- });
799
- if (result.outcome === "advanced") {
800
- await context.reply(challengeMessage(result.state, {
801
- sessionId: session.sessionId,
802
- version: advanced.version,
803
- }));
804
- return;
805
- }
806
- await finaliseAttempt(context, session.sessionId, result, state.chain[state.currentStep].type);
807
- }
808
- async function finaliseAttempt(context, sessionId, result, challengeType) {
809
- const guildId = context.serverId;
810
- await requiredRuntimeConfiguration(context.services, guildId);
811
- if (await isAccountGateWorkSuppressed(context.services, guildId, context.userId, new Date(result.state.startedAt))) {
812
- await context.services
813
- .get(HELYX_SERVICE_NAMES.sessions)
814
- .delete(sessionId);
815
- await replyError(context, "This check can no longer be completed. Start a new check if you still require access.");
816
- return;
817
- }
818
- const records = context.services.get(HELYX_SERVICE_NAMES.records);
819
- await recordAccountGateAttempt(records, guildId, context.userId, sessionId, result.state, challengeType, result.outcome);
820
- if (!(await ensureTerminalFinalization(context.services, guildId, context.userId, sessionId, result.state, result.outcome)))
821
- return;
822
- await finalizeTerminalFinalization(context.services, guildId, sessionId);
823
- await context.reply({
824
- ephemeral: true,
825
- componentsV2: {
826
- accentColor: result.outcome === "passed" ? GREEN : RED,
827
- text: [
828
- {
829
- content: result.outcome === "passed"
830
- ? "## Check submitted"
831
- : "## Attempt submitted",
832
- },
833
- {
834
- content: result.outcome === "passed"
835
- ? "Your result is being finalized. If access is not applied, the server team will receive a review."
836
- : "That answer was not correct. Start a new check when the cooldown ends if another attempt is available.",
837
- markdown: false,
838
- },
839
- ],
840
- },
841
- });
842
- }
843
- async function rerollChallenge(context) {
844
- if (!context.serverId)
845
- return replyError(context, "This check is unavailable.");
846
- const sessions = context.services.get(HELYX_SERVICE_NAMES.sessions);
847
- const session = await sessions.getFor(context.serverId, context.userId, ACCOUNT_GATE_SESSION_PURPOSE);
848
- if (!session)
849
- return replyError(context, "This check has expired.");
850
- const binding = parseComponentBinding(context.customId, ACCOUNT_GATE_COMPONENTS.rerollPrefix);
851
- if (!binding ||
852
- binding.sessionId !== session.sessionId ||
853
- binding.version !== session.version ||
854
- binding.step !== parseAccountGateSessionState(session.state).step)
855
- return replyError(context, "That reroll control is no longer current.");
856
- const configuration = await requiredRuntimeConfiguration(context.services, context.serverId);
857
- const currentState = parseAccountGateSessionState(session.state);
858
- if (await isAccountGateWorkSuppressed(context.services, context.serverId, context.userId, new Date(currentState.startedAt))) {
859
- await sessions.delete(session.sessionId);
860
- await replyError(context, "This check can no longer be changed. Start a new check if you still require access.");
861
- return;
862
- }
863
- const state = rerollForAccessibility({
864
- state: currentState,
865
- enabledTypes: generatedChallengePool(configuration),
866
- random: secureRandom(context.services),
867
- operationContext: `${context.serverId}:${context.userId}:reroll`,
868
- });
869
- if (!state) {
870
- await replyError(context, "A different challenge is not available for this check.");
871
- return;
872
- }
873
- const updated = await sessions.update({
874
- sessionId: session.sessionId,
875
- expectedVersion: session.version,
876
- state,
877
- });
878
- await scheduleSessionExpiry(context.services, {
879
- guildId: context.serverId,
880
- userId: context.userId,
881
- sessionId: session.sessionId,
882
- expiredAt: session.expiresAt,
883
- });
884
- await context.reply(challengeMessage(state, {
885
- sessionId: session.sessionId,
886
- version: updated.version,
887
- }));
888
- }
889
- async function openRecallModal(context) {
890
- if (!context.serverId)
891
- return replyError(context, "This check is unavailable.");
892
- const binding = parseComponentBinding(context.customId, ACCOUNT_GATE_COMPONENTS.recallOpenPrefix);
893
- if (!binding)
894
- return replyError(context, "This recall challenge has expired.");
895
- await context.showModal({
896
- customId: boundComponentId(ACCOUNT_GATE_COMPONENTS.recallSubmitPrefix, binding),
897
- title: "Enter the recalled code",
898
- fields: [
899
- {
900
- customId: "code",
901
- label: "Recall code",
902
- description: "Letter case and surrounding spaces are ignored.",
903
- style: "short",
904
- required: true,
905
- minLength: 5,
906
- maxLength: 8,
907
- },
908
- ],
909
- });
910
- let session;
911
- try {
912
- session = await context.services
913
- .get(HELYX_SERVICE_NAMES.sessions)
914
- .getFor(context.serverId, context.userId, ACCOUNT_GATE_SESSION_PURPOSE);
915
- }
916
- catch {
917
- return;
918
- }
919
- const state = session ? parseAccountGateSessionState(session.state) : null;
920
- if (!session ||
921
- !state ||
922
- binding.sessionId !== session.sessionId ||
923
- binding.version !== session.version ||
924
- binding.step !== state.step ||
925
- state.chain[state.currentStep]?.type !== "modal_recall") {
926
- return;
927
- }
928
- await context
929
- .editSourceMessage?.(challengeMessage(state, {
930
- sessionId: session.sessionId,
931
- version: session.version,
932
- }, { redactRecallCode: true }))
933
- .catch(() => undefined);
934
- }
935
- async function submitRecallModal(context) {
936
- const binding = parseComponentBinding(context.customId, ACCOUNT_GATE_COMPONENTS.recallSubmitPrefix);
937
- if (!binding) {
938
- await replyError(context, "This recall challenge is no longer current.");
939
- return;
940
- }
941
- await processAction(context, {
942
- type: "recall",
943
- value: context.getField("code"),
944
- }, binding);
945
- }
946
- async function processBoundOption(context) {
947
- const binding = parseComponentBinding(context.customId, ACCOUNT_GATE_COMPONENTS.answerPrefix);
948
- const option = binding ? Number(binding.value) : -1;
949
- if (!binding || !Number.isInteger(option) || option < 0 || option > 3) {
950
- await replyError(context, "This answer control is invalid.");
951
- return;
952
- }
953
- await processAction(context, { type: "option", option }, binding);
954
- }
955
- async function processBoundSequence(context) {
956
- const binding = parseComponentBinding(context.customId, ACCOUNT_GATE_COMPONENTS.sequencePrefix);
957
- if (!binding || !["red", "blue", "green"].includes(binding.value ?? "")) {
958
- await replyError(context, "This sequence control is invalid.");
959
- return;
960
- }
961
- await processAction(context, {
962
- type: "sequence",
963
- value: binding.value,
964
- }, binding);
965
- }
966
- async function handlePasswordEntry(context) {
967
- if (context.userIsBot || context.webhookId !== null)
968
- return;
969
- const configuration = await getConfiguration(context.services, context.serverId);
970
- if (!configuration || configuration.passwordChannelId !== context.channelId) {
971
- return;
972
- }
973
- const passwordConfiguration = configurationForPanel(configuration, "password");
974
- if ((await validateAccountGateSetup(context.services, context.serverId, passwordConfiguration, false)).length > 0)
975
- return;
976
- const sessions = context.services.get(HELYX_SERVICE_NAMES.sessions);
977
- const session = await sessions.getFor(context.serverId, context.userId, ACCOUNT_GATE_SESSION_PURPOSE);
978
- if (!session)
979
- return;
980
- const state = parseAccountGateSessionState(session.state);
981
- if (state.chain[state.currentStep]?.type !== "password_entry")
982
- return;
983
- await context.deleteSource();
984
- const store = context.services.get(HELYX_SERVICE_NAMES.records);
985
- const secret = state.panelId
986
- ? ((await getGatePanelRuntime(store, context.serverId, state.panelId))
987
- ?.password ?? null)
988
- : null;
989
- const matched = secret !== null &&
990
- (await verifyPasswordEntry(context.content, secret.encodedHash, secret));
991
- await processGatewayAction(context.services, context.serverId, context.userId, session, matched
992
- ? { type: "static-complete", challenge: "password_entry" }
993
- : { type: "static-failed", challenge: "password_entry" });
994
- }
995
- async function handleEmojiEntry(context) {
996
- if (context.reactionType !== "normal")
997
- return;
998
- const configuration = await getConfiguration(context.services, context.serverId);
999
- if (!configuration ||
1000
- !configuration.enabledChallengeTypes.includes("emoji_entry") ||
1001
- configuration.emojiChannelId !== context.channelId ||
1002
- configuration.emojiMessageId !== context.messageId ||
1003
- canonicalEmojiKey(configuration.emojiIdentifier) !== context.emojiKey) {
1004
- return;
1005
- }
1006
- if ((await validateAccountGateSetup(context.services, context.serverId, configuration, false)).length > 0)
1007
- return;
1008
- const [member] = await context.services
1009
- .get(HELYX_SERVICE_NAMES.discordResources)
1010
- .resolveMemberSnapshots({
1011
- guildId: context.serverId,
1012
- userIds: [context.userId],
1013
- });
1014
- if (!member || !member.exists || member.isBot)
1015
- return;
1016
- const sessions = context.services.get(HELYX_SERVICE_NAMES.sessions);
1017
- const session = await sessions.getFor(context.serverId, context.userId, ACCOUNT_GATE_SESSION_PURPOSE);
1018
- if (!session)
1019
- return;
1020
- const state = parseAccountGateSessionState(session.state);
1021
- if (state.chain[state.currentStep]?.type !== "emoji_entry")
1022
- return;
1023
- await processGatewayAction(context.services, context.serverId, context.userId, session, { type: "static-complete", challenge: "emoji_entry" });
1024
- }
1025
- async function processGatewayAction(services, guildId, userId, session, action) {
1026
- if (!session)
1027
- return;
1028
- const configuration = await requiredRuntimeConfiguration(services, guildId);
1029
- if (!(await isCurrentPendingMember(services, guildId, userId, configuration.verifiedRoleId)))
1030
- return;
1031
- const state = parseAccountGateSessionState(session.state);
1032
- const result = applyChallengeAction(state, action);
1033
- if (result.outcome === "progress" || result.outcome === "stale")
1034
- return;
1035
- const advanced = await services
1036
- .get(HELYX_SERVICE_NAMES.sessions)
1037
- .advance({
1038
- sessionId: session.sessionId,
1039
- guildId,
1040
- userId,
1041
- purpose: ACCOUNT_GATE_SESSION_PURPOSE,
1042
- expectedVersion: session.version,
1043
- expectedStep: state.step,
1044
- nextStep: state.step + 1,
1045
- state: { ...result.state, step: state.step + 1 },
1046
- });
1047
- await scheduleSessionExpiry(services, {
1048
- guildId,
1049
- userId,
1050
- sessionId: session.sessionId,
1051
- expiredAt: session.expiresAt,
1052
- });
1053
- if (result.outcome === "passed" || result.outcome === "failed") {
1054
- await recordAccountGateAttempt(services.get(HELYX_SERVICE_NAMES.records), guildId, userId, session.sessionId, result.state, state.chain[state.currentStep].type, result.outcome);
1055
- if (!(await ensureTerminalFinalization(services, guildId, userId, session.sessionId, result.state, result.outcome)))
1056
- return;
1057
- }
1058
- if (result.outcome === "advanced") {
1059
- let delivery = "failed";
1060
- try {
1061
- delivery = (await services
1062
- .get(HELYX_SERVICE_NAMES.discordMessages)
1063
- .sendDirect({
1064
- userId,
1065
- response: staticResumeMessage(guildId, configuration.gateChannelId),
1066
- })).outcome;
1067
- }
1068
- catch {
1069
- // A failed DM must not expose user-specific challenge controls publicly.
1070
- }
1071
- await emitLog(services, guildId, {
1072
- eventId: "account-gate-resume-notice",
1073
- summary: "Private Access Control resume guidance was attempted.",
1074
- details: [
1075
- { label: "Member ID", value: userId },
1076
- { label: "Delivery", value: delivery },
1077
- ],
1078
- idempotencyKey: `account-gate-resume:${session.sessionId}:${String(advanced.version)}`,
1079
- });
1080
- return;
1081
- }
1082
- if (result.outcome === "passed") {
1083
- await finalizeTerminalFinalization(services, guildId, session.sessionId);
1084
- }
1085
- else if (result.outcome === "failed") {
1086
- await finalizeTerminalFinalization(services, guildId, session.sessionId);
1087
- }
1088
- }
1089
- export async function resumeRevokedMember(services, guildId, memberUserId, summary, configuration, correlationId, now = new Date()) {
1090
- if (summary.last_decision !== "revoked")
1091
- return summary;
1092
- const records = services.get(HELYX_SERVICE_NAMES.records);
1093
- const revision = numberValue(summary.revision);
1094
- const generation = numberValue(summary.join_generation);
1095
- const deadlineAt = new Date(now.getTime() + configuration.joinDeadlineSeconds * 1_000);
1096
- const updated = await records.updateOne({
1097
- moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
1098
- collection: "member_summaries",
1099
- where: { guild_id: guildId, member_user_id: memberUserId },
1100
- expected: { field: "revision", value: revision },
1101
- requireModuleEnabledForGuildId: guildId,
1102
- values: {
1103
- failure_count: 0,
1104
- lockout_until: new Date(0),
1105
- last_failure_terminal_id: "",
1106
- deadline_at: deadlineAt,
1107
- pending_reason: "none",
1108
- left_at: new Date(0),
1109
- last_decision: "pending",
1110
- revision: revision + 1,
1111
- retention_until: new Date(now.getTime() + RETENTION_MS),
1112
- updated_at: now,
1113
- },
1114
- audit: {
1115
- guildId,
1116
- actorUserId: memberUserId,
1117
- action: "account_gate.revoked_check_started",
1118
- source: "discord",
1119
- correlationId,
1120
- idempotencyKey: correlationId,
1121
- targetType: "account_gate_member",
1122
- targetId: memberUserId,
1123
- metadata: { generation },
1124
- },
1125
- });
1126
- if (!updated.updated)
1127
- return null;
1128
- await scheduleJoinDeadline(services, guildId, memberUserId, generation, deadlineAt);
1129
- return loadMemberSummary(records, guildId, memberUserId);
1130
- }
1131
- export async function recordAccountGateAttempt(records, guildId, memberUserId, sessionId, state, challengeType, outcome, now = new Date()) {
1132
- const attemptId = randomUUID();
1133
- const retentionUntil = new Date(now.getTime() + RETENTION_MS);
1134
- const recorded = await records.appendUnique({
1135
- moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
1136
- collection: "attempts",
1137
- uniqueBy: ["guild_id", "session_id", "challenge_type"],
1138
- values: {
1139
- attempt_id: attemptId,
1140
- guild_id: guildId,
1141
- member_user_id: memberUserId,
1142
- session_id: sessionId,
1143
- challenge_type: challengeType,
1144
- outcome,
1145
- reason_code: outcome === "passed" ? "challenge_passed" : "challenge_failed",
1146
- duration_ms: Math.max(0, now.getTime() - Date.parse(state.startedAt)),
1147
- retention_until: retentionUntil,
1148
- created_at: now,
1149
- },
1150
- });
1151
- if (recorded.created)
1152
- await armRecordRetention(records, guildId, "attempts", "attempt_id", attemptId, retentionUntil);
1153
- }
1154
- async function showStatus(context) {
1155
- if (!context.serverId)
1156
- return replyError(context, "This command is server-only.");
1157
- const configuration = await requiredRuntimeConfiguration(context.services, context.serverId);
1158
- const errors = await validateAccountGateSetup(context.services, context.serverId, configuration, false);
1159
- const strict = await currentStrictMode(context.services, context.serverId);
1160
- await context.reply({
1161
- ephemeral: true,
1162
- embed: {
1163
- title: "Access Control status",
1164
- description: `${errors.length === 0 ? "Configuration checks passed." : errors.join("\n")}\nTemporary strict mode: ${strict ? "Active" : "Off"}.`,
1165
- color: errors.length === 0 ? GREEN : RED,
1166
- },
1167
- });
1168
- }
1169
- async function activateStrictModeCommand(context) {
1170
- if (!context.serverId)
1171
- return replyError(context, "This command is server-only.");
1172
- const configuration = await requiredRuntimeConfiguration(context.services, context.serverId);
1173
- const endsAt = await activateStrictMode(context.services, context.serverId, context.userId, configuration.strictModeDurationMinutes, context.interactionId);
1174
- await context.reply({
1175
- ephemeral: true,
1176
- embed: {
1177
- title: "Temporary strict mode active",
1178
- description: `One generated challenge has been added until <t:${Math.floor(endsAt.getTime() / 1_000)}:F>.`,
1179
- color: VIOLET,
1180
- },
1181
- });
1182
- }
1183
- async function activateStrictMode(services, guildId, actorUserId, durationMinutes, correlationId) {
1184
- const records = services.get(HELYX_SERVICE_NAMES.records);
1185
- let generation = 0;
1186
- let endsAt = new Date();
1187
- for (let retry = 0; retry < 4; retry += 1) {
1188
- const current = await records.findOne({
1189
- moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
1190
- collection: "strict_modes",
1191
- select: accountGateRecords.strict_modes.readableFields,
1192
- where: { guild_id: guildId },
1193
- });
1194
- const now = new Date();
1195
- endsAt = new Date(now.getTime() + durationMinutes * 60_000);
1196
- generation = current ? numberValue(current.generation) + 1 : 1;
1197
- const stored = current
1198
- ? await records.updateOne({
1199
- moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
1200
- collection: "strict_modes",
1201
- where: { guild_id: guildId },
1202
- expected: {
1203
- field: "revision",
1204
- value: numberValue(current.revision),
1205
- },
1206
- values: {
1207
- status: "active",
1208
- ends_at: endsAt,
1209
- generation,
1210
- activated_by_user_id: actorUserId,
1211
- revision: numberValue(current.revision) + 1,
1212
- updated_at: now,
1213
- },
1214
- })
1215
- : await records.appendUnique({
1216
- moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
1217
- collection: "strict_modes",
1218
- uniqueBy: ["guild_id"],
1219
- values: {
1220
- guild_id: guildId,
1221
- status: "active",
1222
- ends_at: endsAt,
1223
- generation,
1224
- activated_by_user_id: actorUserId,
1225
- revision: 1,
1226
- created_at: now,
1227
- updated_at: now,
1228
- },
1229
- });
1230
- if ("updated" in stored ? stored.updated : stored.created)
1231
- break;
1232
- if (retry === 3)
1233
- throw new Error("Access Control strict mode changed concurrently");
1234
- }
1235
- await services
1236
- .get(HELYX_SERVICE_NAMES.scheduledTasks)
1237
- .schedule({
1238
- moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
1239
- taskKind: STRICT_EXPIRY_TASK,
1240
- guildId,
1241
- idempotencyKey: `${guildId}:strict:${generation}`,
1242
- payload: { guildId },
1243
- scheduledFor: endsAt,
1244
- });
1245
- await emitLog(services, guildId, {
1246
- eventId: "account-gate-strict-mode-changed",
1247
- summary: "Temporary Access Control strict mode was activated.",
1248
- details: [
1249
- { label: "Duration minutes", value: String(durationMinutes) },
1250
- { label: "Generation", value: String(generation) },
1251
- ],
1252
- idempotencyKey: correlationId,
1253
- });
1254
- return endsAt;
1255
- }
1256
- function validateStrictTaskPayload(input) {
1257
- if (!input || typeof input !== "object" || Array.isArray(input))
1258
- throw new Error("Account Gate strict task payload is invalid");
1259
- const value = input;
1260
- if (typeof value.guildId !== "string" || !/^\d{17,20}$/u.test(value.guildId))
1261
- throw new Error("Account Gate strict task payload is invalid");
1262
- return { guildId: value.guildId };
1263
- }
1264
- async function expireStrictMode(context, task) {
1265
- const records = context.services.get(HELYX_SERVICE_NAMES.records);
1266
- const row = await records.findOne({
1267
- moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
1268
- collection: "strict_modes",
1269
- select: accountGateRecords.strict_modes.readableFields,
1270
- where: { guild_id: task.payload.guildId },
1271
- });
1272
- if (!row || row.status !== "active")
1273
- return { outcome: "completed" };
1274
- const endsAt = dateValue(row.ends_at);
1275
- if (!endsAt)
1276
- return { outcome: "completed" };
1277
- if (endsAt.getTime() > Date.now())
1278
- return {
1279
- outcome: "deferred",
1280
- delayMs: Math.max(100, endsAt.getTime() - Date.now()),
1281
- safeCode: "strict-expiry-not-due",
1282
- };
1283
- const generation = numberValue(row.generation);
1284
- const result = await records.updateOne({
1285
- moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
1286
- collection: "strict_modes",
1287
- where: { guild_id: task.payload.guildId },
1288
- expected: { field: "generation", value: generation },
1289
- dateAtOrBeforeNow: { field: "ends_at" },
1290
- values: {
1291
- status: "expired",
1292
- revision: numberValue(row.revision) + 1,
1293
- updated_at: new Date(),
1294
- },
1295
- });
1296
- if (result.updated) {
1297
- await emitLog(context.services, task.payload.guildId, {
1298
- eventId: "account-gate-strict-mode-changed",
1299
- summary: "Temporary Access Control strict mode expired.",
1300
- details: [{ label: "Generation", value: String(generation) }],
1301
- });
1302
- }
1303
- return { outcome: "completed" };
1304
- }
1305
- function validateJoinDeadlinePayload(input) {
1306
- if (!input || typeof input !== "object" || Array.isArray(input))
1307
- throw new Error("Account Gate join-deadline payload is invalid");
1308
- const value = input;
1309
- if (typeof value.guildId !== "string" ||
1310
- !/^\d{17,20}$/u.test(value.guildId) ||
1311
- typeof value.memberUserId !== "string" ||
1312
- !/^\d{17,20}$/u.test(value.memberUserId))
1313
- throw new Error("Account Gate join-deadline payload is invalid");
1314
- return {
1315
- guildId: value.guildId,
1316
- memberUserId: value.memberUserId,
1317
- };
1318
- }
1319
- function validateLeaveRetentionPayload(input) {
1320
- if (!input || typeof input !== "object" || Array.isArray(input))
1321
- throw new Error("Account Gate leave-retention payload is invalid");
1322
- const value = input;
1323
- if (typeof value.guildId !== "string" ||
1324
- !/^\d{17,20}$/u.test(value.guildId) ||
1325
- typeof value.memberUserId !== "string" ||
1326
- !/^\d{17,20}$/u.test(value.memberUserId) ||
1327
- typeof value.summaryId !== "string")
1328
- throw new Error("Account Gate leave-retention payload is invalid");
1329
- return {
1330
- guildId: value.guildId,
1331
- memberUserId: value.memberUserId,
1332
- summaryId: value.summaryId,
1333
- };
1334
- }
1335
- async function runLeaveRetention(context, task) {
1336
- const guildId = task.payload.guildId;
1337
- const memberUserId = task.payload.memberUserId;
1338
- const summaryId = task.payload.summaryId;
1339
- const records = context.services.get(HELYX_SERVICE_NAMES.records);
1340
- const row = await records.findOne({
1341
- moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
1342
- collection: "member_summaries",
1343
- select: accountGateRecords.member_summaries.readableFields,
1344
- where: { summary_id: summaryId },
1345
- });
1346
- const leftAt = dateValue(row?.left_at);
1347
- const retentionUntil = dateValue(row?.retention_until);
1348
- if (!row ||
1349
- row.guild_id !== guildId ||
1350
- row.member_user_id !== memberUserId ||
1351
- !leftAt ||
1352
- !retentionUntil ||
1353
- (dateValue(row.joined_at)?.getTime() ?? Number.POSITIVE_INFINITY) >
1354
- leftAt.getTime())
1355
- return { outcome: "completed" };
1356
- if (retentionUntil.getTime() > Date.now())
1357
- return {
1358
- outcome: "deferred",
1359
- delayMs: Math.max(100, retentionUntil.getTime() - Date.now()),
1360
- safeCode: "leave-retention-not-due",
1361
- };
1362
- const [member] = await context.services
1363
- .get(HELYX_SERVICE_NAMES.discordResources)
1364
- .resolveMemberSnapshots({ guildId, userIds: [memberUserId] });
1365
- if (member?.exists)
1366
- return { outcome: "completed" };
1367
- await records.deleteMany({
1368
- moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
1369
- collection: "member_summaries",
1370
- where: { summary_id: summaryId },
1371
- limit: 1,
1372
- });
1373
- return { outcome: "completed" };
1374
- }
1375
- async function enforceJoinDeadline(context, task) {
1376
- const guildId = task.payload.guildId;
1377
- const memberUserId = task.payload.memberUserId;
1378
- const enabled = await context.services
1379
- .get(HELYX_SERVICE_NAMES.installations)
1380
- .isModuleEnabled(guildId, ACCOUNT_GATE_IDENTITY.moduleId);
1381
- if (!enabled)
1382
- return { outcome: "completed" };
1383
- const records = context.services.get(HELYX_SERVICE_NAMES.records);
1384
- const row = await loadMemberSummary(records, guildId, memberUserId);
1385
- if (!row || row.last_decision !== "pending")
1386
- return { outcome: "completed" };
1387
- const generation = numberValue(row.join_generation);
1388
- const joinedAt = dateValue(row.joined_at) ?? new Date(0);
1389
- const deadlineAt = dateValue(row.deadline_at);
1390
- if (!deadlineAt || deadlineAt.getTime() > Date.now()) {
1391
- return deadlineAt
1392
- ? {
1393
- outcome: "deferred",
1394
- delayMs: Math.max(100, deadlineAt.getTime() - Date.now()),
1395
- safeCode: "join-deadline-not-due",
1396
- }
1397
- : { outcome: "completed" };
1398
- }
1399
- if (await isAccountGateWorkSuppressed(context.services, guildId, memberUserId, joinedAt))
1400
- return { outcome: "completed" };
1401
- const [member] = await context.services
1402
- .get(HELYX_SERVICE_NAMES.discordResources)
1403
- .resolveMemberSnapshots({ guildId, userIds: [memberUserId] });
1404
- if (!member?.exists || member.isBot)
1405
- return { outcome: "completed" };
1406
- const sessions = context.services.get(HELYX_SERVICE_NAMES.sessions);
1407
- const active = await sessions.getFor(guildId, memberUserId, ACCOUNT_GATE_SESSION_PURPOSE);
1408
- if (active)
1409
- await sessions.delete(active.sessionId);
1410
- const configuration = await requiredRuntimeConfiguration(context.services, guildId);
1411
- const reason = row.pending_reason === "account_too_new"
1412
- ? "account_too_new"
1413
- : "join_deadline_expired";
1414
- const decision = row.pending_reason === "account_too_new"
1415
- ? "review"
1416
- : configuration.failureAction === "kick"
1417
- ? "kick_pending"
1418
- : configuration.failureAction === "review"
1419
- ? "review"
1420
- : "failed";
1421
- const claimed = await setSummaryDecision(records, guildId, memberUserId, generation, decision);
1422
- if (!claimed)
1423
- return { outcome: "completed" };
1424
- if (decision === "review") {
1425
- await createReview(context.services, guildId, memberUserId, reason, null);
1426
- }
1427
- else if (decision === "kick_pending") {
1428
- const result = await context.services
1429
- .get(HELYX_SERVICE_NAMES.memberEnforcement)
1430
- .kick({
1431
- guildId,
1432
- memberUserId,
1433
- moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
1434
- reason: `Access Control ${reason}`,
1435
- directMessage: {
1436
- embed: {
1437
- title: "Removed after incomplete Access Control",
1438
- description: neutralKickDirectMessage(`Discord server ${guildId}`),
1439
- color: VIOLET,
1440
- },
1441
- },
1442
- revalidate: async () => {
1443
- const [stillEnabled, current, currentSession] = await Promise.all([
1444
- context.services
1445
- .get(HELYX_SERVICE_NAMES.installations)
1446
- .isModuleEnabled(guildId, ACCOUNT_GATE_IDENTITY.moduleId),
1447
- loadMemberSummary(records, guildId, memberUserId),
1448
- sessions.getFor(guildId, memberUserId, ACCOUNT_GATE_SESSION_PURPOSE),
1449
- ]);
1450
- return (stillEnabled &&
1451
- current !== null &&
1452
- numberValue(current.join_generation) === generation &&
1453
- current?.last_decision === "kick_pending" &&
1454
- currentSession === null &&
1455
- !(await isAccountGateWorkSuppressed(context.services, guildId, memberUserId, joinedAt)));
1456
- },
1457
- });
1458
- if (["kicked", "member_missing"].includes(result.outcome)) {
1459
- const committed = await setSummaryDecision(records, guildId, memberUserId, generation, "kicked", "kick_pending");
1460
- if (committed)
1461
- await emitAccountGateKickLog(context.services, guildId, {
1462
- memberUserId,
1463
- outcome: successfulKickOutcome(result.outcome),
1464
- directMessage: result.directMessage,
1465
- source: "Join deadline",
1466
- idempotencyKey: `account-gate-kick:deadline:${memberUserId}:${generation}`,
1467
- });
1468
- }
1469
- }
1470
- return { outcome: "completed" };
1471
- }
1472
- function validateSessionExpiryPayload(input) {
1473
- if (!input || typeof input !== "object" || Array.isArray(input))
1474
- throw new Error("Account Gate session-expiry payload is invalid");
1475
- const value = input;
1476
- if (typeof value.guildId !== "string" ||
1477
- typeof value.userId !== "string" ||
1478
- typeof value.sessionId !== "string")
1479
- throw new Error("Account Gate session-expiry payload is invalid");
1480
- return {
1481
- guildId: value.guildId,
1482
- userId: value.userId,
1483
- sessionId: value.sessionId,
1484
- };
1485
- }
1486
- async function expireSession(context, task) {
1487
- const guildId = task.payload.guildId;
1488
- const userId = task.payload.userId;
1489
- const sessionId = task.payload.sessionId;
1490
- const sessions = context.services.get(HELYX_SERVICE_NAMES.sessions);
1491
- const current = await sessions.getFor(guildId, userId, ACCOUNT_GATE_SESSION_PURPOSE);
1492
- if (!current || current.sessionId !== sessionId) {
1493
- return { outcome: "completed" };
1494
- }
1495
- const result = await sessions.expire({
1496
- sessionId,
1497
- guildId,
1498
- userId,
1499
- purpose: ACCOUNT_GATE_SESSION_PURPOSE,
1500
- expiredAt: current.expiresAt,
1501
- });
1502
- if (!result.expired)
1503
- return { outcome: "completed" };
1504
- const expiredState = parseAccountGateSessionState(result.state);
1505
- if (await isAccountGateWorkSuppressed(context.services, guildId, userId, new Date(expiredState.startedAt)))
1506
- return { outcome: "completed" };
1507
- if (expiredState.status === "passed" || expiredState.status === "failed") {
1508
- const configuration = await requiredRuntimeConfiguration(context.services, guildId);
1509
- if (!(await isCurrentPendingMember(context.services, guildId, userId, configuration.verifiedRoleId)))
1510
- return { outcome: "completed" };
1511
- }
1512
- if (expiredState.status === "failed") {
1513
- if (!(await ensureTerminalFinalization(context.services, guildId, userId, sessionId, expiredState, "failed")))
1514
- return { outcome: "completed" };
1515
- await finalizeTerminalFinalization(context.services, guildId, sessionId);
1516
- return { outcome: "completed" };
1517
- }
1518
- if (expiredState.status === "passed") {
1519
- if (!(await ensureTerminalFinalization(context.services, guildId, userId, sessionId, expiredState, "passed")))
1520
- return { outcome: "completed" };
1521
- const now = new Date();
1522
- const challengeType = expiredState.chain.at(-1)?.type;
1523
- if (challengeType) {
1524
- const records = context.services.get(HELYX_SERVICE_NAMES.records);
1525
- const attemptId = randomUUID();
1526
- const retentionUntil = new Date(now.getTime() + RETENTION_MS);
1527
- const recorded = await records.appendUnique({
1528
- moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
1529
- collection: "attempts",
1530
- uniqueBy: ["guild_id", "session_id", "challenge_type"],
1531
- values: {
1532
- attempt_id: attemptId,
1533
- guild_id: guildId,
1534
- member_user_id: userId,
1535
- session_id: sessionId,
1536
- challenge_type: challengeType,
1537
- outcome: "passed",
1538
- reason_code: "challenge_passed_recovered",
1539
- duration_ms: Math.max(0, now.getTime() - Date.parse(expiredState.startedAt)),
1540
- retention_until: retentionUntil,
1541
- created_at: now,
1542
- },
1543
- });
1544
- if (recorded.created)
1545
- await armRecordRetention(records, guildId, "attempts", "attempt_id", attemptId, retentionUntil);
1546
- }
1547
- await finalizeTerminalFinalization(context.services, guildId, sessionId);
1548
- return { outcome: "completed" };
1549
- }
1550
- const challengeType = expiredState.chain[expiredState.currentStep]?.type;
1551
- if (challengeType) {
1552
- const now = new Date();
1553
- const records = context.services.get(HELYX_SERVICE_NAMES.records);
1554
- const attemptId = randomUUID();
1555
- const retentionUntil = new Date(now.getTime() + RETENTION_MS);
1556
- const recorded = await records.appendUnique({
1557
- moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
1558
- collection: "attempts",
1559
- uniqueBy: ["guild_id", "session_id", "challenge_type"],
1560
- values: {
1561
- attempt_id: attemptId,
1562
- guild_id: guildId,
1563
- member_user_id: userId,
1564
- session_id: sessionId,
1565
- challenge_type: challengeType,
1566
- outcome: "expired",
1567
- reason_code: "session_expired",
1568
- duration_ms: Math.max(0, now.getTime() - Date.parse(expiredState.startedAt)),
1569
- retention_until: retentionUntil,
1570
- created_at: now,
1571
- },
1572
- });
1573
- if (recorded.created)
1574
- await armRecordRetention(records, guildId, "attempts", "attempt_id", attemptId, retentionUntil);
1575
- }
1576
- return { outcome: "completed" };
1577
- }
1578
- export async function ensureTerminalFinalization(services, guildId, memberUserId, sessionId, state, outcome) {
1579
- const records = services.get(HELYX_SERVICE_NAMES.records);
1580
- const summary = await loadMemberSummary(records, guildId, memberUserId);
1581
- if (!summary ||
1582
- !["pending", "finalizing"].includes(stringValue(summary.last_decision)))
1583
- return false;
1584
- const generation = state.joinGeneration;
1585
- if (numberValue(summary.join_generation) !== generation)
1586
- return false;
1587
- const challenge = outcome === "passed" ? state.chain.at(-1) : state.chain[state.currentStep];
1588
- if (!challenge)
1589
- return false;
1590
- const now = new Date();
1591
- const retentionUntil = new Date(now.getTime() + RETENTION_MS);
1592
- const created = await records.appendUnique({
1593
- moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
1594
- collection: "terminal_finalizations",
1595
- uniqueBy: ["guild_id", "session_id"],
1596
- values: {
1597
- finalization_id: sessionId,
1598
- guild_id: guildId,
1599
- member_user_id: memberUserId,
1600
- session_id: sessionId,
1601
- join_generation: generation,
1602
- outcome,
1603
- challenge_type: challenge.type,
1604
- challenge_types_json: JSON.stringify(state.chain.map(({ type }) => type)),
1605
- work_created_at: new Date(state.startedAt),
1606
- status: "pending",
1607
- revision: 1,
1608
- retention_until: retentionUntil,
1609
- created_at: now,
1610
- updated_at: now,
1611
- },
1612
- });
1613
- if (created.created)
1614
- await armRecordRetention(records, guildId, "terminal_finalizations", "finalization_id", sessionId, retentionUntil);
1615
- if (summary.last_decision === "pending") {
1616
- const revision = numberValue(summary.revision);
1617
- const claimed = await records.updateOne({
1618
- moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
1619
- collection: "member_summaries",
1620
- where: { guild_id: guildId, member_user_id: memberUserId },
1621
- expected: { field: "revision", value: revision },
1622
- values: {
1623
- last_decision: "finalizing",
1624
- revision: revision + 1,
1625
- updated_at: now,
1626
- },
1627
- schedule: {
1628
- taskKind: TERMINAL_FINALIZATION_TASK,
1629
- guildId,
1630
- idempotencyKey: `${guildId}:${sessionId}:terminal`,
1631
- payload: { guildId, finalizationId: sessionId },
1632
- scheduledFor: now,
1633
- },
1634
- });
1635
- if (!claimed.updated)
1636
- return false;
1637
- }
1638
- else {
1639
- await scheduleTerminalFinalization(services, guildId, sessionId, now);
1640
- }
1641
- return true;
1642
- }
1643
- function validateTerminalFinalizationPayload(input) {
1644
- if (!input || typeof input !== "object" || Array.isArray(input))
1645
- throw new Error("Account Gate terminal-finalization payload is invalid");
1646
- const value = input;
1647
- if (typeof value.guildId !== "string" ||
1648
- typeof value.finalizationId !== "string" ||
1649
- value.finalizationId.length > 100)
1650
- throw new Error("Account Gate terminal-finalization payload is invalid");
1651
- return {
1652
- guildId: value.guildId,
1653
- finalizationId: value.finalizationId,
1654
- };
1655
- }
1656
- async function runTerminalFinalizationTask(context, task) {
1657
- await finalizeTerminalFinalization(context.services, task.payload.guildId, task.payload.finalizationId);
1658
- return { outcome: "completed" };
1659
- }
1660
- async function finalizeTerminalFinalization(services, guildId, finalizationId) {
1661
- const records = services.get(HELYX_SERVICE_NAMES.records);
1662
- const row = await records.findOne({
1663
- moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
1664
- collection: "terminal_finalizations",
1665
- select: accountGateRecords.terminal_finalizations.readableFields,
1666
- where: { guild_id: guildId, finalization_id: finalizationId },
1667
- });
1668
- if (!row || ["completed", "cancelled"].includes(stringValue(row.status)))
1669
- return;
1670
- const memberUserId = stringValue(row.member_user_id);
1671
- const generation = numberValue(row.join_generation);
1672
- const summary = await loadMemberSummary(records, guildId, memberUserId);
1673
- const enabled = await services
1674
- .get(HELYX_SERVICE_NAMES.installations)
1675
- .isModuleEnabled(guildId, ACCOUNT_GATE_IDENTITY.moduleId);
1676
- if (!enabled ||
1677
- !summary ||
1678
- numberValue(summary.join_generation) !== generation ||
1679
- !["finalizing", "failed", "passed", "review"].includes(stringValue(summary.last_decision)) ||
1680
- (await isAccountGateWorkSuppressed(services, guildId, memberUserId, dateValue(row.work_created_at) ?? new Date(0)))) {
1681
- await finishTerminalRecord(records, row, "cancelled");
1682
- return;
1683
- }
1684
- const configuration = await requiredRuntimeConfiguration(services, guildId);
1685
- const sessions = services.get(HELYX_SERVICE_NAMES.sessions);
1686
- if (row.outcome === "passed") {
1687
- if (summary.last_decision === "passed") {
1688
- await emitAccountGateCompletedLog(services, guildId, {
1689
- memberUserId,
1690
- roleOutcome: "already_applied",
1691
- idempotencyKey: `account-gate-completed:${finalizationId}`,
1692
- });
1693
- await sessions.delete(stringValue(row.session_id));
1694
- await finishTerminalRecord(records, row, "completed");
1695
- return;
1696
- }
1697
- const transition = await applyRoleTransition({
1698
- guildId,
1699
- memberUserId,
1700
- verifiedRoleId: configuration.verifiedRoleId,
1701
- temporaryRoleIds: configuration.temporaryRoleIds,
1702
- resources: services.get(HELYX_SERVICE_NAMES.discordResources),
1703
- roles: services.get(HELYX_SERVICE_NAMES.memberRoles),
1704
- revalidate: () => terminalPassStillCurrent(services, guildId, memberUserId, generation, dateValue(row.work_created_at) ?? new Date(0)),
1705
- });
1706
- if (transition.outcome === "stale") {
1707
- await finishTerminalRecord(records, row, "cancelled");
1708
- return;
1709
- }
1710
- if (transition.outcome === "blocked") {
1711
- await setSummaryDecision(records, guildId, memberUserId, generation, "review", "finalizing");
1712
- await createReview(services, guildId, memberUserId, "role_assignment_failed", null, generation, JSON.parse(stringValue(row.challenge_types_json)), dateValue(row.work_created_at) ?? new Date(0));
1713
- }
1714
- else {
1715
- if (transition.outcome === "partial")
1716
- await persistPartialTransition(services, guildId, memberUserId, configuration.verifiedRoleId, transition.pendingRoleIds, dateValue(row.work_created_at) ?? new Date(0));
1717
- if (!(await markSummaryPassed(records, guildId, memberUserId, new Date(), generation))) {
1718
- await compensateVerifiedRoleAddition(services, guildId, memberUserId, configuration.verifiedRoleId, transition);
1719
- await finishTerminalRecord(records, row, "cancelled");
1720
- return;
1721
- }
1722
- await emitAccountGateCompletedLog(services, guildId, {
1723
- memberUserId,
1724
- roleOutcome: transition.outcome,
1725
- idempotencyKey: `account-gate-completed:${finalizationId}`,
1726
- });
1727
- }
1728
- }
1729
- else {
1730
- const failures = await recordFailureSummary(records, guildId, memberUserId, new Date(), configuration.retryCooldownSeconds, configuration.attempts, finalizationId, generation);
1731
- if (failures === null) {
1732
- await finishTerminalRecord(records, row, "cancelled");
1733
- return;
1734
- }
1735
- await sessions.delete(stringValue(row.session_id));
1736
- if (failures >= configuration.attempts)
1737
- await applyDurableFailureAction(services, configuration, guildId, memberUserId, generation, row);
1738
- }
1739
- await sessions.delete(stringValue(row.session_id));
1740
- await finishTerminalRecord(records, row, "completed");
1741
- }
1742
- async function finishTerminalRecord(records, row, status) {
1743
- const revision = numberValue(row.revision);
1744
- await records.updateOne({
1745
- moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
1746
- collection: "terminal_finalizations",
1747
- where: { finalization_id: stringValue(row.finalization_id) },
1748
- expected: { field: "revision", value: revision },
1749
- values: { status, revision: revision + 1, updated_at: new Date() },
1750
- });
1751
- }
1752
- async function applyDurableFailureAction(services, configuration, guildId, memberUserId, generation, terminal) {
1753
- if (configuration.failureAction === "review") {
1754
- await createReview(services, guildId, memberUserId, "attempt_limit_reached", null, generation, JSON.parse(stringValue(terminal.challenge_types_json)), dateValue(terminal.work_created_at) ?? new Date(0));
1755
- return;
1756
- }
1757
- if (configuration.failureAction !== "kick")
1758
- return;
1759
- const records = services.get(HELYX_SERVICE_NAMES.records);
1760
- const result = await services
1761
- .get(HELYX_SERVICE_NAMES.memberEnforcement)
1762
- .kick({
1763
- guildId,
1764
- memberUserId,
1765
- moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
1766
- reason: "Access Control final failure",
1767
- directMessage: {
1768
- embed: {
1769
- title: "Removed after incomplete Access Control",
1770
- description: neutralKickDirectMessage(`Discord server ${guildId}`),
1771
- color: VIOLET,
1772
- },
1773
- },
1774
- revalidate: async () => {
1775
- const [enabled, summary, session] = await Promise.all([
1776
- services
1777
- .get(HELYX_SERVICE_NAMES.installations)
1778
- .isModuleEnabled(guildId, ACCOUNT_GATE_IDENTITY.moduleId),
1779
- loadMemberSummary(records, guildId, memberUserId),
1780
- services
1781
- .get(HELYX_SERVICE_NAMES.sessions)
1782
- .getFor(guildId, memberUserId, ACCOUNT_GATE_SESSION_PURPOSE),
1783
- ]);
1784
- if (!enabled || session !== null || !summary)
1785
- return false;
1786
- if (numberValue(summary.join_generation) !== generation ||
1787
- summary.last_decision !== "failed")
1788
- return false;
1789
- try {
1790
- await requiredRuntimeConfiguration(services, guildId);
1791
- }
1792
- catch {
1793
- return false;
1794
- }
1795
- return !(await isAccountGateWorkSuppressed(services, guildId, memberUserId, dateValue(terminal.work_created_at) ?? new Date(0)));
1796
- },
1797
- });
1798
- if (["kicked", "member_missing"].includes(result.outcome))
1799
- await emitAccountGateKickLog(services, guildId, {
1800
- memberUserId,
1801
- outcome: successfulKickOutcome(result.outcome),
1802
- directMessage: result.directMessage,
1803
- source: "Failed check",
1804
- idempotencyKey: `account-gate-kick:terminal:${stringValue(terminal.finalization_id)}`,
1805
- });
1806
- }
1807
- function validateReviewDeliveryPayload(input) {
1808
- if (!input || typeof input !== "object" || Array.isArray(input))
1809
- throw new Error("Account Gate review-delivery payload is invalid");
1810
- const value = input;
1811
- if (typeof value.guildId !== "string" ||
1812
- typeof value.reviewId !== "string" ||
1813
- value.reviewId.length > 100)
1814
- throw new Error("Account Gate review-delivery payload is invalid");
1815
- return { guildId: value.guildId, reviewId: value.reviewId };
1816
- }
1817
- async function deliverReview(context, task) {
1818
- const guildId = task.payload.guildId;
1819
- const reviewId = task.payload.reviewId;
1820
- const enabled = await context.services
1821
- .get(HELYX_SERVICE_NAMES.installations)
1822
- .isModuleEnabled(guildId, ACCOUNT_GATE_IDENTITY.moduleId);
1823
- if (!enabled)
1824
- return { outcome: "completed" };
1825
- const records = context.services.get(HELYX_SERVICE_NAMES.records);
1826
- const row = await records.findOne({
1827
- moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
1828
- collection: "reviews",
1829
- select: accountGateRecords.reviews.readableFields,
1830
- where: { guild_id: guildId, review_id: reviewId },
1831
- });
1832
- if (!row || row.status !== "pending" || row.delivery_status === "sent")
1833
- return { outcome: "completed" };
1834
- if (await isAccountGateWorkSuppressed(context.services, guildId, stringValue(row.member_user_id), dateValue(row.created_at) ?? new Date(0)))
1835
- return { outcome: "completed" };
1836
- const configuration = await getConfiguration(context.services, guildId);
1837
- if (!configuration?.reviewChannelId)
1838
- return {
1839
- outcome: "requires_review",
1840
- safeCode: "review-channel-unavailable",
1841
- };
1842
- try {
1843
- const sent = await context.services
1844
- .get(HELYX_SERVICE_NAMES.discordMessages)
1845
- .send({
1846
- guildId,
1847
- channelId: configuration.reviewChannelId,
1848
- response: reviewMessage(stringValue(row.member_user_id), stringValue(row.reason_code)),
1849
- });
1850
- const revision = numberValue(row.revision);
1851
- const stored = await records.updateOne({
1852
- moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
1853
- collection: "reviews",
1854
- where: { guild_id: guildId, review_id: reviewId },
1855
- expected: { field: "revision", value: revision },
1856
- values: {
1857
- delivery_status: "sent",
1858
- review_channel_id: configuration.reviewChannelId,
1859
- review_message_id: sent.messageId,
1860
- revision: revision + 1,
1861
- updated_at: new Date(),
1862
- },
1863
- });
1864
- return stored.updated
1865
- ? { outcome: "completed" }
1866
- : {
1867
- outcome: "retry",
1868
- delayMs: 5_000,
1869
- safeCode: "review-delivery-cas",
1870
- };
1871
- }
1872
- catch {
1873
- return {
1874
- outcome: "retry",
1875
- delayMs: 30_000,
1876
- safeCode: "review-delivery-failed",
1877
- };
1878
- }
1879
- }
1880
- function validateReviewClaimRecoveryPayload(input) {
1881
- if (!input || typeof input !== "object" || Array.isArray(input))
1882
- throw new Error("Account Gate review-claim payload is invalid");
1883
- const value = input;
1884
- if (typeof value.guildId !== "string" ||
1885
- !/^\d{17,20}$/u.test(value.guildId) ||
1886
- typeof value.reviewId !== "string")
1887
- throw new Error("Account Gate review-claim payload is invalid");
1888
- return {
1889
- guildId: value.guildId,
1890
- reviewId: value.reviewId,
1891
- };
1892
- }
1893
- async function recoverReviewClaim(context, task) {
1894
- const guildId = task.payload.guildId;
1895
- const reviewId = task.payload.reviewId;
1896
- const records = context.services.get(HELYX_SERVICE_NAMES.records);
1897
- const row = await records.findOne({
1898
- moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
1899
- collection: "reviews",
1900
- select: accountGateRecords.reviews.readableFields,
1901
- where: { guild_id: guildId, review_id: reviewId },
1902
- });
1903
- if (!row || !stringValue(row.status).startsWith("claim_"))
1904
- return { outcome: "completed" };
1905
- const claimExpiresAt = dateValue(row.claim_expires_at);
1906
- if (claimExpiresAt && claimExpiresAt.getTime() > Date.now())
1907
- return {
1908
- outcome: "deferred",
1909
- delayMs: Math.max(100, claimExpiresAt.getTime() - Date.now()),
1910
- safeCode: "review-claim-not-due",
1911
- };
1912
- const generation = numberValue(row.join_generation);
1913
- const claimRevision = numberValue(row.revision);
1914
- const summary = await loadMemberSummary(records, guildId, stringValue(row.member_user_id));
1915
- const memberUserId = stringValue(row.member_user_id);
1916
- const enabled = await context.services
1917
- .get(HELYX_SERVICE_NAMES.installations)
1918
- .isModuleEnabled(guildId, ACCOUNT_GATE_IDENTITY.moduleId);
1919
- if (!enabled ||
1920
- !summary ||
1921
- numberValue(summary.join_generation) !== generation ||
1922
- (await isAccountGateWorkSuppressed(context.services, guildId, memberUserId, dateValue(row.created_at) ?? new Date(0)))) {
1923
- await finalizeReviewClaim(records, guildId, reviewId, claimRevision, "dismissed", row);
1924
- return { outcome: "completed" };
1925
- }
1926
- let configuration;
1927
- try {
1928
- configuration = await requiredRuntimeConfiguration(context.services, guildId);
1929
- }
1930
- catch {
1931
- return {
1932
- outcome: "retry",
1933
- delayMs: 30_000,
1934
- safeCode: "review-claim-setup-invalid",
1935
- };
1936
- }
1937
- const action = stringValue(row.claim_action);
1938
- if (action === "approve") {
1939
- let completionRoleOutcome = null;
1940
- if (summary.last_decision !== "passed") {
1941
- const snapshots = await context.services
1942
- .get(HELYX_SERVICE_NAMES.discordResources)
1943
- .resolveMemberSnapshots({ guildId, userIds: [memberUserId] });
1944
- const verifiedRoleAlreadyApplied = snapshots[0]?.exists === true &&
1945
- snapshots[0].roleIds.includes(configuration.verifiedRoleId);
1946
- if (!verifiedRoleAlreadyApplied &&
1947
- !(await reviewStillCurrent(context.services, guildId, memberUserId, generation, configuration.verifiedRoleId))) {
1948
- await finalizeReviewClaim(records, guildId, reviewId, claimRevision, "dismissed", row);
1949
- return { outcome: "completed" };
1950
- }
1951
- const transition = await applyRoleTransition({
1952
- guildId,
1953
- memberUserId,
1954
- verifiedRoleId: configuration.verifiedRoleId,
1955
- temporaryRoleIds: configuration.temporaryRoleIds,
1956
- resources: context.services.get(HELYX_SERVICE_NAMES.discordResources),
1957
- roles: context.services.get(HELYX_SERVICE_NAMES.memberRoles),
1958
- revalidate: () => reviewStillCurrent(context.services, guildId, memberUserId, generation, configuration.verifiedRoleId, true),
1959
- });
1960
- if (transition.outcome === "stale") {
1961
- await finalizeReviewClaim(records, guildId, reviewId, claimRevision, "dismissed", row);
1962
- return { outcome: "completed" };
1963
- }
1964
- if (transition.outcome === "blocked")
1965
- return {
1966
- outcome: "retry",
1967
- delayMs: 30_000,
1968
- safeCode: "review-approve-role-blocked",
1969
- };
1970
- if (transition.outcome === "partial")
1971
- await persistPartialTransition(context.services, guildId, memberUserId, configuration.verifiedRoleId, transition.pendingRoleIds, dateValue(row.created_at) ?? new Date(0));
1972
- if (!(await markSummaryPassed(records, guildId, memberUserId, new Date(), generation))) {
1973
- await compensateVerifiedRoleAddition(context.services, guildId, memberUserId, configuration.verifiedRoleId, transition);
1974
- await finalizeReviewClaim(records, guildId, reviewId, claimRevision, "dismissed", row);
1975
- return { outcome: "completed" };
1976
- }
1977
- completionRoleOutcome = transition.outcome;
1978
- }
1979
- const finalized = await finalizeReviewClaim(records, guildId, reviewId, claimRevision, "approved", row);
1980
- if (finalized && completionRoleOutcome)
1981
- await emitAccountGateCompletedLog(context.services, guildId, {
1982
- memberUserId,
1983
- roleOutcome: completionRoleOutcome,
1984
- idempotencyKey: `account-gate-completed:review:${reviewId}`,
1985
- });
1986
- return { outcome: "completed" };
1987
- }
1988
- if (action === "retry") {
1989
- if (summary.last_decision !== "pending") {
1990
- const reset = await resetSummaryForRetry(records, guildId, memberUserId, new Date(), generation);
1991
- if (!reset) {
1992
- await finalizeReviewClaim(records, guildId, reviewId, claimRevision, "dismissed", row);
1993
- return { outcome: "completed" };
1994
- }
1995
- }
1996
- await finalizeReviewClaim(records, guildId, reviewId, claimRevision, "retry", row);
1997
- return { outcome: "completed" };
1998
- }
1999
- if (action === "kick") {
2000
- const snapshots = await context.services
2001
- .get(HELYX_SERVICE_NAMES.discordResources)
2002
- .resolveMemberSnapshots({ guildId, userIds: [memberUserId] });
2003
- let kickOutcome = "member_missing";
2004
- let directMessage = "not_requested";
2005
- if (snapshots[0]?.exists === true) {
2006
- const result = await context.services
2007
- .get(HELYX_SERVICE_NAMES.memberEnforcement)
2008
- .kick({
2009
- guildId,
2010
- memberUserId,
2011
- moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
2012
- reason: "Access Control staff review decision recovery",
2013
- directMessage: {
2014
- embed: {
2015
- title: "Removed after an Access Control review",
2016
- description: neutralKickDirectMessage(`Discord server ${guildId}`),
2017
- color: VIOLET,
2018
- },
2019
- },
2020
- revalidate: () => reviewStillCurrent(context.services, guildId, memberUserId, generation, configuration.verifiedRoleId),
2021
- });
2022
- if (!["kicked", "member_missing"].includes(result.outcome))
2023
- if (!(await reviewStillCurrent(context.services, guildId, memberUserId, generation, configuration.verifiedRoleId))) {
2024
- await finalizeReviewClaim(records, guildId, reviewId, claimRevision, "dismissed", row);
2025
- return { outcome: "completed" };
2026
- }
2027
- else
2028
- return {
2029
- outcome: "retry",
2030
- delayMs: 30_000,
2031
- safeCode: "review-kick-not-complete",
2032
- };
2033
- kickOutcome = successfulKickOutcome(result.outcome);
2034
- directMessage = result.directMessage;
2035
- }
2036
- const finalized = await finalizeReviewClaim(records, guildId, reviewId, claimRevision, "kicked", row);
2037
- if (finalized)
2038
- await emitAccountGateKickLog(context.services, guildId, {
2039
- memberUserId,
2040
- outcome: kickOutcome,
2041
- directMessage,
2042
- source: "Staff review recovery",
2043
- idempotencyKey: `account-gate-kick:review:${reviewId}`,
2044
- });
2045
- return { outcome: "completed" };
2046
- }
2047
- await finalizeReviewClaim(records, guildId, reviewId, claimRevision, "dismissed", row);
2048
- return { outcome: "completed" };
2049
- }
2050
- async function finalizeReviewClaim(records, guildId, reviewId, claimRevision, status, row, expiredOnly = true) {
2051
- const now = new Date();
2052
- return (await records.updateOne({
2053
- moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
2054
- collection: "reviews",
2055
- where: { guild_id: guildId, review_id: reviewId },
2056
- expected: { field: "revision", value: claimRevision },
2057
- ...(expiredOnly
2058
- ? { dateAtOrBeforeNow: { field: "claim_expires_at" } }
2059
- : {}),
2060
- values: {
2061
- pending_key: `resolved:${reviewId}`,
2062
- status,
2063
- claim_action: "",
2064
- claim_expires_at: new Date(0),
2065
- decided_by_user_id: typeof row.decided_by_user_id === "string"
2066
- ? row.decided_by_user_id
2067
- : "system",
2068
- decided_at: dateValue(row.decided_at) ?? now,
2069
- revision: claimRevision + 1,
2070
- updated_at: now,
2071
- },
2072
- })).updated;
2073
- }
2074
- function validateRoleRetryPayload(input) {
2075
- if (!input || typeof input !== "object" || Array.isArray(input))
2076
- throw new Error("Account Gate role retry payload is invalid");
2077
- const value = input;
2078
- if (typeof value.guildId !== "string" ||
2079
- typeof value.transitionId !== "string" ||
2080
- value.transitionId.length > 100)
2081
- throw new Error("Account Gate role retry payload is invalid");
2082
- return { guildId: value.guildId, transitionId: value.transitionId };
2083
- }
2084
- async function retryRoleTransition(context, task) {
2085
- const records = context.services.get(HELYX_SERVICE_NAMES.records);
2086
- const row = await records.findOne({
2087
- moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
2088
- collection: "role_transitions",
2089
- select: accountGateRecords.role_transitions.readableFields,
2090
- where: {
2091
- guild_id: task.payload.guildId,
2092
- transition_id: task.payload.transitionId,
2093
- },
2094
- });
2095
- if (!row || row.status === "completed")
2096
- return { outcome: "completed" };
2097
- if (await isAccountGateWorkSuppressed(context.services, stringValue(row.guild_id), stringValue(row.member_user_id), dateValue(row.created_at) ?? new Date(0)))
2098
- return { outcome: "completed" };
2099
- const transition = await applyRoleTransition({
2100
- guildId: stringValue(row.guild_id),
2101
- memberUserId: stringValue(row.member_user_id),
2102
- verifiedRoleId: stringValue(row.verified_role_id),
2103
- temporaryRoleIds: JSON.parse(stringValue(row.pending_remove_role_ids_json)),
2104
- resources: context.services.get(HELYX_SERVICE_NAMES.discordResources),
2105
- roles: context.services.get(HELYX_SERVICE_NAMES.memberRoles),
2106
- revalidate: () => roleRetryStillCurrent(context.services, stringValue(row.guild_id), stringValue(row.member_user_id), dateValue(row.created_at) ?? new Date(0)),
2107
- });
2108
- const revision = numberValue(row.revision);
2109
- await records.updateOne({
2110
- moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
2111
- collection: "role_transitions",
2112
- where: { transition_id: task.payload.transitionId },
2113
- expected: { field: "revision", value: revision },
2114
- values: {
2115
- pending_remove_role_ids_json: JSON.stringify(transition.pendingRoleIds),
2116
- status: ["completed", "stale"].includes(transition.outcome)
2117
- ? "completed"
2118
- : transition.outcome === "blocked"
2119
- ? "review"
2120
- : "partial",
2121
- revision: revision + 1,
2122
- completed_at: ["completed", "stale"].includes(transition.outcome)
2123
- ? new Date()
2124
- : new Date(0),
2125
- updated_at: new Date(),
2126
- },
2127
- });
2128
- return transition.outcome === "partial"
2129
- ? {
2130
- outcome: "retry",
2131
- delayMs: 30_000,
2132
- safeCode: "role-removal-pending",
2133
- }
2134
- : transition.outcome === "blocked"
2135
- ? {
2136
- outcome: "requires_review",
2137
- safeCode: "role-transition-blocked",
2138
- }
2139
- : { outcome: "completed" };
2140
- }
2141
- async function persistPartialTransition(services, guildId, memberUserId, verifiedRoleId, pendingRoleIds, workCreatedAt = new Date()) {
2142
- if (await isAccountGateWorkSuppressed(services, guildId, memberUserId, workCreatedAt))
2143
- return;
2144
- const now = new Date();
2145
- const records = services.get(HELYX_SERVICE_NAMES.records);
2146
- const existing = await records.findOne({
2147
- moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
2148
- collection: "role_transitions",
2149
- select: accountGateRecords.role_transitions.readableFields,
2150
- where: { guild_id: guildId, member_user_id: memberUserId },
2151
- });
2152
- const id = existing ? stringValue(existing.transition_id) : randomUUID();
2153
- const revision = existing ? numberValue(existing.revision) + 1 : 1;
2154
- const stored = existing
2155
- ? await records.updateOne({
2156
- moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
2157
- collection: "role_transitions",
2158
- where: { guild_id: guildId, transition_id: id },
2159
- expected: {
2160
- field: "revision",
2161
- value: numberValue(existing.revision),
2162
- },
2163
- values: {
2164
- verified_role_id: verifiedRoleId,
2165
- pending_remove_role_ids_json: JSON.stringify(pendingRoleIds),
2166
- status: "partial",
2167
- revision,
2168
- next_retry_at: new Date(now.getTime() + 30_000),
2169
- updated_at: now,
2170
- },
2171
- })
2172
- : await records.appendUnique({
2173
- moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
2174
- collection: "role_transitions",
2175
- uniqueBy: ["guild_id", "member_user_id"],
2176
- values: {
2177
- transition_id: id,
2178
- guild_id: guildId,
2179
- member_user_id: memberUserId,
2180
- verified_role_id: verifiedRoleId,
2181
- pending_remove_role_ids_json: JSON.stringify(pendingRoleIds),
2182
- status: "partial",
2183
- revision,
2184
- next_retry_at: new Date(now.getTime() + 30_000),
2185
- created_at: now,
2186
- updated_at: now,
2187
- },
2188
- });
2189
- if (!("updated" in stored ? stored.updated : stored.created))
2190
- return;
2191
- await services
2192
- .get(HELYX_SERVICE_NAMES.scheduledTasks)
2193
- .schedule({
2194
- moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
2195
- taskKind: ROLE_RETRY_TASK,
2196
- guildId,
2197
- idempotencyKey: `${guildId}:${memberUserId}:role-transition:${revision}`,
2198
- payload: { guildId, transitionId: id },
2199
- scheduledFor: new Date(now.getTime() + 30_000),
2200
- });
2201
- }
2202
- async function createReview(services, guildId, memberUserId, reasonCode, state, expectedJoinGeneration, boundedChallengeTypes, workCreatedAtOverride) {
2203
- const records = services.get(HELYX_SERVICE_NAMES.records);
2204
- const summary = await loadMemberSummary(records, guildId, memberUserId);
2205
- if (!summary)
2206
- return;
2207
- const joinGeneration = numberValue(summary.join_generation);
2208
- if (expectedJoinGeneration !== undefined &&
2209
- joinGeneration !== expectedJoinGeneration)
2210
- return;
2211
- const workCreatedAt = workCreatedAtOverride ?? (state ? new Date(state.startedAt) : new Date());
2212
- if (await isAccountGateWorkSuppressed(services, guildId, memberUserId, workCreatedAt))
2213
- return;
2214
- const now = new Date();
2215
- const retentionUntil = new Date(now.getTime() + RETENTION_MS);
2216
- const reviewId = randomUUID();
2217
- const created = await services
2218
- .get(HELYX_SERVICE_NAMES.records)
2219
- .appendUnique({
2220
- moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
2221
- collection: "reviews",
2222
- uniqueBy: ["pending_key"],
2223
- values: {
2224
- review_id: reviewId,
2225
- pending_key: `pending:${guildId}:${memberUserId}`,
2226
- guild_id: guildId,
2227
- member_user_id: memberUserId,
2228
- join_generation: joinGeneration,
2229
- status: "pending",
2230
- delivery_status: "pending",
2231
- reason_code: reasonCode,
2232
- challenge_types_json: JSON.stringify(boundedChallengeTypes ?? state?.chain.map(({ type }) => type) ?? []),
2233
- failed_attempts: 0,
2234
- expired_attempts: 0,
2235
- revision: 1,
2236
- retention_until: retentionUntil,
2237
- created_at: now,
2238
- updated_at: now,
2239
- },
2240
- });
2241
- if (created.created)
2242
- await armRecordRetention(services.get(HELYX_SERVICE_NAMES.records), guildId, "reviews", "review_id", reviewId, retentionUntil);
2243
- const pending = created.created
2244
- ? { review_id: reviewId }
2245
- : await services
2246
- .get(HELYX_SERVICE_NAMES.records)
2247
- .findOne({
2248
- moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
2249
- collection: "reviews",
2250
- select: ["review_id", "delivery_status"],
2251
- where: { pending_key: `pending:${guildId}:${memberUserId}` },
2252
- });
2253
- if (pending && pending.delivery_status !== "sent")
2254
- await scheduleReviewDelivery(services, guildId, stringValue(pending.review_id), now);
2255
- await emitLog(services, guildId, {
2256
- eventId: "account-gate-review-created",
2257
- summary: "An Access Control check requires staff review.",
2258
- details: [
2259
- { label: "Member ID", value: memberUserId },
2260
- { label: "Reason", value: reasonCode },
2261
- ],
2262
- });
2263
- }
2264
- async function handleReviewDecision(context, decision) {
2265
- if (!context.serverId || !context.messageId) {
2266
- await replyError(context, "This review action is unavailable.");
2267
- return;
2268
- }
2269
- const authorised = await context.services
2270
- .get(HELYX_SERVICE_NAMES.resourceAuthorization)
2271
- .canUse({
2272
- guildId: context.serverId,
2273
- moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
2274
- permissionId: "account-gate.review",
2275
- actor: {
2276
- userId: context.userId,
2277
- roleIds: context.userRoleIds,
2278
- permissions: context.userPermissions ?? [],
2279
- isServerOwner: context.isServerOwner ?? false,
2280
- isAdministrator: context.isAdministrator,
2281
- },
2282
- });
2283
- if (!authorised) {
2284
- await replyError(context, "You are not authorised to manage Access Control reviews.");
2285
- return;
2286
- }
2287
- const records = context.services.get(HELYX_SERVICE_NAMES.records);
2288
- const row = await records.findOne({
2289
- moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
2290
- collection: "reviews",
2291
- select: accountGateRecords.reviews.readableFields,
2292
- where: {
2293
- guild_id: context.serverId,
2294
- review_message_id: context.messageId,
2295
- },
2296
- });
2297
- if (!row) {
2298
- await replyError(context, "This review record is no longer available.");
2299
- return;
2300
- }
2301
- if (row.status !== "pending") {
2302
- await context.reply({
2303
- ephemeral: true,
2304
- embed: {
2305
- title: "Review already handled",
2306
- description: `This review is already ${stringValue(row.status)}.`,
2307
- color: VIOLET,
2308
- },
2309
- });
2310
- return;
2311
- }
2312
- const memberUserId = stringValue(row.member_user_id);
2313
- if (await isAccountGateWorkSuppressed(context.services, context.serverId, memberUserId, dateValue(row.created_at) ?? new Date(0))) {
2314
- await replyError(context, "This review was removed by a privacy request. No Discord action was taken.");
2315
- return;
2316
- }
2317
- const revision = numberValue(row.revision);
2318
- const configuration = await requiredRuntimeConfiguration(context.services, context.serverId);
2319
- const reviewGeneration = numberValue(row.join_generation);
2320
- if (!(await reviewStillCurrent(context.services, context.serverId, memberUserId, reviewGeneration, configuration.verifiedRoleId))) {
2321
- await replyError(context, "This review belongs to an older join or is no longer actionable.");
2322
- return;
2323
- }
2324
- const claimStatus = `claim_${decision}`;
2325
- const claimExpiresAt = new Date(Date.now() + 2 * 60_000);
2326
- const claimed = await records.updateOne({
2327
- moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
2328
- collection: "reviews",
2329
- where: {
2330
- guild_id: context.serverId,
2331
- review_id: stringValue(row.review_id),
2332
- },
2333
- expected: { field: "revision", value: revision },
2334
- values: {
2335
- pending_key: `resolved:${stringValue(row.review_id)}`,
2336
- status: claimStatus,
2337
- claim_action: decision,
2338
- claim_expires_at: claimExpiresAt,
2339
- decided_by_user_id: context.userId,
2340
- decided_at: new Date(),
2341
- revision: revision + 1,
2342
- updated_at: new Date(),
2343
- },
2344
- schedule: {
2345
- taskKind: REVIEW_CLAIM_RECOVERY_TASK,
2346
- guildId: context.serverId,
2347
- idempotencyKey: `${context.serverId}:${stringValue(row.review_id)}:claim:${revision + 1}`,
2348
- payload: {
2349
- guildId: context.serverId,
2350
- reviewId: stringValue(row.review_id),
2351
- },
2352
- scheduledFor: claimExpiresAt,
2353
- },
2354
- });
2355
- if (!claimed.updated) {
2356
- await replyError(context, "This review changed in another session. Reload it before trying again.");
2357
- return;
2358
- }
2359
- const claimRevision = revision + 1;
2360
- if (!(await reviewStillCurrent(context.services, context.serverId, memberUserId, reviewGeneration, configuration.verifiedRoleId))) {
2361
- await finalizeReviewClaim(records, context.serverId, stringValue(row.review_id), claimRevision, "dismissed", { ...row, decided_by_user_id: context.userId, decided_at: new Date() }, false);
2362
- await replyError(context, "This review changed before the Discord action could begin.");
2363
- return;
2364
- }
2365
- if (await isAccountGateWorkSuppressed(context.services, context.serverId, memberUserId, dateValue(row.created_at) ?? new Date(0))) {
2366
- await finalizeReviewClaim(records, context.serverId, stringValue(row.review_id), claimRevision, "dismissed", { ...row, decided_by_user_id: context.userId, decided_at: new Date() }, false);
2367
- await replyError(context, "This review was removed by a privacy request. No Discord action was taken.");
2368
- return;
2369
- }
2370
- let status;
2371
- let completionRoleOutcome = null;
2372
- let kickResult = null;
2373
- if (decision === "approve") {
2374
- const transition = await applyRoleTransition({
2375
- guildId: context.serverId,
2376
- memberUserId,
2377
- verifiedRoleId: configuration.verifiedRoleId,
2378
- temporaryRoleIds: configuration.temporaryRoleIds,
2379
- resources: context.services.get(HELYX_SERVICE_NAMES.discordResources),
2380
- roles: context.services.get(HELYX_SERVICE_NAMES.memberRoles),
2381
- revalidate: () => reviewStillCurrent(context.services, context.serverId, memberUserId, reviewGeneration, configuration.verifiedRoleId, true),
2382
- });
2383
- if (transition.outcome === "stale") {
2384
- await finalizeReviewClaim(records, context.serverId, stringValue(row.review_id), claimRevision, "dismissed", { ...row, decided_by_user_id: context.userId, decided_at: new Date() }, false);
2385
- await replyError(context, "The member rejoined before approval could mutate roles; the old review was dismissed.");
2386
- return;
2387
- }
2388
- if (transition.outcome === "blocked") {
2389
- await replyError(context, "Helyx still cannot apply the access role. The claimed approval remains queued for safe recovery.");
2390
- return;
2391
- }
2392
- if (transition.outcome === "partial") {
2393
- await persistPartialTransition(context.services, context.serverId, memberUserId, configuration.verifiedRoleId, transition.pendingRoleIds, dateValue(row.created_at) ?? new Date(0));
2394
- }
2395
- if (!(await markSummaryPassed(records, context.serverId, memberUserId, new Date(), reviewGeneration))) {
2396
- await compensateVerifiedRoleAddition(context.services, context.serverId, memberUserId, configuration.verifiedRoleId, transition);
2397
- await finalizeReviewClaim(records, context.serverId, stringValue(row.review_id), claimRevision, "dismissed", { ...row, decided_by_user_id: context.userId, decided_at: new Date() }, false);
2398
- await replyError(context, "The member rejoined before approval completed; the old review was dismissed.");
2399
- return;
2400
- }
2401
- completionRoleOutcome = transition.outcome;
2402
- status = "approved";
2403
- }
2404
- else if (decision === "retry") {
2405
- const active = await context.services
2406
- .get(HELYX_SERVICE_NAMES.sessions)
2407
- .getFor(context.serverId, memberUserId, ACCOUNT_GATE_SESSION_PURPOSE);
2408
- if (active) {
2409
- await context.services
2410
- .get(HELYX_SERVICE_NAMES.sessions)
2411
- .delete(active.sessionId);
2412
- }
2413
- const reset = await resetSummaryForRetry(records, context.serverId, memberUserId, new Date(), reviewGeneration);
2414
- if (!reset) {
2415
- await finalizeReviewClaim(records, context.serverId, stringValue(row.review_id), claimRevision, "dismissed", { ...row, decided_by_user_id: context.userId, decided_at: new Date() }, false);
2416
- await replyError(context, "This review belongs to an older join and was not changed.");
2417
- return;
2418
- }
2419
- status = "retry";
2420
- }
2421
- else if (decision === "kick") {
2422
- const result = await context.services
2423
- .get(HELYX_SERVICE_NAMES.memberEnforcement)
2424
- .kick({
2425
- guildId: context.serverId,
2426
- memberUserId,
2427
- moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
2428
- reason: "Access Control staff review decision",
2429
- directMessage: {
2430
- embed: {
2431
- title: "Removed after an Access Control review",
2432
- description: neutralKickDirectMessage(`Discord server ${context.serverId}`),
2433
- color: VIOLET,
2434
- },
2435
- },
2436
- revalidate: async () => {
2437
- const [enabled, current, session] = await Promise.all([
2438
- context.services
2439
- .get(HELYX_SERVICE_NAMES.installations)
2440
- .isModuleEnabled(context.serverId, ACCOUNT_GATE_IDENTITY.moduleId),
2441
- records.findOne({
2442
- moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
2443
- collection: "reviews",
2444
- select: ["status", "revision"],
2445
- where: {
2446
- guild_id: context.serverId,
2447
- review_id: stringValue(row.review_id),
2448
- },
2449
- }),
2450
- context.services
2451
- .get(HELYX_SERVICE_NAMES.sessions)
2452
- .getFor(context.serverId, memberUserId, ACCOUNT_GATE_SESSION_PURPOSE),
2453
- ]);
2454
- return (enabled &&
2455
- current?.status === claimStatus &&
2456
- current.revision === claimRevision &&
2457
- session === null &&
2458
- (await reviewStillCurrent(context.services, context.serverId, memberUserId, reviewGeneration, configuration.verifiedRoleId)) &&
2459
- !(await isAccountGateWorkSuppressed(context.services, context.serverId, memberUserId, dateValue(row.created_at) ?? new Date(0))));
2460
- },
2461
- });
2462
- if (!["kicked", "member_missing"].includes(result.outcome)) {
2463
- await replyError(context, `The member was not kicked (${result.outcome.replaceAll("_", " ")}). The claimed decision remains queued for safe recovery.`);
2464
- return;
2465
- }
2466
- kickResult = {
2467
- outcome: successfulKickOutcome(result.outcome),
2468
- directMessage: result.directMessage,
2469
- };
2470
- status = "kicked";
2471
- }
2472
- else {
2473
- status = "dismissed";
2474
- }
2475
- const now = new Date();
2476
- const updated = await records.updateOne({
2477
- moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
2478
- collection: "reviews",
2479
- where: {
2480
- guild_id: context.serverId,
2481
- review_id: stringValue(row.review_id),
2482
- },
2483
- expected: { field: "revision", value: claimRevision },
2484
- values: {
2485
- pending_key: `resolved:${stringValue(row.review_id)}`,
2486
- status,
2487
- claim_action: "",
2488
- claim_expires_at: new Date(0),
2489
- decided_by_user_id: context.userId,
2490
- decided_at: now,
2491
- revision: claimRevision + 1,
2492
- updated_at: now,
2493
- },
2494
- audit: {
2495
- guildId: context.serverId,
2496
- actorUserId: context.userId,
2497
- action: `account_gate.review_${status}`,
2498
- source: "discord",
2499
- correlationId: context.interactionId,
2500
- idempotencyKey: context.interactionId,
2501
- targetType: "account_gate_review",
2502
- targetId: stringValue(row.review_id),
2503
- metadata: { memberUserId, decision: status },
2504
- },
2505
- });
2506
- if (!updated.updated) {
2507
- await replyError(context, "This review changed in another session. Reload it before trying again.");
2508
- return;
2509
- }
2510
- if (status === "kicked" && kickResult)
2511
- await emitAccountGateKickLog(context.services, context.serverId, {
2512
- memberUserId,
2513
- ...kickResult,
2514
- source: "Staff review",
2515
- idempotencyKey: `account-gate-kick:review:${stringValue(row.review_id)}`,
2516
- });
2517
- if (status === "approved" && completionRoleOutcome)
2518
- await emitAccountGateCompletedLog(context.services, context.serverId, {
2519
- memberUserId,
2520
- roleOutcome: completionRoleOutcome,
2521
- idempotencyKey: `account-gate-completed:review:${stringValue(row.review_id)}`,
2522
- });
2523
- if (context.channelId) {
2524
- await context.editChannelMessage(context.channelId, context.messageId, {
2525
- allowedUserMentionIds: [],
2526
- componentsV2: {
2527
- accentColor: VIOLET,
2528
- text: [
2529
- { content: "## Access Control review resolved" },
2530
- { content: `Member ID: ${memberUserId}`, markdown: false },
2531
- { content: `Decision: ${status}`, markdown: false },
2532
- ],
2533
- },
2534
- });
2535
- }
2536
- await context.reply({
2537
- ephemeral: true,
2538
- embed: {
2539
- title: "Review updated",
2540
- description: `The review is now ${status}.`,
2541
- color: GREEN,
2542
- },
2543
- });
2544
- }
2545
- async function reviewStillCurrent(services, guildId, memberUserId, joinGeneration, verifiedRoleId, allowVerifiedRole = false) {
2546
- const [enabled, summary, members] = await Promise.all([
2547
- services
2548
- .get(HELYX_SERVICE_NAMES.installations)
2549
- .isModuleEnabled(guildId, ACCOUNT_GATE_IDENTITY.moduleId),
2550
- loadMemberSummary(services.get(HELYX_SERVICE_NAMES.records), guildId, memberUserId),
2551
- services
2552
- .get(HELYX_SERVICE_NAMES.discordResources)
2553
- .resolveMemberSnapshots({ guildId, userIds: [memberUserId] }),
2554
- ]);
2555
- const member = members[0];
2556
- return (enabled &&
2557
- summary !== null &&
2558
- numberValue(summary.join_generation) === joinGeneration &&
2559
- ["review", "failed", "finalizing"].includes(stringValue(summary.last_decision)) &&
2560
- member?.exists === true &&
2561
- !member.isBot &&
2562
- (allowVerifiedRole || !member.roleIds.includes(verifiedRoleId)));
2563
- }
2564
- async function terminalPassStillCurrent(services, guildId, memberUserId, joinGeneration, workCreatedAt) {
2565
- const [enabled, summary, members, suppressed] = await Promise.all([
2566
- services
2567
- .get(HELYX_SERVICE_NAMES.installations)
2568
- .isModuleEnabled(guildId, ACCOUNT_GATE_IDENTITY.moduleId),
2569
- loadMemberSummary(services.get(HELYX_SERVICE_NAMES.records), guildId, memberUserId),
2570
- services
2571
- .get(HELYX_SERVICE_NAMES.discordResources)
2572
- .resolveMemberSnapshots({ guildId, userIds: [memberUserId] }),
2573
- isAccountGateWorkSuppressed(services, guildId, memberUserId, workCreatedAt),
2574
- ]);
2575
- const member = members[0];
2576
- return (enabled &&
2577
- !suppressed &&
2578
- summary !== null &&
2579
- numberValue(summary.join_generation) === joinGeneration &&
2580
- summary.last_decision === "finalizing" &&
2581
- member?.exists === true &&
2582
- !member.isBot);
2583
- }
2584
- async function compensateVerifiedRoleAddition(services, guildId, memberUserId, verifiedRoleId, transition) {
2585
- if (!transition.accessRoleAdded)
2586
- return;
2587
- await services
2588
- .get(HELYX_SERVICE_NAMES.memberRoles)
2589
- .removeApprovedRole({
2590
- guildId,
2591
- memberUserId,
2592
- roleId: verifiedRoleId,
2593
- moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
2594
- reason: "Access Control stale-generation compensation",
2595
- });
2596
- }
2597
- async function roleRetryStillCurrent(services, guildId, memberUserId, workCreatedAt) {
2598
- const [enabled, summary, members, suppressed] = await Promise.all([
2599
- services
2600
- .get(HELYX_SERVICE_NAMES.installations)
2601
- .isModuleEnabled(guildId, ACCOUNT_GATE_IDENTITY.moduleId),
2602
- loadMemberSummary(services.get(HELYX_SERVICE_NAMES.records), guildId, memberUserId),
2603
- services
2604
- .get(HELYX_SERVICE_NAMES.discordResources)
2605
- .resolveMemberSnapshots({ guildId, userIds: [memberUserId] }),
2606
- isAccountGateWorkSuppressed(services, guildId, memberUserId, workCreatedAt),
2607
- ]);
2608
- return (enabled &&
2609
- !suppressed &&
2610
- summary?.last_decision === "passed" &&
2611
- members[0]?.exists === true &&
2612
- !members[0].isBot);
2613
- }
2614
- function reviewMessage(memberUserId, reasonCode) {
2615
- return {
2616
- allowedUserMentionIds: [memberUserId],
2617
- componentsV2: {
2618
- accentColor: VIOLET,
2619
- text: [
2620
- { content: "## Access Control review" },
2621
- { content: `Member: <@${memberUserId}> (${memberUserId})` },
2622
- { content: `Reason: ${reasonCode}`, markdown: false },
2623
- {
2624
- content: "Challenge answers and Password Entry content are not included.",
2625
- markdown: false,
2626
- },
2627
- ],
2628
- },
2629
- buttons: [
2630
- {
2631
- type: "button",
2632
- customId: ACCOUNT_GATE_COMPONENTS.review.approve,
2633
- label: "Approve",
2634
- style: "primary",
2635
- },
2636
- {
2637
- type: "button",
2638
- customId: ACCOUNT_GATE_COMPONENTS.review.retry,
2639
- label: "Retry",
2640
- style: "secondary",
2641
- },
2642
- {
2643
- type: "button",
2644
- customId: ACCOUNT_GATE_COMPONENTS.review.kick,
2645
- label: "Kick",
2646
- style: "danger",
2647
- },
2648
- {
2649
- type: "button",
2650
- customId: ACCOUNT_GATE_COMPONENTS.review.dismiss,
2651
- label: "Dismiss",
2652
- style: "secondary",
2653
- },
2654
- ],
2655
- };
2656
- }
2657
- export async function validateAccountGateSetup(services, guildId, configuration, throwOnError = true, allowMissingPanel = false) {
2658
- const resources = services.get(HELYX_SERVICE_NAMES.discordResources);
2659
- const records = services.get(HELYX_SERVICE_NAMES.records);
2660
- const verificationEnabled = await services
2661
- .get(HELYX_SERVICE_NAMES.installations)
2662
- .isModuleEnabled(guildId, "helyx.verification");
2663
- const [gateChannel, gateAudience, guildCapabilities, verifiedRole, capabilities,] = await Promise.all([
2664
- resources.inspectChannel({
2665
- guildId,
2666
- channelId: configuration.gateChannelId,
2667
- }),
2668
- resources.inspectChannelAudience({
2669
- guildId,
2670
- channelId: configuration.gateChannelId,
2671
- roleIds: configuration.temporaryRoleIds,
2672
- }),
2673
- resources.inspectGuildCapabilities({ guildId }),
2674
- resources.inspectRole({
2675
- guildId,
2676
- roleId: configuration.verifiedRoleId,
2677
- }),
2678
- resources.inspectRuntimeCapabilities(),
2679
- ]);
2680
- const errors = [...validateRuntimeCapabilities(configuration, capabilities)];
2681
- const panel = await records.findOne({
2682
- moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
2683
- collection: "gate_panels",
2684
- select: accountGateRecords.gate_panels.readableFields,
2685
- where: { guild_id: guildId },
2686
- });
2687
- if (!panel) {
2688
- if (!allowMissingPanel)
2689
- errors.push("The managed Access Control panel has not been posted. Post or repair it before processing joins.");
2690
- }
2691
- else {
2692
- const panelMessage = await resources.inspectMessage({
2693
- guildId,
2694
- channelId: stringValue(panel.channel_id),
2695
- messageId: stringValue(panel.message_id),
2696
- });
2697
- if (!panelMessage.exists ||
2698
- !panelMessage.guildMatches ||
2699
- !panelMessage.channelMatches)
2700
- errors.push("The managed Access Control panel message is missing. Repost it before processing joins.");
2701
- }
2702
- if (verificationEnabled)
2703
- errors.push("Helyx Verification already owns this server's entry-role workflow. Disable it before enabling Access Control.");
2704
- if (!gateChannel.viewable || !gateChannel.sendMessages)
2705
- errors.push("Helyx cannot view and send messages in the gate channel.");
2706
- if (!gateAudience.everyoneCanView &&
2707
- (gateAudience.roles.length === 0 ||
2708
- gateAudience.roles.some(({ exists, canView }) => !exists || !canView)))
2709
- errors.push("The gate channel must be visible to @everyone or every configured temporary member role.");
2710
- if (!guildCapabilities.manageRoles)
2711
- errors.push("Access Control requires Manage Roles.");
2712
- if (!verifiedRole.assignable)
2713
- errors.push("The access role is missing or is above Helyx.");
2714
- for (const roleId of configuration.temporaryRoleIds) {
2715
- const role = await resources.inspectRole({ guildId, roleId });
2716
- if (!role.exists || !role.assignable)
2717
- errors.push(`Temporary role ${roleId} is missing or is above Helyx.`);
2718
- }
2719
- if (configuration.reviewChannelId) {
2720
- const [reviewChannel, reviewAudience] = await Promise.all([
2721
- resources.inspectChannel({
2722
- guildId,
2723
- channelId: configuration.reviewChannelId,
2724
- }),
2725
- resources.inspectChannelAudience({
2726
- guildId,
2727
- channelId: configuration.reviewChannelId,
2728
- roleIds: configuration.temporaryRoleIds,
2729
- }),
2730
- ]);
2731
- if (!reviewChannel.viewable || !reviewChannel.sendMessages)
2732
- errors.push("Helyx cannot use the configured staff review channel.");
2733
- if (reviewAudience.everyoneCanView ||
2734
- reviewAudience.roles.some(({ canView }) => canView))
2735
- errors.push("The staff review channel must not be visible to @everyone or temporary pre-gate roles.");
2736
- }
2737
- if (configuration.failureAction === "kick" && !guildCapabilities.kickMembers)
2738
- errors.push("The kick outcome requires Kick Members.");
2739
- if (configuration.enabledChallengeTypes.includes("password_entry")) {
2740
- const [passwordChannel, passwordAudience] = await Promise.all([
2741
- resources.inspectChannel({
2742
- guildId,
2743
- channelId: configuration.passwordChannelId,
2744
- }),
2745
- resources.inspectChannelAudience({
2746
- guildId,
2747
- channelId: configuration.passwordChannelId,
2748
- roleIds: configuration.temporaryRoleIds,
2749
- }),
2750
- ]);
2751
- if (!passwordChannel.viewable ||
2752
- !passwordChannel.readMessageHistory ||
2753
- !passwordChannel.manageMessages)
2754
- errors.push("Password Entry requires View Channel, Read Message History and Manage Messages.");
2755
- if (passwordAudience.everyoneCanView)
2756
- errors.push("Password Entry must not be visible to @everyone.");
2757
- if (configuration.temporaryRoleIds.length === 0 ||
2758
- !configuration.temporaryRoleIds.every((roleId) => passwordAudience.roles.some((role) => role.roleId === roleId && role.exists && role.canView)))
2759
- errors.push("Password Entry requires every configured temporary pre-gate role to view its isolated channel.");
2760
- const store = services.get(HELYX_SERVICE_NAMES.records);
2761
- if (!(await hasPasswordGatePanel(store, guildId)))
2762
- errors.push("Password Entry needs a published panel with a password.");
2763
- }
2764
- if (configuration.enabledChallengeTypes.includes("server_question") &&
2765
- (await loadQuestions(services, guildId)).length === 0)
2766
- errors.push("Server Question requires at least one enabled question.");
2767
- if (configuration.enabledChallengeTypes.includes("emoji_entry")) {
2768
- const [message, emoji, channel] = await Promise.all([
2769
- resources.inspectMessage({
2770
- guildId,
2771
- channelId: configuration.emojiChannelId,
2772
- messageId: configuration.emojiMessageId,
2773
- }),
2774
- resources.inspectEmoji({
2775
- guildId,
2776
- emojiIdentifier: configuration.emojiIdentifier,
2777
- }),
2778
- resources.inspectChannel({
2779
- guildId,
2780
- channelId: configuration.emojiChannelId,
2781
- }),
2782
- ]);
2783
- if (!message.exists || !message.guildMatches || !message.channelMatches)
2784
- errors.push("The Emoji Entry message is unavailable.");
2785
- if (!emoji.exists || !emoji.usable)
2786
- errors.push("The Emoji Entry emoji is unavailable.");
2787
- if (!channel.viewable ||
2788
- !channel.readMessageHistory ||
2789
- !channel.addReactions)
2790
- errors.push("Emoji Entry requires View Channel, Read Message History and Add Reactions.");
2791
- if (errors.length === 0 && throwOnError)
2792
- await resources.seedMessageReaction({
2793
- guildId,
2794
- channelId: configuration.emojiChannelId,
2795
- messageId: configuration.emojiMessageId,
2796
- emojiIdentifier: configuration.emojiIdentifier,
2797
- });
2798
- }
2799
- if (throwOnError && errors.length > 0)
2800
- throw new DashboardActionValidationError(errors[0]);
2801
- return errors;
2802
- }
2803
- function challengeMessage(state, binding, options = {}) {
2804
- const challenge = state.chain[state.currentStep];
2805
- if (!challenge) {
2806
- return {
2807
- ephemeral: true,
2808
- componentsV2: {
2809
- accentColor: GREEN,
2810
- text: [
2811
- { content: "## Check complete" },
2812
- {
2813
- content: "Your Access Control check is complete.",
2814
- markdown: false,
2815
- },
2816
- ],
2817
- },
2818
- };
2819
- }
2820
- const base = {
2821
- ephemeral: true,
2822
- componentsV2: {
2823
- accentColor: VIOLET,
2824
- text: [
2825
- { content: "## Complete Access Control" },
2826
- {
2827
- content: options.redactRecallCode && challenge.type === "modal_recall"
2828
- ? "Remember this code: **-****"
2829
- : challenge.prompt,
2830
- markdown: false,
2831
- },
2832
- {
2833
- content: `Challenge ${state.currentStep + 1} of ${state.chain.length}`,
2834
- markdown: false,
2835
- },
2836
- ],
2837
- },
2838
- };
2839
- switch (challenge.type) {
2840
- case "quick_sum":
2841
- case "number_order":
2842
- case "symbol_match":
2843
- case "server_question":
2844
- return {
2845
- ...base,
2846
- buttons: [
2847
- ...challenge.options.map((label, index) => ({
2848
- type: "button",
2849
- customId: boundComponentId(ACCOUNT_GATE_COMPONENTS.answerPrefix, {
2850
- ...binding,
2851
- step: state.step,
2852
- value: String(index),
2853
- }),
2854
- label,
2855
- style: "secondary",
2856
- ...(challenge.type === "number_order" &&
2857
- challenge.expectedSequence
2858
- .slice(0, challenge.progress)
2859
- .includes(index)
2860
- ? { disabled: true }
2861
- : {}),
2862
- })),
2863
- {
2864
- type: "button",
2865
- customId: boundComponentId(ACCOUNT_GATE_COMPONENTS.rerollPrefix, {
2866
- ...binding,
2867
- step: state.step,
2868
- }),
2869
- label: "Different challenge",
2870
- style: "secondary",
2871
- },
2872
- ],
2873
- };
2874
- case "button_sequence":
2875
- return {
2876
- ...base,
2877
- buttons: [
2878
- {
2879
- type: "button",
2880
- customId: boundComponentId(ACCOUNT_GATE_COMPONENTS.sequencePrefix, {
2881
- ...binding,
2882
- step: state.step,
2883
- value: "red",
2884
- }),
2885
- label: "🔴 Red",
2886
- style: "secondary",
2887
- },
2888
- {
2889
- type: "button",
2890
- customId: boundComponentId(ACCOUNT_GATE_COMPONENTS.sequencePrefix, {
2891
- ...binding,
2892
- step: state.step,
2893
- value: "blue",
2894
- }),
2895
- label: "🔵 Blue",
2896
- style: "secondary",
2897
- },
2898
- {
2899
- type: "button",
2900
- customId: boundComponentId(ACCOUNT_GATE_COMPONENTS.sequencePrefix, {
2901
- ...binding,
2902
- step: state.step,
2903
- value: "green",
2904
- }),
2905
- label: "🟢 Green",
2906
- style: "secondary",
2907
- },
2908
- ],
2909
- };
2910
- case "modal_recall":
2911
- return {
2912
- ...base,
2913
- buttons: [
2914
- {
2915
- type: "button",
2916
- customId: boundComponentId(ACCOUNT_GATE_COMPONENTS.recallOpenPrefix, { ...binding, step: state.step }),
2917
- label: "Enter code",
2918
- style: "primary",
2919
- },
2920
- ],
2921
- };
2922
- case "password_entry":
2923
- case "emoji_entry":
2924
- return base;
2925
- }
2926
- }
2927
- async function loadQuestions(services, guildId) {
2928
- const rows = await services
2929
- .get(HELYX_SERVICE_NAMES.records)
2930
- .findMany({
2931
- moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
2932
- collection: "questions",
2933
- select: accountGateRecords.questions.readableFields,
2934
- where: { guild_id: guildId, enabled: true },
2935
- orderBy: [
2936
- { field: "guild_id", direction: "asc" },
2937
- { field: "sort_order", direction: "asc" },
2938
- { field: "question_id", direction: "asc" },
2939
- ],
2940
- limit: 100,
2941
- });
2942
- return rows.records.map((row) => validateServerQuestion({
2943
- id: row.question_id,
2944
- question: row.question_text,
2945
- answers: JSON.parse(stringValue(row.answers_json)),
2946
- correctOption: row.correct_option_index,
2947
- }));
2948
- }
2949
- async function currentStrictMode(services, guildId) {
2950
- const row = await services
2951
- .get(HELYX_SERVICE_NAMES.records)
2952
- .findOne({
2953
- moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
2954
- collection: "strict_modes",
2955
- select: accountGateRecords.strict_modes.readableFields,
2956
- where: { guild_id: guildId, status: "active" },
2957
- });
2958
- return !!row && isStrictModeActive(dateValue(row.ends_at));
2959
- }
2960
- async function getConfiguration(services, guildId) {
2961
- const record = await services
2962
- .get(HELYX_SERVICE_NAMES.configuration)
2963
- .get(guildId, ACCOUNT_GATE_IDENTITY.moduleId);
2964
- return record ? parseAccountGateConfiguration(record.value) : null;
2965
- }
2966
- async function requiredRuntimeConfiguration(services, guildId) {
2967
- const configuration = await getConfiguration(services, guildId);
2968
- if (!configuration)
2969
- throw new DashboardActionValidationError("Save the Access Control settings first.");
2970
- const errors = await validateAccountGateSetup(services, guildId, configuration, false);
2971
- if (errors.length > 0)
2972
- throw new DashboardActionValidationError(errors[0]);
2973
- return configuration;
2974
- }
2975
- function secureRandom(services) {
2976
- const random = services.get(HELYX_SERVICE_NAMES.secureRandom);
2977
- return {
2978
- integer(maximumExclusive, operationContext) {
2979
- return random.boundedInteger({ maximumExclusive, operationContext })
2980
- .value;
2981
- },
2982
- };
2983
- }
2984
- async function replyError(context, description) {
2985
- await context.reply({
2986
- ephemeral: true,
2987
- componentsV2: {
2988
- accentColor: RED,
2989
- text: [
2990
- { content: "## Account gate unavailable" },
2991
- { content: description, markdown: false },
2992
- ],
2993
- },
2994
- });
2995
- }
2996
- function stringValue(value) {
2997
- if (typeof value !== "string")
2998
- throw new Error("Account Gate record is invalid");
2999
- return value;
3000
- }
3001
- function numberValue(value) {
3002
- if (typeof value !== "number" || !Number.isSafeInteger(value))
3003
- throw new Error("Account Gate record is invalid");
3004
- return value;
3005
- }
3006
- function dateValue(value) {
3007
- return value instanceof Date ? value : null;
3008
- }
3009
- function canonicalEmojiKey(value) {
3010
- if (/^(?:custom|unicode):/u.test(value))
3011
- return value.normalize("NFKC");
3012
- const custom = /^([A-Za-z0-9_]{2,32}):(\d{17,20})$/u.exec(value);
3013
- return custom ? `custom:${custom[2]}` : `unicode:${value.normalize("NFKC")}`;
3014
- }
3015
- export function boundComponentId(prefix, binding) {
3016
- return `${prefix}${binding.sessionId}:${String(binding.version)}:${binding.step}${binding.value === undefined ? "" : `:${binding.value}`}`;
3017
- }
3018
- export function parseComponentBinding(customId, prefix) {
3019
- if (!customId?.startsWith(prefix))
3020
- return null;
3021
- const [sessionId, version, step, value] = customId
3022
- .slice(prefix.length)
3023
- .split(":");
3024
- if (!sessionId ||
3025
- !version ||
3026
- !/^\d+$/u.test(version) ||
3027
- !step ||
3028
- !/^\d+$/u.test(step))
3029
- return null;
3030
- return {
3031
- sessionId,
3032
- version: BigInt(version),
3033
- step: Number(step),
3034
- ...(value === undefined ? {} : { value }),
3035
- };
3036
- }
3037
- export function staticResumeMessage(guildId, gateChannelId) {
3038
- return {
3039
- embed: {
3040
- title: "Continue your Access Control check",
3041
- description: `Your existing check for Discord server ${guildId} is ready to continue. Return to <#${gateChannelId}> and press **Start check** to resume it.`,
3042
- color: VIOLET,
3043
- },
3044
- allowedUserMentionIds: [],
3045
- };
3046
- }
3047
- async function loadMemberSummary(records, guildId, memberUserId) {
3048
- return records.findOne({
3049
- moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
3050
- collection: "member_summaries",
3051
- select: accountGateRecords.member_summaries.readableFields,
3052
- where: { guild_id: guildId, member_user_id: memberUserId },
3053
- });
3054
- }
3055
- async function isCurrentPendingMember(services, guildId, memberUserId, verifiedRoleId) {
3056
- const [summary, members] = await Promise.all([
3057
- loadMemberSummary(services.get(HELYX_SERVICE_NAMES.records), guildId, memberUserId),
3058
- services
3059
- .get(HELYX_SERVICE_NAMES.discordResources)
3060
- .resolveMemberSnapshots({ guildId, userIds: [memberUserId] }),
3061
- ]);
3062
- const member = members[0];
3063
- return (summary?.last_decision === "pending" &&
3064
- member?.exists === true &&
3065
- !member.isBot &&
3066
- !member.roleIds.includes(verifiedRoleId));
3067
- }
3068
- function summaryValues(input) {
3069
- return {
3070
- summary_id: typeof input.previous?.summary_id === "string"
3071
- ? input.previous.summary_id
3072
- : randomUUID(),
3073
- guild_id: input.guildId,
3074
- member_user_id: input.memberUserId,
3075
- previous_success_at: input.previousSuccessAt ??
3076
- dateValue(input.previous?.previous_success_at) ??
3077
- new Date(0),
3078
- failure_count: input.failureCount,
3079
- lockout_until: input.lockoutUntil,
3080
- rejoin_count: input.rejoinCount,
3081
- join_generation: input.joinGeneration,
3082
- account_created_at: input.accountCreatedAt,
3083
- joined_at: input.joinedAt,
3084
- deadline_at: input.deadlineAt,
3085
- pending_reason: input.pendingReason,
3086
- last_failure_terminal_id: input.lastFailureTerminalId ??
3087
- (typeof input.previous?.last_failure_terminal_id === "string"
3088
- ? input.previous.last_failure_terminal_id
3089
- : ""),
3090
- left_at: input.leftAt ?? new Date(0),
3091
- last_decision: input.lastDecision,
3092
- revision: input.previous ? numberValue(input.previous.revision) + 1 : 1,
3093
- retention_until: new Date(input.now.getTime() + RETENTION_MS),
3094
- created_at: dateValue(input.previous?.created_at) ?? input.now,
3095
- updated_at: input.now,
3096
- };
3097
- }
3098
- function summaryUpdateValues(values) {
3099
- return {
3100
- previous_success_at: values.previous_success_at,
3101
- failure_count: values.failure_count,
3102
- lockout_until: values.lockout_until,
3103
- rejoin_count: values.rejoin_count,
3104
- join_generation: values.join_generation,
3105
- account_created_at: values.account_created_at,
3106
- joined_at: values.joined_at,
3107
- deadline_at: values.deadline_at,
3108
- pending_reason: values.pending_reason,
3109
- last_failure_terminal_id: values.last_failure_terminal_id,
3110
- left_at: values.left_at,
3111
- last_decision: values.last_decision,
3112
- revision: values.revision,
3113
- retention_until: values.retention_until,
3114
- updated_at: values.updated_at,
3115
- };
3116
- }
3117
- async function setSummaryDecision(records, guildId, memberUserId, generation, decision, expectedDecision = "pending") {
3118
- const row = await loadMemberSummary(records, guildId, memberUserId);
3119
- if (!row ||
3120
- numberValue(row.join_generation) !== generation ||
3121
- row.last_decision !== expectedDecision)
3122
- return false;
3123
- return (await records.updateOne({
3124
- moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
3125
- collection: "member_summaries",
3126
- where: { guild_id: guildId, member_user_id: memberUserId },
3127
- expected: { field: "revision", value: numberValue(row.revision) },
3128
- values: {
3129
- last_decision: decision,
3130
- revision: numberValue(row.revision) + 1,
3131
- updated_at: new Date(),
3132
- },
3133
- })).updated;
3134
- }
3135
- async function recordFailureSummary(records, guildId, memberUserId, now, cooldownSeconds, attemptLimit, terminalId, expectedGeneration) {
3136
- for (let retry = 0; retry < 4; retry += 1) {
3137
- const previous = await loadMemberSummary(records, guildId, memberUserId);
3138
- if (!previous ||
3139
- numberValue(previous.join_generation) !== expectedGeneration)
3140
- return null;
3141
- if (previous?.last_failure_terminal_id === terminalId)
3142
- return numberValue(previous.failure_count);
3143
- const failureCount = (previous ? numberValue(previous.failure_count) : 0) + 1;
3144
- const requestedLockout = new Date(now.getTime() + cooldownSeconds * 1_000);
3145
- const existingLockout = dateValue(previous?.lockout_until);
3146
- const lockoutUntil = existingLockout && existingLockout.getTime() > requestedLockout.getTime()
3147
- ? existingLockout
3148
- : requestedLockout;
3149
- const values = summaryValues({
3150
- guildId,
3151
- memberUserId,
3152
- now,
3153
- previous,
3154
- failureCount,
3155
- lockoutUntil,
3156
- rejoinCount: previous ? numberValue(previous.rejoin_count) : 0,
3157
- joinGeneration: expectedGeneration,
3158
- accountCreatedAt: dateValue(previous?.account_created_at) ?? now,
3159
- joinedAt: dateValue(previous?.joined_at) ?? now,
3160
- deadlineAt: dateValue(previous?.deadline_at) ?? now,
3161
- pendingReason: previous?.pending_reason === "account_too_new"
3162
- ? "account_too_new"
3163
- : "none",
3164
- lastDecision: failureCount >= attemptLimit ? "failed" : "pending",
3165
- lastFailureTerminalId: terminalId,
3166
- });
3167
- const result = await records.updateOne({
3168
- moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
3169
- collection: "member_summaries",
3170
- where: { guild_id: guildId, member_user_id: memberUserId },
3171
- expected: {
3172
- field: "revision",
3173
- value: numberValue(previous.revision),
3174
- },
3175
- values: summaryUpdateValues(values),
3176
- });
3177
- if (result.updated)
3178
- return failureCount;
3179
- }
3180
- const current = await loadMemberSummary(records, guildId, memberUserId);
3181
- return current &&
3182
- numberValue(current.join_generation) === expectedGeneration &&
3183
- current.last_failure_terminal_id === terminalId
3184
- ? numberValue(current.failure_count)
3185
- : null;
3186
- }
3187
- async function markSummaryPassed(records, guildId, memberUserId, now, expectedGeneration) {
3188
- const previous = await loadMemberSummary(records, guildId, memberUserId);
3189
- if (!previous || numberValue(previous.join_generation) !== expectedGeneration)
3190
- return false;
3191
- const values = summaryValues({
3192
- guildId,
3193
- memberUserId,
3194
- now,
3195
- previous,
3196
- previousSuccessAt: now,
3197
- failureCount: 0,
3198
- lockoutUntil: new Date(0),
3199
- rejoinCount: previous ? numberValue(previous.rejoin_count) : 0,
3200
- joinGeneration: expectedGeneration,
3201
- accountCreatedAt: dateValue(previous?.account_created_at) ?? now,
3202
- joinedAt: dateValue(previous?.joined_at) ?? now,
3203
- deadlineAt: dateValue(previous?.deadline_at) ?? now,
3204
- pendingReason: "none",
3205
- lastDecision: "passed",
3206
- });
3207
- return (await records.updateOne({
3208
- moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
3209
- collection: "member_summaries",
3210
- where: { guild_id: guildId, member_user_id: memberUserId },
3211
- expected: { field: "revision", value: numberValue(previous.revision) },
3212
- values: summaryUpdateValues(values),
3213
- })).updated;
3214
- }
3215
- async function resetSummaryForRetry(records, guildId, memberUserId, now, expectedGeneration) {
3216
- const previous = await loadMemberSummary(records, guildId, memberUserId);
3217
- if (!previous || numberValue(previous.join_generation) !== expectedGeneration)
3218
- return false;
3219
- const values = summaryValues({
3220
- guildId,
3221
- memberUserId,
3222
- now,
3223
- previous,
3224
- failureCount: 0,
3225
- lockoutUntil: new Date(0),
3226
- rejoinCount: previous ? numberValue(previous.rejoin_count) : 0,
3227
- joinGeneration: expectedGeneration,
3228
- accountCreatedAt: dateValue(previous?.account_created_at) ?? now,
3229
- joinedAt: dateValue(previous?.joined_at) ?? now,
3230
- deadlineAt: dateValue(previous?.deadline_at) ?? now,
3231
- pendingReason: previous?.pending_reason === "account_too_new"
3232
- ? "account_too_new"
3233
- : "none",
3234
- lastDecision: "pending",
3235
- });
3236
- return (await records.updateOne({
3237
- moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
3238
- collection: "member_summaries",
3239
- where: { guild_id: guildId, member_user_id: memberUserId },
3240
- expected: { field: "revision", value: numberValue(previous.revision) },
3241
- values: summaryUpdateValues(values),
3242
- })).updated;
3243
- }
3244
- async function scheduleJoinDeadline(services, guildId, memberUserId, generation, deadlineAt) {
3245
- await services
3246
- .get(HELYX_SERVICE_NAMES.scheduledTasks)
3247
- .schedule({
3248
- moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
3249
- taskKind: JOIN_DEADLINE_TASK,
3250
- guildId,
3251
- idempotencyKey: `${guildId}:${memberUserId}:join:${generation}`,
3252
- payload: {
3253
- guildId,
3254
- memberUserId,
3255
- },
3256
- scheduledFor: deadlineAt,
3257
- });
3258
- }
3259
- async function scheduleSessionExpiry(services, input) {
3260
- await services
3261
- .get(HELYX_SERVICE_NAMES.scheduledTasks)
3262
- .schedule({
3263
- moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
3264
- taskKind: SESSION_EXPIRY_TASK,
3265
- guildId: input.guildId,
3266
- idempotencyKey: `${input.sessionId}:expiry`,
3267
- payload: {
3268
- guildId: input.guildId,
3269
- userId: input.userId,
3270
- sessionId: input.sessionId,
3271
- },
3272
- scheduledFor: input.expiredAt,
3273
- });
3274
- }
3275
- async function scheduleReviewDelivery(services, guildId, reviewId, scheduledFor) {
3276
- await services
3277
- .get(HELYX_SERVICE_NAMES.scheduledTasks)
3278
- .schedule({
3279
- moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
3280
- taskKind: REVIEW_DELIVERY_TASK,
3281
- guildId,
3282
- idempotencyKey: `${guildId}:${reviewId}:delivery`,
3283
- payload: { guildId, reviewId },
3284
- scheduledFor,
3285
- });
3286
- }
3287
- async function scheduleTerminalFinalization(services, guildId, finalizationId, scheduledFor) {
3288
- await services
3289
- .get(HELYX_SERVICE_NAMES.scheduledTasks)
3290
- .schedule({
3291
- moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
3292
- taskKind: TERMINAL_FINALIZATION_TASK,
3293
- guildId,
3294
- idempotencyKey: `${guildId}:${finalizationId}:terminal`,
3295
- payload: { guildId, finalizationId },
3296
- scheduledFor,
3297
- });
3298
- }
3299
- async function armRecordRetention(records, guildId, collection, idField, id, scheduledFor) {
3300
- await records.updateOne({
3301
- moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
3302
- collection,
3303
- where: { [idField]: id },
3304
- expected: { field: idField, value: id },
3305
- values: { retention_until: scheduledFor },
3306
- retention: [
3307
- {
3308
- collection,
3309
- where: { guild_id: guildId, [idField]: id },
3310
- scheduledFor,
3311
- idempotencyKey: `account-gate-retention:${collection}:${id}`,
3312
- audit: {
3313
- action: `account_gate.${collection}_retention`,
3314
- targetType: `account_gate_${collection}`,
3315
- targetId: id,
3316
- },
3317
- },
3318
- ],
3319
- });
3320
- }
194
+ export { hasPreviousSuccess, isAccountTooYoung } from "./member-lifecycle.js";
195
+ export { recordAccountGateAttempt, resumeRevokedMember, } from "./member-restart.js";
196
+ export { ensureTerminalFinalization };
197
+ export { validateAccountGateSetup };
198
+ export { boundComponentId, parseComponentBinding, staticResumeMessage, } from "./runtime-support.js";
3321
199
  //# sourceMappingURL=index.js.map