@helyx/module-account-gate 1.0.12 → 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,18 @@
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
+
3
16
  ## 1.0.12
4
17
 
5
18
  ### 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.
@@ -1,10 +1,10 @@
1
- import type { CommandContribution, CommandInteractionContext } from "@helyx/sdk";
1
+ import type { CommandInteractionContext, SharedCommandContribution } from "@helyx/sdk";
2
2
  type CommandHandler = (context: CommandInteractionContext) => Promise<void>;
3
3
  export declare function accountGateCommands(handlers: {
4
4
  start: CommandHandler;
5
5
  reset: CommandHandler;
6
6
  status: CommandHandler;
7
7
  strict: CommandHandler;
8
- }): readonly CommandContribution[];
8
+ }): readonly SharedCommandContribution[];
9
9
  export {};
10
10
  //# sourceMappingURL=commands.d.ts.map
package/dist/commands.js CHANGED
@@ -1,39 +1,37 @@
1
1
  export function accountGateCommands(handlers) {
2
2
  return [
3
3
  {
4
- name: "access-control",
5
- description: "Start or manage the server's Access Control checks",
4
+ family: "access",
5
+ path: "control.start",
6
+ description: "Start or resume your Access Control check",
6
7
  acknowledgement: "deferred-ephemeral",
7
- registration: "global",
8
- contexts: ["guild"],
9
- integrationTypes: ["guild_install"],
10
- subcommands: [
11
- {
12
- name: "start",
13
- description: "Start or resume your Access Control check",
14
- rateLimit: { scope: "user", limit: 5, windowSeconds: 60 },
15
- execute: handlers.start,
16
- },
17
- {
18
- name: "reset",
19
- description: "Revoke your access and complete the gate again",
20
- permissionId: "account-gate.reset",
21
- rateLimit: { scope: "user", limit: 10, windowSeconds: 60 },
22
- execute: handlers.reset,
23
- },
24
- {
25
- name: "status",
26
- description: "Show Access Control health and strict-mode status",
27
- permissionId: "account-gate.manage",
28
- execute: handlers.status,
29
- },
30
- {
31
- name: "strict",
32
- description: "Activate temporary strict mode",
33
- permissionId: "account-gate.manage",
34
- execute: handlers.strict,
35
- },
36
- ],
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,
37
35
  },
38
36
  ];
39
37
  }
@@ -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.12";
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.12",
5
+ version: "1.0.14",
6
6
  releaseStatus: "live",
7
7
  category: "safety",
8
8
  icon: "shield-check",
package/dist/index.js CHANGED
@@ -14,6 +14,7 @@ import { gatePanelResource, getGatePanelRuntime, hasPasswordGatePanel, } from ".
14
14
  import { isAccountGateWorkSuppressed } from "./privacy.js";
15
15
  import { applyChallengeAction, createAccountGateSessionState, parseAccountGateSessionState, rerollForAccessibility, } from "./workflow.js";
16
16
  import { accountGateCommands } from "./commands.js";
17
+ import { accountGateLogging, emitAccountGateCompletedLog, emitAccountGateKickLog, emitLog, successfulKickOutcome, } from "./logging.js";
17
18
  const manifest = parseModuleManifest(JSON.parse(readFileSync(new URL("../manifest.json", import.meta.url), "utf8")));
18
19
  const VIOLET = 0x7c3aed;
19
20
  const GREEN = 0x16a34a;
