@helyx/bot 0.2.0 → 0.2.2

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,40 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.2
4
+
5
+ ### Patch Changes
6
+
7
+ - b0f1932: Cache Discord application owners outside the interaction acknowledgement path
8
+ so Cloud Connect commands and modals respond reliably during Discord API
9
+ latency.
10
+
11
+ Treat an unreadable local Cloud Connect identity as an actionable recovery
12
+ state instead of retrying indefinitely. Owners can revoke the old dashboard
13
+ installation and enrol again with a new one-use code.
14
+
15
+ Run bounded cleanup for expired interaction sessions and rate-limit windows.
16
+
17
+ - Updated dependencies [18265bc]
18
+ - @helyx/database@0.2.1
19
+ - @helyx/platform@0.1.5
20
+
21
+ ## 0.2.1
22
+
23
+ ### Patch Changes
24
+
25
+ - Add explicit Planned, Testing, Beta and Live module lifecycle metadata.
26
+
27
+ Propagate lifecycle status through the hosted control contract and permit only
28
+ Live modules in hosted distributions and generated self-host projects.
29
+
30
+ - Updated dependencies
31
+ - @helyx/module-manifest@0.3.0
32
+ - @helyx/control-protocol@0.1.1
33
+ - @helyx/core@0.1.4
34
+ - @helyx/sdk@0.2.4
35
+ - @helyx/framework@0.1.4
36
+ - @helyx/platform@0.1.4
37
+
3
38
  ## 0.2.0
4
39
 
5
40
  ### Minor Changes
package/README.md CHANGED
@@ -16,6 +16,6 @@ Gateway intents are derived from installed event contributions. The bot always r
16
16
 
17
17
  When `HELYX_BOT_CONTROL_SECRET` is configured, the bot also starts a private authenticated HTTP control server on `HELYX_BOT_CONTROL_PORT`. The dashboard API uses this service to obtain real Discord roles and channels and to apply validated module, configuration and permission changes. Only `/health` is unauthenticated; all `/v1` routes require the shared bearer secret. Keep this endpoint on a private service network and do not assign it a public domain.
18
18
 
19
- Optional Helyx Cloud Connect is a separate outbound management path for self-hosted deployments. It is disabled unless `HELYX_CLOUD_CONNECT_ENABLED=true` and requires a separate local `HELYX_DATA_ENCRYPTION_KEY`. The owner-only `/helyx-cloud` command supports connection-code enrolment, status, credential rotation, disconnection and bounded support consent. The bot never sends its Discord token, database URL or connector private key to Helyx Cloud, and local Discord operation continues when Cloud is unavailable.
19
+ Optional Helyx Cloud Connect is a separate outbound management path for self-hosted deployments. It is disabled unless `HELYX_CLOUD_CONNECT_ENABLED=true` and requires a separate local `HELYX_DATA_ENCRYPTION_KEY`. Preserve that key across every restart and deployment. If it is lost or changed, revoke the old installation in the dashboard and use a new one-use code with `/helyx-cloud connect`; the connector stops retrying until that recovery is completed. The owner-only `/helyx-cloud` command supports connection-code enrolment, status, credential rotation, disconnection and bounded support consent. The bot never sends its Discord token, database URL or connector private key to Helyx Cloud, and local Discord operation continues when Cloud is unavailable.
20
20
 
21
21
  For packaged operation use `helyx start`. `helyx migrate` applies core database migrations without Discord, and read-only `helyx doctor` verifies environment, PostgreSQL migration history, and configured package metadata.
@@ -4,7 +4,7 @@ import type { Client } from "discord.js";
4
4
  import type { Logger } from "pino";
5
5
  import type { ConfiguredModulePackage } from "./modules.js";
6
6
  import type { BotControlHandler } from "./control-server.js";
