@helyx/bot 0.2.1 → 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 +18 -0
- package/README.md +1 -1
- package/dist/cloud-connector.d.ts +1 -1
- package/dist/cloud-connector.js +36 -9
- package/dist/cloud-management-module.d.ts +6 -0
- package/dist/cloud-management-module.js +69 -14
- package/dist/index.js +30 -1
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
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
|
+
|
|
3
21
|
## 0.2.1
|
|
4
22
|
|
|
5
23
|
### Patch 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;
|
package/dist/cloud-connector.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
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 (
|
|
51
|
+
if (ownerAuthorizer.isOwner(context.userId))
|
|
19
52
|
return true;
|
|
20
53
|
await context.reply({
|
|
21
54
|
ephemeral: true,
|
|
@@ -411,21 +444,37 @@ export function createCloudManagementModule(input) {
|
|
|
411
444
|
},
|
|
412
445
|
],
|
|
413
446
|
},
|
|
414
|
-
|
|
415
|
-
|
|
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
|
+
},
|
|
416
476
|
});
|
|
417
477
|
}
|
|
418
|
-
async function isDeploymentOwner(client, userId, configuredOwnerUserIds) {
|
|
419
|
-
if (configuredOwnerUserIds.includes(userId))
|
|
420
|
-
return true;
|
|
421
|
-
const application = await client.application?.fetch();
|
|
422
|
-
const owner = application?.owner;
|
|
423
|
-
if (!owner)
|
|
424
|
-
return false;
|
|
425
|
-
if ("members" in owner)
|
|
426
|
-
return owner.members.has(userId);
|
|
427
|
-
return owner.id === userId;
|
|
428
|
-
}
|
|
429
478
|
function statusResponse(status) {
|
|
430
479
|
const labels = {
|
|
431
480
|
disabled: "Disabled by deployment configuration",
|
|
@@ -433,6 +482,7 @@ function statusResponse(status) {
|
|
|
433
482
|
connecting: "Connecting securely",
|
|
434
483
|
online: "Connected",
|
|
435
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",
|
|
436
486
|
revoked: "Disconnected and revoked",
|
|
437
487
|
stopped: "Stopped",
|
|
438
488
|
};
|
|
@@ -488,4 +538,9 @@ function restrictedPolicy() {
|
|
|
488
538
|
deniedUserIds: [],
|
|
489
539
|
};
|
|
490
540
|
}
|
|
541
|
+
function safeError(error) {
|
|
542
|
+
return error instanceof Error
|
|
543
|
+
? { name: error.name, message: error.message }
|
|
544
|
+
: { name: "Error", message: String(error) };
|
|
545
|
+
}
|
|
491
546
|
//# sourceMappingURL=cloud-management-module.js.map
|
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
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@helyx/bot",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "Production Discord runtime for the modular Helyx platform.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"helyx",
|
|
@@ -51,10 +51,10 @@
|
|
|
51
51
|
"@helyx/config": "0.2.1",
|
|
52
52
|
"@helyx/control-protocol": "0.1.1",
|
|
53
53
|
"@helyx/core": "0.1.4",
|
|
54
|
-
"@helyx/database": "0.2.
|
|
54
|
+
"@helyx/database": "0.2.1",
|
|
55
55
|
"@helyx/framework": "0.1.4",
|
|
56
56
|
"@helyx/module-manifest": "0.3.0",
|
|
57
|
-
"@helyx/platform": "0.1.
|
|
57
|
+
"@helyx/platform": "0.1.5",
|
|
58
58
|
"@helyx/sdk": "0.2.4",
|
|
59
59
|
"discord.js": "14.27.0",
|
|
60
60
|
"fastify": "5.10.0",
|