@helyx/module-account-gate 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/LICENSE +725 -0
- package/README.md +90 -0
- package/dist/challenges.d.ts +16 -0
- package/dist/challenges.js +233 -0
- package/dist/configuration.d.ts +41 -0
- package/dist/configuration.js +224 -0
- package/dist/contracts.d.ts +82 -0
- package/dist/contracts.js +56 -0
- package/dist/enforcement.d.ts +19 -0
- package/dist/enforcement.js +110 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +3324 -0
- package/dist/managed-resource-summary.d.ts +3 -0
- package/dist/managed-resource-summary.js +6 -0
- package/dist/member-resource.d.ts +3 -0
- package/dist/member-resource.js +348 -0
- package/dist/panel-resource.d.ts +15 -0
- package/dist/panel-resource.js +527 -0
- package/dist/password-entry.d.ts +8 -0
- package/dist/password-entry.js +59 -0
- package/dist/password-resource.d.ts +8 -0
- package/dist/password-resource.js +208 -0
- package/dist/privacy.d.ts +4 -0
- package/dist/privacy.js +20 -0
- package/dist/public-channel-resource.d.ts +4 -0
- package/dist/public-channel-resource.js +135 -0
- package/dist/question-resource.d.ts +3 -0
- package/dist/question-resource.js +246 -0
- package/dist/records.d.ts +508 -0
- package/dist/records.js +456 -0
- package/dist/workflow.d.ts +43 -0
- package/dist/workflow.js +167 -0
- package/manifest.json +750 -0
- package/migrations/0001_account_gate_foundation.sql +303 -0
- package/migrations/0002_account_gate_panel_message.sql +27 -0
- package/migrations/0003_account_gate_revoked_member_status.sql +17 -0
- package/migrations/0004_account_gate_multiple_panels.sql +23 -0
- package/migrations/0005_account_gate_panel_passwords.sql +45 -0
- package/package.json +54 -0
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { DashboardActionValidationError, HELYX_SERVICE_NAMES, } from "@helyx/sdk";
|
|
3
|
+
import { parseAccountGateConfiguration } from "./configuration.js";
|
|
4
|
+
import { ACCOUNT_GATE_IDENTITY, ACCOUNT_GATE_SESSION_PURPOSE, } from "./contracts.js";
|
|
5
|
+
import { accountGateRecords } from "./records.js";
|
|
6
|
+
const PAGE_SIZE = 10;
|
|
7
|
+
const SEARCH_RESULT_LIMIT = 25;
|
|
8
|
+
const RETENTION_MS = 90 * 24 * 60 * 60 * 1_000;
|
|
9
|
+
const DISCORD_SNOWFLAKE = /^\d{17,20}$/u;
|
|
10
|
+
export const accountGateMemberResource = {
|
|
11
|
+
id: "members",
|
|
12
|
+
async list(context, input) {
|
|
13
|
+
const search = input.search?.trim() ?? "";
|
|
14
|
+
if (search.length > 100)
|
|
15
|
+
throw new DashboardActionValidationError("Search using a Discord display name or user ID of up to 100 characters.");
|
|
16
|
+
if (search)
|
|
17
|
+
return searchKnownMembers(context, search, input.cursor);
|
|
18
|
+
const page = await memberRecords(context).findMany({
|
|
19
|
+
moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
|
|
20
|
+
collection: "member_summaries",
|
|
21
|
+
select: accountGateRecords.member_summaries.readableFields,
|
|
22
|
+
where: { guild_id: context.guildId },
|
|
23
|
+
orderBy: [
|
|
24
|
+
{ field: "guild_id", direction: "asc" },
|
|
25
|
+
{ field: "member_user_id", direction: "asc" },
|
|
26
|
+
],
|
|
27
|
+
...(input.cursor
|
|
28
|
+
? { cursor: decodeRecordCursor(input.cursor, context.guildId) }
|
|
29
|
+
: {}),
|
|
30
|
+
limit: PAGE_SIZE,
|
|
31
|
+
});
|
|
32
|
+
const identities = await resolveLiveIdentities(context, page.records.map((row) => stringValue(row.member_user_id)));
|
|
33
|
+
return {
|
|
34
|
+
items: page.records.map((row) => memberSummary(row, identities.get(stringValue(row.member_user_id)))),
|
|
35
|
+
...(page.nextCursor
|
|
36
|
+
? {
|
|
37
|
+
nextCursor: encodeRecordCursor(context.guildId, stringValue(page.nextCursor[1])),
|
|
38
|
+
}
|
|
39
|
+
: {}),
|
|
40
|
+
};
|
|
41
|
+
},
|
|
42
|
+
async read(context, resourceId) {
|
|
43
|
+
validateMemberId(resourceId);
|
|
44
|
+
const row = await loadMemberSummary(context, resourceId);
|
|
45
|
+
if (!row)
|
|
46
|
+
return null;
|
|
47
|
+
const identities = await resolveLiveIdentities(context, [resourceId]);
|
|
48
|
+
return memberDetail(row, identities.get(resourceId));
|
|
49
|
+
},
|
|
50
|
+
preview() {
|
|
51
|
+
return Promise.reject(unsupported());
|
|
52
|
+
},
|
|
53
|
+
create() {
|
|
54
|
+
return Promise.reject(unsupported());
|
|
55
|
+
},
|
|
56
|
+
update() {
|
|
57
|
+
return Promise.reject(unsupported());
|
|
58
|
+
},
|
|
59
|
+
delete() {
|
|
60
|
+
return Promise.reject(unsupported());
|
|
61
|
+
},
|
|
62
|
+
async executeAction(context, input) {
|
|
63
|
+
if (input.actionId !== "revoke")
|
|
64
|
+
throw new DashboardActionValidationError("This Access Control member action is not supported.");
|
|
65
|
+
validateMemberId(input.resourceId);
|
|
66
|
+
const row = await loadMemberSummary(context, input.resourceId);
|
|
67
|
+
if (!row)
|
|
68
|
+
throw new DashboardActionValidationError("Member not found.");
|
|
69
|
+
const revision = numberValue(row.revision);
|
|
70
|
+
if (revision !== input.expectedRevision)
|
|
71
|
+
throw new DashboardActionValidationError("This member changed in another session. Refresh and try again.");
|
|
72
|
+
const [member] = await discordResources(context).resolveMemberSnapshots({
|
|
73
|
+
guildId: context.guildId,
|
|
74
|
+
userIds: [input.resourceId],
|
|
75
|
+
});
|
|
76
|
+
if (!member?.exists)
|
|
77
|
+
throw new DashboardActionValidationError("This member has left the server. There is no live Discord access to revoke.");
|
|
78
|
+
if (member.isBot)
|
|
79
|
+
throw new DashboardActionValidationError("Bot accounts cannot be managed through Access Control.");
|
|
80
|
+
const configurationRecord = await context.services
|
|
81
|
+
.get(HELYX_SERVICE_NAMES.configuration)
|
|
82
|
+
.get(context.guildId, ACCOUNT_GATE_IDENTITY.moduleId);
|
|
83
|
+
if (!configurationRecord)
|
|
84
|
+
throw new DashboardActionValidationError("Save the Access Control settings before revoking access.");
|
|
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
|
+
const identities = await resolveLiveIdentities(context, [input.resourceId]);
|
|
158
|
+
return memberDetail(refreshed, identities.get(input.resourceId));
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
async function searchKnownMembers(context, search, cursor) {
|
|
162
|
+
if (DISCORD_SNOWFLAKE.test(search)) {
|
|
163
|
+
const row = await loadMemberSummary(context, search);
|
|
164
|
+
if (!row)
|
|
165
|
+
return { items: [] };
|
|
166
|
+
const identities = await resolveLiveIdentities(context, [search]);
|
|
167
|
+
return { items: [memberSummary(row, identities.get(search))] };
|
|
168
|
+
}
|
|
169
|
+
const offset = cursor ? decodeSearchCursor(cursor, search) : 0;
|
|
170
|
+
const matches = await discordResources(context).searchMembers({
|
|
171
|
+
guildId: context.guildId,
|
|
172
|
+
query: search,
|
|
173
|
+
limit: SEARCH_RESULT_LIMIT,
|
|
174
|
+
});
|
|
175
|
+
const known = (await Promise.all(matches
|
|
176
|
+
.filter(({ isBot }) => !isBot)
|
|
177
|
+
.map(async (identity) => ({
|
|
178
|
+
identity: {
|
|
179
|
+
userId: identity.userId,
|
|
180
|
+
displayName: identity.displayName,
|
|
181
|
+
avatarUrl: identity.avatarUrl ?? "",
|
|
182
|
+
},
|
|
183
|
+
row: await loadMemberSummary(context, identity.userId),
|
|
184
|
+
})))).filter((item) => item.row !== null);
|
|
185
|
+
const page = known.slice(offset, offset + PAGE_SIZE);
|
|
186
|
+
const nextOffset = offset + page.length;
|
|
187
|
+
return {
|
|
188
|
+
items: page.map(({ identity, row }) => memberSummary(row, identity)),
|
|
189
|
+
...(nextOffset < known.length
|
|
190
|
+
? { nextCursor: encodeSearchCursor(nextOffset, search) }
|
|
191
|
+
: {}),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
async function resolveLiveIdentities(context, userIds) {
|
|
195
|
+
if (userIds.length === 0)
|
|
196
|
+
return new Map();
|
|
197
|
+
const resolved = await discordResources(context).resolveMembers({
|
|
198
|
+
guildId: context.guildId,
|
|
199
|
+
userIds,
|
|
200
|
+
});
|
|
201
|
+
return new Map(resolved.flatMap((member) => member.displayName === null
|
|
202
|
+
? []
|
|
203
|
+
: [
|
|
204
|
+
[
|
|
205
|
+
member.userId,
|
|
206
|
+
{
|
|
207
|
+
userId: member.userId,
|
|
208
|
+
displayName: member.displayName,
|
|
209
|
+
avatarUrl: member.avatarUrl ?? "",
|
|
210
|
+
},
|
|
211
|
+
],
|
|
212
|
+
]));
|
|
213
|
+
}
|
|
214
|
+
function memberSummary(row, identity) {
|
|
215
|
+
const memberUserId = stringValue(row.member_user_id);
|
|
216
|
+
const completedAt = successfulDate(row.previous_success_at);
|
|
217
|
+
const status = displayStatus(stringValue(row.last_decision));
|
|
218
|
+
const updatedAt = dateValue(row.updated_at);
|
|
219
|
+
return {
|
|
220
|
+
id: memberUserId,
|
|
221
|
+
revision: numberValue(row.revision),
|
|
222
|
+
status,
|
|
223
|
+
title: identity?.displayName ?? "Member left server",
|
|
224
|
+
description: memberUserId,
|
|
225
|
+
attributes: {
|
|
226
|
+
memberUserId,
|
|
227
|
+
displayName: identity?.displayName ?? null,
|
|
228
|
+
avatarUrl: identity?.avatarUrl ?? null,
|
|
229
|
+
memberPresent: identity !== undefined,
|
|
230
|
+
completedAt: completedAt?.toISOString() ?? null,
|
|
231
|
+
},
|
|
232
|
+
updatedAt: updatedAt.toISOString(),
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
function memberDetail(row, identity) {
|
|
236
|
+
const summary = memberSummary(row, identity);
|
|
237
|
+
return {
|
|
238
|
+
...summary,
|
|
239
|
+
value: {
|
|
240
|
+
memberUserId: summary.id,
|
|
241
|
+
memberPresent: identity !== undefined,
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
function displayStatus(decision) {
|
|
246
|
+
if (decision === "passed")
|
|
247
|
+
return "passed";
|
|
248
|
+
if (decision === "review")
|
|
249
|
+
return "review";
|
|
250
|
+
if (decision === "revoked")
|
|
251
|
+
return "revoked";
|
|
252
|
+
if (decision === "pending" || decision === "finalizing")
|
|
253
|
+
return "pending";
|
|
254
|
+
return "failed";
|
|
255
|
+
}
|
|
256
|
+
async function loadMemberSummary(context, memberUserId) {
|
|
257
|
+
return memberRecords(context).findOne({
|
|
258
|
+
moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
|
|
259
|
+
collection: "member_summaries",
|
|
260
|
+
select: accountGateRecords.member_summaries.readableFields,
|
|
261
|
+
where: { guild_id: context.guildId, member_user_id: memberUserId },
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
function memberRecords(context) {
|
|
265
|
+
return context.services.get(HELYX_SERVICE_NAMES.records);
|
|
266
|
+
}
|
|
267
|
+
function discordResources(context) {
|
|
268
|
+
return context.services.get(HELYX_SERVICE_NAMES.discordResources);
|
|
269
|
+
}
|
|
270
|
+
function encodeRecordCursor(guildId, memberUserId) {
|
|
271
|
+
return Buffer.from(JSON.stringify({ version: 1, kind: "records", guildId, memberUserId })).toString("base64url");
|
|
272
|
+
}
|
|
273
|
+
function decodeRecordCursor(cursor, guildId) {
|
|
274
|
+
const value = decodeCursor(cursor);
|
|
275
|
+
if (value.kind !== "records" ||
|
|
276
|
+
value.guildId !== guildId ||
|
|
277
|
+
typeof value.memberUserId !== "string" ||
|
|
278
|
+
!DISCORD_SNOWFLAKE.test(value.memberUserId))
|
|
279
|
+
throw new DashboardActionValidationError("The Access Control member page cursor is invalid.");
|
|
280
|
+
return [guildId, value.memberUserId];
|
|
281
|
+
}
|
|
282
|
+
function encodeSearchCursor(offset, search) {
|
|
283
|
+
return Buffer.from(JSON.stringify({
|
|
284
|
+
version: 1,
|
|
285
|
+
kind: "search",
|
|
286
|
+
offset,
|
|
287
|
+
searchHash: searchHash(search),
|
|
288
|
+
})).toString("base64url");
|
|
289
|
+
}
|
|
290
|
+
function decodeSearchCursor(cursor, search) {
|
|
291
|
+
const value = decodeCursor(cursor);
|
|
292
|
+
if (value.kind !== "search" ||
|
|
293
|
+
value.searchHash !== searchHash(search) ||
|
|
294
|
+
typeof value.offset !== "number" ||
|
|
295
|
+
!Number.isSafeInteger(value.offset) ||
|
|
296
|
+
value.offset < 1 ||
|
|
297
|
+
value.offset > SEARCH_RESULT_LIMIT)
|
|
298
|
+
throw new DashboardActionValidationError("The Access Control member search cursor is invalid.");
|
|
299
|
+
return value.offset;
|
|
300
|
+
}
|
|
301
|
+
function decodeCursor(cursor) {
|
|
302
|
+
if (cursor.length < 1 || cursor.length > 500)
|
|
303
|
+
throw new DashboardActionValidationError("The Access Control member page cursor is invalid.");
|
|
304
|
+
try {
|
|
305
|
+
const value = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
|
|
306
|
+
if (!value ||
|
|
307
|
+
typeof value !== "object" ||
|
|
308
|
+
Array.isArray(value) ||
|
|
309
|
+
value.version !== 1)
|
|
310
|
+
throw new Error("invalid");
|
|
311
|
+
return value;
|
|
312
|
+
}
|
|
313
|
+
catch {
|
|
314
|
+
throw new DashboardActionValidationError("The Access Control member page cursor is invalid.");
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
function searchHash(search) {
|
|
318
|
+
return createHash("sha256")
|
|
319
|
+
.update(search.trim().toLocaleLowerCase("en-GB"))
|
|
320
|
+
.digest("base64url");
|
|
321
|
+
}
|
|
322
|
+
function successfulDate(value) {
|
|
323
|
+
const date = dateValue(value);
|
|
324
|
+
return date.getTime() > 0 ? date : null;
|
|
325
|
+
}
|
|
326
|
+
function validateMemberId(value) {
|
|
327
|
+
if (!DISCORD_SNOWFLAKE.test(value))
|
|
328
|
+
throw new DashboardActionValidationError("The Access Control member ID is invalid.");
|
|
329
|
+
}
|
|
330
|
+
function unsupported() {
|
|
331
|
+
return new DashboardActionValidationError("Access Control members can only be listed or have access revoked.");
|
|
332
|
+
}
|
|
333
|
+
function stringValue(value) {
|
|
334
|
+
if (typeof value !== "string")
|
|
335
|
+
throw new Error("Access Control member record is invalid");
|
|
336
|
+
return value;
|
|
337
|
+
}
|
|
338
|
+
function numberValue(value) {
|
|
339
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value))
|
|
340
|
+
throw new Error("Access Control member record is invalid");
|
|
341
|
+
return value;
|
|
342
|
+
}
|
|
343
|
+
function dateValue(value) {
|
|
344
|
+
if (!(value instanceof Date))
|
|
345
|
+
throw new Error("Access Control member record is invalid");
|
|
346
|
+
return value;
|
|
347
|
+
}
|
|
348
|
+
//# sourceMappingURL=member-resource.js.map
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ModuleManagedResource, ModuleRecordService } from "@helyx/sdk";
|
|
2
|
+
type GatePanelAccessType = "challenges" | "password" | "emoji";
|
|
3
|
+
interface PanelPassword {
|
|
4
|
+
encodedHash: string;
|
|
5
|
+
ignoreCase: boolean;
|
|
6
|
+
trimWhitespace: boolean;
|
|
7
|
+
}
|
|
8
|
+
export declare const gatePanelResource: ModuleManagedResource;
|
|
9
|
+
export declare function getGatePanelRuntime(store: ModuleRecordService, guildId: string, panelId: string): Promise<{
|
|
10
|
+
accessType: GatePanelAccessType;
|
|
11
|
+
password: PanelPassword | null;
|
|
12
|
+
} | null>;
|
|
13
|
+
export declare function hasPasswordGatePanel(store: ModuleRecordService, guildId: string): Promise<boolean>;
|
|
14
|
+
export {};
|
|
15
|
+
//# sourceMappingURL=panel-resource.d.ts.map
|