Dunipod : indexeur de données on-chain et off-chain avec couche P2P et API GraphQL unifiée

Depuis longtemps, je voulais refaire l’indexeur on-chain pour plusieurs raisons expliquées plus bas, mais j’étais concentré sur le passage à la v2. Puis, j’ai eu besoin de prendre une pause de quelques mois après la migration vers la v2.

À mon retour, j’ai commencé à travailler sur une refonte de l’indexeur on-chain, que j’ai fusionnée avec un autre projet : une couche de stockage pour les données off-chain (voir ce message lié).

Je suis donc parti sur un ensemble de microservices permettant d’indexer les données on-chain et off-chain, et de propager les données off-chain sur un réseau décentralisé de pods.

Le projet se nomme « Dunipod » et est découpé en six microservices indépendants :

  • onchain-indexer : remplace Squid
  • distance-worker : évalue périodiquement la distance de toute la toile
  • offchain-indexer : indexe les documents JSON off-chain soumis via HTTP ou reçus via P2P
  • graphql : expose, via une API GraphQL unifiée, les données on-chain, l’évaluation de la distance et les données off-chain
  • p2p : couche générique de propagation des documents off-chain et des blobs
  • blob-gateway : API HTTP permettant de récupérer les blobs (les documents JSON sont exposés via GraphQL)

Le code source du projet est disponible sur le GitLab de Duniter : nodes / rust / Dunipod · GitLab

L’API GraphQL est exposée sur mon pod : g1pod.elo.tf/v1/graphql
Le tableau de bord GraphiQL : Dunipod GraphiQL

Pourquoi refaire un indexeur on-chain ?

Pour plusieurs raisons :

  • Squid nécessite un nœud Duniter d’archive, ce qui prend beaucoup de place et rend donc difficile l’hébergement d’un indexeur on-chain. Je veux un indexeur on-chain qui puisse fonctionner avec un nœud Duniter miroir minimal supprimant les anciens blocs.
  • Squid conserve forcément toutes les données indexées depuis la Genèse. Je veux un indexeur permettant de paramétrer un espace disque maximal et d’élaguer automatiquement les données les plus anciennes en fonction de l’espace disque restant.
  • Squid ne se base que sur les événements Substrate. Il ne peut donc pas indexer correctement l’historique du solde d’un compte, car certains changements de solde n’ont pas d’événement associé. Plus largement, les événements Substrate sont simplement des tags qui donnent une sémantique à certaines transactions : ils n’ont pas vocation à tracer tous les changements de stockage que le runtime Substrate peut engendrer. Je veux un indexeur on-chain capable de tracer les changements du stockage brut afin d’indexer proprement l’historique du solde d’un compte.
  • Squid ne fournit pas aux applications un moyen déterministe de calculer à l’avance le coût d’une requête ni de connaître précisément le quota restant. Je veux un indexeur qui utilise un mécanisme de limitation de débit fondé sur un Token Bucket, associé à un modèle de coût GraphQL public et déterministe. Afin qu’une application ou un script puisse calculer le coût d’une requête avant de l’envoyer, connaître la capacité encore disponible sur chaque pod et répartir automatiquement sa charge entre plusieurs pods lorsqu’elle doit récupérer un volume important de données.

Comment fonctionne l’indexeur off-chain ?

Les données off-chain sont des documents JSON et des blobs binaires. Chaque document appartient à un domaine : profiles, user_private_data, tx_comment, ad, etc.

Chaque pod choisit les domaines qu’il accepte. Il partage alors via P2P uniquement les documents relatifs à ces domaines.

Chaque pod définit ses propres règles d’indexation off-chain par domaine. Ces règles sont définies dans un dossier qui doit contenir un manifeste JSON, des scripts Rhai et un sous-schéma GraphQL. Elles définissent notamment :

  • Sous quelles conditions un document JSON est admissible
  • Comment un document JSON admis doit être projeté dans la base de données
  • Sous quelles conditions un blob référencé par un document JSON est promu
  • Quelles primitives génériques du binaire offchain-indexer sont utilisées et comment. Il existe notamment des primitives pour la génération de miniatures d’avatars et pour la classification des noms similaires. J’ai ouvers un sujet dédié à la classification des noms de profils : Recherche par nom de Profil sécurisée (y compris pour les non-membre).

Comment fonctionne la couche P2P ?

