@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.
Files changed (40) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/LICENSE +725 -0
  3. package/README.md +90 -0
  4. package/dist/challenges.d.ts +16 -0
  5. package/dist/challenges.js +233 -0
  6. package/dist/configuration.d.ts +41 -0
  7. package/dist/configuration.js +224 -0
  8. package/dist/contracts.d.ts +82 -0
  9. package/dist/contracts.js +56 -0
  10. package/dist/enforcement.d.ts +19 -0
  11. package/dist/enforcement.js +110 -0
  12. package/dist/index.d.ts +27 -0
  13. package/dist/index.js +3324 -0
  14. package/dist/managed-resource-summary.d.ts +3 -0
  15. package/dist/managed-resource-summary.js +6 -0
  16. package/dist/member-resource.d.ts +3 -0
  17. package/dist/member-resource.js +348 -0
  18. package/dist/panel-resource.d.ts +15 -0
  19. package/dist/panel-resource.js +527 -0
  20. package/dist/password-entry.d.ts +8 -0
  21. package/dist/password-entry.js +59 -0
  22. package/dist/password-resource.d.ts +8 -0
  23. package/dist/password-resource.js +208 -0
  24. package/dist/privacy.d.ts +4 -0
  25. package/dist/privacy.js +20 -0
  26. package/dist/public-channel-resource.d.ts +4 -0
  27. package/dist/public-channel-resource.js +135 -0
  28. package/dist/question-resource.d.ts +3 -0
  29. package/dist/question-resource.js +246 -0
  30. package/dist/records.d.ts +508 -0
  31. package/dist/records.js +456 -0
  32. package/dist/workflow.d.ts +43 -0
  33. package/dist/workflow.js +167 -0
  34. package/manifest.json +750 -0
  35. package/migrations/0001_account_gate_foundation.sql +303 -0
  36. package/migrations/0002_account_gate_panel_message.sql +27 -0
  37. package/migrations/0003_account_gate_revoked_member_status.sql +17 -0
  38. package/migrations/0004_account_gate_multiple_panels.sql +23 -0
  39. package/migrations/0005_account_gate_panel_passwords.sql +45 -0
  40. package/package.json +54 -0
@@ -0,0 +1,3 @@
1
+ import type { ManagedResourceDetail, ManagedResourceSummary } from "@helyx/sdk";
2
+ export declare function managedResourceSummary(detail: ManagedResourceDetail): ManagedResourceSummary;
3
+ //# sourceMappingURL=managed-resource-summary.d.ts.map
@@ -0,0 +1,6 @@
1
+ export function managedResourceSummary(detail) {
2
+ const { value, ...summary } = detail;
3
+ void value;
4
+ return summary;
5
+ }
6
+ //# sourceMappingURL=managed-resource-summary.js.map
@@ -0,0 +1,3 @@
1
+ import type { ModuleManagedResource } from "@helyx/sdk";
2
+ export declare const accountGateMemberResource: ModuleManagedResource;
3
+ //# sourceMappingURL=member-resource.d.ts.map
@@ -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