@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.
Files changed (121) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/command-root-catalogue.json +452 -0
  3. package/dist/cached-message-deletion-authority.d.ts +27 -0
  4. package/dist/cached-message-deletion-authority.js +288 -0
  5. package/dist/channel-audit-configuration.d.ts +11 -1
  6. package/dist/channel-audit-configuration.js +28 -6
  7. package/dist/channel-audit-control.js +42 -2
  8. package/dist/channel-audit-permissions.js +76 -3
  9. package/dist/cloud-command-policy-sync.d.ts +26 -0
  10. package/dist/cloud-command-policy-sync.js +198 -0
  11. package/dist/cloud-connector.d.ts +6 -1
  12. package/dist/cloud-connector.js +224 -53
  13. package/dist/cloud-control-dispatcher.js +18 -0
  14. package/dist/command-policy-runtime.d.ts +23 -0
  15. package/dist/command-policy-runtime.js +53 -0
  16. package/dist/command-root-catalogue.d.ts +5 -0
  17. package/dist/command-root-catalogue.js +16 -0
  18. package/dist/control-error-mapping.d.ts +8 -0
  19. package/dist/control-error-mapping.js +60 -0
  20. package/dist/control-errors.d.ts +3 -1
  21. package/dist/control-errors.js +4 -1
  22. package/dist/control-health-routes.d.ts +10 -0
  23. package/dist/control-health-routes.js +28 -0
  24. package/dist/control-server-schemas.d.ts +7 -0
  25. package/dist/control-server-schemas.js +9 -0
  26. package/dist/control-server.d.ts +27 -6
  27. package/dist/control-server.js +103 -122
  28. package/dist/creator-invite-key.d.ts +6 -0
  29. package/dist/creator-invite-key.js +16 -0
  30. package/dist/creator-invite-service.d.ts +3 -1
  31. package/dist/creator-invite-service.js +8 -7
  32. package/dist/creator-onboarding-platform.d.ts +1 -0
  33. package/dist/creator-onboarding-platform.js +5 -2
  34. package/dist/dashboard-feature-control.d.ts +2 -1
  35. package/dist/dashboard-feature-control.js +1 -0
  36. package/dist/dashboard-logging.d.ts +20 -0
  37. package/dist/dashboard-logging.js +39 -0
  38. package/dist/dashboard-module-projection.d.ts +1 -1
  39. package/dist/dashboard-module-projection.js +5 -2
  40. package/dist/dashboard-preview-import-routes.js +8 -0
  41. package/dist/database-bootstrap.d.ts +1 -1
  42. package/dist/database-bootstrap.js +4 -3
  43. package/dist/discord-channel-audit.js +1 -0
  44. package/dist/discord-user-identities.d.ts +14 -0
  45. package/dist/discord-user-identities.js +64 -0
  46. package/dist/emergency-deletion-dispatcher.d.ts +68 -0
  47. package/dist/emergency-deletion-dispatcher.js +502 -0
  48. package/dist/emergency-deletion-rest-transport.d.ts +24 -0
  49. package/dist/emergency-deletion-rest-transport.js +253 -0
  50. package/dist/events.d.ts +5 -0
  51. package/dist/events.js +53 -10
  52. package/dist/guarded-discord-dispatcher.d.ts +29 -0
  53. package/dist/guarded-discord-dispatcher.js +204 -0
  54. package/dist/guild-command-discord-client.d.ts +2 -0
  55. package/dist/guild-command-discord-client.js +7 -0
  56. package/dist/guild-command-projection.d.ts +4 -1
  57. package/dist/guild-command-projection.js +25 -0
  58. package/dist/guild-command-read-service.d.ts +10 -1
  59. package/dist/guild-command-read-service.js +37 -0
  60. package/dist/guild-command-worker.d.ts +19 -4
  61. package/dist/guild-command-worker.js +356 -26
  62. package/dist/guild-message-protection-author-roles.d.ts +7 -0
  63. package/dist/guild-message-protection-author-roles.js +93 -0
  64. package/dist/guild-message-protection-deletion.d.ts +21 -0
  65. package/dist/guild-message-protection-deletion.js +138 -0
  66. package/dist/guild-message-protection-delivery.d.ts +41 -0
  67. package/dist/guild-message-protection-delivery.js +223 -0
  68. package/dist/guild-message-update-metadata.d.ts +32 -0
  69. package/dist/guild-message-update-metadata.js +255 -0
  70. package/dist/guilds.d.ts +2 -1
  71. package/dist/guilds.js +11 -1
  72. package/dist/hosted-server-moderation.d.ts +2 -2
  73. package/dist/index.js +68 -4
  74. package/dist/interactions.d.ts +3 -1
  75. package/dist/interactions.js +77 -12
  76. package/dist/invite-rollup-maintenance.d.ts +9 -0
  77. package/dist/invite-rollup-maintenance.js +12 -0
  78. package/dist/managed-resource-authorization.d.ts +15 -0
  79. package/dist/managed-resource-authorization.js +37 -0
  80. package/dist/managed-resource-create-defaults.d.ts +21 -0
  81. package/dist/managed-resource-create-defaults.js +83 -0
  82. package/dist/managed-resource-query.d.ts +6 -0
  83. package/dist/managed-resource-query.js +15 -0
  84. package/dist/member-role-ids.d.ts +17 -0
  85. package/dist/member-role-ids.js +66 -0
  86. package/dist/message-protection-admission.d.ts +60 -0
  87. package/dist/message-protection-admission.js +279 -0
  88. package/dist/message-protection-cold-data.d.ts +42 -0
  89. package/dist/message-protection-cold-data.js +180 -0
  90. package/dist/message-protection-coordinator.d.ts +73 -0
  91. package/dist/message-protection-coordinator.js +622 -0
  92. package/dist/message-protection-settings.d.ts +40 -0
  93. package/dist/message-protection-settings.js +275 -0
  94. package/dist/message-protection-summary.d.ts +8 -0
  95. package/dist/message-protection-summary.js +98 -0
  96. package/dist/module-availability-control.js +20 -12
  97. package/dist/module-logging.d.ts +3 -2
  98. package/dist/module-logging.js +6 -1
  99. package/dist/modules.js +56 -1
  100. package/dist/mysql-runtime-persistence.d.ts +1 -0
  101. package/dist/mysql-runtime-persistence.js +4 -1
  102. package/dist/nexus-command-recovery-control-routes.d.ts +5 -0
  103. package/dist/nexus-command-recovery-control-routes.js +48 -0
  104. package/dist/nexus-command-references.d.ts +6 -0
  105. package/dist/nexus-command-references.js +33 -0
  106. package/dist/scheduled-task-runner.js +23 -4
  107. package/dist/shard-command-startup.d.ts +13 -0
  108. package/dist/shard-command-startup.js +27 -0
  109. package/dist/shard-contracts.d.ts +84 -0
  110. package/dist/shard-contracts.js +33 -0
  111. package/dist/shard-core-readiness.d.ts +5 -0
  112. package/dist/shard-core-readiness.js +19 -0
  113. package/dist/shard-router.d.ts +3 -0
  114. package/dist/shard-router.js +10 -1
  115. package/dist/shard-supervisor.d.ts +1 -1
  116. package/dist/shard-supervisor.js +131 -43
  117. package/dist/shard-worker.d.ts +1 -0
  118. package/dist/shard-worker.js +37 -15
  119. package/documentation/README.md +1 -0
  120. package/documentation/guides/self-hosting.md +54 -0
  121. package/package.json +18 -17