Un document JSON doit être admis par un pod pour pouvoir être partagé via P2P. Il n’est pas possible de le soumettre directement au réseau P2P, afin de protéger efficacement le réseau contre le spam. Un hébergeur de pod malveillant peut toujours soumettre du spam au réseau P2P, mais il est possible de bannir un pod. Il existe également un système de certificats pour les pods hébergés par des membres de la toile de confiance, qui sont prioritaires par rapport aux pods anonymes.

Le microservice P2P communique avec l’indexeur off-chain via une outbox PostgreSQL. Il partage tous les documents des domaines suivis par le pod, quelles que soient les règles off-chain propres à celui-ci. Il partage également les blobs promus localement.

La couche réseau bas niveau est gérée par Iroh, tandis que le protocole de synchronisation des documents JSON s’appuie sur RIBLT, une structure de réconciliation qui permet à deux nœuds d’identifier précisément leurs différences sans échanger leurs inventaires complets. Son coût s’adapte au nombre de documents divergents, ce qui convient particulièrement à un réseau où des nœuds décentralisés possèdent généralement des ensembles très proches, mais différents.

Roadmap

Dans les prochains jours

Stabiliser le prototype. Publier une version un peu plus stable et aider d’autres personnes à héberger Dunipod.

Dans les prochaines semaines

Stabiliser la gestion des profils et contribuer aux applications (Cesiumv2s, Gecko, Ginkgo, Tikka, etc.) afin de les aider à gérer les profils via Dunipod et, éventuellement, à effectuer une migration si cette solution est retenue.

Dans les prochains mois

Ajouter les domaines off-chain pour les commentaires de transaction et les annonces qui serviront à créer un Gchange v2 décentralisé.
Dunipod n’apporterait que le backend : il pourrait y avoir plusieurs applications différentes servant les annonces du réseau Dunipod, et chaque application pourrait éventuellement décider de servir également les annonces d’autres réseaux.


Ce prototype est une proposition. Je suis resté très succinct dans ce post, mais j’ai longuement réfléchi aux choix de conception, que je détaillerai au fil des questions. Si vous parvenez à me convaincre de modifier certains choix d’architecture, je le ferai volontiers. Il faut toutefois que je comprenne ce qui motive vos propositions et qu’elles soient justifiées au regard des besoins et des contraintes de l’écosystème G1.

Bravo et merci pour ce travail de conception !

Un première question à chaud :

Cela veut dire que l’on “peut” ne pas vouloir stocker tous les blocs de données onchain, ou bien que l’on ne “peut” pas stocker tous les blocs de données onchain ?

Peux-t-on avoir quand même, si on le désire, des données depuis la genèse, sur son Dunipod, pour faire des statistiques par exemple ?

Oui, ce sera au choix de chaque hébergeur de Dunipod.
Un hébergeur pourra décider de conserver toutes les données on-chain depuis la Genèse, notamment pour permettre des analyses statistiques. Il pourra aussi fixer une limite d’espace disque : Dunipod élaguera alors progressivement les données les plus anciennes lorsque cette limite sera atteinte.

L’API GraphQL indiquera clairement le point à partir duquel les données sont disponibles. Une application pourra ainsi détecter qu’un pod ne possède pas les données historiques demandées et basculer automatiquement vers un autre pod disposant d’un historique plus ancien, voire complet.

Super ! Content de voir que nos idées vont dans le même sens, notamment sur :

  • un indexeur onchain qui
    • ne dépend pas d’un noeud archive
    • peut indexer le storage substrate sans nécessairement d’événement
    • contrôle le coût des requêtes plutôt que d’“espérer que ça passe”
  • un indexeur offchain qui
    • a un politique d’admission des documents (j’appelle ça des “faits”)
    • a des règles de construction de l’index à partir des faits (ce que tu appelles ici “projection”)
    • tu parles de promotion de blob, j’imagine que c’est une indication de stockage obligatoire / optionnel
    • intègre des primitives comme la génération de miniatures et de recherche sémantique

J’ai hâte de pouvoir te partager mes idées pour voir comment tu les intégrerais. Mais là pas le temps pour l’instant.


J’ai tenté la requête

query Q {
  identities(first: 1, filter: {name: {equalTo: "poka"}}) {
    nodes {
      name
    }
  }
}

qui m’a donné l’erreur :

{
  "errors": [
    {
      "message": "unsupported_graphql_feature: `Identity.name` has no bounded equality plan"
    }
  ]
}

