Comment créer un chat chiffré de bout-en-bout avec PubNub et Seald 🔒💬
Vous pouvez retrouver le projet exemple à cet endroit : https://github.com/seald/pubnub-example-project
Bonjour 👋
Aujourd'hui, découvrons comment construire un chat (messagerie instantanée) chiffré avec Pubnub et le Seald-SDK.
👉 Un exemple entièrement fonctionnel de ce projet est disponible à l'adresse suivante : https://github.com/seald/pubnub-example-project
Qu'est-ce que Pubnub ? PubNub est une messagerie instantanée qui peut être intégré dans la plupart des applications. Fiable et évolutif, il s'intègre facilement aux frameworks les plus courants.
Qu'est-ce que Seald ? Seald offre un SDK qui vous permet de faire du chiffrement de bout-en-bout, sans aucune connaissance préalable en cryptographie. Ce SDK peut être intégré dans des applications web, backend, mobiles ou de bureau.
Pourquoi utiliser du chiffrement de bout-en-bout ? 🔒
Le chiffrement de bout-en-bout offre le plus haut niveau de confidentialité et de sécurité. Il permet de chiffrer les données sensibles dès qu'elles sont collectées. Un tel chiffrement réduit la surface d'attaque de votre application. L'autre atout majeur est que vous pouvez gérer précisément qui peut accéder aux données. Cela vous protégera également lorsque les données ne sont pas dans votre champ d'action, comme les services tiers.
Le chiffrement de bout-en-bout vous permet de garder le contrôle à tout moment, lorsque vos données sont en transit, lorsqu'elles sont au repos, même lorsqu'elles ne sont pas entre vos mains. Il offre donc une protection beaucoup plus large que les autres technologies de chiffrement (TLS, chiffrement intégral du disque, etc.).
Chaque fois que vous êtes confronté à un cas où la conformité est importante (RGPD, HIPAA, SOC-2, ...) ou que vous avez des données sensibles (médicales, défense, ...), le chiffrement de bout-en-bout est un must-have. Mais même pour des données plus banales, c'est une bonne pratique à avoir. Une fuite de données est un événement dévastateur qui devient de plus en plus fréquent.
Pourquoi utiliser Seald au lieu du chiffrement de PubNub ? 👀
Le SDK de PubNub offre un mécanisme de chiffrement simple, en utilisant l'argument cipherKey
lors de l'instanciation de son SDK. En procédant ainsi, vous vous assurez que tous les messages téléchargés sont chiffrés avant d'être envoyés. Cependant, vous devez vous charger vous-même de la gestion des clés, ce qui est la partie la plus difficile d'un système chiffré de bout-en-bout.
Les vulnérabilités proviennent rarement du chiffrement lui-même, mais le plus souvent de la fuite des clés par des failles dans le modèle de sécurité.
Seald.io propose un modèle de sécurité robuste, certifié par l'ANSSI, avec un contrôle de gestion des accès en temps réel, la révocation et la récupération des utilisateurs, le 2FA, etc.
Objectifs 🏆
Cet article explique comment intégrer Seald à PubNub pas à pas, afin de sécuriser votre messagerie avec du chiffrement de bout-en-bout. Nous allons construire un exemple d'application de messagerie, avec les fonctionnalités suivantes :
- Salles de chat individuelles et de groupe.
- Chaque membre dispose d'un salon de discussion dédié avec tous les autres utilisateurs.
- N'importe qui peut créer un salon de discussion de groupe avec plusieurs autres utilisateurs.
- Utilisez le chiffrement de bout-en-bout pour chaque message et fichier envoyé.
- Gestion en temps réel de l'accès aux chats.
Implémentation 🧠
Créer un compte PubNub 👤
Pour commencer, il vous faut un compte PubNub, que vous pouvez créer ici. Une fois connecté à votre tableau de bord, vous devriez voir que une application de démonstration avec un jeu de clés de démonstration a été créée.
Sélectionnez le jeu de clés de démonstration, et faites défiler jusqu'à l'onglet de configuration. Pour notre démo, nous devons activer les permissions Files
et Objects
. Pour la permission Objects
, nous allons utiliser les événements suivants : User Metadata Events
, Channel Metadata Events
andMembership Events
.
Une fois le jeu de clés créé et configuré, nous devons le copier sur notre frontend.
Créons un fichier JSON dans le dossier src/
, appelé settings.json
. Nous allons utiliser ce fichier pour toutes les clés API dont nous aurons besoin. En commençant par le jeu de clés PubNub :
{
"PUBNUB_PUB_KEY" : "pub-c-XXXXXXXX-XXXX-XXXXXX-XXXXXXXX",
"PUBNUB_SUB_KEY" : "sub-c-XXXXXXXX-XXXX-XXXX-XXXX-XXXX"
}
Création d'un chat à l'aide de PubNub 💬
Nous utiliserons PubNub pour presque toutes les tâches de backend. Notre backend ne gérera que l'inscription/la signature des utilisateurs, et utilisera un modèle d'utilisateur minimaliste avec seulement un ID, un nom et un email.
Du côté frontal, nous avons besoin d'une petite interface d'authentification.
Une fois que l'utilisateur a un compte, la première chose dont il a besoin est une instance du SDK PubNub.
Pour identifier l'utilisateur sur Pubnub, nous devons fournir un UUID. Pour garder les choses simples, nous allons utiliser le même id que sur notre backend.
/* frontend/src/App.js */
import settings from './settings.json' // notre fichier de paramètres pour les clés API
/*
...
*/
const pubnub = new PubNub({
publishKey: settings.PUBNUB_PUB_KEY,
subscribeKey: settings.PUBNUB_SUB_KEY,
uuid: currentUser.id
})
Pour garder notre backend aussi simple que possible, nous allons utiliser les métadonnées utilisateur de PubNub pour échanger les informations des utilisateurs. Juste après l'instanciation du SDK, nous appelons simplement la fonction setUUIDMetadata
de PubNub.
/* frontend/src/App.js */
await pubnub.objects.setUUIDMetadata({
uuid: currentUser.id,
data: {
email: currentUser.emailAddress,
name: currentUser.name
}
})
Obtenir l'état initial de l'application 🌱
La première chose à faire avec PubNub est de récupérer tous les membres existants et de les stocker dans notre magasin de données local.
/* frontend/src/App.js */
const existingMembers = await pubnub.objects.getAllUUIDMetadata()
dispatch({
type: SET_USERS,
payload: {
users: existingMembers.data.map(u => new User({ id: u.id, name: u.name, emailAddress: u.email })))
}
})
Chaque salon de discussion correspondra à un canal PubNub. Nous allons également ajouter quelques métadonnées à chaque canal :
ownerId
: L'ID de l'utilisateur qui a créé la salle.one2one
: Un booléen pour différencier les salons de messagerie directe, et les salons de groupe.archived
: Un booléen, pour cacher un espace de groupe supprimé.
Les métadonnées ownerId
seront utilisées plus tard, lors de l'ajout du SDK de Seald. PubNub n'a pas de concept de propriété, mais Seald en a un. Il définira qui peut ajouter ou supprimer des utilisateurs d'un canal. Il définit essentiellement un administrateur de groupe.
Nous allons commencer par récupérer les salons de discussion existants. Nous aurons également besoin des métadonnées des salons, et nous devrons donc inclure des champs personnalisés. Ensuite, nous devons filtrer les salles archivées et tout envoyer à notre magasin de données. Enfin, nous nous "abonnons" au canal PubNub associé à la salle, afin de recevoir les nouveaux messages.
/* frontend/src/App.js */
// Récupération des salles dont nous sommes membres
const memberships = await pubnub.objects.getMemberships({
include: {
customChannelFields: true
}
})
const knownRooms = []
// Pour chaque chambre, récupérer les membres de la chambre
for (const room of memberships.data.filter(r => !r.channel.custom.archived)) {
const roomMembers = await pubnub.objects.getChannelMembers({ channel : room.channel.id })
knownRooms.push(new Room({
id: chambre.canal.id,
nom: chambre.canal.nom,
users: roomMembers.data.map(u => u.uuid.id),
ownerId: room.channel.custom.ownerId,
one2one: room.channel.custom.one2one
}))
}
// Stocke les chambres dans notre magasin de données
dispatch({
type: SET_ROOMS,
payload: {
rooms: knownRooms
}
})
// S'abonner aux canaux pour recevoir les nouveaux messages
pubnub.subscribe({ channels : knownRooms.map(r => r.id) })
Maintenant nous avons récupéré toutes les chambres dans lesquelles nous sommes. Nous avons besoin d'une dernière chose pour terminer l'initialisation de l'application : s'assurer que nous avons un salon one2one
avec chaque autre membre, y compris les nouveaux inscrits.
Pour chaque utilisateur nouvellement trouvé, nous allons créer un nouveau salon et envoyer un message de bienvenue. Ensuite, nous définirons les métadonnées de la salle, et nous nous abonnerons à elle.
/* frontend/src/App.js */
// Assurez-vous que nous avons une salle one2one avec tout le monde.
const one2oneRooms = knownRooms.filter(r => r.one2one)
for (const m of existingMembers.data.filter(u => u.id!== currentUser.id)) {
if (!one2oneRooms.find(r => r.users.includes(m.id)))) {
// Nouvel utilisateur trouvé : génération d'une nouvelle chambre one2one
const newRoomId = PubNub.generateUUID()
const newRoom = new Room({
id: newRoomId,
utilisateurs: [currentUser.id, m.id],
one2one: true,
nom: m.name,
ownerId: currentUser.id
})
// Ajoute la nouvelle chambre à notre liste locale
dispatch({
type: EDIT_OR_ADD_ROOM,
payload: {
room: new Room({
id: newRoomId,
users: [currentUser.id, m.id],
one2one: true,
name: m.name, ownerId : currentUser.id
})
}
})
// Publiez un message "Hello" dans la pièce
await pubnub.publish({
channel: newRoomId,
message: {
type: 'message',
data: (await sealdSession.encryptMessage('Hello 👋'))
}
})
// S'abonner à la nouvelle salle
pubnub.subscribe({ channels : [newRoomId] })
await pubnub.objects.setChannelMetadata({
channel: newRoomId,
data: {
name: 'one2one',
custom: {
one2one: true,
ownerId: currentUser.id,
},
}
})
await pubnub.objects.setChannelMembers({
channel: newRoomId,
uuids: [currentUser.id, m.id]
})
}
}
Une fois que tout cela est fait, l'état initial de notre application est entièrement défini. Cependant, nous devons le maintenir à jour.
Cela peut être fait simplement en ajoutant un écouteur d'événements pour les événements membership
.
/* frontend/src/App.js */
pubnub.addListener({
objects : async function(objectEvent) {
if (objectEvent.message.type === 'membership') {
if (objectEvent.message.event === 'delete') { // L'utilisateur est supprimé d'un espace.
/*
Suppression de la chambre du magasin...
*/
}
if (objectEvent.message.event === 'set') { // L'utilisateur est ajouté à une chambre.
const metadata = await pubnub.objects.getChannelMetadata({ channel : objectEvent.message.data.channel.id })
const roomMembers = (await pubnub.objects.getChannelMembers({ channel : objectEvent.message.data.channel.id })).data.map(u => u.uuid.id)
/*
Ajout d'une nouvelle chambre au magasin + abonnement au canal de la nouvelle chambre...
*/
}
}
}
})
pubnub.subscribe({ channels : [currentUser.id] }) // canal sur lequel sont publiés les événements concernant l'utilisateur actuel
Nous pouvons maintenant jeter un coup d'oeil à un salon de discussion one2one
en lui-même. Nous allons ensuite nous occuper des salles de groupe.
Recevoir et envoyer des messages dans un salon de discussion 📩
Dans un fichier chat.js
, nous aurons toute la logique pour afficher les messages d'une salle de chat.
Pour initialiser cette salle, la seule chose que nous devons faire est de récupérer tous les messages préexistants. Cela peut être fait simplement en connaissant l'ID de la salle.
/* frontend/src/components/Chat.jsx */
const fetchedMessages = (await pubnub.fetchMessages({ channels : [currentRoomId] })).channels[currentRoomId]
Nous pouvons nous abonner au canal pour obtenir les nouveaux messages, et ajouter un listner pour les afficher en temps réel.
/* frontend/src/components/Chat.jsx */
pubnub.addListener({ message : handleReceiveMessage })
pubnub.subscribe({ channels : [currentRoomId] })
Pour envoyer un message, il suffit de le publier sur le canal :
/* frontend/src/components/Chat.jsx */
const handleSubmitMessage = async e => {
/* Quelques vérifications que la pièce est dans un état correct... */
await pubnub.publish({
channel: state.room.id,
message: {
type: 'message',
data: state.message
}
})
}
Pour envoyer un fichier, nous allons d'abord le télécharger sur PubNub. Ensuite, nous récupérerons l'URI du fichier téléchargé et le publierons sous forme de message dans la salle de discussion.
/* frontend/src/components/UploadButton.jsx */
// Téléchargement d'un fichier chiffré
const uploadData = await pubnub.sendFile({
channel: room.id,
file: myFile,
storeInHistory: false
})
const fileURL = await pubnub.getFileUrl({ id: uploadData.id, name: uploadData.name, channel: room.id })
await pubnub.publish({
channel: state.room.id,
message: {
type: 'file',
url: fileURL,
fileName: await sealdSession.encryptMessage(selectedFiles[0].name)
}
})
Gérer les groupes de discussion 👨👩👦👦
Pour créer et gérer des groupes, nous aurons besoin d'une interface pour sélectionner les utilisateurs.
Une fois que les membres du groupe ont été sélectionnés, nous pouvons créer un canal PubNub pour notre salle, puis définir les métadonnées et les membres pour le canal. Le code est très similaire à ce qui est fait pour les salles one2one, nous ne le répéterons donc pas ici.
Nous avons maintenant une application de chat complète. Ajoutons le chiffrement de bout-en-bout pour chaque message!
Ajout du chiffrement de bout-en-bout avec Seald 🔒💬
Créer un compte Seald 👤
Pour commencer avec Seald, créer un compte d'essai gratuit ici
En atterrissant sur le tableau de bord de Seald, quelques URL et jetons d'API s'affichent.
Récupérez les éléments suivants :
- appId
- apiURL
- keyStorageURL
Nous allons ajouter ces clés à notre settings.json
{
"PUBNUB_PUB_KEY": "pub-c-XXXXXXXX-XXXX-XXXXXX-XXXXXXXX",
"PUBNUB_SUB_KEY": "sub-c-XXXXXXXX-XXXX-XXXXXX-XXXXXX",
"SEALD_APP_ID": "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX",
"SEALD_API_URL": "https://api.staging-0.seald.io",
"SEALD_KEYSTORAGE_URL": "https://ssks.staging-0.seald.io"
}
**Pour pouvoir utiliser le SDK de Seald, chaque utilisateur a besoin d'une licence JWT lors de son inscription.
Ces JWT doivent être générés sur le backend en utilisant un secret et un ID secret.
Depuis la page d'accueil du tableau de bord, copiez le secret JWT et son ID associé dans backend/settings.json
{
"SEALD_JWT_SECRET_ID": "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXX",
"SEALD_JWT_SECRET": "XXXXXXXXXXXXXXXXXXXXXXXX"
}
Au cours de l'appel de l'API d'inscription, nous allons générer un JWT de licence Seald et le renvoyer.
/* backend/routes/account.js */
const token = new SignJWT({
iss: settings.SEALD_JWT_SECRET_ID,
jti: uuidv4(), /// Chaîne aléatoire avec suffisamment d'entropie pour ne jamais se répéter.
iat: Math.floor(Date.now() / 1000), // JWT valide uniquement pendant 10 minutes. `Date.now()` renvoie l'information en millisecondes, ici on en a besoin en secondes.
scopes: [3], // PERMISSION_JOIN_TEAM
join_team: true
})
.setProtectedHeader({ alg: 'HS256' })
const signupJWT = await token.sign(Buffer.from(settings.SEALD_JWT_SECRET, 'ascii'))
Pour plus d'informations à ce sujet, consultez notre article de documentation sur JWT.
Ensuite, nous devons installer le SDK de Seald.
Nous devons également installer un plugin pour identifier les utilisateurs sur le serveur Seald. Pour cela, nous allons utiliser le paquet sdk-plugin-ssks-password
.
Ce plugin permet une authentification simple par mot de passe de nos utilisateurs.
npm i -S @seald-io/sdk @seald-io/sdk-plugin-ssks-password
Instanciation du SDK de Seald 💡
Ensuite, nous allons créer un fichier seald.js
. Dans ce fichier, nous allons commencer par créer une fonction pour instancier le SDK Seald.
/* frontend/src/services/seald.js */
import SealdSDK from '@seald-io/sdk-web'
import SealdSDKPluginSSKSPassword de '@seald-io/sdk-plugin-ssks-password'
import settings from './settings.json'
let sealdSDKInstance = null
const instantiateSealdSDK = async () => {
sealdSDKInstance = SealdSDK({
appId: settings.SEALD_APP_ID,
apiURL: settings.SEALD_API_URL,
plugins: [SealdSDKPluginSSKSPassword(settings.SEALD_KEYSTORAGE_URL)]
})
}
Création et récupération des identités Seald 🔑
Dans seald.js
, nous allons également ajouter deux fonctions : une pour créer une identité et une pour en récupérer une. Pour créer une identité, nous avons également besoin de la licence JWT retournée lors de la création du compte.
/* frontend/src/services/seald.js */
export const createIdentity = async ({ userId, password, signupJWT }) => {
await instantiateSealdSDK()
await sealdSDKInstance.initiateIdentity({ signupJWT })
await sealdSDKInstance.ssksPassword.saveIdentity({ userId, password })
}
export const retrieveIdentity = async ({ userId, password }) => {
await instantiateSealdSDK()
await sealdSDKInstance.ssksPassword.retrieveIdentity({ userId, password })
}
Au cours de nos flux d'inscription et d'ouverture de session, il nous suffit d'appeler ces fonctions après que l'utilisateur s'est connecté.
À ce stade, chaque fois que notre utilisateur est connecté, il dispose d'un SDK Seald opérationnel, prêt à chiffrer et déchiffrer.
Avertissement : Pour une bonne sécurité, le mot de passe doit être pré-haché avant d'être envoyé au serveur d'authentification. Pour plus de détails à ce sujet, veuillez consulter le paragraphe sur l'authentification par mot de passe dans notre documentation.
Commencez à chiffrer et à déchiffrer les messages 🔒🔓
Chaque salle de chat sera associée à une encryptionSession
sur le SDK de Seald.
Chaque fois que nous créons un salon de discussion, il suffit d'ajouter une ligne pour créer la session de chiffrement, puis de l'utiliser :
/* frontend/src/App.js */
// Créez une session Seald
const sealdSession = await getSealdSDKInstance().createEncryptionSession(
{ userIds: [currentUser.id, m.id] },
{ metadata: newRoomId }
)
// Publiez un message "Hello" dans la pièce
await pubnub.publish({
channel: newRoomId,
message: {
type: 'message',
data: (await sealdSession.encryptMessage('Hello 👋'))
}
})
Notez que notre utilisateur est inclu par défaut comme destinataire de la encryptionSession
.
Lorsque nous accédons à une pièce, nous devons obtenir la encryptionSession
correspondante. Il peut être récupéré à partir de n'importe quel message ou fichier chiffré. Une fois que nous l'avons, nous le gardons dans la référence du composant.
Ensuite, nous pouvons simplement utiliser les fonctions session.encryptMessage
, session.encryptFile
, session.decryptMessage
et session.decryptFile
.
Commençons par les messages. Pour envoyer un message :
/* frontend/src/components/Chat.js */
const handleSubmitMessage = async m => {
/* Une certaine validation que nous sommes dans une pièce valide... */
// s'il n'y a pas encore de session de chiffrement dans le cache, en créer une
// (cela ne devrait jamais arriver, car un "Hello" est envoyé à la création de la salle)
if (!sealdSessionRef.current) {
sealdSessionRef.current = await getSealdSDKInstance().createEncryptionSession(
{ userIds: state.room.users },
{ metadata: state.room.id }
)
}
// utiliser la session pour chiffrer le message que nous essayons d'envoyer
const encryptedMessage = await sealdSessionRef.current.encryptMessage(state.message)
// publier le message chiffré à pubnub
await pubnub.publish({
channel: state.room.id,
message: {
type: 'message',
data: encryptedMessage
}
})
/* Un peu de nettoyage... */
}
Et quand nous recevons un message :
/* frontend/src/components/Chat.js */
const decryptMessage = async m => {
/* Filtrer les fichiers... */
let encryptedData = m.message.data
if (!sealdSessionRef.current) { // aucune session de chiffrement n'a encore été définie dans le cache
// nous essayons de l'obtenir en analysant le message actuel
sealdSessionRef.current = await getSealdSDKInstance().retrieveEncryptionSession({ encryptedMessage : encryptedData })
// maintenant que nous avons une session chargée, déchiffrons.
}
const decryptedData = await sealdSessionRef.current.decryptMessage(encryptedData)
// nous avons décrypté le message avec succès
return {
...m,
uuid: m.uuid || m.publisher,
value: decryptedData
}
/* Un peu de gestion des erreurs... */
}
/* Autre chose... */
const handleReceiveMessage = async m => {
const. decryptedMessage = await decryptMessage(m)
setState(draft => {
draft.messages = [...draft.messages, decryptedMessage]
})
}
Nous utilisons également cette fonction decryptMessage
pour déchiffrer tous les messages déjà présents dans la session lors de l'ouverture d'une pièce :
/* frontend/src/components/Chat.js */
const fetchedMessages = (await pubnub.fetchMessages({chaînes : [currentRoomId] })).chaînes[currentRoomId])
const clearMessages = fetchedMessages ? await Promise.all(fetchedMessages.map(decryptMessage)) : []
Et maintenant, les fichiers. Pour télécharger un fichier :
/* frontend/src/components/UploadButton.js */
// Chiffrement du fichier
const encryptedBlob = await sealdSession.encryptFile(
selectedFiles[0],
selectedFiles[0].name,
{ fileSize: selectedFiles[0].size }
)
const encryptedFile = new File([encryptedBlob], selectedFiles[0].name)
// Téléchargement du fichier chiffré
const uploadData = await pubnub.sendFile({
channel: room.id,
file: encryptedFile,
storeInHistory: false
})
Et pour déchiffrer un fichier :
/* frontend/src/components/Message.js */
const onClick = async () => {
if (state.data.type === 'file') {
const response = await fetch(state.data.url)
const encryptedBlob = await response.blob()
const { data: clearBlob, filename } = await sealdSession.decryptFile(encryptedBlob)
const href = window.URL.createObjectURL(clearBlob)
/* Create an <a> element and simulate a click on it to download the created objectURL */
}
}
Gérer les membres du groupe 👨👩👦
Les chats de groupe auront aussi leur encryptionSession
. À chaque fois qu'un groupe est créé, nous devons en créer une.
/* frontend/src/components/ManageDialogRoom.js.js */
// Pour créer l'encryptionSession
const sealdSession = await getSealdSDKInstance().createEncryptionSession(
{ userIds: dialogRoom.selectedUsersId },
{ metadata: newRoomId }
)
Ensuite, à chaque fois que nous modifions les membres du groupe, nous devrons les ajouter ou les supprimer de celui-ci.
/* frontend/src/components/ManageDialogRoom.js.js */
// nous comparons les anciens et les nouveaux membres pour déterminer ceux qui viennent d'être ajoutés ou supprimés
const usersToRemove = dialogRoom.room.users.filter(id => !dialogRoom.selectedUsersId.includes(id))
const usersToAdd = dialogRoom.selectedUsersId.filter(id => !dialogRoom.room.users.includes(id))
if (usersToAdd.length > 0) {
// pour chaque utilisateur ajouté, ajoutez-le à la session Seald
await dialogRoom.sealdSession.addRecipients({ userIds: usersToAdd })
// puis les ajouter au canal pubnub
await pubnub.objects.setChannelMembers({
channel: dialogRoom.room.id,
uuids: usersToAdd
})
}
if (usersToRemove.length > 0) {
// pour chaque utilisateur supprimé, révoquez-le de la session Seald
await dialogRoom.sealdSession.revokeRecipients({ userIds: usersToRemove })
// puis les supprimer du canal pubnub
for (const u of usersToRemove) {
await pubnub.objects.removeMemberships({
channels: [dialogRoom.room.id],
uuid: u
})
}
}
Conclusion ✅
Et le tour est joué !
Nous avons réussi à intégrer Seald dans PubNub avec seulement quelques lignes de code.
Maintenant que le chat est chiffré de bout en bout, vous pouvez assurer à vos utilisateurs que leurs données resteront confidentielles, même en cas de fuites de données.
Comme toujours, n'hésitez pas à nous contacter si vous avez besoin de conseils supplémentaires.
Nous sommes impatients de voir ce que vous avez construit 🥳.
Ps : vous utilisez Stream plutôt que PubNub ? Voici un article qui explique commente intégrer Seald dans PubNub.