@helyx/bot 0.3.1 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,45 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.2
4
+
5
+ ### Patch Changes
6
+
7
+ - Republish the bot from a clean, reproducible declaration build.
8
+
9
+ ## 0.4.1
10
+
11
+ ### Patch Changes
12
+
13
+ - d25b465: Replace the user-specific shard diagnostic with a reusable, internal-guild-only
14
+ `/dev` command. Authorize Discord administrators or configured role IDs, expose
15
+ bounded shard, bot/module-version and process diagnostics, and keep the command
16
+ available for validating the unsharded compatibility baseline.
17
+ - Updated dependencies [d25b465]
18
+ - @helyx/config@0.4.1
19
+ - @helyx/sdk@0.3.2
20
+ - @helyx/core@0.1.7
21
+ - @helyx/framework@0.1.7
22
+ - @helyx/platform@0.2.3
23
+
24
+ ## 0.4.0
25
+
26
+ ### Minor Changes
27
+
28
+ - 9cdb2ef: Add the opt-in shard supervisor/worker runtime, fenced coordination and routing,
29
+ durable command state, shard-aware guild ownership, and private operational
30
+ health while preserving unsharded defaults.
31
+
32
+ ### Patch Changes
33
+
34
+ - Updated dependencies [9cdb2ef]
35
+ - @helyx/control-protocol@0.1.2
36
+ - @helyx/config@0.4.0
37
+ - @helyx/database@0.4.0
38
+ - @helyx/sdk@0.3.1
39
+ - @helyx/platform@0.2.2
40
+ - @helyx/core@0.1.6
41
+ - @helyx/framework@0.1.6
42
+
3
43
  ## 0.3.1
4
44
 
5
45
  ### Patch Changes
package/README.md CHANGED
@@ -24,4 +24,72 @@ bot-control endpoint and a separate least-privilege PostgreSQL executor
24
24
  credential. The bot refuses Privacy execution when that credential resolves to
25
25
  the ordinary runtime database role.
26
26
 