J’attends la stabilisation alors !

Au plaisir d’en discuter quand tu auras le temps :slight_smile:

La requête que tu a tenté interroge les identités on-chain (identities), pas les profils. Le champ Identity.name ne dispose pas d’un index permettant ce filtre d’égalité borné. Je n’ai pas priorisé cette fonctionnalité car je pense que les identités on-chain ne devraient pas avoir de nom : ça fait doublon avec le nom de profil, et c’est un nom que l’on ne peut ni modifier ni supprimer, donc ça empêche le droit à l’oubli (idem pour les commentaires de transactions ; c’est pour ça que, selon moi, on doit déplacer les noms et commentaires de transactions off-chain).
Pour rechercher un profil par nom, utilise le champ dédié :

query SearchProfiles {
  searchProfilesByName(text: "poka", first: 10) {
    nodes {
      accoutnId
      displayName
    }
  }
}

Et tu auras bien des réponses :

{
  "data": {
    "searchProfilesByName": {
      "nodes": [
        {
          "accountId": "g1L7LKQ1h2pC2oGR2EyT8dheHAXQP51EwUbprE5jQ7VZfed2x",
          "displayName": "poka back dev"
        },
        {
          "accountId": "g1M4gLsUP7pz3k6PRvJcdviYZMv7VUc5CsWzyVE5y9rByn64G",
          "displayName": "Poka larfeuil"
        },
        {
          "accountId": "g1QiXdJdEpwpqzBtyvTcFj1KD13Uq6fsg2qHJiWrBSify6fGB",
          "displayName": "PokaSMS"
        },
        {
          "accountId": "g1NnmFRmQi9PwUuSH1zGfJE6utJvXWpNWK9xovdEyD1odPJdP",
          "displayName": "Poky"
        },
        {
          "accountId": "g1PHkacRUadXx9yaUYBPLgfJZtttigXqo2Vypv9SFwBuSjHeR",
          "displayName": "Poky"
        }
      ]
    }
  }
}

Cette requête fonctionne désormais. Elle échouait car il n’y avait pas d’index prefix pour les noms onchain et j’avais ajouté une sécurité (toujours en place) qui rejette les requêtes qui nécessitent un scan complet sans index.

Je suis en train d’implémenter une requête vers le dunipod pour la données de distance et la prochaine certification à renouveler (wot wizard dans Tikka).

Mais il manque la donnée nommé “qualité” de wot wizard. J’ai demandé à l’IA quoi te demander comme données pour afficher la qualité comme dans wot wizard

xx% (référents à stepmax - 1 / total référents)

Penses-tu, connaissant le gql, que la colonne qualité peut être implémentée correctement maintenant ou qu’elle nécessite d’autres données du serveur. S’il manque des données pour la qualité, lesquelles (je peux les demander au développeur).

Bonne question — j’ai le schéma complet sous les yeux, voici l’analyse précise.

Verdict : la qualité ne peut pas être implémentée correctement avec le schéma actuel

Pourquoi

La règle de qualité est identique à la règle de distance, mais avec stepMax − 1 pas
(au lieu de stepMax). Or le schéma dunipod n’expose qu’un seul snapshot de distance par identité :

type Identity {
  ...
  distanceRule: DistanceRuleSnapshot   # ← un seul, avec maxDepth fixe
}

type DistanceRuleSnapshot {
  perbill: Int!
  accessibleReferees: Int!
  effectiveAccessibleReferees: Int!
  refereeCount: Int!
  effectiveRefereeCount: Int!
  passes: Boolean!
  maxDepth: Int!              # vraisemblablement = stepMax
  minimumRequiredPerbill: Int!
  refereeThreshold: Int!
  ...
}

Il n’y a aucun qualityRule, aucun second snapshot à maxDepth − 1, et rien dans DistanceRuleCalculationConfig (config globale) qui suggère un calcul de qualité séparé.
Pourquoi on ne peut pas la déduire côté client

effectiveAccessibleReferees est le nombre de référents atteints à maxDepth = stepMax. Pour la qualité, il faudrait le même comptage à stepMax − 1 — c’est-à-dire la distribution des distances (combien de référents à 1 pas, 2 pas, …), qui n’est pas exposée. Aucune transformation de perbill/ accessibleReferees ne peut produire la valeur à stepMax − 1 : c’est un résultat de calcul de graphe, pas un dérivé.

