@helyx/bot 1.5.1 → 1.5.3
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 +25 -0
- package/command-root-catalogue.json +452 -0
- package/dist/cached-message-deletion-authority.d.ts +27 -0
- package/dist/cached-message-deletion-authority.js +288 -0
- package/dist/channel-audit-configuration.d.ts +11 -1
- package/dist/channel-audit-configuration.js +28 -6
- package/dist/channel-audit-control.js +42 -2
- package/dist/channel-audit-permissions.js +76 -3
- package/dist/cloud-command-policy-sync.d.ts +26 -0
- package/dist/cloud-command-policy-sync.js +198 -0
- package/dist/cloud-connector.d.ts +6 -1
- package/dist/cloud-connector.js +224 -53
- package/dist/cloud-control-dispatcher.js +18 -0
- package/dist/command-policy-runtime.d.ts +23 -0
- package/dist/command-policy-runtime.js +53 -0
- package/dist/command-root-catalogue.d.ts +5 -0
- package/dist/command-root-catalogue.js +16 -0
- package/dist/control-error-mapping.d.ts +8 -0
- package/dist/control-error-mapping.js +60 -0
- package/dist/control-errors.d.ts +3 -1
- package/dist/control-errors.js +4 -1
- package/dist/control-health-routes.d.ts +10 -0
- package/dist/control-health-routes.js +28 -0
- package/dist/control-server-schemas.d.ts +7 -0
- package/dist/control-server-schemas.js +9 -0
- package/dist/control-server.d.ts +27 -6
- package/dist/control-server.js +103 -122
- package/dist/creator-invite-key.d.ts +6 -0
- package/dist/creator-invite-key.js +16 -0
- package/dist/creator-invite-service.d.ts +3 -1
- package/dist/creator-invite-service.js +8 -7
- package/dist/creator-onboarding-platform.d.ts +1 -0
- package/dist/creator-onboarding-platform.js +5 -2
- package/dist/dashboard-feature-control.d.ts +2 -1
- package/dist/dashboard-feature-control.js +1 -0
- package/dist/dashboard-logging.d.ts +20 -0
- package/dist/dashboard-logging.js +39 -0
- package/dist/dashboard-module-projection.d.ts +1 -1
- package/dist/dashboard-module-projection.js +5 -2
- package/dist/dashboard-preview-import-routes.js +8 -0
- package/dist/database-bootstrap.d.ts +1 -1
- package/dist/database-bootstrap.js +4 -3
- package/dist/discord-channel-audit.js +1 -0
- package/dist/discord-user-identities.d.ts +14 -0
- package/dist/discord-user-identities.js +64 -0
- package/dist/emergency-deletion-dispatcher.d.ts +68 -0
- package/dist/emergency-deletion-dispatcher.js +502 -0
- package/dist/emergency-deletion-rest-transport.d.ts +24 -0
- package/dist/emergency-deletion-rest-transport.js +253 -0
- package/dist/events.d.ts +5 -0
- package/dist/events.js +53 -10
- package/dist/guarded-discord-dispatcher.d.ts +29 -0
- package/dist/guarded-discord-dispatcher.js +204 -0
- package/dist/guild-command-discord-client.d.ts +2 -0
- package/dist/guild-command-discord-client.js +7 -0
- package/dist/guild-command-projection.d.ts +4 -1
- package/dist/guild-command-projection.js +25 -0
- package/dist/guild-command-read-service.d.ts +10 -1
- package/dist/guild-command-read-service.js +37 -0
- package/dist/guild-command-worker.d.ts +19 -4
- package/dist/guild-command-worker.js +356 -26
- package/dist/guild-message-protection-author-roles.d.ts +7 -0
- package/dist/guild-message-protection-author-roles.js +93 -0
- package/dist/guild-message-protection-deletion.d.ts +21 -0
- package/dist/guild-message-protection-deletion.js +138 -0
- package/dist/guild-message-protection-delivery.d.ts +41 -0
- package/dist/guild-message-protection-delivery.js +223 -0
- package/dist/guild-message-update-metadata.d.ts +32 -0
- package/dist/guild-message-update-metadata.js +255 -0
- package/dist/guilds.d.ts +2 -1
- package/dist/guilds.js +11 -1
- package/dist/hosted-server-moderation.d.ts +2 -2
- package/dist/index.js +68 -4
- package/dist/interactions.d.ts +3 -1
- package/dist/interactions.js +77 -12
- package/dist/invite-rollup-maintenance.d.ts +9 -0
- package/dist/invite-rollup-maintenance.js +12 -0
- package/dist/managed-resource-authorization.d.ts +15 -0
- package/dist/managed-resource-authorization.js +37 -0
- package/dist/managed-resource-create-defaults.d.ts +21 -0
- package/dist/managed-resource-create-defaults.js +83 -0
- package/dist/managed-resource-query.d.ts +6 -0
- package/dist/managed-resource-query.js +15 -0
- package/dist/member-role-ids.d.ts +17 -0
- package/dist/member-role-ids.js +66 -0
- package/dist/message-protection-admission.d.ts +60 -0
- package/dist/message-protection-admission.js +279 -0
- package/dist/message-protection-cold-data.d.ts +42 -0
- package/dist/message-protection-cold-data.js +180 -0
- package/dist/message-protection-coordinator.d.ts +73 -0
- package/dist/message-protection-coordinator.js +622 -0
- package/dist/message-protection-settings.d.ts +40 -0
- package/dist/message-protection-settings.js +275 -0
- package/dist/message-protection-summary.d.ts +8 -0
- package/dist/message-protection-summary.js +98 -0
- package/dist/module-availability-control.js +20 -12
- package/dist/module-logging.d.ts +3 -2
- package/dist/module-logging.js +6 -1
- package/dist/modules.js +56 -1
- package/dist/mysql-runtime-persistence.d.ts +1 -0
- package/dist/mysql-runtime-persistence.js +4 -1
- package/dist/nexus-command-recovery-control-routes.d.ts +5 -0
- package/dist/nexus-command-recovery-control-routes.js +48 -0
- package/dist/nexus-command-references.d.ts +6 -0
- package/dist/nexus-command-references.js +33 -0
- package/dist/scheduled-task-runner.js +23 -4
- package/dist/shard-command-startup.d.ts +13 -0
- package/dist/shard-command-startup.js +27 -0
- package/dist/shard-contracts.d.ts +84 -0
- package/dist/shard-contracts.js +33 -0
- package/dist/shard-core-readiness.d.ts +5 -0
- package/dist/shard-core-readiness.js +19 -0
- package/dist/shard-router.d.ts +3 -0
- package/dist/shard-router.js +10 -1
- package/dist/shard-supervisor.d.ts +1 -1
- package/dist/shard-supervisor.js +131 -43
- package/dist/shard-worker.d.ts +1 -0
- package/dist/shard-worker.js +37 -15
- package/documentation/README.md +1 -0
- package/documentation/guides/self-hosting.md +54 -0
- package/package.json +18 -17
package/dist/cloud-connector.js
CHANGED
|
@@ -6,7 +6,10 @@ import { resolve } from "node:path";
|
|
|
6
6
|
import WebSocket from "ws";
|
|
7
7
|
import { z } from "zod";
|
|
8
8
|
import { CloudControlDeniedError, CloudControlDispatcher, } from "./cloud-control-dispatcher.js";
|
|
9
|
-
import { BotControlTransportError, } from "./control-server.js";
|
|
9
|
+
import { BotControlTransportError, safeBotControlFieldError, } from "./control-server.js";
|
|
10
|
+
import { BotControlForbiddenError } from "./control-errors.js";
|
|
11
|
+
import { COMMAND_POLICY_CAPABILITY } from "@helyx/control-protocol/command-policy";
|
|
12
|
+
import { CloudCommandPolicySynchronizer } from "./cloud-command-policy-sync.js";
|
|
10
13
|
const enrolmentFileSchema = z
|
|
11
14
|
.object({
|
|
12
15
|
schemaVersion: z.literal(1),
|
|
@@ -83,6 +86,10 @@ export class CloudConnectorController {
|
|
|
83
86
|
#stopping = false;
|
|
84
87
|
#connectionBlocked = false;
|
|
85
88
|
#loop;
|
|
89
|
+
#policyRuntime;
|
|
90
|
+
#policySession;
|
|
91
|
+
#policySynchronizer;
|
|
92
|
+
#policyAbort = new AbortController();
|
|
86
93
|
#activeSupport;
|
|
87
94
|
#pendingSupport = new Map();
|
|
88
95
|
constructor(input) {
|
|
@@ -101,6 +108,61 @@ export class CloudConnectorController {
|
|
|
101
108
|
? new CloudControlDispatcher(this.input.database, handler, commands)
|
|
102
109
|
: new CloudControlDispatcher({ repository: this.#repository }, handler, commands);
|
|
103
110
|
}
|
|
111
|
+
/** Called only after the runtime has claimed its immutable SQL owner scope. */
|
|
112
|
+
async setCommandPolicyDelivery(runtime) {
|
|
113
|
+
const previous = this.#policySynchronizer;
|
|
114
|
+
this.#policyAbort.abort();
|
|
115
|
+
this.#policySession = undefined;
|
|
116
|
+
this.#policySynchronizer = undefined;
|
|
117
|
+
this.#policyRuntime = runtime;
|
|
118
|
+
this.#policyAbort = new AbortController();
|
|
119
|
+
this.#socket?.close(1012, "Command policy runtime replaced");
|
|
120
|
+
await previous?.stop();
|
|
121
|
+
}
|
|
122
|
+
#identityContext(identity) {
|
|
123
|
+
return {
|
|
124
|
+
runtime: this.#policyRuntime,
|
|
125
|
+
session: this.#policySession ?? null,
|
|
126
|
+
identity,
|
|
127
|
+
signal: this.#policyAbort.signal,
|
|
128
|
+
synchronizer: this.#policySynchronizer,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
async #changeIdentity(context, change) {
|
|
132
|
+
if (!context.runtime)
|
|
133
|
+
return change(this.#repository);
|
|
134
|
+
context.signal.throwIfAborted();
|
|
135
|
+
if (context.runtime.isOwnerCurrent &&
|
|
136
|
+
!(await context.runtime.isOwnerCurrent()))
|
|
137
|
+
throw new Error("command_policy_owner_changed");
|
|
138
|
+
context.synchronizer?.cancel();
|
|
139
|
+
let result;
|
|
140
|
+
try {
|
|
141
|
+
result = await context.runtime.delivery.withIdentityChange({
|
|
142
|
+
owner: context.runtime.owner,
|
|
143
|
+
expectedSession: context.session,
|
|
144
|
+
expectedIdentity: context.identity
|
|
145
|
+
? {
|
|
146
|
+
installationId: context.identity.installationId,
|
|
147
|
+
credentialId: context.identity.credentialId,
|
|
148
|
+
credentialGeneration: context.identity.credentialGeneration,
|
|
149
|
+
}
|
|
150
|
+
: null,
|
|
151
|
+
signal: context.signal,
|
|
152
|
+
}, change);
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
if (context.synchronizer &&
|
|
156
|
+
this.#policySynchronizer === context.synchronizer)
|
|
157
|
+
this.#socket?.close(1012, "Connector identity transition held");
|
|
158
|
+
throw error;
|
|
159
|
+
}
|
|
160
|
+
if (this.#policySession?.sessionId === context.session?.sessionId) {
|
|
161
|
+
this.#policySession = undefined;
|
|
162
|
+
this.#policySynchronizer = undefined;
|
|
163
|
+
}
|
|
164
|
+
return result;
|
|
165
|
+
}
|
|
104
166
|
async start() {
|
|
105
167
|
if (!this.input.environment.HELYX_CLOUD_CONNECT_ENABLED)
|
|
106
168
|
return;
|
|
@@ -170,6 +232,7 @@ export class CloudConnectorController {
|
|
|
170
232
|
if (!this.input.environment.HELYX_CLOUD_CONNECT_ENABLED)
|
|
171
233
|
throw new CloudConnectorUserError("Cloud Connect is disabled in this deployment");
|
|
172
234
|
const existing = await this.#repository.getIdentity();
|
|
235
|
+
const identityContext = this.#identityContext(existing);
|
|
173
236
|
const recoveringIdentity = existing && !existing.revokedAt && this.#state === "recovery_required";
|
|
174
237
|
if (existing && !existing.revokedAt && !recoveringIdentity)
|
|
175
238
|
throw new CloudConnectorUserError("This Helyx deployment is already connected");
|
|
@@ -217,19 +280,20 @@ export class CloudConnectorController {
|
|
|
217
280
|
}
|
|
218
281
|
const exchange = exchangeResponseSchema.parse(payload);
|
|
219
282
|
const encrypted = encryptPrivateKey(keyPair.privateKey, this.#dataKey, exchange.installationId);
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
283
|
+
this.#identity = await this.#changeIdentity(identityContext, async (repository) => {
|
|
284
|
+
if (recoveringIdentity && !(await repository.revoke()))
|
|
285
|
+
throw new CloudConnectorUserError("The previous local Cloud Connect identity could not be reset");
|
|
286
|
+
return repository.createIdentity({
|
|
287
|
+
installationId: exchange.installationId,
|
|
288
|
+
credentialId: exchange.credentialId,
|
|
289
|
+
cloudEndpointId: exchange.cloudEndpointId,
|
|
290
|
+
publicKey: keyPair.publicKey,
|
|
291
|
+
publicKeyFingerprint: keyPair.fingerprint,
|
|
292
|
+
privateKey: encrypted,
|
|
293
|
+
credentialGeneration: exchange.credentialGeneration,
|
|
294
|
+
protocolVersion: CLOUD_PROTOCOL_VERSION,
|
|
295
|
+
enrolledAt: new Date(),
|
|
296
|
+
});
|
|
233
297
|
});
|
|
234
298
|
this.#connectionBlocked = false;
|
|
235
299
|
await this.#repository.appendAudit({
|
|
@@ -247,6 +311,7 @@ export class CloudConnectorController {
|
|
|
247
311
|
async disconnect() {
|
|
248
312
|
if (!this.#identity)
|
|
249
313
|
throw new CloudConnectorUserError("This Helyx deployment is not connected");
|
|
314
|
+
const identityContext = this.#identityContext(this.#identity);
|
|
250
315
|
await this.#performCredentialAction({
|
|
251
316
|
protocolVersion: CLOUD_PROTOCOL_VERSION,
|
|
252
317
|
action: "revoke",
|
|
@@ -256,6 +321,7 @@ export class CloudConnectorController {
|
|
|
256
321
|
nonce: randomBytes(32).toString("base64url"),
|
|
257
322
|
issuedAt: new Date().toISOString(),
|
|
258
323
|
});
|
|
324
|
+
await this.#changeIdentity(identityContext, (repository) => repository.revoke());
|
|
259
325
|
const revokedAt = new Date();
|
|
260
326
|
this.#identity = {
|
|
261
327
|
...this.#identity,
|
|
@@ -264,7 +330,6 @@ export class CloudConnectorController {
|
|
|
264
330
|
this.#connectionBlocked = true;
|
|
265
331
|
this.#state = "revoked";
|
|
266
332
|
this.#socket?.close(1000, "Local owner disconnected Cloud Connect");
|
|
267
|
-
await this.#repository.revoke();
|
|
268
333
|
await this.#repository.appendAudit({
|
|
269
334
|
action: "installation.disconnected",
|
|
270
335
|
source: "discord",
|
|
@@ -273,11 +338,13 @@ export class CloudConnectorController {
|
|
|
273
338
|
}
|
|
274
339
|
async rotate() {
|
|
275
340
|
const identity = this.#identity;
|
|
341
|
+
const identityContext = this.#identityContext(identity ?? null);
|
|
276
342
|
if (!identity || identity.revokedAt)
|
|
277
343
|
throw new CloudConnectorUserError("This Helyx deployment is not connected");
|
|
278
344
|
if (!this.#dataKey)
|
|
279
345
|
throw new CloudConnectorUserError("HELYX_DATA_ENCRYPTION_KEY is not configured");
|
|
280
346
|
const nextKeyPair = generateConnectorKeyPair();
|
|
347
|
+
const dataKey = this.#dataKey;
|
|
281
348
|
const result = rotationResponseSchema.parse(await this.#performCredentialAction({
|
|
282
349
|
protocolVersion: CLOUD_PROTOCOL_VERSION,
|
|
283
350
|
action: "rotate",
|
|
@@ -289,27 +356,27 @@ export class CloudConnectorController {
|
|
|
289
356
|
newPublicKey: nextKeyPair.publicKey,
|
|
290
357
|
newPublicKeyFingerprint: nextKeyPair.fingerprint,
|
|
291
358
|
}));
|
|
292
|
-
this.#socket?.close(1012, "Connector credential rotated");
|
|
293
359
|
let updated;
|
|
294
360
|
try {
|
|
295
|
-
updated = await this.#repository.rotateIdentity({
|
|
361
|
+
updated = await this.#changeIdentity(identityContext, (repository) => repository.rotateIdentity({
|
|
296
362
|
credentialId: result.credentialId,
|
|
297
363
|
publicKey: nextKeyPair.publicKey,
|
|
298
364
|
publicKeyFingerprint: nextKeyPair.fingerprint,
|
|
299
|
-
privateKey: encryptPrivateKey(nextKeyPair.privateKey,
|
|
365
|
+
privateKey: encryptPrivateKey(nextKeyPair.privateKey, dataKey, identity.installationId),
|
|
300
366
|
expectedGeneration: identity.credentialGeneration,
|
|
301
367
|
newGeneration: result.credentialGeneration,
|
|
302
|
-
});
|
|
368
|
+
}));
|
|
303
369
|
}
|
|
304
370
|
catch {
|
|
305
|
-
await this.#recordRotationRecovery(identity);
|
|
371
|
+
await this.#recordRotationRecovery(identity, identityContext);
|
|
306
372
|
throw new CloudConnectorUserError("The cloud credential changed but could not be saved locally. Revoke this installation in the dashboard, then connect it again");
|
|
307
373
|
}
|
|
308
374
|
if (!updated)
|
|
309
|
-
await this.#recordRotationRecovery(identity);
|
|
375
|
+
await this.#recordRotationRecovery(identity, identityContext);
|
|
310
376
|
if (!updated)
|
|
311
377
|
throw new CloudConnectorUserError("The cloud credential changed while the local identity was being updated. Revoke this installation in the dashboard, then connect it again");
|
|
312
378
|
this.#identity = updated;
|
|
379
|
+
this.#socket?.close(1012, "Connector credential rotated");
|
|
313
380
|
this.#connectionBlocked = false;
|
|
314
381
|
await this.#repository.appendAudit({
|
|
315
382
|
action: "credential.rotated",
|
|
@@ -350,6 +417,8 @@ export class CloudConnectorController {
|
|
|
350
417
|
});
|
|
351
418
|
}
|
|
352
419
|
async stop() {
|
|
420
|
+
this.#policyAbort.abort();
|
|
421
|
+
await this.#policySynchronizer?.stop().catch(() => undefined);
|
|
353
422
|
this.#stopping = true;
|
|
354
423
|
this.#state = "stopped";
|
|
355
424
|
this.#socket?.close(1001, "Helyx bot shutting down");
|
|
@@ -414,43 +483,69 @@ export class CloudConnectorController {
|
|
|
414
483
|
const socket = new WebSocket(endpoint);
|
|
415
484
|
this.#socket = socket;
|
|
416
485
|
let incomingGuard;
|
|
486
|
+
let helloSent = false;
|
|
417
487
|
let outgoingSequence = 0;
|
|
418
488
|
let heartbeat;
|
|
419
489
|
let plannedReconnect;
|
|
420
490
|
let activeControlRequests = 0;
|
|
421
491
|
let draining = false;
|
|
492
|
+
let policySynchronizer;
|
|
493
|
+
let policyStopping;
|
|
494
|
+
const policyRuntime = this.#policyRuntime;
|
|
495
|
+
const stopPolicy = () => {
|
|
496
|
+
if (!policyStopping) {
|
|
497
|
+
policyStopping = (policySynchronizer?.stop() ?? Promise.resolve()).catch(() => undefined);
|
|
498
|
+
if (this.#policySynchronizer === policySynchronizer) {
|
|
499
|
+
this.#policySynchronizer = undefined;
|
|
500
|
+
this.#policySession = undefined;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
return policyStopping;
|
|
504
|
+
};
|
|
422
505
|
const privateKey = decryptPrivateKey(identity.privateKey, this.#dataKey, identity.installationId);
|
|
423
506
|
const finished = new Promise((resolveFinished, rejectFinished) => {
|
|
424
507
|
const fail = (error) => {
|
|
425
508
|
clearInterval(heartbeat);
|
|
426
509
|
clearTimeout(plannedReconnect);
|
|
427
|
-
rejectFinished(error instanceof Error ? error : new Error("websocket_failed"));
|
|
510
|
+
void stopPolicy().finally(() => rejectFinished(error instanceof Error ? error : new Error("websocket_failed")));
|
|
428
511
|
};
|
|
429
512
|
socket.once("error", fail);
|
|
430
513
|
socket.once("close", () => {
|
|
431
514
|
clearInterval(heartbeat);
|
|
432
515
|
clearTimeout(plannedReconnect);
|
|
433
|
-
this.#socket
|
|
516
|
+
if (this.#socket === socket)
|
|
517
|
+
this.#socket = undefined;
|
|
434
518
|
this.#activeSupport = undefined;
|
|
435
519
|
this.#rejectPendingSupport("Cloud Connect disconnected");
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
520
|
+
void stopPolicy().finally(() => {
|
|
521
|
+
if (this.#stopping)
|
|
522
|
+
resolveFinished();
|
|
523
|
+
else
|
|
524
|
+
rejectFinished(new Error("connection_closed"));
|
|
525
|
+
});
|
|
440
526
|
});
|
|
441
527
|
socket.on("message", (raw) => {
|
|
442
528
|
void (async () => {
|
|
443
529
|
try {
|
|
530
|
+
if (this.#socket !== socket || this.#stopping)
|
|
531
|
+
return;
|
|
444
532
|
const bytes = rawDataBuffer(raw);
|
|
445
533
|
assertCloudMessageSize(bytes);
|
|
446
534
|
const payload = JSON.parse(bytes.toString("utf8"));
|
|
447
535
|
const challenge = connectorChallengeSchema.safeParse(payload);
|
|
448
536
|
if (challenge.success) {
|
|
449
537
|
await this.#sendHello(socket, identity, privateKey, challenge.data);
|
|
538
|
+
helloSent = true;
|
|
450
539
|
return;
|
|
451
540
|
}
|
|
452
541
|
const accepted = connectorAcceptedSchema.safeParse(payload);
|
|
453
542
|
if (accepted.success) {
|
|
543
|
+
if (!helloSent)
|
|
544
|
+
throw new Error("connector_acceptance_before_hello");
|
|
545
|
+
if (accepted.data.installationId !== identity.installationId)
|
|
546
|
+
throw new Error("connector_acceptance_installation_mismatch");
|
|
547
|
+
if (incomingGuard)
|
|
548
|
+
throw new Error("duplicate_connector_acceptance");
|
|
454
549
|
incomingGuard = new ConnectionSequenceGuard(identity.installationId, accepted.data.connectionEpoch);
|
|
455
550
|
this.#state = "online";
|
|
456
551
|
this.#activeSupport = {
|
|
@@ -467,6 +562,54 @@ export class CloudConnectorController {
|
|
|
467
562
|
},
|
|
468
563
|
};
|
|
469
564
|
this.#lastErrorCode = undefined;
|
|
565
|
+
if (this.input.environment.HELYX_RUNTIME_PROFILE ===
|
|
566
|
+
"self-hosted" &&
|
|
567
|
+
policyRuntime &&
|
|
568
|
+
accepted.data.capabilities.includes(COMMAND_POLICY_CAPABILITY)) {
|
|
569
|
+
const session = {
|
|
570
|
+
installationId: identity.installationId,
|
|
571
|
+
credentialId: identity.credentialId,
|
|
572
|
+
credentialGeneration: identity.credentialGeneration,
|
|
573
|
+
connectionEpoch: accepted.data.connectionEpoch,
|
|
574
|
+
sessionId: randomUUID(),
|
|
575
|
+
};
|
|
576
|
+
policySynchronizer = new CloudCommandPolicySynchronizer({
|
|
577
|
+
applicationId: this.input.environment.DISCORD_CLIENT_ID,
|
|
578
|
+
owner: policyRuntime.owner,
|
|
579
|
+
session,
|
|
580
|
+
delivery: policyRuntime.delivery,
|
|
581
|
+
...(policyRuntime.isOwnerCurrent
|
|
582
|
+
? {
|
|
583
|
+
isOwnerCurrent: () => policyRuntime.isOwnerCurrent?.() ??
|
|
584
|
+
Promise.resolve(false),
|
|
585
|
+
}
|
|
586
|
+
: {}),
|
|
587
|
+
onHeld: () => {
|
|
588
|
+
this.#lastErrorCode = "command_policy_unverified";
|
|
589
|
+
socket.close(1012, "Command policy verification held");
|
|
590
|
+
},
|
|
591
|
+
send: (correlationId, pull) => {
|
|
592
|
+
if (socket.readyState !== WebSocket.OPEN ||
|
|
593
|
+
draining ||
|
|
594
|
+
this.#socket !== socket)
|
|
595
|
+
throw new Error("command_policy_session_closed");
|
|
596
|
+
socket.send(JSON.stringify(connectedMessage({
|
|
597
|
+
type: "command-policy.request",
|
|
598
|
+
installationId: identity.installationId,
|
|
599
|
+
connectionEpoch: session.connectionEpoch,
|
|
600
|
+
sequence: ++outgoingSequence,
|
|
601
|
+
correlationId,
|
|
602
|
+
payload: pull,
|
|
603
|
+
})));
|
|
604
|
+
},
|
|
605
|
+
});
|
|
606
|
+
this.#policySynchronizer = policySynchronizer;
|
|
607
|
+
this.#policySession = session;
|
|
608
|
+
await policySynchronizer.start();
|
|
609
|
+
if (socket.readyState !== WebSocket.OPEN ||
|
|
610
|
+
this.#socket !== socket)
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
470
613
|
await this.#repository.markConnected();
|
|
471
614
|
this.#identity = {
|
|
472
615
|
...identity,
|
|
@@ -487,6 +630,7 @@ export class CloudConnectorController {
|
|
|
487
630
|
if (socket.readyState !== WebSocket.OPEN)
|
|
488
631
|
return;
|
|
489
632
|
draining = true;
|
|
633
|
+
void stopPolicy();
|
|
490
634
|
socket.send(JSON.stringify(connectedMessage({
|
|
491
635
|
type: "connector.draining",
|
|
492
636
|
installationId: identity.installationId,
|
|
@@ -515,7 +659,7 @@ export class CloudConnectorController {
|
|
|
515
659
|
this.#connectionBlocked = true;
|
|
516
660
|
if (rejected.data.code === "revoked" ||
|
|
517
661
|
rejected.data.code === "invalid_credential") {
|
|
518
|
-
await this.#repository.revoke();
|
|
662
|
+
await this.#changeIdentity(this.#identityContext(identity), (repository) => repository.revoke());
|
|
519
663
|
this.#identity = {
|
|
520
664
|
...identity,
|
|
521
665
|
revokedAt: new Date(),
|
|
@@ -530,6 +674,13 @@ export class CloudConnectorController {
|
|
|
530
674
|
return;
|
|
531
675
|
}
|
|
532
676
|
const message = connectedCloudMessageSchema.parse(payload);
|
|
677
|
+
if (message.type === "command-policy.response") {
|
|
678
|
+
if (!incomingGuard || !policySynchronizer || draining)
|
|
679
|
+
throw new Error("command_policy_session_unavailable");
|
|
680
|
+
incomingGuard.accept(message);
|
|
681
|
+
policySynchronizer.accept(message.correlationId, message.payload, message.expiresAt);
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
533
684
|
incomingGuard?.accept(message);
|
|
534
685
|
if (message.type === "support.response") {
|
|
535
686
|
const pending = this.#pendingSupport.get(message.correlationId);
|
|
@@ -579,6 +730,9 @@ export class CloudConnectorController {
|
|
|
579
730
|
throw new Error("challenge_installation_mismatch");
|
|
580
731
|
const discord = await this.input.discord.snapshot();
|
|
581
732
|
const capabilities = connectorCapabilities();
|
|
733
|
+
if (this.input.environment.HELYX_RUNTIME_PROFILE === "self-hosted" &&
|
|
734
|
+
this.#policyRuntime)
|
|
735
|
+
capabilities.push(COMMAND_POLICY_CAPABILITY);
|
|
582
736
|
const hello = connectorHelloSchema.parse({
|
|
583
737
|
protocolVersion: CLOUD_PROTOCOL_VERSION,
|
|
584
738
|
type: "connector.hello",
|
|
@@ -615,18 +769,7 @@ export class CloudConnectorController {
|
|
|
615
769
|
});
|
|
616
770
|
}
|
|
617
771
|
catch (error) {
|
|
618
|
-
|
|
619
|
-
return cloudControlResponsePayloadSchema.parse({
|
|
620
|
-
operation: request.operation,
|
|
621
|
-
ok: false,
|
|
622
|
-
error: {
|
|
623
|
-
code: denied ? "forbidden" : mapControlError(error),
|
|
624
|
-
message: denied
|
|
625
|
-
? "The self-hoster has not allowed this support view"
|
|
626
|
-
: safeControlMessage(error),
|
|
627
|
-
retryable: !denied && mapControlError(error) === "temporarily_unavailable",
|
|
628
|
-
},
|
|
629
|
-
});
|
|
772
|
+
return cloudControlFailureResponse(request.operation, error);
|
|
630
773
|
}
|
|
631
774
|
}
|
|
632
775
|
async #submitHostedBug(input) {
|
|
@@ -727,19 +870,27 @@ export class CloudConnectorController {
|
|
|
727
870
|
throw new CloudConnectorUserError("Helyx Cloud rejected the credential change");
|
|
728
871
|
return payload;
|
|
729
872
|
}
|
|
730
|
-
async #recordRotationRecovery(identity) {
|
|
731
|
-
this.#
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
this.#
|
|
736
|
-
this.#
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
873
|
+
async #recordRotationRecovery(identity, context = this.#identityContext(identity)) {
|
|
874
|
+
const revoked = await this.#changeIdentity(context, (repository) => repository.revoke()).catch(() => false);
|
|
875
|
+
// A delayed failed rotation must not replace a newer in-memory identity.
|
|
876
|
+
if (revoked &&
|
|
877
|
+
!context.signal.aborted &&
|
|
878
|
+
this.#identity?.installationId === identity.installationId &&
|
|
879
|
+
this.#identity.credentialId === identity.credentialId &&
|
|
880
|
+
this.#identity.credentialGeneration === identity.credentialGeneration) {
|
|
881
|
+
this.#connectionBlocked = true;
|
|
882
|
+
this.#state = "revoked";
|
|
883
|
+
this.#identity = { ...identity, revokedAt: new Date() };
|
|
884
|
+
this.#socket?.close(1012, "Connector rotation recovery required");
|
|
885
|
+
}
|
|
886
|
+
await this.#repository
|
|
887
|
+
.appendAudit({
|
|
888
|
+
action: "credential.rotation_recovery_required",
|
|
889
|
+
source: "system",
|
|
890
|
+
outcome: "failed",
|
|
891
|
+
safeReasonCode: "local_identity_update_failed",
|
|
892
|
+
})
|
|
893
|
+
.catch(() => undefined);
|
|
743
894
|
}
|
|
744
895
|
}
|
|
745
896
|
export class CloudConnectorUserError extends Error {
|
|
@@ -752,6 +903,7 @@ function connectorCapabilities() {
|
|
|
752
903
|
return [
|
|
753
904
|
"installation.health.read.v1",
|
|
754
905
|
"guild.state.read.v1",
|
|
906
|
+
"guild.user-identities.read.v1",
|
|
755
907
|
"guild.dashboard.access.read.v1",
|
|
756
908
|
"guild.access-policy.read.v1",
|
|
757
909
|
"guild.access-policy.write.v1",
|
|
@@ -775,6 +927,7 @@ function connectorCapabilities() {
|
|
|
775
927
|
"guild.module.resource.preview.v1",
|
|
776
928
|
"guild.module.resource.import.v1",
|
|
777
929
|
"guild.module.resource.create.v1",
|
|
930
|
+
"guild.module.resource.create-defaults.v1",
|
|
778
931
|
"guild.module.resource.update.v1",
|
|
779
932
|
"guild.module.resource.delete.v1",
|
|
780
933
|
"guild.module.resource.action.execute.v1",
|
|
@@ -834,6 +987,8 @@ function connectedMessage(input) {
|
|
|
834
987
|
});
|
|
835
988
|
}
|
|
836
989
|
export function mapControlError(error) {
|
|
990
|
+
if (error instanceof BotControlForbiddenError)
|
|
991
|
+
return "forbidden";
|
|
837
992
|
if (error instanceof BotControlTransportError) {
|
|
838
993
|
if (error.status === 400)
|
|
839
994
|
return "bad_request";
|
|
@@ -857,6 +1012,22 @@ export function mapControlError(error) {
|
|
|
857
1012
|
return "temporarily_unavailable";
|
|
858
1013
|
return "internal_error";
|
|
859
1014
|
}
|
|
1015
|
+
export function cloudControlFailureResponse(operation, error) {
|
|
1016
|
+
const denied = error instanceof CloudControlDeniedError;
|
|
1017
|
+
const fieldError = denied ? undefined : safeBotControlFieldError(error);
|
|
1018
|
+
return cloudControlResponsePayloadSchema.parse({
|
|
1019
|
+
operation,
|
|
1020
|
+
ok: false,
|
|
1021
|
+
error: {
|
|
1022
|
+
code: denied ? "forbidden" : mapControlError(error),
|
|
1023
|
+
message: denied
|
|
1024
|
+
? "The self-hoster has not allowed this support view"
|
|
1025
|
+
: safeControlMessage(error),
|
|
1026
|
+
retryable: !denied && mapControlError(error) === "temporarily_unavailable",
|
|
1027
|
+
...(fieldError ? { fieldError } : {}),
|
|
1028
|
+
},
|
|
1029
|
+
});
|
|
1030
|
+
}
|
|
860
1031
|
export function safeControlMessage(error) {
|
|
861
1032
|
const code = mapControlError(error);
|
|
862
1033
|
if (code === "not_found")
|
|
@@ -30,6 +30,13 @@ export class CloudControlDispatcher {
|
|
|
30
30
|
result = await this.control.getGuildState(input.guildId);
|
|
31
31
|
break;
|
|
32
32
|
}
|
|
33
|
+
case "guild.user-identities.read.v1": {
|
|
34
|
+
const input = parseOperationInput(request.operation, request.input);
|
|
35
|
+
if (!this.control.resolveDiscordUserIdentities)
|
|
36
|
+
throw new Error("Discord identity lookup is unavailable");
|
|
37
|
+
result = await this.control.resolveDiscordUserIdentities(input);
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
33
40
|
case "guild.dashboard.access.read.v1": {
|
|
34
41
|
const input = parseOperationInput(request.operation, request.input);
|
|
35
42
|
result = {
|
|
@@ -270,6 +277,17 @@ export class CloudControlDispatcher {
|
|
|
270
277
|
});
|
|
271
278
|
break;
|
|
272
279
|
}
|
|
280
|
+
case "guild.module.resource.create-defaults.v1": {
|
|
281
|
+
const input = parseOperationInput(request.operation, request.input);
|
|
282
|
+
if (!this.control.getManagedResourceCreateDefaults)
|
|
283
|
+
throw new Error("Creation defaults are unavailable");
|
|
284
|
+
result = await this.control.getManagedResourceCreateDefaults({
|
|
285
|
+
...input,
|
|
286
|
+
actorUserId: requiredActor(actorUserId),
|
|
287
|
+
correlationId,
|
|
288
|
+
});
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
273
291
|
case "guild.module.resource.update.v1": {
|
|
274
292
|
const input = parseOperationInput(request.operation, request.input);
|
|
275
293
|
result = await this.control.updateManagedResource({
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { CommandPolicyOwnerToken, CommandPolicyRuntimeScope } from "@helyx/database-contracts";
|
|
2
|
+
import type { CommandPolicyDeliveryService } from "@helyx/platform";
|
|
3
|
+
import type { ShardCoordinator, ShardGenerationDescriptor, ShardLeaderLease } from "./shard-coordinator.js";
|
|
4
|
+
export interface CommandPolicyRuntime {
|
|
5
|
+
delivery: CommandPolicyDeliveryService;
|
|
6
|
+
owner: CommandPolicyOwnerToken;
|
|
7
|
+
isOwnerCurrent?(): Promise<boolean>;
|
|
8
|
+
}
|
|
9
|
+
/** Capture once; a conflict must not recapture a newer authority version. */
|
|
10
|
+
export declare function admitStandaloneCommandPolicy(delivery: CommandPolicyDeliveryService): Promise<CommandPolicyRuntime>;
|
|
11
|
+
/** Captured immediately after elected-leader/Core DB readiness, before module work. */
|
|
12
|
+
export declare function captureShardedCommandPolicyAdmission(delivery: CommandPolicyDeliveryService, ownerId: string): Promise<{
|
|
13
|
+
claim(leaderToken: string, isOwnerCurrent: () => Promise<boolean>): Promise<CommandPolicyRuntime>;
|
|
14
|
+
}>;
|
|
15
|
+
export declare function shardCommandPolicyScope(generation: string, deploymentOrder: number | undefined): CommandPolicyRuntimeScope;
|
|
16
|
+
/** Redis ownership is a preflight; the SQL owner tuple remains the commit fence. */
|
|
17
|
+
export declare function commandPolicyLeaderIsCurrent(input: {
|
|
18
|
+
coordinator: Pick<ShardCoordinator, "refreshGeneration" | "getLeaderLease">;
|
|
19
|
+
descriptor: ShardGenerationDescriptor;
|
|
20
|
+
deploymentOrder: number;
|
|
21
|
+
leader: ShardLeaderLease;
|
|
22
|
+
}): Promise<boolean>;
|
|
23
|
+
//# sourceMappingURL=command-policy-runtime.d.ts.map
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/** Capture once; a conflict must not recapture a newer authority version. */
|
|
2
|
+
export async function admitStandaloneCommandPolicy(delivery) {
|
|
3
|
+
const ticket = await delivery.captureStandaloneAdmission();
|
|
4
|
+
return { delivery, owner: await delivery.claimStandalone(ticket) };
|
|
5
|
+
}
|
|
6
|
+
/** Captured immediately after elected-leader/Core DB readiness, before module work. */
|
|
7
|
+
export async function captureShardedCommandPolicyAdmission(delivery, ownerId) {
|
|
8
|
+
const admission = await delivery.captureShardedAdmission({ ownerId });
|
|
9
|
+
let claimed;
|
|
10
|
+
return {
|
|
11
|
+
claim(leaderToken, isOwnerCurrent) {
|
|
12
|
+
claimed ??= (async () => {
|
|
13
|
+
if (!(await isOwnerCurrent()))
|
|
14
|
+
throw new Error("Command policy leader changed before admission");
|
|
15
|
+
const owner = await delivery.claimSharded({
|
|
16
|
+
ownerId,
|
|
17
|
+
leaderToken,
|
|
18
|
+
admission,
|
|
19
|
+
});
|
|
20
|
+
if (!(await isOwnerCurrent()))
|
|
21
|
+
throw new Error("Command policy leader changed after admission");
|
|
22
|
+
return { delivery, owner, isOwnerCurrent };
|
|
23
|
+
})();
|
|
24
|
+
return claimed;
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export function shardCommandPolicyScope(generation, deploymentOrder) {
|
|
29
|
+
if (!generation ||
|
|
30
|
+
!Number.isSafeInteger(deploymentOrder) ||
|
|
31
|
+
deploymentOrder < 1)
|
|
32
|
+
throw new Error("Command policy requires the current shard generation and deployment order");
|
|
33
|
+
return {
|
|
34
|
+
mode: "sharded",
|
|
35
|
+
generation,
|
|
36
|
+
deploymentOrder: String(deploymentOrder),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/** Redis ownership is a preflight; the SQL owner tuple remains the commit fence. */
|
|
40
|
+
export async function commandPolicyLeaderIsCurrent(input) {
|
|
41
|
+
try {
|
|
42
|
+
if (!(await input.coordinator.refreshGeneration(input.descriptor, input.deploymentOrder)))
|
|
43
|
+
return false;
|
|
44
|
+
const actual = await input.coordinator.getLeaderLease();
|
|
45
|
+
return (actual?.generation === input.leader.generation &&
|
|
46
|
+
actual.supervisorId === input.leader.supervisorId &&
|
|
47
|
+
actual.fencingToken === input.leader.fencingToken);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
//# sourceMappingURL=command-policy-runtime.js.map
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type CommandCatalogueSnapshot } from "@helyx/database-contracts";
|
|
2
|
+
export declare function parseBundledCommandCatalogue(contents: string): CommandCatalogueSnapshot;
|
|
3
|
+
/** Metadata only: never import packages omitted from the configured runtime. */
|
|
4
|
+
export declare function loadBundledCommandCatalogue(): Promise<CommandCatalogueSnapshot>;
|
|
5
|
+
//# sourceMappingURL=command-root-catalogue.d.ts.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { normalizeCommandCatalogueSnapshot, } from "@helyx/database-contracts";
|
|
3
|
+
export function parseBundledCommandCatalogue(contents) {
|
|
4
|
+
const value = JSON.parse(contents);
|
|
5
|
+
if (!value ||
|
|
6
|
+
typeof value !== "object" ||
|
|
7
|
+
!("fingerprint" in value) ||
|
|
8
|
+
typeof value.fingerprint !== "string")
|
|
9
|
+
throw new Error("The bundled approved command catalogue must include its fingerprint");
|
|
10
|
+
return normalizeCommandCatalogueSnapshot(value);
|
|
11
|
+
}
|
|
12
|
+
/** Metadata only: never import packages omitted from the configured runtime. */
|
|
13
|
+
export async function loadBundledCommandCatalogue() {
|
|
14
|
+
return parseBundledCommandCatalogue(await readFile(new URL("../command-root-catalogue.json", import.meta.url), "utf8"));
|
|
15
|
+
}
|
|
16
|
+
//# sourceMappingURL=command-root-catalogue.js.map
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type DashboardFieldValidation } from "@helyx/control-protocol/field-validation";
|
|
2
|
+
export declare function mapBotControlError(error: unknown): {
|
|
3
|
+
status: 400 | 403 | 404 | 409;
|
|
4
|
+
message: string;
|
|
5
|
+
fieldError?: DashboardFieldValidation;
|
|
6
|
+
} | undefined;
|
|
7
|
+
export declare function safeBotControlFieldError(error: unknown): DashboardFieldValidation | undefined;
|
|
8
|
+
//# sourceMappingURL=control-error-mapping.d.ts.map
|