@helyx/bot 1.3.1 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/CHANGELOG.md +100 -28
  2. package/README.md +65 -11
  3. package/dist/channel-audit-configuration.d.ts +7 -0
  4. package/dist/channel-audit-configuration.js +40 -0
  5. package/dist/channel-audit-control.d.ts +14 -0
  6. package/dist/channel-audit-control.js +61 -0
  7. package/dist/channel-audit-permissions.d.ts +56 -0
  8. package/dist/channel-audit-permissions.js +417 -0
  9. package/dist/channel-audit-routes.d.ts +4 -0
  10. package/dist/channel-audit-routes.js +42 -0
  11. package/dist/cloud-connector.d.ts +11 -4
  12. package/dist/cloud-connector.js +54 -15
  13. package/dist/cloud-control-dispatcher.d.ts +8 -3
  14. package/dist/cloud-control-dispatcher.js +84 -4
  15. package/dist/control-errors.d.ts +12 -0
  16. package/dist/control-errors.js +32 -0
  17. package/dist/control-server-schemas.d.ts +84 -0
  18. package/dist/control-server-schemas.js +101 -0
  19. package/dist/control-server.d.ts +25 -8
  20. package/dist/control-server.js +101 -152
  21. package/dist/core-retention.d.ts +26 -0
  22. package/dist/core-retention.js +49 -0
  23. package/dist/creator-guild-join-service.d.ts +25 -0
  24. package/dist/creator-guild-join-service.js +218 -0
  25. package/dist/creator-invite-service.d.ts +37 -0
  26. package/dist/creator-invite-service.js +686 -0
  27. package/dist/creator-onboarding-control.d.ts +12 -0
  28. package/dist/creator-onboarding-control.js +65 -0
  29. package/dist/creator-onboarding-platform.d.ts +20 -0
  30. package/dist/creator-onboarding-platform.js +58 -0
  31. package/dist/dashboard-action-control.d.ts +2 -2
  32. package/dist/dashboard-action-control.js +2 -3
  33. package/dist/database-bootstrap.d.ts +36 -0
  34. package/dist/database-bootstrap.js +101 -0
  35. package/dist/developer-operations-module.d.ts +1 -0
  36. package/dist/developer-operations-module.js +34 -16
  37. package/dist/discord-channel-audit.d.ts +9 -0
  38. package/dist/discord-channel-audit.js +195 -0
  39. package/dist/discord-guild-health.d.ts +2 -2
  40. package/dist/discord-owned-message-service.d.ts +1 -0
  41. package/dist/discord-owned-message-service.js +3 -0
  42. package/dist/discord-role-safety.d.ts +4 -0
  43. package/dist/discord-role-safety.js +30 -0
  44. package/dist/events.d.ts +2 -0
  45. package/dist/events.js +5 -20
  46. package/dist/guild-command-read-service.d.ts +8 -3
  47. package/dist/guild-command-read-service.js +14 -7
  48. package/dist/guild-command-release.d.ts +15 -8
  49. package/dist/guild-command-release.js +10 -3
  50. package/dist/guild-command-worker.d.ts +17 -4
  51. package/dist/guild-command-worker.js +17 -6
  52. package/dist/guilds.d.ts +24 -3
  53. package/dist/guilds.js +185 -64
  54. package/dist/hosted-server-moderation.js +10 -4
  55. package/dist/index.d.ts +1 -0
  56. package/dist/index.js +200 -82
  57. package/dist/interactions.js +16 -3
  58. package/dist/module-configuration.d.ts +1 -1
  59. package/dist/module-configuration.js +5 -2
  60. package/dist/module-logging.d.ts +8 -3
  61. package/dist/module-logging.js +13 -7
  62. package/dist/modules.d.ts +1 -0
  63. package/dist/modules.js +8 -1
  64. package/dist/mysql-runtime-persistence.d.ts +46 -0
  65. package/dist/mysql-runtime-persistence.js +91 -0
  66. package/dist/oauth-module.js +2 -2
  67. package/dist/oauth-service.d.ts +7 -0
  68. package/dist/oauth-service.js +3 -0
  69. package/dist/scheduled-task-runner.d.ts +9 -85
  70. package/dist/scheduled-task-runner.js +1 -1
  71. package/dist/shard-contracts.d.ts +222 -0
  72. package/dist/shard-contracts.js +52 -1
  73. package/dist/shard-router.d.ts +4 -1
  74. package/dist/shard-router.js +19 -0
  75. package/dist/shard-supervisor.js +43 -9
  76. package/dist/shard-worker.d.ts +5 -2
  77. package/dist/shard-worker.js +41 -5
  78. package/dist/supervisor-cloud-directory.d.ts +9 -3
  79. package/dist/supervisor-cloud-directory.js +8 -12
  80. package/dist/trivia-data-provider.d.ts +11 -0
  81. package/dist/trivia-data-provider.js +46 -26
  82. package/dist/verification-service.d.ts +2 -2
  83. package/dist/verification-service.js +6 -3
  84. package/documentation/README.md +5 -3
  85. package/documentation/examples/.env.example +15 -11
  86. package/documentation/guides/getting-started.md +29 -12
  87. package/documentation/guides/privacy-and-support.md +6 -5
  88. package/documentation/guides/self-hosting.md +16 -11
  89. package/documentation/modules/auto-moderation.md +7 -5
  90. package/documentation/modules/building-a-module.md +48 -2
  91. package/documentation/modules/moderation.md +5 -5
  92. package/documentation/modules/publishing-a-module.md +6 -4
  93. package/documentation/modules/testing-a-module.md +39 -4
  94. package/documentation/reference/environment.md +43 -9
  95. package/documentation/runbooks/backup-and-recovery.md +26 -7
  96. package/documentation/runbooks/installation-and-upgrades.md +97 -27
  97. package/package.json +20 -16
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
- import { CLOUD_MAX_RESOURCE_IMPORT_BYTES } from "@helyx/control-protocol";
2
+ import { channelAuditModuleIdSchema, channelAuditInputSchema, channelAuditConfigurationSchema, channelAuditReportSchema, } from "@helyx/control-protocol/channel-audit";
3
+ import { CLOUD_MAX_RESOURCE_IMPORT_BYTES, creatorOnboardingRequestSchema, } from "@helyx/control-protocol";
3
4
  import { AUTOMOD_SCANNER_LIMITS } from "@helyx/sdk";
