@helyx/bot 0.1.3 → 0.2.1

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.
@@ -0,0 +1,671 @@
1
+ import { CLOUD_DEFAULT_REQUEST_TIMEOUT_MS, CLOUD_PROTOCOL_VERSION, ConnectionSequenceGuard, assertCloudMessageSize, cloudControlResponsePayloadSchema, connectedCloudMessageSchema, connectorAcceptedSchema, connectorChallengeSchema, connectorCredentialActionSchema, connectorHelloSchema, connectorRejectedSchema, generateConnectorKeyPair, signConnectorCredentialAction, signConnectorChallenge, } from "@helyx/control-protocol";
2
+ import { CloudConnectorRepository, } from "@helyx/database";
3
+ import { createCipheriv, createDecipheriv, createHash, randomBytes, randomUUID, } from "node:crypto";
4
+ import { readFile, rm } from "node:fs/promises";
5
+ import { resolve } from "node:path";
6
+ import WebSocket from "ws";
7
+ import { z } from "zod";
8
+ import { CloudControlDeniedError, CloudControlDispatcher, } from "./cloud-control-dispatcher.js";
9
+ const enrolmentFileSchema = z
10
+ .object({
11
+ schemaVersion: z.literal(1),
12
+ enrolmentToken: z.string().trim().min(19).max(100),
13
+ })
14
+ .strict();
15
+ const exchangeResponseSchema = z
16
+ .object({
17
+ installationId: z.uuid(),
18
+ credentialId: z.uuid(),
19
+ credentialGeneration: z.number().int().positive(),
20
+ cloudEndpointId: z.string().min(1).max(200),
21
+ })
22
+ .strict();
23
+ const rotationResponseSchema = z
24
+ .object({
25
+ credentialId: z.uuid(),
26
+ credentialGeneration: z.number().int().positive(),
27
+ })
28
+ .strict();
29
+ const supportConsentInputSchema = z
30
+ .object({
31
+ supportInspectionEnabled: z.boolean(),
32
+ supportDataClasses: z
33
+ .array(z.enum([
34
+ "module_inventory",
35
+ "configuration",
36
+ "permissions",
37
+ "logging",
38
+ "health",
39
+ ]))
40
+ .max(5)
41
+ .transform((values) => [...new Set(values)]),
42
+ diagnosticsEnabled: z.boolean(),
43
+ })
44
+ .strict();
45
+ export class CloudConnectorController {
46
+ input;
47
+ #repository;
48
+ #dataKey;
49
+ #dispatcher;
50
+ #identity;
51
+ #socket;
52
+ #state;
53
+ #lastErrorCode;
54
+ #stopping = false;
55
+ #connectionBlocked = false;
56
+ #loop;
57
+ constructor(input) {
58
+ this.input = input;
59
+ this.#repository = new CloudConnectorRepository(input.database);
60
+ this.#state = input.environment.HELYX_CLOUD_CONNECT_ENABLED
61
+ ? "unpaired"
62
+ : "disabled";
63
+ this.#dataKey = input.environment.HELYX_DATA_ENCRYPTION_KEY
64
+ ? Buffer.from(input.environment.HELYX_DATA_ENCRYPTION_KEY, "base64")
65
+ : undefined;
66
+ }
67
+ setControlHandler(handler) {
68
+ this.#dispatcher = new CloudControlDispatcher(this.input.database, handler);
69
+ }
70
+ async start() {
71
+ if (!this.input.environment.HELYX_CLOUD_CONNECT_ENABLED)
72
+ return;
73
+ await this.#repository.cleanupAudit({
74
+ ordinaryRetentionDays: this.input.environment.HELYX_CLOUD_AUDIT_RETENTION_DAYS,
75
+ credentialRetentionDays: this.input.environment.HELYX_CLOUD_CREDENTIAL_AUDIT_RETENTION_DAYS,
76
+ });
77
+ this.#identity = (await this.#repository.getIdentity()) ?? undefined;
78
+ if (this.#identity?.revokedAt) {
79
+ this.#state = "revoked";
80
+ return;
81
+ }
82
+ if (!this.#identity) {
83
+ const bootstrap = await this.#readBootstrap();
84
+ if (bootstrap.conflict) {
85
+ this.#state = "unpaired";
86
+ this.#lastErrorCode = "bootstrap_sources_conflict";
87
+ this.input.logger.error({ event: "cloud.bootstrap_conflict" }, "Cloud Connect enrolment sources conflict");
88
+ return;
89
+ }
90
+ if (bootstrap.token) {
91
+ await this.enrol(bootstrap.token, bootstrap.source ?? "startup");
92
+ if (bootstrap.filePath)
93
+ await rm(bootstrap.filePath, { force: true }).catch(() => undefined);
94
+ }
95
+ }
96
+ if (this.#identity)
97
+ this.#ensureLoop();
98
+ }
99
+ status() {
100
+ return {
101
+ state: this.#state,
102
+ ...(this.#identity
103
+ ? {
104
+ installationId: this.#identity.installationId,
105
+ ...(this.#identity.lastConnectedAt
106
+ ? { lastConnectedAt: this.#identity.lastConnectedAt }
107
+ : {}),
108
+ }
109
+ : {}),
110
+ ...(this.#lastErrorCode ? { lastErrorCode: this.#lastErrorCode } : {}),
111
+ };
112
+ }
113
+ getSupportConsent() {
114
+ return this.#repository.getConsent();
115
+ }
116
+ async updateSupportConsent(input) {
117
+ const accepted = supportConsentInputSchema.parse(input);
118
+ const consent = await this.#repository.getConsent();
119
+ const updated = await this.#repository.updateConsent({
120
+ policyVersion: consent.policyVersion + 1,
121
+ supportInspectionEnabled: accepted.supportInspectionEnabled,
122
+ supportDataClasses: accepted.supportInspectionEnabled
123
+ ? accepted.supportDataClasses
124
+ : [],
125
+ diagnosticsEnabled: accepted.diagnosticsEnabled,
126
+ });
127
+ await this.#repository.appendAudit({
128
+ action: "support.consent.updated",
129
+ source: "discord",
130
+ outcome: "succeeded",
131
+ dataClasses: updated.supportDataClasses,
132
+ });
133
+ return updated;
134
+ }
135
+ async enrol(rawToken, source) {
136
+ if (!this.input.environment.HELYX_CLOUD_CONNECT_ENABLED)
137
+ throw new CloudConnectorUserError("Cloud Connect is disabled in this deployment");
138
+ const existing = await this.#repository.getIdentity();
139
+ if (existing && !existing.revokedAt)
140
+ throw new CloudConnectorUserError("This Helyx deployment is already connected");
141
+ if (!this.#dataKey)
142
+ throw new CloudConnectorUserError("HELYX_DATA_ENCRYPTION_KEY is not configured");
143
+ const token = normalizeEnrolmentToken(rawToken);
144
+ const keyPair = generateConnectorKeyPair();
145
+ const application = await this.input.client.application?.fetch();
146
+ const botUser = this.input.client.user;
147
+ if (!application || !botUser)
148
+ throw new CloudConnectorUserError("Discord must be connected before Cloud Connect enrolment");
149
+ let response;
150
+ try {
151
+ response = await fetch(`${this.input.environment.HELYX_CLOUD_API_URL.replace(/\/$/u, "")}/v1/cloud-connect/enrolments/exchange`, {
152
+ method: "POST",
153
+ headers: {
154
+ Accept: "application/json",
155
+ "Content-Type": "application/json",
156
+ },
157
+ body: JSON.stringify({
158
+ enrolmentToken: token,
159
+ publicKey: keyPair.publicKey,
160
+ publicKeyFingerprint: keyPair.fingerprint,
161
+ protocolVersion: CLOUD_PROTOCOL_VERSION,
162
+ coreVersion: this.input.coreVersion,
163
+ botApplicationId: application.id,
164
+ botUserId: botUser.id,
165
+ displayName: botUser.username,
166
+ }),
167
+ signal: AbortSignal.timeout(CLOUD_DEFAULT_REQUEST_TIMEOUT_MS),
168
+ });
169
+ }
170
+ catch {
171
+ this.#lastErrorCode = "enrolment_unavailable";
172
+ throw new CloudConnectorUserError("Helyx Cloud is temporarily unavailable");
173
+ }
174
+ const payload = await response.json().catch(() => null);
175
+ if (!response.ok) {
176
+ const message = z
177
+ .object({ message: z.string().min(1).max(300) })
178
+ .safeParse(payload);
179
+ this.#lastErrorCode = "enrolment_rejected";
180
+ throw new CloudConnectorUserError(message.success
181
+ ? message.data.message
182
+ : "The Cloud Connect code was rejected");
183
+ }
184
+ const exchange = exchangeResponseSchema.parse(payload);
185
+ const encrypted = encryptPrivateKey(keyPair.privateKey, this.#dataKey, exchange.installationId);
186
+ this.#identity = await this.#repository.createIdentity({
187
+ installationId: exchange.installationId,
188
+ credentialId: exchange.credentialId,
189
+ cloudEndpointId: exchange.cloudEndpointId,
190
+ publicKey: keyPair.publicKey,
191
+ publicKeyFingerprint: keyPair.fingerprint,
192
+ privateKey: encrypted,
193
+ credentialGeneration: exchange.credentialGeneration,
194
+ protocolVersion: CLOUD_PROTOCOL_VERSION,
195
+ enrolledAt: new Date(),
196
+ });
197
+ this.#connectionBlocked = false;
198
+ await this.#repository.appendAudit({
199
+ action: "installation.enrolled",
200
+ source,
201
+ outcome: "succeeded",
202
+ });
203
+ this.#state = "offline";
204
+ this.#lastErrorCode = undefined;
205
+ this.#ensureLoop();
206
+ return this.status();
207
+ }
208
+ async disconnect() {
209
+ if (!this.#identity)
210
+ throw new CloudConnectorUserError("This Helyx deployment is not connected");
211
+ await this.#performCredentialAction({
212
+ protocolVersion: CLOUD_PROTOCOL_VERSION,
213
+ action: "revoke",
214
+ installationId: this.#identity.installationId,
215
+ credentialId: this.#identity.credentialId,
216
+ credentialGeneration: this.#identity.credentialGeneration,
217
+ nonce: randomBytes(32).toString("base64url"),
218
+ issuedAt: new Date().toISOString(),
219
+ });
220
+ const revokedAt = new Date();
221
+ this.#identity = {
222
+ ...this.#identity,
223
+ revokedAt,
224
+ };
225
+ this.#connectionBlocked = true;
226
+ this.#state = "revoked";
227
+ this.#socket?.close(1000, "Local owner disconnected Cloud Connect");
228
+ await this.#repository.revoke();
229
+ await this.#repository.appendAudit({
230
+ action: "installation.disconnected",
231
+ source: "discord",
232
+ outcome: "succeeded",
233
+ });
234
+ }
235
+ async rotate() {
236
+ const identity = this.#identity;
237
+ if (!identity || identity.revokedAt)
238
+ throw new CloudConnectorUserError("This Helyx deployment is not connected");
239
+ if (!this.#dataKey)
240
+ throw new CloudConnectorUserError("HELYX_DATA_ENCRYPTION_KEY is not configured");
241
+ const nextKeyPair = generateConnectorKeyPair();
242
+ const result = rotationResponseSchema.parse(await this.#performCredentialAction({
243
+ protocolVersion: CLOUD_PROTOCOL_VERSION,
244
+ action: "rotate",
245
+ installationId: identity.installationId,
246
+ credentialId: identity.credentialId,
247
+ credentialGeneration: identity.credentialGeneration,
248
+ nonce: randomBytes(32).toString("base64url"),
249
+ issuedAt: new Date().toISOString(),
250
+ newPublicKey: nextKeyPair.publicKey,
251
+ newPublicKeyFingerprint: nextKeyPair.fingerprint,
252
+ }));
253
+ this.#socket?.close(1012, "Connector credential rotated");
254
+ let updated;
255
+ try {
256
+ updated = await this.#repository.rotateIdentity({
257
+ credentialId: result.credentialId,
258
+ publicKey: nextKeyPair.publicKey,
259
+ publicKeyFingerprint: nextKeyPair.fingerprint,
260
+ privateKey: encryptPrivateKey(nextKeyPair.privateKey, this.#dataKey, identity.installationId),
261
+ expectedGeneration: identity.credentialGeneration,
262
+ newGeneration: result.credentialGeneration,
263
+ });
264
+ }
265
+ catch {
266
+ await this.#recordRotationRecovery(identity);
267
+ throw new CloudConnectorUserError("The cloud credential changed but could not be saved locally. Revoke this installation in the dashboard, then connect it again");
268
+ }
269
+ if (!updated)
270
+ await this.#recordRotationRecovery(identity);
271
+ if (!updated)
272
+ throw new CloudConnectorUserError("The cloud credential changed while the local identity was being updated. Revoke this installation in the dashboard, then connect it again");
273
+ this.#identity = updated;
274
+ this.#connectionBlocked = false;
275
+ await this.#repository.appendAudit({
276
+ action: "credential.rotated",
277
+ source: "discord",
278
+ outcome: "succeeded",
279
+ });
280
+ this.#state = "connecting";
281
+ return this.status();
282
+ }
283
+ async stop() {
284
+ this.#stopping = true;
285
+ this.#state = "stopped";
286
+ this.#socket?.close(1001, "Helyx bot shutting down");
287
+ await this.#loop?.catch(() => undefined);
288
+ }
289
+ #ensureLoop() {
290
+ if (this.#loop || this.#stopping || this.#connectionBlocked)
291
+ return;
292
+ this.#loop = this.#runLoop().finally(() => {
293
+ this.#loop = undefined;
294
+ if (!this.#stopping &&
295
+ !this.#connectionBlocked &&
296
+ this.#identity &&
297
+ !this.#identity.revokedAt)
298
+ this.#ensureLoop();
299
+ });
300
+ }
301
+ async #runLoop() {
302
+ let attempt = 0;
303
+ while (!this.#stopping &&
304
+ !this.#connectionBlocked &&
305
+ this.#identity &&
306
+ !this.#identity.revokedAt) {
307
+ this.#state = "connecting";
308
+ try {
309
+ await this.#connectOnce(this.#identity);
310
+ attempt = 0;
311
+ }
312
+ catch (error) {
313
+ if (this.#stopping)
314
+ break;
315
+ this.#state = "offline";
316
+ this.#lastErrorCode = safeConnectorError(error);
317
+ this.input.logger.warn({
318
+ event: "cloud.connection_failed",
319
+ reason: this.#lastErrorCode,
320
+ }, "Cloud Connect is offline; local bot operation continues");
321
+ const delay = Math.min(1_000 * 2 ** attempt, 30_000);
322
+ attempt = Math.min(attempt + 1, 5);
323
+ await wait(delay + Math.floor(Math.random() * Math.max(250, delay / 4)));
324
+ }
325
+ }
326
+ }
327
+ async #connectOnce(identity) {
328
+ if (!this.#dispatcher)
329
+ throw new Error("cloud_dispatcher_unavailable");
330
+ if (!this.#dataKey)
331
+ throw new Error("data_encryption_key_unavailable");
332
+ const endpoint = new URL(this.input.environment.HELYX_CLOUD_ENDPOINT);
333
+ endpoint.searchParams.set("installation_id", identity.installationId);
334
+ const socket = new WebSocket(endpoint);
335
+ this.#socket = socket;
336
+ let incomingGuard;
337
+ let outgoingSequence = 0;
338
+ let heartbeat;
339
+ let plannedReconnect;
340
+ const privateKey = decryptPrivateKey(identity.privateKey, this.#dataKey, identity.installationId);
341
+ const finished = new Promise((resolveFinished, rejectFinished) => {
342
+ const fail = (error) => {
343
+ clearInterval(heartbeat);
344
+ clearTimeout(plannedReconnect);
345
+ rejectFinished(error instanceof Error ? error : new Error("websocket_failed"));
346
+ };
347
+ socket.once("error", fail);
348
+ socket.once("close", () => {
349
+ clearInterval(heartbeat);
350
+ clearTimeout(plannedReconnect);
351
+ this.#socket = undefined;
352
+ if (this.#stopping)
353
+ resolveFinished();
354
+ else
355
+ rejectFinished(new Error("connection_closed"));
356
+ });
357
+ socket.on("message", (raw) => {
358
+ void (async () => {
359
+ try {
360
+ const bytes = rawDataBuffer(raw);
361
+ assertCloudMessageSize(bytes);
362
+ const payload = JSON.parse(bytes.toString("utf8"));
363
+ const challenge = connectorChallengeSchema.safeParse(payload);
364
+ if (challenge.success) {
365
+ await this.#sendHello(socket, identity, privateKey, challenge.data);
366
+ return;
367
+ }
368
+ const accepted = connectorAcceptedSchema.safeParse(payload);
369
+ if (accepted.success) {
370
+ incomingGuard = new ConnectionSequenceGuard(identity.installationId, accepted.data.connectionEpoch);
371
+ this.#state = "online";
372
+ this.#lastErrorCode = undefined;
373
+ await this.#repository.markConnected();
374
+ this.#identity = {
375
+ ...identity,
376
+ lastConnectedAt: new Date(),
377
+ };
378
+ heartbeat = setInterval(() => {
379
+ if (socket.readyState !== WebSocket.OPEN)
380
+ return;
381
+ socket.send(JSON.stringify(connectedMessage({
382
+ type: "connector.heartbeat",
383
+ installationId: identity.installationId,
384
+ connectionEpoch: accepted.data.connectionEpoch,
385
+ sequence: ++outgoingSequence,
386
+ payload: { state: "online" },
387
+ })));
388
+ }, accepted.data.heartbeatIntervalSeconds * 1_000);
389
+ plannedReconnect = setTimeout(() => socket.close(1012, "Planned Cloud Connect session rotation"), accepted.data.reconnectBeforeSeconds * 1_000);
390
+ return;
391
+ }
392
+ const rejected = connectorRejectedSchema.safeParse(payload);
393
+ if (rejected.success) {
394
+ this.#lastErrorCode = rejected.data.code;
395
+ if (!rejected.data.retryable) {
396
+ this.#connectionBlocked = true;
397
+ if (rejected.data.code === "revoked" ||
398
+ rejected.data.code === "invalid_credential") {
399
+ await this.#repository.revoke();
400
+ this.#identity = {
401
+ ...identity,
402
+ revokedAt: new Date(),
403
+ };
404
+ this.#state = "revoked";
405
+ }
406
+ else {
407
+ this.#state = "offline";
408
+ }
409
+ }
410
+ socket.close(1008, rejected.data.code);
411
+ return;
412
+ }
413
+ const message = connectedCloudMessageSchema.parse(payload);
414
+ incomingGuard?.accept(message);
415
+ if (message.type !== "control.request")
416
+ return;
417
+ const response = await this.#dispatch(message.payload, message.correlationId);
418
+ if (socket.readyState === WebSocket.OPEN)
419
+ socket.send(JSON.stringify(connectedMessage({
420
+ type: "control.response",
421
+ installationId: identity.installationId,
422
+ connectionEpoch: message.connectionEpoch,
423
+ sequence: ++outgoingSequence,
424
+ correlationId: message.correlationId,
425
+ payload: response,
426
+ })));
427
+ }
428
+ catch (error) {
429
+ this.input.logger.warn({
430
+ event: "cloud.message_rejected",
431
+ reason: safeConnectorError(error),
432
+ }, "Cloud Connect message was rejected");
433
+ socket.close(1008, "Protocol message rejected");
434
+ }
435
+ })();
436
+ });
437
+ });
438
+ return finished;
439
+ }
440
+ async #sendHello(socket, identity, privateKey, challenge) {
441
+ if (challenge.installationId !== identity.installationId)
442
+ throw new Error("challenge_installation_mismatch");
443
+ const application = await this.input.client.application?.fetch();
444
+ const botUser = this.input.client.user;
445
+ if (!application || !botUser)
446
+ throw new Error("discord_not_ready");
447
+ const guilds = this.input.client.guilds.cache.map((guild) => ({
448
+ guildId: guild.id,
449
+ name: guild.name,
450
+ ownerUserId: guild.ownerId || null,
451
+ }));
452
+ const capabilities = connectorCapabilities();
453
+ const hello = connectorHelloSchema.parse({
454
+ protocolVersion: CLOUD_PROTOCOL_VERSION,
455
+ type: "connector.hello",
456
+ messageId: randomUUID(),
457
+ installationId: identity.installationId,
458
+ credentialId: identity.credentialId,
459
+ credentialGeneration: identity.credentialGeneration,
460
+ challengeId: challenge.challengeId,
461
+ signature: signConnectorChallenge(privateKey, challenge),
462
+ coreVersion: this.input.coreVersion,
463
+ botApplicationId: application.id,
464
+ botUserId: botUser.id,
465
+ capabilities,
466
+ modules: this.input.modules.map(({ specifier, definition }) => ({
467
+ id: definition.manifest.id,
468
+ package: specifier,
469
+ version: definition.manifest.version,
470
+ manifestSchemaVersion: 1,
471
+ configurationSchemaVersion: definition.configuration?.schemaVersion ?? 1,
472
+ })),
473
+ guilds,
474
+ });
475
+ socket.send(JSON.stringify(hello));
476
+ }
477
+ async #dispatch(request, correlationId) {
478
+ try {
479
+ const result = await this.#dispatcher.execute(request, correlationId);
480
+ return cloudControlResponsePayloadSchema.parse({
481
+ operation: request.operation,
482
+ ok: true,
483
+ result,
484
+ });
485
+ }
486
+ catch (error) {
487
+ const denied = error instanceof CloudControlDeniedError;
488
+ return cloudControlResponsePayloadSchema.parse({
489
+ operation: request.operation,
490
+ ok: false,
491
+ error: {
492
+ code: denied ? "forbidden" : mapControlError(error),
493
+ message: denied
494
+ ? "The self-hoster has not allowed this support view"
495
+ : safeControlMessage(error),
496
+ retryable: !denied && mapControlError(error) === "temporarily_unavailable",
497
+ },
498
+ });
499
+ }
500
+ }
501
+ async #readBootstrap() {
502
+ const environmentToken = this.input.environment.HELYX_CLOUD_ENROLMENT_TOKEN;
503
+ const filePath = resolve(process.cwd(), this.input.environment.HELYX_CLOUD_ENROLMENT_FILE);
504
+ let fileToken;
505
+ try {
506
+ const file = enrolmentFileSchema.parse(JSON.parse(await readFile(filePath, "utf8")));
507
+ fileToken = file.enrolmentToken;
508
+ }
509
+ catch (error) {
510
+ if (!isMissingFile(error)) {
511
+ this.input.logger.warn({ event: "cloud.enrolment_file_invalid" }, "Cloud Connect enrolment file was invalid");
512
+ }
513
+ }
514
+ if (environmentToken && fileToken)
515
+ return { conflict: true };
516
+ if (environmentToken)
517
+ return { token: environmentToken, source: "startup", conflict: false };
518
+ if (fileToken)
519
+ return { token: fileToken, source: "file", filePath, conflict: false };
520
+ return { conflict: false };
521
+ }
522
+ async #performCredentialAction(unsigned) {
523
+ const identity = this.#identity;
524
+ if (!identity || !this.#dataKey)
525
+ throw new CloudConnectorUserError("This Helyx deployment has no active Cloud Connect identity");
526
+ const privateKey = decryptPrivateKey(identity.privateKey, this.#dataKey, identity.installationId);
527
+ const action = connectorCredentialActionSchema.parse({
528
+ ...unsigned,
529
+ signature: signConnectorCredentialAction(privateKey, unsigned),
530
+ });
531
+ let response;
532
+ try {
533
+ response = await fetch(`${this.input.environment.HELYX_CLOUD_API_URL.replace(/\/$/u, "")}/v1/cloud-connect/credentials/action`, {
534
+ method: "POST",
535
+ headers: {
536
+ Accept: "application/json",
537
+ "Content-Type": "application/json",
538
+ },
539
+ body: JSON.stringify(action),
540
+ signal: AbortSignal.timeout(CLOUD_DEFAULT_REQUEST_TIMEOUT_MS),
541
+ });
542
+ }
543
+ catch {
544
+ throw new CloudConnectorUserError("Helyx Cloud is unavailable, so the credential was not changed");
545
+ }
546
+ const payload = await response.json().catch(() => null);
547
+ if (!response.ok)
548
+ throw new CloudConnectorUserError("Helyx Cloud rejected the credential change");
549
+ return payload;
550
+ }
551
+ async #recordRotationRecovery(identity) {
552
+ this.#connectionBlocked = true;
553
+ this.#state = "revoked";
554
+ this.#identity = { ...identity, revokedAt: new Date() };
555
+ await Promise.allSettled([
556
+ this.#repository.revoke(),
557
+ this.#repository.appendAudit({
558
+ action: "credential.rotation_recovery_required",
559
+ source: "system",
560
+ outcome: "failed",
561
+ safeReasonCode: "local_identity_update_failed",
562
+ }),
563
+ ]);
564
+ }
565
+ }
566
+ export class CloudConnectorUserError extends Error {
567
+ constructor(message) {
568
+ super(message);
569
+ this.name = "CloudConnectorUserError";
570
+ }
571
+ }
572
+ function connectorCapabilities() {
573
+ return [
574
+ "installation.health.read.v1",
575
+ "guild.state.read.v1",
576
+ "guild.module.status.write.v1",
577
+ "guild.module.configuration.write.v1",
578
+ "guild.module.permissions.write.v1",
579
+ "guild.module.logging.write.v1",
580
+ "guild.module.action.execute.v1",
581
+ "support.guild.snapshot.read.v1",
582
+ ];
583
+ }
584
+ function normalizeEnrolmentToken(value) {
585
+ const normalized = value.trim().toUpperCase();
586
+ if (!/^[A-Z2-9]{4}(?:-[A-Z2-9]{4}){4}$/u.test(normalized))
587
+ throw new CloudConnectorUserError("Enter the complete Cloud Connect code shown in the dashboard");
588
+ return normalized;
589
+ }
590
+ function encryptPrivateKey(privateKey, dataKey, installationId) {
591
+ const iv = randomBytes(12);
592
+ const cipher = createCipheriv("aes-256-gcm", dataKey, iv);
593
+ cipher.setAAD(Buffer.from(`helyx-cloud-connector:${installationId}`, "utf8"));
594
+ const ciphertext = Buffer.concat([
595
+ cipher.update(privateKey, "utf8"),
596
+ cipher.final(),
597
+ ]);
598
+ return { ciphertext, iv, tag: cipher.getAuthTag() };
599
+ }
600
+ 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");
608
+ }
609
+ function connectedMessage(input) {
610
+ const now = new Date();
611
+ return connectedCloudMessageSchema.parse({
612
+ protocolVersion: CLOUD_PROTOCOL_VERSION,
613
+ type: input.type,
614
+ messageId: randomUUID(),
615
+ correlationId: input.correlationId ?? randomUUID(),
616
+ installationId: input.installationId,
617
+ connectionEpoch: input.connectionEpoch,
618
+ sequence: input.sequence,
619
+ sentAt: now.toISOString(),
620
+ expiresAt: new Date(now.getTime() + CLOUD_DEFAULT_REQUEST_TIMEOUT_MS).toISOString(),
621
+ payload: input.payload,
622
+ });
623
+ }
624
+ function mapControlError(error) {
625
+ const name = error instanceof Error ? error.name : "";
626
+ if (name.includes("NotFound"))
627
+ return "not_found";
628
+ if (name.includes("Concurrency") || name.includes("Conflict"))
629
+ return "conflict";
630
+ if (name.includes("Validation"))
631
+ return "bad_request";
632
+ if (name.includes("Unavailable"))
633
+ return "temporarily_unavailable";
634
+ return "internal_error";
635
+ }
636
+ function safeControlMessage(error) {
637
+ const code = mapControlError(error);
638
+ if (code === "not_found")
639
+ return "The requested Helyx resource was not found";
640
+ if (code === "conflict")
641
+ return "The setting changed elsewhere. Refresh and try again";
642
+ if (code === "bad_request")
643
+ return error instanceof Error
644
+ ? error.message.slice(0, 300)
645
+ : "The requested change was not valid";
646
+ if (code === "temporarily_unavailable")
647
+ return "The self-hosted bot is temporarily unavailable";
648
+ return "The self-hosted bot could not complete the request";
649
+ }
650
+ function safeConnectorError(error) {
651
+ if (!(error instanceof Error))
652
+ return "unknown_error";
653
+ return createHash("sha256")
654
+ .update(`${error.name}:${error.message}`, "utf8")
655
+ .digest("hex")
656
+ .slice(0, 16);
657
+ }
658
+ function isMissingFile(error) {
659
+ return (typeof error === "object" &&
660
+ error !== null &&
661
+ error.code === "ENOENT");
662
+ }
663
+ function wait(milliseconds) {
664
+ return new Promise((resolveWait) => setTimeout(resolveWait, milliseconds));
665
+ }
666
+ function rawDataBuffer(raw) {
667
+ if (Array.isArray(raw))
668
+ return Buffer.concat(raw);
669
+ return Buffer.isBuffer(raw) ? raw : Buffer.from(raw);
670
+ }
671
+ //# sourceMappingURL=cloud-connector.js.map
@@ -0,0 +1,16 @@
1
+ import { type CloudControlRequestPayload } from "@helyx/control-protocol";
2
+ import { type DatabasePool } from "@helyx/database";
3
+ import type { BotControlHandler, BotControlState } from "./control-server.js";
4
+ export declare class CloudControlDispatcher {
5
+ #private;
6
+ private readonly database;
7
+ private readonly control;
8
+ constructor(database: DatabasePool, control: BotControlHandler);
9
+ execute(request: CloudControlRequestPayload, correlationId: string): Promise<unknown>;
10
+ }
11
+ export declare class CloudControlDeniedError extends Error {
12
+ readonly safeReasonCode: string;
13
+ constructor(safeReasonCode: string);
14
+ }
15
+ export declare function buildSupportSnapshot(state: BotControlState, dataClasses: string[], guildId: string): unknown;
16
+ //# sourceMappingURL=cloud-control-dispatcher.d.ts.map