@helyx/bot 0.4.9 → 0.4.13

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.
@@ -1,6 +1,7 @@
1
1
  import { safeError, ServiceRegistry } from "@helyx/core";
2
- import { checkDatabaseConnection, createDatabasePool, InteractionSessionRepository, RateLimitRepository, runConfiguredModuleMigrations, runCoreMigrations, RuntimeStateRepository, } from "@helyx/database";
2
+ import { checkDatabaseConnection, createDatabasePool, AuditRepository, InteractionSessionRepository, RateLimitRepository, runConfiguredModuleMigrations, runCoreMigrations, RuntimeStateRepository, } from "@helyx/database";
3
3
  import { createPlatformServices, PostgresPrivacyProviderExecutor, } from "@helyx/platform";
4
+ import { HELYX_SERVICE_NAMES } from "@helyx/sdk";
4
5
  import { Client, ShardingManager } from "discord.js";
5
6
  import { createHash, randomUUID } from "node:crypto";
6
7
  import { createRequire } from "node:module";
@@ -15,22 +16,38 @@ import { buildApplicationCommandDefinitions } from "./interactions.js";
15
16
  import { resolveModulePackageSpecifiers } from "./module-configuration.js";
16
17
  import { deriveSecureRandomSelectionKey, loadConfiguredModulePackages, platformServiceOptions, secureRandomSelectionWorkerKeyName, } from "./modules.js";
17
18
  import { createPermissionsModule } from "./permissions-module.js";
18
- import { SHARD_CONTROL_MAX_BYTES, SHARD_HEALTH_STALE_MS, assertShardMessageSize, controlOperationGuildId, executeShardControlOperation, gatewayBotInformationSchema, guildIdToShardId, shardCloudRequestSchema, shardHealthSnapshotSchema, workerMessageSchema, } from "./shard-contracts.js";
19
+ import { SHARD_CONTROL_MAX_BYTES, SHARD_CONTROL_TIMEOUT_MS, SHARD_HEALTH_STALE_MS, assertShardMessageSize, controlOperationGuildId, executeShardControlOperation, gatewayBotInformationSchema, guildIdToShardId, shardCloudRequestSchema, shardHealthSnapshotSchema, workerMessageSchema, } from "./shard-contracts.js";
19
20
  import { RedisShardCoordinator, } from "./shard-coordinator.js";
20
21
  import { ShardControlRouter } from "./shard-router.js";
21
22
  import { createDeveloperOperationsModule } from "./developer-operations-module.js";
22
23
  import { SupervisorCloudDiscordDirectory } from "./supervisor-cloud-directory.js";
23
24
  import { createSupportModule } from "./support-module.js";
24
25
  import { createScheduledTaskRunner, schedulerOperationalHealth, } from "./scheduled-task-runner.js";
26
+ import { numericWriteBehindRegistrations } from "./numeric-write-behind.js";
27
+ import { executeRedisNumericOperation, RedisNumericWriteBehindService, } from "./numeric-write-behind-redis.js";
28
+ import { encodeNumericIpcValue } from "./numeric-write-behind-proxy.js";
25
29
  const LEASE_RENEW_INTERVAL_MS = 10_000;
26
30
  const STARTUP_LEASE_RENEW_INTERVAL_MS = 5_000;
27
31
  const LEASE_VALIDATION_TIMEOUT_MS = 1_000;
32
+ const LEASE_RENEW_ATTEMPTS = 2;
28
33
  const RETENTION_CLEANUP_INTERVAL_MS = 60 * 60 * 1_000;
29
34
  const STARTUP_WAIT_MS = 60_000;
30
35
  const WORKER_SHUTDOWN_MS = 5_000;
31
36
  const MAX_RESTARTS_PER_WINDOW = 5;
32
37
  const RESTART_WINDOW_MS = 10 * 60 * 1_000;
33
- export const STEADY_STATE_LEASE_LOSS_BOUND_MS = LEASE_RENEW_INTERVAL_MS + LEASE_VALIDATION_TIMEOUT_MS + WORKER_SHUTDOWN_MS;
38
+ export const STEADY_STATE_LEASE_LOSS_BOUND_MS = LEASE_RENEW_INTERVAL_MS +
39
+ LEASE_VALIDATION_TIMEOUT_MS * LEASE_RENEW_ATTEMPTS +
40
+ WORKER_SHUTDOWN_MS;
41
+ const FATAL_SUPERVISOR_SHUTDOWN_REASONS = new Set([
42
+ "generation-lease-lost",
43
+ "leader-lease-lost",
44
+ "leader-promotion-failed",
45
+ "rebalance-failed",
46
+ "coordination-unavailable",
47
+ ]);
48
+ export function supervisorShutdownExitCode(reason) {
49
+ return FATAL_SUPERVISOR_SHUTDOWN_REASONS.has(reason) ? 1 : undefined;
50
+ }
34
51
  export function createStartupWorkerHeartbeatMaintenance(heartbeat, intervalMs = STARTUP_LEASE_RENEW_INTERVAL_MS) {
35
52
  const timer = setInterval(heartbeat, intervalMs);
36
53
  timer.unref();
@@ -50,6 +67,14 @@ export async function deferScheduledRoutingFailures(route) {
50
67
  };
51
68
  }
