@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
@@ -0,0 +1,21 @@
1
+ import type { CommandInteractionContext, ServiceAccess } from "./runtime-dependencies.js";
2
+ export declare function handleGuildMemberAdd(context: {
3
+ serverId: string;
4
+ serverName: string;
5
+ memberId: string;
6
+ memberIsBot: boolean;
7
+ accountCreatedAt: Date;
8
+ joinedAt: Date;
9
+ services: ServiceAccess;
10
+ }): Promise<void>;
11
+ export declare function handleGuildMemberRemove(context: {
12
+ serverId: string;
13
+ memberId: string;
14
+ memberIsBot: boolean;
15
+ joinedAt: Date | null;
16
+ services: ServiceAccess;
17
+ }): Promise<void>;
18
+ export declare function isAccountTooYoung(accountCreatedAt: Date, joinedAt: Date, minimumAccountAgeDays: number): boolean;
19
+ export declare function hasPreviousSuccess(value: unknown): boolean;
20
+ export declare function resetOwnAccess(context: CommandInteractionContext): Promise<void>;
21
+ //# sourceMappingURL=member-lifecycle.d.ts.map
@@ -0,0 +1,305 @@
1
+ import { HELYX_SERVICE_NAMES, ACCOUNT_GATE_IDENTITY, ACCOUNT_GATE_SESSION_PURPOSE, isAccountGateWorkSuppressed, parseAccountGateSessionState, VIOLET, JOIN_DEADLINE_TASK, LEAVE_RETENTION_TASK, RETENTION_MS, } from "./runtime-dependencies.js";
2
+ import { loadMemberSummary, setSummaryDecision, summaryUpdateValues, summaryValues, } from "./member-summary.js";
3
+ import { createReview } from "./review-store.js";
4
+ import { dateValue, numberValue, replyError, stringValue, } from "./runtime-support.js";
5
+ import { getConfiguration, requiredRuntimeConfiguration, } from "./setup-presentation.js";
6
+ import { scheduleJoinDeadline } from "./task-scheduling.js";
7
+ export async function handleGuildMemberAdd(context) {
8
+ if (context.memberIsBot)
9
+ return;
10
+ let configuration;
11
+ try {
12
+ configuration = await requiredRuntimeConfiguration(context.services, context.serverId);
13
+ }
14
+ catch {
15
+ return;
16
+ }
17
+ if (await isAccountGateWorkSuppressed(context.services, context.serverId, context.memberId, context.joinedAt))
18
+ return;
19
+ const records = context.services.get(HELYX_SERVICE_NAMES.records);
20
+ const previous = await loadMemberSummary(records, context.serverId, context.memberId);
21
+ const sameJoin = dateValue(previous?.joined_at)?.getTime() === context.joinedAt.getTime() &&
22
+ (dateValue(previous?.left_at)?.getTime() ?? 0) === 0;
23
+ const generation = sameJoin
24
+ ? numberValue(previous?.join_generation)
25
+ : (previous ? numberValue(previous.join_generation) : 0) + 1;
26
+ const accountTooYoung = isAccountTooYoung(context.accountCreatedAt, context.joinedAt, configuration.minimumAccountAgeDays);
27
+ const returningSuccess = previous?.last_decision !== "revoked" &&
28
+ hasPreviousSuccess(previous?.previous_success_at);
29
+ const restore = returningSuccess &&
30
+ configuration.restoreVerifiedMembers &&
31
+ !configuration.reverifyReturningMembers &&
32
+ !accountTooYoung;
33
+ const now = context.joinedAt;
34
+ const deadlineAt = accountTooYoung
35
+ ? now
36
+ : new Date(now.getTime() + configuration.joinDeadlineSeconds * 1_000);
37
+ const values = summaryValues({
38
+ guildId: context.serverId,
39
+ memberUserId: context.memberId,
40
+ now,
41
+ previous,
42
+ failureCount: 0,
43
+ lockoutUntil: new Date(0),
44
+ rejoinCount: previous ? numberValue(previous.rejoin_count) + 1 : 1,
45
+ joinGeneration: generation,
46
+ accountCreatedAt: context.accountCreatedAt,
47
+ joinedAt: context.joinedAt,
48
+ deadlineAt,
49
+ pendingReason: accountTooYoung ? "account_too_new" : "none",
50
+ lastDecision: restore ? "passed" : "pending",
51
+ });
52
+ const stored = sameJoin
53
+ ? { updated: true }
54
+ : previous
55
+ ? await records.updateOne({
56
+ moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
57
+ collection: "member_summaries",
58
+ where: {
59
+ guild_id: context.serverId,
60
+ member_user_id: context.memberId,
61
+ },
62
+ expected: {
63
+ field: "revision",
64
+ value: numberValue(previous.revision),
65
+ },
66
+ values: summaryUpdateValues(values),
67
+ })
68
+ : await records.appendUnique({
69
+ moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
70
+ collection: "member_summaries",
71
+ uniqueBy: ["guild_id", "member_user_id"],
72
+ values,
73
+ });
74
+ if (!("updated" in stored ? stored.updated : stored.created))
75
+ return;
76
+ if (restore) {
77
+ const role = await context.services
78
+ .get(HELYX_SERVICE_NAMES.discordResources)
79
+ .inspectRole({
80
+ guildId: context.serverId,
81
+ roleId: configuration.verifiedRoleId,
82
+ });
83
+ if (role.exists && role.assignable) {
84
+ await context.services
85
+ .get(HELYX_SERVICE_NAMES.memberRoles)
86
+ .addApprovedRole({
87
+ guildId: context.serverId,
88
+ memberUserId: context.memberId,
89
+ roleId: configuration.verifiedRoleId,
90
+ moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
91
+ reason: "Restore retained Access Control success after rejoin",
92
+ });
93
+ return;
94
+ }
95
+ await setSummaryDecision(records, context.serverId, context.memberId, generation, "review");
96
+ await createReview(context.services, context.serverId, context.memberId, "returning_role_restore_failed", null);
97
+ return;
98
+ }
99
+ for (const roleId of configuration.temporaryRoleIds) {
100
+ await context.services
101
+ .get(HELYX_SERVICE_NAMES.memberRoles)
102
+ .addApprovedRole({
103
+ guildId: context.serverId,
104
+ memberUserId: context.memberId,
105
+ roleId,
106
+ moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
107
+ reason: "Apply Access Control temporary pre-gate role",
108
+ });
109
+ }
110
+ await scheduleJoinDeadline(context.services, context.serverId, context.memberId, generation, deadlineAt);
111
+ }
112
+ export async function handleGuildMemberRemove(context) {
113
+ if (context.memberIsBot)
114
+ return;
115
+ const records = context.services.get(HELYX_SERVICE_NAMES.records);
116
+ const row = await loadMemberSummary(records, context.serverId, context.memberId);
117
+ if (!row)
118
+ return;
119
+ if (context.joinedAt &&
120
+ dateValue(row.joined_at)?.getTime() !== context.joinedAt.getTime())
121
+ return;
122
+ const [member] = await context.services
123
+ .get(HELYX_SERVICE_NAMES.discordResources)
124
+ .resolveMemberSnapshots({
125
+ guildId: context.serverId,
126
+ userIds: [context.memberId],
127
+ });
128
+ if (member?.exists)
129
+ return;
130
+ const now = new Date();
131
+ const retentionUntil = new Date(now.getTime() + RETENTION_MS);
132
+ const generation = numberValue(row.join_generation);
133
+ const stored = await records.updateOne({
134
+ moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
135
+ collection: "member_summaries",
136
+ where: {
137
+ guild_id: context.serverId,
138
+ member_user_id: context.memberId,
139
+ },
140
+ expected: { field: "revision", value: numberValue(row.revision) },
141
+ values: {
142
+ revision: numberValue(row.revision) + 1,
143
+ left_at: now,
144
+ retention_until: retentionUntil,
145
+ updated_at: now,
146
+ },
147
+ schedule: {
148
+ taskKind: LEAVE_RETENTION_TASK,
149
+ guildId: context.serverId,
150
+ idempotencyKey: `${context.serverId}:${context.memberId}:leave:${generation}`,
151
+ payload: {
152
+ guildId: context.serverId,
153
+ memberUserId: context.memberId,
154
+ summaryId: stringValue(row.summary_id),
155
+ },
156
+ scheduledFor: retentionUntil,
157
+ },
158
+ });
159
+ if (!stored.updated)
160
+ return;
161
+ const sessions = context.services.get(HELYX_SERVICE_NAMES.sessions);
162
+ const active = await sessions.getFor(context.serverId, context.memberId, ACCOUNT_GATE_SESSION_PURPOSE);
163
+ if (!active)
164
+ return;
165
+ try {
166
+ if (parseAccountGateSessionState(active.state).joinGeneration !== generation)
167
+ return;
168
+ }
169
+ catch {
170
+ // Invalid module-owned state is safe to discard after the fenced leave.
171
+ }
172
+ await sessions.delete(active.sessionId).catch(() => undefined);
173
+ }
174
+ export function isAccountTooYoung(accountCreatedAt, joinedAt, minimumAccountAgeDays) {
175
+ if (minimumAccountAgeDays <= 0)
176
+ return false;
177
+ return (joinedAt.getTime() - accountCreatedAt.getTime() <
178
+ minimumAccountAgeDays * 24 * 60 * 60 * 1_000);
179
+ }
180
+ export function hasPreviousSuccess(value) {
181
+ return value instanceof Date && value.getTime() > 0;
182
+ }
183
+ export async function resetOwnAccess(context) {
184
+ if (!context.serverId) {
185
+ await replyError(context, "Access Control only works in a Discord server.");
186
+ return;
187
+ }
188
+ const configuration = await getConfiguration(context.services, context.serverId);
189
+ if (!configuration) {
190
+ await replyError(context, "Access Control has not been configured by the server team.");
191
+ return;
192
+ }
193
+ const records = context.services.get(HELYX_SERVICE_NAMES.records);
194
+ const summary = await loadMemberSummary(records, context.serverId, context.userId);
195
+ const [member] = await context.services
196
+ .get(HELYX_SERVICE_NAMES.discordResources)
197
+ .resolveMemberSnapshots({
198
+ guildId: context.serverId,
199
+ userIds: [context.userId],
200
+ });
201
+ if (!summary || !member?.exists || member.isBot) {
202
+ await replyError(context, "There is no Access Control member state to reset.");
203
+ return;
204
+ }
205
+ const sessions = context.services.get(HELYX_SERVICE_NAMES.sessions);
206
+ const activeSession = await sessions.getFor(context.serverId, context.userId, ACCOUNT_GATE_SESSION_PURPOSE);
207
+ const now = new Date();
208
+ const generation = numberValue(summary.join_generation) + 1;
209
+ const accountCreatedAt = dateValue(summary.account_created_at) ?? new Date(0);
210
+ const deadlineAt = new Date(now.getTime() + configuration.joinDeadlineSeconds * 1_000);
211
+ const values = summaryValues({
212
+ guildId: context.serverId,
213
+ memberUserId: context.userId,
214
+ now,
215
+ previous: summary,
216
+ previousSuccessAt: new Date(0),
217
+ failureCount: 0,
218
+ lockoutUntil: new Date(0),
219
+ rejoinCount: numberValue(summary.rejoin_count),
220
+ joinGeneration: generation,
221
+ accountCreatedAt,
222
+ joinedAt: dateValue(summary.joined_at) ?? member.joinedAt ?? now,
223
+ deadlineAt,
224
+ pendingReason: isAccountTooYoung(accountCreatedAt, now, configuration.minimumAccountAgeDays)
225
+ ? "account_too_new"
226
+ : "none",
227
+ lastFailureTerminalId: "",
228
+ leftAt: new Date(0),
229
+ lastDecision: "pending",
230
+ });
231
+ const reset = await records.updateOne({
232
+ moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
233
+ collection: "member_summaries",
234
+ where: {
235
+ guild_id: context.serverId,
236
+ member_user_id: context.userId,
237
+ },
238
+ expected: {
239
+ field: "revision",
240
+ value: numberValue(summary.revision),
241
+ },
242
+ requireModuleEnabledForGuildId: context.serverId,
243
+ values: summaryUpdateValues(values),
244
+ schedule: {
245
+ taskKind: JOIN_DEADLINE_TASK,
246
+ guildId: context.serverId,
247
+ idempotencyKey: `${context.serverId}:${context.userId}:join:${generation}`,
248
+ payload: {
249
+ guildId: context.serverId,
250
+ memberUserId: context.userId,
251
+ },
252
+ scheduledFor: deadlineAt,
253
+ },
254
+ audit: {
255
+ guildId: context.serverId,
256
+ actorUserId: context.userId,
257
+ action: "account_gate.self_reset",
258
+ source: "discord",
259
+ correlationId: context.interactionId,
260
+ idempotencyKey: context.interactionId,
261
+ targetType: "account_gate_member",
262
+ targetId: context.userId,
263
+ metadata: { generation },
264
+ },
265
+ });
266
+ if (!reset.updated) {
267
+ await replyError(context, "The Access Control state changed while it was being reset. Run the command again.");
268
+ return;
269
+ }
270
+ if (activeSession)
271
+ await sessions.delete(activeSession.sessionId).catch(() => undefined);
272
+ try {
273
+ await context.services
274
+ .get(HELYX_SERVICE_NAMES.memberRoles)
275
+ .reconcileApprovedRoles({
276
+ guildId: context.serverId,
277
+ memberUserId: context.userId,
278
+ allowedRoleIds: [
279
+ configuration.verifiedRoleId,
280
+ ...configuration.temporaryRoleIds,
281
+ ],
282
+ desiredRoleIds: configuration.temporaryRoleIds,
283
+ moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
284
+ reason: "Access Control self reset",
285
+ });
286
+ }
287
+ catch {
288
+ await replyError(context, "The check was reset, but Discord did not apply all configured roles. Run the reset command again.");
289
+ return;
290
+ }
291
+ await context.reply({
292
+ ephemeral: true,
293
+ componentsV2: {
294
+ accentColor: VIOLET,
295
+ text: [
296
+ { content: "## Access Control reset" },
297
+ {
298
+ content: "Your access was revoked and a fresh Access Control check is ready. Press Start check when you are ready.",
299
+ markdown: false,
300
+ },
301
+ ],
302
+ },
303
+ });
304
+ }
305
+ //# sourceMappingURL=member-lifecycle.js.map
@@ -1,11 +1,11 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { DashboardActionValidationError, HELYX_SERVICE_NAMES, } from "@helyx/sdk";
3
3
  import { parseAccountGateConfiguration } from "./configuration.js";
