@helyx/bot 0.4.42 → 0.4.43

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,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.43
4
+
5
+ ### Patch Changes
6
+
7
+ - Generalise the hosted Creator OAuth boundary with typed provider registrations,
8
+ neutral versioned consumer context, bounded Creator account slots and scheduled
9
+ cross-database orphan reconciliation without exposing credential material.
10
+ - Complete the Nexus redemption experience with contextual custom-request modals,
11
+ a dedicated protected redemption report including stored unit price, and
12
+ structured Achievement balance amount and currency projections.
13
+ - Report bounded aggregate bot readiness and keep status heartbeat preparation and delivery fail-open under timeouts and failures.
14
+ - Updated dependencies
15
+ - Updated dependencies
16
+ - Updated dependencies
17
+ - @helyx/sdk@0.9.0
18
+ - @helyx/database@0.9.0
19
+ - @helyx/module-manifest@0.8.0
20
+
3
21
  ## 0.4.42
4
22
 
5
23
  ### Patch Changes
package/README.md CHANGED
@@ -38,6 +38,13 @@ When `HELYX_BOT_CONTROL_SECRET` is configured, the bot also starts a private aut
38
38
 
39
39
  Optional Helyx Cloud Connect is a separate outbound management path for self-hosted deployments. It is disabled unless `HELYX_CLOUD_CONNECT_ENABLED=true` and requires a separate local `HELYX_DATA_ENCRYPTION_KEY`. Preserve that key across every restart and deployment. If it is lost or changed, revoke the old installation in the dashboard and use a new one-use code with `/helyx-cloud connect`; the connector stops retrying until that recovery is completed. The owner-only `/helyx-cloud` command supports connection-code enrolment, status, credential rotation, disconnection and bounded support consent. The bot never sends its Discord token, database URL or connector private key to Helyx Cloud, and local Discord operation continues when Cloud is unavailable.
40
40
 
41
+ Production Status reporting is opt-in and disabled by default. Set
42
+ `HELYX_STATUS_ENABLED=true` only with the reviewed `HELYX_STATUS_URL`, the
43
+ bot-specific `HELYX_STATUS_HEARTBEAT_SECRET`,
44
+ `HELYX_STATUS_HEARTBEAT_INTERVAL_SECONDS`, and a bot-specific
45
+ `CF_ACCESS_CLIENT_ID`/`CF_ACCESS_CLIENT_SECRET` pair. Never reuse another
46
+ producer's Access identity or heartbeat secret.
47
+
41
48
  Giveaways also requires the existing `HELYX_DATA_ENCRYPTION_KEY` so core can
42
49
  resume secure weighted-selection checkpoints without exposing the selection
43
50
  key to a module. Keep it stable across deployments; do not rotate it while a
@@ -628,6 +628,10 @@ export class BotControlService {
628
628
  });
629
629
  }