7
- type ConnectorState = "disabled" | "unpaired" | "connecting" | "online" | "offline" | "revoked" | "stopped";
7
+ type ConnectorState = "disabled" | "unpaired" | "connecting" | "online" | "offline" | "recovery_required" | "revoked" | "stopped";
8
8
  export interface CloudConnectorStatus {
9
9
  state: ConnectorState;
10
10
  installationId?: string;
@@ -136,7 +136,8 @@ export class CloudConnectorController {
136
136
  if (!this.input.environment.HELYX_CLOUD_CONNECT_ENABLED)
137
137
  throw new CloudConnectorUserError("Cloud Connect is disabled in this deployment");
138
138
  const existing = await this.#repository.getIdentity();
139
- if (existing && !existing.revokedAt)
139
+ const recoveringIdentity = existing && !existing.revokedAt && this.#state === "recovery_required";
140
+ if (existing && !existing.revokedAt && !recoveringIdentity)
140
141
  throw new CloudConnectorUserError("This Helyx deployment is already connected");
141
142
  if (!this.#dataKey)
142
143
  throw new CloudConnectorUserError("HELYX_DATA_ENCRYPTION_KEY is not configured");
@@ -183,6 +184,9 @@ export class CloudConnectorController {
183
184
  }
184
185
  const exchange = exchangeResponseSchema.parse(payload);
185
186
  const encrypted = encryptPrivateKey(keyPair.privateKey, this.#dataKey, exchange.installationId);
187
+ if (recoveringIdentity && !(await this.#repository.revoke())) {
188
+ throw new CloudConnectorUserError("The previous local Cloud Connect identity could not be reset");
189
+ }
186
190
  this.#identity = await this.#repository.createIdentity({
187
191
  installationId: exchange.installationId,
188
192
  credentialId: exchange.credentialId,
@@ -196,7 +200,9 @@ export class CloudConnectorController {
196
200
  });
197
201
  this.#connectionBlocked = false;
198
202
  await this.#repository.appendAudit({
199
- action: "installation.enrolled",
203
+ action: recoveringIdentity
204
+ ? "installation.recovered"
205
+ : "installation.enrolled",
200
206
  source,
201
207
  outcome: "succeeded",
202
208
  });
@@ -312,6 +318,16 @@ export class CloudConnectorController {
312
318
  catch (error) {
313
319
  if (this.#stopping)
314
320
  break;
321
+ if (error instanceof CloudConnectorIdentityKeyError) {
322
+ this.#connectionBlocked = true;
323
+ this.#state = "recovery_required";
324
+ this.#lastErrorCode = "local_identity_key_mismatch";
325
+ this.input.logger.error({
326
+ event: "cloud.identity_recovery_required",
327
+ reason: this.#lastErrorCode,
328
+ }, "Cloud Connect cannot decrypt its local identity. Revoke the old installation in the dashboard, then use a new one-use code with /helyx-cloud connect");
329
+ break;
330
+ }
315
331
  this.#state = "offline";
316
332
  this.#lastErrorCode = safeConnectorError(error);
317
333
  this.input.logger.warn({
@@ -598,13 +614,24 @@ function encryptPrivateKey(privateKey, dataKey, installationId) {
598
614
  return { ciphertext, iv, tag: cipher.getAuthTag() };
599
615
  }
600
616
  function decryptPrivateKey(encrypted, dataKey, installationId) {
601
- const decipher = createDecipheriv("aes-256-gcm", dataKey, encrypted.iv);
602
- decipher.setAAD(Buffer.from(`helyx-cloud-connector:${installationId}`, "utf8"));
603
- decipher.setAuthTag(encrypted.tag);
604
- return Buffer.concat([
605
- decipher.update(encrypted.ciphertext),
606
- decipher.final(),
607
- ]).toString("utf8");
617
+ try {
618
+ const decipher = createDecipheriv("aes-256-gcm", dataKey, encrypted.iv);
619
+ decipher.setAAD(Buffer.from(`helyx-cloud-connector:${installationId}`, "utf8"));
620
+ decipher.setAuthTag(encrypted.tag);
621
+ return Buffer.concat([
622
+ decipher.update(encrypted.ciphertext),
623
+ decipher.final(),
624
+ ]).toString("utf8");
625
+ }
626
+ catch {
627
+ throw new CloudConnectorIdentityKeyError();
628
+ }
629
+ }
630
+ class CloudConnectorIdentityKeyError extends Error {
631
+ constructor() {
632
+ super("The local Cloud Connect identity cannot be decrypted");
633
+ this.name = "CloudConnectorIdentityKeyError";
634
+ }
608
635
  }
609
636
  function connectedMessage(input) {
610
637
  const now = new Date();
@@ -1,6 +1,12 @@
1
1
  import { type ModuleDefinition } from "@helyx/sdk";
2
2
  import type { Client } from "discord.js";
3
3
  import type { CloudConnectorController } from "./cloud-connector.js";
4
+ export declare class DeploymentOwnerAuthorizer {
5
+ #private;
6
+ constructor(client: Client, configuredOwnerUserIds: readonly string[]);
7
+ isOwner(userId: string): boolean;
8
+ refresh(): Promise<void>;
9
+ }
4
10
  export declare function createCloudManagementModule(input: {
5
11
  connector: CloudConnectorController;
6
12
  client: Client;
@@ -1,4 +1,5 @@
1
1
  import { defineModule, } from "@helyx/sdk";
2
+ import { Events } from "discord.js";
2
3
  import { CloudConnectorUserError, } from "./cloud-connector.js";
3
4
  const CONNECT_MODAL_ID = "helyx.cloud.connect";
4
5
  const CONNECT_CODE_FIELD_ID = "helyx.cloud.connect.code";
@@ -13,9 +14,41 @@ const DIAGNOSTICS_ENABLED_FIELD_ID = "helyx.cloud.support.diagnostics";
13
14
  const VIOLET = 0x7c3aed;
14
15
  const SUCCESS = 0x16a34a;
15
16
  const DANGER = 0xdc2626;
17
+ const OWNER_REFRESH_INTERVAL_MS = 15 * 60 * 1_000;
18
+ export class DeploymentOwnerAuthorizer {
19
+ #client;
20
+ #configuredOwnerUserIds;
21
+ #applicationOwnerUserIds = new Set();
22
+ constructor(client, configuredOwnerUserIds) {
23
+ this.#client = client;
24
+ this.#configuredOwnerUserIds = new Set(configuredOwnerUserIds);
25
+ }
26
+ isOwner(userId) {
27
+ return (this.#configuredOwnerUserIds.has(userId) ||
28
+ this.#applicationOwnerUserIds.has(userId));
29
+ }
30
+ async refresh() {
31
+ const application = this.#client.application;
32
+ if (!application)
33
+ return;
34
+ this.#replaceApplicationOwners(application.owner);
35
+ const refreshed = await application.fetch();
36
+ this.#replaceApplicationOwners(refreshed.owner);
37
+ }
38
+ #replaceApplicationOwners(owner) {
39
+ this.#applicationOwnerUserIds = !owner
40
+ ? new Set()
41
+ : "members" in owner
42
+ ? new Set(owner.members.keys())
43
+ : new Set([owner.id]);
44
+ }
45
+ }
16
46
  export function createCloudManagementModule(input) {
47
+ const ownerAuthorizer = new DeploymentOwnerAuthorizer(input.client, input.configuredOwnerUserIds);
48
+ let ownerRefreshInterval;
49
+ let onClientReady;
17
50
  const requireOwner = async (context) => {
18
- if (await isDeploymentOwner(input.client, context.userId, input.configuredOwnerUserIds))
51
+ if (ownerAuthorizer.isOwner(context.userId))
19
52
  return true;
20
53
  await context.reply({
21
54
  ephemeral: true,
@@ -33,6 +66,7 @@ export function createCloudManagementModule(input) {
33
66
  return defineModule({
34
67
  manifest: {
35
68
  manifestVersion: "0.1",
69
+ releaseStatus: "live",
36
70
  id: "helyx.cloud-connect",
37
71
  name: "Helyx Cloud Connect",
38
72
  version: "0.1.0",
@@ -410,21 +444,37 @@ export function createCloudManagementModule(input) {
410
444
  },
411
445
  ],
412
446
  },
413
- async start() { },
414
- async stop() { },
447
+ start(context) {
448
+ const refreshOwners = () => {
449
+ void ownerAuthorizer.refresh().catch((error) => {
450
+ context.logger.warn({
451
+ event: "cloud.owner_cache_refresh_failed",
452
+ error: safeError(error),
453
+ }, "Cloud Connect deployment-owner cache refresh failed");
454
+ });
455
+ };
456
+ onClientReady = () => {
457
+ refreshOwners();
458
+ ownerRefreshInterval ??= setInterval(refreshOwners, OWNER_REFRESH_INTERVAL_MS);
459
+ ownerRefreshInterval.unref();
460
+ };
461
+ if (input.client.isReady())
462
+ onClientReady();
463
+ else
464
+ input.client.once(Events.ClientReady, onClientReady);
465
+ return Promise.resolve();
466
+ },
467
+ stop() {
468
+ if (onClientReady)
469
+ input.client.off(Events.ClientReady, onClientReady);
470
+ if (ownerRefreshInterval)
471
+ clearInterval(ownerRefreshInterval);
472
+ onClientReady = undefined;
473
+ ownerRefreshInterval = undefined;
474
+ return Promise.resolve();
475
+ },
415
476
  });
416
477
  }
417
- async function isDeploymentOwner(client, userId, configuredOwnerUserIds) {
418
- if (configuredOwnerUserIds.includes(userId))
419
- return true;
420
- const application = await client.application?.fetch();
421
- const owner = application?.owner;
422
- if (!owner)
423
- return false;
424
- if ("members" in owner)
425
- return owner.members.has(userId);
426
- return owner.id === userId;
427
- }
428
478
  function statusResponse(status) {
429
479
  const labels = {
430
480
  disabled: "Disabled by deployment configuration",
@@ -432,6 +482,7 @@ function statusResponse(status) {
432
482
  connecting: "Connecting securely",
433
483
  online: "Connected",
434
484
  offline: "Temporarily offline; local operation continues",
485
+ recovery_required: "Local identity recovery required; revoke the old dashboard installation, then connect with a new code",
435
486
  revoked: "Disconnected and revoked",
436
487
  stopped: "Stopped",
437
488
  };
@@ -487,4 +538,9 @@ function restrictedPolicy() {
487
538
  deniedUserIds: [],
488
539
  };
489
540
  }
541
+ function safeError(error) {
542
+ return error instanceof Error
543
+ ? { name: error.name, message: error.message }
544
+ : { name: "Error", message: String(error) };
545
+ }
490
546
  //# sourceMappingURL=cloud-management-module.js.map
@@ -34,6 +34,7 @@ interface DashboardModuleDefinition {
34
34
  category: string;
35
35
  icon: string;
36
36
  availability: "available";
37
+ releaseStatus: "planned" | "testing" | "beta" | "live";
37
38
  requiredPermissions: string[];
38
39
  settings: readonly unknown[];
39
40
  commands: DashboardCommandDefinition[];
@@ -575,6 +575,7 @@ function mapDashboardModule(module) {
575
575
  category: dashboard.category[0].toUpperCase() + dashboard.category.slice(1),
576
576
  icon: dashboard.icon,
577
577
  availability: "available",
578
+ releaseStatus: module.manifest.releaseStatus,
578
579
  requiredPermissions: [...module.manifest.permissions],
579
580
  settings: dashboard.settings,
580
581
  commands: dashboardCommands(module),
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { parseBotEnvironment } from "@helyx/config";
2
2
  import { safeError, ServiceRegistry } from "@helyx/core";
3
- import { checkDatabaseConnection, createDatabasePool, runCoreMigrations, runConfiguredModuleMigrations, } from "@helyx/database";
3
+ import { checkDatabaseConnection, createDatabasePool, InteractionSessionRepository, RateLimitRepository, runCoreMigrations, runConfiguredModuleMigrations, } from "@helyx/database";
4
4
  import { createRuntime } from "@helyx/framework";
5
5
  import { createPlatformServices } from "@helyx/platform";
6
6
  import { HELYX_SERVICE_NAMES } from "@helyx/sdk";
@@ -24,6 +24,7 @@ export { loadConfiguredModulePackages } from "./modules.js";
24
24
  export { resolveModulePackageSpecifiers } from "./module-configuration.js";
25
25
  export * from "./control-server.js";
26
26
  const bootstrapLogger = pino({ base: { component: "bot" } });
27
+ const RETENTION_CLEANUP_INTERVAL_MS = 60 * 60 * 1_000;
27
28
  export async function startBot(processEnvironment = process.env) {
28
29
  const environment = parseBotEnvironment(processEnvironment);
29
30
  bootstrapLogger.level = environment.LOG_LEVEL;
@@ -33,6 +34,7 @@ export async function startBot(processEnvironment = process.env) {
33
34
  applicationName: "helyx-bot",
34
35
  });
35
36
  let databaseClosed = false;
37
+ let retentionCleanupTimer;
36
38
  async function closeDatabase() {
37
39
  if (databaseClosed)
38
40
  return;
@@ -47,6 +49,29 @@ export async function startBot(processEnvironment = process.env) {
47
49
  appliedMigrations: migrationResult.applied,
48
50
  verifiedMigrationCount: migrationResult.verified.length,
49
51
  }, "Core database ready");
52
+ const cleanupExpiredCoreRecords = async () => {
53
+ const [sessions, rateLimits] = await Promise.all([
54
+ new InteractionSessionRepository(database).deleteExpired(),
55
+ new RateLimitRepository(database).deleteExpired(),
56
+ ]);
57
+ if (sessions > 0 || rateLimits > 0) {
58
+ bootstrapLogger.info({
59
+ event: "database.retention_cleaned",
60
+ interactionSessions: sessions,
61
+ rateLimitWindows: rateLimits,
62
+ }, "Expired core records removed");
63
+ }
64
+ };
65
+ await cleanupExpiredCoreRecords().catch((error) => {
66
+ bootstrapLogger.warn({ event: "database.retention_cleanup_failed", error: safeError(error) }, "Expired core record cleanup failed");
67
+ });
68
+ retentionCleanupTimer = setInterval(() => void cleanupExpiredCoreRecords().catch((error) => {
69
+ bootstrapLogger.warn({
70
+ event: "database.retention_cleanup_failed",
71
+ error: safeError(error),
72
+ }, "Expired core record cleanup failed");
73
+ }), RETENTION_CLEANUP_INTERVAL_MS);
74
+ retentionCleanupTimer.unref();
50
75
  const moduleSpecifiers = await resolveModulePackageSpecifiers(environment.HELYX_MODULE_PACKAGES, undefined, undefined, environment.HELYX_MODULE_CATALOGUE_PATH);
51
76
  const modulePackages = await loadConfiguredModulePackages(moduleSpecifiers);
52
77
  const moduleMigrationResults = await runConfiguredModuleMigrations(database, modulePackages.map(({ definition, packageRoot }) => ({
@@ -125,6 +150,8 @@ export async function startBot(processEnvironment = process.env) {
125
150
  shuttingDown = true;
126
151
  process.off("SIGINT", onSigint);
127
152
  process.off("SIGTERM", onSigterm);
153
+ if (retentionCleanupTimer)
154
+ clearInterval(retentionCleanupTimer);
128
155
  bootstrapLogger.info({ event: "bot.shutdown", reason }, "Bot shutdown requested");
129
156
  await cloudConnector.stop().catch((error) => {
130
157
  process.exitCode = 1;
@@ -192,6 +219,8 @@ export async function startBot(processEnvironment = process.env) {
192
219
  return { shutdown };
193
220
  }
194
221
  catch (error) {
222
+ if (retentionCleanupTimer)
223
+ clearInterval(retentionCleanupTimer);
195
224
  await closeDatabase().catch((closeError) => {
196
225
  bootstrapLogger.error({ event: "database.close_failed", error: safeError(closeError) }, "Database pool cleanup failed");
197
226
  });
@@ -28,6 +28,7 @@ export function createManagementModule(configurableModules, resources) {
28
28
  return defineModule({
29
29
  manifest: {
30
30
  manifestVersion: "0.1",
31
+ releaseStatus: "live",
31
32
  id: "helyx.management",
32
33
  name: "Helyx management",
33
34
  version: "0.1.0",
@@ -23,6 +23,7 @@ export function createPermissionsModule(configurableModules) {
23
23
  return defineModule({
24
24
  manifest: {
25
25
  manifestVersion: "0.1",
26
+ releaseStatus: "live",
26
27
  id: "helyx.permissions",
27
28
  name: "Permissions",
28
29
  version: "0.1.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@helyx/bot",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Production Discord runtime for the modular Helyx platform.",
5
5
  "keywords": [
6
6
  "helyx",
@@ -48,14 +48,14 @@
48
48
  "start": "node --env-file-if-exists=../../.env dist/index.js"
49
49
  },
50
50
  "dependencies": {
51
- "@helyx/config": "0.2.0",
52
- "@helyx/control-protocol": "0.1.0",
53
- "@helyx/core": "0.1.3",
54
- "@helyx/database": "0.2.0",
55
- "@helyx/framework": "0.1.3",
56
- "@helyx/module-manifest": "0.2.0",
57
- "@helyx/platform": "0.1.3",
58
- "@helyx/sdk": "0.2.3",
51
+ "@helyx/config": "0.2.1",
52
+ "@helyx/control-protocol": "0.1.1",
53
+ "@helyx/core": "0.1.4",
54
+ "@helyx/database": "0.2.1",
55
+ "@helyx/framework": "0.1.4",
56
+ "@helyx/module-manifest": "0.3.0",
57
+ "@helyx/platform": "0.1.5",
58
+ "@helyx/sdk": "0.2.4",
59
59
  "discord.js": "14.27.0",
60
60
  "fastify": "5.10.0",
61
61
  "pino": "10.3.1",