4
5
  import { signedAssessmentEnvelopeSchema } from "@helyx/verification-contracts";
5
6
  export const SHARD_CONTRACT_VERSION = 1;
@@ -254,6 +255,14 @@ export const shardHealthSnapshotSchema = z
254
255
  });
255
256
  });
256
257
  export const shardControlOperationSchema = z.discriminatedUnion("kind", [
258
+ z
259
+ .object({
260
+ kind: z.literal("creator.onboarding"),
261
+ guildId: snowflake,
262
+ input: creatorOnboardingRequestSchema,
263
+ })
264
+ .strict()
265
+ .refine((value) => value.guildId === value.input.guildId),
257
266
  z
258
267
  .object({
259
268
  kind: z.literal("automod.enhanced.configuration.page"),
@@ -347,6 +356,23 @@ export const shardControlOperationSchema = z.discriminatedUnion("kind", [
347
356
  ...mutationContext.shape,
348
357
  })
349
358
  .strict(),
359
+ z
360
+ .object({
361
+ kind: z.literal("guild.module.channel-audit.config.read"),
362
+ guildId: snowflake,
363
+ moduleId: channelAuditModuleIdSchema,
364
+ ...mutationContext.shape,
365
+ })
366
+ .strict(),
367
+ z
368
+ .object({
369
+ kind: z.literal("guild.module.channel-audit.check"),
370
+ guildId: snowflake,
371
+ moduleId: channelAuditModuleIdSchema,
372
+ assumptions: channelAuditInputSchema,
373
+ ...mutationContext.shape,
374
+ })
375
+ .strict(),
350
376
  z
351
377
  .object({
352
378
  kind: z.literal("guild.module.configuration.update"),
@@ -1109,6 +1135,27 @@ export async function executeShardControlOperation(handler, operation) {
1109
1135
  correlationId: operation.correlationId,
1110
1136
  });
1111
1137
  }
1138
+ case "guild.module.channel-audit.config.read": {
1139
+ if (!handler.readChannelAuditConfiguration)
1140
+ throw new Error("Channel audit is not supported by this shard version");
1141
+ return channelAuditConfigurationSchema.parse(await handler.readChannelAuditConfiguration({
1142
+ guildId: operation.guildId,
1143
+ moduleId: operation.moduleId,
1144
+ actorUserId: operation.actorUserId,
1145
+ correlationId: operation.correlationId,
1146
+ }));
1147
+ }
1148
+ case "guild.module.channel-audit.check": {
1149
+ if (!handler.checkChannelAudit)
1150
+ throw new Error("Channel audit is not supported by this shard version");
1151
+ return channelAuditReportSchema.parse(await handler.checkChannelAudit({
1152
+ guildId: operation.guildId,
1153
+ moduleId: operation.moduleId,
1154
+ actorUserId: operation.actorUserId,
1155
+ correlationId: operation.correlationId,
1156
+ assumptions: operation.assumptions,
1157
+ }));
1158
+ }
1112
1159
  case "guild.module.configuration.update": {
1113
1160
  return handler.updateConfiguration({
1114
1161
  guildId: operation.guildId,
@@ -1273,6 +1320,10 @@ export async function executeShardControlOperation(handler, operation) {
1273
1320
  ? { actorAuthenticatedAt: operation.actorAuthenticatedAt }
1274
1321
  : {}),
1275
1322
  });
1323
+ case "creator.onboarding":
1324
+ if (!handler.creatorOnboarding)
1325
+ throw new Error("Creator onboarding is unavailable");
1326
+ return handler.creatorOnboarding(operation.input);
1276
1327
  case "oauth.consumer.cleanup":
1277
1328
  return handler.cleanupOAuthConsumer({
1278
1329
  consumerBindingId: operation.consumerBindingId,
@@ -1,4 +1,4 @@
1
- import { type AutoModerationEnhancedResultOutcome } from "@helyx/sdk";
1
+ import { type AutoModerationEnhancedResultOutcome, type CreatorOnboardingRequest, type CreatorOnboardingResponse } from "@helyx/sdk";
2
2
  import { type BotControlHandler } from "./control-server.js";
3
3
  import { type ShardControlRequest, type ShardControlResponse } from "./shard-contracts.js";
4
4
  import type { ShardCoordinator, ShardLeaderLease, ShardLease } from "./shard-coordinator.js";
@@ -8,6 +8,7 @@ type LocalDispatcher = (shardId: number, request: ShardControlRequest) => Promis
8
8
  export declare class ShardControlRouter implements BotControlHandler {
9
9
  #private;
10
10
  private readonly input;
11
+ creatorOnboarding(input: CreatorOnboardingRequest): Promise<CreatorOnboardingResponse>;
11
12
  constructor(input: {
12
13
  generation: string;
13
14
  shardCount: number;
@@ -47,6 +48,8 @@ export declare class ShardControlRouter implements BotControlHandler {
47
48
  listGuildPresence(input: Parameters<BotControlHandler["listGuildPresence"]>[0]): ReturnType<BotControlHandler["listGuildPresence"]>;
48
49
  setModuleStatus(input: Parameters<BotControlHandler["setModuleStatus"]>[0]): ReturnType<BotControlHandler["setModuleStatus"]>;
49
50
  completeFirstTimeCommandSetup(input: Parameters<BotControlHandler["completeFirstTimeCommandSetup"]>[0]): ReturnType<BotControlHandler["completeFirstTimeCommandSetup"]>;
51
+ readChannelAuditConfiguration(input: Parameters<NonNullable<BotControlHandler["readChannelAuditConfiguration"]>>[0]): ReturnType<NonNullable<BotControlHandler["readChannelAuditConfiguration"]>>;
52
+ checkChannelAudit(input: Parameters<NonNullable<BotControlHandler["checkChannelAudit"]>>[0]): ReturnType<NonNullable<BotControlHandler["checkChannelAudit"]>>;
50
53
  updateConfiguration(input: Parameters<BotControlHandler["updateConfiguration"]>[0]): ReturnType<BotControlHandler["updateConfiguration"]>;
51
54
  previewConfiguration(input: Parameters<BotControlHandler["previewConfiguration"]>[0]): ReturnType<BotControlHandler["previewConfiguration"]>;
52
55
  updatePermissions(input: Parameters<BotControlHandler["updatePermissions"]>[0]): ReturnType<BotControlHandler["updatePermissions"]>;
@@ -11,6 +11,13 @@ const LEASE_VALIDATION_TIMEOUT_MS = 3_000;
11
11
  const ECKO_RESULT_TIMEOUT_MS = AUTOMOD_SCANNER_LIMITS.resultAcknowledgementSeconds * 1_000;
12
12
  export class ShardControlRouter {
13
13
  input;
14
+ creatorOnboarding(input) {
15
+ return this.#route({
16
+ kind: "creator.onboarding",
17
+ guildId: input.guildId,
18
+ input,
19
+ });
20
+ }
14
21
  constructor(input) {
15
22
  this.input = input;
16
23
  }
@@ -110,6 +117,18 @@ export class ShardControlRouter {
110
117
  expectedRevision: input.expectedRevision.toString(),
111
118
  });
112
119
  }
120
+ readChannelAuditConfiguration(input) {
121
+ return this.#route(shardControlOperationSchema.parse({
122
+ kind: "guild.module.channel-audit.config.read",
123
+ ...input,
124
+ }));
125
+ }
126
+ checkChannelAudit(input) {
127
+ return this.#route(shardControlOperationSchema.parse({
128
+ kind: "guild.module.channel-audit.check",
129
+ ...input,
130
+ }));
131
+ }
113
132
  updateConfiguration(input) {
114
133
  return this.#route(shardControlOperationSchema.parse({
115
134
  kind: "guild.module.configuration.update",
@@ -1,5 +1,6 @@
1
1
  import { safeError, ServiceRegistry } from "@helyx/core";
2
- import { checkDatabaseConnection, createDatabasePool, AuditRepository, GuildCommandRepository, HostedServerSafetyRepository, InteractionSessionRepository, EckoOperationsRepository, PremiumAllocationRepository, RateLimitRepository, runConfiguredModuleMigrations, runCoreMigrations, RuntimeStateRepository, } from "@helyx/database";
2
+ import { checkDatabaseConnection, createDatabasePool, AuditRepository, GuildCommandRepository, HostedServerSafetyRepository, InstallationRepository, inspectConfiguredModuleMigrations, inspectCoreMigrations, inspectCreatorOnboardingMigrations, runCreatorOnboardingMigrations, InteractionSessionRepository, EckoOperationsRepository, PremiumAllocationRepository, RateLimitRepository, runConfiguredModuleMigrations, runCoreMigrations, RuntimeStateRepository, } from "@helyx/database";
3
+ import { assertRepositoryEngineSupported } from "@helyx/database/adapters";
3
4
  import { createPlatformServices, gamingStatisticsRecordOwner, achievementContributionRecordOwner, PostgresPrivacyProviderExecutor, } from "@helyx/platform";
4
5
  import { AUTOMOD_ENHANCED_CONTRACT_VERSION, AUTOMOD_ENHANCED_PROVIDER_ID, HELYX_SERVICE_NAMES, } from "@helyx/sdk";
5
6
  import { Client, ShardingManager } from "discord.js";
@@ -102,6 +103,7 @@ function sameNumericIdentity(left, right) {
102
103
  left.fencingToken === right.fencingToken);
103
104
  }
104
105
  export async function startShardSupervisor(environment, processEnvironment, workerEntry) {
106
+ assertRepositoryEngineSupported(environment.DATABASE_ENGINE, ["postgresql"], "Bot shard supervisor");
105
107
  if (environment.HELYX_SHARD_MODE === "off")
106
108
  throw new Error("Shard supervisor requires sharding to be enabled");
107
109
  const shardMode = environment.HELYX_SHARD_MODE;
@@ -185,7 +187,7 @@ export async function startShardSupervisor(environment, processEnvironment, work
185
187
  maxConcurrency: gateway.session_start_limit.max_concurrency,
186
188
  observedAt: new Date().toISOString(),
187
189
  };
188
- const supervisorDirectory = new SupervisorCloudDiscordDirectory(environment.DISCORD_TOKEN, database, environment.HELYX_OWNER_USER_IDS);
190
+ const supervisorDirectory = new SupervisorCloudDiscordDirectory(environment.DISCORD_TOKEN, new InstallationRepository(database), environment.HELYX_OWNER_USER_IDS);
189
191
  const eckoOperations = new EckoOperationsRepository(database);
190
192
  cloudConnector = new CloudConnectorController({
191
193
  environment,
@@ -255,6 +257,7 @@ export async function startShardSupervisor(environment, processEnvironment, work
255
257
  internalGuildId: environment.DISCORD_INTERNAL_GUILD_ID,
256
258
  approvedRoleIds: environment.APPROVED_ROLES,
257
259
  botVersion: botVersion(),
260
+ databaseEngine: environment.DATABASE_ENGINE,
258
261
  ...(processEnvironment.RAILWAY_GIT_COMMIT_SHA
259
262
  ? {
260
263
  deploymentReference: processEnvironment.RAILWAY_GIT_COMMIT_SHA.slice(0, 12),
@@ -282,6 +285,7 @@ export async function startShardSupervisor(environment, processEnvironment, work
282
285
  ...(operationsModule ? [operationsModule] : []),
283
286
  ];
284
287
  commandModules = modules;
288
+ const guildCommandReads = new GuildCommandReadService(database, environment.DISCORD_CLIENT_ID, () => guildCommandWorker?.getHealth(), environment.DISCORD_TOKEN, environment.HELYX_GUILD_COMMAND_WRITES_ENABLED, environment.HELYX_GUILD_COMMAND_WRITE_GUILD_IDS, environment.HELYX_GUILD_COMMAND_WRITE_SCOPE === "all", commandModules);
285
289
  const availabilityModules = availabilityCatalogueDefinitions(modules);
286
290
  const statusModules = statusCatalogueDefinitions(featureModules);
287
291
  let runtimeAvailability;
@@ -328,7 +332,7 @@ export async function startShardSupervisor(environment, processEnvironment, work
328
332
  notificationGuildId: environment.DISCORD_INTERNAL_GUILD_ID,
329
333
  }
330
334
  : {}),
331
- }));
335
+ }), guildCommandReads);
332
336
  await startLeaderSingletons();
333
337
  assertStartupLeaseHealthy();
334
338
  };