4
- import { ACCOUNT_GATE_IDENTITY, ACCOUNT_GATE_SESSION_PURPOSE, } from "./contracts.js";
4
+ import { ACCOUNT_GATE_IDENTITY } from "./contracts.js";
5
+ import { revokeMemberAccess } from "./member-revocation.js";
5
6
  import { accountGateRecords } from "./records.js";
6
7
  const PAGE_SIZE = 10;
7
8
  const SEARCH_RESULT_LIMIT = 25;
8
- const RETENTION_MS = 90 * 24 * 60 * 60 * 1_000;
9
9
  const DISCORD_SNOWFLAKE = /^\d{17,20}$/u;
10
10
  export const accountGateMemberResource = {
11
11
  id: "members",
@@ -83,79 +83,19 @@ export const accountGateMemberResource = {
83
83
  if (!configurationRecord)
84
84
  throw new DashboardActionValidationError("Save the Access Control settings before revoking access.");
85
85
  const configuration = parseAccountGateConfiguration(configurationRecord.value);
86
- const roles = context.services.get(HELYX_SERVICE_NAMES.memberRoles);
87
- try {
88
- await roles.removeApprovedRole({
89
- guildId: context.guildId,
90
- memberUserId: input.resourceId,
91
- roleId: configuration.verifiedRoleId,
92
- moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
93
- reason: "Access Control dashboard access revocation",
94
- });
95
- }
96
- catch {
97
- throw new DashboardActionValidationError("Discord did not remove the configured access role. Check the bot role hierarchy and try again.");
98
- }
99
- let temporaryRolesRestored = true;
100
- try {
101
- await roles.reconcileApprovedRoles({
102
- guildId: context.guildId,
103
- memberUserId: input.resourceId,
104
- allowedRoleIds: [
105
- configuration.verifiedRoleId,
106
- ...configuration.temporaryRoleIds,
107
- ],
108
- desiredRoleIds: configuration.temporaryRoleIds,
109
- moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
110
- reason: "Access Control dashboard access revocation",
111
- });
112
- }
113
- catch {
114
- temporaryRolesRestored = false;
115
- }
116
- const now = new Date();
117
- const updated = await memberRecords(context).updateOne({
118
- moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
119
- collection: "member_summaries",
120
- where: {
121
- guild_id: context.guildId,
122
- member_user_id: input.resourceId,
123
- },
124
- expected: { field: "revision", value: revision },
125
- requireModuleEnabledForGuildId: context.guildId,
126
- values: {
127
- join_generation: numberValue(row.join_generation) + 1,
128
- deadline_at: now,
129
- pending_reason: "none",
130
- left_at: new Date(0),
131
- last_decision: "revoked",
132
- revision: revision + 1,
133
- retention_until: new Date(now.getTime() + RETENTION_MS),
134
- updated_at: now,
135
- },
136
- audit: {
137
- guildId: context.guildId,
138
- actorUserId: context.actor.userId,
139
- action: "account_gate.access_revoked",
140
- source: "dashboard",
141
- correlationId: context.correlationId,
142
- idempotencyKey: context.correlationId,
143
- targetType: "account_gate_member",
144
- targetId: input.resourceId,
145
- metadata: { temporaryRolesRestored },
146
- },
147
- });
148
- if (!updated.updated)
149
- throw new DashboardActionValidationError("Discord access was removed, but the member state changed concurrently. Refresh before taking another action.");
150
- const sessions = context.services.get(HELYX_SERVICE_NAMES.sessions);
151
- const session = await sessions.getFor(context.guildId, input.resourceId, ACCOUNT_GATE_SESSION_PURPOSE);
152
- if (session)
153
- await sessions.delete(session.sessionId);
154
- const refreshed = await loadMemberSummary(context, input.resourceId);
155
- if (!refreshed)
156
- throw new Error("Revoked Access Control member disappeared");
157
86
  const identities = await resolveLiveIdentities(context, [input.resourceId]);
158
- return memberDetail(refreshed, identities.get(input.resourceId));
87
+ const revoked = await revokeMemberAccess({
88
+ services: context.services,
89
+ guildId: context.guildId,
90
+ memberUserId: input.resourceId,
91
+ actorUserId: context.actor.userId,
92
+ correlationId: context.correlationId,
93
+ current: row,
94
+ configuration,
95
+ });
96
+ if (!revoked)
97
+ throw new DashboardActionValidationError("This member changed in another session. Refresh and try again.");
98
+ return memberDetail(revoked, identities.get(input.resourceId));
159
99
  },
160
100
  };
