@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/records.js CHANGED
@@ -11,6 +11,7 @@ export const accountGateRecords = {
11
11
  password_hash: { type: "string", optional: true },
12
12
  password_ignore_case: { type: "boolean", optional: true },
13
13
  password_trim_whitespace: { type: "boolean", optional: true },
14
+ status: { type: "string" },
14
15
  revision: { type: "number" },
15
16
  created_at: { type: "date" },
16
17
  updated_at: { type: "date" },
@@ -26,6 +27,7 @@ export const accountGateRecords = {
26
27
  "password_hash",
27
28
  "password_ignore_case",
28
29
  "password_trim_whitespace",
30
+ "status",
29
31
  "revision",
30
32
  "created_at",
31
33
  "updated_at",
@@ -38,11 +40,40 @@ export const accountGateRecords = {
38
40
  "password_hash",
39
41
  "password_ignore_case",
40
42
  "password_trim_whitespace",
43
+ "status",
41
44
  "revision",
42
45
  "updated_at",
43
46
  ],
44
47
  orderKeys: [["guild_id", "created_at", "panel_id"]],
45
48
  },
49
+ panel_message_cleanup: {
50
+ table: "account_gate_panel_message_cleanup",
51
+ fields: {
52
+ cleanup_id: { type: "string" },
53
+ guild_id: { type: "string" },
54
+ panel_id: { type: "string" },
55
+ channel_id: { type: "string" },
56
+ message_id: { type: "string" },
57
+ attempts: { type: "number" },
58
+ next_attempt_at: { type: "date" },
59
+ completed_at: { type: "date", optional: true },
60
+ created_at: { type: "date" },
61
+ },
62
+ uniqueKeys: [["cleanup_id"], ["guild_id", "channel_id", "message_id"]],
63
+ readableFields: [
64
+ "cleanup_id",
65
+ "guild_id",
66
+ "panel_id",
67
+ "channel_id",
68
+ "message_id",
69
+ "attempts",
70
+ "next_attempt_at",
71
+ "completed_at",
72
+ "created_at",
73
+ ],
74
+ writableFields: ["attempts", "next_attempt_at", "completed_at"],
75
+ orderKeys: [["next_attempt_at", "cleanup_id"]],
76
+ },
46
77
  questions: {
47
78
  table: "account_gate_questions",
48
79
  fields: {
@@ -0,0 +1,3 @@
1
+ import type { ModalCapableInteractionContext } from "./runtime-dependencies.js";
2
+ export declare function handleReviewDecision(context: ModalCapableInteractionContext, decision: "approve" | "retry" | "kick" | "dismiss"): Promise<void>;
3
+ //# sourceMappingURL=review-decisions.d.ts.map
@@ -0,0 +1,274 @@
1
+ import { HELYX_SERVICE_NAMES, ACCOUNT_GATE_IDENTITY, ACCOUNT_GATE_SESSION_PURPOSE, applyRoleTransition, neutralKickDirectMessage, accountGateRecords, isAccountGateWorkSuppressed, parseAccountGateSessionState, emitAccountGateCompletedLog, emitAccountGateKickLog, successfulKickOutcome, VIOLET, REVIEW_CLAIM_RECOVERY_TASK, } from "./runtime-dependencies.js";
2
+ import { markSummaryPassed, resetSummaryForRetry } from "./member-summary.js";
3
+ import { finalizeReviewClaim } from "./review-store.js";
4
+ import { completeReviewInteraction } from "./review-presentation.js";
5
+ import { persistPartialTransition } from "./role-recovery.js";
6
+ import { dateValue, numberValue, replyError, stringValue, } from "./runtime-support.js";
7
+ import { requiredRuntimeConfiguration } from "./setup-presentation.js";
8
+ import { compensateVerifiedRoleAddition, reviewStillCurrent, } from "./workflow-fences.js";
9
+ export async function handleReviewDecision(context, decision) {
10
+ if (!context.serverId || !context.messageId) {
11
+ await replyError(context, "This review action is unavailable.");
12
+ return;
13
+ }
14
+ const authorised = await context.services
15
+ .get(HELYX_SERVICE_NAMES.resourceAuthorization)
16
+ .canUse({
17
+ guildId: context.serverId,
18
+ moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
19
+ permissionId: "account-gate.review",
20
+ actor: {
21
+ userId: context.userId,
22
+ roleIds: context.userRoleIds,
23
+ permissions: context.userPermissions ?? [],
24
+ isServerOwner: context.isServerOwner ?? false,
25
+ isAdministrator: context.isAdministrator,
26
+ },
27
+ });
28
+ if (!authorised) {
29
+ await replyError(context, "You are not authorised to manage Access Control reviews.");
30
+ return;
31
+ }
32
+ const records = context.services.get(HELYX_SERVICE_NAMES.records);
33
+ const row = await records.findOne({
34
+ moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
35
+ collection: "reviews",
36
+ select: accountGateRecords.reviews.readableFields,
37
+ where: {
38
+ guild_id: context.serverId,
39
+ review_message_id: context.messageId,
40
+ },
41
+ });
42
+ if (!row) {
43
+ await replyError(context, "This review record is no longer available.");
44
+ return;
45
+ }
46
+ if (row.status !== "pending") {
47
+ await context.reply({
48
+ ephemeral: true,
49
+ embed: {
50
+ title: "Review already handled",
51
+ description: `This review is already ${stringValue(row.status)}.`,
52
+ color: VIOLET,
53
+ },
54
+ });
55
+ return;
56
+ }
57
+ const memberUserId = stringValue(row.member_user_id);
58
+ if (await isAccountGateWorkSuppressed(context.services, context.serverId, memberUserId, dateValue(row.created_at) ?? new Date(0))) {
59
+ await replyError(context, "This review was removed by a privacy request. No Discord action was taken.");
60
+ return;
61
+ }
62
+ const revision = numberValue(row.revision);
63
+ const configuration = await requiredRuntimeConfiguration(context.services, context.serverId);
64
+ const reviewGeneration = numberValue(row.join_generation);
65
+ if (!(await reviewStillCurrent(context.services, context.serverId, memberUserId, reviewGeneration, configuration.verifiedRoleId))) {
66
+ await replyError(context, "This review belongs to an older join or is no longer actionable.");
67
+ return;
68
+ }
69
+ const claimStatus = `claim_${decision}`;
70
+ const claimExpiresAt = new Date(Date.now() + 2 * 60_000);
71
+ const claimed = await records.updateOne({
72
+ moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
73
+ collection: "reviews",
74
+ where: {
75
+ guild_id: context.serverId,
76
+ review_id: stringValue(row.review_id),
77
+ },
78
+ expected: { field: "revision", value: revision },
79
+ values: {
80
+ pending_key: `resolved:${stringValue(row.review_id)}`,
81
+ status: claimStatus,
82
+ claim_action: decision,
83
+ claim_expires_at: claimExpiresAt,
84
+ decided_by_user_id: context.userId,
85
+ decided_at: new Date(),
86
+ revision: revision + 1,
87
+ updated_at: new Date(),
88
+ },
89
+ schedule: {
90
+ taskKind: REVIEW_CLAIM_RECOVERY_TASK,
91
+ guildId: context.serverId,
92
+ idempotencyKey: `${context.serverId}:${stringValue(row.review_id)}:claim:${revision + 1}`,
93
+ payload: {
94
+ guildId: context.serverId,
95
+ reviewId: stringValue(row.review_id),
96
+ },
97
+ scheduledFor: claimExpiresAt,
98
+ },
99
+ });
100
+ if (!claimed.updated) {
101
+ await replyError(context, "This review changed in another session. Reload it before trying again.");
102
+ return;
103
+ }
104
+ const claimRevision = revision + 1;
105
+ if (!(await reviewStillCurrent(context.services, context.serverId, memberUserId, reviewGeneration, configuration.verifiedRoleId))) {
106
+ await finalizeReviewClaim(records, context.serverId, stringValue(row.review_id), claimRevision, "dismissed", { ...row, decided_by_user_id: context.userId, decided_at: new Date() }, false);
107
+ await replyError(context, "This review changed before the Discord action could begin.");
108
+ return;
109
+ }
110
+ if (await isAccountGateWorkSuppressed(context.services, context.serverId, memberUserId, dateValue(row.created_at) ?? new Date(0))) {
111
+ await finalizeReviewClaim(records, context.serverId, stringValue(row.review_id), claimRevision, "dismissed", { ...row, decided_by_user_id: context.userId, decided_at: new Date() }, false);
112
+ await replyError(context, "This review was removed by a privacy request. No Discord action was taken.");
113
+ return;
114
+ }
115
+ let status;
116
+ let completionRoleOutcome = null;
117
+ let kickResult = null;
118
+ if (decision === "approve") {
119
+ const transition = await applyRoleTransition({
120
+ guildId: context.serverId,
121
+ memberUserId,
122
+ verifiedRoleId: configuration.verifiedRoleId,
123
+ temporaryRoleIds: configuration.temporaryRoleIds,
124
+ resources: context.services.get(HELYX_SERVICE_NAMES.discordResources),
125
+ roles: context.services.get(HELYX_SERVICE_NAMES.memberRoles),
126
+ revalidate: () => reviewStillCurrent(context.services, context.serverId, memberUserId, reviewGeneration, configuration.verifiedRoleId, true),
127
+ });
128
+ if (transition.outcome === "stale") {
129
+ await finalizeReviewClaim(records, context.serverId, stringValue(row.review_id), claimRevision, "dismissed", { ...row, decided_by_user_id: context.userId, decided_at: new Date() }, false);
130
+ await replyError(context, "The member rejoined before approval could mutate roles; the old review was dismissed.");
131
+ return;
132
+ }
133
+ if (transition.outcome === "blocked") {
134
+ await replyError(context, "Helyx still cannot apply the access role. The claimed approval remains queued for safe recovery.");
135
+ return;
136
+ }
137
+ if (transition.outcome === "partial") {
138
+ await persistPartialTransition(context.services, context.serverId, memberUserId, configuration.verifiedRoleId, transition.pendingRoleIds, dateValue(row.created_at) ?? new Date(0));
139
+ }
140
+ if (!(await markSummaryPassed(records, context.serverId, memberUserId, new Date(), reviewGeneration))) {
141
+ await compensateVerifiedRoleAddition(context.services, context.serverId, memberUserId, configuration.verifiedRoleId, transition);
142
+ await finalizeReviewClaim(records, context.serverId, stringValue(row.review_id), claimRevision, "dismissed", { ...row, decided_by_user_id: context.userId, decided_at: new Date() }, false);
143
+ await replyError(context, "The member rejoined before approval completed; the old review was dismissed.");
144
+ return;
145
+ }
146
+ completionRoleOutcome = transition.outcome;
147
+ status = "approved";
148
+ }
149
+ else if (decision === "retry") {
150
+ const sessions = context.services.get(HELYX_SERVICE_NAMES.sessions);
151
+ const active = await sessions.getFor(context.serverId, memberUserId, ACCOUNT_GATE_SESSION_PURPOSE);
152
+ const reset = await resetSummaryForRetry(records, context.serverId, memberUserId, new Date(), reviewGeneration);
153
+ if (!reset) {
154
+ await finalizeReviewClaim(records, context.serverId, stringValue(row.review_id), claimRevision, "dismissed", { ...row, decided_by_user_id: context.userId, decided_at: new Date() }, false);
155
+ await replyError(context, "This review belongs to an older join and was not changed.");
156
+ return;
157
+ }
158
+ if (active) {
159
+ try {
160
+ if (parseAccountGateSessionState(active.state).joinGeneration ===
161
+ reviewGeneration)
162
+ await sessions.delete(active.sessionId).catch(() => undefined);
163
+ }
164
+ catch {
165
+ await sessions.delete(active.sessionId).catch(() => undefined);
166
+ }
167
+ }
168
+ status = "retry";
169
+ }
170
+ else if (decision === "kick") {
171
+ const result = await context.services
172
+ .get(HELYX_SERVICE_NAMES.memberEnforcement)
173
+ .kick({
174
+ guildId: context.serverId,
175
+ memberUserId,
176
+ moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
177
+ reason: "Access Control staff review decision",
178
+ directMessage: {
179
+ embed: {
180
+ title: "Removed after an Access Control review",
181
+ description: neutralKickDirectMessage(`Discord server ${context.serverId}`),
182
+ color: VIOLET,
183
+ },
184
+ },
185
+ revalidate: async () => {
186
+ const [enabled, current, session] = await Promise.all([
187
+ context.services
188
+ .get(HELYX_SERVICE_NAMES.installations)
189
+ .isModuleEnabled(context.serverId, ACCOUNT_GATE_IDENTITY.moduleId),
190
+ records.findOne({
191
+ moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
192
+ collection: "reviews",
193
+ select: ["status", "revision"],
194
+ where: {
195
+ guild_id: context.serverId,
196
+ review_id: stringValue(row.review_id),
197
+ },
198
+ }),
199
+ context.services
200
+ .get(HELYX_SERVICE_NAMES.sessions)
201
+ .getFor(context.serverId, memberUserId, ACCOUNT_GATE_SESSION_PURPOSE),
202
+ ]);
203
+ return (enabled &&
204
+ current?.status === claimStatus &&
205
+ current.revision === claimRevision &&
206
+ session === null &&
207
+ (await reviewStillCurrent(context.services, context.serverId, memberUserId, reviewGeneration, configuration.verifiedRoleId)) &&
208
+ !(await isAccountGateWorkSuppressed(context.services, context.serverId, memberUserId, dateValue(row.created_at) ?? new Date(0))));
209
+ },
210
+ });
211
+ if (!["kicked", "member_missing"].includes(result.outcome)) {
212
+ await replyError(context, `The member was not kicked (${result.outcome.replaceAll("_", " ")}). The claimed decision remains queued for safe recovery.`);
213
+ return;
214
+ }
215
+ kickResult = {
216
+ outcome: successfulKickOutcome(result.outcome),
217
+ directMessage: result.directMessage,
218
+ };
219
+ status = "kicked";
220
+ }
221
+ else {
222
+ status = "dismissed";
223
+ }
224
+ const now = new Date();
225
+ const updated = await records.updateOne({
226
+ moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
227
+ collection: "reviews",
228
+ where: {
229
+ guild_id: context.serverId,
230
+ review_id: stringValue(row.review_id),
231
+ },
232
+ expected: { field: "revision", value: claimRevision },
233
+ values: {
234
+ pending_key: `resolved:${stringValue(row.review_id)}`,
235
+ status,
236
+ claim_action: "",
237
+ claim_expires_at: new Date(0),
238
+ decided_by_user_id: context.userId,
239
+ decided_at: now,
240
+ revision: claimRevision + 1,
241
+ updated_at: now,
242
+ },
243
+ audit: {
244
+ guildId: context.serverId,
245
+ actorUserId: context.userId,
246
+ action: `account_gate.review_${status}`,
247
+ source: "discord",
248
+ correlationId: context.interactionId,
249
+ idempotencyKey: context.interactionId,
250
+ targetType: "account_gate_review",
251
+ targetId: stringValue(row.review_id),
252
+ metadata: { memberUserId, decision: status },
253
+ },
254
+ });
255
+ if (!updated.updated) {
256
+ await replyError(context, "This review changed in another session. Reload it before trying again.");
257
+ return;
258
+ }
259
+ if (status === "kicked" && kickResult)
260
+ await emitAccountGateKickLog(context.services, context.serverId, {
261
+ memberUserId,
262
+ ...kickResult,
263
+ source: "Staff review",
264
+ idempotencyKey: `account-gate-kick:review:${stringValue(row.review_id)}`,
265
+ });
266
+ if (status === "approved" && completionRoleOutcome)
267
+ await emitAccountGateCompletedLog(context.services, context.serverId, {
268
+ memberUserId,
269
+ roleOutcome: completionRoleOutcome,
270
+ idempotencyKey: `account-gate-completed:review:${stringValue(row.review_id)}`,
271
+ });
272
+ await completeReviewInteraction(context, context.messageId, memberUserId, status);
273
+ }
274
+ //# sourceMappingURL=review-decisions.js.map
@@ -0,0 +1,34 @@
1
+ import type { RuntimeContext, ScheduledTaskPayload } from "./runtime-dependencies.js";
2
+ export declare function validateReviewDeliveryPayload(input: unknown): ScheduledTaskPayload;
3
+ export declare function deliverReview(context: RuntimeContext, task: {
4
+ payload: ScheduledTaskPayload;
5
+ }): Promise<{
6
+ outcome: "completed";
7
+ safeCode?: never;
8
+ delayMs?: never;
9
+ } | {
10
+ outcome: "requires_review";
11
+ safeCode: string;
12
+ delayMs?: never;
13
+ } | {
14
+ outcome: "retry";
15
+ delayMs: number;
16
+ safeCode: string;
17
+ }>;
18
+ export declare function validateReviewClaimRecoveryPayload(input: unknown): ScheduledTaskPayload;
19
+ export declare function recoverReviewClaim(context: RuntimeContext, task: {
20
+ payload: ScheduledTaskPayload;
21
+ }): Promise<{
22
+ outcome: "completed";
23
+ delayMs?: never;
24
+ safeCode?: never;
25
+ } | {
26
+ outcome: "deferred";
27
+ delayMs: number;
28
+ safeCode: string;
29
+ } | {
30
+ outcome: "retry";
31
+ delayMs: number;
32
+ safeCode: string;
33
+ }>;
34
+ //# sourceMappingURL=review-delivery.d.ts.map
@@ -0,0 +1,252 @@
1
+ import { HELYX_SERVICE_NAMES, ACCOUNT_GATE_IDENTITY, applyRoleTransition, neutralKickDirectMessage, accountGateRecords, isAccountGateWorkSuppressed, emitAccountGateCompletedLog, emitAccountGateKickLog, successfulKickOutcome, VIOLET, } from "./runtime-dependencies.js";
2
+ import { loadMemberSummary, markSummaryPassed, resetSummaryForRetry, } from "./member-summary.js";
3
+ import { finalizeReviewClaim } from "./review-store.js";
4
+ import { compensateVerifiedRoleAddition, reviewStillCurrent, } from "./workflow-fences.js";
5
+ import { persistPartialTransition } from "./role-recovery.js";
6
+ import { dateValue, numberValue, stringValue } from "./runtime-support.js";
7
+ import { getConfiguration, requiredRuntimeConfiguration, reviewMessage, } from "./setup-presentation.js";
8
+ export function validateReviewDeliveryPayload(input) {
9
+ if (!input || typeof input !== "object" || Array.isArray(input))
10
+ throw new Error("Account Gate review-delivery payload is invalid");
11
+ const value = input;
12
+ if (typeof value.guildId !== "string" ||
13
+ typeof value.reviewId !== "string" ||
14
+ value.reviewId.length > 100)
15
+ throw new Error("Account Gate review-delivery payload is invalid");
16
+ return { guildId: value.guildId, reviewId: value.reviewId };
17
+ }
18
+ export async function deliverReview(context, task) {
19
+ const guildId = task.payload.guildId;
20
+ const reviewId = task.payload.reviewId;
21
+ const enabled = await context.services
22
+ .get(HELYX_SERVICE_NAMES.installations)
23
+ .isModuleEnabled(guildId, ACCOUNT_GATE_IDENTITY.moduleId);
24
+ if (!enabled)
25
+ return { outcome: "completed" };
26
+ const records = context.services.get(HELYX_SERVICE_NAMES.records);
27
+ const row = await records.findOne({
28
+ moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
29
+ collection: "reviews",
30
+ select: accountGateRecords.reviews.readableFields,
31
+ where: { guild_id: guildId, review_id: reviewId },
32
+ });
33
+ if (!row || row.status !== "pending" || row.delivery_status === "sent")
34
+ return { outcome: "completed" };
35
+ if (await isAccountGateWorkSuppressed(context.services, guildId, stringValue(row.member_user_id), dateValue(row.created_at) ?? new Date(0)))
36
+ return { outcome: "completed" };
37
+ const configuration = await getConfiguration(context.services, guildId);
38
+ if (!configuration?.reviewChannelId)
39
+ return {
40
+ outcome: "requires_review",
41
+ safeCode: "review-channel-unavailable",
42
+ };
43
+ try {
44
+ const sent = await context.services
45
+ .get(HELYX_SERVICE_NAMES.discordMessages)
46
+ .send({
47
+ guildId,
48
+ channelId: configuration.reviewChannelId,
49
+ idempotencyKey: `account-gate-review:${guildId}:${reviewId}`,
50
+ response: reviewMessage(stringValue(row.member_user_id), stringValue(row.reason_code)),
51
+ });
52
+ const revision = numberValue(row.revision);
53
+ const stored = await records.updateOne({
54
+ moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
55
+ collection: "reviews",
56
+ where: { guild_id: guildId, review_id: reviewId },
57
+ expected: { field: "revision", value: revision },
58
+ values: {
59
+ delivery_status: "sent",
60
+ review_channel_id: configuration.reviewChannelId,
61
+ review_message_id: sent.messageId,
62
+ revision: revision + 1,
63
+ updated_at: new Date(),
64
+ },
65
+ });
66
+ return stored.updated
67
+ ? { outcome: "completed" }
68
+ : {
69
+ outcome: "retry",
70
+ delayMs: 5_000,
71
+ safeCode: "review-delivery-cas",
72
+ };
73
+ }
74
+ catch {
75
+ return {
76
+ outcome: "retry",
77
+ delayMs: 30_000,
78
+ safeCode: "review-delivery-failed",
79
+ };
80
+ }
81
+ }
82
+ export function validateReviewClaimRecoveryPayload(input) {
83
+ if (!input || typeof input !== "object" || Array.isArray(input))
84
+ throw new Error("Account Gate review-claim payload is invalid");
85
+ const value = input;
86
+ if (typeof value.guildId !== "string" ||
87
+ !/^\d{17,20}$/u.test(value.guildId) ||
88
+ typeof value.reviewId !== "string")
89
+ throw new Error("Account Gate review-claim payload is invalid");
90
+ return {
91
+ guildId: value.guildId,
92
+ reviewId: value.reviewId,
93
+ };
94
+ }
95
+ export async function recoverReviewClaim(context, task) {
96
+ const guildId = task.payload.guildId;
97
+ const reviewId = task.payload.reviewId;
98
+ const records = context.services.get(HELYX_SERVICE_NAMES.records);
99
+ const row = await records.findOne({
100
+ moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
101
+ collection: "reviews",
102
+ select: accountGateRecords.reviews.readableFields,
103
+ where: { guild_id: guildId, review_id: reviewId },
104
+ });
105
+ if (!row || !stringValue(row.status).startsWith("claim_"))
106
+ return { outcome: "completed" };
107
+ const claimExpiresAt = dateValue(row.claim_expires_at);
108
+ if (claimExpiresAt && claimExpiresAt.getTime() > Date.now())
109
+ return {
110
+ outcome: "deferred",
111
+ delayMs: Math.max(100, claimExpiresAt.getTime() - Date.now()),
112
+ safeCode: "review-claim-not-due",
113
+ };
114
+ const generation = numberValue(row.join_generation);
115
+ const claimRevision = numberValue(row.revision);
116
+ const summary = await loadMemberSummary(records, guildId, stringValue(row.member_user_id));
117
+ const memberUserId = stringValue(row.member_user_id);
118
+ const enabled = await context.services
119
+ .get(HELYX_SERVICE_NAMES.installations)
120
+ .isModuleEnabled(guildId, ACCOUNT_GATE_IDENTITY.moduleId);
121
+ if (!enabled ||
122
+ !summary ||
123
+ numberValue(summary.join_generation) !== generation ||
124
+ (await isAccountGateWorkSuppressed(context.services, guildId, memberUserId, dateValue(row.created_at) ?? new Date(0)))) {
125
+ await finalizeReviewClaim(records, guildId, reviewId, claimRevision, "dismissed", row);
126
+ return { outcome: "completed" };
127
+ }
128
+ let configuration;
129
+ try {
130
+ configuration = await requiredRuntimeConfiguration(context.services, guildId);
131
+ }
132
+ catch {
133
+ return {
134
+ outcome: "retry",
135
+ delayMs: 30_000,
136
+ safeCode: "review-claim-setup-invalid",
137
+ };
138
+ }
139
+ const action = stringValue(row.claim_action);
140
+ if (action === "approve") {
141
+ let completionRoleOutcome = null;
142
+ if (summary.last_decision !== "passed") {
143
+ const snapshots = await context.services
144
+ .get(HELYX_SERVICE_NAMES.discordResources)
145
+ .resolveMemberSnapshots({ guildId, userIds: [memberUserId] });
146
+ const verifiedRoleAlreadyApplied = snapshots[0]?.exists === true &&
147
+ snapshots[0].roleIds.includes(configuration.verifiedRoleId);
148
+ if (!verifiedRoleAlreadyApplied &&
149
+ !(await reviewStillCurrent(context.services, guildId, memberUserId, generation, configuration.verifiedRoleId))) {
150
+ await finalizeReviewClaim(records, guildId, reviewId, claimRevision, "dismissed", row);
151
+ return { outcome: "completed" };
152
+ }
153
+ const transition = await applyRoleTransition({
154
+ guildId,
155
+ memberUserId,
156
+ verifiedRoleId: configuration.verifiedRoleId,
157
+ temporaryRoleIds: configuration.temporaryRoleIds,
158
+ resources: context.services.get(HELYX_SERVICE_NAMES.discordResources),
159
+ roles: context.services.get(HELYX_SERVICE_NAMES.memberRoles),
160
+ revalidate: () => reviewStillCurrent(context.services, guildId, memberUserId, generation, configuration.verifiedRoleId, true),
161
+ });
162
+ if (transition.outcome === "stale") {
163
+ await finalizeReviewClaim(records, guildId, reviewId, claimRevision, "dismissed", row);
164
+ return { outcome: "completed" };
165
+ }
166
+ if (transition.outcome === "blocked")
167
+ return {
168
+ outcome: "retry",
169
+ delayMs: 30_000,
170
+ safeCode: "review-approve-role-blocked",
171
+ };
172
+ if (transition.outcome === "partial")
173
+ await persistPartialTransition(context.services, guildId, memberUserId, configuration.verifiedRoleId, transition.pendingRoleIds, dateValue(row.created_at) ?? new Date(0));
174
+ if (!(await markSummaryPassed(records, guildId, memberUserId, new Date(), generation))) {
175
+ await compensateVerifiedRoleAddition(context.services, guildId, memberUserId, configuration.verifiedRoleId, transition);
176
+ await finalizeReviewClaim(records, guildId, reviewId, claimRevision, "dismissed", row);
177
+ return { outcome: "completed" };
178
+ }
179
+ completionRoleOutcome = transition.outcome;
180
+ }
181
+ const finalized = await finalizeReviewClaim(records, guildId, reviewId, claimRevision, "approved", row);
182
+ if (finalized && completionRoleOutcome)
183
+ await emitAccountGateCompletedLog(context.services, guildId, {
184
+ memberUserId,
185
+ roleOutcome: completionRoleOutcome,
186
+ idempotencyKey: `account-gate-completed:review:${reviewId}`,
187
+ });
188
+ return { outcome: "completed" };
189
+ }
190
+ if (action === "retry") {
191
+ if (summary.last_decision !== "pending") {
192
+ const reset = await resetSummaryForRetry(records, guildId, memberUserId, new Date(), generation);
193
+ if (!reset) {
194
+ await finalizeReviewClaim(records, guildId, reviewId, claimRevision, "dismissed", row);
195
+ return { outcome: "completed" };
196
+ }
197
+ }
198
+ await finalizeReviewClaim(records, guildId, reviewId, claimRevision, "retry", row);
199
+ return { outcome: "completed" };
200
+ }
201
+ if (action === "kick") {
202
+ const snapshots = await context.services
203
+ .get(HELYX_SERVICE_NAMES.discordResources)
204
+ .resolveMemberSnapshots({ guildId, userIds: [memberUserId] });
205
+ let kickOutcome = "member_missing";
206
+ let directMessage = "not_requested";
207
+ if (snapshots[0]?.exists === true) {
208
+ const result = await context.services
209
+ .get(HELYX_SERVICE_NAMES.memberEnforcement)
210
+ .kick({
211
+ guildId,
212
+ memberUserId,
213
+ moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
214
+ reason: "Access Control staff review decision recovery",
215
+ directMessage: {
216
+ embed: {
217
+ title: "Removed after an Access Control review",
218
+ description: neutralKickDirectMessage(`Discord server ${guildId}`),
219
+ color: VIOLET,
220
+ },
221
+ },
222
+ revalidate: () => reviewStillCurrent(context.services, guildId, memberUserId, generation, configuration.verifiedRoleId),
223
+ });
224
+ if (!["kicked", "member_missing"].includes(result.outcome))
225
+ if (!(await reviewStillCurrent(context.services, guildId, memberUserId, generation, configuration.verifiedRoleId))) {
226
+ await finalizeReviewClaim(records, guildId, reviewId, claimRevision, "dismissed", row);
227
+ return { outcome: "completed" };
228
+ }
229
+ else
230
+ return {
231
+ outcome: "retry",
232
+ delayMs: 30_000,
233
+ safeCode: "review-kick-not-complete",
234
+ };
235
+ kickOutcome = successfulKickOutcome(result.outcome);
236
+ directMessage = result.directMessage;
237
+ }
238
+ const finalized = await finalizeReviewClaim(records, guildId, reviewId, claimRevision, "kicked", row);
239
+ if (finalized)
240
+ await emitAccountGateKickLog(context.services, guildId, {
241
+ memberUserId,
242
+ outcome: kickOutcome,
243
+ directMessage,
244
+ source: "Staff review recovery",
245
+ idempotencyKey: `account-gate-kick:review:${reviewId}`,
246
+ });
247
+ return { outcome: "completed" };
248
+ }
249
+ await finalizeReviewClaim(records, guildId, reviewId, claimRevision, "dismissed", row);
250
+ return { outcome: "completed" };
251
+ }
252
+ //# sourceMappingURL=review-delivery.js.map
@@ -0,0 +1,3 @@
1
+ import type { ModalCapableInteractionContext } from "./runtime-dependencies.js";
2
+ export declare function completeReviewInteraction(context: ModalCapableInteractionContext, messageId: string, memberUserId: string, status: "approved" | "retry" | "kicked" | "dismissed"): Promise<void>;
3
+ //# sourceMappingURL=review-presentation.d.ts.map
@@ -0,0 +1,28 @@
1
+ import { GREEN, VIOLET } from "./runtime-dependencies.js";
2
+ export async function completeReviewInteraction(context, messageId, memberUserId, status) {
3
+ if (context.channelId)
4
+ await context
5
+ .editChannelMessage(context.channelId, messageId, {
6
+ allowedUserMentionIds: [],
7
+ componentsV2: {
8
+ accentColor: VIOLET,
9
+ text: [
10
+ { content: "## Access Control review resolved" },
11
+ { content: `Member ID: ${memberUserId}`, markdown: false },
12
+ { content: `Decision: ${status}`, markdown: false },
13
+ ],
14
+ },
15
+ })
16
+ .catch(() => undefined);
17
+ await context
18
+ .reply({
19
+ ephemeral: true,
20
+ embed: {
21
+ title: "Review updated",
22
+ description: `The review is now ${status}.`,
23
+ color: GREEN,
24
+ },
25
+ })
26
+ .catch(() => undefined);
27
+ }
28
+ //# sourceMappingURL=review-presentation.js.map
@@ -0,0 +1,4 @@
1
+ import type { ModuleRecordService, ServiceAccess, AccountGateSessionState } from "./runtime-dependencies.js";
2
+ export declare function createReview(services: ServiceAccess, guildId: string, memberUserId: string, reasonCode: string, state: AccountGateSessionState | null, expectedJoinGeneration?: number, boundedChallengeTypes?: readonly string[], workCreatedAtOverride?: Date): Promise<void>;
3
+ export declare function finalizeReviewClaim(records: ModuleRecordService, guildId: string, reviewId: string, claimRevision: number, status: "approved" | "retry" | "kicked" | "dismissed", row: Readonly<Record<string, unknown>>, expiredOnly?: boolean): Promise<boolean>;
4
+ //# sourceMappingURL=review-store.d.ts.map