27
+ ## Shard configuration and operations
28
+
29
+ `HELYX_SHARD_MODE=off` is the default and compatibility baseline. It runs the
30
+ existing unsharded bot path. Do not set shard-only tuning values in this mode:
31
+ startup rejects shard count, workers, Redis, generation, database budget and
32
+ explicit database-pool settings. The internal `/dev` diagnostics remain
33
+ available in this mode when their guild and role configuration is present.
34
+
35
+ | Mode | Required values | Prohibited values | Behaviour |
36
+ | ------- | ---------------------------- | ----------------------------------------------------------------------------- | ---------------------------------------------------------------- |
37
+ | `off` | None | Shard count, workers, Redis, namespace, generation, budget and explicit pools | Existing single-process behaviour |
38
+ | `auto` | None for local mode | Shard count; Redis and namespace unless coordinated | Obtains Discord's recommended total |
39
+ | `fixed` | `HELYX_SHARD_COUNT` (1-4096) | None beyond the scope rules | Uses the deliberate total; workers per replica may not exceed it |
40
+
41
+ `HELYX_SHARD_SCOPE` defaults to `local`; use it for one supervisor host.
42
+ `coordinated` is only valid in a sharded mode and requires all of
43
+ `HELYX_SHARD_REDIS_URL` (`redis:` or `rediss:`),
44
+ `HELYX_SHARD_REDIS_NAMESPACE` (lowercase safe 3-128-character namespace),
45
+ and `HELYX_SHARD_GENERATION` (safe 1-128-character identifier). Redis URL and
46
+ namespace are forbidden in local mode. Generation is optional for local
47
+ sharded mode and forbidden when sharding is off.
48
+
49
+ `HELYX_SHARD_WORKERS_PER_REPLICA` is a positive integer up to 64 and defaults
50
+ to 1 when off or 2 when sharded. In sharded modes
51
+ `HELYX_SHARD_SUPERVISOR_DATABASE_POOL_MAX` and
52
+ `HELYX_SHARD_WORKER_DATABASE_POOL_MAX` default to 2 and 4 respectively (each
53
+ may be 1-20).
54
+ `HELYX_SHARD_DATABASE_CONNECTION_BUDGET` is required for any production
55
+ sharded mode, optional elsewhere, and may not exceed 1000. When it is set,
56
+ the following must fit:
57
+
58
+ ```text
59
+ supervisor pool + (workers per replica x worker pool) + (2 when Privacy is enabled) <= database budget
60
+ ```
61
+
62
+ Record the provider's actual connection, memory, CPU and replica limits before
63
+ choosing workers or pools; this repository does not declare a measured
64
+ production budget.
65
+
66
+ `DISCORD_INTERNAL_GUILD_ID` registers the private `/dev` command only in the
67
+ exact internal guild. `APPROVED_ROLES` is a de-duplicated, comma-separated
68
+ list of at most 100 Discord role IDs. A caller must have Discord's
69
+ Administrator permission or at least one approved role; role names are never
70
+ used for authorization. Every response is ephemeral and read-only:
71
+
72
+ - `/dev shards` reports bounded shard health, including the unsharded
73
+ compatibility baseline;
74
+ - `/dev bot` reports the bot build and bounded installed-module versions;
75
+ - `/dev proc` reports safe metrics for the process handling the command.
76
+
77
+ No `/dev` command exposes tokens, URLs, Redis keys, database details,
78
+ environment variables or process arguments. Future developer subcommands must
79
+ extend this guild-only `/dev` root and reuse the same authorization boundary.
80
+ Hosted operators with `operations.read` can use the Admin Operations shard
81
+ view instead.
82
+
83
+ For self-hosting, leave the default `off` unless the owner has sized the local
84
+ machine and database. `auto` with the default `local` scope is the supported
85
+ advanced local path and does not require Redis. Coordinated multi-host
86
+ self-hosting is outside the first release.
87
+
88
+ Changing the total shard count redistributes guild ownership. Treat it as a
89
+ generation change: drain the old generation before admitting the new one and
90
+ never run both as active owners. Shard-capable code does not authorize hosted
91
+ activation. Phase 10 production activation, replica scaling, or any Railway
92
+ variable change requires separate owner authorization; do not provision a new
93
+ Redis or coordinator service for sharding.
94
+
27
95
  For packaged operation use `helyx start`. `helyx migrate` applies core database migrations without Discord, and read-only `helyx doctor` verifies environment, PostgreSQL migration history, and configured package metadata.
@@ -11,13 +11,47 @@ export interface CloudConnectorStatus {
11
11
  lastConnectedAt?: Date;
12
12
  lastErrorCode?: string;
13
13
  }