@@ -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
- if (recoveringIdentity && !(await this.#repository.revoke())) {
221
- throw new CloudConnectorUserError("The previous local Cloud Connect identity could not be reset");
222
- }
223
- this.#identity = await this.#repository.createIdentity({
224
- installationId: exchange.installationId,
225
- credentialId: exchange.credentialId,
226
- cloudEndpointId: exchange.cloudEndpointId,
227
- publicKey: keyPair.publicKey,
228
- publicKeyFingerprint: keyPair.fingerprint,
229
- privateKey: encrypted,
230
- credentialGeneration: exchange.credentialGeneration,
231
- protocolVersion: CLOUD_PROTOCOL_VERSION,
232
- enrolledAt: new Date(),
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, this.#dataKey, identity.installationId),
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 = undefined;
516
+ if (this.#socket === socket)
517
+ this.#socket = undefined;
434
518
  this.#activeSupport = undefined;
435
519
  this.#rejectPendingSupport("Cloud Connect disconnected");
436
- if (this.#stopping)
437
- resolveFinished();
438
- else
439
- rejectFinished(new Error("connection_closed"));
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
- const denied = error instanceof CloudControlDeniedError;
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.#connectionBlocked = true;
732
- this.#state = "revoked";
733
- this.#identity = { ...identity, revokedAt: new Date() };
734
- await Promise.allSettled([
735
- this.#repository.revoke(),
736
- this.#repository.appendAudit({
737
- action: "credential.rotation_recovery_required",
738
- source: "system",
739
- outcome: "failed",
740
- safeReasonCode: "local_identity_update_failed",
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