@helyx/bot 1.0.1 → 1.2.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 (88) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +27 -2
  3. package/dist/auto-moderation-scanner-registry.d.ts +27 -0
  4. package/dist/auto-moderation-scanner-registry.js +172 -0
  5. package/dist/cloud-connector.js +3 -0
  6. package/dist/cloud-control-dispatcher.js +32 -0
  7. package/dist/command-path-contract.js +1 -0
  8. package/dist/control-permissions.d.ts +2 -2
  9. package/dist/control-permissions.js +4 -2
  10. package/dist/control-server.d.ts +72 -16
  11. package/dist/control-server.js +150 -250
  12. package/dist/dashboard-action-control.d.ts +25 -0
  13. package/dist/dashboard-action-control.js +73 -0
  14. package/dist/dashboard-action-routes.d.ts +9 -0
  15. package/dist/dashboard-action-routes.js +36 -0
  16. package/dist/dashboard-feature-control.d.ts +37 -0
  17. package/dist/dashboard-feature-control.js +206 -0
  18. package/dist/dashboard-module-projection.d.ts +9 -0
  19. package/dist/dashboard-module-projection.js +15 -0
  20. package/dist/dashboard-preview-import-routes.d.ts +6 -0
  21. package/dist/dashboard-preview-import-routes.js +121 -0
  22. package/dist/developer-operations-module.d.ts +2 -0
  23. package/dist/developer-operations-module.js +136 -2
  24. package/dist/discord-analytics.d.ts +3 -0
  25. package/dist/discord-analytics.js +49 -5
  26. package/dist/discord-auto-moderation-service.d.ts +21 -0
  27. package/dist/discord-auto-moderation-service.js +425 -0
  28. package/dist/discord-guild-health.d.ts +2 -2
  29. package/dist/discord-member-enforcement-service.d.ts +26 -0
  30. package/dist/discord-member-enforcement-service.js +590 -0
  31. package/dist/discord-message-enforcement-service.d.ts +9 -0
  32. package/dist/discord-message-enforcement-service.js +102 -0
  33. package/dist/discord-violation-thread-service.d.ts +33 -0
  34. package/dist/discord-violation-thread-service.js +789 -0
  35. package/dist/ecko-evidence-receiver.d.ts +32 -0
  36. package/dist/ecko-evidence-receiver.js +173 -0
  37. package/dist/ecko-transport-auth.d.ts +19 -0
  38. package/dist/ecko-transport-auth.js +80 -0
  39. package/dist/ecko-transport-routes.d.ts +19 -0
  40. package/dist/ecko-transport-routes.js +130 -0
  41. package/dist/embedded-auto-moderation-pool.d.ts +46 -0
  42. package/dist/embedded-auto-moderation-pool.js +324 -0
  43. package/dist/embedded-auto-moderation-scanner.d.ts +34 -0
  44. package/dist/embedded-auto-moderation-scanner.js +263 -0
  45. package/dist/embedded-auto-moderation-worker-runtime.d.ts +37 -0
  46. package/dist/embedded-auto-moderation-worker-runtime.js +37 -0
  47. package/dist/embedded-auto-moderation-worker.d.ts +2 -0
  48. package/dist/embedded-auto-moderation-worker.js +13 -0
  49. package/dist/events.d.ts +5 -1
  50. package/dist/events.js +275 -9
  51. package/dist/guilds.d.ts +2 -1
  52. package/dist/guilds.js +164 -5
  53. package/dist/hosted-server-moderation.d.ts +100 -0
  54. package/dist/hosted-server-moderation.js +226 -0
  55. package/dist/index.d.ts +1 -1
  56. package/dist/index.js +101 -12
  57. package/dist/moderation-action-registry.d.ts +11 -0
  58. package/dist/moderation-action-registry.js +375 -0
  59. package/dist/moderation-case-disassociation.d.ts +13 -0
  60. package/dist/moderation-case-disassociation.js +83 -0
  61. package/dist/moderation-platform-services.d.ts +27 -0
  62. package/dist/moderation-platform-services.js +47 -0
  63. package/dist/modules.d.ts +6 -0
  64. package/dist/modules.js +65 -3
  65. package/dist/premium-activation-module.js +2 -0
  66. package/dist/privileged-message-metadata-cache.d.ts +35 -0
  67. package/dist/privileged-message-metadata-cache.js +70 -0
  68. package/dist/scheduled-task-runner.js +24 -1
  69. package/dist/shard-contracts.d.ts +151 -1
  70. package/dist/shard-contracts.js +142 -1
  71. package/dist/shard-coordinator.d.ts +1 -1
  72. package/dist/shard-ecko-scanner-registry.d.ts +38 -0
  73. package/dist/shard-ecko-scanner-registry.js +96 -0
  74. package/dist/shard-router.d.ts +23 -0
  75. package/dist/shard-router.js +69 -5
  76. package/dist/shard-supervisor.d.ts +11 -0
  77. package/dist/shard-supervisor.js +72 -6
  78. package/dist/shard-worker.d.ts +16 -1
  79. package/dist/shard-worker.js +108 -10
  80. package/dist/transient-violation-evidence.d.ts +39 -0
  81. package/dist/transient-violation-evidence.js +132 -0
  82. package/documentation/README.md +2 -0
  83. package/documentation/examples/.env.example +13 -0
  84. package/documentation/guides/self-hosting.md +17 -1
  85. package/documentation/modules/auto-moderation.md +51 -0
  86. package/documentation/modules/moderation.md +42 -0
  87. package/documentation/reference/environment.md +11 -0
  88. package/package.json +17 -5
