Compare commits

..

7 Commits

Author SHA1 Message Date
admin eacffd7755 docs: record the decision to keep choral-permission and permissions-template
Build release Docker image / Build Docker Images (push) Successful in 21s
Inutilisés (aucune référence, 0 ligne) mais conservés sciemment le
27/07/2026 comme fondation d'un modèle de permissions plus riche.
Tableau des content-types mis à jour après la suppression de
conversation et direct-message.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 17:59:49 +02:00
admin 3e9f5c3f60 0.13.25 : drop the dead legacy direct-message content-types
Build release Docker image / Build Docker Images (push) Successful in 8m5s
`conversation` et `direct-message` n'ont plus aucun appelant depuis la
suppression de choral-old (front 0.18.17) : aucune référence dans le
back, aucune dans le front, et 0 ligne en base.

- content-types supprimés
- leurs 4 permissions retirées de permissions-sync

⚠️ La synchro étant additive, les permissions déjà présentes en base ne
sont pas supprimées automatiquement : elles apparaîtront désormais dans
la liste des permissions non déclarées au démarrage, à décocher dans
l'admin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 17:55:09 +02:00
admin ec1d9bc1a4 0.13.24 : move the conversations endpoint off a colliding path
Build release Docker image / Build Docker Images (push) Successful in 8m22s
/chat-conversation-members/mine pouvait être capté par le routeur cœur
comme un findOne avec id = "mine", selon l'ordre d'enregistrement des
routes — et les deux répondent 403 sans jeton, ce qui rendait le
diagnostic impossible. L'endpoint devient /api/my-chat-conversations.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 17:11:18 +02:00
admin a93d48cada 0.13.23 : return the caller's conversations without opening private messages
Build release Docker image / Build Docker Images (push) Successful in 7m47s
La messagerie affichait des conversations vides : ni nom, ni
interlocuteur, ni message. Le front peuplait `conversation` et ses
`messages` via l'API REST, dont le sanitizer retire toute relation vers
un content-type que le rôle ne peut pas lire — or ni
`chat-conversation.find` ni `chat-message.find` ne sont accordées.

Les accorder aurait suffi, mais aurait ouvert la lecture de TOUTES les
conversations et de TOUS les messages privés à n'importe quel compte
connecté. On ajoute donc une action dédiée :

- GET /api/chat-conversation-members/mine — conversations du seul
  appelant, avec interlocuteurs et messages, renvoyées via ctx.send
- mot de passe et jetons retirés des utilisateurs peuplés
- permission `mine` déclarée dans permissions-sync, avec la raison pour
  laquelle les deux `find` restent volontairement absentes

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 16:33:49 +02:00
admin 62064f053a 0.13.22 : tell a join request apart from an invitation
Build release Docker image / Build Docker Images (push) Successful in 8m5s
Le controller forçait `state = "pending_user_approval"` quel que soit le
sens — commentaire à l'appui : « On force le statut en attente peu
importe le type d'invité ». Une candidature déposée par un utilisateur
ressortait donc en invitation, et il recevait un email « vous avez été
invité » pour une chorale qu'il venait lui-même de demander à rejoindre.

- une adhésion créée par un utilisateur pour lui-même est une
  candidature : `pending_admin_approval`, à valider par un administrateur
- rôle imposé à `member` dans ce cas : sans cela, une candidature pouvait
  se déclarer `owner` de la chorale visée
- aucun email d'invitation n'est envoyé pour une candidature
- l'invitation, elle, garde son comportement : `pending_user_approval`,
  token pour un email externe, notification pour un compte existant

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 14:02:01 +02:00
admin e168b6c826 0.13.21 : create the owner membership when a choral is created
Build release Docker image / Build Docker Images (push) Successful in 7m57s
Le créateur d'une chorale n'avait aucune adhésion : la chorale
n'apparaissait pas dans « Mes chorales » et son créateur n'en était pas
propriétaire — il n'avait donc aucun droit sur ce qu'il venait de créer.
Seule la recherche la montrait.

