@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,208 @@
|
|
|
1
|
+
import { DashboardActionValidationError, HELYX_SERVICE_NAMES, } from "@helyx/sdk";
|
|
2
|
+
import { ACCOUNT_GATE_IDENTITY } from "./contracts.js";
|
|
3
|
+
import { parseAccountGateConfiguration } from "./configuration.js";
|
|
4
|
+
import { managedResourceSummary } from "./managed-resource-summary.js";
|
|
5
|
+
import { hashPasswordEntryPhrase } from "./password-entry.js";
|
|
6
|
+
const RESOURCE_ID = "shared-phrase";
|
|
7
|
+
const select = [
|
|
8
|
+
"guild_id",
|
|
9
|
+
"encoded_hash",
|
|
10
|
+
"ignore_case",
|
|
11
|
+
"trim_whitespace",
|
|
12
|
+
"revision",
|
|
13
|
+
"created_at",
|
|
14
|
+
"updated_at",
|
|
15
|
+
];
|
|
16
|
+
export const passwordEntryPhraseResource = {
|
|
17
|
+
id: "password-entry-phrase",
|
|
18
|
+
availableWhileDisabled: true,
|
|
19
|
+
async list(context) {
|
|
20
|
+
const row = await findSecret(context.services, context.guildId);
|
|
21
|
+
return { items: row ? [managedResourceSummary(safeDetail(row))] : [] };
|
|
22
|
+
},
|
|
23
|
+
async read(context, resourceId) {
|
|
24
|
+
if (resourceId !== RESOURCE_ID)
|
|
25
|
+
return null;
|
|
26
|
+
const row = await findSecret(context.services, context.guildId);
|
|
27
|
+
return row ? safeDetail(row) : null;
|
|
28
|
+
},
|
|
29
|
+
preview() {
|
|
30
|
+
return Promise.resolve({
|
|
31
|
+
ephemeral: true,
|
|
32
|
+
embed: {
|
|
33
|
+
title: "Password Entry phrase",
|
|
34
|
+
description: "The replacement phrase will be converted to a one-way hash. The phrase and hash are not shown after saving.",
|
|
35
|
+
color: 0x7c3aed,
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
},
|
|
39
|
+
async create(context, value) {
|
|
40
|
+
const records = context.services.get(HELYX_SERVICE_NAMES.records);
|
|
41
|
+
if (await findSecret(context.services, context.guildId)) {
|
|
42
|
+
throw new DashboardActionValidationError("A shared phrase is already configured. Open it and replace the phrase.");
|
|
43
|
+
}
|
|
44
|
+
const rules = await currentRules(context.services, context.guildId);
|
|
45
|
+
const encodedHash = await hashPasswordEntryPhrase(replacementPhrase(value), rules);
|
|
46
|
+
const now = new Date();
|
|
47
|
+
const result = await records.appendUnique({
|
|
48
|
+
moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
|
|
49
|
+
collection: "password_secrets",
|
|
50
|
+
uniqueBy: ["guild_id"],
|
|
51
|
+
values: {
|
|
52
|
+
guild_id: context.guildId,
|
|
53
|
+
encoded_hash: encodedHash,
|
|
54
|
+
ignore_case: rules.ignoreCase,
|
|
55
|
+
trim_whitespace: rules.trimWhitespace,
|
|
56
|
+
revision: 1,
|
|
57
|
+
created_at: now,
|
|
58
|
+
updated_at: now,
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
if (!result.created) {
|
|
62
|
+
throw new DashboardActionValidationError("The shared phrase changed in another session. Reload and try again.");
|
|
63
|
+
}
|
|
64
|
+
return safeDetail({
|
|
65
|
+
revision: 1,
|
|
66
|
+
updated_at: now,
|
|
67
|
+
});
|
|
68
|
+
},
|
|
69
|
+
async update(context, input) {
|
|
70
|
+
if (input.resourceId !== RESOURCE_ID) {
|
|
71
|
+
throw new DashboardActionValidationError("The Password Entry phrase was not found.");
|
|
72
|
+
}
|
|
73
|
+
const rules = await currentRules(context.services, context.guildId);
|
|
74
|
+
const encodedHash = await hashPasswordEntryPhrase(replacementPhrase(input.value), rules);
|
|
75
|
+
const now = new Date();
|
|
76
|
+
const result = await context.services
|
|
77
|
+
.get(HELYX_SERVICE_NAMES.records)
|
|
78
|
+
.updateOne({
|
|
79
|
+
moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
|
|
80
|
+
collection: "password_secrets",
|
|
81
|
+
where: { guild_id: context.guildId },
|
|
82
|
+
expected: { field: "revision", value: input.expectedRevision },
|
|
83
|
+
values: {
|
|
84
|
+
encoded_hash: encodedHash,
|
|
85
|
+
ignore_case: rules.ignoreCase,
|
|
86
|
+
trim_whitespace: rules.trimWhitespace,
|
|
87
|
+
revision: input.expectedRevision + 1,
|
|
88
|
+
updated_at: now,
|
|
89
|
+
},
|
|
90
|
+
audit: {
|
|
91
|
+
guildId: context.guildId,
|
|
92
|
+
actorUserId: context.actor.userId,
|
|
93
|
+
action: "account_gate.password_phrase_replaced",
|
|
94
|
+
source: "dashboard",
|
|
95
|
+
correlationId: context.correlationId,
|
|
96
|
+
idempotencyKey: context.correlationId,
|
|
97
|
+
targetType: "account_gate_password_phrase",
|
|
98
|
+
targetId: context.guildId,
|
|
99
|
+
metadata: { revision: input.expectedRevision + 1 },
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
if (!result.updated) {
|
|
103
|
+
throw new DashboardActionValidationError("The shared phrase changed in another session. Reload and try again.");
|
|
104
|
+
}
|
|
105
|
+
return safeDetail({
|
|
106
|
+
revision: input.expectedRevision + 1,
|
|
107
|
+
updated_at: now,
|
|
108
|
+
});
|
|
109
|
+
},
|
|
110
|
+
async delete(context, input) {
|
|
111
|
+
if (input.resourceId !== RESOURCE_ID)
|
|
112
|
+
return { deleted: false };
|
|
113
|
+
const current = await findSecret(context.services, context.guildId);
|
|
114
|
+
if (!current || number(current.revision) !== input.expectedRevision) {
|
|
115
|
+
throw new DashboardActionValidationError("The shared phrase changed in another session. Reload and try again.");
|
|
116
|
+
}
|
|
117
|
+
const result = await context.services
|
|
118
|
+
.get(HELYX_SERVICE_NAMES.records)
|
|
119
|
+
.deleteMany({
|
|
120
|
+
moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
|
|
121
|
+
collection: "password_secrets",
|
|
122
|
+
where: { guild_id: context.guildId },
|
|
123
|
+
limit: 1,
|
|
124
|
+
});
|
|
125
|
+
return { deleted: result.deleted === 1 };
|
|
126
|
+
},
|
|
127
|
+
executeAction() {
|
|
128
|
+
return Promise.reject(new DashboardActionValidationError("Password Entry phrase actions are not supported."));
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
export async function getPasswordSecret(records, guildId) {
|
|
132
|
+
const row = await records.findOne({
|
|
133
|
+
moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
|
|
134
|
+
collection: "password_secrets",
|
|
135
|
+
select,
|
|
136
|
+
where: { guild_id: guildId },
|
|
137
|
+
});
|
|
138
|
+
if (!row)
|
|
139
|
+
return null;
|
|
140
|
+
return {
|
|
141
|
+
encodedHash: string(row.encoded_hash),
|
|
142
|
+
ignoreCase: boolean(row.ignore_case),
|
|
143
|
+
trimWhitespace: boolean(row.trim_whitespace),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
function safeDetail(row) {
|
|
147
|
+
const updatedAt = row.updated_at instanceof Date ? row.updated_at : new Date(0);
|
|
148
|
+
return {
|
|
149
|
+
id: RESOURCE_ID,
|
|
150
|
+
revision: number(row.revision),
|
|
151
|
+
status: "configured",
|
|
152
|
+
title: "Password Entry phrase",
|
|
153
|
+
description: "A one-way phrase hash is configured. Enter a replacement to change it.",
|
|
154
|
+
attributes: {
|
|
155
|
+
phraseConfigured: true,
|
|
156
|
+
},
|
|
157
|
+
updatedAt: updatedAt.toISOString(),
|
|
158
|
+
value: {
|
|
159
|
+
replacementPhrase: "",
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
async function findSecret(services, guildId) {
|
|
164
|
+
return services
|
|
165
|
+
.get(HELYX_SERVICE_NAMES.records)
|
|
166
|
+
.findOne({
|
|
167
|
+
moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
|
|
168
|
+
collection: "password_secrets",
|
|
169
|
+
select,
|
|
170
|
+
where: { guild_id: guildId },
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
async function currentRules(services, guildId) {
|
|
174
|
+
const record = await services
|
|
175
|
+
.get(HELYX_SERVICE_NAMES.configuration)
|
|
176
|
+
.get(guildId, ACCOUNT_GATE_IDENTITY.moduleId);
|
|
177
|
+
if (!record) {
|
|
178
|
+
throw new DashboardActionValidationError("Save the Access Control settings before configuring a shared phrase.");
|
|
179
|
+
}
|
|
180
|
+
const configuration = parseAccountGateConfiguration(record.value);
|
|
181
|
+
return {
|
|
182
|
+
ignoreCase: configuration.passwordIgnoreCase,
|
|
183
|
+
trimWhitespace: configuration.passwordTrimWhitespace,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
function replacementPhrase(value) {
|
|
187
|
+
const phrase = value.replacementPhrase;
|
|
188
|
+
if (typeof phrase !== "string") {
|
|
189
|
+
throw new DashboardActionValidationError("Enter a replacement phrase containing 4 to 200 characters.");
|
|
190
|
+
}
|
|
191
|
+
return phrase;
|
|
192
|
+
}
|
|
193
|
+
function string(value) {
|
|
194
|
+
if (typeof value !== "string")
|
|
195
|
+
throw new Error("Password secret is invalid");
|
|
196
|
+
return value;
|
|
197
|
+
}
|
|
198
|
+
function boolean(value) {
|
|
199
|
+
if (typeof value !== "boolean")
|
|
200
|
+
throw new Error("Password secret is invalid");
|
|
201
|
+
return value;
|
|
202
|
+
}
|
|
203
|
+
function number(value) {
|
|
204
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value))
|
|
205
|
+
throw new Error("Password secret is invalid");
|
|
206
|
+
return value;
|
|
207
|
+
}
|
|
208
|
+
//# sourceMappingURL=password-resource.js.map
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ServiceAccess } from "@helyx/sdk";
|
|
2
|
+
export declare function privacySuppressionBlocksWork(suppressedAt: Date | null, workCreatedAt: Date): boolean;
|
|
3
|
+
export declare function isAccountGateWorkSuppressed(services: ServiceAccess, guildId: string, memberUserId: string, workCreatedAt: Date): Promise<boolean>;
|
|
4
|
+
//# sourceMappingURL=privacy.d.ts.map
|
package/dist/privacy.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { HELYX_SERVICE_NAMES } from "@helyx/sdk";
|
|
2
|
+
import { ACCOUNT_GATE_IDENTITY } from "./contracts.js";
|
|
3
|
+
export function privacySuppressionBlocksWork(suppressedAt, workCreatedAt) {
|
|
4
|
+
return (suppressedAt !== null && suppressedAt.getTime() >= workCreatedAt.getTime());
|
|
5
|
+
}
|
|
6
|
+
export async function isAccountGateWorkSuppressed(services, guildId, memberUserId, workCreatedAt) {
|
|
7
|
+
const row = await services
|
|
8
|
+
.get(HELYX_SERVICE_NAMES.records)
|
|
9
|
+
.findOne({
|
|
10
|
+
moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
|
|
11
|
+
collection: "privacy_suppressions",
|
|
12
|
+
select: ["suppressed_at"],
|
|
13
|
+
where: {
|
|
14
|
+
guild_id: guildId,
|
|
15
|
+
member_user_id: memberUserId,
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
return privacySuppressionBlocksWork(row?.suppressed_at instanceof Date ? row.suppressed_at : null, workCreatedAt);
|
|
19
|
+
}
|
|
20
|
+
//# sourceMappingURL=privacy.js.map
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ModuleManagedResource, ModuleRecordService } from "@helyx/sdk";
|
|
2
|
+
export declare const preGatePublicChannelResource: ModuleManagedResource;
|
|
3
|
+
export declare function loadPreGatePublicChannelIds(records: ModuleRecordService, guildId: string): Promise<readonly string[]>;
|
|
4
|
+
//# sourceMappingURL=public-channel-resource.d.ts.map
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { DashboardActionValidationError, HELYX_SERVICE_NAMES, } from "@helyx/sdk";
|
|
2
|
+
import { ACCOUNT_GATE_IDENTITY } from "./contracts.js";
|
|
3
|
+
import { managedResourceSummary } from "./managed-resource-summary.js";
|
|
4
|
+
import { accountGateRecords } from "./records.js";
|
|
5
|
+
const SNOWFLAKE = /^\d{17,20}$/u;
|
|
6
|
+
export const preGatePublicChannelResource = {
|
|
7
|
+
id: "pre-gate-public-channels",
|
|
8
|
+
availableWhileDisabled: true,
|
|
9
|
+
async list(context, input) {
|
|
10
|
+
const page = await recordService(context).findMany({
|
|
11
|
+
moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
|
|
12
|
+
collection: "public_channels",
|
|
13
|
+
select: accountGateRecords.public_channels.readableFields,
|
|
14
|
+
where: { guild_id: context.guildId },
|
|
15
|
+
orderBy: [
|
|
16
|
+
{ field: "guild_id", direction: "asc" },
|
|
17
|
+
{ field: "channel_id", direction: "asc" },
|
|
18
|
+
],
|
|
19
|
+
limit: Math.min(100, input.limit),
|
|
20
|
+
});
|
|
21
|
+
return {
|
|
22
|
+
items: page.records.map((row) => managedResourceSummary(channelDetail(row))),
|
|
23
|
+
};
|
|
24
|
+
},
|
|
25
|
+
async read(context, resourceId) {
|
|
26
|
+
const row = await recordService(context).findOne({
|
|
27
|
+
moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
|
|
28
|
+
collection: "public_channels",
|
|
29
|
+
select: accountGateRecords.public_channels.readableFields,
|
|
30
|
+
where: { guild_id: context.guildId, channel_id: resourceId },
|
|
31
|
+
});
|
|
32
|
+
return row ? channelDetail(row) : null;
|
|
33
|
+
},
|
|
34
|
+
preview(_context, value) {
|
|
35
|
+
const channelId = channelValue(value);
|
|
36
|
+
return Promise.resolve({
|
|
37
|
+
ephemeral: true,
|
|
38
|
+
embed: {
|
|
39
|
+
title: "Pre-gate public channel",
|
|
40
|
+
description: `<#${channelId}> will be allowed to remain visible before members complete Account Gate.`,
|
|
41
|
+
color: 0x7c3aed,
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
},
|
|
45
|
+
async create(context, value) {
|
|
46
|
+
const channelId = channelValue(value);
|
|
47
|
+
const channel = await context.services
|
|
48
|
+
.get(HELYX_SERVICE_NAMES.discordResources)
|
|
49
|
+
.inspectChannel({ guildId: context.guildId, channelId });
|
|
50
|
+
if (!channel.exists ||
|
|
51
|
+
!channel.guildMatches ||
|
|
52
|
+
!["guild_text", "guild_announcement"].includes(channel.type))
|
|
53
|
+
throw new DashboardActionValidationError("Choose a text or announcement channel from this Discord server.");
|
|
54
|
+
const createdAt = new Date();
|
|
55
|
+
const result = await recordService(context).appendUnique({
|
|
56
|
+
moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
|
|
57
|
+
collection: "public_channels",
|
|
58
|
+
uniqueBy: ["guild_id", "channel_id"],
|
|
59
|
+
values: {
|
|
60
|
+
guild_id: context.guildId,
|
|
61
|
+
channel_id: channelId,
|
|
62
|
+
created_at: createdAt,
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
if (!result.created)
|
|
66
|
+
throw new DashboardActionValidationError("That channel is already in the pre-gate public allowlist.");
|
|
67
|
+
return detail(channelId, createdAt);
|
|
68
|
+
},
|
|
69
|
+
update() {
|
|
70
|
+
return Promise.reject(new DashboardActionValidationError("Remove this channel and add another channel instead."));
|
|
71
|
+
},
|
|
72
|
+
async delete(context, input) {
|
|
73
|
+
const result = await recordService(context).deleteMany({
|
|
74
|
+
moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
|
|
75
|
+
collection: "public_channels",
|
|
76
|
+
where: {
|
|
77
|
+
guild_id: context.guildId,
|
|
78
|
+
channel_id: input.resourceId,
|
|
79
|
+
},
|
|
80
|
+
limit: 1,
|
|
81
|
+
});
|
|
82
|
+
return { deleted: result.deleted === 1 };
|
|
83
|
+
},
|
|
84
|
+
executeAction() {
|
|
85
|
+
return Promise.reject(new DashboardActionValidationError("Pre-gate public channel actions are not supported."));
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
export async function loadPreGatePublicChannelIds(records, guildId) {
|
|
89
|
+
const page = await records.findMany({
|
|
90
|
+
moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
|
|
91
|
+
collection: "public_channels",
|
|
92
|
+
select: accountGateRecords.public_channels.readableFields,
|
|
93
|
+
where: { guild_id: guildId },
|
|
94
|
+
orderBy: [
|
|
95
|
+
{ field: "guild_id", direction: "asc" },
|
|
96
|
+
{ field: "channel_id", direction: "asc" },
|
|
97
|
+
],
|
|
98
|
+
limit: 100,
|
|
99
|
+
});
|
|
100
|
+
return page.records.map((row) => stringValue(row.channel_id));
|
|
101
|
+
}
|
|
102
|
+
function channelDetail(row) {
|
|
103
|
+
return detail(stringValue(row.channel_id), dateValue(row.created_at));
|
|
104
|
+
}
|
|
105
|
+
function detail(channelId, createdAt) {
|
|
106
|
+
return {
|
|
107
|
+
id: channelId,
|
|
108
|
+
revision: 1,
|
|
109
|
+
status: "allowed",
|
|
110
|
+
title: `Channel ${channelId}`,
|
|
111
|
+
description: "Intentionally visible before Account Gate completion.",
|
|
112
|
+
attributes: { channelId },
|
|
113
|
+
updatedAt: createdAt.toISOString(),
|
|
114
|
+
value: { channelId },
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
function channelValue(value) {
|
|
118
|
+
if (typeof value.channelId !== "string" || !SNOWFLAKE.test(value.channelId))
|
|
119
|
+
throw new DashboardActionValidationError("Choose a valid Discord text or announcement channel.");
|
|
120
|
+
return value.channelId;
|
|
121
|
+
}
|
|
122
|
+
function recordService(context) {
|
|
123
|
+
return context.services.get(HELYX_SERVICE_NAMES.records);
|
|
124
|
+
}
|
|
125
|
+
function stringValue(value) {
|
|
126
|
+
if (typeof value !== "string")
|
|
127
|
+
throw new Error("Pre-gate channel record is invalid");
|
|
128
|
+
return value;
|
|
129
|
+
}
|
|
130
|
+
function dateValue(value) {
|
|
131
|
+
if (!(value instanceof Date))
|
|
132
|
+
throw new Error("Pre-gate channel record is invalid");
|
|
133
|
+
return value;
|
|
134
|
+
}
|
|
135
|
+
//# sourceMappingURL=public-channel-resource.js.map
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { DashboardActionValidationError, HELYX_SERVICE_NAMES, } from "@helyx/sdk";
|
|
3
|
+
import { validateServerQuestion } from "./challenges.js";
|
|
4
|
+
import { ACCOUNT_GATE_IDENTITY } from "./contracts.js";
|
|
5
|
+
import { managedResourceSummary } from "./managed-resource-summary.js";
|
|
6
|
+
import { accountGateRecords } from "./records.js";
|
|
7
|
+
export const serverQuestionResource = {
|
|
8
|
+
id: "server-questions",
|
|
9
|
+
availableWhileDisabled: true,
|
|
10
|
+
async list(context, input) {
|
|
11
|
+
const page = await records(context).findMany({
|
|
12
|
+
moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
|
|
13
|
+
collection: "questions",
|
|
14
|
+
select: accountGateRecords.questions.readableFields,
|
|
15
|
+
where: { guild_id: context.guildId },
|
|
16
|
+
orderBy: [
|
|
17
|
+
{ field: "guild_id", direction: "asc" },
|
|
18
|
+
{ field: "sort_order", direction: "asc" },
|
|
19
|
+
{ field: "question_id", direction: "asc" },
|
|
20
|
+
],
|
|
21
|
+
limit: Math.min(100, input.limit),
|
|
22
|
+
...(input.search ? { search: input.search } : {}),
|
|
23
|
+
});
|
|
24
|
+
return {
|
|
25
|
+
items: page.records.map((row) => managedResourceSummary(questionDetail(row))),
|
|
26
|
+
};
|
|
27
|
+
},
|
|
28
|
+
async read(context, resourceId) {
|
|
29
|
+
const row = await records(context).findOne({
|
|
30
|
+
moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
|
|
31
|
+
collection: "questions",
|
|
32
|
+
select: accountGateRecords.questions.readableFields,
|
|
33
|
+
where: { guild_id: context.guildId, question_id: resourceId },
|
|
34
|
+
});
|
|
35
|
+
return row ? questionDetail(row) : null;
|
|
36
|
+
},
|
|
37
|
+
preview(_context, value) {
|
|
38
|
+
const question = questionFromValue("preview", value);
|
|
39
|
+
return Promise.resolve({
|
|
40
|
+
ephemeral: true,
|
|
41
|
+
componentsV2: {
|
|
42
|
+
accentColor: 0x7c3aed,
|
|
43
|
+
text: [
|
|
44
|
+
{ content: "## Server Question preview" },
|
|
45
|
+
{ content: question.question, markdown: false },
|
|
46
|
+
...question.answers.map((answer) => ({
|
|
47
|
+
content: `• ${answer}`,
|
|
48
|
+
markdown: false,
|
|
49
|
+
})),
|
|
50
|
+
],
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
},
|
|
54
|
+
async create(context, value, intent = "draft") {
|
|
55
|
+
const question = questionFromValue(randomUUID(), value);
|
|
56
|
+
const now = new Date();
|
|
57
|
+
const count = await records(context).count({
|
|
58
|
+
moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
|
|
59
|
+
collection: "questions",
|
|
60
|
+
where: { guild_id: context.guildId },
|
|
61
|
+
});
|
|
62
|
+
if (count >= 100) {
|
|
63
|
+
throw new DashboardActionValidationError("Access Control supports up to 100 server questions.");
|
|
64
|
+
}
|
|
65
|
+
await records(context).append({
|
|
66
|
+
moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
|
|
67
|
+
collection: "questions",
|
|
68
|
+
values: {
|
|
69
|
+
question_id: question.id,
|
|
70
|
+
guild_id: context.guildId,
|
|
71
|
+
question_text: question.question,
|
|
72
|
+
answers_json: JSON.stringify(question.answers),
|
|
73
|
+
correct_option_index: question.correctOption,
|
|
74
|
+
enabled: intent === "publish" && booleanValue(value.enabled, true),
|
|
75
|
+
sort_order: count,
|
|
76
|
+
revision: 1,
|
|
77
|
+
created_at: now,
|
|
78
|
+
updated_at: now,
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
await records(context).appendUnique({
|
|
82
|
+
moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
|
|
83
|
+
collection: "question_creators",
|
|
84
|
+
uniqueBy: ["question_id"],
|
|
85
|
+
values: {
|
|
86
|
+
attribution_id: randomUUID(),
|
|
87
|
+
question_id: question.id,
|
|
88
|
+
guild_id: context.guildId,
|
|
89
|
+
creator_user_id: context.actor.userId,
|
|
90
|
+
created_at: now,
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
return detailFromQuestion(question, intent === "publish" && booleanValue(value.enabled, true), 1, now);
|
|
94
|
+
},
|
|
95
|
+
async update(context, input) {
|
|
96
|
+
const question = questionFromValue(input.resourceId, input.value);
|
|
97
|
+
const now = new Date();
|
|
98
|
+
const result = await records(context).updateOne({
|
|
99
|
+
moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
|
|
100
|
+
collection: "questions",
|
|
101
|
+
where: {
|
|
102
|
+
guild_id: context.guildId,
|
|
103
|
+
question_id: input.resourceId,
|
|
104
|
+
},
|
|
105
|
+
expected: { field: "revision", value: input.expectedRevision },
|
|
106
|
+
values: {
|
|
107
|
+
question_text: question.question,
|
|
108
|
+
answers_json: JSON.stringify(question.answers),
|
|
109
|
+
correct_option_index: question.correctOption,
|
|
110
|
+
enabled: booleanValue(input.value.enabled, true),
|
|
111
|
+
revision: input.expectedRevision + 1,
|
|
112
|
+
updated_at: now,
|
|
113
|
+
},
|
|
114
|
+
audit: {
|
|
115
|
+
guildId: context.guildId,
|
|
116
|
+
actorUserId: context.actor.userId,
|
|
117
|
+
action: "account_gate.question_updated",
|
|
118
|
+
source: "dashboard",
|
|
119
|
+
correlationId: context.correlationId,
|
|
120
|
+
idempotencyKey: context.correlationId,
|
|
121
|
+
targetType: "account_gate_question",
|
|
122
|
+
targetId: input.resourceId,
|
|
123
|
+
metadata: {
|
|
124
|
+
enabled: booleanValue(input.value.enabled, true),
|
|
125
|
+
answerCount: question.answers.length,
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
if (!result.updated) {
|
|
130
|
+
throw new DashboardActionValidationError("This question changed in another session. Reload and try again.");
|
|
131
|
+
}
|
|
132
|
+
return detailFromQuestion(question, booleanValue(input.value.enabled, true), input.expectedRevision + 1, now);
|
|
133
|
+
},
|
|
134
|
+
async delete(context, input) {
|
|
135
|
+
const row = await this.read(context, input.resourceId);
|
|
136
|
+
if (!row)
|
|
137
|
+
return { deleted: false };
|
|
138
|
+
if (row.revision !== input.expectedRevision) {
|
|
139
|
+
throw new DashboardActionValidationError("This question changed in another session. Reload and try again.");
|
|
140
|
+
}
|
|
141
|
+
const result = await records(context).deleteMany({
|
|
142
|
+
moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
|
|
143
|
+
collection: "questions",
|
|
144
|
+
where: {
|
|
145
|
+
guild_id: context.guildId,
|
|
146
|
+
question_id: input.resourceId,
|
|
147
|
+
},
|
|
148
|
+
limit: 1,
|
|
149
|
+
});
|
|
150
|
+
if (result.deleted === 1) {
|
|
151
|
+
await records(context).deleteMany({
|
|
152
|
+
moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
|
|
153
|
+
collection: "question_creators",
|
|
154
|
+
where: { question_id: input.resourceId },
|
|
155
|
+
limit: 1,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
return { deleted: result.deleted === 1 };
|
|
159
|
+
},
|
|
160
|
+
executeAction() {
|
|
161
|
+
return Promise.reject(new DashboardActionValidationError("Server Question actions are not supported."));
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
function questionFromValue(id, value) {
|
|
165
|
+
const question = text(value.question, 200, "question");
|
|
166
|
+
const correctAnswer = text(value.correctAnswer, 80, "correct answer");
|
|
167
|
+
if (typeof value.incorrectAnswers !== "string") {
|
|
168
|
+
throw new DashboardActionValidationError("Enter two to four incorrect answers, one per line.");
|
|
169
|
+
}
|
|
170
|
+
const incorrect = value.incorrectAnswers
|
|
171
|
+
.split(/\r?\n/u)
|
|
172
|
+
.map((answer) => answer.trim())
|
|
173
|
+
.filter(Boolean);
|
|
174
|
+
return validateServerQuestion({
|
|
175
|
+
id,
|
|
176
|
+
question,
|
|
177
|
+
answers: [correctAnswer, ...incorrect],
|
|
178
|
+
correctOption: 0,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
function questionDetail(row) {
|
|
182
|
+
const question = validateServerQuestion({
|
|
183
|
+
id: row.question_id,
|
|
184
|
+
question: row.question_text,
|
|
185
|
+
answers: JSON.parse(stringValue(row.answers_json)),
|
|
186
|
+
correctOption: row.correct_option_index,
|
|
187
|
+
});
|
|
188
|
+
return detailFromQuestion(question, booleanValue(row.enabled, false), numberValue(row.revision), dateValue(row.updated_at));
|
|
189
|
+
}
|
|
190
|
+
function detailFromQuestion(question, enabled, revision, updatedAt) {
|
|
191
|
+
const incorrect = question.answers.filter((_, index) => index !== question.correctOption);
|
|
192
|
+
return {
|
|
193
|
+
id: question.id,
|
|
194
|
+
revision,
|
|
195
|
+
status: enabled ? "enabled" : "draft",
|
|
196
|
+
title: question.question,
|
|
197
|
+
description: `${question.answers.length} answer options`,
|
|
198
|
+
attributes: {
|
|
199
|
+
status: enabled ? "Enabled" : "Draft",
|
|
200
|
+
updatedAt: updatedAt.toISOString(),
|
|
201
|
+
},
|
|
202
|
+
updatedAt: updatedAt.toISOString(),
|
|
203
|
+
value: {
|
|
204
|
+
question: question.question,
|
|
205
|
+
correctAnswer: question.answers[question.correctOption],
|
|
206
|
+
incorrectAnswers: incorrect.join("\n"),
|
|
207
|
+
enabled,
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
function records(context) {
|
|
212
|
+
return context.services.get(HELYX_SERVICE_NAMES.records);
|
|
213
|
+
}
|
|
214
|
+
function text(value, maximum, field) {
|
|
215
|
+
if (typeof value !== "string") {
|
|
216
|
+
throw new DashboardActionValidationError(`Enter a valid ${field}.`);
|
|
217
|
+
}
|
|
218
|
+
const normalized = value.trim();
|
|
219
|
+
if (normalized.length < 1 || normalized.length > maximum) {
|
|
220
|
+
throw new DashboardActionValidationError(`The ${field} must contain 1 to ${maximum} characters.`);
|
|
221
|
+
}
|
|
222
|
+
return normalized;
|
|
223
|
+
}
|
|
224
|
+
function stringValue(value) {
|
|
225
|
+
if (typeof value !== "string")
|
|
226
|
+
throw new Error("Question record is invalid");
|
|
227
|
+
return value;
|
|
228
|
+
}
|
|
229
|
+
function numberValue(value) {
|
|
230
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value))
|
|
231
|
+
throw new Error("Question record is invalid");
|
|
232
|
+
return value;
|
|
233
|
+
}
|
|
234
|
+
function booleanValue(value, fallback) {
|
|
235
|
+
if (value === undefined)
|
|
236
|
+
return fallback;
|
|
237
|
+
if (typeof value !== "boolean")
|
|
238
|
+
throw new DashboardActionValidationError("Choose whether this question is enabled.");
|
|
239
|
+
return value;
|
|
240
|
+
}
|
|
241
|
+
function dateValue(value) {
|
|
242
|
+
if (!(value instanceof Date))
|
|
243
|
+
throw new Error("Question record is invalid");
|
|
244
|
+
return value;
|
|
245
|
+
}
|
|
246
|
+
//# sourceMappingURL=question-resource.js.map
|