14
- export declare class CloudConnectorController {
14
+ export interface CloudDiscordSnapshot {
15
+ applicationId: string;
16
+ botUserId: string;
17
+ botUsername: string;
18
+ ownerUserIds: string[];
19
+ guilds: Array<{
20
+ guildId: string;
21
+ name: string;
22
+ ownerUserId: string | null;
23
+ }>;
24
+ }
25
+ export interface CloudDiscordDirectory {
26
+ snapshot(): Promise<CloudDiscordSnapshot>;
27
+ }
28
+ export interface CloudConnector {
29
+ setControlHandler(handler: BotControlHandler): void;
30
+ start(): Promise<void>;
31
+ status(): CloudConnectorStatus;
32
+ getSupportConsent(): Promise<CloudConnectorConsentRecord>;
33
+ updateSupportConsent(input: {
34
+ supportInspectionEnabled: boolean;
35
+ supportDataClasses: string[];
36
+ diagnosticsEnabled: boolean;
37
+ }): Promise<CloudConnectorConsentRecord>;
38
+ enrol(rawToken: string, source: "startup" | "discord" | "file"): Promise<CloudConnectorStatus>;
39
+ disconnect(): Promise<void>;
40
+ rotate(): Promise<CloudConnectorStatus>;
41
+ stop(): Promise<void>;
42
+ }
43
+ export declare class ClientCloudDiscordDirectory implements CloudDiscordDirectory {
44
+ private readonly client;
45
+ constructor(client: Client);
46
+ snapshot(): Promise<CloudDiscordSnapshot>;
47
+ }
48
+ export declare class CloudConnectorController implements CloudConnector {
15
49
  #private;
16
50
  private readonly input;
17
51
  constructor(input: {
18
52
  environment: BotEnvironment;
19
53
  database: DatabasePool;
20
- client: Client;
54
+ discord: CloudDiscordDirectory;
21
55
  modules: readonly ConfiguredModulePackage[];
22
56
  coreVersion: string;
23
57
  logger: Logger;
@@ -1,4 +1,4 @@
1
- import { CLOUD_DEFAULT_REQUEST_TIMEOUT_MS, CLOUD_PROTOCOL_VERSION, ConnectionSequenceGuard, assertCloudMessageSize, cloudControlResponsePayloadSchema, connectedCloudMessageSchema, connectorAcceptedSchema, connectorChallengeSchema, connectorCredentialActionSchema, connectorHelloSchema, connectorRejectedSchema, generateConnectorKeyPair, signConnectorCredentialAction, signConnectorChallenge, } from "@helyx/control-protocol";
1
+ import { CLOUD_DEFAULT_REQUEST_TIMEOUT_MS, CLOUD_PROTOCOL_VERSION, ConnectionSequenceGuard, assertCloudHandshakeSize, assertCloudMessageSize, cloudControlResponsePayloadSchema, connectedCloudMessageSchema, connectorAcceptedSchema, connectorChallengeSchema, connectorCredentialActionSchema, connectorHelloSchema, connectorRejectedSchema, generateConnectorKeyPair, signConnectorCredentialAction, signConnectorChallenge, } from "@helyx/control-protocol";
2
2
  import { CloudConnectorRepository, } from "@helyx/database";
3
3
  import { createCipheriv, createDecipheriv, createHash, randomBytes, randomUUID, } from "node:crypto";
4
4
  import { readFile, rm } from "node:fs/promises";
@@ -42,6 +42,34 @@ const supportConsentInputSchema = z
42
42
  diagnosticsEnabled: z.boolean(),
43
43
  })
44
44
  .strict();
45
+ export class ClientCloudDiscordDirectory {
46
+ client;
47
+ constructor(client) {
48
+ this.client = client;
49
+ }
50
+ async snapshot() {
51
+ const application = await this.client.application?.fetch();
52
+ const botUser = this.client.user;
53
+ if (!application || !botUser)
54
+ throw new Error("discord_not_ready");
55
+ const owner = application.owner;
56
+ return {
57
+ applicationId: application.id,
58
+ botUserId: botUser.id,
59
+ botUsername: botUser.username,
60
+ ownerUserIds: !owner
61
+ ? []
62
+ : "members" in owner
63
+ ? [...owner.members.keys()]
64
+ : [owner.id],
65
+ guilds: this.client.guilds.cache.map((guild) => ({
66
+ guildId: guild.id,
67
+ name: guild.name,
68
+ ownerUserId: guild.ownerId || null,
69
+ })),
70
+ };
71
+ }
72
+ }
45
73
  export class CloudConnectorController {
46
74
  input;
47
75
  #repository;
@@ -143,9 +171,8 @@ export class CloudConnectorController {
143
171
  throw new CloudConnectorUserError("HELYX_DATA_ENCRYPTION_KEY is not configured");
144
172
  const token = normalizeEnrolmentToken(rawToken);
145
173
  const keyPair = generateConnectorKeyPair();
146
- const application = await this.input.client.application?.fetch();
147
- const botUser = this.input.client.user;
148
- if (!application || !botUser)
174
+ const discord = await this.input.discord.snapshot().catch(() => undefined);
175
+ if (!discord)
149
176
  throw new CloudConnectorUserError("Discord must be connected before Cloud Connect enrolment");
150
177
  let response;
151
178
  try {
@@ -161,9 +188,9 @@ export class CloudConnectorController {
161
188
  publicKeyFingerprint: keyPair.fingerprint,
162
189
  protocolVersion: CLOUD_PROTOCOL_VERSION,
163
190
  coreVersion: this.input.coreVersion,
164
- botApplicationId: application.id,
165
- botUserId: botUser.id,
166
- displayName: botUser.username,
191
+ botApplicationId: discord.applicationId,
192
+ botUserId: discord.botUserId,
193
+ displayName: discord.botUsername,
167
194
  }),
168
195
  signal: AbortSignal.timeout(CLOUD_DEFAULT_REQUEST_TIMEOUT_MS),
169
196
  });
@@ -456,15 +483,7 @@ export class CloudConnectorController {
456
483
  async #sendHello(socket, identity, privateKey, challenge) {
457
484
  if (challenge.installationId !== identity.installationId)
458
485
  throw new Error("challenge_installation_mismatch");
459
- const application = await this.input.client.application?.fetch();
460
- const botUser = this.input.client.user;
461
- if (!application || !botUser)
462
- throw new Error("discord_not_ready");
463
- const guilds = this.input.client.guilds.cache.map((guild) => ({
464
- guildId: guild.id,
465
- name: guild.name,
466
- ownerUserId: guild.ownerId || null,
467
- }));
486
+ const discord = await this.input.discord.snapshot();
468
487
  const capabilities = connectorCapabilities();
469
488
  const hello = connectorHelloSchema.parse({
470
489
  protocolVersion: CLOUD_PROTOCOL_VERSION,
@@ -476,8 +495,8 @@ export class CloudConnectorController {
476
495
  challengeId: challenge.challengeId,
477
496
  signature: signConnectorChallenge(privateKey, challenge),
478
497
  coreVersion: this.input.coreVersion,
479
- botApplicationId: application.id,
480
- botUserId: botUser.id,
498
+ botApplicationId: discord.applicationId,
499
+ botUserId: discord.botUserId,
481
500
  capabilities,
482
501
  modules: this.input.modules.map(({ specifier, definition }) => ({
483
502
  id: definition.manifest.id,
@@ -486,9 +505,11 @@ export class CloudConnectorController {
486
505
  manifestSchemaVersion: 1,
487
506
  configurationSchemaVersion: definition.configuration?.schemaVersion ?? 1,
488
507
  })),
489
- guilds,
508
+ guilds: discord.guilds,
490
509
  });
491
- socket.send(JSON.stringify(hello));
510
+ const encodedHello = JSON.stringify(hello);
511
+ assertCloudHandshakeSize(encodedHello);
512
+ socket.send(encodedHello);
492
513
  }
493
514
  async #dispatch(request, correlationId) {
494
515
  try {
@@ -1,15 +1,17 @@
1
1
  import { type ModuleDefinition } from "@helyx/sdk";
2
2
  import type { Client } from "discord.js";
3
- import type { CloudConnectorController } from "./cloud-connector.js";
3
+ import { type CloudConnector, type CloudDiscordDirectory } from "./cloud-connector.js";
4
4
  export declare class DeploymentOwnerAuthorizer {
5
5
  #private;
6
- constructor(client: Client, configuredOwnerUserIds: readonly string[]);
6
+ constructor(directory: CloudDiscordDirectory | Client, configuredOwnerUserIds: readonly string[]);
7
7
  isOwner(userId: string): boolean;
8
8
  refresh(): Promise<void>;
9
9
  }
10
10
  export declare function createCloudManagementModule(input: {
11
- connector: CloudConnectorController;
11
+ connector: CloudConnector;
12
+ discord?: CloudDiscordDirectory;
12
13
  client: Client;
13
14
  configuredOwnerUserIds: readonly string[];
15
+ periodicOwnerRefresh?: boolean;
14
16
  }): ModuleDefinition;
15
17
  //# sourceMappingURL=cloud-management-module.d.ts.map
@@ -1,6 +1,6 @@
1
1
  import { defineModule, } from "@helyx/sdk";
2
2
  import { Events } from "discord.js";
3
- import { CloudConnectorUserError, } from "./cloud-connector.js";
3
+ import { ClientCloudDiscordDirectory, CloudConnectorUserError, } from "./cloud-connector.js";
4
4
  const CONNECT_MODAL_ID = "helyx.cloud.connect";
5
5
  const CONNECT_CODE_FIELD_ID = "helyx.cloud.connect.code";
6
6
  const DISCONNECT_MODAL_ID = "helyx.cloud.disconnect";
@@ -16,11 +16,17 @@ const SUCCESS = 0x16a34a;
16
16
  const DANGER = 0xdc2626;
17
17
  const OWNER_REFRESH_INTERVAL_MS = 15 * 60 * 1_000;
18
18
  export class DeploymentOwnerAuthorizer {
19
+ #directory;
19
20
  #client;
20
21
  #configuredOwnerUserIds;
21
22
  #applicationOwnerUserIds = new Set();
22
- constructor(client, configuredOwnerUserIds) {
23
- this.#client = client;
23
+ constructor(directory, configuredOwnerUserIds) {
24
+ if ("snapshot" in directory)
25
+ this.#directory = directory;
26
+ else {
27
+ this.#client = directory;
28
+ this.#applicationOwnerUserIds = discordOwnerIds(directory.application?.owner);
29
+ }
24
30
  this.#configuredOwnerUserIds = new Set(configuredOwnerUserIds);
25
31
  }
26
32
  isOwner(userId) {
@@ -28,23 +34,24 @@ export class DeploymentOwnerAuthorizer {
28
34
  this.#applicationOwnerUserIds.has(userId));
29
35
  }
30
36
  async refresh() {
31
- const application = this.#client.application;
32
- if (!application)
37
+ if (this.#directory) {
38
+ const snapshot = await this.#directory.snapshot();
39
+ this.#applicationOwnerUserIds = new Set(snapshot.ownerUserIds);
33
40
  return;
34
- this.#replaceApplicationOwners(application.owner);
35
- const refreshed = await application.fetch();
36
- this.#replaceApplicationOwners(refreshed.owner);
37
- }
38
- #replaceApplicationOwners(owner) {
39
- this.#applicationOwnerUserIds = !owner
40
- ? new Set()
41
- : "members" in owner
42
- ? new Set(owner.members.keys())
43
- : new Set([owner.id]);
41
+ }
42
+ const application = await this.#client?.application?.fetch();
43
+ this.#applicationOwnerUserIds = discordOwnerIds(application?.owner);
44
44
  }
45
45
  }
46
+ function discordOwnerIds(owner) {
47
+ if (!owner)
48
+ return new Set();
49
+ if ("id" in owner)
50
+ return new Set([owner.id]);
51
+ return new Set([...owner.members.values()].map((member) => member.id));
52
+ }
46
53
  export function createCloudManagementModule(input) {
47
- const ownerAuthorizer = new DeploymentOwnerAuthorizer(input.client, input.configuredOwnerUserIds);
54
+ const ownerAuthorizer = new DeploymentOwnerAuthorizer(input.discord ?? new ClientCloudDiscordDirectory(input.client), input.configuredOwnerUserIds);
48
55
  let ownerRefreshInterval;
49
56
  let onClientReady;
50
57
  const requireOwner = async (context) => {
@@ -455,8 +462,10 @@ export function createCloudManagementModule(input) {
455
462
  };
456
463
  onClientReady = () => {
457
464
  refreshOwners();
458
- ownerRefreshInterval ??= setInterval(refreshOwners, OWNER_REFRESH_INTERVAL_MS);
459
- ownerRefreshInterval.unref();
465
+ if (input.periodicOwnerRefresh !== false) {
466
+ ownerRefreshInterval ??= setInterval(refreshOwners, OWNER_REFRESH_INTERVAL_MS);
467
+ ownerRefreshInterval.unref();
468
+ }
460
469
  };
461
470
  if (input.client.isReady())
462
471
  onClientReady();
@@ -0,0 +1,32 @@
1
+ import type { ModuleDefinition } from "@helyx/sdk";
2
+ export interface CommandHashState {
3
+ value: unknown;
4
+ version: bigint;
5
+ }
6
+ export interface CommandHashStore {
7
+ get(key: string): Promise<CommandHashState | null>;
8
+ set(key: string, value: unknown, expectedVersion?: bigint | null): Promise<CommandHashState>;
9
+ }
10
+ export interface DiscordCommandRest {
11
+ put(route: `/${string}`, options: {
12
+ body: unknown;
13
+ }): Promise<unknown>;
14
+ }
15
+ export interface CommandSyncResult {
16
+ global: "updated" | "unchanged";
17
+ guild: "updated" | "unchanged" | "not_configured";
18
+ globalCount: number;
19
+ guildCount: number;
20
+ globalHash: string;
21
+ guildHash?: string;
22
+ }
23
+ export declare function synchroniseApplicationCommands(input: {
24
+ applicationId: string;
25
+ token: string;
26
+ modules: readonly ModuleDefinition[];
27
+ internalGuildId?: string;
28
+ state: CommandHashStore;
29
+ rest?: DiscordCommandRest;
30
+ }): Promise<CommandSyncResult>;
31
+ export declare function commandDefinitionsHash(definitions: readonly unknown[]): string;
32
+ //# sourceMappingURL=command-sync.d.ts.map
@@ -0,0 +1,67 @@
1
+ import { createHash } from "node:crypto";
2
+ import { REST, Routes } from "discord.js";
3
+ import { buildApplicationCommandDefinitions } from "./interactions.js";
4
+ export async function synchroniseApplicationCommands(input) {
5
+ const definitions = buildApplicationCommandDefinitions(input.modules, input.internalGuildId);
6
+ definitions.global.sort((left, right) => left.name.localeCompare(right.name));
7
+ definitions.guild.sort((left, right) => left.name.localeCompare(right.name));
8
+ const rest = input.rest ?? new REST({ version: "10" }).setToken(input.token);
9
+ const globalKey = `commands:${input.applicationId}:global`;
10
+ const globalHash = commandDefinitionsHash(definitions.global);
11
+ const global = await synchroniseCollection({
12
+ key: globalKey,
13
+ hash: globalHash,
14
+ definitions: definitions.global,
15
+ state: input.state,
16
+ update: () => rest.put(Routes.applicationCommands(input.applicationId), {
17
+ body: definitions.global,
18
+ }),
19
+ });
20
+ if (!input.internalGuildId)
21
+ return {
22
+ global,
23
+ guild: "not_configured",
24
+ globalCount: definitions.global.length,
25
+ guildCount: 0,
26
+ globalHash,
27
+ };
28
+ const guildKey = `commands:${input.applicationId}:guild:${input.internalGuildId}`;
29
+ const guildHash = commandDefinitionsHash(definitions.guild);
30
+ const guild = await synchroniseCollection({
31
+ key: guildKey,
32
+ hash: guildHash,
33
+ definitions: definitions.guild,
34
+ state: input.state,
35
+ update: () => rest.put(Routes.applicationGuildCommands(input.applicationId, input.internalGuildId), { body: definitions.guild }),
36
+ });
37
+ return {
38
+ global,
39
+ guild,
40
+ globalCount: definitions.global.length,
41
+ guildCount: definitions.guild.length,
42
+ globalHash,
43
+ guildHash,
44
+ };
45
+ }
46
+ export function commandDefinitionsHash(definitions) {
47
+ return createHash("sha256").update(JSON.stringify(definitions)).digest("hex");
48
+ }
49
+ async function synchroniseCollection(input) {
50
+ const current = await input.state.get(input.key);
51
+ if (isCommandHashState(current?.value, input.hash))
52
+ return "unchanged";
53
+ await input.update();
54
+ await input.state.set(input.key, {
55
+ hash: input.hash,
56
+ commandCount: input.definitions.length,
57
+ synchronisedAt: new Date().toISOString(),
58
+ }, current?.version ?? null);
59
+ return "updated";
60
+ }
61
+ function isCommandHashState(value, expectedHash) {
62
+ return (typeof value === "object" &&
63
+ value !== null &&
64
+ "hash" in value &&
65
+ value.hash === expectedHash);
66
+ }
67
+ //# sourceMappingURL=command-sync.js.map
@@ -2,6 +2,7 @@ import { type DatabasePool } from "@helyx/database";
2
2
  import { type DashboardActionResult, type MessageResponse, type ModuleDefinition, type PrivacyCapturePage, type PrivacyErasePage, type PrivacyProviderExecutor, type ServiceAccess } from "@helyx/sdk";
3
3
  import { type Client } from "discord.js";
4
4
  import { type FastifyInstance } from "fastify";
5
+ import { type ShardHealthSnapshot } from "./shard-contracts.js";
5
6
  interface DashboardPermissionPolicy {
6
7
  preset: "everyone" | "moderators" | "administrators" | "custom";
7
8
  allowedRoleIds: string[];
@@ -161,6 +162,10 @@ export interface BotControlHandler {
161
162
  cursor?: string;
162
163
  }): Promise<PrivacyErasePage>;
163
164
  }
165
+ export declare class BotControlTransportError extends Error {
166
+ readonly status: 400 | 404 | 409 | 502 | 503 | 504;
167
+ constructor(status: 400 | 404 | 409 | 502 | 503 | 504, message: string);
168
+ }
164
169
  export declare class BotControlService implements BotControlHandler {
165
170
  #private;
166
171
  private readonly database;
@@ -236,6 +241,11 @@ export declare function createBotControlServer(input: {
236
241
  logger?: boolean | {
237
242
  level: string;
238
243
  };
244
+ shardHealth?: () => Promise<ShardHealthSnapshot>;
239
245
  }): Promise<FastifyInstance>;
246
+ export declare function mapBotControlError(error: unknown): {
247
+ status: 400 | 404 | 409;
248
+ message: string;
249
+ } | undefined;
240
250
  export {};
241
251
  //# sourceMappingURL=control-server.d.ts.map
@@ -5,6 +5,7 @@ import Fastify, {} from "fastify";
5
5
  import { createHash, timingSafeEqual } from "node:crypto";
6
6
  import { z, ZodError } from "zod";
7
7
  import { deleteChannelMessage as deleteDiscordChannelMessage, sendToChannel as sendDiscordMessageToChannel, } from "./interactions.js";
8
+ import { SHARD_HEALTH_STALE_MS, } from "./shard-contracts.js";
8
9
  const snowflake = z.string().regex(/^\d{17,20}$/u);
9
10
  const moduleId = z.string().regex(/^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)+$/u);
10
11
  const configurationValue = z.record(z.string(), z.union([
@@ -87,6 +88,14 @@ export class DiscordGuildResourceDirectory {
87
88
  return deleteDiscordChannelMessage(this.client, channelId, messageId);
88
89
  }
89
90
  }
91
+ export class BotControlTransportError extends Error {
92
+ status;
93
+ constructor(status, message) {
94
+ super(message);
95
+ this.status = status;
96
+ this.name = "BotControlTransportError";
97
+ }
98
+ }
90
99
  export class BotControlService {
91
100
  database;
92
101
  resources;
@@ -504,8 +513,62 @@ export async function createBotControlServer(input) {
504
513
  if (!timingSafeEqual(expectedSecret, digest(supplied)))
505
514
  return reply.code(401).send({ message: "Control authentication failed" });
506
515
  });
507
- server.get("/health", () => ({ status: "ok" }));
508
- server.get("/v1/health", () => ({ status: "ok" }));
516
+ async function readiness(reply) {
517
+ if (!input.shardHealth)
518
+ return { status: "ok" };
519
+ try {
520
+ const health = await input.shardHealth();
521
+ if (health.status === "healthy")
522
+ return { status: "ok" };
523
+ }
524
+ catch {
525
+ // The public readiness route deliberately returns no internal detail.
526
+ }
527
+ return reply.code(503).send({ status: "unavailable" });
528
+ }
529
+ server.get("/health", (_request, reply) => readiness(reply));
530
+ server.get("/v1/health", (_request, reply) => readiness(reply));
531
+ server.get("/v1/shards/health", async (request, reply) => {
532
+ reply.header("Cache-Control", "no-store");
533
+ if (!input.shardHealth)
534
+ return reply.code(503).send({
535
+ message: "Shard health is unavailable while sharding is off",
536
+ });
537
+ const { cursor, limit } = z
538
+ .object({
539
+ cursor: z.coerce.number().int().min(0).max(4_095).default(0),
540
+ limit: z.coerce.number().int().min(1).max(100).default(50),
541
+ })
542
+ .strict()
543
+ .parse(request.query);
544
+ const health = await input.shardHealth();
545
+ const now = Date.now();
546
+ const shards = health.shards.slice(cursor, cursor + limit);
547
+ const nextCursor = cursor + shards.length < health.shards.length
548
+ ? String(cursor + shards.length)
549
+ : null;
550
+ return {
551
+ mode: health.mode,
552
+ configuredShardCount: health.configuredShardCount,
553
+ readyShardCount: health.readyShardCount,
554
+ observedAt: health.observedAt,
555
+ nextCursor,
556
+ shards: shards.map((shard) => ({
557
+ id: shard.shardId,
558
+ status: now - new Date(shard.observedAt).getTime() > SHARD_HEALTH_STALE_MS
559
+ ? "stale"
560
+ : shard.status === "ready"
561
+ ? "healthy"
562
+ : shard.status === "starting" || shard.status === "reconnecting"
563
+ ? "starting"
564
+ : "degraded",
565
+ guildCount: shard.guildCount,
566
+ gatewayPingMs: shard.gatewayPingMs < 0 ? null : Math.round(shard.gatewayPingMs),
567
+ uptimeSeconds: Math.floor(shard.uptimeSeconds),
568
+ restartCount: shard.restartCount,
569
+ })),
570
+ };
571
+ });
509
572
  server.get("/v1/privacy/providers", () => input.service.listPrivacyProviders());
510
573
  server.post("/v1/privacy/capture", async (request) => {
511
574
  const body = z
@@ -605,22 +668,11 @@ export async function createBotControlServer(input) {
605
668
  return input.service.updateLoggingPolicy({ ...params, ...body });
606
669
  });
607
670
  server.setErrorHandler(async (error, request, reply) => {
608
- if (error instanceof ZodError)
609
- return reply
610
- .code(400)
611
- .send({ message: "The control request was invalid" });
612
- if (error instanceof BotControlValidationError)
613
- return reply.code(400).send({ message: error.message });
614
- if (error instanceof DashboardActionValidationError)
615
- return reply.code(400).send({ message: error.message });
616
- if (error instanceof ModuleStateValidationError)
617
- return reply.code(400).send({ message: error.message });
618
- if (error instanceof BotControlNotFoundError)
619
- return reply.code(404).send({ message: error.message });
620
- if (error instanceof OptimisticConcurrencyError)
621
- return reply.code(409).send({
622
- message: "Configuration changed in another session. Reload and try again.",
623
- });
671
+ const mapped = mapBotControlError(error);
672
+ if (mapped)
673
+ return reply.code(mapped.status).send({ message: mapped.message });
674
+ if (error instanceof BotControlTransportError)
675
+ return reply.code(error.status).send({ message: error.message });
624
676
  const safeError = error instanceof Error
625
677
  ? { name: error.name, message: error.message }
626
678
  : { name: "Error", message: "Unknown control failure" };
@@ -629,6 +681,22 @@ export async function createBotControlServer(input) {
629
681
  });
630
682
  return server;
631
683
  }
684
+ export function mapBotControlError(error) {
685
+ if (error instanceof ZodError)
686
+ return { status: 400, message: "The control request was invalid" };
687
+ if (error instanceof BotControlValidationError ||
688
+ error instanceof DashboardActionValidationError ||
689
+ error instanceof ModuleStateValidationError)
690
+ return { status: 400, message: error.message };
691
+ if (error instanceof BotControlNotFoundError)
692
+ return { status: 404, message: error.message };
693
+ if (error instanceof OptimisticConcurrencyError)
694
+ return {
695
+ status: 409,
696
+ message: "Configuration changed in another session. Reload and try again.",
697
+ };
698
+ return undefined;
699
+ }
632
700
  class BotControlValidationError extends Error {
633
701
  }
634
702
  class BotControlNotFoundError extends Error {