Le controller crée désormais une choral-membership `owner` / `active`
après la création, et journalise l'anomalie si l'id de la chorale n'est
pas exploitable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 11:51:36 +02:00
admin c0a0966557 0.13.20 : check environment variables at boot
Build release Docker image / Build Docker Images (push) Successful in 7m58s
Trois erreurs de configuration successives — clés R2 pointant sur
l'ancien MinIO, `wwhsec_` au lieu de `whsec_`, `k_test_` amputé de son
`s` — n'ont produit aucun message exploitable. Chacune se manifestait
très loin de sa cause : un upload qui échoue, un webhook rejeté.

src/env-check.ts vérifie au bootstrap la forme des variables sensibles
(Stripe, R2, SMTP) : présence, préfixe, longueur, et les altérations de
copier-coller (guillemets englobants, espaces parasites). Les trois
erreurs ci-dessus sont détectées.

Ne bloque pas le démarrage — une clé SMTP erronée ne doit pas empêcher
le site de servir — mais journalise en erreur, une ligne par anomalie.
Aucune valeur n'est journalisée, uniquement des longueurs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 01:23:51 +02:00
17 changed files with 335 additions and 127 deletions
+12 -3
View File
@@ -35,22 +35,31 @@ config/ # server, database, plugins, middlewares, cron-tasks
## Content-types ## Content-types
Vue par domaine (34 au total, liste exhaustive dans `src/api/`) : Vue par domaine (32 au total, liste exhaustive dans `src/api/`) :
| Domaine | Content-types | | Domaine | Content-types |
|---|---| |---|---|
| Chorale | `choral`, `choral-membership`, `choral-permission`, `permissions-template` | | Chorale | `choral`, `choral-membership`, `choral-permission` ⚠️, `permissions-template` ⚠️ |
| Social (offre gratuite) | `post`, `post-ownership`, `comment`, `activity`, `report` | | Social (offre gratuite) | `post`, `post-ownership`, `comment`, `activity`, `report` |
| Groupes | `group`, `group-membership` | | Groupes | `group`, `group-membership` |
| Événements | `event`, `event-relationship` | | Événements | `event`, `event-relationship` |
| Boards (kanban) | `board`, `board-list`, `board-card` | | Boards (kanban) | `board`, `board-list`, `board-card` |
| Messagerie | `channel`, `chat-conversation`, `chat-conversation-member`, `chat-message`, `conversation`, `direct-message`, `message` | | Messagerie **deux systèmes distincts**, inventaire détaillé dans `../CLAUDE.md` | générale : `chat-conversation`, `chat-conversation-member`, `chat-message` · chorale : `channel`, `message` |
| Notifications | `notification`, `announcement`, `invite` | | Notifications | `notification`, `announcement`, `invite` |
| Paiement | `order` (Stripe — ⚠️ route custom `auth: false` dans `src/api/order/routes/order.ts`, webhook) | | Paiement | `order` (Stripe — ⚠️ route custom `auth: false` dans `src/api/order/routes/order.ts`, webhook) |
| Contenu / divers | `page`, `legal-page`, `ad`, `contact`, `contact-meta`, `form-template`, `mails` | | Contenu / divers | `page`, `legal-page`, `ad`, `contact`, `contact-meta`, `form-template`, `mails` |
<!-- Tableau détaillé (champs sensibles, accès par rôle) à compléter après export des permissions. --> <!-- Tableau détaillé (champs sensibles, accès par rôle) à compléter après export des permissions. -->
⚠️ `choral-permission` et `permissions-template` sont **inutilisés** : aucune
référence dans le back ni le front, 0 ligne en base. La gestion des permissions
passe en réalité par le composant `permissions` de `choral-membership`,
`permission_exceptions`, et `choral.available_roles` — c'est là que
`updatePermissionsTemplatesAction` écrit. **Décision du 27/07/2026 : on les
conserve**, comme fondation d'un modèle de permissions plus riche. Ne pas les
proposer à la suppression sans une nouvelle décision. Leurs permissions
`permissions-template.create/delete` restent accordées pour rien.
## Sécurité — règles NON NÉGOCIABLES ## Sécurité — règles NON NÉGOCIABLES
- Aucune route custom en `auth: false` sans justification écrite en commentaire - Aucune route custom en `auth: false` sans justification écrite en commentaire
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "harmony-back", "name": "harmony-back",
"version": "0.13.19", "version": "0.13.25",
"private": true, "private": true,
"description": "A Strapi application", "description": "A Strapi application",
"scripts": { "scripts": {
@@ -2,6 +2,85 @@
* chat-conversation-member controller * chat-conversation-member controller
*/ */
import { factories } from '@strapi/strapi' import { factories } from "@strapi/strapi";
export default factories.createCoreController('api::chat-conversation-member.chat-conversation-member'); export default factories.createCoreController(
"api::chat-conversation-member.chat-conversation-member",
({ strapi }) => ({
/**
* Conversations de l'utilisateur courant, avec leurs interlocuteurs et
* leurs messages.
*
* Pourquoi une action dédiée plutôt que le `find` générique : le front
* peuplait `conversation` et ses `messages` à travers l'API REST, dont le
* sanitizer retire toute relation vers un content-type que le rôle n'a pas
* le droit de lire. Les conversations revenaient donc vides — ni nom, ni
* interlocuteur, ni message.
*
* Accorder `chat-conversation.find` et `chat-message.find` aurait suffi à
* lever le filtrage, mais aurait ouvert la lecture de **toutes** les
* conversations et de **tous** les messages privés à n'importe quel compte
* connecté. Cette action retourne les données via `ctx.send`, en les
* limitant à l'appelant.
*/
async mine(ctx) {
const userId = ctx.state.user?.id;
if (!userId) {
return ctx.unauthorized();
}
const members = await strapi.db
.query("api::chat-conversation-member.chat-conversation-member")
.findMany({
where: { user: { id: userId } },
populate: {
user: true,
conversation: {
populate: {
users: { populate: { avatar: true } },
messages: {
populate: {
sender: { populate: { avatar: true } },
media: true,
},
},
},
},
},
});
// Les entités portent le mot de passe et les jetons des utilisateurs
// peuplés : on ne renvoie jamais ces champs au navigateur.
const stripUser = (user: any) => {
if (!user) return user;
const {
password,
resetPasswordToken,
confirmationToken,
...safeUser
} = user;
return safeUser;
};
const sanitized = members.map((member: any) => ({
...member,
user: stripUser(member.user),
conversation: member.conversation
? {
...member.conversation,
users: (member.conversation.users ?? []).map(stripUser),
messages: (member.conversation.messages ?? []).map(
(message: any) => ({
...message,
sender: stripUser(message.sender),
})
),
}
: null,
}));
ctx.send(sanitized);
},
})
);
@@ -0,0 +1,16 @@
/**
* Routes personnalisées de chat-conversation-member.
*/
export default {
routes: [
{
method: "GET",
// Chemin distinct : sous /chat-conversation-members/:id, le routeur
// cœur capterait « mine » comme un identifiant (findOne), selon l'ordre
// d'enregistrement des routes.
path: "/my-chat-conversations",
handler: "chat-conversation-member.mine",
},
],
};
@@ -8,19 +8,33 @@ export default factories.createCoreController(
const { data } = ctx.request.body; const { data } = ctx.request.body;
const inviter = ctx.state.user; const inviter = ctx.state.user;
// 1. Déterminer le type d'invitation // 1. Déterminer le sens de la demande.
// Une adhésion peut naître dans deux sens opposés, que ce controller
// confondait : il forçait `pending_user_approval` et envoyait un
// email d'invitation dans tous les cas — y compris à quelqu'un qui
// venait lui-même de demander à rejoindre la chorale.
const isSelfRequest =
!!data.user && !!inviter?.id && String(data.user) === String(inviter.id);
const isExternalInvite = !!data.invite_email && !data.user; const isExternalInvite = !!data.invite_email && !data.user;
const isInternalInvite = !!data.user; const isInternalInvite = !!data.user && !isSelfRequest;
let token = null; let token = null;
let targetEmail = null; let targetEmail = null;
// 2. Configuration commune // 2. Statut selon le sens : c'est à l'autre partie de se prononcer.
// On force le statut "en attente" peut importe le type d'invité if (isSelfRequest) {
ctx.request.body.data.state = "pending_admin_approval";
// Rôle imposé : sans cela, une candidature pourrait se déclarer
// `owner` de la chorale qu'elle demande à rejoindre.
ctx.request.body.data.role = "member";
ctx.request.body.data.invite_token = null;
ctx.request.body.data.invite_email = null;
} else {
ctx.request.body.data.state = "pending_user_approval"; ctx.request.body.data.state = "pending_user_approval";
if (!data.role) { if (!data.role) {
ctx.request.body.data.role = "member"; ctx.request.body.data.role = "member";
} }
}
// 3. Traitement spécifique selon le cas // 3. Traitement spécifique selon le cas
if (isExternalInvite) { if (isExternalInvite) {
@@ -51,8 +65,10 @@ export default factories.createCoreController(
// 4. Exécution de la création native par Strapi // 4. Exécution de la création native par Strapi
const response = await super.create(ctx); const response = await super.create(ctx);
// 5. Post-traitement : Envoi de l'email // 5. Post-traitement : Envoi de l'email d'invitation.
if (response && targetEmail) { // Jamais pour une candidature : `targetEmail` reste nul dans ce cas,
// on n'écrit pas « vous avez été invité » à qui vient de postuler.
if (response && targetEmail && !isSelfRequest) {
try { try {
const chorals = await strapi const chorals = await strapi
.documents("api::choral.choral") .documents("api::choral.choral")
+22
View File
@@ -44,6 +44,28 @@ export default factories.createCoreController(
ctx.request.body = { data }; ctx.request.body = { data };
const result = await super.create(ctx); const result = await super.create(ctx);
const choralId = (result as any)?.data?.id;
// Adhésion propriétaire : sans elle, le créateur n'apparaît pas dans
// « Mes chorales » et n'a aucun droit sur ce qu'il vient de créer.
if (choralId) {
await strapi.db.query("api::choral-membership.choral-membership").create({
data: {
user: user.id,
choral: choralId,
role: "owner",
state: "active",
},
});
strapi.log.info(
`✅ Chorale ${choralId} créée par l'utilisateur ${user.id}, adhésion propriétaire enregistrée`,
);
} else {
strapi.log.error(
`❌ Chorale créée sans id exploitable : aucune adhésion propriétaire pour l'utilisateur ${user.id}`,
);
}
// Décrémente le crédit de création une fois la chorale créée. // Décrémente le crédit de création une fois la chorale créée.
await strapi.db.query("plugin::users-permissions.user").update({ await strapi.db.query("plugin::users-permissions.user").update({
where: { id: user.id }, where: { id: user.id },
@@ -1,32 +0,0 @@
{
"kind": "collectionType",
"collectionName": "conversations",
"info": {
"singularName": "conversation",
"pluralName": "conversations",
"displayName": "Conversation",
"description": ""
},
"options": {
"draftAndPublish": false
},
"pluginOptions": {},
"attributes": {
"user1": {
"type": "relation",
"relation": "oneToOne",
"target": "plugin::users-permissions.user"
},
"user2": {
"type": "relation",
"relation": "oneToOne",
"target": "plugin::users-permissions.user"
},
"direct_messages": {
"type": "relation",
"relation": "oneToMany",
"target": "api::direct-message.direct-message",
"mappedBy": "conversation"
}
}
}
@@ -1,7 +0,0 @@
/**
* conversation controller
*/
import { factories } from '@strapi/strapi'
export default factories.createCoreController('api::conversation.conversation');
@@ -1,7 +0,0 @@
/**
* conversation router
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreRouter('api::conversation.conversation');
@@ -1,7 +0,0 @@
/**
* conversation service
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreService('api::conversation.conversation');
@@ -1,33 +0,0 @@
{
"kind": "collectionType",
"collectionName": "direct_messages",
"info": {
"singularName": "direct-message",
"pluralName": "direct-messages",
"displayName": "DirectMessage",
"description": ""
},
"options": {
"draftAndPublish": false
},
"pluginOptions": {},
"attributes": {
"content": {
"type": "string"
},
"fileUrl": {
"type": "string"
},
"author": {
"type": "relation",
"relation": "oneToOne",
"target": "plugin::users-permissions.user"
},
"conversation": {
"type": "relation",
"relation": "manyToOne",
"target": "api::conversation.conversation",
"inversedBy": "direct_messages"
}
}
}
@@ -1,7 +0,0 @@
/**
* direct-message controller
*/
import { factories } from '@strapi/strapi'
export default factories.createCoreController('api::direct-message.direct-message');
@@ -1,7 +0,0 @@
/**
* direct-message router
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreRouter('api::direct-message.direct-message');
@@ -1,7 +0,0 @@
/**
* direct-message service
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreService('api::direct-message.direct-message');
+164
View File
@@ -0,0 +1,164 @@
import type { Core } from "@strapi/strapi";
/**
* Contrôle des variables d'environnement au démarrage.
*
* Motivation : trois erreurs de configuration successives (clés R2 pointant
* sur l'ancien MinIO, `wwhsec_` au lieu de `whsec_`, `k_test_` amputé de son
* `s`) n'ont produit aucun message exploitable. Chacune s'est manifestée très
* loin de sa cause — un upload qui échoue, un webhook rejeté — et a coûté des
* heures de diagnostic.
*
* Ce module ne vérifie que ce qui est vérifiable sans appel réseau : présence,
* préfixe, longueur, et les altérations de copier-coller (guillemets
* englobants, espaces parasites). Il ne valide évidemment pas qu'une clé est
* la *bonne* — seulement qu'elle a la forme attendue.
*
* Il ne bloque pas le démarrage : une clé SMTP erronée ne doit pas empêcher
* le site de servir. Il journalise en erreur, ce qui suffit à orienter le
* diagnostic dès le boot.
*
* ⚠️ Aucune valeur n'est journalisée, jamais — uniquement des longueurs.
*/
type EnvRule = {
name: string;
/** Préfixe attendu, le cas échéant. */
prefix?: string;
/** Longueur exacte attendue. */
length?: number;
/** Longueur minimale attendue. */
minLength?: number;
/** Contexte affiché dans le message, pour orienter la correction. */
usedFor: string;
};
const ENV_RULES: EnvRule[] = [
// --- Paiement
{
name: "STRIPE_SECRET_KEY",
prefix: "sk_",
minLength: 20,
usedFor: "appels à l'API Stripe",
},
{
name: "STRIPE_WEBHOOK_SECRET",
prefix: "whsec_",
minLength: 30,
usedFor: "vérification de signature des webhooks Stripe",
},
// --- Stockage des médias (Cloudflare R2)
{
name: "R2_ACCESS_KEY_ID",
length: 32,
usedFor: "upload des médias",
},
{
name: "R2_SECRET_ACCESS_KEY",
length: 64,
usedFor: "upload des médias",
},
{
name: "R2_ENDPOINT",
prefix: "https://",
usedFor: "upload des médias",
},
{
name: "R2_BUCKET",
minLength: 1,
usedFor: "upload des médias",
},
{
name: "R2_PUBLIC_URL",
prefix: "https://",
usedFor: "URLs publiques des médias",
},
// --- Email transactionnel
{
name: "SMTP_PASSWORD",
minLength: 10,
usedFor: "envoi des emails transactionnels",
},
];
/** Guillemets englobants ou espaces parasites laissés par un copier-coller. */
const findCopyPasteDamage = (raw: string): string | null => {
if (raw !== raw.trim()) {
return "espace ou retour à la ligne en début ou fin de valeur";
}
const quoted =
(raw.startsWith('"') && raw.endsWith('"')) ||
(raw.startsWith("'") && raw.endsWith("'"));
if (quoted && raw.length > 1) {
return "valeur entourée de guillemets (ils font partie de la valeur)";
}
return null;
};
const checkRule = (rule: EnvRule): string[] => {
const raw = process.env[rule.name];
if (raw === undefined || raw === "") {
return [`${rule.name} n'est pas définie — ${rule.usedFor}`];
}
const problems: string[] = [];
const damage = findCopyPasteDamage(raw);
if (damage) {
problems.push(`${rule.name} : ${damage}`);
}
const value = raw.trim();
if (rule.prefix && !value.startsWith(rule.prefix)) {
problems.push(
`${rule.name} ne commence pas par « ${rule.prefix} » — ${rule.usedFor}`,
);
}
if (rule.length !== undefined && value.length !== rule.length) {
problems.push(
`${rule.name} fait ${value.length} caractères, ${rule.length} attendus — ${rule.usedFor}`,
);
}
if (rule.minLength !== undefined && value.length < rule.minLength) {
problems.push(
`${rule.name} fait ${value.length} caractères, au moins ${rule.minLength} attendus — ${rule.usedFor}`,
);
}
return problems;
};
/**
* Vérifie la forme des variables d'environnement et journalise les anomalies.
* @returns le nombre d'anomalies détectées
*/
export const checkEnvironment = (strapi: Core.Strapi): number => {
const problems = ENV_RULES.flatMap(checkRule);
if (problems.length === 0) {
strapi.log.info(
`[env-check] ${ENV_RULES.length} variables vérifiées, aucune anomalie de forme`,
);
return 0;
}
strapi.log.error(
`[env-check] ${problems.length} anomalie(s) détectée(s) dans les variables d'environnement :`,
);
for (const problem of problems) {
strapi.log.error(`[env-check] ✗ ${problem}`);
}
strapi.log.error(
"[env-check] Ces valeurs viennent de l'environnement de déploiement " +
"(Dokploy en production, .env en local). Une variable modifiée n'est " +
"relue qu'au redémarrage du processus.",
);
return problems.length;
};
+7
View File
@@ -1,4 +1,5 @@
import type { Core } from "@strapi/strapi"; import type { Core } from "@strapi/strapi";
import { checkEnvironment } from "./env-check";
import { syncRolePermissions } from "./permissions-sync"; import { syncRolePermissions } from "./permissions-sync";
export default { export default {
@@ -26,6 +27,12 @@ export default {
* run jobs, or perform some special logic. * run jobs, or perform some special logic.
*/ */
async bootstrap({ strapi }: { strapi: Core.Strapi }) { async bootstrap({ strapi }: { strapi: Core.Strapi }) {
// Contrôle de forme des variables d'environnement (préfixes, longueurs,
// guillemets parasites). Ne bloque pas le démarrage, mais signale au boot
// les erreurs de configuration qui, sinon, ne se manifestent que très
// loin de leur cause — cf. src/env-check.ts.
checkEnvironment(strapi);
// Synchronise les permissions Users & Permissions (source de vérité // Synchronise les permissions Users & Permissions (source de vérité
// versionnée dans src/permissions-sync.ts — additif, idempotent). // versionnée dans src/permissions-sync.ts — additif, idempotent).
await syncRolePermissions(strapi); await syncRolePermissions(strapi);
+8 -6
View File
@@ -58,16 +58,18 @@ const ROLE_PERMISSIONS: Record<string, string[]> = {
"api::message.message.delete", "api::message.message.delete",
"api::message.message.addReaction", "api::message.message.addReaction",
// --- Messagerie directe (ancien système)
"api::conversation.conversation.find",
"api::conversation.conversation.create",
"api::direct-message.direct-message.find",
"api::direct-message.direct-message.create",
// --- Messagerie (nouveau système chat-conversation) // --- Messagerie (nouveau système chat-conversation)
"api::chat-conversation.chat-conversation.create", "api::chat-conversation.chat-conversation.create",
"api::chat-conversation-member.chat-conversation-member.find", "api::chat-conversation-member.chat-conversation-member.find",
"api::chat-conversation-member.chat-conversation-member.update", "api::chat-conversation-member.chat-conversation-member.update",
// Conversations de l'appelant, avec interlocuteurs et messages.
// ⚠️ VOLONTAIREMENT ABSENTES : chat-conversation.find et
// chat-message.find. Elles lèveraient le filtrage du sanitizer sur les
// relations peuplées, mais ouvriraient la lecture de toutes les
// conversations et de tous les messages privés à n'importe quel compte
// connecté. L'action `mine` retourne les mêmes données, limitées à
// l'utilisateur courant.
"api::chat-conversation-member.chat-conversation-member.mine",
"api::chat-message.chat-message.create", "api::chat-message.chat-message.create",
// --- Social (posts) // --- Social (posts)