Ce qu’il faut demander au développeur dunipod

La demande la plus simple, qui réutilise l’existant :

Sur le type Identity, ajouter un second champ calculé au même format que distanceRule, mais avec maxDepth = stepMax − 1 :
graphql qualityRule: DistanceRuleSnapshot
Variantes acceptables (à proposer si l’architecture dunipod préfère) :

  • un paramètre sur le champ : distanceRule(maxDepth: Int): DistanceRuleSnapshot (permet de demander stepMax ou stepMax − 1 — plus flexible, couvre aussi les dev futures),
  • ou, dans le snapshot existant, un champ supplémentaire : qualityPasses: Boolean! + qualityPerbill: Int! + qualityAccessibleReferees: Int! (le strict minimum pour l’onglet : % = qualityAccessibleReferees / effectiveRefereeCount * 100, mais moins propre).

La première option (qualityRule: DistanceRuleSnapshot sur Identity) est la plus propre et la plus alignée avec la règle officielle.

Voilà, si tu le veux bien, l’ajout de la qualité dans Dunipod serait un must pour Tikka et autres clients palliant l’absence de Wot Wizard.

Ok ce sera ajouté quand j’aurais le temps

D’abord un grand merci pour ce projet et ces serveurs Dunipod !

Tikka est le premier client a l’utiliser je pense, pour l’instant de façon expérimentale.

J’ai un petit souci pour optimiser et faire une seule requête pour plusieurs identités en filtrant par accountId ou index :

query {
  identities(filter: { index: { in: [58, 61] } }) {
    nodes {
      id
      accountId
      distanceRule {
        effectiveAccessibleReferees
        effectiveRefereeCount
      }
    }
  }
}
  • in rejeté : filter operator ‘in’ has no bounded plan (pas d’index sur index).
query {
  identities (filter: { or: [{index: {equalTo: 58}}, {index: {equalTo: 61}}]} ){

    nodes {
      id
      accountId
      distanceRule {
        effectiveAccessibleReferees
        effectiveRefereeCount
      }
    }
  }
}
  • or rejeté : boolean filter operator ‘or’ has no bounded plan.

Je ne sais pas si c’est juste créer un index dans la base SQL qui manque ou si c’est plus compliqué à résoudre, mais les filtres par IN et OR ne fonctionnent pas sur identities.

Je vais ajouter le support de in, mais pas de or, pour requeter un set de comptes in suffit.

En attendant, tu peut déjà récupérer le résultat de distance de plusieurs comptes avec ce workaround:

query GetIdentities($account1: String!, $account2: String!) {
  first: identityByAccountId(accountId: $account1) {
    id
    accountId
    distanceRule {
      effectiveAccessibleReferees
      effectiveRefereeCount
    }
  }

  second: identityByAccountId(accountId: $account2) {
    id
    accountId
    distanceRule {
      effectiveAccessibleReferees
      effectiveRefereeCount
    }
  }
}

Merci pour l’astuce !

C’est justement ce que l’IA a fait ! :joy: Mais le serveur limite à 4 alias maximum, alors elle fait des chunks de 4 alias/comptes… La maligne.

Ça fonctionne en attendant plus efficace. Je vais pouvoir livrer.

Je ne doute pas que Dunipod soit plus avancé que mon “datapod”, et sûrement plus adapté pour les applications liées à la Ǧ1, mais il y a quelques propriétés que je vise qui me semblent incompatibles avec l’approche Dunipod :

  • “local first” : l’indexeur doit fonctionner hors ligne ou en réseau local et supporter une connexion asynchrone (la connexion au reste du réseau n’a pas nécessairement lieu au moment où l’utilisateur utilise l’appli)
  • “p2p” : les applis “client” intègrent l’indexeur à l’intérieur et peuvent se suffire à elles mêmes, il n’y a pas de distinction client/serveur, seulement des rôles sur le réseau pour les machines en ligne h24
  • indépendance d’une blockchain : n’importe qui peut soumettre de la donnée en local sans compte sur blockchain, sans tokens, sans frais, sans autorisation, sans création de compte préalable. Chaque indexeur choisit ses critères d’indexation pour l’acceptation d’un fait et l’indexation d’une donnée.
  • fork social / fédération mycélienne : c’est le concept qu’il faudrait que je détaille parce que c’est le plus nouveau, mais en gros si un groupe de personnes se mettent d’accord sur des règles, le protocole de synchronisation et indexation des faits doit les mener à un état convergent, et si un groupe entre en désaccord sur les règles, le fork doit se faire sans nécessité de consensus sur la copie “légitime”, les branches doivent pouvoir continuer à avancer en parallèle le temps que durera le désaccord.

