@helyx/module-account-gate 1.0.11 → 1.0.14

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,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.14
4
+
5
+ ### Patch Changes
6
+
7
+ - Move Access Control commands beneath `/access control` and register them only for servers where the module is selected.
8
+ - Emit the declared completion and kick Logging events for automatic, staff and recovery outcomes.
9
+
10
+ ## 1.0.13
11
+
12
+ ### Patch Changes
13
+
14
+ - Consolidate the module database migration history into a clean one-file baseline for the planned database rebuild.
15
+
16
+ ## 1.0.12
17
+
18
+ ### Patch Changes
19
+
20
+ - Updated dependencies [26b4c3e]
21
+ - @helyx/module-manifest@0.7.0
22
+ - @helyx/sdk@0.8.0
23
+
3
24
  ## 1.0.11
4
25
 
5
26
  ### Patch Changes
package/README.md CHANGED
@@ -72,7 +72,7 @@ Password Entry and Emoji Entry are single-step access types rather than members
72
72
  of a generated challenge chain. Helyx sends answer-free private resume guidance
73
73
  that identifies the Discord server and gate channel. Closed direct messages
74
74
  never cause user-specific controls or answers to be posted publicly; the member
75
- can use the panel or `/access-control start` to resume the persisted check.
75
+ can use the panel or `/access control start` to resume the persisted check.
76
76
 
77
77
  ## Stored data
78
78
 
@@ -85,6 +85,6 @@ erasure.
85
85
 
86
86
  ## Release state
87
87
 
88
- Version 1.0.0 is private and in Testing. Live Discord validation has not yet
89
- been performed. Runtime enforcement rechecks module state, member state, kick
90
- permission and role hierarchy immediately before every kick.
88
+ Access Control is a Live Helyx module. Runtime enforcement rechecks module
89
+ state, member state, kick permission and role hierarchy immediately before
90
+ every kick.
@@ -0,0 +1,10 @@
1
+ import type { CommandInteractionContext, SharedCommandContribution } from "@helyx/sdk";
2
+ type CommandHandler = (context: CommandInteractionContext) => Promise<void>;
3
+ export declare function accountGateCommands(handlers: {
4
+ start: CommandHandler;
5
+ reset: CommandHandler;
6
+ status: CommandHandler;
7
+ strict: CommandHandler;
8
+ }): readonly SharedCommandContribution[];
9
+ export {};
10
+ //# sourceMappingURL=commands.d.ts.map
@@ -0,0 +1,38 @@
1
+ export function accountGateCommands(handlers) {
2
+ return [
3
+ {
4
+ family: "access",
5
+ path: "control.start",
6
+ description: "Start or resume your Access Control check",
7
+ acknowledgement: "deferred-ephemeral",
8
+ rateLimit: { scope: "user", limit: 5, windowSeconds: 60 },
9
+ execute: handlers.start,
10
+ },
11
+ {
12
+ family: "access",
13
+ path: "control.reset",
14
+ description: "Revoke your access and complete the gate again",
15
+ acknowledgement: "deferred-ephemeral",
16
+ permissionId: "account-gate.reset",
17
+ rateLimit: { scope: "user", limit: 10, windowSeconds: 60 },
18
+ execute: handlers.reset,
19
+ },
20
+ {
21
+ family: "access",
22
+ path: "control.status",
23
+ description: "Show Access Control health and strict-mode status",
24
+ acknowledgement: "deferred-ephemeral",
25
+ permissionId: "account-gate.manage",
26
+ execute: handlers.status,
27
+ },
28
+ {
29
+ family: "access",
30
+ path: "control.strict",
31
+ description: "Activate temporary strict mode",
32
+ acknowledgement: "deferred-ephemeral",
33
+ permissionId: "account-gate.manage",
34
+ execute: handlers.strict,
35
+ },
36
+ ];
37
+ }
38
+ //# sourceMappingURL=commands.js.map
@@ -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.11";
5
+ readonly version: "1.0.14";
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.11",
5
+ version: "1.0.14",
6
6
  releaseStatus: "live",
7
7
  category: "safety",
8
8
  icon: "shield-check",
package/dist/index.js CHANGED
@@ -13,6 +13,8 @@ import { serverQuestionResource } from "./question-resource.js";
13
13
  import { gatePanelResource, getGatePanelRuntime, hasPasswordGatePanel, } from "./panel-resource.js";
14
14
  import { isAccountGateWorkSuppressed } from "./privacy.js";
15
15
  import { applyChallengeAction, createAccountGateSessionState, parseAccountGateSessionState, rerollForAccessibility, } from "./workflow.js";
16
+ import { accountGateCommands } from "./commands.js";
17
+ import { accountGateLogging, emitAccountGateCompletedLog, emitAccountGateKickLog, emitLog, successfulKickOutcome, } from "./logging.js";
16
18
  const manifest = parseModuleManifest(JSON.parse(readFileSync(new URL("../manifest.json", import.meta.url), "utf8")));
17
19
  const VIOLET = 0x7c3aed;
18
20
  const GREEN = 0x16a34a;