52
69
  }
70
+ function sameNumericIdentity(left, right) {
71
+ return (left.generation === right.generation &&
72
+ left.deploymentOrder === right.deploymentOrder &&
73
+ left.supervisorId === right.supervisorId &&
74
+ left.shardId === right.shardId &&
75
+ left.shardCount === right.shardCount &&
76
+ left.fencingToken === right.fencingToken);
77
+ }
53
78
  export async function startShardSupervisor(environment, processEnvironment, workerEntry) {
54
79
  if (environment.HELYX_SHARD_MODE === "off")
55
80
  throw new Error("Shard supervisor requires sharding to be enabled");
@@ -72,6 +97,8 @@ export async function startShardSupervisor(environment, processEnvironment, work
72
97
  let privacyDatabase;
73
98
  let controlServer;
74
99
  let coordinator;
100
+ let numericWriteBehind;
101
+ let numericRegistrations = [];
75
102
  let leaderLease;
76
103
  let cloudConnector;
77
104
  let cloudStarted = false;
@@ -88,6 +115,7 @@ export async function startShardSupervisor(environment, processEnvironment, work
88
115
  const healthByShard = new Map();
89
116
  const pendingControl = new Map();
90
117
  const pendingScheduled = new Map();
118
+ const pendingNumericCommitted = new Map();
91
119
  const restartHistory = new Map();
92
120
  const restartCounts = new Map();
93
121
  const workers = new Map();
@@ -135,6 +163,11 @@ export async function startShardSupervisor(environment, processEnvironment, work
135
163
  });
136
164
  const commandClient = new Client({ intents: [] });
137
165
  const unavailableResources = {
166
+ listGuildPresence: (guildIds) => Promise.resolve(guildIds.map((guildId) => ({
167
+ guildId,
168
+ state: "unknown",
169
+ observedAt: new Date().toISOString(),
170
+ }))),
138
171
  getGuildResources: () => Promise.reject(new Error("Guild resources are worker-owned")),
139
172
  sendToChannel: () => Promise.reject(new Error("Guild resources are worker-owned")),
140
173
  deleteChannelMessage: () => Promise.reject(new Error("Guild resources are worker-owned")),
@@ -160,6 +193,7 @@ export async function startShardSupervisor(environment, processEnvironment, work
160
193
  ...(cloudManagementModule ? [cloudManagementModule] : []),
161
194
  ...featureModules,
162
195
  ];
196
+ numericRegistrations = numericWriteBehindRegistrations(publicModules);
163
197
  scheduledModules = publicModules;
164
198
  const operationsModule = environment.DISCORD_INTERNAL_GUILD_ID
165
199
  ? createDeveloperOperationsModule({
@@ -194,6 +228,8 @@ export async function startShardSupervisor(environment, processEnvironment, work
194
228
  privacyDatabase = await createPrivacyDatabase(environment, database);
195
229
  assertStartupLeaseHealthy();
196
230
  const services = new Map(createPlatformServices(database, publicModules, platformServiceOptions(environment, publicModules)));
231
+ if (numericWriteBehind)
232
+ services.set(HELYX_SERVICE_NAMES.numericWriteBehind, numericWriteBehind);
197
233
  privacyService = new BotControlService(database, publicModules, unavailableResources, new ServiceRegistry(services), privacyDatabase
198
234
  ? new PostgresPrivacyProviderExecutor({
199
235
  readPool: database,
@@ -233,6 +269,21 @@ export async function startShardSupervisor(environment, processEnvironment, work
233
269
  });
234
270
  supervisorId = coordinator.supervisorId;
235
271
  await coordinator.start((targetShardId, request) => dispatchLocal(targetShardId, request), (request) => executeLeaderControl(request), (request) => executeLeaderCloud(request), (targetShardId, request) => dispatchLocalScheduled(targetShardId, request));
272
+ const numericPlatformServices = createPlatformServices(database, publicModules, platformServiceOptions(environment, publicModules));
273
+ numericWriteBehind = new RedisNumericWriteBehindService({
274
+ redisUrl: environment.HELYX_SHARD_REDIS_URL,
275
+ namespace: environment.HELYX_SHARD_REDIS_NAMESPACE,
276
+ writer: numericPlatformServices.get(HELYX_SERVICE_NAMES.records),
277
+ registrations: numericRegistrations,
278
+ supervisorId,
279
+ shardLeaseKeyPrefix: `helyx:shard:v1:${environment.HELYX_SHARD_REDIS_NAMESPACE}:lease:shard:`,
280
+ onError: (error) => logger.error({
281
+ event: "numeric_write_behind.redis_failed",
282
+ error: safeError(error),
283
+ }, "Persistent numeric write-behind operation failed"),
284
+ });
285
+ await numericWriteBehind.start();
286
+ installNumericCommitRouting(numericWriteBehind);
236
287
  deploymentOrder = await coordinator.assertGeneration(generationDescriptor, deploymentOrder);
237
288
  leaderLease = await coordinator.acquireLeader(generationDescriptor, deploymentOrder);
238
289
  startStartupLeaseMaintenance();
@@ -458,6 +509,135 @@ export async function startShardSupervisor(environment, processEnvironment, work
458
509
  ?.send(heartbeat)
459
510
  .catch(() => undefined);
460
511
  }
512
+ function installNumericCommitRouting(service) {
513
+ for (const registration of numericRegistrations) {
514
+ service.subscribeCommitted(registration, async (batch) => {
515
+ for (let index = 0; index < batch.mutations.length; index += 1) {
516
+ const mutation = batch.mutations[index];
517
+ const result = batch.results[index];
518
+ const guildId = mutation?.identity.guild_id;
519
+ if (typeof guildId !== "string" || !result)
520
+ continue;
521
+ const committedItem = splitNumericCommittedBatch(batch, index);
522
+ const shardId = guildIdToShardId(guildId, shardCount);
523
+ const lease = localLeases.get(shardId);
524
+ const worker = workers.get(shardId);
525
+ if (!lease || !worker?.process)
526
+ throw new Error("Committed numeric batch owner is not local or available");
527
+ const identity = {
528
+ version: 1,
529
+ mode: shardMode,
530
+ scope: environment.HELYX_SHARD_SCOPE,
531
+ generation: lease.generation,
532
+ deploymentOrder: lease.deploymentOrder,
533
+ supervisorId: lease.supervisorId,
534
+ shardId,
535
+ shardCount,
536
+ fencingToken: lease.fencingToken,
537
+ };
538
+ const notification = {
539
+ version: 1,
540
+ type: "helyx.numeric.committed",
541
+ requestId: randomUUID(),
542
+ deadlineAt: new Date(Date.now() + SHARD_CONTROL_TIMEOUT_MS).toISOString(),
543
+ identity,
544
+ scope: {
545
+ moduleId: batch.moduleId,
546
+ collection: batch.collection,
547
+ },
548
+ payload: encodeNumericIpcValue(committedItem),
549
+ };
550
+ assertShardMessageSize(notification);
551
+ const response = await dispatchNumericCommitted(shardId, identity, notification);
552
+ if (!response.ok)
553
+ throw new Error(`Committed numeric batch was not applied: ${response.safeCode}`);
554
+ }
555
+ });
556
+ }
557
+ }
558
+ function dispatchNumericCommitted(shardId, identity, notification) {
559
+ const worker = workers.get(shardId);
560
+ if (!worker?.process)
561
+ return Promise.reject(new Error("Committed numeric batch owner is unavailable"));
562
+ return new Promise((resolve, reject) => {
563
+ const timer = setTimeout(() => {
564
+ pendingNumericCommitted.delete(notification.requestId);
565
+ reject(new Error("Committed numeric batch acknowledgement timed out"));
566
+ }, SHARD_CONTROL_TIMEOUT_MS);
567
+ timer.unref();
568
+ pendingNumericCommitted.set(notification.requestId, {
569
+ shardId,
570
+ identity,
571
+ resolve,
572
+ timer,
573
+ });
574
+ void worker.send(notification).catch((error) => {
575
+ clearTimeout(timer);
576
+ pendingNumericCommitted.delete(notification.requestId);
577
+ reject(error instanceof Error
578
+ ? error
579
+ : new Error("Committed numeric delivery failed"));
580
+ });
581
+ });
582
+ }
583
+ async function handleNumericRequest(shardId, request) {
584
+ const lease = localLeases.get(shardId);
585
+ const numericService = numericWriteBehind;
586
+ const guildId = "guildId" in request.operation ? request.operation.guildId : undefined;
587
+ const valid = numericService !== undefined &&
588
+ lease !== undefined &&
589
+ request.identity.shardId === shardId &&
590
+ request.identity.shardCount === shardCount &&
591
+ request.identity.generation === lease.generation &&
592
+ request.identity.supervisorId === lease.supervisorId &&
593
+ request.identity.fencingToken === lease.fencingToken &&
594
+ request.identity.deploymentOrder === lease.deploymentOrder &&
595
+ request.identity.deploymentOrder !== undefined &&
596
+ new Date(request.deadlineAt).getTime() > Date.now() &&
597
+ (!guildId || guildIdToShardId(guildId, shardCount) === shardId);
598
+ let response;
599
+ if (!valid) {
600
+ response = {
601
+ version: 1,
602
+ type: "helyx.numeric.response",
603
+ requestId: request.requestId,
604
+ ok: false,
605
+ safeCode: "stale_owner",
606
+ };
607
+ }
608
+ else {
609
+ try {
610
+ if (!numericService)
611
+ throw new Error("Numeric write-behind service is unavailable");
612
+ response = {
613
+ version: 1,
614
+ type: "helyx.numeric.response",
615
+ requestId: request.requestId,
616
+ ok: true,
617
+ result: encodeNumericIpcValue(await executeRedisNumericOperation(numericService, request.operation, {
618
+ identity: request.identity,
619
+ shardLeaseKeyPrefix: `helyx:shard:v1:${environment.HELYX_SHARD_REDIS_NAMESPACE}:lease:shard:`,
620
+ })),
621
+ };
622
+ }
623
+ catch (error) {
624
+ logger.error({
625
+ event: "numeric_write_behind.ipc_failed",
626
+ shardId,
627
+ error: safeError(error),
628
+ }, "Numeric write-behind IPC operation failed");
629
+ response = {
630
+ version: 1,
631
+ type: "helyx.numeric.response",
632
+ requestId: request.requestId,
633
+ ok: false,
634
+ safeCode: "operation_failed",
635
+ };
636
+ }
637
+ }
638
+ assertShardMessageSize(response);
639
+ await workers.get(shardId)?.send(response);
640
+ }
461
641
  async function handleWorkerMessage(shardId, raw) {
462
642
  if (serializedBytes(raw) > SHARD_CONTROL_MAX_BYTES)
463
643
  return;
@@ -495,6 +675,21 @@ export async function startShardSupervisor(environment, processEnvironment, work
495
675
  pending.resolve(message.data);
496
676
  return;
497
677
  }
678
+ if (message.data.type === "helyx.numeric.committed.response") {
679
+ const pending = pendingNumericCommitted.get(message.data.requestId);
680
+ if (!pending ||
681
+ pending.shardId !== shardId ||
682
+ !sameNumericIdentity(pending.identity, message.data.identity))
683
+ return;
684
+ pendingNumericCommitted.delete(message.data.requestId);
685
+ clearTimeout(pending.timer);
686
+ pending.resolve(message.data);
687
+ return;
688
+ }
689
+ if (message.data.type === "helyx.numeric.request") {
690
+ await handleNumericRequest(shardId, message.data);
691
+ return;
692
+ }
498
693
  if (message.data.type === "helyx.cloud.request") {
499
694
  const response = leaderLease
500
695
  ? await executeLeaderCloud(message.data)
@@ -706,8 +901,19 @@ export async function startShardSupervisor(environment, processEnvironment, work
706
901
  try {
707
902
  if (!coordinator)
708
903
  return;
709
- const renewal = await renewSteadyStateOwnership(coordinator, generationDescriptor, deploymentOrder, localLeases, leaderLease).catch(() => undefined);
710
- if (!renewal?.generationRenewed) {
904
+ let renewal;
905
+ try {
906
+ renewal = await retryOwnershipRenewal(() => renewSteadyStateOwnership(coordinator, generationDescriptor, deploymentOrder, localLeases, leaderLease));
907
+ }
908
+ catch (error) {
909
+ logger.error({
910
+ event: "shard.coordination_renewal_failed",
911
+ error: safeError(error),
912
+ }, "Shard coordination could not be renewed after a bounded retry");
913
+ await shutdown("coordination-unavailable");
914
+ return;
915
+ }
916
+ if (!renewal.generationRenewed) {
711
917
  logger.error({ event: "shard.generation_lease_lost" }, "Shard generation lease was lost; stopping this supervisor safely");
712
918
  await shutdown("generation-lease-lost");
713
919
  return;
@@ -987,6 +1193,9 @@ export async function startShardSupervisor(environment, processEnvironment, work
987
1193
  if (shuttingDown)
988
1194
  return;
989
1195
  shuttingDown = true;
1196
+ const shutdownExitCode = supervisorShutdownExitCode(reason);
1197
+ if (shutdownExitCode !== undefined)
1198
+ process.exitCode = shutdownExitCode;
990
1199
  if (leaseTimer)
991
1200
  clearInterval(leaseTimer);
992
1201
  if (startupLeaseTimer)
@@ -1014,6 +1223,23 @@ export async function startShardSupervisor(environment, processEnvironment, work
1014
1223
  if (shard.process)
1015
1224
  shard.kill();
1016
1225
  }));
1226
+ if (numericWriteBehind) {
1227
+ try {
1228
+ const { drained } = await numericWriteBehind.stop();
1229
+ if (!drained) {
1230
+ process.exitCode = 1;
1231
+ logger.error({ event: "numeric_write_behind.shutdown_not_drained", reason }, "Numeric write-behind exceeded its bounded shutdown drain");
1232
+ }
1233
+ }
1234
+ catch (error) {
1235
+ process.exitCode = 1;
1236
+ logger.error({
1237
+ event: "numeric_write_behind.shutdown_failed",
1238
+ reason,
1239
+ error: safeError(error),
1240
+ }, "Numeric write-behind shutdown failed");
1241
+ }
1242
+ }
1017
1243
  await cloudConnector?.stop().catch(() => undefined);
1018
1244
  if (coordinator) {
1019
1245
  const activeCoordinator = coordinator;
@@ -1027,6 +1253,18 @@ export async function startShardSupervisor(environment, processEnvironment, work
1027
1253
  logger.info({ event: "shard.supervisor_stopped", reason }, "Shard supervisor stopped");
1028
1254
  }
1029
1255
  }
1256
+ export function splitNumericCommittedBatch(batch, index) {
1257
+ const mutation = batch.mutations[index];
1258
+ const result = batch.results[index];
1259
+ if (!Number.isSafeInteger(index) || index < 0 || !mutation || !result)
1260
+ throw new Error("Numeric committed batch item is unavailable");
1261
+ return {
1262
+ ...batch,
1263
+ itemIndexes: [batch.itemIndexes?.[index] ?? index],
1264
+ mutations: [mutation],
1265
+ results: [result],
1266
+ };
1267
+ }
1030
1268
  async function runLeaderStartup(database, environment, modulePackages, modules, logger) {
1031
1269
  await runCoreMigrations(database);
1032
1270
  await runConfiguredModuleMigrations(database, modulePackages.map(({ definition, packageRoot }) => ({
@@ -1138,6 +1376,7 @@ export function buildShardWorkerEnvironment(source, identity, restartCount) {
1138
1376
  "DISCORD_INTERNAL_GUILD_ID",
1139
1377
  "HELYX_SUPPORT_STAFF_CHANNEL_ID",
1140
1378
  "HELYX_MODULE_PACKAGES",
1379
+ "HELYX_MESSAGE_CONTENT_ENABLED",
1141
1380
  "HELYX_MODULE_CATALOGUE_PATH",
1142
1381
  "HELYX_CLOUD_CONNECT_ENABLED",
1143
1382
  "HELYX_OWNER_USER_IDS",
@@ -1243,15 +1482,17 @@ function failedCloud(requestId, message) {
1243
1482
  }
1244
1483
  async function cleanupExpiredCoreRecords(database, logger) {
1245
1484
  try {
1246
- const [sessions, rateLimits] = await Promise.all([
1485
+ const [sessions, rateLimits, verificationAudit] = await Promise.all([
1247
1486
  new InteractionSessionRepository(database).deleteExpired(),
1248
1487
  new RateLimitRepository(database).deleteExpired(),
1488
+ new AuditRepository(database).deleteModuleEventsOlderThanMonths("helyx.verification", 12),
1249
1489
  ]);
1250
- if (sessions > 0 || rateLimits > 0)
1490
+ if (sessions > 0 || rateLimits > 0 || verificationAudit > 0)
1251
1491
  logger.info({
1252
1492
  event: "database.retention_cleaned",
1253
1493
  interactionSessions: sessions,
1254
1494
  rateLimitWindows: rateLimits,
1495
+ verificationAuditEvents: verificationAudit,
1255
1496
  }, "Expired core records removed");
1256
1497
  }
1257
1498
  catch (error) {
@@ -1302,6 +1543,20 @@ async function waitForDeath(shard, timeoutMs) {
1302
1543
  export function boundedRespawnDelay(attempt) {
1303
1544
  return Math.min(30_000, 1_000 * 2 ** Math.max(0, attempt - 1));
1304
1545
  }
1546
+ export async function retryOwnershipRenewal(renew, attempts = LEASE_RENEW_ATTEMPTS) {
1547
+ if (!Number.isSafeInteger(attempts) || attempts < 1)
1548
+ throw new Error("Ownership renewal attempts must be a positive integer");
1549
+ let lastError;
1550
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
1551
+ try {
1552
+ return await renew();
1553
+ }
1554
+ catch (error) {
1555
+ lastError = error;
1556
+ }
1557
+ }
1558
+ throw lastError;
1559
+ }
1305
1560
  export function selectFencedShardHealth(input) {
1306
1561
  const liveByShard = new Map(input.liveLeases.map((lease) => [lease.shardId, lease]));
1307
1562
  const selected = new Map();
@@ -35,8 +35,12 @@ export declare function parseShardWorkerEnvironment(processEnvironment: NodeJS.P
35
35
  HELYX_MODULE_PACKAGES: string[];
36
36
  HELYX_MODULE_CATALOGUE_PATH: string | undefined;
37
37
  HELYX_SUPPORT_STAFF_CHANNEL_ID: string | undefined;
38
+ HELYX_VERIFICATION_ENABLED: boolean;
39
+ HELYX_VERIFICATION_SIGNING_PUBLIC_KEYS: Record<string, string>;
38
40
  HELYX_BOT_CONTROL_PORT: number;
41
+ HELYX_GUILD_RECONCILIATION_INTERVAL_SECONDS: number;
39
42
  HELYX_PRIVACY_ENABLED: boolean;
43
+ HELYX_MESSAGE_CONTENT_ENABLED: boolean;
40
44
  HELYX_CLOUD_CONNECT_ENABLED: boolean;
41
45
  HELYX_CLOUD_ENROLMENT_TOKEN: string | undefined;
42
46
  HELYX_CLOUD_ENROLMENT_FILE: string;
@@ -51,6 +55,9 @@ export declare function parseShardWorkerEnvironment(processEnvironment: NodeJS.P
51
55
  NODE_ENV: "development" | "test" | "production";
52
56
  HELYX_BOT_CONTROL_SECRET?: string | undefined;
53
57
  HELYX_SUPPORT_SERVICE_SECRET?: string | undefined;
58
+ HELYX_VERIFICATION_INSTALLATION_ID?: string | undefined;
59
+ HELYX_VERIFICATION_URL?: string | undefined;
60
+ HELYX_VERIFICATION_INTERNAL_SECRET?: string | undefined;
54
61
  HELYX_PRIVACY_EXECUTOR_DATABASE_URL?: string | undefined;
55
62
  HELYX_PRIVACY_EXECUTOR_DATABASE_SSL?: "disable" | "require" | "verify-full" | undefined;
56
63
  HELYX_SHARD_COUNT?: number | undefined;
@@ -1,6 +1,6 @@
1
1
  import { parseBotEnvironment } from "@helyx/config";
2
2
  import { safeError, ServiceRegistry } from "@helyx/core";
3
- import { checkDatabaseConnection, createDatabasePool } from "@helyx/database";
3
+ import { checkDatabaseConnection, createDatabasePool, VerificationSetupRepository, } from "@helyx/database";
4
4
  import { createRuntime } from "@helyx/framework";
5
5
  import { createPlatformServices } from "@helyx/platform";
6
6
  import { HELYX_SERVICE_NAMES } from "@helyx/sdk";
@@ -9,8 +9,9 @@ import { monitorEventLoopDelay } from "node:perf_hooks";
9
9
  import pino from "pino";
10
10
  import { createCloudManagementModule } from "./cloud-management-module.js";
11
11
  import { BotControlService, DiscordGuildResourceDirectory, mapBotControlError, } from "./control-server.js";
12
- import { DiscordMemberRoleService, DiscordResourceInspection, installModuleEvents, } from "./events.js";
13
- import { installGuildTracking } from "./guilds.js";
12
+ import { HttpVerificationService } from "./verification-service.js";
13
+ import { DiscordMessageDeliveryService, DiscordMemberEnforcementService, DiscordMemberRoleService, DiscordVerificationMemberService, DiscordVerificationSetupService, DiscordResourceInspection, installModuleEvents, } from "./events.js";
14
+ import { installGuildTracking, } from "./guilds.js";
14
15
  import { installInteractions, } from "./interactions.js";
15
16
  import { createManagementModule } from "./management-module.js";
16
17
  import { DiscordModuleLoggingService } from "./module-logging.js";
@@ -25,6 +26,8 @@ import { assertShardMessageSize, controlOperationGuildId, executeShardControlOpe
25
26
  import { gatewayIntentsForModules, readBotVersion, } from "./index.js";
26
27
  import { createSupportModule } from "./support-module.js";
27
28
  import { createLocalScheduledTaskRoute, } from "./scheduled-task-runner.js";
29
+ import { createLocalNumericWriteBehind } from "./numeric-write-behind.js";
30
+ import { WorkerNumericWriteBehindProxy } from "./numeric-write-behind-proxy.js";
28
31
  const HEALTH_INTERVAL_MS = 5_000;
29
32
  export const WORKER_ORPHAN_TERMINATION_BOUND_MS = SHARD_LEASE_HEARTBEAT_TIMEOUT_MS + 5_000;
30
33
  export function createLeaseHeartbeatWatchdog(onExpired, timeoutMs = SHARD_LEASE_HEARTBEAT_TIMEOUT_MS) {
@@ -71,16 +74,19 @@ export async function startShardWorker(processEnvironment = process.env, identit
71
74
  let shuttingDown = false;
72
75
  let runtimeStarted = false;
73
76
  let moduleEvents;
77
+ let guildTracking;
74
78
  let client;
75
79
  let cloudProxy;
76
80
  let shardHealthProvider;
77
81
  let leaseHeartbeatWatchdog;
78
82
  let forcedLeaseExit;
83
+ let numericWriteBehind;
79
84
  async function shutdown(reason = "programmatic") {
80
85
  if (shuttingDown)
81
86
  return;
82
87
  shuttingDown = true;
83
88
  status = "stopped";
89
+ guildTracking?.stop();
84
90
  if (healthTimer)
85
91
  clearInterval(healthTimer);
86
92
  leaseHeartbeatWatchdog?.stop();
@@ -92,6 +98,7 @@ export async function startShardWorker(processEnvironment = process.env, identit
92
98
  await moduleEvents?.stop().catch(() => undefined);
93
99
  if (runtimeStarted)
94
100
  await runtime?.stop().catch(() => undefined);
101
+ await numericWriteBehind?.stop();
95
102
  await client?.destroy();
96
103
  eventLoopDelay.disable();
97
104
  await database.end();
@@ -104,7 +111,7 @@ export async function startShardWorker(processEnvironment = process.env, identit
104
111
  const modulePackages = await loadConfiguredModulePackages(moduleSpecifiers);
105
112
  const featureModules = modulePackages.map(({ definition }) => definition);
106
113
  client = new Client({
107
- intents: gatewayIntentsForModules(featureModules),
114
+ intents: gatewayIntentsForModules(featureModules, environment.HELYX_MESSAGE_CONTENT_ENABLED),
108
115
  partials: [Partials.Message, Partials.Channel, Partials.Reaction],
109
116
  allowedMentions: { parse: [] },
110
117
  shards: [identity.shardId],
@@ -156,14 +163,39 @@ export async function startShardWorker(processEnvironment = process.env, identit
156
163
  ...publicModules,
157
164
  ...(operationsModule ? [operationsModule] : []),
158
165
  ];
159
- const platformServices = new Map(createPlatformServices(database, publicModules, platformServiceOptions(environment, publicModules)));
166
+ const platformServices = new Map();
167
+ const serviceAccess = new ServiceRegistry(platformServices);
168
+ for (const [name, service] of createPlatformServices(database, publicModules, {
169
+ ...platformServiceOptions(environment, publicModules),
170
+ activationServices: serviceAccess,
171
+ })) {
172
+ platformServices.set(name, service);
173
+ }
174
+ if (environment.HELYX_VERIFICATION_ENABLED &&
175
+ environment.HELYX_VERIFICATION_URL &&
176
+ environment.HELYX_VERIFICATION_INTERNAL_SECRET) {
177
+ platformServices.set(HELYX_SERVICE_NAMES.verification, new HttpVerificationService({
178
+ baseUrl: environment.HELYX_VERIFICATION_URL,
179
+ internalSecret: environment.HELYX_VERIFICATION_INTERNAL_SECRET,
180
+ installationId: environment.HELYX_VERIFICATION_INSTALLATION_ID,
181
+ publicSigningKeys: environment.HELYX_VERIFICATION_SIGNING_PUBLIC_KEYS,
182
+ }));
183
+ platformServices.set(HELYX_SERVICE_NAMES.verificationMembers, new DiscordVerificationMemberService(client));
184
+ platformServices.set(HELYX_SERVICE_NAMES.verificationSetup, new DiscordVerificationSetupService(client, new VerificationSetupRepository(database)));
185
+ }
186
+ numericWriteBehind =
187
+ identity.scope === "coordinated"
188
+ ? new WorkerNumericWriteBehindProxy(identity)
189
+ : createLocalNumericWriteBehind(platformServices, publicModules);
190
+ platformServices.set(HELYX_SERVICE_NAMES.numericWriteBehind, numericWriteBehind);
160
191
  platformServices.set(HELYX_SERVICE_NAMES.resourceAuthorization, new CoreResourceAuthorizationService(platformServices.get(HELYX_SERVICE_NAMES.permissions)));
192
+ platformServices.set(HELYX_SERVICE_NAMES.discordMessages, new DiscordMessageDeliveryService(client));
161
193
  platformServices.set(HELYX_SERVICE_NAMES.discordOwnedMessages, new DiscordOwnedMessageDeletionService(client));
194
+ platformServices.set(HELYX_SERVICE_NAMES.memberEnforcement, new DiscordMemberEnforcementService(client));
162
195
  platformServices.set(HELYX_SERVICE_NAMES.memberRoles, new DiscordMemberRoleService(client));
163
196
  platformServices.set(HELYX_SERVICE_NAMES.discordResources, new DiscordResourceInspection(client));
164
197
  if (featureModules.some(({ manifest }) => manifest.id === "helyx.logging"))
165
198
  platformServices.set(HELYX_SERVICE_NAMES.moduleLogging, new DiscordModuleLoggingService(client, database, publicModules, logger));
166
- const serviceAccess = new ServiceRegistry(platformServices);
167
199
  const scheduledTaskRoute = createLocalScheduledTaskRoute({
168
200
  modules: publicModules,
169
201
  services: serviceAccess,
@@ -177,13 +209,15 @@ export async function startShardWorker(processEnvironment = process.env, identit
177
209
  ...(cloudManagementModule ? [cloudManagementModule.manifest.id] : []),
178
210
  ...(operationsModule ? [operationsModule.manifest.id] : []),
179
211
  ]);
180
- const prepareGuilds = installGuildTracking(client, database, publicModules, logger, enabledCoreModuleIds, {
212
+ const shardOwnership = {
181
213
  generation: identity.generation,
182
214
  deploymentOrder: BigInt(identity.deploymentOrder ?? 1),
183
215
  supervisorId: identity.supervisorId,
184
216
  shardId: identity.shardId,
185
217
  fencingToken: BigInt(identity.fencingToken),
186
- });
218
+ };
219
+ const prepareGuilds = installGuildTracking(client, database, publicModules, logger, enabledCoreModuleIds, shardOwnership, environment.HELYX_GUILD_RECONCILIATION_INTERVAL_SECONDS * 1_000, identity.shardCount);
220
+ guildTracking = prepareGuilds;
187
221
  const commandsReady = installInteractions(client, modules, shardWorkerCommandRegistration({
188
222
  ...(environment.DISCORD_INTERNAL_GUILD_ID
189
223
  ? { internalGuildId: environment.DISCORD_INTERNAL_GUILD_ID }
@@ -201,7 +235,7 @@ export async function startShardWorker(processEnvironment = process.env, identit
201
235
  guildId: environment.DISCORD_INTERNAL_GUILD_ID,
202
236
  channelId: environment.HELYX_SUPPORT_STAFF_CHANNEL_ID,
203
237
  }
204
- : undefined);
238
+ : undefined, shardOwnership, identity.shardCount);
205
239
  process.on("message", (raw) => {
206
240
  const parsed = supervisorMessageSchema.safeParse(raw);
207
241
  if (!parsed.success)
@@ -436,7 +470,27 @@ async function handleControl(identity, service, request, logger) {
436
470
  };
437
471
  }
438
472
  }
439
- assertShardMessageSize(response);
473
+ try {
474
+ assertShardMessageSize(response);
475
+ }
476
+ catch (error) {
477
+ logger.error({
478
+ event: "shard.control_response_oversized",
479
+ requestId: request.requestId,
480
+ operation: request.operation.kind,
481
+ guildId,
482
+ error: safeError(error),
483
+ }, "Shard control response exceeded the transport limit");
484
+ response = {
485
+ version: 1,
486
+ type: "helyx.control.response",
487
+ requestId: request.requestId,
488
+ ok: false,
489
+ status: 502,
490
+ message: "The owning shard returned too much data",
491
+ };
492
+ assertShardMessageSize(response);
493
+ }
440
494
  process.send?.(response);
441
495
  }
442
496
  function health(identity, client, status, eventLoopDelayNanoseconds, queueDepth) {
@@ -0,0 +1,50 @@
1
+ import { type ActionReceipt, type ChallengeCreateRequest, type ChallengeCreateResponse, type DashboardAttemptPage, type DashboardAttemptQuery, type DashboardSafeAttemptDetail, type SignedAssessmentEnvelope, type VerificationInstallationClaims } from "@helyx/verification-contracts";
2
+ import type { VerificationResultContext, VerificationService } from "@helyx/sdk";
3
+ export interface HttpVerificationServiceOptions {
4
+ baseUrl: string;
5
+ internalSecret: string;
6
+ publicSigningKeys: Readonly<Record<string, string>>;
7
+ installationId: string;
8
+ fetchImplementation?: typeof fetch;
9
+ }
10
+ export declare class HttpVerificationService implements VerificationService {
11
+ #private;
12
+ constructor(options: HttpVerificationServiceOptions);
13
+ getInstallationContext(): Promise<{
14
+ installation: VerificationInstallationClaims;
15
+ }>;
16
+ createChallenge(input: ChallengeCreateRequest): Promise<ChallengeCreateResponse>;
17
+ cancelChallenges(input: {
18
+ installationId: string;
19
+ guildId: string;
20
+ userId?: string;
21
+ }): Promise<{
22
+ cancelled: number;
23
+ }>;
24
+ listAttempts(input: DashboardAttemptQuery & {
25
+ installationId: string;
26
+ guildId: string;
27
+ }): Promise<DashboardAttemptPage>;
28
+ getAttempt(input: {
29
+ installationId: string;
30
+ guildId: string;
31
+ attemptId: string;
32
+ }): Promise<DashboardSafeAttemptDetail | null>;
33
+ decideAttempt(input: {
34
+ installationId: string;
35
+ guildId: string;
36
+ attemptId: string;
37
+ expectedVersion: number;
38
+ actorUserId: string;
39
+ decision: "approve" | "deny" | "retry" | "dismiss_relationship";
40
+ reason: string;
41
+ }): Promise<DashboardSafeAttemptDetail>;
42
+ registerResultHandler(handler: (context: VerificationResultContext) => Promise<ActionReceipt>): () => void;
43
+ acceptResult(envelopeInput: SignedAssessmentEnvelope): Promise<ActionReceipt>;
44
+ health(): Promise<{
45
+ status: "healthy" | "degraded" | "unavailable";
46
+ provider: "available" | "degraded" | "unavailable";
47
+ allowanceRemaining?: number;
48
+ }>;
49
+ }
50
+ //# sourceMappingURL=verification-service.d.ts.map