@@ -12,13 +12,18 @@ import { mutateDashboardModuleStatus, } from "./module-status-control.js";
12
12
  import { canAccessModule, PREMIUM_MODULE_UNAVAILABLE_MESSAGE, } from "./premium-access.js";
13
13
  import { completeFirstTimeCommandSetup, } from "./first-time-command-setup.js";
14
14
  import { SHARD_HEALTH_STALE_MS, } from "./shard-contracts.js";
15
- import { assertVersion, dashboardActionPermissionId, declaredManagedResourcePermission, fromDashboardPolicy, managedResourcePermissionId, permissionPoliciesEqual, permissionPolicyKey, safeOptionalVersion, safeVersion, toDashboardPolicy, } from "./control-permissions.js";
15
+ import { assertVersion, declaredManagedResourcePermission, fromDashboardPolicy, managedResourcePermissionId, permissionPoliciesEqual, permissionPolicyKey, safeOptionalVersion, safeVersion, toDashboardPolicy, } from "./control-permissions.js";
16
16
  import { assertPermissionMutationAllowed, BotControlForbiddenError, } from "./control-errors.js";
17
17
  import { dashboardCommands } from "./dashboard-commands.js";
18
18
  import { mapDashboardModule, } from "./dashboard-module-projection.js";
19
19
  import { availabilityCatalogueDefinitions, availabilityCatalogue, dashboardModuleStatus, disabledModuleSummary, moduleAvailabilityService, moduleAvailabilityStates, statusCatalogueDefinitions, } from "./module-availability-control.js";
20
20
  import { registerGuildCommandControlRoutes } from "./guild-command-control-routes.js";
21
21
  import { createPrivacyProviderRegistry, describePrivacyProviders, } from "./privacy-provider-registry.js";
22
+ import { changedSettingFeatureIds, configurationPreviewFeatureIds, hasBoundedManagedResourceBytes, isBoundedManagedResourceValue, managedResourceColumnKeys, normalizeManagedResourceDetail, normalizeManagedResourcePage, normalizeManagedResourceRelationPage, projectManagedResourceObject, requireDashboardFeatures, resourceFieldFeatureIds, scalarInputFeatureIds, validateManagedResourceInput, } from "./dashboard-feature-control.js";
23
+ import { registerDashboardPreviewImportRoutes, registerDashboardResourceMutationRoutes, } from "./dashboard-preview-import-routes.js";
24
+ import { executeDashboardAction as executeDeclaredDashboardAction, previewDashboardAction as previewDeclaredDashboardAction, } from "./dashboard-action-control.js";
25
+ import { registerDashboardActionRoutes, } from "./dashboard-action-routes.js";
26
+ import { registerEckoTransportRoutes, } from "./ecko-transport-routes.js";
22
27
  const snowflake = z.string().regex(/^\d{17,20}$/u);
23
28
  const moduleId = z.string().regex(/^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)+$/u);
24
29
  const MAX_DISCORD_SETTING_SELECTIONS = 25;