Concernant la Ğ1 et Ğchange, ça me paraît évidemment mieux de passer par Dunipod, il ne reste plus qu’à recoder une appli basée sur une API Dunipod (et même avec un light node dans le meilleur des mondes). Mais pour mes applications hors Ǧ1, je compte continuer à explorer dans ma direction pour voir où ça me mène.

plusieurs raisons que je ne vais pas détailler ici [sauf] s’il y a une demande pour

Je suis preneur de tous ces détails, ça aide à identifier tôt les points clés de conception.

Dans 99 % des cas, l’utilisateur veut faire quelque chose qui implique des données fraîches, que ce soit rechercher un profil pour faire un transfert ou envoyer un message, ou encore chercher une annonce ou y répondre.

Les cas du type « je veux récupérer les données puis les consulter hors ligne » me semblent très minoritaires et correspondent davantage à un usage de niche.

Il est toutefois tout à fait possible de suppporter ce cas de niche à l’avenir dans dunipod avec un dunipod-light que je mentionne au point suivant.

C’est justement plutôt un inconvénient de mon point de vue. Cela impose un client lourd, qui doit effectuer beaucoup de choses localement, ce qui ne me semble pas très compatible avec un usage sur un mobile modeste (davantage de travail côté client) et/ou avec une connexion limitée, ce qui est souvent le cas en mobilité.

Je préfère donc un modèle « client léger first ». Cela ne m’empêche pas, à terme, de faire une version dunipod-light de Dunipod qui compile en WASM et se comporte localement comme un nœud Dunipod P2P si c’est souhaité. Rien ne l’empêche techniquement ; ce n’est simplement pas ma priorité actuellement.

Plus profondément, je ne vois pas très bien en quoi cette approche apporte davantage de décentralisation réelle. Je comprends qu’elle puisse paraître plus décentralisée en apparence, mais le client a toujours besoin de faire confiance à d’autres pods auxquels il s’abonne, à moins de récupérer toutes les données du réseau pour toutes les réindexer lui-même.

Or, il ne me semble pas réaliste de faire cela sur le mobile ou l’ordinateur de chaque utilisateur. En pratique, on retombe donc sur la nécessité de faire confiance à d’autres pods pour pré-indexer certaines données.

C’est déjà le cas dans Dunipod. N’importe qui peut soumettre des données off-chain à un pod Dunipod, tant que ces données respectent les règles du pod, qui sont personnalisables pour chaque pod.

Par défaut, le protocole exige uniquement une signature Ed25519, mais il n’est pas nécessaire qu’elle corresponde à un compte Ğ1 on-chain. Certaines règles peuvent l’exiger dans certains cas plus consommateurs en données, ou simplement appliquer des quotas différents selon que le compte existe ou non.

Dans Dunipod également, chaque indexeur choisit ses propres critères d’indexation pour l’acceptation d’un fait et l’indexation d’une donnée.

Là aussi, c’est déjà le cas dans Dunipod. Le protocole P2P de Dunipod est agnostique vis-à-vis des règles spécifiques à chaque pod et partage tous les documents des domaines suivis, même lorsque les pods appliquent des règles différentes.

Les règles d’indexation des données off-chain sont déterministes, et je vérifie notamment que deux pods qui choisissent les mêmes règles convergent vers le même état. Un pod peut également forker ses règles off-chain sans nécessiter de consensus sur une copie « légitime », puisqu’il n’existe justement pas de copie légitime globale.

Dunipod fonctionne bien sans consensus global et sans blockchain pour les données off-chain. L’indexeur on-chain sert uniquement à fournir, de manière déterministe, des données sur lesquelles les règles off-chain peuvent choisir de s’appuyer, avec la garantie, apportée par le protocole, de l’accessibilité des données nécessaires à l’application de ces règles.

Cela implique effectivement que le protocole générique soit « aware » des données on-chain dont les règles peuvent avoir besoin.

Au final, il me semble avoir déjà retenu et intégré dans Dunipod la plupart des bonnes idées que tu évoques, même si leur mise en œuvre ou l’architecture choisie diffèrent parfois.