630
630
  async completeFirstTimeCommandSetup(input) {
631
+ for (const moduleIdValue of new Set(input.moduleIds)) {
632
+ this.#requireDashboardModule(moduleIdValue);
633
+ await this.#requireModuleAvailable(moduleIdValue);
634
+ }
631
635
  return completeFirstTimeCommandSetup({
632
636
  installations: this.#installations,
633
637
  dashboardModuleIds: this.#dashboardModules.map(({ id }) => id),
package/dist/events.d.ts CHANGED
@@ -1,6 +1,7 @@
1
- import type { DiscordChannelAudienceInspection, DiscordChannelInspection, DiscordEmojiInspection, DiscordMessageInspection, DiscordGuildCapabilityInspection, DiscordGuildChannelAudienceInspection, DiscordResourceInspectionService, DiscordRoleInspection, DiscordMessageService, MemberEnforcementService, MemberRoleService, ModuleDefinition, ServiceAccess, VerificationMemberService } from "@helyx/sdk";
1
+ import type { DiscordChannelAudienceInspection, DiscordChannelInspection, DiscordEmojiInspection, DiscordMessageInspection, DiscordGuildCapabilityInspection, DiscordGuildChannelAudienceInspection, DiscordResourceInspectionService, DiscordRoleInspection, DiscordMessageService, InstallationService, MemberEnforcementService, MemberRoleService, ModuleDefinition, ServiceAccess, VerificationMemberService } from "@helyx/sdk";
2
2
  import { type Client, type MessageReaction, type PartialMessageReaction } from "discord.js";
3
3
  import { type InteractionLogger } from "./interactions.js";
4
+ export declare function availabilityAwareInstallations(services: ServiceAccess): Pick<InstallationService, "isModuleEnabled">;
4
5
  export declare function installModuleEvents(client: Client, modules: readonly ModuleDefinition[], logger: InteractionLogger, services: ServiceAccess): ModuleEventHandle;
5
6
  export interface ModuleEventHandle {
6
7
  stop(): Promise<void>;
package/dist/events.js CHANGED
@@ -3,7 +3,7 @@ import { ChannelType, Events, GatewayIntentBits, GuildMemberFlags, PermissionFla
3
3
  import { toReplyOptions } from "./interactions.js";
4
4
  import { createHash } from "node:crypto";
5
5
  import { classifyGuildMessage, unavailableGuildMessageClassification, } from "./message-classification.js";
6
- function availabilityAwareInstallations(services) {
6
+ export function availabilityAwareInstallations(services) {
7
7
  const installations = services.get(HELYX_SERVICE_NAMES.installations);
8
8
  const candidate = typeof services.has === "function" &&
9
9
  services.has(HELYX_SERVICE_NAMES.moduleAvailability)
package/dist/index.d.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  import { type ModuleDefinition } from "@helyx/sdk";
2
2
  import { GatewayIntentBits } from "discord.js";
3
+ import { type ShardHealthSnapshot } from "./shard-contracts.js";
3
4
  export { loadConfiguredModulePackages } from "./modules.js";
4
5
  export { resolveModulePackageSpecifiers } from "./module-configuration.js";
5
6
  export * from "./control-server.js";
6
7
  export interface BotHandle {
7
8
  shutdown(reason?: string): Promise<void>;
9
+ statusHealth?(): Promise<ShardHealthSnapshot>;
8
10
  }
9
11
  export declare function startBot(processEnvironment?: NodeJS.ProcessEnv): Promise<BotHandle>;
10
12
  export declare function resolveBotWorkerEntryPoint(entryUrl?: string): string;
package/dist/index.js CHANGED
@@ -26,7 +26,7 @@ import { HttpVerificationService } from "./verification-service.js";
26
26
  import { startShardSupervisor } from "./shard-supervisor.js";
27
27
  import { parseShardWorkerLaunchContract, startShardWorker, } from "./shard-worker.js";
28
28
  import { createDeveloperOperationsModule } from "./developer-operations-module.js";
29
- import { shardHealthSnapshotSchema } from "./shard-contracts.js";
29
+ import { shardHealthSnapshotSchema, } from "./shard-contracts.js";
30
30
  import { createSupportModule } from "./support-module.js";
31
31
  import { createOAuthModule } from "./oauth-module.js";
32
32
  import { createPrivacyModule } from "./privacy-module.js";
@@ -42,12 +42,14 @@ import { PostgresTriviaDataProvider } from "./trivia-data-provider.js";
42
42
  import { createLocalScheduledTaskRoute, createScheduledTaskRunner, schedulerOperationalHealth, } from "./scheduled-task-runner.js";
43
43
  import { createLocalNumericWriteBehind } from "./numeric-write-behind.js";
44
44
  import { discordRestOptions } from "./discord-rest.js";
45
+ import { discordBotStatusService } from "./status-producer.js";
45
46
  export { loadConfiguredModulePackages } from "./modules.js";
46
47
  export { resolveModulePackageSpecifiers } from "./module-configuration.js";
47
48
  export * from "./control-server.js";
48
49
  const bootstrapLogger = pino({ base: { component: "bot" } });
49
50
  const RETENTION_CLEANUP_INTERVAL_MS = 60 * 60 * 1_000;
50
51
  export async function startBot(processEnvironment = process.env) {
52
+ const processStartedAt = new Date().toISOString();
51
53
  if (processEnvironment.HELYX_SHARD_WORKER_CONTRACT !== undefined)
52
54
  return startShardWorker(processEnvironment, parseShardWorkerLaunchContract(processEnvironment.HELYX_SHARD_WORKER_CONTRACT));
53
55
  const environment = parseBotEnvironment(processEnvironment);
@@ -57,7 +59,6 @@ export async function startBot(processEnvironment = process.env) {
57
59
  : await startUnshardedBot(processEnvironment);
58
60
  if (!environment.HELYX_STATUS_ENABLED)
59
61
  return handle;
60
- const processStartedAt = new Date().toISOString();
61
62
  const reporter = new StatusHeartbeatReporter({
62
63
  producer: "bot",
63
64
  baseUrl: environment.HELYX_STATUS_URL,
@@ -65,21 +66,32 @@ export async function startBot(processEnvironment = process.env) {
65
66
  accessClientId: environment.CF_ACCESS_CLIENT_ID,
66
67
  accessClientSecret: environment.CF_ACCESS_CLIENT_SECRET,
67
68
  intervalSeconds: environment.HELYX_STATUS_HEARTBEAT_INTERVAL_SECONDS,
68
- services: () => [
69
- {
70
- key: "discord-bot",
71
- state: "operational",
72
- processStartedAt,
73
- release: readBotVersion(),
74
- },
75
- {
76
- key: "creator-connections",
77
- state: environment.HELYX_OAUTH_ENABLED ? "operational" : "major_outage",
78
- reasonCode: environment.HELYX_OAUTH_ENABLED ? null : "feature_disabled",
79
- processStartedAt,
80
- release: readBotVersion(),
81
- },
82
- ],
69
+ services: async () => {
70
+ const release = readBotVersion();
71
+ const health = await handle.statusHealth?.();
72
+ return [
73
+ health
74
+ ? discordBotStatusService({ health, processStartedAt, release })
75
+ : {
76
+ key: "discord-bot",
77
+ state: "major_outage",
78
+ reasonCode: "runtime_not_ready",
79
+ processStartedAt,
80
+ release,
81
+ },
82
+ {
83
+ key: "creator-connections",
84
+ state: environment.HELYX_OAUTH_ENABLED
85
+ ? "operational"
86
+ : "major_outage",
87
+ reasonCode: environment.HELYX_OAUTH_ENABLED
88
+ ? null
89
+ : "feature_disabled",
90
+ processStartedAt,
91
+ release,
92
+ },
93
+ ];
94
+ },
83
95
  onError(error) {
84
96
  bootstrapLogger.warn({ event: "status.heartbeat_failed", reason: error.message }, "Status heartbeat could not be delivered");
85
97
  },
@@ -226,6 +238,60 @@ async function startUnshardedBot(processEnvironment) {
226
238
  let guildCommandWorker;
227
239
  const unshardedSupervisorId = randomUUID();
228
240
  const deployedCommit = deploymentReference(processEnvironment);
241
+ const buildHealth = async () => {
242
+ let databaseHealthy = true;
243
+ try {
244
+ await checkDatabaseConnection(database);
245
+ }
246
+ catch {
247
+ databaseHealthy = false;
248
+ }
249
+ const ready = client.isReady();
250
+ const scheduler = scheduledTasks
251
+ ? await scheduledTasks
252
+ .getHealth()
253
+ .then(schedulerOperationalHealth)
254
+ .catch(() => ({ status: "unavailable" }))
255
+ : { status: "unavailable" };
256
+ const observedAt = new Date().toISOString();
257
+ const gatewayPingMs = Number.isFinite(client.ws.ping)
258
+ ? client.ws.ping
259
+ : -1;
260
+ return shardHealthSnapshotSchema.parse({
261
+ version: 1,
262
+ mode: "off",
263
+ scope: "local",
264
+ status: ready && databaseHealthy && scheduler.status === "available"
265
+ ? "healthy"
266
+ : ready
267
+ ? "degraded"
268
+ : "starting",
269
+ observedAt,
270
+ generation: "unsharded",
271
+ supervisorId: unshardedSupervisorId,
272
+ configuredShardCount: 1,
273
+ readyShardCount: ready ? 1 : 0,
274
+ coordination: {
275
+ redis: "not_required",
276
+ database: databaseHealthy ? "healthy" : "unavailable",
277
+ },
278
+ scheduler,
279
+ shards: [
280
+ {
281
+ shardId: 0,
282
+ status: ready ? "ready" : "starting",
283
+ guildCount: client.guilds.cache.size,
284
+ gatewayPingMs,
285
+ uptimeSeconds: process.uptime(),
286
+ restartCount: 0,
287
+ eventLoopDelayMs: 0,
288
+ memoryRssBytes: process.memoryUsage().rss,
289
+ queueDepth: 0,
290
+ observedAt,
291
+ },
292
+ ],
293
+ });
294
+ };
229
295
  const operationsModule = environment.DISCORD_INTERNAL_GUILD_ID
230
296
  ? createDeveloperOperationsModule({
231
297
  internalGuildId: environment.DISCORD_INTERNAL_GUILD_ID,
@@ -244,60 +310,7 @@ async function startUnshardedBot(processEnvironment) {
244
310
  processLabel: "unsharded bot process",
245
311
  triviaData: new PostgresTriviaDataProvider(database),
246
312
  health: {
247
- getHealth: async () => {
248
- let databaseHealthy = true;
249
- try {
250
- await checkDatabaseConnection(database);
251
- }
252
- catch {
253
- databaseHealthy = false;
254
- }
255
- const ready = client.isReady();
256
- const scheduler = scheduledTasks
257
- ? await scheduledTasks
258
- .getHealth()
259
- .then(schedulerOperationalHealth)
260
- .catch(() => ({ status: "unavailable" }))
261
- : { status: "unavailable" };
262
- const observedAt = new Date().toISOString();
263
- const gatewayPingMs = Number.isFinite(client.ws.ping)
264
- ? client.ws.ping
265
- : -1;
266
- return shardHealthSnapshotSchema.parse({
267
- version: 1,
268
- mode: "off",
269
- scope: "local",
270
- status: ready && databaseHealthy && scheduler.status === "available"
271
- ? "healthy"
272
- : ready
273
- ? "degraded"
274
- : "starting",
275
- observedAt,
276
- generation: "unsharded",
277
- supervisorId: unshardedSupervisorId,
278
- configuredShardCount: 1,
279
- readyShardCount: ready ? 1 : 0,
280
- coordination: {
281
- redis: "not_required",
282
- database: databaseHealthy ? "healthy" : "unavailable",
283
- },
284
- scheduler,
285
- shards: [
286
- {
287
- shardId: 0,
288
- status: ready ? "ready" : "starting",
289
- guildCount: client.guilds.cache.size,
290
- gatewayPingMs,
291
- uptimeSeconds: process.uptime(),
292
- restartCount: 0,
293
- eventLoopDelayMs: 0,
294
- memoryRssBytes: process.memoryUsage().rss,
295
- queueDepth: 0,
296
- observedAt,
297
- },
298
- ],
299
- });
300
- },
313
+ getHealth: buildHealth,
301
314
  },
302
315
  })
303
316
  : undefined;
@@ -561,7 +574,7 @@ async function startUnshardedBot(processEnvironment) {
561
574
  await client.destroy();
562
575
  throw error;
563
576
  }
564
- return { shutdown };
577
+ return { shutdown, statusHealth: buildHealth };
565
578
  }
566
579
  catch (error) {
567
580
  if (retentionCleanupTimer)
@@ -44,4 +44,5 @@ export declare function normalizeDiscordPermission(permission: string): string;
44
44
  export declare function toReplyOptions(response: MessageResponse): InteractionReplyOptions;
45
45
  export declare function toUpdateOptions(response: MessageResponse): InteractionUpdateOptions;
46
46
  export declare function toModal(definition: ModalDefinition): ModalBuilder;
47
+ export declare function safeInteractionFailureMessage(error: unknown): string;
47
48
  //# sourceMappingURL=interactions.d.ts.map
@@ -1,5 +1,5 @@
1
1
  import { lookup as lookupDns } from "node:dns/promises";
2
- import { HELYX_SERVICE_NAMES } from "@helyx/sdk";
2
+ import { HELYX_SERVICE_NAMES, NexusEconomyError, UserFacingInteractionError, } from "@helyx/sdk";
3
3
  import { canUseCommand } from "@helyx/core";
4
4
  import { commandRegistrationPolicy, contributionRegistrationPolicy, } from "./command-registration-policy.js";
5
5
  import { ActionRowBuilder, ButtonBuilder, ButtonStyle, ChannelSelectMenuBuilder, EmbedBuilder, Events, GuildMember, ApplicationIntegrationType, ChannelType, InteractionContextType, LabelBuilder, MessageFlags, MentionableSelectMenuBuilder, ModalBuilder, RoleSelectMenuBuilder, Routes, StringSelectMenuBuilder, SlashCommandBuilder, TextInputBuilder, TextInputStyle, UserSelectMenuBuilder, TextDisplayBuilder, PermissionFlagsBits, escapeMarkdown, } from "discord.js";
@@ -282,7 +282,7 @@ export function installInteractions(client, modules, registration, logger, servi
282
282
  ...interactionLogContext(interaction),
283
283
  error: safeError(error),
284
284
  }, "Module interaction failed");
285
- await sendFailure(interaction);
285
+ await sendFailure(interaction, error);
286
286
  }
287
287
  }
288
288
  return registrationReady;
@@ -578,6 +578,7 @@ function staleComponentResponse() {
578
578
  function createContext(interaction, services) {
579
579
  return {
580
580
  interactionId: interaction.id,
581
+ interactionCreatedAt: new Date(interaction.createdTimestamp),
581
582
  ...("customId" in interaction ? { customId: interaction.customId } : {}),
582
583
  userId: interaction.user.id,
583
584
  userDisplayName: interaction.user.globalName ?? interaction.user.username,
@@ -739,6 +740,7 @@ function createModalContext(interaction, services) {
739
740
  : undefined;
740
741
  return {
741
742
  interactionId: interaction.id,
743
+ interactionCreatedAt: new Date(interaction.createdTimestamp),
742
744
  customId: interaction.customId,
743
745
  userId: interaction.user.id,
744
746
  userDisplayName: interaction.user.globalName ?? interaction.user.username,
@@ -1289,14 +1291,48 @@ function discordChannelType(type) {
1289
1291
  return ChannelType.GuildCategory;
1290
1292
  return ChannelType.GuildText;
1291
1293
  }
1292
- async function sendFailure(interaction) {
1294
+ export function safeInteractionFailureMessage(error) {
1295
+ if (error instanceof UserFacingInteractionError) {
1296
+ const messages = {
1297
+ nexus_exchange_slow: "The Exchange is taking too long to respond. Try again shortly.",
1298
+ nexus_exchange_item_unavailable: "That Exchange item is not available to you right now.",
1299
+ nexus_exchange_item_changed: "That Exchange item changed or is no longer available. Start again.",
1300
+ nexus_exchange_balance_changed: "Your Nexus balance changed after the preview. Start again.",
1301
+ nexus_exchange_form_expired: "This request form expired. Run /nexus redeem again.",
1302
+ nexus_exchange_form_replayed: "This request form was already submitted. Run /nexus redeem again.",
1303
+ nexus_exchange_preview_expired: "This redemption preview expired. Start the command again.",
1304
+ nexus_exchange_invalid_request: "This Exchange request is invalid. Start the command again.",
1305
+ nexus_exchange_response_required: "This custom request needs a response.",
1306
+ };
1307
+ return messages[error.code];
1308
+ }
1309
+ if (error instanceof NexusEconomyError) {
1310
+ const messages = {
1311
+ invalid_input: "This Exchange request is invalid. Start again.",
1312
+ module_disabled: "Nexus Economy is not enabled for this server.",
1313
+ item_unavailable: "That Exchange item is not available to you right now.",
1314
+ ineligible: "You are not eligible to redeem that Exchange item.",
1315
+ insufficient_stock: "There is not enough stock for this redemption.",
1316
+ limit_reached: "You have reached this Exchange item's redemption limit.",
1317
+ cooldown_active: "That Exchange item is still on cooldown for your account.",
1318
+ already_owned: "You already own that non-stackable Exchange item.",
1319
+ insufficient_balance: "Your Nexus balance is too low for this item.",
1320
+ operation_conflict: "This redemption was already handled or changed. Start again.",
1321
+ privacy_erased: "This Nexus account cannot redeem items after privacy erasure.",
1322
+ };
1323
+ return (messages[error.code] ??
1324
+ "Something went wrong while handling that interaction.");
1325
+ }
1326
+ return "Something went wrong while handling that interaction.";
1327
+ }
1328
+ async function sendFailure(interaction, error) {
1293
1329
  if (interaction.isAutocomplete()) {
1294
1330
  await interaction.respond([]).catch(() => undefined);
1295
1331
  return;
1296
1332
  }
1297
1333
  if (!interaction.isRepliable())
1298
1334
  return;
1299
- const message = "Something went wrong while handling that interaction.";
1335
+ const message = safeInteractionFailureMessage(error);
1300
1336
  if (interaction.replied || interaction.deferred) {
1301
1337
  await interaction.editReply({ content: message }).catch(() => undefined);
1302
1338
  return;
@@ -1,4 +1,4 @@
1
- import type { OAuthConnectionSummary, OAuthEligibilityResult, OAuthService } from "@helyx/sdk";
1
+ import type { OAuthConnectionSummary, OAuthConnectionIdentity, OAuthEligibilityResult, OAuthService } from "@helyx/sdk";
2
2
  export declare class HttpOAuthService implements OAuthService {
3
3
  #private;
4
4
  private readonly internalSecret;
@@ -8,7 +8,8 @@ export declare class HttpOAuthService implements OAuthService {
8
8
  }): Promise<readonly OAuthConnectionSummary[]>;
9
9
  connectionStatus(input: {
10
10
  discordUserId: string;
11
- }): Promise<readonly ("youtube" | "twitch")[]>;
11
+ purpose?: "creator";
12
+ }): Promise<readonly OAuthConnectionIdentity[]>;
12
13
  createAuthorization(input: Parameters<OAuthService["createAuthorization"]>[0]): ReturnType<OAuthService["createAuthorization"]>;
13
14
  verifyEligibility(input: Parameters<OAuthService["verifyEligibility"]>[0]): Promise<OAuthEligibilityResult>;
14
15
  revoke(input: Parameters<OAuthService["revoke"]>[0]): ReturnType<OAuthService["revoke"]>;
@@ -11,7 +11,7 @@ export class HttpOAuthService {
11
11
  }
12
12
  async connectionStatus(input) {
13
13
  const result = await this.#request("/v1/internal/oauth/status", input);
14
- return result.providers;
14
+ return result.connections;
15
15
  }
16
16
  createAuthorization(input) {
17
17
  return this.#request("/v1/internal/oauth/authorizations", input);
@@ -447,7 +447,7 @@ export async function startShardSupervisor(environment, processEnvironment, work
447
447
  shardCount,
448
448
  localShardCount: claimedIds.length,
449
449
  }, "Shard supervisor started");
450
- return { shutdown };
450
+ return { shutdown, statusHealth: buildHealth };
451
451
  }
452
452
  catch (error) {
453
453
  await shutdown("startup-failed");
@@ -0,0 +1,13 @@
1
+ import type { StatusHeartbeatService } from "@helyx/status-client";
2
+ import type { ShardHealthSnapshot } from "./shard-contracts.js";
3
+ /**
4
+ * Reduce the private shard-health contract to the bounded state accepted by the
5
+ * public status service. No shard IDs, guild counts, host details or errors
6
+ * leave the bot process.
7
+ */
8
+ export declare function discordBotStatusService(input: {
9
+ health: ShardHealthSnapshot;
10
+ processStartedAt: string;
11
+ release: string;
12
+ }): StatusHeartbeatService;
13
+ //# sourceMappingURL=status-producer.d.ts.map
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Reduce the private shard-health contract to the bounded state accepted by the
3
+ * public status service. No shard IDs, guild counts, host details or errors
4
+ * leave the bot process.
5
+ */
6
+ export function discordBotStatusService(input) {
7
+ const health = input.health;
8
+ const dependencyUnavailable = health.coordination.database === "unavailable" ||
9
+ health.coordination.redis === "unavailable";
10
+ const noReadyWorkers = health.configuredShardCount > 0 && health.readyShardCount === 0;
11
+ const runtimeUnavailable = health.status === "unavailable" || dependencyUnavailable || noReadyWorkers;
12
+ const fullyReady = health.status === "healthy" &&
13
+ health.readyShardCount === health.configuredShardCount &&
14
+ health.scheduler?.status === "available" &&
15
+ !dependencyUnavailable;
16
+ return {
17
+ key: "discord-bot",
18
+ state: runtimeUnavailable
19
+ ? "major_outage"
20
+ : fullyReady
21
+ ? "operational"
22
+ : "degraded",
23
+ reasonCode: runtimeUnavailable
24
+ ? dependencyUnavailable
25
+ ? "dependency_unavailable"
26
+ : "runtime_not_ready"
27
+ : fullyReady
28
+ ? null
29
+ : "runtime_not_ready",
30
+ processStartedAt: input.processStartedAt,
31
+ release: input.release,
32
+ };
33
+ }
34
+ //# sourceMappingURL=status-producer.js.map
@@ -11,11 +11,29 @@ database, the reviewed core packages, and independently selected modules. Keep
11
11
  module boundaries intact: a module must be installable, configurable, testable,
12
12
  and removable without relying on unrelated module behaviour.
13
13
 
14
+ Every selectable module runs from that generated bot and its normal local
15
+ runtime. A self-host-compatible module does not require a provider API key,
16
+ OAuth application or separate third-party integration service. Provider-backed
17
+ features are Hosted-only and excluded from the generated project. The Helyx
18
+ Dashboard and optional Cloud Connect are the explicit hosted-service exceptions;
19
+ local Discord module work continues if either is unavailable.
20
+
14
21
  Helyx supports Docker Compose and direct Node.js operation. The generated
15
22
  project is the source of truth for its pinned public packages and module
16
23
  catalogue. Do not copy source trees from the Helyx repository into an
17
24
  installation.
18
25
 
26
+ Every implemented catalogue entry carries a manifest-backed deployment label
27
+ and credential-risk label. A planned entry without a canonical v0.4 manifest
28
+ is explicitly `unclassified`; that label makes no promise about its future
29
+ deployment support and it cannot be selected. The builder accepts only Live
30
+ modules whose
31
+ `deploymentSupport` is `self-hosted` or `both`, copies that policy into the
32
+ downloaded build manifest and resolves only eligible dependencies. The
33
+ dashboard shows the deployment and credential label beside each selectable
34
+ module. A `hosted` module cannot be selected or introduced by editing the
35
+ downloaded manifest.
36
+
19
37
  ## Secrets and access
20
38
 
21
39
  - Store `.env` outside version control and restrict who can read it.
@@ -30,6 +30,42 @@ must exactly match executable command contributions. Declare Discord
30
30
  permissions and privileged capabilities only when the module actually needs
31
31
  them.
32
32
 
33
+ ## Deployment and credential policy
34
+
35
+ Use manifest version `0.4` and make deployment eligibility explicit:
36
+
37
+ - `deploymentSupport` is `hosted`, `self-hosted` or `both`;
38
+ - `credentialRisk` is `none`, `low`, `medium`, `high` or `very-high`; and
39
+ - `deploymentReason` is a short internal explanation with no secret values.
40
+
41
+ The classification is a release decision, not a runtime guess. A hosted-only
42
+ manifest is rejected from self-host selection and generated projects. A
43
+ self-host-only manifest is rejected from hosted composition. The separate
44
+ hosted-only package list remains a defence-in-depth check and must agree with
45
+ the canonical manifest.
46
+
47
+ Self-host-compatible modules must be complete within the generated bot and
48
+ must not require a provider credential or separate integration service. If a
49
+ future design changes that boundary, review the deployment and credential risk
50
+ before implementation; do not weaken the manifest to make composition pass.
51
+
52
+ ## Commands and dashboard policy
53
+
54
+ Normal customer-module commands use
55
+ `registrationPolicy: "enabled-guild"`, server context and guild installation.
56
+ Helyx projects those commands only into servers that select the module; a
57
+ module change may remain pending while the per-server Discord command update
58
+ is coalesced. Do not use the legacy `registration: "global" | "guild"` field
59
+ in new code. `platform-global`, `deployment` and `internal-guild` are reserved
60
+ for their explicit platform, deployment or Helyx operational purposes.
61
+
62
+ Use manifest-defined settings, dashboard actions and managed resources before
63
+ requesting a custom interface. Keep Permissions available for module-level and
64
+ resource access even when the module has no commands; command overrides exist
65
+ only for real commands. Present record details as labelled sections, fields,
66
+ lists and tables rather than serialized JSON, and provide bounded loading,
67
+ empty, disabled, conflict and failure states.
68
+
33
69
  ## Preserve modularity
34
70
 
35
71
  A module owns its commands, configuration, data, migrations, health, and tests.
@@ -21,6 +21,11 @@ boundary values, wrong types, and missing required settings against the
21
21
  module's configuration contribution. Each dashboard setting key must have one
22
22
  matching validator field.
23
23
 
24
+ For resources and actions, test declared operations, permissions, status
25
+ gates, revisions, pagination and safe errors. Verify normal, empty, disabled,
26
+ conflict and failed states, and confirm that read-only details expose labelled
27
+ values rather than raw JSON.
28
+
24
29
  ## Slash commands
25
30
 
26
31
  Assert that manifest command IDs and executable command names are identical.
@@ -29,6 +34,19 @@ generated application-command payload, and execute the handler with a fake SDK
29
34
  context. Test acknowledgement, context, integration type, option limits, and a
30
35
  representative response without contacting Discord.
31
36
 
37
+ Assert the explicit registration policy too. A normal customer-module command
38
+ uses `enabled-guild`; new code must not use the legacy `registration` field.
39
+ Test that disabled-module and denied-policy execution is rejected even while a
40
+ stale command may still be visible during Discord reconciliation.
41
+
42
+ ## Privacy and multi-worker behaviour
43
+
44
+ Test the privacy declaration and executable provider together. A provider must
45
+ find matching retained records even when its module is disabled, and a missing
46
+ or failed provider must not be interpreted as an empty result. Stateful
47
+ modules also test duplicate delivery, concurrent claims, retry, restart and
48
+ stale-worker behaviour where those failures are possible.
49
+
32
50
  ## Before packaging
33
51
 
34
52
  Run the module's build and test scripts, then inspect exactly what npm would
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@helyx/bot",
3
- "version": "0.4.42",
3
+ "version": "0.4.43",
4
4
  "description": "Production Discord runtime for the modular Helyx platform.",
5
5
  "keywords": [
6
6
  "helyx",
@@ -53,11 +53,11 @@
53
53
  "@helyx/config": ">=0.5.0 <0.6.0",
54
54
  "@helyx/control-protocol": ">=0.4.0 <0.5.0",
55
55
  "@helyx/core": ">=0.1.0 <0.2.0",
56
- "@helyx/database": ">=0.8.6 <0.9.0",
56
+ "@helyx/database": ">=0.9.0 <0.10.0",
57
57
  "@helyx/framework": ">=0.1.0 <0.2.0",
58
- "@helyx/module-manifest": ">=0.7.0 <0.8.0",
58
+ "@helyx/module-manifest": ">=0.8.0 <0.9.0",
59
59
  "@helyx/platform": ">=0.6.2 <0.7.0",
60
- "@helyx/sdk": ">=0.8.4 <0.9.0",
60
+ "@helyx/sdk": ">=0.9.0 <0.10.0",
61
61
  "@helyx/status-client": ">=0.1.0 <0.2.0",
62
62
  "@helyx/verification-contracts": ">=0.3.0 <0.4.0",
63
63
  "discord.js": "14.27.0",