@@ -48,7 +50,7 @@ export const accountGateModule = defineModule({
48
50
  async validateActivation(context) {
49
51
  const configuration = await getConfiguration(context.services, context.guildId);
50
52
  if (!configuration)
51
- throw new ModuleStateValidationError("Save Access Control settings before enabling the module.");
53
+ return;
52
54
  const errors = await validateAccountGateSetup(context.services, context.guildId, configuration, false, true);
53
55
  if (errors.length > 0)
54
56
  throw new ModuleStateValidationError(errors[0]);
@@ -81,77 +83,14 @@ export const accountGateModule = defineModule({
81
83
  accountGateMemberResource,
82
84
  serverQuestionResource,
83
85
  ],
84
- logging: {
85
- events: [
86
- {
87
- id: "account-gate-completed",
88
- name: "Account gate completed",
89
- description: "Records successful completion using IDs and bounded outcome metadata.",
90
- defaultEnabled: true,
91
- },
92
- {
93
- id: "account-gate-review-created",
94
- name: "Account gate review created",
95
- description: "Records a staff-review request without copying challenge answers.",
96
- defaultEnabled: true,
97
- },
98
- {
99
- id: "account-gate-kicked",
100
- name: "Account gate kick",
101
- description: "Records the bounded enforcement and direct-message outcomes.",
102
- defaultEnabled: true,
103
- },
104
- {
105
- id: "account-gate-strict-mode-changed",
106
- name: "Account gate strict mode changed",
107
- description: "Records authorised strict-mode activation or automatic expiry.",
108
- defaultEnabled: true,
109
- },
110
- {
111
- id: "account-gate-resume-notice",
112
- name: "Account gate resume notice",
113
- description: "Records only whether private resume guidance could be delivered after a static gate.",
114
- defaultEnabled: true,
115
- },
116
- ],
117
- },
86
+ sharedCommands: accountGateCommands({
87
+ start: startCheck,
88
+ reset: resetOwnAccess,
89
+ status: showStatus,
90
+ strict: activateStrictModeCommand,
91
+ }),
92
+ logging: accountGateLogging,
118
93
  interactions: {
119
- commands: [
120
- {
121
- name: "access-control",
122
- description: "Start or manage the server's Access Control checks",
123
- registration: "global",
124
- contexts: ["guild"],
125
- integrationTypes: ["guild_install"],
126
- subcommands: [
127
- {
128
- name: "start",
129
- description: "Start or resume your Access Control check",
130
- rateLimit: { scope: "user", limit: 5, windowSeconds: 60 },
131
- execute: startCheck,
132
- },
133
- {
134
- name: "reset",
135
- description: "Revoke your access and complete the gate again",
136
- permissionId: "account-gate.reset",
137
- rateLimit: { scope: "user", limit: 10, windowSeconds: 60 },
138
- execute: resetOwnAccess,
139
- },
140
- {
141
- name: "status",
142
- description: "Show Access Control health and strict-mode status",
143
- permissionId: "account-gate.manage",
144
- execute: showStatus,
145
- },
146
- {
147
- name: "strict",
148
- description: "Activate temporary strict mode",
149
- permissionId: "account-gate.manage",
150
- execute: activateStrictModeCommand,
151
- },
152
- ],
153
- },
154
- ],
155
94
  buttons: [
156
95
  {
157
96
  customId: ACCOUNT_GATE_COMPONENTS.start,
@@ -196,6 +135,7 @@ export const accountGateModule = defineModule({
196
135
  modals: [
197
136
  {
198
137
  customIdPrefix: ACCOUNT_GATE_COMPONENTS.recallSubmitPrefix,
138
+ acknowledgement: "deferred-ephemeral",
199
139
  execute: submitRecallModal,
200
140
  },
201
141
  ],
@@ -949,20 +889,9 @@ async function rerollChallenge(context) {
949
889
  async function openRecallModal(context) {
950
890
  if (!context.serverId)
951
891
  return replyError(context, "This check is unavailable.");
952
- const session = await context.services
953
- .get(HELYX_SERVICE_NAMES.sessions)
954
- .getFor(context.serverId, context.userId, ACCOUNT_GATE_SESSION_PURPOSE);
955
892
  const binding = parseComponentBinding(context.customId, ACCOUNT_GATE_COMPONENTS.recallOpenPrefix);
956
- const state = session ? parseAccountGateSessionState(session.state) : null;
957
- if (!session ||
958
- !state ||
959
- !binding ||
960
- binding.sessionId !== session.sessionId ||
961
- binding.version !== session.version ||
962
- binding.step !== state.step ||
963
- state.chain[state.currentStep]?.type !== "modal_recall") {
893
+ if (!binding)
964
894
  return replyError(context, "This recall challenge has expired.");
965
- }
966
895
  await context.showModal({
967
896
  customId: boundComponentId(ACCOUNT_GATE_COMPONENTS.recallSubmitPrefix, binding),
968
897
  title: "Enter the recalled code",
@@ -978,10 +907,30 @@ async function openRecallModal(context) {
978
907
  },
979
908
  ],
980
909
  });
981
- await context.editSourceMessage?.(challengeMessage(state, {
910
+ let session;
911
+ try {
912
+ session = await context.services
913
+ .get(HELYX_SERVICE_NAMES.sessions)
914
+ .getFor(context.serverId, context.userId, ACCOUNT_GATE_SESSION_PURPOSE);
915
+ }
916
+ catch {
917
+ return;
918
+ }
919
+ const state = session ? parseAccountGateSessionState(session.state) : null;
920
+ if (!session ||
921
+ !state ||
922
+ binding.sessionId !== session.sessionId ||
923
+ binding.version !== session.version ||
924
+ binding.step !== state.step ||
925
+ state.chain[state.currentStep]?.type !== "modal_recall") {
926
+ return;
927
+ }
928
+ await context
929
+ .editSourceMessage?.(challengeMessage(state, {
982
930
  sessionId: session.sessionId,
983
931
  version: session.version,
984
- }, { redactRecallCode: true }));
932
+ }, { redactRecallCode: true }))
933
+ .catch(() => undefined);
985
934
  }
986
935
  async function submitRecallModal(context) {
987
936
  const binding = parseComponentBinding(context.customId, ACCOUNT_GATE_COMPONENTS.recallSubmitPrefix);
@@ -1507,7 +1456,15 @@ async function enforceJoinDeadline(context, task) {
1507
1456
  },
1508
1457
  });
1509
1458
  if (["kicked", "member_missing"].includes(result.outcome)) {
1510
- await setSummaryDecision(records, guildId, memberUserId, generation, "kicked", "kick_pending");
1459
+ const committed = await setSummaryDecision(records, guildId, memberUserId, generation, "kicked", "kick_pending");
1460
+ if (committed)
1461
+ await emitAccountGateKickLog(context.services, guildId, {
1462
+ memberUserId,
1463
+ outcome: successfulKickOutcome(result.outcome),
1464
+ directMessage: result.directMessage,
1465
+ source: "Join deadline",
1466
+ idempotencyKey: `account-gate-kick:deadline:${memberUserId}:${generation}`,
1467
+ });
1511
1468
  }
1512
1469
  }
1513
1470
  return { outcome: "completed" };
@@ -1728,6 +1685,11 @@ async function finalizeTerminalFinalization(services, guildId, finalizationId) {
1728
1685
  const sessions = services.get(HELYX_SERVICE_NAMES.sessions);
1729
1686
  if (row.outcome === "passed") {
1730
1687
  if (summary.last_decision === "passed") {
1688
+ await emitAccountGateCompletedLog(services, guildId, {
1689
+ memberUserId,
1690
+ roleOutcome: "already_applied",
1691
+ idempotencyKey: `account-gate-completed:${finalizationId}`,
1692
+ });
1731
1693
  await sessions.delete(stringValue(row.session_id));
1732
1694
  await finishTerminalRecord(records, row, "completed");
1733
1695
  return;
@@ -1757,6 +1719,11 @@ async function finalizeTerminalFinalization(services, guildId, finalizationId) {
1757
1719
  await finishTerminalRecord(records, row, "cancelled");
1758
1720
  return;
1759
1721
  }
1722
+ await emitAccountGateCompletedLog(services, guildId, {
1723
+ memberUserId,
1724
+ roleOutcome: transition.outcome,
1725
+ idempotencyKey: `account-gate-completed:${finalizationId}`,
1726
+ });
1760
1727
  }
1761
1728
  }
1762
1729
  else {
@@ -1790,7 +1757,7 @@ async function applyDurableFailureAction(services, configuration, guildId, membe
1790
1757
  if (configuration.failureAction !== "kick")
1791
1758
  return;
1792
1759
  const records = services.get(HELYX_SERVICE_NAMES.records);
1793
- await services
1760
+ const result = await services
1794
1761
  .get(HELYX_SERVICE_NAMES.memberEnforcement)
1795
1762
  .kick({
1796
1763
  guildId,
@@ -1828,6 +1795,14 @@ async function applyDurableFailureAction(services, configuration, guildId, membe
1828
1795
  return !(await isAccountGateWorkSuppressed(services, guildId, memberUserId, dateValue(terminal.work_created_at) ?? new Date(0)));
1829
1796
  },
1830
1797
  });
1798
+ if (["kicked", "member_missing"].includes(result.outcome))
1799
+ await emitAccountGateKickLog(services, guildId, {
1800
+ memberUserId,
1801
+ outcome: successfulKickOutcome(result.outcome),
1802
+ directMessage: result.directMessage,
1803
+ source: "Failed check",
1804
+ idempotencyKey: `account-gate-kick:terminal:${stringValue(terminal.finalization_id)}`,
1805
+ });
1831
1806
  }
1832
1807
  function validateReviewDeliveryPayload(input) {
1833
1808
  if (!input || typeof input !== "object" || Array.isArray(input))
@@ -1961,6 +1936,7 @@ async function recoverReviewClaim(context, task) {
1961
1936
  }
1962
1937
  const action = stringValue(row.claim_action);
1963
1938
  if (action === "approve") {
1939
+ let completionRoleOutcome = null;
1964
1940
  if (summary.last_decision !== "passed") {
1965
1941
  const snapshots = await context.services
1966
1942
  .get(HELYX_SERVICE_NAMES.discordResources)
@@ -1998,8 +1974,15 @@ async function recoverReviewClaim(context, task) {
1998
1974
  await finalizeReviewClaim(records, guildId, reviewId, claimRevision, "dismissed", row);
1999
1975
  return { outcome: "completed" };
2000
1976
  }
1977
+ completionRoleOutcome = transition.outcome;
2001
1978
  }
2002
- await finalizeReviewClaim(records, guildId, reviewId, claimRevision, "approved", row);
1979
+ const finalized = await finalizeReviewClaim(records, guildId, reviewId, claimRevision, "approved", row);
1980
+ if (finalized && completionRoleOutcome)
1981
+ await emitAccountGateCompletedLog(context.services, guildId, {
1982
+ memberUserId,
1983
+ roleOutcome: completionRoleOutcome,
1984
+ idempotencyKey: `account-gate-completed:review:${reviewId}`,
1985
+ });
2003
1986
  return { outcome: "completed" };
2004
1987
  }
2005
1988
  if (action === "retry") {
@@ -2017,6 +2000,8 @@ async function recoverReviewClaim(context, task) {
2017
2000
  const snapshots = await context.services
2018
2001
  .get(HELYX_SERVICE_NAMES.discordResources)
2019
2002
  .resolveMemberSnapshots({ guildId, userIds: [memberUserId] });
2003
+ let kickOutcome = "member_missing";
2004
+ let directMessage = "not_requested";
2020
2005
  if (snapshots[0]?.exists === true) {
2021
2006
  const result = await context.services
2022
2007
  .get(HELYX_SERVICE_NAMES.memberEnforcement)
@@ -2045,8 +2030,18 @@ async function recoverReviewClaim(context, task) {
2045
2030
  delayMs: 30_000,
2046
2031
  safeCode: "review-kick-not-complete",
2047
2032
  };
2033
+ kickOutcome = successfulKickOutcome(result.outcome);
2034
+ directMessage = result.directMessage;
2048
2035
  }
2049
- await finalizeReviewClaim(records, guildId, reviewId, claimRevision, "kicked", row);
2036
+ const finalized = await finalizeReviewClaim(records, guildId, reviewId, claimRevision, "kicked", row);
2037
+ if (finalized)
2038
+ await emitAccountGateKickLog(context.services, guildId, {
2039
+ memberUserId,
2040
+ outcome: kickOutcome,
2041
+ directMessage,
2042
+ source: "Staff review recovery",
2043
+ idempotencyKey: `account-gate-kick:review:${reviewId}`,
2044
+ });
2050
2045
  return { outcome: "completed" };
2051
2046
  }
2052
2047
  await finalizeReviewClaim(records, guildId, reviewId, claimRevision, "dismissed", row);
@@ -2373,6 +2368,8 @@ async function handleReviewDecision(context, decision) {
2373
2368
  return;
2374
2369
  }
2375
2370
  let status;
2371
+ let completionRoleOutcome = null;
2372
+ let kickResult = null;
2376
2373
  if (decision === "approve") {
2377
2374
  const transition = await applyRoleTransition({
2378
2375
  guildId: context.serverId,
@@ -2401,6 +2398,7 @@ async function handleReviewDecision(context, decision) {
2401
2398
  await replyError(context, "The member rejoined before approval completed; the old review was dismissed.");
2402
2399
  return;
2403
2400
  }
2401
+ completionRoleOutcome = transition.outcome;
2404
2402
  status = "approved";
2405
2403
  }
2406
2404
  else if (decision === "retry") {
@@ -2465,6 +2463,10 @@ async function handleReviewDecision(context, decision) {
2465
2463
  await replyError(context, `The member was not kicked (${result.outcome.replaceAll("_", " ")}). The claimed decision remains queued for safe recovery.`);
2466
2464
  return;
2467
2465
  }
2466
+ kickResult = {
2467
+ outcome: successfulKickOutcome(result.outcome),
2468
+ directMessage: result.directMessage,
2469
+ };
2468
2470
  status = "kicked";
2469
2471
  }
2470
2472
  else {
@@ -2505,6 +2507,19 @@ async function handleReviewDecision(context, decision) {
2505
2507
  await replyError(context, "This review changed in another session. Reload it before trying again.");
2506
2508
  return;
2507
2509
  }
2510
+ if (status === "kicked" && kickResult)
2511
+ await emitAccountGateKickLog(context.services, context.serverId, {
2512
+ memberUserId,
2513
+ ...kickResult,
2514
+ source: "Staff review",
2515
+ idempotencyKey: `account-gate-kick:review:${stringValue(row.review_id)}`,
2516
+ });
2517
+ if (status === "approved" && completionRoleOutcome)
2518
+ await emitAccountGateCompletedLog(context.services, context.serverId, {
2519
+ memberUserId,
2520
+ roleOutcome: completionRoleOutcome,
2521
+ idempotencyKey: `account-gate-completed:review:${stringValue(row.review_id)}`,
2522
+ });
2508
2523
  if (context.channelId) {
2509
2524
  await context.editChannelMessage(context.channelId, context.messageId, {
2510
2525
  allowedUserMentionIds: [],
@@ -2966,22 +2981,6 @@ function secureRandom(services) {
2966
2981
  },
2967
2982
  };
2968
2983
  }
2969
- async function emitLog(services, guildId, input) {
2970
- if (!services.has(HELYX_SERVICE_NAMES.moduleLogging))
2971
- return;
2972
- try {
2973
- await services
2974
- .get(HELYX_SERVICE_NAMES.moduleLogging)
2975
- .emit({
2976
- guildId,
2977
- moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
2978
- ...input,
2979
- });
2980
- }
2981
- catch {
2982
- // Customer logging is optional and must not block the gate.
2983
- }
2984
- }
2985
2984
  async function replyError(context, description) {
2986
2985
  await context.reply({
2987
2986
  ephemeral: true,
@@ -0,0 +1,44 @@
1
+ import type { DirectMessageOutcome, MemberEnforcementOutcome, ModuleLoggingService, ServiceAccess } from "@helyx/sdk";
2
+ export declare const accountGateLogging: {
3
+ readonly events: readonly [{
4
+ readonly id: "account-gate-completed";
5
+ readonly name: "Account gate completed";
6
+ readonly description: "Records successful completion using IDs and bounded outcome metadata.";
7
+ readonly defaultEnabled: true;
8
+ }, {
9
+ readonly id: "account-gate-review-created";
10
+ readonly name: "Account gate review created";
11
+ readonly description: "Records a staff-review request without copying challenge answers.";
12
+ readonly defaultEnabled: true;
13
+ }, {
14
+ readonly id: "account-gate-kicked";
15
+ readonly name: "Account gate kick";
16
+ readonly description: "Records the bounded enforcement and direct-message outcomes.";
17
+ readonly defaultEnabled: true;
18
+ }, {
19
+ readonly id: "account-gate-strict-mode-changed";
20
+ readonly name: "Account gate strict mode changed";
21
+ readonly description: "Records authorised strict-mode activation or automatic expiry.";
22
+ readonly defaultEnabled: true;
23
+ }, {
24
+ readonly id: "account-gate-resume-notice";
25
+ readonly name: "Account gate resume notice";
26
+ readonly description: "Records only whether private resume guidance could be delivered after a static gate.";
27
+ readonly defaultEnabled: true;
28
+ }];
29
+ };
30
+ export declare function successfulKickOutcome(outcome: MemberEnforcementOutcome): Extract<MemberEnforcementOutcome, "kicked" | "member_missing">;
31
+ export declare function emitAccountGateCompletedLog(services: ServiceAccess, guildId: string, input: {
32
+ memberUserId: string;
33
+ roleOutcome: string;
34
+ idempotencyKey: string;
35
+ }): Promise<void>;
36
+ export declare function emitAccountGateKickLog(services: ServiceAccess, guildId: string, input: {
37
+ memberUserId: string;
38
+ outcome: Extract<MemberEnforcementOutcome, "kicked" | "member_missing">;
39
+ directMessage: DirectMessageOutcome;
40
+ source: string;
41
+ idempotencyKey: string;
42
+ }): Promise<void>;
43
+ export declare function emitLog(services: ServiceAccess, guildId: string, input: Omit<Parameters<ModuleLoggingService["emit"]>[0], "guildId" | "moduleId">): Promise<void>;
44
+ //# sourceMappingURL=logging.d.ts.map
@@ -0,0 +1,84 @@
1
+ import { HELYX_SERVICE_NAMES } from "@helyx/sdk";
2
+ import { ACCOUNT_GATE_IDENTITY } from "./contracts.js";
3
+ export const accountGateLogging = {
4
+ events: [
5
+ {
6
+ id: "account-gate-completed",
7
+ name: "Account gate completed",
8
+ description: "Records successful completion using IDs and bounded outcome metadata.",
9
+ defaultEnabled: true,
10
+ },
11
+ {
12
+ id: "account-gate-review-created",
13
+ name: "Account gate review created",
14
+ description: "Records a staff-review request without copying challenge answers.",
15
+ defaultEnabled: true,
16
+ },
17
+ {
18
+ id: "account-gate-kicked",
19
+ name: "Account gate kick",
20
+ description: "Records the bounded enforcement and direct-message outcomes.",
21
+ defaultEnabled: true,
22
+ },
23
+ {
24
+ id: "account-gate-strict-mode-changed",
25
+ name: "Account gate strict mode changed",
26
+ description: "Records authorised strict-mode activation or automatic expiry.",
27
+ defaultEnabled: true,
28
+ },
29
+ {
30
+ id: "account-gate-resume-notice",
31
+ name: "Account gate resume notice",
32
+ description: "Records only whether private resume guidance could be delivered after a static gate.",
33
+ defaultEnabled: true,
34
+ },
35
+ ],
36
+ };
37
+ export function successfulKickOutcome(outcome) {
38
+ if (outcome !== "kicked" && outcome !== "member_missing")
39
+ throw new Error("Access Control kick outcome is not successful");
40
+ return outcome;
41
+ }
42
+ export async function emitAccountGateCompletedLog(services, guildId, input) {
43
+ await emitLog(services, guildId, {
44
+ eventId: "account-gate-completed",
45
+ summary: "A member completed Access Control.",
46
+ details: [
47
+ { label: "Member ID", value: input.memberUserId },
48
+ { label: "Role update", value: input.roleOutcome },
49
+ ],
50
+ idempotencyKey: input.idempotencyKey,
51
+ });
52
+ }
53
+ export async function emitAccountGateKickLog(services, guildId, input) {
54
+ await emitLog(services, guildId, {
55
+ eventId: "account-gate-kicked",
56
+ summary: input.outcome === "kicked"
57
+ ? "Access Control removed a member from the server."
58
+ : "Access Control found that the member had already left the server.",
59
+ details: [
60
+ { label: "Member ID", value: input.memberUserId },
61
+ { label: "Outcome", value: input.outcome },
62
+ { label: "Direct message", value: input.directMessage },
63
+ { label: "Source", value: input.source },
64
+ ],
65
+ idempotencyKey: input.idempotencyKey,
66
+ });
67
+ }
68
+ export async function emitLog(services, guildId, input) {
69
+ if (!services.has(HELYX_SERVICE_NAMES.moduleLogging))
70
+ return;
71
+ try {
72
+ await services
73
+ .get(HELYX_SERVICE_NAMES.moduleLogging)
74
+ .emit({
75
+ guildId,
76
+ moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
77
+ ...input,
78
+ });
79
+ }
80
+ catch {
81
+ // Customer logging is optional and must not block the gate.
82
+ }
83
+ }
84
+ //# sourceMappingURL=logging.js.map
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.11",
6
+ "version": "1.0.14",
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",
@@ -686,7 +686,7 @@
686
686
  ],
687
687
  "operations": ["list", "read", "preview", "create", "update", "delete"],
688
688
  "creationIntents": ["draft", "publish"],
689
- "publicationMode": "preview_confirm",
689
+ "publicationMode": "validate_on_publish",
690
690
  "fields": [
691
691
  {
692
692
  "key": "question",
@@ -732,7 +732,12 @@
732
732
  ]
733
733
  },
734
734
  "contributions": {
735
- "commands": ["access-control"],
735
+ "commands": [
736
+ "access.control.start",
737
+ "access.control.reset",
738
+ "access.control.status",
739
+ "access.control.strict"
740
+ ],
736
741
  "events": [
737
742
  "discord.guild-member-add",
738
743
  "discord.guild-member-remove",
@@ -1,3 +1,5 @@
1
+ -- Baseline source: 0001_account_gate_foundation.sql
2
+
1
3
  CREATE TABLE IF NOT EXISTS account_gate_gate_panels (
2
4
  panel_id text PRIMARY KEY,
3
5
  guild_id text NOT NULL UNIQUE,
@@ -9,13 +11,6 @@ CREATE TABLE IF NOT EXISTS account_gate_gate_panels (
9
11
  CHECK (updated_at >= created_at)
10
12
  );
11
13
 
12
- CREATE TABLE IF NOT EXISTS account_gate_public_channels (
13
- guild_id text NOT NULL,
14
- channel_id text NOT NULL,
15
- created_at timestamptz NOT NULL,
16
- PRIMARY KEY (guild_id, channel_id)
17
- );
18
-
19
14
  CREATE TABLE IF NOT EXISTS account_gate_questions (
20
15
  question_id text PRIMARY KEY,
21
16
  guild_id text NOT NULL,
@@ -48,17 +43,6 @@ CREATE INDEX IF NOT EXISTS account_gate_question_creator_privacy_idx
48
43
  attribution_id
49
44
  );
50
45
 
51
- CREATE TABLE IF NOT EXISTS account_gate_password_secrets (
52
- guild_id text PRIMARY KEY,
53
- encoded_hash text NOT NULL,
54
- ignore_case boolean NOT NULL,
55
- trim_whitespace boolean NOT NULL,
56
- revision integer NOT NULL CHECK (revision > 0),
57
- created_at timestamptz NOT NULL,
58
- updated_at timestamptz NOT NULL,
59
- CHECK (updated_at >= created_at)
60
- );
61
-
62
46
  CREATE TABLE IF NOT EXISTS account_gate_attempts (
63
47
  attempt_id text PRIMARY KEY,
64
48
  guild_id text NOT NULL,
@@ -301,3 +285,152 @@ DROP TRIGGER IF EXISTS account_gate_terminal_privacy_suppressed
301
285
  CREATE TRIGGER account_gate_terminal_privacy_suppressed
302
286
  AFTER DELETE ON account_gate_terminal_finalizations
303
287
  FOR EACH ROW EXECUTE FUNCTION account_gate_install_privacy_suppression();
288
+
289
+ -- Baseline source: 0002_account_gate_panel_message.sql
290
+
291
+ ALTER TABLE account_gate_gate_panels
292
+ ADD COLUMN IF NOT EXISTS message_body text;
293
+
294
+ UPDATE account_gate_gate_panels
295
+ SET message_body =
296
+ 'Complete a short human-interaction check to access the server. Press the button below when you are ready.'
297
+ WHERE message_body IS NULL;
298
+
299
+ ALTER TABLE account_gate_gate_panels
300
+ ALTER COLUMN message_body SET DEFAULT
301
+ 'Complete a short human-interaction check to access the server. Press the button below when you are ready.',
302
+ ALTER COLUMN message_body SET NOT NULL;
303
+
304
+ DO $$
305
+ BEGIN
306
+ IF NOT EXISTS (
307
+ SELECT 1
308
+ FROM pg_constraint
309
+ WHERE conname = 'account_gate_gate_panels_message_body_length'
310
+ AND conrelid = 'account_gate_gate_panels'::regclass
311
+ ) THEN
312
+ ALTER TABLE account_gate_gate_panels
313
+ ADD CONSTRAINT account_gate_gate_panels_message_body_length
314
+ CHECK (char_length(message_body) BETWEEN 1 AND 1000);
315
+ END IF;
316
+ END
317
+ $$;
318
+
319
+ -- Baseline source: 0003_account_gate_revoked_member_status.sql
320
+
321
+ DO $$
322
+ BEGIN
323
+ IF to_regclass('account_gate_member_summaries') IS NOT NULL THEN
324
+ ALTER TABLE account_gate_member_summaries
325
+ DROP CONSTRAINT IF EXISTS account_gate_member_summaries_last_decision_check;
326
+
327
+ ALTER TABLE account_gate_member_summaries
328
+ ADD CONSTRAINT account_gate_member_summaries_last_decision_check
329
+ CHECK (
330
+ last_decision IN (
331
+ 'pending', 'finalizing', 'passed', 'failed', 'review', 'kick_pending',
332
+ 'kicked', 'dismissed', 'revoked'
333
+ )
334
+ );
335
+ END IF;
336
+ END
337
+ $$;
338
+
339
+ -- Baseline source: 0004_account_gate_multiple_panels.sql
340
+
341
+ ALTER TABLE account_gate_gate_panels
342
+ ADD COLUMN IF NOT EXISTS access_type text NOT NULL DEFAULT 'challenges';
343
+
344
+ DO $$
345
+ BEGIN
346
+ IF NOT EXISTS (
347
+ SELECT 1
348
+ FROM pg_constraint
349
+ WHERE conname = 'account_gate_gate_panels_access_type'
350
+ AND conrelid = 'account_gate_gate_panels'::regclass
351
+ ) THEN
352
+ ALTER TABLE account_gate_gate_panels
353
+ ADD CONSTRAINT account_gate_gate_panels_access_type
354
+ CHECK (access_type IN ('challenges', 'password', 'emoji'));
355
+ END IF;
356
+ END
357
+ $$;
358
+
359
+ ALTER TABLE account_gate_gate_panels
360
+ DROP CONSTRAINT IF EXISTS account_gate_gate_panels_guild_id_key;
361
+
362
+ CREATE INDEX IF NOT EXISTS account_gate_gate_panels_guild_idx
363
+ ON account_gate_gate_panels (guild_id, created_at, panel_id);
364
+
365
+ -- Baseline source: 0005_account_gate_panel_passwords.sql
366
+
367
+ ALTER TABLE account_gate_gate_panels
368
+ ADD COLUMN IF NOT EXISTS password_hash text,
369
+ ADD COLUMN IF NOT EXISTS password_ignore_case boolean NOT NULL DEFAULT true,
370
+ ADD COLUMN IF NOT EXISTS password_trim_whitespace boolean NOT NULL DEFAULT true;
371
+
372
+ CREATE OR REPLACE FUNCTION account_gate_enforce_panel_limit()
373
+ RETURNS trigger
374
+ LANGUAGE plpgsql
375
+ AS $$
376
+ BEGIN
377
+ PERFORM pg_advisory_xact_lock(
378
+ hashtextextended(NEW.guild_id, 4815162342)
379
+ );
380
+
381
+ IF (
382
+ SELECT count(*)
383
+ FROM account_gate_gate_panels
384
+ WHERE guild_id = NEW.guild_id
385
+ ) >= 10 THEN
386
+ RAISE EXCEPTION USING
387
+ ERRCODE = 'check_violation',
388
+ MESSAGE = 'Account Gate panel limit reached';
389
+ END IF;
390
+
391
+ RETURN NEW;
392
+ END
393
+ $$;
394
+
395
+ DROP TRIGGER IF EXISTS account_gate_gate_panels_limit
396
+ ON account_gate_gate_panels;
397
+
398
+ CREATE TRIGGER account_gate_gate_panels_limit
399
+ BEFORE INSERT ON account_gate_gate_panels
400
+ FOR EACH ROW
401
+ EXECUTE FUNCTION account_gate_enforce_panel_limit();
402
+
403
+ -- Baseline source: 0007_privacy_suppression_trigger_privileges.sql
404
+
405
+ -- Privacy erasure deletes Account Gate subject rows through a deliberately
406
+ -- column-scoped executor role. The deletion triggers must still be able to
407
+ -- write the suppression tombstone that prevents erased data being recreated.
408
+ CREATE OR REPLACE FUNCTION public.account_gate_install_privacy_suppression()
409
+ RETURNS trigger
410
+ LANGUAGE plpgsql
411
+ SECURITY DEFINER
412
+ SET search_path = pg_catalog, public
413
+ AS $$
414
+ BEGIN
415
+ INSERT INTO public.account_gate_privacy_suppressions (
416
+ suppression_id,
417
+ guild_id,
418
+ member_user_id,
419
+ suppressed_at,
420
+ created_at
421
+ )
422
+ VALUES (
423
+ OLD.guild_id || ':' || OLD.member_user_id,
424
+ OLD.guild_id,
425
+ OLD.member_user_id,
426
+ clock_timestamp(),
427
+ clock_timestamp()
428
+ )
429
+ ON CONFLICT (guild_id, member_user_id)
430
+ DO UPDATE SET suppressed_at = GREATEST(
431
+ public.account_gate_privacy_suppressions.suppressed_at,
432
+ EXCLUDED.suppressed_at
433
+ );
434
+ RETURN OLD;
435
+ END;
436
+ $$;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@helyx/module-account-gate",
3
- "version": "1.0.11",
3
+ "version": "1.0.14",
4
4
  "description": "Require configurable Discord interaction checks before new members gain access.",
5
5
  "keywords": [
6
6
  "helyx",
@@ -48,7 +48,7 @@
48
48
  "build": "tsc -b"
49
49
  },
50
50
  "dependencies": {
51
- "@helyx/module-manifest": ">=0.6.0 <0.7.0",
52
- "@helyx/sdk": ">=0.7.0 <0.8.0"
51
+ "@helyx/module-manifest": ">=0.7.0 <0.8.0",
52
+ "@helyx/sdk": ">=0.8.4 <0.9.0"
53
53
  }
54
54
  }
@@ -1,27 +0,0 @@
1
- ALTER TABLE account_gate_gate_panels
2
- ADD COLUMN IF NOT EXISTS message_body text;
3
-
4
- UPDATE account_gate_gate_panels
5
- SET message_body =
6
- 'Complete a short human-interaction check to access the server. Press the button below when you are ready.'
7
- WHERE message_body IS NULL;
8
-
9
- ALTER TABLE account_gate_gate_panels
10
- ALTER COLUMN message_body SET DEFAULT
11
- 'Complete a short human-interaction check to access the server. Press the button below when you are ready.',
12
- ALTER COLUMN message_body SET NOT NULL;
13
-
14
- DO $$
15
- BEGIN
16
- IF NOT EXISTS (
17
- SELECT 1
18
- FROM pg_constraint
19
- WHERE conname = 'account_gate_gate_panels_message_body_length'
20
- AND conrelid = 'account_gate_gate_panels'::regclass
21
- ) THEN
22
- ALTER TABLE account_gate_gate_panels
23
- ADD CONSTRAINT account_gate_gate_panels_message_body_length
24
- CHECK (char_length(message_body) BETWEEN 1 AND 1000);
25
- END IF;
26
- END
27
- $$;
@@ -1,17 +0,0 @@
1
- DO $$
2
- BEGIN
3
- IF to_regclass('account_gate_member_summaries') IS NOT NULL THEN
4
- ALTER TABLE account_gate_member_summaries
5
- DROP CONSTRAINT IF EXISTS account_gate_member_summaries_last_decision_check;
6
-
7
- ALTER TABLE account_gate_member_summaries
8
- ADD CONSTRAINT account_gate_member_summaries_last_decision_check
9
- CHECK (
10
- last_decision IN (
11
- 'pending', 'finalizing', 'passed', 'failed', 'review', 'kick_pending',
12
- 'kicked', 'dismissed', 'revoked'
13
- )
14
- );
15
- END IF;
16
- END
17
- $$;
@@ -1,23 +0,0 @@
1
- ALTER TABLE account_gate_gate_panels
2
- ADD COLUMN IF NOT EXISTS access_type text NOT NULL DEFAULT 'challenges';
3
-
4
- DO $$
5
- BEGIN
6
- IF NOT EXISTS (
7
- SELECT 1
8
- FROM pg_constraint
9
- WHERE conname = 'account_gate_gate_panels_access_type'
10
- AND conrelid = 'account_gate_gate_panels'::regclass
11
- ) THEN
12
- ALTER TABLE account_gate_gate_panels
13
- ADD CONSTRAINT account_gate_gate_panels_access_type
14
- CHECK (access_type IN ('challenges', 'password', 'emoji'));
15
- END IF;
16
- END
17
- $$;
18
-
19
- ALTER TABLE account_gate_gate_panels
20
- DROP CONSTRAINT IF EXISTS account_gate_gate_panels_guild_id_key;
21
-
22
- CREATE INDEX IF NOT EXISTS account_gate_gate_panels_guild_idx
23
- ON account_gate_gate_panels (guild_id, created_at, panel_id);
@@ -1,45 +0,0 @@
1
- ALTER TABLE account_gate_gate_panels
2
- ADD COLUMN IF NOT EXISTS password_hash text,
3
- ADD COLUMN IF NOT EXISTS password_ignore_case boolean NOT NULL DEFAULT true,
4
- ADD COLUMN IF NOT EXISTS password_trim_whitespace boolean NOT NULL DEFAULT true;
5
-
6
- UPDATE account_gate_gate_panels AS panel
7
- SET
8
- password_hash = secret.encoded_hash,
9
- password_ignore_case = secret.ignore_case,
10
- password_trim_whitespace = secret.trim_whitespace
11
- FROM account_gate_password_secrets AS secret
12
- WHERE panel.guild_id = secret.guild_id
13
- AND panel.access_type = 'password'
14
- AND panel.password_hash IS NULL;
15
-
16
- CREATE OR REPLACE FUNCTION account_gate_enforce_panel_limit()
17
- RETURNS trigger
18
- LANGUAGE plpgsql
19
- AS $$
20
- BEGIN
21
- PERFORM pg_advisory_xact_lock(
22
- hashtextextended(NEW.guild_id, 4815162342)
23
- );
24
-
25
- IF (
26
- SELECT count(*)
27
- FROM account_gate_gate_panels
28
- WHERE guild_id = NEW.guild_id
29
- ) >= 10 THEN
30
- RAISE EXCEPTION USING
31
- ERRCODE = 'check_violation',
32
- MESSAGE = 'Account Gate panel limit reached';
33
- END IF;
34
-
35
- RETURN NEW;
36
- END
37
- $$;
38
-
39
- DROP TRIGGER IF EXISTS account_gate_gate_panels_limit
40
- ON account_gate_gate_panels;
41
-
42
- CREATE TRIGGER account_gate_gate_panels_limit
43
- BEFORE INSERT ON account_gate_gate_panels
44
- FOR EACH ROW
45
- EXECUTE FUNCTION account_gate_enforce_panel_limit();
@@ -1,2 +0,0 @@
1
- DROP TABLE IF EXISTS account_gate_public_channels;
2
- DROP TABLE IF EXISTS account_gate_password_secrets;
@@ -1,32 +0,0 @@
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
- $$;