@@ -444,6 +449,7 @@ export class BotControlService {
444
449
  const configurations = {};
445
450
  const permissions = {};
446
451
  const logging = {};
452
+ const featureAccess = {};
447
453
  const loggingPolicies = new Map(storedLoggingPolicies.map((policy) => [
448
454
  `${policy.sourceModuleId}:${policy.eventId}`,
449
455
  policy,
@@ -466,6 +472,22 @@ export class BotControlService {
466
472
  version: stored ? safeVersion(stored.version) : 0,
467
473
  };
468
474
  });
475
+ if (module.manifest.manifestVersion === "0.5" &&
476
+ module.manifest.features.length > 0) {
477
+ const access = this.#services.has(HELYX_SERVICE_NAMES.featureAccess)
478
+ ? this.#services.get(HELYX_SERVICE_NAMES.featureAccess)
479
+ : undefined;
480
+ featureAccess[dashboardModule.id] = Object.fromEntries(await Promise.all(module.manifest.features.map(async (feature) => [
481
+ feature.id,
482
+ access
483
+ ? (await access.check({
484
+ guildId,
485
+ moduleId: module.manifest.id,
486
+ featureId: feature.id,
487
+ })).decision
488
+ : "dependency_unhealthy",
489
+ ])));
490
+ }
469
491
  }
470
492
  return {
471
493
  discordCapabilities: {
@@ -473,6 +495,7 @@ export class BotControlService {
473
495
  },
474
496
  ...resources,
475
497
  modules: structuredClone(this.#dashboardModules),
498
+ featureAccess,
476
499
  moduleStatuses,
477
500
  globallyDisabledModuleIds: disabledModules.ids,
478
501
  configurations,
@@ -652,6 +675,15 @@ export class BotControlService {
652
675
  async updateConfiguration(input) {
653
676
  const module = this.#requireDashboardModule(input.moduleId);
654
677
  await this.#requireModuleAccessible(input.guildId, input.moduleId);
678
+ const settingsPermissionId = module.manifest.manifestVersion === "0.5"
679
+ ? module.manifest.dashboard?.settingsMutationPermissionId
680
+ : undefined;
681
+ if (settingsPermissionId)
682
+ await this.#authorizeAction(input.guildId, module, input.actorUserId, settingsPermissionId);
683
+ if (module.manifest.manifestVersion === "0.5") {
684
+ const current = await this.#configurations.get(input.guildId, input.moduleId);
685
+ await requireDashboardFeatures(this.#services, input.guildId, input.moduleId, changedSettingFeatureIds(module.manifest.dashboard?.settings ?? [], current?.value ?? {}, input.value));
686
+ }
655
687
  await this.#validateDiscordSettings(input.guildId, module, input.value);
656
688
  if (module.configuration?.validateUpdate) {
657
689
  try {
@@ -683,6 +715,25 @@ export class BotControlService {
683
715
  });
684
716
  return { value: saved.value, version: safeVersion(saved.version) };
685
717
  }
718
+ async previewConfiguration(input) {
719
+ const module = this.#requireDashboardModule(input.moduleId);
720
+ await this.#requireModuleAccessible(input.guildId, input.moduleId);
721
+ const declaration = module.manifest.manifestVersion === "0.5"
722
+ ? module.manifest.dashboard?.settingsPreview
723
+ : undefined;
724
+ if (!declaration || !module.configuration?.settingsPreview)
725
+ throw new BotControlNotFoundError("Module settings preview is not declared by this module");
726
+ const actor = await this.#authorizeAction(input.guildId, module, input.actorUserId, declaration.permissionId);
727
+ validateManagedResourceInput(declaration.inputs, input.input);
728
+ await requireDashboardFeatures(this.#services, input.guildId, input.moduleId, await configurationPreviewFeatureIds(this.#configurations, input.guildId, module, declaration, input));
729
+ const value = module.configuration.validate(input.value);
730
+ return managedResourceObject.parse(await module.configuration.settingsPreview({
731
+ guildId: input.guildId,
732
+ actor,
733
+ correlationId: input.correlationId,
734
+ services: this.#services,
735
+ }, { value, input: input.input }));
736
+ }
686
737
  async updatePermissions(input) {
687
738
  const module = this.#requireDashboardModule(input.moduleId);
688
739
  await this.#requireModuleAccessible(input.guildId, input.moduleId);
@@ -856,31 +907,28 @@ export class BotControlService {
856
907
  };
857
908
  }
858
909
  async executeDashboardAction(input) {
859
- const module = this.#requireDashboardModule(input.moduleId);
860
- await this.#requireModuleAccessible(input.guildId, input.moduleId);
861
- const declared = module.manifest.dashboard?.actions?.find(({ id }) => id === input.actionId);
862
- const action = module.dashboardActions?.find(({ id }) => id === input.actionId);
863
- if (!declared || !action) {
864
- throw new BotControlNotFoundError("Dashboard action is not declared by this module");
865
- }
866
- const [enabled] = await Promise.all([
867
- new InstallationRepository(this.database).isModuleEnabled(input.guildId, input.moduleId),
868
- this.#authorizeAction(input.guildId, module, input.actorUserId, dashboardActionPermissionId(module, input.actionId)),
869
- ]);
870
- if (!enabled) {
871
- throw new BotControlValidationError("Enable this module before running its dashboard actions");
872
- }
873
- return action.execute({
874
- guildId: input.guildId,
875
- actorUserId: input.actorUserId,
876
- correlationId: input.correlationId,
910
+ return executeDeclaredDashboardAction(input, this.#dashboardActionControl());
911
+ }
912
+ async previewDashboardAction(input) {
913
+ return previewDeclaredDashboardAction(input, this.#dashboardActionControl());
914
+ }
915
+ #dashboardActionControl() {
916
+ return {
917
+ database: this.database,
877
918
  services: this.#services,
919
+ requireDashboardModule: (moduleIdValue) => this.#requireDashboardModule(moduleIdValue),
920
+ requireModuleAccessible: (guildId, moduleIdValue) => this.#requireModuleAccessible(guildId, moduleIdValue),
921
+ authorizeAction: (guildId, module, actorUserId, permissionId) => this.#authorizeAction(guildId, module, actorUserId, permissionId),
878
922
  sendToChannel: (channelId, response) => this.resources.sendToChannel(channelId, response),
879
923
  deleteChannelMessage: (channelId, messageId) => this.resources.deleteChannelMessage(channelId, messageId),
880
- });
924
+ notFound: (message) => new BotControlNotFoundError(message),
925
+ invalid: (message) => new BotControlValidationError(message),
926
+ };
881
927
  }
882
928
  async listManagedResources(input) {
883
929
  const { module, resource } = this.#prepareManagedResource(input);
930
+ const declaration = module.manifest.dashboard?.resources?.find(({ id }) => id === input.resourceType);
931
+ await requireDashboardFeatures(this.#services, input.guildId, input.moduleId, [declaration?.featureId]);
884
932
  this.#requireManagedResourceOperation(module, input.resourceType, "list");
885
933
  this.#validateManagedResourceQuery(module, input.resourceType, input);
886
934
  const result = await resource.list(await this.#managedResourceContext(input, module, "list"), {
@@ -893,6 +941,8 @@ export class BotControlService {
893
941
  }
894
942
  async readManagedResource(input) {
895
943
  const { module, resource } = this.#prepareManagedResource(input);
944
+ const declaration = module.manifest.dashboard?.resources?.find(({ id }) => id === input.resourceType);
945
+ await requireDashboardFeatures(this.#services, input.guildId, input.moduleId, [declaration?.featureId]);
896
946
  this.#requireManagedResourceOperation(module, input.resourceType, "read");
897
947
  const result = await resource.read(await this.#managedResourceContext(input, module, "read"), input.resourceId);
898
948
  if (!result)
@@ -907,6 +957,8 @@ export class BotControlService {
907
957
  ?.relations?.find(({ id }) => id === input.relationId);
908
958
  if (!relation || !resource.listRelation)
909
959
  throw new BotControlNotFoundError("Managed resource relation is not declared by this module");
960
+ const resourceDeclaration = module.manifest.dashboard?.resources?.find(({ id }) => id === input.resourceType);
961
+ await requireDashboardFeatures(this.#services, input.guildId, input.moduleId, [resourceDeclaration?.featureId, relation.featureId]);
910
962
  const context = await this.#managedResourceContext(input, module, "read", relation.permissionId);
911
963
  return normalizeManagedResourceRelationPage(managedResourceRelationPageSchema.parse(await resource.listRelation(context, input.resourceId, input.relationId, {
912
964
  ...(input.cursor === undefined ? {} : { cursor: input.cursor }),
@@ -919,14 +971,44 @@ export class BotControlService {
919
971
  await this.#requireModuleAccessible(input.guildId, input.moduleId);
920
972
  const { module, resource } = this.#prepareManagedResource(input);
921
973
  this.#requireManagedResourceOperation(module, input.resourceType, "preview");
974
+ const declaration = module.manifest.dashboard?.resources?.find(({ id }) => id === input.resourceType);
975
+ await requireDashboardFeatures(this.#services, input.guildId, input.moduleId, [
976
+ declaration?.featureId,
977
+ ...resourceFieldFeatureIds(declaration?.fields ?? [], input.value),
978
+ ]);
922
979
  return resource.preview(await this.#managedResourceContext(input, module, "preview"), input.value);
923
980
  }
981
+ async importManagedResource(input) {
982
+ await this.#requireModuleAccessible(input.guildId, input.moduleId);
983
+ const { module, resource } = this.#prepareManagedResource(input);
984
+ this.#requireManagedResourceOperation(module, input.resourceType, "import");
985
+ const declaration = module.manifest.dashboard?.resources?.find(({ id }) => id === input.resourceType);
986
+ if (!declaration || !("import" in declaration) || !declaration.import)
987
+ throw new BotControlNotFoundError("Managed resource import is not declared by this module");
988
+ if (!resource.import)
989
+ throw new BotControlNotFoundError("Managed resource import is not implemented by this module");
990
+ if (Buffer.byteLength(input.content, "utf8") > declaration.import.maxBytes)
991
+ throw new BotControlValidationError("Managed resource import exceeds the declared byte limit");
992
+ await requireDashboardFeatures(this.#services, input.guildId, input.moduleId, [declaration.featureId, declaration.import.featureId]);
993
+ const result = await resource.import(await this.#managedResourceContext(input, module, "import"), { format: input.format, content: input.content });
994
+ const value = managedResourceObject.parse(result.value);
995
+ await requireDashboardFeatures(this.#services, input.guildId, input.moduleId, resourceFieldFeatureIds(declaration.fields, value));
996
+ return {
997
+ value: projectManagedResourceObject(value, declaration.fields.map(({ key }) => key)),
998
+ preview: managedResourceObject.parse(result.preview),
999
+ };
1000
+ }
924
1001
  async createManagedResource(input) {
925
1002
  await this.#requireModuleAccessible(input.guildId, input.moduleId);
926
1003
  const { module, resource } = this.#prepareManagedResource(input);
927
1004
  this.#requireManagedResourceOperation(module, input.resourceType, "create");
928
1005
  const context = await this.#managedResourceContext(input, module, "create");
929
- const declaredIntents = module.manifest.dashboard?.resources?.find(({ id }) => id === input.resourceType)?.creationIntents ?? ["publish"];
1006
+ const declaration = module.manifest.dashboard?.resources?.find(({ id }) => id === input.resourceType);
1007
+ await requireDashboardFeatures(this.#services, input.guildId, input.moduleId, [
1008
+ declaration?.featureId,
1009
+ ...resourceFieldFeatureIds(declaration?.fields ?? [], input.value),
1010
+ ]);
1011
+ const declaredIntents = declaration?.creationIntents ?? ["publish"];
930
1012
  if (!declaredIntents.includes(input.intent))
931
1013
  throw new DashboardActionValidationError("This creation intent is not declared by the module.");
932
1014
  return normalizeManagedResourceDetail(managedResourceDetailSchema.parse(await resource.create(context, input.value, input.intent)), managedResourceColumnKeys(module, input.resourceType));
@@ -935,6 +1017,11 @@ export class BotControlService {
935
1017
  await this.#requireModuleAccessible(input.guildId, input.moduleId);
936
1018
  const { module, resource } = this.#prepareManagedResource(input);
937
1019
  this.#requireManagedResourceOperation(module, input.resourceType, "update");
1020
+ const declaration = module.manifest.dashboard?.resources?.find(({ id }) => id === input.resourceType);
1021
+ await requireDashboardFeatures(this.#services, input.guildId, input.moduleId, [
1022
+ declaration?.featureId,
1023
+ ...resourceFieldFeatureIds(declaration?.fields ?? [], input.value),
1024
+ ]);
938
1025
  return normalizeManagedResourceDetail(managedResourceDetailSchema.parse(await resource.update(await this.#managedResourceContext(input, module, "update"), {
939
1026
  resourceId: input.resourceId,
940
1027
  expectedRevision: input.expectedRevision,
@@ -945,6 +1032,8 @@ export class BotControlService {
945
1032
  await this.#requireModuleAccessible(input.guildId, input.moduleId);
946
1033
  const { module, resource } = this.#prepareManagedResource(input);
947
1034
  this.#requireManagedResourceOperation(module, input.resourceType, "delete");
1035
+ const declaration = module.manifest.dashboard?.resources?.find(({ id }) => id === input.resourceType);
1036
+ await requireDashboardFeatures(this.#services, input.guildId, input.moduleId, [declaration?.featureId]);
948
1037
  return managedResourceDeleteResultSchema.parse(await resource.delete(await this.#managedResourceContext(input, module, "delete"), {
949
1038
  resourceId: input.resourceId,
950
1039
  expectedRevision: input.expectedRevision,
@@ -961,7 +1050,13 @@ export class BotControlService {
961
1050
  throw new BotControlNotFoundError("Managed resource action is not declared by this module");
962
1051
  const permissionId = managedResourcePermissionId(module, input.resourceType, "action", input.actionId, declared.permissionId);
963
1052
  const context = await this.#managedResourceContext(input, module, "action", permissionId);
964
- validateManagedResourceActionValue(declared.inputs ?? [], input.value);
1053
+ validateManagedResourceInput(declared.inputs ?? [], input.value);
1054
+ const resourceDeclaration = module.manifest.dashboard?.resources?.find(({ id }) => id === input.resourceType);
1055
+ await requireDashboardFeatures(this.#services, input.guildId, input.moduleId, [
1056
+ resourceDeclaration?.featureId,
1057
+ declared.featureId,
1058
+ ...scalarInputFeatureIds(declared.inputs ?? [], input.value),
1059
+ ]);
965
1060
  const result = await resource.executeAction(context, {
966
1061
  resourceId: input.resourceId,
967
1062
  actionId: input.actionId,
@@ -1207,9 +1302,13 @@ export async function createBotControlServer(input) {
1207
1302
  requestTimeout: 15_000,
1208
1303
  });
1209
1304
  const expectedSecret = digest(input.secret);
1305
+ if (input.ecko)
1306
+ server.addContentTypeParser("application/ecko+json", { parseAs: "buffer", bodyLimit: 256 * 1_024 }, (_request, body, done) => done(null, body));
1210
1307
  server.addHook("onRequest", async (request, reply) => {
1211
1308
  if (request.url === "/health")
1212
1309
  return;
1310
+ if (input.ecko && request.url.startsWith("/v1/ecko/"))
1311
+ return;
1213
1312
  const authorization = request.headers.authorization;
1214
1313
  const supplied = authorization?.startsWith("Bearer ")
1215
1314
  ? authorization.slice("Bearer ".length)
@@ -1240,7 +1339,27 @@ export async function createBotControlServer(input) {
1240
1339
  }
1241
1340
  server.get("/health", (_request, reply) => readiness(reply));
1242
1341
  server.get("/v1/health", (_request, reply) => readiness(reply));
1342
+ server.get("/v1/runtime-configuration", (_request, reply) => reply.header("Cache-Control", "no-store").send({
1343
+ hostedModerationEnabled: input.runtimeConfiguration?.hostedModerationEnabled ?? false,
1344
+ }));
1345
+ server.post("/v1/hosted-server-moderation/leave/deliver", async (request) => {
1346
+ if (!input.deliverHostedServerLeave)
1347
+ throw new BotControlTransportError(503, "Hosted server moderation delivery is unavailable");
1348
+ const body = z
1349
+ .object({ operationId: z.uuid(), guildId: snowflake })
1350
+ .strict()
1351
+ .parse(request.body);
1352
+ return { outcome: await input.deliverHostedServerLeave(body) };
1353
+ });
1354
+ server.post("/v1/hosted-servers/:guildId/owner/refresh", async (request) => {
1355
+ if (!input.refreshHostedServerOwner)
1356
+ throw new BotControlTransportError(503, "Hosted server owner refresh is unavailable");
1357
+ const { guildId } = z.object({ guildId: snowflake }).parse(request.params);
1358
+ return input.refreshHostedServerOwner(guildId);
1359
+ });
1243
1360
  registerGuildCommandControlRoutes(server, input.guildCommands, input.service);
1361
+ if (input.ecko)
1362
+ registerEckoTransportRoutes(server, input.ecko);
1244
1363
  server.post("/v1/verification/results", async (request) => input.service.acceptVerificationResult(signedAssessmentEnvelopeSchema.parse(request.body)));
1245
1364
  server.post("/v1/oauth/consumers/cleanup", async (request) => {
1246
1365
  const body = z
@@ -1458,17 +1577,7 @@ export async function createBotControlServer(input) {
1458
1577
  .parse(request.body);
1459
1578
  return input.service.setModuleStatus({ ...params, ...body });
1460
1579
  });
1461
- server.post("/v1/guilds/:guildId/modules/:moduleId/actions/:actionId", async (request) => {
1462
- const params = z
1463
- .object({
1464
- guildId: snowflake,
1465
- moduleId,
1466
- actionId: z.string().regex(/^[a-z][a-z0-9_-]*$/u),
1467
- })
1468
- .parse(request.params);
1469
- const body = mutationContext.parse(request.body);
1470
- return input.service.executeDashboardAction({ ...params, ...body });
1471
- });
1580
+ registerDashboardActionRoutes(server, input.service);
1472
1581
  server.get("/v1/guilds/:guildId/modules/:moduleId/resources/:resourceType", async (request) => {
1473
1582
  const params = z
1474
1583
  .object({
@@ -1570,104 +1679,8 @@ export async function createBotControlServer(input) {
1570
1679
  correlationId: query.correlationId,
1571
1680
  });
1572
1681
  });
1573
- server.post("/v1/guilds/:guildId/modules/:moduleId/resources/:resourceType/preview", async (request) => {
1574
- const params = z
1575
- .object({
1576
- guildId: snowflake,
1577
- moduleId,
1578
- resourceType: resourceActionId,
1579
- })
1580
- .strict()
1581
- .parse(request.params);
1582
- const body = mutationContext
1583
- .extend({ value: managedResourceObject })
1584
- .strict()
1585
- .parse(request.body);
1586
- return input.service.previewManagedResource({ ...params, ...body });
1587
- });
1588
- server.post("/v1/guilds/:guildId/modules/:moduleId/resources/:resourceType", async (request) => {
1589
- const params = z
1590
- .object({
1591
- guildId: snowflake,
1592
- moduleId,
1593
- resourceType: resourceActionId,
1594
- })
1595
- .strict()
1596
- .parse(request.params);
1597
- const body = mutationContext
1598
- .extend({
1599
- intent: z.enum(["draft", "publish"]).optional().default("publish"),
1600
- value: managedResourceObject,
1601
- })
1602
- .strict()
1603
- .parse(request.body);
1604
- return input.service.createManagedResource({ ...params, ...body });
1605
- });
1606
- server.put("/v1/guilds/:guildId/modules/:moduleId/resources/:resourceType/:resourceId", async (request) => {
1607
- const params = z
1608
- .object({
1609
- guildId: snowflake,
1610
- moduleId,
1611
- resourceType: resourceActionId,
1612
- resourceId,
1613
- })
1614
- .strict()
1615
- .parse(request.params);
1616
- const body = mutationContext
1617
- .extend({
1618
- expectedRevision: z.number().int().min(0),
1619
- value: managedResourceObject.optional().default({}),
1620
- })
1621
- .strict()
1622
- .parse(request.body);
1623
- return input.service.updateManagedResource({ ...params, ...body });
1624
- });
1625
- server.delete("/v1/guilds/:guildId/modules/:moduleId/resources/:resourceType/:resourceId", async (request) => {
1626
- const params = z
1627
- .object({
1628
- guildId: snowflake,
1629
- moduleId,
1630
- resourceType: resourceActionId,
1631
- resourceId,
1632
- })
1633
- .strict()
1634
- .parse(request.params);
1635
- const body = mutationContext
1636
- .extend({ expectedRevision: z.number().int().min(0) })
1637
- .strict()
1638
- .parse(request.body);
1639
- return input.service.deleteManagedResource({ ...params, ...body });
1640
- });
1641
- server.post("/v1/guilds/:guildId/modules/:moduleId/resources/:resourceType/:resourceId/actions/:actionId", async (request) => {
1642
- const params = z
1643
- .object({
1644
- guildId: snowflake,
1645
- moduleId,
1646
- resourceType: resourceActionId,
1647
- resourceId,
1648
- actionId: resourceActionId,
1649
- })
1650
- .strict()
1651
- .parse(request.params);
1652
- const body = mutationContext
1653
- .extend({
1654
- expectedRevision: z.number().int().min(0),
1655
- value: managedResourceObject.optional().default({}),
1656
- actorAuthenticatedAt: z.iso.datetime({ offset: true }).optional(),
1657
- })
1658
- .strict()
1659
- .parse(request.body);
1660
- return input.service.executeManagedResourceAction({
1661
- ...params,
1662
- actorUserId: body.actorUserId,
1663
- correlationId: body.correlationId,
1664
- expectedRevision: body.expectedRevision,
1665
- value: body.value,
1666
- ...(body.actorAuthenticatedAt === undefined
1667
- ? {}
1668
- : { actorAuthenticatedAt: body.actorAuthenticatedAt }),
1669
- });
1670
- });
1682
+ registerDashboardPreviewImportRoutes(server, input.service);
1683
+ registerDashboardResourceMutationRoutes(server, input.service);
1671
1684
  server.put("/v1/guilds/:guildId/modules/:moduleId/configuration", async (request) => {
1672
1685
  const params = z
1673
1686
  .object({ guildId: snowflake, moduleId })
@@ -1711,7 +1724,12 @@ export async function createBotControlServer(input) {
1711
1724
  return reply.code(mapped.status).send({ message: mapped.message });
1712
1725
  if (error instanceof BotControlTransportError)
1713
1726
  return reply.code(error.status).send({ message: error.message });
1714
- request.log.error({ error: safeError(error) }, "Private bot control request failed");
1727
+ request.log.error({
1728
+ error: safeError(error),
1729
+ errorCode: typeof error.code === "string"
1730
+ ? error.code
1731
+ : "unclassified",
1732
+ }, "Private bot control request failed");
1715
1733
  return reply.code(500).send({ message: "The control request failed" });
1716
1734
  });
1717
1735
  return server;
@@ -1772,122 +1790,4 @@ function isUnknownMemberError(error) {
1772
1790
  "code" in error &&
1773
1791
  error.code === 10_007);
1774
1792
  }
1775
- function isBoundedManagedResourceValue(value, depth = 0, budget = { keys: 0 }) {
1776
- if (depth > 8)
1777
- return false;
1778
- if (value === null ||
1779
- typeof value === "boolean" ||
1780
- (typeof value === "number" && Number.isFinite(value)))
1781
- return true;
1782
- if (typeof value === "string")
1783
- return value.length <= 4_000;
1784
- if (Array.isArray(value))
1785
- return (value.length <= 100 &&
1786
- value.every((item) => isBoundedManagedResourceValue(item, depth + 1, budget)));
1787
- if (typeof value !== "object")
1788
- return false;
1789
- const prototype = Object.getPrototypeOf(value);
1790
- if (prototype !== Object.prototype && prototype !== null)
1791
- return false;
1792
- const entries = Object.entries(value);
1793
- budget.keys += entries.length;
1794
- return (entries.length <= 100 &&
1795
- budget.keys <= 500 &&
1796
- entries.every(([key, item]) => key.length >= 1 &&
1797
- key.length <= 100 &&
1798
- isBoundedManagedResourceValue(item, depth + 1, budget)));
1799
- }
1800
- function hasBoundedManagedResourceBytes(value) {
1801
- return (new TextEncoder().encode(JSON.stringify(value)).byteLength <= 48 * 1_024);
1802
- }
1803
- function normalizeManagedResourceDetail(value, attributeKeys) {
1804
- return {
1805
- id: value.id,
1806
- revision: value.revision,
1807
- status: value.status,
1808
- title: value.title,
1809
- ...(value.description === undefined
1810
- ? {}
1811
- : { description: value.description }),
1812
- ...(value.attributes === undefined || attributeKeys.length === 0
1813
- ? {}
1814
- : {
1815
- attributes: projectManagedResourceObject(value.attributes, attributeKeys),
1816
- }),
1817
- updatedAt: value.updatedAt,
1818
- value: value.value,
1819
- };
1820
- }
1821
- function normalizeManagedResourcePage(value, attributeKeys) {
1822
- return {
1823
- items: value.items.map((item) => ({
1824
- id: item.id,
1825
- revision: item.revision,
1826
- status: item.status,
1827
- title: item.title,
1828
- ...(item.description === undefined
1829
- ? {}
1830
- : { description: item.description }),
1831
- ...(item.attributes === undefined || attributeKeys.length === 0
1832
- ? {}
1833
- : {
1834
- attributes: projectManagedResourceObject(item.attributes, attributeKeys),
1835
- }),
1836
- updatedAt: item.updatedAt,
1837
- })),
1838
- ...(value.nextCursor === undefined ? {} : { nextCursor: value.nextCursor }),
1839
- ...(value.total === undefined ? {} : { total: value.total }),
1840
- };
1841
- }
1842
- function normalizeManagedResourceRelationPage(value, valueKeys) {
1843
- return {
1844
- items: value.items.map((item) => ({
1845
- id: item.id,
1846
- value: projectManagedResourceObject(item.value, valueKeys),
1847
- })),
1848
- ...(value.nextCursor === undefined ? {} : { nextCursor: value.nextCursor }),
1849
- };
1850
- }
1851
- function projectManagedResourceObject(value, declaredKeys) {
1852
- const allowed = new Set(declaredKeys);
1853
- return Object.fromEntries(Object.entries(value)
1854
- .filter(([key]) => allowed.has(key))
1855
- .map(([key, item]) => [key, structuredClone(item)]));
1856
- }
1857
- function managedResourceColumnKeys(module, resourceType) {
1858
- return (module.manifest.dashboard?.resources
1859
- ?.find(({ id }) => id === resourceType)
1860
- ?.columns?.map(({ key }) => key) ?? []);
1861
- }
1862
- function validateManagedResourceActionValue(inputs, value) {
1863
- const declared = new Map(inputs.map((input) => [input.key, input]));
1864
- for (const key of Object.keys(value))
1865
- if (!declared.has(key))
1866
- throw new DashboardActionValidationError(`The action value "${key}" is not declared by the module.`);
1867
- for (const input of inputs) {
1868
- const item = value[input.key];
1869
- if (item === undefined) {
1870
- if (input.required)
1871
- throw new DashboardActionValidationError(`${input.label} is required for this action.`);
1872
- continue;
1873
- }
1874
- const valid = input.type === "boolean"
1875
- ? typeof item === "boolean"
1876
- : input.type === "integer"
1877
- ? typeof item === "number" &&
1878
- Number.isInteger(item) &&
1879
- item >= input.minimum &&
1880
- item <= input.maximum
1881
- : input.type === "select"
1882
- ? typeof item === "string" &&
1883
- input.options.some(({ value: option }) => option === item)
1884
- : input.type === "discord-channel" || input.type === "discord-role"
1885
- ? typeof item === "string" && snowflake.safeParse(item).success
1886
- : typeof item === "string" &&
1887
- item.length >= input.minLength &&
1888
- item.length <= input.maxLength;
1889
- if (!valid)
1890
- throw new DashboardActionValidationError(`${input.label} is not a valid action value.`);
1891
- }
1892
- }
1893
1793
  //# sourceMappingURL=control-server.js.map
@@ -0,0 +1,25 @@
1
+ import { type DatabasePool } from "@helyx/database";
2
+ import { type DashboardActionContext, type DashboardActionInspection, type DashboardActionResult, type ModuleDefinition, type PermissionActor, type ServiceAccess } from "@helyx/sdk";
3
+ export interface DashboardActionInput {
4
+ guildId: string;
5
+ moduleId: string;
6
+ actionId: string;
7
+ actorUserId: string;
8
+ correlationId: string;
9
+ inspectionRevision?: string;
10
+ }
11
+ export type DashboardActionPreviewInput = Omit<DashboardActionInput, "inspectionRevision">;
12
+ export interface DashboardActionControl {
13
+ database: DatabasePool;
14
+ services: ServiceAccess;
15
+ requireDashboardModule(moduleId: string): ModuleDefinition;
16
+ requireModuleAccessible(guildId: string, moduleId: string): Promise<void>;
17
+ authorizeAction(guildId: string, module: ModuleDefinition, actorUserId: string, permissionId: string): Promise<PermissionActor>;
18
+ sendToChannel: DashboardActionContext["sendToChannel"];
19
+ deleteChannelMessage: DashboardActionContext["deleteChannelMessage"];
20
+ notFound(message: string): Error;
21
+ invalid(message: string): Error;
22
+ }
23
+ export declare function executeDashboardAction(input: DashboardActionInput, control: DashboardActionControl): Promise<DashboardActionResult>;
24
+ export declare function previewDashboardAction(input: DashboardActionPreviewInput, control: DashboardActionControl): Promise<DashboardActionInspection>;
25
+ //# sourceMappingURL=dashboard-action-control.d.ts.map
@@ -0,0 +1,73 @@
1
+ import { InstallationRepository } from "@helyx/database";
2
+ import {} from "@helyx/sdk";
3
+ import { dashboardActionPermissionId } from "./control-permissions.js";
4
+ import { requireDashboardFeatures } from "./dashboard-feature-control.js";
5
+ function prepareAction(input, control) {
6
+ const module = control.requireDashboardModule(input.moduleId);
7
+ const declared = module.manifest.dashboard?.actions?.find(({ id }) => id === input.actionId);
8
+ const action = module.dashboardActions?.find(({ id }) => id === input.actionId);
9
+ return { module, declared, action };
10
+ }
11
+ async function requireEnabledAndAuthorized(input, control, module, permissionId, disabledMessage) {
12
+ const [enabled] = await Promise.all([
13
+ new InstallationRepository(control.database).isModuleEnabled(input.guildId, input.moduleId),
14
+ control.authorizeAction(input.guildId, module, input.actorUserId, permissionId),
15
+ ]);
16
+ if (!enabled)
17
+ throw control.invalid(disabledMessage);
18
+ }
19
+ function actionContext(input, control, module, permissionId) {
20
+ return {
21
+ guildId: input.guildId,
22
+ actorUserId: input.actorUserId,
23
+ correlationId: input.correlationId,
24
+ services: control.services,
25
+ revalidate: async () => {
26
+ try {
27
+ await control.requireModuleAccessible(input.guildId, input.moduleId);
28
+ const enabled = await new InstallationRepository(control.database).isModuleEnabled(input.guildId, input.moduleId);
29
+ if (!enabled)
30
+ return false;
31
+ await control.authorizeAction(input.guildId, module, input.actorUserId, permissionId);
32
+ return true;
33
+ }
34
+ catch {
35
+ return false;
36
+ }
37
+ },
38
+ sendToChannel: control.sendToChannel,
39
+ deleteChannelMessage: control.deleteChannelMessage,
40
+ };
41
+ }
42
+ export async function executeDashboardAction(input, control) {
43
+ const { module, declared, action } = prepareAction(input, control);
44
+ await control.requireModuleAccessible(input.guildId, input.moduleId);
45
+ if (!declared || !action)
46
+ throw control.notFound("Dashboard action is not declared by this module");
47
+ await requireDashboardFeatures(control.services, input.guildId, input.moduleId, [declared.featureId]);
48
+ if (declared.inspection === "required" &&
49
+ (typeof input.inspectionRevision !== "string" ||
50
+ input.inspectionRevision.length < 1 ||
51
+ input.inspectionRevision.length > 200))
52
+ throw control.invalid("Run the action inspection before applying this change");
53
+ const permissionId = dashboardActionPermissionId(module, input.actionId, declared.permissionId);
54
+ await requireEnabledAndAuthorized(input, control, module, permissionId, "Enable this module before running its dashboard actions");
55
+ return action.execute(actionContext(input, control, module, permissionId), input.inspectionRevision
56
+ ? { inspectionRevision: input.inspectionRevision }
57
+ : undefined);
58
+ }
59
+ export async function previewDashboardAction(input, control) {
60
+ const { module, declared, action } = prepareAction(input, control);
61
+ await control.requireModuleAccessible(input.guildId, input.moduleId);
62
+ if (!declared || !action?.inspect || !declared.inspection)
63
+ throw control.notFound("Dashboard action inspection is not declared by this module");
64
+ await requireDashboardFeatures(control.services, input.guildId, input.moduleId, [declared.featureId]);
65
+ const permissionId = dashboardActionPermissionId(module, input.actionId, declared.permissionId);
66
+ await requireEnabledAndAuthorized(input, control, module, permissionId, "Enable this module before inspecting its dashboard actions");
67
+ const result = await action.inspect(actionContext(input, control, module, permissionId));
68
+ if (result.inspectionRevision.length < 1 ||
69
+ result.inspectionRevision.length > 200)
70
+ throw new Error("Dashboard action returned an invalid inspection revision");
71
+ return result;
72
+ }
73
+ //# sourceMappingURL=dashboard-action-control.js.map
@@ -0,0 +1,9 @@
1
+ import type { DashboardActionInspection, DashboardActionResult } from "@helyx/sdk";
2
+ import type { FastifyInstance } from "fastify";
3
+ import type { DashboardActionInput, DashboardActionPreviewInput } from "./dashboard-action-control.js";
4
+ export interface DashboardActionHandler {
5
+ executeDashboardAction(input: DashboardActionInput): Promise<DashboardActionResult>;
6
+ previewDashboardAction(input: DashboardActionPreviewInput): Promise<DashboardActionInspection>;
7
+ }
8
+ export declare function registerDashboardActionRoutes(server: FastifyInstance, service: DashboardActionHandler): void;
9
+ //# sourceMappingURL=dashboard-action-routes.d.ts.map