@@ -49,7 +50,7 @@ export const accountGateModule = defineModule({
49
50
  async validateActivation(context) {
50
51
  const configuration = await getConfiguration(context.services, context.guildId);
51
52
  if (!configuration)
52
- throw new ModuleStateValidationError("Save Access Control settings before enabling the module.");
53
+ return;
53
54
  const errors = await validateAccountGateSetup(context.services, context.guildId, configuration, false, true);
54
55
  if (errors.length > 0)
55
56
  throw new ModuleStateValidationError(errors[0]);
@@ -82,47 +83,14 @@ export const accountGateModule = defineModule({
82
83
  accountGateMemberResource,
83
84
  serverQuestionResource,
84
85
  ],
85
- logging: {
86
- events: [
87
- {
88
- id: "account-gate-completed",
89
- name: "Account gate completed",
90
- description: "Records successful completion using IDs and bounded outcome metadata.",
91
- defaultEnabled: true,
92
- },
93
- {
94
- id: "account-gate-review-created",
95
- name: "Account gate review created",
96
- description: "Records a staff-review request without copying challenge answers.",
97
- defaultEnabled: true,
98
- },
99
- {
100
- id: "account-gate-kicked",
101
- name: "Account gate kick",
102
- description: "Records the bounded enforcement and direct-message outcomes.",
103
- defaultEnabled: true,
104
- },
105
- {
106
- id: "account-gate-strict-mode-changed",
107
- name: "Account gate strict mode changed",
108
- description: "Records authorised strict-mode activation or automatic expiry.",
109
- defaultEnabled: true,
110
- },
111
- {
112
- id: "account-gate-resume-notice",
113
- name: "Account gate resume notice",
114
- description: "Records only whether private resume guidance could be delivered after a static gate.",
115
- defaultEnabled: true,
116
- },
117
- ],
118
- },
86
+ sharedCommands: accountGateCommands({
87
+ start: startCheck,
88
+ reset: resetOwnAccess,
89
+ status: showStatus,
90
+ strict: activateStrictModeCommand,
91
+ }),
92
+ logging: accountGateLogging,
119
93
  interactions: {
120
- commands: accountGateCommands({
121
- start: startCheck,
122
- reset: resetOwnAccess,
123
- status: showStatus,
124
- strict: activateStrictModeCommand,
125
- }),
126
94
  buttons: [
127
95
  {
128
96
  customId: ACCOUNT_GATE_COMPONENTS.start,
@@ -167,6 +135,7 @@ export const accountGateModule = defineModule({
167
135
  modals: [
168
136
  {
169
137
  customIdPrefix: ACCOUNT_GATE_COMPONENTS.recallSubmitPrefix,
138
+ acknowledgement: "deferred-ephemeral",
170
139
  execute: submitRecallModal,
171
140
  },
172
141
  ],
@@ -920,20 +889,9 @@ async function rerollChallenge(context) {
920
889
  async function openRecallModal(context) {
921
890
  if (!context.serverId)
922
891
  return replyError(context, "This check is unavailable.");
923
- const session = await context.services
924
- .get(HELYX_SERVICE_NAMES.sessions)
925
- .getFor(context.serverId, context.userId, ACCOUNT_GATE_SESSION_PURPOSE);
926
892
  const binding = parseComponentBinding(context.customId, ACCOUNT_GATE_COMPONENTS.recallOpenPrefix);
927
- const state = session ? parseAccountGateSessionState(session.state) : null;
928
- if (!session ||
929
- !state ||
930
- !binding ||
931
- binding.sessionId !== session.sessionId ||
932
- binding.version !== session.version ||
933
- binding.step !== state.step ||
934
- state.chain[state.currentStep]?.type !== "modal_recall") {
893
+ if (!binding)
935
894
  return replyError(context, "This recall challenge has expired.");
936
- }
937
895
  await context.showModal({
938
896
  customId: boundComponentId(ACCOUNT_GATE_COMPONENTS.recallSubmitPrefix, binding),
939
897
  title: "Enter the recalled code",
@@ -949,10 +907,30 @@ async function openRecallModal(context) {
949
907
  },
950
908
  ],
951
909
  });
952
- 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, {
953
930
  sessionId: session.sessionId,
954
931
  version: session.version,
955
- }, { redactRecallCode: true }));
932
+ }, { redactRecallCode: true }))
933
+ .catch(() => undefined);
956
934
  }
957
935
  async function submitRecallModal(context) {
958
936
  const binding = parseComponentBinding(context.customId, ACCOUNT_GATE_COMPONENTS.recallSubmitPrefix);
@@ -1478,7 +1456,15 @@ async function enforceJoinDeadline(context, task) {
1478
1456
  },
1479
1457
  });
1480
1458
  if (["kicked", "member_missing"].includes(result.outcome)) {
1481
- 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
+ });
1482
1468
  }
1483
1469
  }
1484
1470
  return { outcome: "completed" };
