@helyx/module-account-gate 1.0.9 → 1.0.11

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 CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.11
4
+
5
+ ### Patch Changes
6
+
7
+ - Harden dashboard authentication, privacy execution, control routing, database transactions, and self-host configuration.
8
+
9
+ Add bounded multi-value record reads and use them to batch Polls and Reaction Roles dashboard queries. Improve dashboard presentation, Forms workflows, release safety, and operational tooling across hosted and self-hosted deployments.
10
+
11
+ - Updated dependencies
12
+ - @helyx/sdk@0.7.0
13
+
14
+ ## 1.0.10
15
+
16
+ ### Patch Changes
17
+
18
+ - Launch the shared gaming statistics foundation and the Achievements, Counting and Trivia module integrations, while removing retired Access Control and Counting storage through guarded migrations.
19
+
3
20
  ## 1.0.9
4
21
 
5
22
  ### Patch Changes
@@ -2,7 +2,7 @@ export declare const ACCOUNT_GATE_IDENTITY: {
2
2
  readonly packageName: "@helyx/module-account-gate";
3
3
  readonly moduleId: "helyx.account-gate";
4
4
  readonly name: "Access Control";
5
- readonly version: "1.0.9";
5
+ readonly version: "1.0.11";
6
6
  readonly releaseStatus: "live";
7
7
  readonly category: "safety";
8
8
  readonly icon: "shield-check";
package/dist/contracts.js CHANGED
@@ -2,7 +2,7 @@ export const ACCOUNT_GATE_IDENTITY = {
2
2
  packageName: "@helyx/module-account-gate",
3
3
  moduleId: "helyx.account-gate",
4
4
  name: "Access Control",
5
- version: "1.0.9",
5
+ version: "1.0.11",
6
6
  releaseStatus: "live",
7
7
  category: "safety",
8
8
  icon: "shield-check",
package/dist/index.js CHANGED
@@ -7,7 +7,6 @@ import { ACCOUNT_GATE_COMPONENTS, ACCOUNT_GATE_IDENTITY, ACCOUNT_GATE_SESSION_PU
7
7
  import { generatedChallengePool, isStrictModeActive, parseAccountGateConfiguration, validateRuntimeCapabilities, } from "./configuration.js";
8
8
  import { applyRoleTransition, neutralKickDirectMessage, } from "./enforcement.js";
9
9
  import { verifyPasswordEntry } from "./password-entry.js";
10
- import { getPasswordSecret } from "./password-resource.js";
11
10
  import { accountGateMemberResource } from "./member-resource.js";
12
11
  import { accountGateRecords } from "./records.js";
13
12
  import { serverQuestionResource } from "./question-resource.js";
@@ -1037,7 +1036,7 @@ async function handlePasswordEntry(context) {
1037
1036
  const secret = state.panelId
1038
1037
  ? ((await getGatePanelRuntime(store, context.serverId, state.panelId))
1039
1038
  ?.password ?? null)
1040
- : await getPasswordSecret(store, context.serverId);
1039
+ : null;
1041
1040
  const matched = secret !== null &&
1042
1041
  (await verifyPasswordEntry(context.content, secret.encodedHash, secret));
1043
1042
  await processGatewayAction(context.services, context.serverId, context.userId, session, matched
@@ -2744,8 +2743,7 @@ export async function validateAccountGateSetup(services, guildId, configuration,
2744
2743
  !configuration.temporaryRoleIds.every((roleId) => passwordAudience.roles.some((role) => role.roleId === roleId && role.exists && role.canView)))
2745
2744
  errors.push("Password Entry requires every configured temporary pre-gate role to view its isolated channel.");
2746
2745
  const store = services.get(HELYX_SERVICE_NAMES.records);
2747
- if (!(await hasPasswordGatePanel(store, guildId)) &&
2748
- !(await getPasswordSecret(store, guildId)))
2746
+ if (!(await hasPasswordGatePanel(store, guildId)))
2749
2747
  errors.push("Password Entry needs a published panel with a password.");
2750
2748
  }
2751
2749
  if (configuration.enabledChallengeTypes.includes("server_question") &&
package/dist/records.d.ts CHANGED
@@ -47,23 +47,6 @@ export declare const accountGateRecords: {
47
47
  readonly writableFields: readonly ["channel_id", "message_id", "message_body", "access_type", "password_hash", "password_ignore_case", "password_trim_whitespace", "revision", "updated_at"];
48
48
  readonly orderKeys: readonly [readonly ["guild_id", "created_at", "panel_id"]];
49
49
  };
50
- readonly public_channels: {
51
- readonly table: "account_gate_public_channels";
52
- readonly fields: {
53
- readonly guild_id: {
54
- readonly type: "string";
55
- };
56
- readonly channel_id: {
57
- readonly type: "string";
58
- };
59
- readonly created_at: {
60
- readonly type: "date";
61
- };
62
- };
63
- readonly uniqueKeys: readonly [readonly ["guild_id", "channel_id"]];
64
- readonly readableFields: readonly ["guild_id", "channel_id", "created_at"];
65
- readonly orderKeys: readonly [readonly ["guild_id", "channel_id"]];
66
- };
67
50
  readonly questions: {
68
51
  readonly table: "account_gate_questions";
69
52
  readonly fields: {
@@ -128,36 +111,6 @@ export declare const accountGateRecords: {
128
111
  readonly readableFields: readonly ["attribution_id", "question_id", "guild_id", "creator_user_id", "created_at"];
129
112
  readonly orderKeys: readonly [readonly ["creator_user_id", "created_at", "attribution_id"]];
130
113
  };
131
- readonly password_secrets: {
132
- readonly table: "account_gate_password_secrets";
133
- readonly fields: {
134
- readonly guild_id: {
135
- readonly type: "string";
136
- };
137
- readonly encoded_hash: {
138
- readonly type: "string";
139
- };
140
- readonly ignore_case: {
141
- readonly type: "boolean";
142
- };
143
- readonly trim_whitespace: {
144
- readonly type: "boolean";
145
- };
146
- readonly revision: {
147
- readonly type: "number";
148
- };
149
- readonly created_at: {
150
- readonly type: "date";
151
- };
152
- readonly updated_at: {
153
- readonly type: "date";
154
- };
155
- };
156
- readonly uniqueKeys: readonly [readonly ["guild_id"]];
157
- readonly readableFields: readonly ["guild_id", "encoded_hash", "ignore_case", "trim_whitespace", "revision", "created_at", "updated_at"];
158
- readonly writableFields: readonly ["encoded_hash", "ignore_case", "trim_whitespace", "revision", "updated_at"];
159
- readonly orderKeys: readonly [readonly ["guild_id"]];
160
- };
161
114
  readonly attempts: {
162
115
  readonly table: "account_gate_attempts";
163
116
  readonly fields: {
package/dist/records.js CHANGED
@@ -43,17 +43,6 @@ export const accountGateRecords = {
43
43
  ],
44
44
  orderKeys: [["guild_id", "created_at", "panel_id"]],
45
45
  },
46
- public_channels: {
47
- table: "account_gate_public_channels",
48
- fields: {
49
- guild_id: { type: "string" },
50
- channel_id: { type: "string" },
51
- created_at: { type: "date" },
52
- },
53
- uniqueKeys: [["guild_id", "channel_id"]],
54
- readableFields: ["guild_id", "channel_id", "created_at"],
55
- orderKeys: [["guild_id", "channel_id"]],
56
- },
57
46
  questions: {
58
47
  table: "account_gate_questions",
59
48
  fields: {
@@ -113,36 +102,6 @@ export const accountGateRecords = {
113
102
  ],
114
103
  orderKeys: [["creator_user_id", "created_at", "attribution_id"]],
115
104
  },
116
- password_secrets: {
117
- table: "account_gate_password_secrets",
118
- fields: {
119
- guild_id: { type: "string" },
120
- encoded_hash: { type: "string" },
121
- ignore_case: { type: "boolean" },
122
- trim_whitespace: { type: "boolean" },
123
- revision: { type: "number" },
124
- created_at: { type: "date" },
125
- updated_at: { type: "date" },
126
- },
127
- uniqueKeys: [["guild_id"]],
128
- readableFields: [
129
- "guild_id",
130
- "encoded_hash",
131
- "ignore_case",
132
- "trim_whitespace",
133
- "revision",
134
- "created_at",
135
- "updated_at",
136
- ],
137
- writableFields: [
138
- "encoded_hash",
139
- "ignore_case",
140
- "trim_whitespace",
141
- "revision",
142
- "updated_at",
143
- ],
144
- orderKeys: [["guild_id"]],
145
- },
146
105
  attempts: {
147
106
  table: "account_gate_attempts",
148
107
  fields: {
package/manifest.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "releaseStatus": "live",
4
4
  "id": "helyx.account-gate",
5
5
  "name": "Access Control",
6
- "version": "1.0.9",
6
+ "version": "1.0.11",
7
7
  "description": "Require new members to complete configurable Discord interaction checks before gaining access.",
8
8
  "entry": "./dist/index.js",
9
9
  "framework": "^0.1.0",
@@ -0,0 +1,2 @@
1
+ DROP TABLE IF EXISTS account_gate_public_channels;
2
+ DROP TABLE IF EXISTS account_gate_password_secrets;
@@ -0,0 +1,32 @@
1
+ -- Privacy erasure deletes Account Gate subject rows through a deliberately
2
+ -- column-scoped executor role. The deletion triggers must still be able to
3
+ -- write the suppression tombstone that prevents erased data being recreated.
4
+ CREATE OR REPLACE FUNCTION public.account_gate_install_privacy_suppression()
5
+ RETURNS trigger
6
+ LANGUAGE plpgsql
7
+ SECURITY DEFINER
8
+ SET search_path = pg_catalog, public
9
+ AS $$
10
+ BEGIN
11
+ INSERT INTO public.account_gate_privacy_suppressions (
12
+ suppression_id,
13
+ guild_id,
14
+ member_user_id,
15
+ suppressed_at,
16
+ created_at
17
+ )
18
+ VALUES (
19
+ OLD.guild_id || ':' || OLD.member_user_id,
20
+ OLD.guild_id,
21
+ OLD.member_user_id,
22
+ clock_timestamp(),
23
+ clock_timestamp()
24
+ )
25
+ ON CONFLICT (guild_id, member_user_id)
26
+ DO UPDATE SET suppressed_at = GREATEST(
27
+ public.account_gate_privacy_suppressions.suppressed_at,
28
+ EXCLUDED.suppressed_at
29
+ );
30
+ RETURN OLD;
31
+ END;
32
+ $$;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@helyx/module-account-gate",
3
- "version": "1.0.9",
3
+ "version": "1.0.11",
4
4
  "description": "Require configurable Discord interaction checks before new members gain access.",
5
5
  "keywords": [
6
6
  "helyx",
@@ -49,6 +49,6 @@
49
49
  },
50
50
  "dependencies": {
51
51
  "@helyx/module-manifest": ">=0.6.0 <0.7.0",
52
- "@helyx/sdk": ">=0.6.0 <0.7.0"
52
+ "@helyx/sdk": ">=0.7.0 <0.8.0"
53
53
  }
54
54
  }
@@ -1,8 +0,0 @@
1
- import type { ModuleManagedResource, ModuleRecordService } from "@helyx/sdk";
2
- export declare const passwordEntryPhraseResource: ModuleManagedResource;
3
- export declare function getPasswordSecret(records: ModuleRecordService, guildId: string): Promise<{
4
- encodedHash: string;
5
- ignoreCase: boolean;
6
- trimWhitespace: boolean;
7
- } | null>;
8
- //# sourceMappingURL=password-resource.d.ts.map
@@ -1,208 +0,0 @@
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