@helyx/module-account-gate 1.0.14 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/CHANGELOG.md +26 -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 +9 -2
  68. package/migrations/0002_panel_mutation_claims.sql +34 -0
  69. package/package.json +5 -4
@@ -0,0 +1,199 @@
1
+ import { HELYX_SERVICE_NAMES, generateChallengeChain, ACCOUNT_GATE_COMPONENTS, ACCOUNT_GATE_SESSION_PURPOSE, validateRuntimeCapabilities, getGatePanelRuntime, createAccountGateSessionState, parseAccountGateSessionState, VIOLET, GREEN, RED, } from "./runtime-dependencies.js";
2
+ import { resumeRevokedMember } from "./member-restart.js";
3
+ import { loadMemberSummary } from "./member-summary.js";
4
+ import { dateValue, numberValue, replyError, secureRandom, } from "./runtime-support.js";
5
+ import { challengeMessage, configurationForPanel, currentStrictMode, getConfiguration, loadQuestions, validateAccountGateSetup, } from "./setup-presentation.js";
6
+ import { scheduleSessionExpiry } from "./task-scheduling.js";
7
+ import { ensureTerminalFinalization, finalizeTerminalFinalization, } from "./terminal-finalization.js";
8
+ export async function startCheck(context) {
9
+ if (!context.serverId) {
10
+ await replyError(context, "Access Control only works in a Discord server.");
11
+ return;
12
+ }
13
+ const configuration = await getConfiguration(context.services, context.serverId);
14
+ if (!configuration) {
15
+ await replyError(context, "Access Control has not been configured by the server team.");
16
+ return;
17
+ }
18
+ const panelId = startPanelId(context.customId);
19
+ if (context.customId?.startsWith(ACCOUNT_GATE_COMPONENTS.startPrefix) &&
20
+ panelId === null) {
21
+ await replyError(context, "This Access Control panel control is invalid.");
22
+ return;
23
+ }
24
+ const panel = panelId === null
25
+ ? null
26
+ : await getGatePanelRuntime(context.services.get(HELYX_SERVICE_NAMES.records), context.serverId, panelId);
27
+ if (panelId !== null && !panel) {
28
+ await replyError(context, "This Access Control panel is no longer available. Ask the server team to publish or repair it.");
29
+ return;
30
+ }
31
+ if (panel?.accessType === "password" && !panel.password) {
32
+ await replyError(context, "This Password Entry panel does not have a password. Ask the server team to update it.");
33
+ return;
34
+ }
35
+ if (panel?.accessType === "challenges" &&
36
+ configuration.accessType !== "challenges") {
37
+ await replyError(context, "This Challenge panel is not currently active. Ask the server team to publish the current Access Control setup.");
38
+ return;
39
+ }
40
+ const activeConfiguration = panel
41
+ ? configurationForPanel(configuration, panel.accessType)
42
+ : configuration;
43
+ const setupErrors = await validateAccountGateSetup(context.services, context.serverId, activeConfiguration, false);
44
+ if (setupErrors.length > 0) {
45
+ await replyError(context, setupErrors[0]);
46
+ return;
47
+ }
48
+ const resources = context.services.get(HELYX_SERVICE_NAMES.discordResources);
49
+ let summary = await loadMemberSummary(context.services.get(HELYX_SERVICE_NAMES.records), context.serverId, context.userId);
50
+ const [member] = await resources.resolveMemberSnapshots({
51
+ guildId: context.serverId,
52
+ userIds: [context.userId],
53
+ });
54
+ if (summary?.last_decision === "revoked" &&
55
+ member?.exists &&
56
+ !member.isBot &&
57
+ !member.roleIds.includes(activeConfiguration.verifiedRoleId)) {
58
+ summary = await resumeRevokedMember(context.services, context.serverId, context.userId, summary, activeConfiguration, context.interactionId);
59
+ if (!summary) {
60
+ await replyError(context, "The revoked Access Control state changed while this check was starting. Press Start check again.");
61
+ return;
62
+ }
63
+ }
64
+ if (!summary ||
65
+ summary.last_decision !== "pending" ||
66
+ !member?.exists ||
67
+ member.isBot ||
68
+ member.roleIds.includes(activeConfiguration.verifiedRoleId)) {
69
+ await replyError(context, "There is no current pending Access Control check for this member.");
70
+ return;
71
+ }
72
+ const capabilityErrors = validateRuntimeCapabilities(activeConfiguration, await resources.inspectRuntimeCapabilities());
73
+ if (capabilityErrors.length > 0) {
74
+ await replyError(context, capabilityErrors[0]);
75
+ return;
76
+ }
77
+ const sessions = context.services.get(HELYX_SERVICE_NAMES.sessions);
78
+ const lockoutUntil = dateValue(summary?.lockout_until);
79
+ if (lockoutUntil && lockoutUntil.getTime() > Date.now()) {
80
+ await replyError(context, `Please wait until <t:${Math.floor(lockoutUntil.getTime() / 1_000)}:R> before starting another check.`);
81
+ return;
82
+ }
83
+ let existing = await sessions.getFor(context.serverId, context.userId, ACCOUNT_GATE_SESSION_PURPOSE);
84
+ if (existing) {
85
+ const existingState = parseAccountGateSessionState(existing.state);
86
+ if (existingState.joinGeneration !== numberValue(summary.join_generation)) {
87
+ await sessions.delete(existing.sessionId);
88
+ existing = null;
89
+ }
90
+ }
91
+ if (existing) {
92
+ const existingState = parseAccountGateSessionState(existing.state);
93
+ if (existingState.status === "passed" ||
94
+ existingState.status === "failed") {
95
+ if (await ensureTerminalFinalization(context.services, context.serverId, context.userId, existing.sessionId, existingState, existingState.status))
96
+ await finalizeTerminalFinalization(context.services, context.serverId, existing.sessionId);
97
+ const finalizedSummary = await loadMemberSummary(context.services.get(HELYX_SERVICE_NAMES.records), context.serverId, context.userId);
98
+ await context.reply(terminalRecoveryMessage(existingState.status, typeof finalizedSummary?.last_decision === "string"
99
+ ? finalizedSummary.last_decision
100
+ : "pending"));
101
+ return;
102
+ }
103
+ await context.reply(challengeMessage(existingState, {
104
+ sessionId: existing.sessionId,
105
+ version: existing.version,
106
+ }));
107
+ return;
108
+ }
109
+ const strictModeActive = await currentStrictMode(context.services, context.serverId);
110
+ const random = secureRandom(context.services);
111
+ const questions = activeConfiguration.enabledChallengeTypes.includes("server_question")
112
+ ? await loadQuestions(context.services, context.serverId)
113
+ : [];
114
+ const state = createAccountGateSessionState(generateChallengeChain({
115
+ configuration: activeConfiguration,
116
+ random,
117
+ operationContext: `${context.serverId}:${context.userId}:${context.interactionId}`,
118
+ strictModeActive,
119
+ adaptiveRisk: (summary ? numberValue(summary.failure_count) : 0) > 0 ||
120
+ summary?.pending_reason === "account_too_new",
121
+ questions,
122
+ }), numberValue(summary.join_generation), new Date(), panelId ?? undefined);
123
+ const expiresAt = new Date(Date.now() + activeConfiguration.sessionExpirySeconds * 1_000);
124
+ const created = await sessions.create({
125
+ guildId: context.serverId,
126
+ userId: context.userId,
127
+ purpose: ACCOUNT_GATE_SESSION_PURPOSE,
128
+ state,
129
+ expiresAt,
130
+ });
131
+ await scheduleSessionExpiry(context.services, {
132
+ guildId: context.serverId,
133
+ userId: context.userId,
134
+ sessionId: created.sessionId,
135
+ expiredAt: expiresAt,
136
+ });
137
+ await context.reply(challengeMessage(state, {
138
+ sessionId: created.sessionId,
139
+ version: created.version,
140
+ }));
141
+ }
142
+ export function startPanelId(customId) {
143
+ if (!customId?.startsWith(ACCOUNT_GATE_COMPONENTS.startPrefix))
144
+ return null;
145
+ const panelId = customId.slice(ACCOUNT_GATE_COMPONENTS.startPrefix.length);
146
+ return panelId.length >= 1 && panelId.length <= 100 && !panelId.includes(":")
147
+ ? panelId
148
+ : null;
149
+ }
150
+ export function terminalRecoveryMessage(outcome, decision) {
151
+ if (outcome === "passed" && decision === "passed")
152
+ return {
153
+ ephemeral: true,
154
+ componentsV2: {
155
+ accentColor: GREEN,
156
+ text: [
157
+ { content: "## Access applied" },
158
+ {
159
+ content: "Your Access Control check is complete and your server access has been applied.",
160
+ markdown: false,
161
+ },
162
+ ],
163
+ },
164
+ };
165
+ if (decision === "review")
166
+ return {
167
+ ephemeral: true,
168
+ componentsV2: {
169
+ accentColor: VIOLET,
170
+ text: [
171
+ { content: "## Check sent for review" },
172
+ {
173
+ content: "Your check is complete, but Helyx could not safely apply the configured roles. The server team has been notified.",
174
+ markdown: false,
175
+ },
176
+ ],
177
+ },
178
+ };
179
+ return {
180
+ ephemeral: true,
181
+ componentsV2: {
182
+ accentColor: outcome === "passed" ? VIOLET : RED,
183
+ text: [
184
+ {
185
+ content: outcome === "passed"
186
+ ? "## Access is being finalized"
187
+ : "## Attempt submitted",
188
+ },
189
+ {
190
+ content: outcome === "passed"
191
+ ? "Your Access Control check is complete. Helyx is still applying the configured access changes."
192
+ : "Your attempt has been recorded. Start a new check after the configured cooldown if another attempt is available.",
193
+ markdown: false,
194
+ },
195
+ ],
196
+ },
197
+ };
198
+ }
199
+ //# sourceMappingURL=member-start.js.map
@@ -0,0 +1,47 @@
1
+ import type { ModuleRecordService, ServiceAccess } from "./runtime-dependencies.js";
2
+ export declare function loadMemberSummary(records: ModuleRecordService, guildId: string, memberUserId: string): Promise<Readonly<Record<string, import("@helyx/sdk").ModuleRecordValue>> | null>;
3
+ export declare function isCurrentPendingMember(services: ServiceAccess, guildId: string, memberUserId: string, verifiedRoleId: string): Promise<boolean>;
4
+ export declare function summaryValues(input: {
5
+ guildId: string;
6
+ memberUserId: string;
7
+ now: Date;
8
+ previous?: Readonly<Record<string, unknown>> | null;
9
+ previousSuccessAt?: Date;
10
+ failureCount: number;
11
+ lockoutUntil: Date;
12
+ rejoinCount: number;
13
+ joinGeneration: number;
14
+ accountCreatedAt: Date;
15
+ joinedAt: Date;
16
+ deadlineAt: Date;
17
+ pendingReason: "none" | "account_too_new";
18
+ lastFailureTerminalId?: string;
19
+ leftAt?: Date;
20
+ lastDecision: "pending" | "finalizing" | "passed" | "failed" | "review" | "kick_pending" | "kicked" | "dismissed" | "revoked";
21
+ }): {
22
+ summary_id: string;
23
+ guild_id: string;
24
+ member_user_id: string;
25
+ previous_success_at: Date;
26
+ failure_count: number;
27
+ lockout_until: Date;
28
+ rejoin_count: number;
29
+ join_generation: number;
30
+ account_created_at: Date;
31
+ joined_at: Date;
32
+ deadline_at: Date;
33
+ pending_reason: "none" | "account_too_new";
34
+ last_failure_terminal_id: string;
35
+ left_at: Date;
36
+ last_decision: "passed" | "failed" | "review" | "kicked" | "pending" | "finalizing" | "kick_pending" | "dismissed" | "revoked";
37
+ revision: number;
38
+ retention_until: Date;
39
+ created_at: Date;
40
+ updated_at: Date;
41
+ };
42
+ export declare function summaryUpdateValues(values: ReturnType<typeof summaryValues>): Readonly<Record<string, string | number | boolean | Date>>;
43
+ export declare function setSummaryDecision(records: ModuleRecordService, guildId: string, memberUserId: string, generation: number, decision: "pending" | "finalizing" | "passed" | "failed" | "review" | "kick_pending" | "kicked" | "dismissed" | "revoked", expectedDecision?: string): Promise<boolean>;
44
+ export declare function recordFailureSummary(records: ModuleRecordService, guildId: string, memberUserId: string, now: Date, cooldownSeconds: number, attemptLimit: number, terminalId: string, expectedGeneration: number): Promise<number | null>;
45
+ export declare function markSummaryPassed(records: ModuleRecordService, guildId: string, memberUserId: string, now: Date, expectedGeneration: number): Promise<boolean>;
46
+ export declare function resetSummaryForRetry(records: ModuleRecordService, guildId: string, memberUserId: string, now: Date, expectedGeneration: number): Promise<boolean>;
47
+ //# sourceMappingURL=member-summary.d.ts.map
@@ -0,0 +1,200 @@
1
+ import { randomUUID, HELYX_SERVICE_NAMES, ACCOUNT_GATE_IDENTITY, accountGateRecords, RETENTION_MS, } from "./runtime-dependencies.js";
2
+ import { dateValue, numberValue } from "./runtime-support.js";
3
+ export async function loadMemberSummary(records, guildId, memberUserId) {
4
+ return records.findOne({
5
+ moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
6
+ collection: "member_summaries",
7
+ select: accountGateRecords.member_summaries.readableFields,
8
+ where: { guild_id: guildId, member_user_id: memberUserId },
9
+ });
10
+ }
11
+ export async function isCurrentPendingMember(services, guildId, memberUserId, verifiedRoleId) {
12
+ const [summary, members] = await Promise.all([
13
+ loadMemberSummary(services.get(HELYX_SERVICE_NAMES.records), guildId, memberUserId),
14
+ services
15
+ .get(HELYX_SERVICE_NAMES.discordResources)
16
+ .resolveMemberSnapshots({ guildId, userIds: [memberUserId] }),
17
+ ]);
18
+ const member = members[0];
19
+ return (summary?.last_decision === "pending" &&
20
+ member?.exists === true &&
21
+ !member.isBot &&
22
+ !member.roleIds.includes(verifiedRoleId));
23
+ }
24
+ export function summaryValues(input) {
25
+ return {
26
+ summary_id: typeof input.previous?.summary_id === "string"
27
+ ? input.previous.summary_id
28
+ : randomUUID(),
29
+ guild_id: input.guildId,
30
+ member_user_id: input.memberUserId,
31
+ previous_success_at: input.previousSuccessAt ??
32
+ dateValue(input.previous?.previous_success_at) ??
33
+ new Date(0),
34
+ failure_count: input.failureCount,
35
+ lockout_until: input.lockoutUntil,
36
+ rejoin_count: input.rejoinCount,
37
+ join_generation: input.joinGeneration,
38
+ account_created_at: input.accountCreatedAt,
39
+ joined_at: input.joinedAt,
40
+ deadline_at: input.deadlineAt,
41
+ pending_reason: input.pendingReason,
42
+ last_failure_terminal_id: input.lastFailureTerminalId ??
43
+ (typeof input.previous?.last_failure_terminal_id === "string"
44
+ ? input.previous.last_failure_terminal_id
45
+ : ""),
46
+ left_at: input.leftAt ?? new Date(0),
47
+ last_decision: input.lastDecision,
48
+ revision: input.previous ? numberValue(input.previous.revision) + 1 : 1,
49
+ retention_until: new Date(input.now.getTime() + RETENTION_MS),
50
+ created_at: dateValue(input.previous?.created_at) ?? input.now,
51
+ updated_at: input.now,
52
+ };
53
+ }
54
+ export function summaryUpdateValues(values) {
55
+ return {
56
+ previous_success_at: values.previous_success_at,
57
+ failure_count: values.failure_count,
58
+ lockout_until: values.lockout_until,
59
+ rejoin_count: values.rejoin_count,
60
+ join_generation: values.join_generation,
61
+ account_created_at: values.account_created_at,
62
+ joined_at: values.joined_at,
63
+ deadline_at: values.deadline_at,
64
+ pending_reason: values.pending_reason,
65
+ last_failure_terminal_id: values.last_failure_terminal_id,
66
+ left_at: values.left_at,
67
+ last_decision: values.last_decision,
68
+ revision: values.revision,
69
+ retention_until: values.retention_until,
70
+ updated_at: values.updated_at,
71
+ };
72
+ }
73
+ export async function setSummaryDecision(records, guildId, memberUserId, generation, decision, expectedDecision = "pending") {
74
+ const row = await loadMemberSummary(records, guildId, memberUserId);
75
+ if (!row ||
76
+ numberValue(row.join_generation) !== generation ||
77
+ row.last_decision !== expectedDecision)
78
+ return false;
79
+ return (await records.updateOne({
80
+ moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
81
+ collection: "member_summaries",
82
+ where: { guild_id: guildId, member_user_id: memberUserId },
83
+ expected: { field: "revision", value: numberValue(row.revision) },
84
+ values: {
85
+ last_decision: decision,
86
+ revision: numberValue(row.revision) + 1,
87
+ updated_at: new Date(),
88
+ },
89
+ })).updated;
90
+ }
91
+ export async function recordFailureSummary(records, guildId, memberUserId, now, cooldownSeconds, attemptLimit, terminalId, expectedGeneration) {
92
+ for (let retry = 0; retry < 4; retry += 1) {
93
+ const previous = await loadMemberSummary(records, guildId, memberUserId);
94
+ if (!previous ||
95
+ numberValue(previous.join_generation) !== expectedGeneration)
96
+ return null;
97
+ if (previous?.last_failure_terminal_id === terminalId)
98
+ return numberValue(previous.failure_count);
99
+ const failureCount = (previous ? numberValue(previous.failure_count) : 0) + 1;
100
+ const requestedLockout = new Date(now.getTime() + cooldownSeconds * 1_000);
101
+ const existingLockout = dateValue(previous?.lockout_until);
102
+ const lockoutUntil = existingLockout && existingLockout.getTime() > requestedLockout.getTime()
103
+ ? existingLockout
104
+ : requestedLockout;
105
+ const values = summaryValues({
106
+ guildId,
107
+ memberUserId,
108
+ now,
109
+ previous,
110
+ failureCount,
111
+ lockoutUntil,
112
+ rejoinCount: previous ? numberValue(previous.rejoin_count) : 0,
113
+ joinGeneration: expectedGeneration,
114
+ accountCreatedAt: dateValue(previous?.account_created_at) ?? now,
115
+ joinedAt: dateValue(previous?.joined_at) ?? now,
116
+ deadlineAt: dateValue(previous?.deadline_at) ?? now,
117
+ pendingReason: previous?.pending_reason === "account_too_new"
118
+ ? "account_too_new"
119
+ : "none",
120
+ lastDecision: failureCount >= attemptLimit ? "failed" : "pending",
121
+ lastFailureTerminalId: terminalId,
122
+ });
123
+ const result = await records.updateOne({
124
+ moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
125
+ collection: "member_summaries",
126
+ where: { guild_id: guildId, member_user_id: memberUserId },
127
+ expected: {
128
+ field: "revision",
129
+ value: numberValue(previous.revision),
130
+ },
131
+ values: summaryUpdateValues(values),
132
+ });
133
+ if (result.updated)
134
+ return failureCount;
135
+ }
136
+ const current = await loadMemberSummary(records, guildId, memberUserId);
137
+ return current &&
138
+ numberValue(current.join_generation) === expectedGeneration &&
139
+ current.last_failure_terminal_id === terminalId
140
+ ? numberValue(current.failure_count)
141
+ : null;
142
+ }
143
+ export async function markSummaryPassed(records, guildId, memberUserId, now, expectedGeneration) {
144
+ const previous = await loadMemberSummary(records, guildId, memberUserId);
145
+ if (!previous || numberValue(previous.join_generation) !== expectedGeneration)
146
+ return false;
147
+ const values = summaryValues({
148
+ guildId,
149
+ memberUserId,
150
+ now,
151
+ previous,
152
+ previousSuccessAt: now,
153
+ failureCount: 0,
154
+ lockoutUntil: new Date(0),
155
+ rejoinCount: previous ? numberValue(previous.rejoin_count) : 0,
156
+ joinGeneration: expectedGeneration,
157
+ accountCreatedAt: dateValue(previous?.account_created_at) ?? now,
158
+ joinedAt: dateValue(previous?.joined_at) ?? now,
159
+ deadlineAt: dateValue(previous?.deadline_at) ?? now,
160
+ pendingReason: "none",
161
+ lastDecision: "passed",
162
+ });
163
+ return (await records.updateOne({
164
+ moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
165
+ collection: "member_summaries",
166
+ where: { guild_id: guildId, member_user_id: memberUserId },
167
+ expected: { field: "revision", value: numberValue(previous.revision) },
168
+ values: summaryUpdateValues(values),
169
+ })).updated;
170
+ }
171
+ export async function resetSummaryForRetry(records, guildId, memberUserId, now, expectedGeneration) {
172
+ const previous = await loadMemberSummary(records, guildId, memberUserId);
173
+ if (!previous || numberValue(previous.join_generation) !== expectedGeneration)
174
+ return false;
175
+ const values = summaryValues({
176
+ guildId,
177
+ memberUserId,
178
+ now,
179
+ previous,
180
+ failureCount: 0,
181
+ lockoutUntil: new Date(0),
182
+ rejoinCount: previous ? numberValue(previous.rejoin_count) : 0,
183
+ joinGeneration: expectedGeneration,
184
+ accountCreatedAt: dateValue(previous?.account_created_at) ?? now,
185
+ joinedAt: dateValue(previous?.joined_at) ?? now,
186
+ deadlineAt: dateValue(previous?.deadline_at) ?? now,
187
+ pendingReason: previous?.pending_reason === "account_too_new"
188
+ ? "account_too_new"
189
+ : "none",
190
+ lastDecision: "pending",
191
+ });
192
+ return (await records.updateOne({
193
+ moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
194
+ collection: "member_summaries",
195
+ where: { guild_id: guildId, member_user_id: memberUserId },
196
+ expected: { field: "revision", value: numberValue(previous.revision) },
197
+ values: summaryUpdateValues(values),
198
+ })).updated;
199
+ }
200
+ //# sourceMappingURL=member-summary.js.map
@@ -0,0 +1,26 @@
1
+ import type { RuntimeContext, ScheduledTaskPayload, ServiceAccess } from "@helyx/sdk";
2
+ export declare const PANEL_MESSAGE_CLEANUP_TASK = "account-gate.panel-message-cleanup";
3
+ export declare function queuePanelMessageCleanup(input: {
4
+ services: ServiceAccess;
5
+ guildId: string;
6
+ panelId: string;
7
+ channelId: string;
8
+ messageId: string;
9
+ }): Promise<void>;
10
+ export declare function validatePanelCleanupPayload(input: unknown): ScheduledTaskPayload;
11
+ export declare function executePanelMessageCleanup(context: RuntimeContext, task: {
12
+ payload: ScheduledTaskPayload;
13
+ }): Promise<{
14
+ outcome: "completed";
15
+ safeCode?: never;
16
+ delayMs?: never;
17
+ } | {
18
+ outcome: "requires_review";
19
+ safeCode: string;
20
+ delayMs?: never;
21
+ } | {
22
+ outcome: "retry";
23
+ delayMs: number;
24
+ safeCode: string;
25
+ }>;
26
+ //# sourceMappingURL=panel-maintenance.d.ts.map
@@ -0,0 +1,143 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { HELYX_SERVICE_NAMES } from "@helyx/sdk";
3
+ import { ACCOUNT_GATE_IDENTITY } from "./contracts.js";
4
+ import { accountGateRecords } from "./records.js";
5
+ export const PANEL_MESSAGE_CLEANUP_TASK = "account-gate.panel-message-cleanup";
6
+ export async function queuePanelMessageCleanup(input) {
7
+ const records = recordService(input.services);
8
+ const cleanupId = randomUUID();
9
+ const now = new Date();
10
+ const created = await records.appendUnique({
11
+ moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
12
+ collection: "panel_message_cleanup",
13
+ values: {
14
+ cleanup_id: cleanupId,
15
+ guild_id: input.guildId,
16
+ panel_id: input.panelId,
17
+ channel_id: input.channelId,
18
+ message_id: input.messageId,
19
+ attempts: 0,
20
+ next_attempt_at: now,
21
+ created_at: now,
22
+ },
23
+ uniqueBy: ["guild_id", "channel_id", "message_id"],
24
+ schedule: {
25
+ taskKind: PANEL_MESSAGE_CLEANUP_TASK,
26
+ guildId: input.guildId,
27
+ idempotencyKey: `account-gate-panel-cleanup:${input.guildId}:${input.channelId}:${input.messageId}`,
28
+ payload: { cleanupId },
29
+ scheduledFor: now,
30
+ },
31
+ });
32
+ if (created.created)
33
+ return;
34
+ const existing = await records.findOne({
35
+ moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
36
+ collection: "panel_message_cleanup",
37
+ select: accountGateRecords.panel_message_cleanup.readableFields,
38
+ where: {
39
+ guild_id: input.guildId,
40
+ channel_id: input.channelId,
41
+ message_id: input.messageId,
42
+ },
43
+ });
44
+ if (!existing || existing.completed_at !== null)
45
+ return;
46
+ const existingId = recordString(existing.cleanup_id);
47
+ await input.services
48
+ .get(HELYX_SERVICE_NAMES.scheduledTasks)
49
+ .schedule({
50
+ moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
51
+ taskKind: PANEL_MESSAGE_CLEANUP_TASK,
52
+ guildId: input.guildId,
53
+ idempotencyKey: `account-gate-panel-cleanup:${input.guildId}:${input.channelId}:${input.messageId}`,
54
+ payload: { cleanupId: existingId },
55
+ scheduledFor: now,
56
+ });
57
+ }
58
+ export function validatePanelCleanupPayload(input) {
59
+ const cleanupId = input && typeof input === "object" && !Array.isArray(input)
60
+ ? input.cleanupId
61
+ : undefined;
62
+ if (typeof cleanupId !== "string" || cleanupId === "")
63
+ throw new Error("Access Control panel cleanup payload is invalid");
64
+ return { cleanupId };
65
+ }
66
+ export async function executePanelMessageCleanup(context, task) {
67
+ const cleanupId = recordString(task.payload.cleanupId);
68
+ const records = recordService(context.services);
69
+ const row = await records.findOne({
70
+ moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
71
+ collection: "panel_message_cleanup",
72
+ select: accountGateRecords.panel_message_cleanup.readableFields,
73
+ where: { cleanup_id: cleanupId },
74
+ });
75
+ if (!row || row.completed_at !== null)
76
+ return { outcome: "completed" };
77
+ const attempts = recordNumber(row.attempts);
78
+ if (attempts >= 10)
79
+ return {
80
+ outcome: "requires_review",
81
+ safeCode: "account_gate.panel_cleanup_exhausted",
82
+ };
83
+ const claimed = await records.updateOne({
84
+ moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
85
+ collection: "panel_message_cleanup",
86
+ where: { cleanup_id: cleanupId },
87
+ expected: { field: "attempts", value: attempts },
88
+ values: {
89
+ attempts: attempts + 1,
90
+ next_attempt_at: new Date(Date.now() + Math.min(3_600_000, 30_000 * 2 ** attempts)),
91
+ },
92
+ });
93
+ if (!claimed.updated)
94
+ return {
95
+ outcome: "retry",
96
+ delayMs: 5_000,
97
+ safeCode: "account_gate.panel_cleanup_claim_conflict",
98
+ };
99
+ try {
100
+ await context.services
101
+ .get(HELYX_SERVICE_NAMES.discordOwnedMessages)
102
+ .deleteOwnedMessage({
103
+ guildId: recordString(row.guild_id),
104
+ channelId: recordString(row.channel_id),
105
+ messageId: recordString(row.message_id),
106
+ });
107
+ }
108
+ catch {
109
+ return {
110
+ outcome: "retry",
111
+ delayMs: Math.min(3_600_000, 30_000 * 2 ** attempts),
112
+ safeCode: "account_gate.panel_cleanup_retry",
113
+ };
114
+ }
115
+ const completed = await records.updateOne({
116
+ moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
117
+ collection: "panel_message_cleanup",
118
+ where: { cleanup_id: cleanupId },
119
+ expected: { field: "attempts", value: attempts + 1 },
120
+ values: { completed_at: new Date() },
121
+ });
122
+ return completed.updated
123
+ ? { outcome: "completed" }
124
+ : {
125
+ outcome: "retry",
126
+ delayMs: 5_000,
127
+ safeCode: "account_gate.panel_cleanup_finalize_conflict",
128
+ };
129
+ }
130
+ function recordService(services) {
131
+ return services.get(HELYX_SERVICE_NAMES.records);
132
+ }
133
+ function recordString(value) {
134
+ if (typeof value !== "string" || value === "")
135
+ throw new Error("Access Control panel cleanup record is invalid");
136
+ return value;
137
+ }
138
+ function recordNumber(value) {
139
+ if (typeof value !== "number" || !Number.isSafeInteger(value))
140
+ throw new Error("Access Control panel cleanup record is invalid");
141
+ return value;
142
+ }
143
+ //# sourceMappingURL=panel-maintenance.js.map
@@ -0,0 +1,47 @@
1
+ import { DashboardActionValidationError } from "@helyx/sdk";
2
+ import type { ManagedResourceDetail } from "@helyx/sdk";
3
+ export type GatePanelAccessType = "challenges" | "password" | "emoji";
4
+ export interface PanelPassword {
5
+ encodedHash: string;
6
+ ignoreCase: boolean;
7
+ trimWhitespace: boolean;
8
+ }
9
+ export declare const DEFAULT_PANEL_MESSAGES: Readonly<Record<GatePanelAccessType, string>>;
10
+ export declare function panelMessage(messageBody: string, accessType: GatePanelAccessType, panelId: string): {
11
+ allowedUserMentionIds: never[];
12
+ componentsV2: {
13
+ text: {
14
+ content: string;
15
+ markdown: boolean;
16
+ }[];
17
+ };
18
+ buttons?: never;
19
+ } | {
20
+ allowedUserMentionIds: never[];
21
+ componentsV2: {
22
+ text: ({
23
+ content: string;
24
+ markdown?: never;
25
+ } | {
26
+ content: string;
27
+ markdown: boolean;
28
+ })[];
29
+ accentColor?: number;
30
+ };
31
+ buttons: {
32
+ type: "button";
33
+ customId: string;
34
+ label: string;
35
+ style: "primary";
36
+ }[];
37
+ };
38
+ export declare function panelDetail(row: Readonly<Record<string, unknown>>, status: "posted" | "missing"): ManagedResourceDetail;
39
+ export declare function panelAccessType(row: Readonly<Record<string, unknown>>): GatePanelAccessType;
40
+ export declare function panelMessageFromValue(value: Readonly<Record<string, unknown>>, fallback: string): string;
41
+ export declare function savedPanelMessage(row: Readonly<Record<string, unknown>>): string;
42
+ export declare function panelPasswordFromRow(row: Readonly<Record<string, unknown>>): PanelPassword | null;
43
+ export declare function replacementPhrase(value: Readonly<Record<string, unknown>>, required: boolean): string;
44
+ export declare function panelLimitError(): DashboardActionValidationError;
45
+ export declare function panelString(value: unknown): string;
46
+ export declare function panelNumber(value: unknown): number;
47
+ //# sourceMappingURL=panel-model.d.ts.map