161
101
  async function searchKnownMembers(context, search, cursor) {
@@ -0,0 +1,4 @@
1
+ import type { ModuleRecordService, ModuleRecordValue, ServiceAccess, AccountGateSessionState, Challenge, AccountGateConfiguration } from "./runtime-dependencies.js";
2
+ export declare function resumeRevokedMember(services: ServiceAccess, guildId: string, memberUserId: string, summary: Readonly<Record<string, ModuleRecordValue>>, configuration: AccountGateConfiguration, correlationId: string, now?: Date): Promise<Readonly<Record<string, ModuleRecordValue>> | null>;
3
+ export declare function recordAccountGateAttempt(records: ModuleRecordService, guildId: string, memberUserId: string, sessionId: string, state: AccountGateSessionState, challengeType: Challenge["type"], outcome: "passed" | "failed", now?: Date): Promise<void>;
4
+ //# sourceMappingURL=member-restart.d.ts.map
@@ -0,0 +1,76 @@
1
+ import { randomUUID, HELYX_SERVICE_NAMES, ACCOUNT_GATE_IDENTITY, JOIN_DEADLINE_TASK, RETENTION_MS, } from "./runtime-dependencies.js";
2
+ import { loadMemberSummary } from "./member-summary.js";
3
+ import { numberValue } from "./runtime-support.js";
4
+ import { armRecordRetention } from "./task-scheduling.js";
5
+ export async function resumeRevokedMember(services, guildId, memberUserId, summary, configuration, correlationId, now = new Date()) {
6
+ if (summary.last_decision !== "revoked")
7
+ return summary;
8
+ const records = services.get(HELYX_SERVICE_NAMES.records);
9
+ const revision = numberValue(summary.revision);
10
+ const generation = numberValue(summary.join_generation);
11
+ const deadlineAt = new Date(now.getTime() + configuration.joinDeadlineSeconds * 1_000);
12
+ const updated = await records.updateOne({
13
+ moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
14
+ collection: "member_summaries",
15
+ where: { guild_id: guildId, member_user_id: memberUserId },
16
+ expected: { field: "revision", value: revision },
17
+ requireModuleEnabledForGuildId: guildId,
18
+ values: {
19
+ failure_count: 0,
20
+ lockout_until: new Date(0),
21
+ last_failure_terminal_id: "",
22
+ deadline_at: deadlineAt,
23
+ pending_reason: "none",
24
+ left_at: new Date(0),
25
+ last_decision: "pending",
26
+ revision: revision + 1,
27
+ retention_until: new Date(now.getTime() + RETENTION_MS),
28
+ updated_at: now,
29
+ },
30
+ schedule: {
31
+ taskKind: JOIN_DEADLINE_TASK,
32
+ guildId,
33
+ idempotencyKey: `${guildId}:${memberUserId}:join:${generation}`,
34
+ payload: { guildId, memberUserId },
35
+ scheduledFor: deadlineAt,
36
+ },
37
+ audit: {
38
+ guildId,
39
+ actorUserId: memberUserId,
40
+ action: "account_gate.revoked_check_started",
41
+ source: "discord",
42
+ correlationId,
43
+ idempotencyKey: correlationId,
44
+ targetType: "account_gate_member",
45
+ targetId: memberUserId,
46
+ metadata: { generation },
47
+ },
48
+ });
49
+ if (!updated.updated)
50
+ return null;
51
+ return loadMemberSummary(records, guildId, memberUserId);
52
+ }
53
+ export async function recordAccountGateAttempt(records, guildId, memberUserId, sessionId, state, challengeType, outcome, now = new Date()) {
54
+ const attemptId = randomUUID();
55
+ const retentionUntil = new Date(now.getTime() + RETENTION_MS);
56
+ const recorded = await records.appendUnique({
57
+ moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
58
+ collection: "attempts",
59
+ uniqueBy: ["guild_id", "session_id", "challenge_type"],
60
+ values: {
61
+ attempt_id: attemptId,
62
+ guild_id: guildId,
63
+ member_user_id: memberUserId,
64
+ session_id: sessionId,
65
+ challenge_type: challengeType,
66
+ outcome,
67
+ reason_code: outcome === "passed" ? "challenge_passed" : "challenge_failed",
68
+ duration_ms: Math.max(0, now.getTime() - Date.parse(state.startedAt)),
69
+ retention_until: retentionUntil,
70
+ created_at: now,
71
+ },
72
+ });
73
+ if (recorded.created)
74
+ await armRecordRetention(records, guildId, "attempts", "attempt_id", attemptId, retentionUntil);
75
+ }
76
+ //# sourceMappingURL=member-restart.js.map
@@ -0,0 +1,21 @@
1
+ import type { RuntimeContext, ScheduledTaskPayload, ServiceAccess, AccountGateConfiguration } from "./runtime-dependencies.js";
2
+ export declare function revokeMemberAccess(input: {
3
+ services: ServiceAccess;
4
+ guildId: string;
5
+ memberUserId: string;
6
+ actorUserId: string;
7
+ correlationId: string;
8
+ current: Readonly<Record<string, unknown>>;
9
+ configuration: AccountGateConfiguration;
10
+ }): Promise<Readonly<Record<string, unknown>> | null>;
11
+ export declare function validateRevocationPayload(input: unknown): ScheduledTaskPayload;
12
+ export declare function reconcileRevocationTask(context: RuntimeContext, task: {
13
+ payload: ScheduledTaskPayload;
14
+ }): Promise<{
15
+ outcome: "requires_review";
16
+ safeCode: string;
17
+ } | {
18
+ outcome: "completed";
19
+ safeCode?: never;
20
+ }>;
21
+ //# sourceMappingURL=member-revocation.d.ts.map
@@ -0,0 +1,127 @@
1
+ import { HELYX_SERVICE_NAMES, ACCOUNT_GATE_IDENTITY, ACCOUNT_GATE_SESSION_PURPOSE, REVOCATION_RECONCILE_TASK, RETENTION_MS, parseAccountGateSessionState, } from "./runtime-dependencies.js";
2
+ import { parseAccountGateConfiguration } from "./configuration.js";
3
+ import { loadMemberSummary } from "./member-summary.js";
4
+ import { numberValue } from "./runtime-support.js";
5
+ export async function revokeMemberAccess(input) {
6
+ const expectedRevision = numberValue(input.current.revision);
7
+ const generation = numberValue(input.current.join_generation) + 1;
8
+ const now = new Date();
9
+ const records = input.services.get(HELYX_SERVICE_NAMES.records);
10
+ const updated = await records.updateOne({
11
+ moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
12
+ collection: "member_summaries",
13
+ where: {
14
+ guild_id: input.guildId,
15
+ member_user_id: input.memberUserId,
16
+ },
17
+ expected: { field: "revision", value: expectedRevision },
18
+ requireModuleEnabledForGuildId: input.guildId,
19
+ values: {
20
+ join_generation: generation,
21
+ deadline_at: now,
22
+ pending_reason: "none",
23
+ left_at: new Date(0),
24
+ last_decision: "revoked",
25
+ revision: expectedRevision + 1,
26
+ retention_until: new Date(now.getTime() + RETENTION_MS),
27
+ updated_at: now,
28
+ },
29
+ schedule: {
30
+ taskKind: REVOCATION_RECONCILE_TASK,
31
+ guildId: input.guildId,
32
+ idempotencyKey: `${input.guildId}:${input.memberUserId}:revoke:${generation}`,
33
+ payload: {
34
+ guildId: input.guildId,
35
+ memberUserId: input.memberUserId,
36
+ generation: String(generation),
37
+ },
38
+ scheduledFor: now,
39
+ },
40
+ audit: {
41
+ guildId: input.guildId,
42
+ actorUserId: input.actorUserId,
43
+ action: "account_gate.access_revoked",
44
+ source: "dashboard",
45
+ correlationId: input.correlationId,
46
+ idempotencyKey: input.correlationId,
47
+ targetType: "account_gate_member",
48
+ targetId: input.memberUserId,
49
+ metadata: { reconciliation: "durable" },
50
+ },
51
+ });
52
+ if (!updated.updated)
53
+ return null;
54
+ await reconcileRevokedMember(input.services, input.guildId, input.memberUserId, generation, input.configuration).catch(() => undefined);
55
+ return {
56
+ ...input.current,
57
+ join_generation: generation,
58
+ deadline_at: now,
59
+ pending_reason: "none",
60
+ left_at: new Date(0),
61
+ last_decision: "revoked",
62
+ revision: expectedRevision + 1,
63
+ retention_until: new Date(now.getTime() + RETENTION_MS),
64
+ updated_at: now,
65
+ };
66
+ }
67
+ export function validateRevocationPayload(input) {
68
+ if (!input || typeof input !== "object" || Array.isArray(input))
69
+ throw new Error("Account Gate revocation payload is invalid");
70
+ const value = input;
71
+ if (typeof value.guildId !== "string" ||
72
+ !/^\d{17,20}$/u.test(value.guildId) ||
73
+ typeof value.memberUserId !== "string" ||
74
+ !/^\d{17,20}$/u.test(value.memberUserId) ||
75
+ typeof value.generation !== "string" ||
76
+ !/^[1-9]\d{0,9}$/u.test(value.generation))
77
+ throw new Error("Account Gate revocation payload is invalid");
78
+ return {
79
+ guildId: value.guildId,
80
+ memberUserId: value.memberUserId,
81
+ generation: value.generation,
82
+ };
83
+ }
84
+ export async function reconcileRevocationTask(context, task) {
85
+ const configurationRecord = await context.services
86
+ .get(HELYX_SERVICE_NAMES.configuration)
87
+ .get(task.payload.guildId, ACCOUNT_GATE_IDENTITY.moduleId);
88
+ if (!configurationRecord)
89
+ return {
90
+ outcome: "requires_review",
91
+ safeCode: "revocation-configuration-missing",
92
+ };
93
+ await reconcileRevokedMember(context.services, task.payload.guildId, task.payload.memberUserId, Number(task.payload.generation), parseAccountGateConfiguration(configurationRecord.value));
94
+ return { outcome: "completed" };
95
+ }
96
+ async function reconcileRevokedMember(services, guildId, memberUserId, generation, configuration) {
97
+ const summary = await loadMemberSummary(services.get(HELYX_SERVICE_NAMES.records), guildId, memberUserId);
98
+ if (summary?.last_decision !== "revoked" ||
99
+ numberValue(summary.join_generation) !== generation)
100
+ return;
101
+ await services
102
+ .get(HELYX_SERVICE_NAMES.memberRoles)
103
+ .reconcileApprovedRoles({
104
+ guildId,
105
+ memberUserId,
106
+ allowedRoleIds: [
107
+ configuration.verifiedRoleId,
108
+ ...configuration.temporaryRoleIds,
109
+ ],
110
+ desiredRoleIds: configuration.temporaryRoleIds,
111
+ moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
112
+ reason: "Access Control dashboard access revocation",
113
+ });
114
+ const sessions = services.get(HELYX_SERVICE_NAMES.sessions);
115
+ const session = await sessions.getFor(guildId, memberUserId, ACCOUNT_GATE_SESSION_PURPOSE);
116
+ if (!session)
117
+ return;
118
+ try {
119
+ if (parseAccountGateSessionState(session.state).joinGeneration >= generation)
120
+ return;
121
+ }
122
+ catch {
123
+ // Invalid module-owned state is safe to discard after revocation committed.
124
+ }
125
+ await sessions.delete(session.sessionId);
126
+ }
127
+ //# sourceMappingURL=member-revocation.js.map
@@ -0,0 +1,5 @@
1
+ import type { CommandInteractionContext, MessageResponse, ModalCapableInteractionContext } from "./runtime-dependencies.js";
2
+ export declare function startCheck(context: CommandInteractionContext | ModalCapableInteractionContext): Promise<void>;
3
+ export declare function startPanelId(customId: string | undefined): string | null;
4
+ export declare function terminalRecoveryMessage(outcome: "passed" | "failed", decision: string): MessageResponse;
5
+ //# sourceMappingURL=member-start.d.ts.map