@@ -1699,6 +1685,11 @@ async function finalizeTerminalFinalization(services, guildId, finalizationId) {
1699
1685
  const sessions = services.get(HELYX_SERVICE_NAMES.sessions);
1700
1686
  if (row.outcome === "passed") {
1701
1687
  if (summary.last_decision === "passed") {
1688
+ await emitAccountGateCompletedLog(services, guildId, {
1689
+ memberUserId,
1690
+ roleOutcome: "already_applied",
1691
+ idempotencyKey: `account-gate-completed:${finalizationId}`,
1692
+ });
1702
1693
  await sessions.delete(stringValue(row.session_id));
1703
1694
  await finishTerminalRecord(records, row, "completed");
1704
1695
  return;
@@ -1728,6 +1719,11 @@ async function finalizeTerminalFinalization(services, guildId, finalizationId) {
1728
1719
  await finishTerminalRecord(records, row, "cancelled");
1729
1720
  return;
1730
1721
  }
1722
+ await emitAccountGateCompletedLog(services, guildId, {
1723
+ memberUserId,
1724
+ roleOutcome: transition.outcome,
1725
+ idempotencyKey: `account-gate-completed:${finalizationId}`,
1726
+ });
1731
1727
  }
1732
1728
  }
1733
1729
  else {
@@ -1761,7 +1757,7 @@ async function applyDurableFailureAction(services, configuration, guildId, membe
1761
1757
  if (configuration.failureAction !== "kick")
1762
1758
  return;
1763
1759
  const records = services.get(HELYX_SERVICE_NAMES.records);
1764
- await services
1760
+ const result = await services
1765
1761
  .get(HELYX_SERVICE_NAMES.memberEnforcement)
1766
1762
  .kick({
1767
1763
  guildId,
@@ -1799,6 +1795,14 @@ async function applyDurableFailureAction(services, configuration, guildId, membe
1799
1795
  return !(await isAccountGateWorkSuppressed(services, guildId, memberUserId, dateValue(terminal.work_created_at) ?? new Date(0)));
1800
1796
  },
1801
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
+ });
1802
1806
  }
1803
1807
  function validateReviewDeliveryPayload(input) {
1804
1808
  if (!input || typeof input !== "object" || Array.isArray(input))
@@ -1932,6 +1936,7 @@ async function recoverReviewClaim(context, task) {
1932
1936
  }
1933
1937
  const action = stringValue(row.claim_action);
1934
1938
  if (action === "approve") {
1939
+ let completionRoleOutcome = null;
1935
1940
  if (summary.last_decision !== "passed") {
1936
1941
  const snapshots = await context.services
1937
1942
  .get(HELYX_SERVICE_NAMES.discordResources)
@@ -1969,8 +1974,15 @@ async function recoverReviewClaim(context, task) {
1969
1974
  await finalizeReviewClaim(records, guildId, reviewId, claimRevision, "dismissed", row);
1970
1975
  return { outcome: "completed" };
1971
1976
  }
1977
+ completionRoleOutcome = transition.outcome;
1972
1978
  }
1973
- 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
+ });
1974
1986
  return { outcome: "completed" };
1975
1987
  }
1976
1988
  if (action === "retry") {
@@ -1988,6 +2000,8 @@ async function recoverReviewClaim(context, task) {
1988
2000
  const snapshots = await context.services
1989
2001
  .get(HELYX_SERVICE_NAMES.discordResources)
1990
2002
  .resolveMemberSnapshots({ guildId, userIds: [memberUserId] });
2003
+ let kickOutcome = "member_missing";
2004
+ let directMessage = "not_requested";
1991
2005
  if (snapshots[0]?.exists === true) {
1992
2006
  const result = await context.services
1993
2007
  .get(HELYX_SERVICE_NAMES.memberEnforcement)
@@ -2016,8 +2030,18 @@ async function recoverReviewClaim(context, task) {
2016
2030
  delayMs: 30_000,
2017
2031
  safeCode: "review-kick-not-complete",
2018
2032
  };
2033
+ kickOutcome = successfulKickOutcome(result.outcome);
2034
+ directMessage = result.directMessage;
2019
2035
  }
2020
- 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
+ });
2021
2045
  return { outcome: "completed" };
2022
2046
  }
2023
2047
  await finalizeReviewClaim(records, guildId, reviewId, claimRevision, "dismissed", row);
@@ -2344,6 +2368,8 @@ async function handleReviewDecision(context, decision) {
2344
2368
  return;
2345
2369
  }
2346
2370
  let status;
2371
+ let completionRoleOutcome = null;
2372
+ let kickResult = null;
2347
2373
  if (decision === "approve") {
2348
2374
  const transition = await applyRoleTransition({
2349
2375
  guildId: context.serverId,
@@ -2372,6 +2398,7 @@ async function handleReviewDecision(context, decision) {
2372
2398
  await replyError(context, "The member rejoined before approval completed; the old review was dismissed.");
2373
2399
  return;
2374
2400
  }
2401
+ completionRoleOutcome = transition.outcome;
2375
2402
  status = "approved";
2376
2403
  }
2377
2404
  else if (decision === "retry") {
@@ -2436,6 +2463,10 @@ async function handleReviewDecision(context, decision) {
2436
2463
  await replyError(context, `The member was not kicked (${result.outcome.replaceAll("_", " ")}). The claimed decision remains queued for safe recovery.`);
2437
2464
  return;
2438
2465
  }
2466
+ kickResult = {
2467
+ outcome: successfulKickOutcome(result.outcome),
2468
+ directMessage: result.directMessage,
2469
+ };
2439
2470
  status = "kicked";
2440
2471
  }
2441
2472
  else {
@@ -2476,6 +2507,19 @@ async function handleReviewDecision(context, decision) {
2476
2507
  await replyError(context, "This review changed in another session. Reload it before trying again.");
2477
2508
  return;
2478
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
+ });
2479
2523
  if (context.channelId) {
2480
2524
  await context.editChannelMessage(context.channelId, context.messageId, {
2481
2525
  allowedUserMentionIds: [],
@@ -2937,22 +2981,6 @@ function secureRandom(services) {
2937
2981
  },
2938
2982
  };
2939
2983
  }
2940
- async function emitLog(services, guildId, input) {
2941
- if (!services.has(HELYX_SERVICE_NAMES.moduleLogging))
2942
- return;
2943
- try {
2944
- await services
2945
- .get(HELYX_SERVICE_NAMES.moduleLogging)
2946
- .emit({
2947
- guildId,
2948
- moduleId: ACCOUNT_GATE_IDENTITY.moduleId,
2949
- ...input,
2950
- });
2951
- }
2952
- catch {
2953
- // Customer logging is optional and must not block the gate.
2954
- }
2955
- }
2956
2984
  async function replyError(context, description) {
2957
2985
  await context.reply({
2958
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.12",
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",
@@ -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.12",
3
+ "version": "1.0.14",
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.7.0 <0.8.0",
52
- "@helyx/sdk": ">=0.8.0 <0.9.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
- $$;