Je te remercie en tout cas pour ces réflexions et ces propositions. Je reste bien sûr ouvert à toute suggestion d’amélioration, ainsi qu’à l’intégration de nouvelles idées :slight_smile:

Est-ce que Dunipod est adapté pour faire un système de messagerie instantané, avec accusés de réception, indicateur de saisie en cours, pièce jointes, le tout par websockets ?

Ma question ne porte pas sur la pertinence ou non de refaire un système de messagerie au lieu de réutiliser un protocole existant.

Dunipod peut supporter un système de messagerie avec pièces jointes, mais pas l’aspect instantané.

Si on voulait utiliser Dunipod comme stockage pour une messagerie instantanée, ce serait possible, mais il faudrait coder un microservice qui gère les websockets et toutes les fonctionnalités liées à l’aspect « instantané », puis qui injecte en arrière-plan les messages dans Dunipod pour assurer leur persistance.

Dunipod est déjà découpé en 7 microservices modulaires. Certains pods peuvent décider de n’en utiliser qu’une partie. On pourrait tout à fait imaginer un microservice optionnel dunipod-instant-messages.

D’accords donc si on peut faire un micro service dédié à ça, c’est possible.

Une autre question que je me pose qui en découle, c’est si chaque pod décide de connecter différents services, j’imagine qu’ils déclarent tous les services associés.

Donc en gros c’est à la charge de l’app de filtrer, au travers d’un scan réseau (encore un), quel pods fait ce dont l’app à besoin pour fonctionner, pour sélectionner seulement ceux qui ont activés au moins les services nécessaires pour l’app. C’est bien ça ?

Oui exactement

Actuellement, oui.
Je prévois d’ajouter dans l’API GraphQL un moyen d’obtenir, en une seule requête, ces informations pour tous les autres pods connus du pod. Cela permettrais de faire moins d’appels réseau pour trouver un pod qui fournit les services que l’on recherche.

Si l’app a besoin de plusieurs services, elle peut aussi combiner plusieurs pods qui ne proposent chacun qu’une partie de ces services. C’est déjà ce que je fais dans G1Data de manière transparente, car j’ai besoin à la fois des données off-chain et des données de distance.

Or, un pod peut être onchain-only (l’équivalent d’un indexeur squid) et disposer ou non du microservice qui calcule la distance.

EDIT:

Aussi, certains services peuvent etre optionnels pour l’application. Par exemple, dans l’hypothèse où l’on développerait ce microservice dunipod-instant-messages et qu’une future app en dépendrait, elle pourrait toujours récupérer et envoyer des messages sans le service de messagerie instantanée. L’app fonctionnerait simplement en mode dégradé, sans les fonctionnalités d’instantanéité.

Bon à voir comment tu gères ça du coup.
Parcequ’il faudrait qu’une lib client sache automatiquement que pour telle type de requête elle peut le faire sur tels endpoints.
J’imagine que ce serait pratique de regrouper des lots de services par profiles. avec des profiles par exemple fullfeatures que seul les pods ayant activés toutes les features disponibles, listés de manière déclarative sur un raw git ou un document iroh, pour simplifier les découvertes réseaux.

Les différents profiles sont à définir mais je ne sais pas si ça voudrait le coup de faire des profiles par app de l’écosystème (gecko, tikka, …), ou sur d’autre nomenclatures à définir.
Cet exercice permettrait de définir à l’avance les contours d’usage qu’on peut attendre d’un Dunipod.

Le protocole P2P de Dunipod prévoit déjà la déclaration, par chaque pod, des services qu’il supporte dans son manifeste, transmis lors de l’établissement de la connexion P2P. Les pods disposent donc déjà de ces informations ; il suffit de les exposer côté GraphQL.

Iroh est une bibliothèque de gestion réseau bas niveau, un peu comme libp2p. Ce n’est pas elle qui définit le format des documents.

Quant aux profils, pourquoi pas, mais cela risque de complexifier les choses s’il faut un nom de profil pour chaque combinaison possible de services. Je préfère exposer directement la liste des services supportés.

Si c’est dans ce but-là, alors d’accord pour définir des profils types, mais dans la documentation, pas dans le code ni dans l’API GraphQL. Ça me semble plus future-proof d’exposer simplement la liste des services supportés dans l’API.