@@ -425,7 +429,7 @@ export async function startShardSupervisor(environment, processEnvironment, work
425
429
  : {}),
426
430
  });
427
431
  if (!leaderLease)
428
- cloudConnector.setControlHandler(router);
432
+ cloudConnector.setControlHandler(router, guildCommandReads);
429
433
  const claimedIds = await claimInitialShards();
430
434
  manager = new ShardingManager(workerEntry, {
431
435
  totalShards: shardCount,
@@ -480,7 +484,7 @@ export async function startShardSupervisor(environment, processEnvironment, work
480
484
  },
481
485
  shardHealth: buildHealth,
482
486
  readiness: buildDeploymentReadiness,
483
- guildCommands: new GuildCommandReadService(database, environment.DISCORD_CLIENT_ID, () => guildCommandWorker?.getHealth(), environment.DISCORD_TOKEN, environment.HELYX_GUILD_COMMAND_WRITES_ENABLED, environment.HELYX_GUILD_COMMAND_WRITE_GUILD_IDS, environment.HELYX_GUILD_COMMAND_WRITE_SCOPE === "all", commandModules),
487
+ guildCommands: guildCommandReads,
484
488
  ...(ecko ? { ecko } : {}),
485
489
  });
486
490
  await controlServer.listen({
@@ -1529,12 +1533,31 @@ export function splitNumericCommittedBatch(batch, index) {
1529
1533
  };
1530
1534
  }
1531
1535
  async function runLeaderStartup(database, environment, modulePackages, modules, logger) {
1532
- await runCoreMigrations(database);
1533
- await runConfiguredModuleMigrations(database, modulePackages.map(({ definition, packageRoot }) => ({
1536
+ const moduleMigrationSources = modulePackages.map(({ definition, packageRoot }) => ({
1534
1537
  moduleId: definition.manifest.id,
1535
1538
  moduleVersion: definition.manifest.version,
1536
1539
  migrationsDirectory: join(packageRoot, "migrations"),
1537
- })));
1540
+ }));
1541
+ if (environment.HELYX_RUNTIME_PROFILE === "hosted" &&
1542
+ modulePackages.some(({ definition }) => definition.manifest.id === "helyx.creator-connections")) {
1543
+ if (environment.HELYX_DATABASE_STARTUP_MODE === "inspect") {
1544
+ const inspection = await inspectCreatorOnboardingMigrations(database);
1545
+ assertNoPendingLeaderMigrations("hosted creator onboarding", inspection.pending);
1546
+ }
1547
+ else
1548
+ await runCreatorOnboardingMigrations(database);
1549
+ }
1550
+ if (environment.HELYX_DATABASE_STARTUP_MODE === "inspect") {
1551
+ const coreInspection = await inspectCoreMigrations(database);
1552
+ assertNoPendingLeaderMigrations("core", coreInspection.pending);
1553
+ const moduleInspections = await inspectConfiguredModuleMigrations(database, moduleMigrationSources);
1554
+ for (const inspection of moduleInspections)
1555
+ assertNoPendingLeaderMigrations(`module ${inspection.moduleId}`, inspection.pending);
1556
+ }
1557
+ else {
1558
+ await runCoreMigrations(database);
1559
+ await runConfiguredModuleMigrations(database, moduleMigrationSources);
1560
+ }
1538
1561
  await enforcePremiumCatalogue(database, logger);
1539
1562
  const repository = new RuntimeStateRepository(database);
1540
1563
  await synchroniseApplicationCommands({
@@ -1545,7 +1568,8 @@ async function runLeaderStartup(database, environment, modulePackages, modules,
1545
1568
  ...(environment.DISCORD_INTERNAL_GUILD_ID
1546
1569
  ? { internalGuildId: environment.DISCORD_INTERNAL_GUILD_ID }
1547
1570
  : {}),
1548
- synchroniseInternalGuild: !environment.HELYX_GUILD_COMMAND_WRITES_ENABLED,
1571
+ // Worker-owned guild collections stay untouched while command writes are off.
1572
+ synchroniseInternalGuild: false,
1549
1573
  state: new RuntimeCommandHashStore(repository),
1550
1574
  });
1551
1575
  await setRuntimeState(repository, `shards:${environment.HELYX_SHARD_GENERATION ?? "local"}:startup`, {
@@ -1554,6 +1578,10 @@ async function runLeaderStartup(database, environment, modulePackages, modules,
1554
1578
  });
1555
1579
  logger.info({ event: "shard.singleton_startup_complete" }, "Shard singleton startup complete");
1556
1580
  }
1581
+ function assertNoPendingLeaderMigrations(scope, pending) {
1582
+ if (pending.length > 0)
1583
+ throw new Error(`Hosted ${scope} migrations must be applied by the dedicated primary migration identity before bot startup: ${pending.join(", ")}`);
1584
+ }
1557
1585
  class RuntimeCommandHashStore {
1558
1586
  repository;
1559
1587
  constructor(repository) {
@@ -1635,6 +1663,7 @@ async function fetchGatewayBotInformation(token) {
1635
1663
  }
1636
1664
  export function buildShardWorkerEnvironment(source, identity, restartCount) {
1637
1665
  const allowed = new Set([
1666
+ "DATABASE_ENGINE",
1638
1667
  "DATABASE_URL",
1639
1668
  "DATABASE_SSL",
1640
1669
  "DISCORD_TOKEN",
@@ -1680,8 +1709,13 @@ export function buildShardWorkerEnvironment(source, identity, restartCount) {
1680
1709
  const selectionKey = source.HELYX_DATA_ENCRYPTION_KEY
1681
1710
  ? Buffer.from(deriveSecureRandomSelectionKey(source.HELYX_DATA_ENCRYPTION_KEY)).toString("base64")
1682
1711
  : undefined;
1712
+ const hostedModerationCredential = source.HELYX_HOSTED_MODERATION_ENABLED === "true" &&
1713
+ source.HELYX_BOT_CONTROL_SECRET
1714
+ ? { HELYX_BOT_CONTROL_SECRET: source.HELYX_BOT_CONTROL_SECRET }
1715
+ : {};
1683
1716
  return {
1684
1717
  ...environment,
1718
+ ...hostedModerationCredential,
1685
1719
  ...(selectionKey
1686
1720
  ? { [secureRandomSelectionWorkerKeyName]: selectionKey }
1687
1721
  : {}),
@@ -28,8 +28,6 @@ export declare function parseShardWorkerEnvironment(processEnvironment: NodeJS.P
28
28
  HELYX_SHARD_WORKERS_PER_REPLICA: number;
29
29
  HELYX_SHARD_SUPERVISOR_DATABASE_POOL_MAX: number | undefined;
30
30
  HELYX_SHARD_WORKER_DATABASE_POOL_MAX: number | undefined;
31
- DATABASE_URL: string;
32
- DATABASE_SSL: "disable" | "require" | "verify-full";
33
31
  HELYX_RUNTIME_PROFILE: "hosted" | "self-hosted";
34
32
  DISCORD_TOKEN: string;
35
33
  DISCORD_CLIENT_ID: string;
@@ -70,6 +68,10 @@ export declare function parseShardWorkerEnvironment(processEnvironment: NodeJS.P
70
68
  HELYX_CLOUD_CREDENTIAL_AUDIT_RETENTION_DAYS: number;
71
69
  LOG_LEVEL: "error" | "fatal" | "warn" | "info" | "debug" | "trace" | "silent";
72
70
  NODE_ENV: "development" | "test" | "production";
71
+ DATABASE_ENGINE: "postgresql" | "mysql";
72
+ DATABASE_URL: string;
73
+ DATABASE_SSL: "disable" | "require" | "verify-full";
74
+ HELYX_DATABASE_STARTUP_MODE: "apply" | "inspect";
73
75
  HELYX_STATUS_ENABLED: boolean;
74
76
  HELYX_STATUS_URL: string;
75
77
  HELYX_STATUS_HEARTBEAT_INTERVAL_SECONDS: number;
@@ -80,6 +82,7 @@ export declare function parseShardWorkerEnvironment(processEnvironment: NodeJS.P
80
82
  HELYX_SUPPORT_SERVICE_SECRET?: string | undefined;
81
83
  HELYX_VERIFICATION_INSTALLATION_ID?: string | undefined;
82
84
  HELYX_VERIFICATION_URL?: string | undefined;
85
+ HELYX_VERIFICATION_PUBLIC_URL?: string | undefined;
83
86
  HELYX_VERIFICATION_INTERNAL_SECRET?: string | undefined;
84
87
  HELYX_GUILD_COMMAND_RELEASE_ID?: string | undefined;
85
88
  HELYX_PRIVACY_EXECUTOR_DATABASE_URL?: string | undefined;
@@ -1,6 +1,7 @@
1
1
  import { parseBotEnvironment } from "@helyx/config";
2
2
  import { safeError, ServiceRegistry } from "@helyx/core";
3
3
  import { checkDatabaseConnection, createDatabasePool, PremiumAllocationRepository, } from "@helyx/database";
4
+ import { assertRepositoryEngineSupported } from "@helyx/database/adapters";
4
5
  import { REVIEWED_PREMIUM_MODULE_IDS } from "@helyx/module-manifest";
5
6
  import { createRuntime } from "@helyx/framework";
6
7
  import { createPlatformServices, PlatformMemberRoleGrantOwnershipService, PostgresMemberRoleGrantOwnershipStore, } from "@helyx/platform";
@@ -13,6 +14,7 @@ import { BotControlService, BotControlTransportError, DiscordGuildResourceDirect
13
14
  import { HttpVerificationService } from "./verification-service.js";
14
15
  import { DiscordMessageDeliveryService, DiscordMemberRoleService, DiscordVerificationMemberService, DiscordResourceInspection, installModuleEvents, } from "./events.js";
15
16
  import { DiscordModerationMemberEnforcementAdapter } from "./discord-member-enforcement-service.js";
17
+ import { installCreatorInvitePlatform } from "./creator-onboarding-platform.js";
16
18
  import { installGuildTracking, } from "./guilds.js";
17
19
  import { installInteractions, } from "./interactions.js";
18
20
  import { createManagementModule } from "./management-module.js";
@@ -64,6 +66,7 @@ export function createLeaseHeartbeatWatchdog(onExpired, timeoutMs = SHARD_LEASE_
64
66
  }
65
67
  export async function startShardWorker(processEnvironment = process.env, identity = parseShardWorkerLaunchContract(processEnvironment.HELYX_SHARD_WORKER_CONTRACT)) {
66
68
  const environment = parseShardWorkerEnvironment(processEnvironment, identity);
69
+ assertRepositoryEngineSupported(environment.DATABASE_ENGINE, ["postgresql"], "Bot shard worker");
67
70
  const logger = pino({
68
71
  level: environment.LOG_LEVEL,
69
72
  base: {
@@ -109,14 +112,16 @@ export async function startShardWorker(processEnvironment = process.env, identit
109
112
  let hostedLeaveWorker;
110
113
  let leaseHeartbeatWatchdog;
111
114
  let forcedLeaseExit;
115
+ let creatorInvitePlatform;
112
116
  let numericWriteBehind;
113
117
  async function shutdown(reason = "programmatic") {
114
118
  if (shuttingDown)
115
119
  return;
116
120
  shuttingDown = true;
117
121
  status = "stopped";
118
- guildTracking?.stop();
122
+ await guildTracking?.stop();
119
123
  await hostedLeaveWorker?.stop();
124
+ await creatorInvitePlatform?.stop();
120
125
  if (healthTimer)
121
126
  clearInterval(healthTimer);
122
127
  leaseHeartbeatWatchdog?.stop();
@@ -193,6 +198,7 @@ export async function startShardWorker(processEnvironment = process.env, identit
193
198
  internalGuildId: environment.DISCORD_INTERNAL_GUILD_ID,
194
199
  approvedRoleIds: environment.APPROVED_ROLES,
195
200
  botVersion: readBotVersion(),
201
+ databaseEngine: environment.DATABASE_ENGINE,
196
202
  ...(processEnvironment.RAILWAY_GIT_COMMIT_SHA
197
203
  ? {
198
204
  deploymentReference: processEnvironment.RAILWAY_GIT_COMMIT_SHA.slice(0, 12),
@@ -232,6 +238,15 @@ export async function startShardWorker(processEnvironment = process.env, identit
232
238
  })) {
233
239
  platformServices.set(name, service);
234
240
  }
241
+ const applicationUrls = serviceAccess.get(HELYX_SERVICE_NAMES.applicationUrls);
242
+ platformServices.set(HELYX_SERVICE_NAMES.applicationUrls, {
243
+ dashboardBaseUrl: applicationUrls.dashboardBaseUrl,
244
+ get dashboardInstallationId() {
245
+ return environment.HELYX_RUNTIME_PROFILE === "self-hosted"
246
+ ? cloudProxy?.status().installationId
247
+ : undefined;
248
+ },
249
+ });
235
250
  await serviceAccess
236
251
  .get(HELYX_SERVICE_NAMES.moduleAvailability)
237
252
  .refresh();
@@ -262,6 +277,25 @@ export async function startShardWorker(processEnvironment = process.env, identit
262
277
  platformServices.set(HELYX_SERVICE_NAMES.memberRoles, memberRoles);
263
278
  platformServices.set(HELYX_SERVICE_NAMES.memberRoleGrants, new PlatformMemberRoleGrantOwnershipService(new PostgresMemberRoleGrantOwnershipStore(database), memberRoles));
264
279
  platformServices.set(HELYX_SERVICE_NAMES.discordResources, new DiscordResourceInspection(client));
280
+ if (environment.HELYX_RUNTIME_PROFILE === "hosted")
281
+ creatorInvitePlatform = await installCreatorInvitePlatform({
282
+ client,
283
+ database,
284
+ services: serviceAccess,
285
+ modules: publicModules,
286
+ encryptionKey: environment.HELYX_DATA_ENCRYPTION_KEY,
287
+ ...(environment.HELYX_CLOUD_API_URL &&
288
+ environment.HELYX_OAUTH_INTERNAL_SECRET
289
+ ? {
290
+ oauthBroker: {
291
+ baseUrl: environment.HELYX_CLOUD_API_URL,
292
+ secret: environment.HELYX_OAUTH_INTERNAL_SECRET,
293
+ },
294
+ }
295
+ : {}),
296
+ register: (name, service) => platformServices.set(name, service),
297
+ warn: (event) => logger.warn({ event }, "Creator invite cleanup will retry"),
298
+ });
265
299
  const moderationPlatformServices = await installModerationPlatformServices({
266
300
  services: platformServices,
267
301
  serviceAccess,
@@ -310,10 +344,12 @@ export async function startShardWorker(processEnvironment = process.env, identit
310
344
  };
311
345
  const prepareGuilds = installGuildTracking(client, database, publicModules, logger, enabledCoreModuleIds, shardOwnership, environment.HELYX_GUILD_RECONCILIATION_INTERVAL_SECONDS * 1_000, identity.shardCount, environment.DISCORD_CLIENT_ID, hostedSafety, hostedInviteGate, hostedOwnerObserver);
312
346
  guildTracking = prepareGuilds;
313
- hostedLeaveWorker = new HostedServerLeaveWorker(database, client, logger, {
314
- shardId: identity.shardId,
315
- shardCount: identity.shardCount,
316
- });
347
+ hostedLeaveWorker = environment.HELYX_HOSTED_MODERATION_ENABLED
348
+ ? new HostedServerLeaveWorker(database, client, logger, {
349
+ shardId: identity.shardId,
350
+ shardCount: identity.shardCount,
351
+ })
352
+ : undefined;
317
353
  const commandsReady = installInteractions(client, modules, shardWorkerCommandRegistration({
318
354
  ...(environment.DISCORD_INTERNAL_GUILD_ID
319
355
  ? { internalGuildId: environment.DISCORD_INTERNAL_GUILD_ID }
@@ -1,11 +1,17 @@
1
- import type { DatabasePool } from "@helyx/database";
2
1
  import type { CloudDiscordDirectory, CloudDiscordSnapshot } from "./cloud-connector.js";
3
2
  export declare const CLOUD_HELLO_GUILD_LIMIT = 1000;
3
+ export interface SupervisorInstallationDirectory {
4
+ listActiveGuilds(): Promise<readonly {
5
+ guildId: string;
6
+ name: string | null;
7
+ ownerUserId: string | null;
8
+ }[]>;
9
+ }
4
10
  export declare class SupervisorCloudDiscordDirectory implements CloudDiscordDirectory {
5
11
  #private;
6
- private readonly database;
12
+ private readonly installations;
7
13
  private readonly configuredOwnerUserIds;
8
- constructor(token: string, database: DatabasePool, configuredOwnerUserIds: readonly string[]);
14
+ constructor(token: string, installations: SupervisorInstallationDirectory, configuredOwnerUserIds: readonly string[]);
9
15
  snapshot(): Promise<CloudDiscordSnapshot>;
10
16
  }
11
17
  //# sourceMappingURL=supervisor-cloud-directory.d.ts.map
@@ -25,23 +25,19 @@ const userSchema = z
25
25
  .object({ id: snowflake, username: z.string().min(1).max(100) })
26
26
  .passthrough();
27
27
  export class SupervisorCloudDiscordDirectory {
28
- database;
28
+ installations;
29
29
  configuredOwnerUserIds;
30
30
  #rest;
31
- constructor(token, database, configuredOwnerUserIds) {
32
- this.database = database;
31
+ constructor(token, installations, configuredOwnerUserIds) {
32
+ this.installations = installations;
33
33
  this.configuredOwnerUserIds = configuredOwnerUserIds;
34
34
  this.#rest = new REST({ version: "10" }).setToken(token);
35
35
  }
36
36
  async snapshot() {
37
- const [applicationValue, botValue, guildResult] = await Promise.all([
37
+ const [applicationValue, botValue, activeGuilds] = await Promise.all([
38
38
  this.#rest.get(Routes.oauth2CurrentApplication()),
39
39
  this.#rest.get(Routes.user("@me")),
40
- this.database.query(`SELECT guild_id, name, owner_user_id
41
- FROM discord_guilds
42
- WHERE active = true
43
- ORDER BY guild_id
44
- LIMIT ${String(CLOUD_HELLO_GUILD_LIMIT)}`),
40
+ this.installations.listActiveGuilds(),
45
41
  ]);
46
42
  const application = applicationSchema.parse(applicationValue);
47
43
  const bot = userSchema.parse(botValue);
@@ -55,10 +51,10 @@ export class SupervisorCloudDiscordDirectory {
55
51
  ownerUserIds: [
56
52
  ...new Set([...this.configuredOwnerUserIds, ...applicationOwnerIds]),
57
53
  ],
58
- guilds: guildResult.rows.map((guild) => ({
59
- guildId: guild.guild_id,
54
+ guilds: activeGuilds.slice(0, CLOUD_HELLO_GUILD_LIMIT).map((guild) => ({
55
+ guildId: guild.guildId,
60
56
  name: guild.name ?? "Unknown server",
61
- ownerUserId: guild.owner_user_id,
57
+ ownerUserId: guild.ownerUserId,
62
58
  })),
63
59
  };
64
60
  }
@@ -18,9 +18,20 @@ export interface TriviaDataSummary {
18
18
  export interface TriviaDataProvider {
19
19
  getSummary(): Promise<TriviaDataSummary>;
20
20
  }
21
+ interface MysqlTriviaExecutor {
22
+ query<Row extends Record<string, unknown>>(sql: string): Promise<{
23
+ rows: readonly Row[];
24
+ }>;
25
+ }
21
26
  export declare class PostgresTriviaDataProvider implements TriviaDataProvider {
22
27
  #private;
23
28
  constructor(database: DatabasePool);
24
29
  getSummary(): Promise<TriviaDataSummary>;
25
30
  }
31
+ export declare class MysqlTriviaDataProvider implements TriviaDataProvider {
32
+ #private;
33
+ constructor(database: MysqlTriviaExecutor);
34
+ getSummary(): Promise<TriviaDataSummary>;
35
+ }
36
+ export {};
26
37
  //# sourceMappingURL=trivia-data-provider.d.ts.map
@@ -14,34 +14,54 @@ export class PostgresTriviaDataProvider {
14
14
  GROUP BY category, difficulty, type
15
15
  ORDER BY category, difficulty, type`),
16
16
  ]);
17
- const counts = new Map();
18
- for (const row of countResult.rows) {
19
- if (!TRIVIA_CATEGORIES.includes(row.category) ||
20
- !difficulties.includes(row.difficulty) ||
21
- !["text_choice", "text_input", "true_false"].includes(row.type))
22
- throw new Error("Trivia data contains an unsupported group");
23
- counts.set(`${row.category}:${row.difficulty}`, (counts.get(`${row.category}:${row.difficulty}`) ?? 0) +
24
- nonNegativeInteger(row.questions, "Trivia question count"));
25
- }
26
- const typeCount = (type) => countResult.rows
27
- .filter((row) => row.type === type)
28
- .reduce((total, row) => total + nonNegativeInteger(row.questions, "Trivia type count"), 0);
29
- return {
30
- uniqueQuestions: nonNegativeInteger(uniqueResult.rows[0]?.unique_questions, "Unique trivia question count"),
31
- types: {
32
- multipleChoice: typeCount("text_choice"),
33
- guess: typeCount("text_input"),
34
- trueFalse: typeCount("true_false"),
35
- },
36
- categories: TRIVIA_CATEGORIES.map((category) => ({
37
- category,
38
- easy: counts.get(`${category}:easy`) ?? 0,
39
- medium: counts.get(`${category}:medium`) ?? 0,
40
- hard: counts.get(`${category}:hard`) ?? 0,
41
- })),
42
- };
17
+ return summarizeTriviaData(uniqueResult.rows[0]?.unique_questions, countResult.rows);
43
18
  }
44
19
  }
20
+ export class MysqlTriviaDataProvider {
21
+ #database;
22
+ constructor(database) {
23
+ this.#database = database;
24
+ }
25
+ async getSummary() {
26
+ const [uniqueResult, countResult] = await Promise.all([
27
+ this.#database.query(`SELECT count(DISTINCT lower(trim(JSON_UNQUOTE(JSON_EXTRACT(question, '$.text'))))) AS unique_questions
28
+ FROM trivia_questions`),
29
+ this.#database.query(`SELECT category, difficulty, type, count(*) AS questions
30
+ FROM trivia_questions
31
+ GROUP BY category, difficulty, type
32
+ ORDER BY category, difficulty, type`),
33
+ ]);
34
+ return summarizeTriviaData(uniqueResult.rows[0]?.unique_questions, countResult.rows);
35
+ }
36
+ }
37
+ function summarizeTriviaData(uniqueQuestions, groupedRows) {
38
+ const counts = new Map();
39
+ for (const row of groupedRows) {
40
+ if (!TRIVIA_CATEGORIES.includes(row.category) ||
41
+ !difficulties.includes(row.difficulty) ||
42
+ !["text_choice", "text_input", "true_false"].includes(row.type))
43
+ throw new Error("Trivia data contains an unsupported group");
44
+ counts.set(`${row.category}:${row.difficulty}`, (counts.get(`${row.category}:${row.difficulty}`) ?? 0) +
45
+ nonNegativeInteger(row.questions, "Trivia question count"));
46
+ }
47
+ const typeCount = (type) => groupedRows
48
+ .filter((row) => row.type === type)
49
+ .reduce((total, row) => total + nonNegativeInteger(row.questions, "Trivia type count"), 0);
50
+ return {
51
+ uniqueQuestions: nonNegativeInteger(uniqueQuestions, "Unique trivia question count"),
52
+ types: {
53
+ multipleChoice: typeCount("text_choice"),
54
+ guess: typeCount("text_input"),
55
+ trueFalse: typeCount("true_false"),
56
+ },
57
+ categories: TRIVIA_CATEGORIES.map((category) => ({
58
+ category,
59
+ easy: counts.get(`${category}:easy`) ?? 0,
60
+ medium: counts.get(`${category}:medium`) ?? 0,
61
+ hard: counts.get(`${category}:hard`) ?? 0,
62
+ })),
63
+ };
64
+ }
45
65
  function nonNegativeInteger(value, label) {
46
66
  const parsed = typeof value === "string" ? Number(value) : value;
47
67
  if (typeof parsed !== "number" || !Number.isSafeInteger(parsed) || parsed < 0)
@@ -1,6 +1,6 @@
1
- import { type ActionReceipt, type ChallengeCreateRequest, type ChallengeCreateResponse, type DashboardAttemptPage, type DashboardAttemptQuery, type DashboardSafeAttemptDetail, type SignedAssessmentEnvelope, type VerificationInstallationClaims } from "@helyx/verification-contracts";
1
+ import { type ActionReceipt, type ChallengeCreateRequest, type ChallengeCreateResponse, type DashboardAttemptPage, type DashboardAttemptQuery, type DashboardSafeAttemptDetail, type SignedAssessmentEnvelope, type VerificationInstallationClaims, type VerificationResponseUrlOptions } from "@helyx/verification-contracts";
2
2
  import type { VerificationResultContext, VerificationService } from "@helyx/sdk";
3
- export interface HttpVerificationServiceOptions {
3
+ export interface HttpVerificationServiceOptions extends VerificationResponseUrlOptions {
4
4
  baseUrl: string;
5
5
  internalSecret: string;
6
6
  publicSigningKeys: Readonly<Record<string, string>>;
@@ -1,4 +1,4 @@
1
- import { actionReceiptSchema, challengeCreateRequestSchema, challengeCreateResponseSchema, dashboardAttemptPageSchema, dashboardAttemptQuerySchema, dashboardSafeAttemptDetailSchema, signedAssessmentEnvelopeSchema, } from "@helyx/verification-contracts";
1
+ import { actionReceiptSchema, challengeCreateRequestSchema, createChallengeCreateResponseSchema, dashboardAttemptPageSchema, dashboardAttemptQuerySchema, dashboardSafeAttemptDetailSchema, signedAssessmentEnvelopeSchema, } from "@helyx/verification-contracts";
2
2
  import { VerificationChallengeActiveError } from "@helyx/sdk";
3
3
  import { createPublicKey, verify } from "node:crypto";
4
4
  import { z } from "zod";
@@ -21,6 +21,7 @@ export class HttpVerificationService {
21
21
  #publicSigningKeys;
22
22
  #fetch;
23
23
  #installationId;
24
+ #challengeResponseSchema;
24
25
  #challengeCache = new Map();
25
26
  #handler;
26
27
  constructor(options) {
@@ -29,6 +30,8 @@ export class HttpVerificationService {
29
30
  this.#publicSigningKeys = { ...options.publicSigningKeys };
30
31
  this.#installationId = options.installationId;
31
32
  this.#fetch = options.fetchImplementation ?? fetch;
33
+ this.#challengeResponseSchema =
34
+ createChallengeCreateResponseSchema(options);
32
35
  }
33
36
  getInstallationContext() {
34
37
  return Promise.resolve({
@@ -54,9 +57,9 @@ export class HttpVerificationService {
54
57
  if (input.replaceActive !== true &&
55
58
  cached &&
56
59
  Date.parse(cached.expiresAt) > Date.now()) {
57
- return challengeCreateResponseSchema.parse({ ...cached, reused: true });
60
+ return this.#challengeResponseSchema.parse({ ...cached, reused: true });
58
61
  }
59
- const created = challengeCreateResponseSchema.parse(await this.#request("/internal/v1/challenges", {
62
+ const created = this.#challengeResponseSchema.parse(await this.#request("/internal/v1/challenges", {
60
63
  method: "POST",
61
64
  body: challengeCreateRequestSchema.parse(input),
62
65
  }));
@@ -23,9 +23,11 @@ and module guides.
23
23
  - [Operate the Moderation module](modules/moderation.md)
24
24
  - [Operate the Auto Moderation module](modules/auto-moderation.md)
25
25
 
26
- The complete working example is published separately as
27
- `@helyx/module-example`. It is intentionally independent: module-specific
28
- commands, settings, tests, and lifecycle behaviour remain inside that module.
26
+ The standalone authoring reference is published separately as
27
+ `@helyx/module-example`. Adapt its compatibility manifest and compose the
28
+ required privacy contribution before bot installation, as described in
29
+ [the author guide](modules/building-a-module.md). Module-specific commands,
30
+ settings, tests and lifecycle behaviour remain inside that module.
29
31
 
30
32
  ## Choose the right path
31
33