Second Brain documentation

ADR 0047 – Cadre de connecteurs et webhook de capture

Accepté (2026-10-16, mode autonome ; ligne 19 du backlog, prise sur la recommandation du fil coordinateur)

← Toutes les décisions

ADR 0047 – Cadre de connecteurs et webhook de capture

Statut : Accepté (2026-10-16, mode autonome ; ligne 19 du backlog, prise sur la recommandation du fil coordinateur)

Ligne 19 du backlog (jalon 3, vague 2), avant les synchros CalDAV, CardDAV et Jira qui attendent leurs secrets. Elle demande :

  • un aspect externe : source, id externe, URL, ETag, date de dernière synchro ;
  • un YAML par connecteur dans config/connectors ;
  • en cas de conflit, la dernière modification gagne, et l’autre version reste dans l’historique ;
  • une suppression à la source marque le nœud au lieu de le supprimer ;
  • des événements sync.*, et l’état des connecteurs sur la page Santé.

Le premier connecteur est un webhook de capture : un service tiers (formulaire, script, raccourci iOS, Home Assistant, n8n…) envoie un JSON, et le nœud correspondant est créé ou mis à jour. Les synchros à venir réutiliseront le même cœur : identité externe, application d’une version, conflits, suppressions, journal et état.

Décision

L’aspect externe est déclaré dans config/, son rôle aussi

config/aspects/externe.yaml est un aspect ordinaire, visible sur la fiche et utilisable dans les vues (externe.source == 'webhook'). Une clé d’aspect external indique au moteur quel champ joue quel rôle, comme deferUntil (ADR 0039) :

external:
  source: source            # nom de la source (le connecteur)
  id: idExterne             # identifiant chez la source
  url: url                  # lien vers l'élément chez la source
  etag: etag                # version chez la source
  syncedAt: synchroLe       # date-heure de la dernière synchro appliquée
  deletedAt: supprimeALaSource   # date-heure à laquelle la source l'a supprimé

Le moteur ne connaît donc pas le nom de l’aspect ni ceux de ses champs. Le chargement vérifie qu’au plus un aspect porte external (y compris quand une version précédente est gardée), que chaque rôle désigne un champ texte (date-heure pour syncedAt et deletedAt), et qu’un connecteur n’existe pas sans cet aspect.

Identité. Un nœud vivant par couple (source, id) : la recherche lit les données de l’aspect (JSONB, @> servi par l’index GIN existant). Une restauration depuis la corbeille peut en faire vivre deux ; la réception vise alors le plus ancien. Deux connecteurs ne partagent pas une même source, sans quoi le jeton de l’un écrirait dans les nœuds de l’autre. Un verrou transactionnel (pg_advisory_xact_lock) par couple empêche deux réceptions simultanées de créer deux nœuds. Il n’y a pas de table ni d’index dédiés, car une table forcerait le moteur à connaître l’aspect, et les volumes restent ceux d’un usage personnel.

Un connecteur, un fichier : config/connectors/<nom>.yaml

name: formulaire
label: Formulaire de contact
kind: webhook                 # seul genre pour l'instant ; caldav, carddav, jira viendront
secret: SB_WEBHOOK_FORMULAIRE # variable d'environnement qui contient le jeton, jamais le jeton lui-même
source: formulaire            # valeur du champ source de l'identité externe ; par défaut, le nom
type: tache                   # type du nœud créé ; sans type, le nœud entre dans l'inbox
inbox: false                  # facultatif ; par défaut, vrai sans type, faux avec
id: /id                       # pointeurs JSON (RFC 6901) dans la charge reçue
url: /lien
etag: /version
updatedAt: /modifieLe         # date de modification à la source (conflits) ; absente, l'heure de réception
deleted: /supprime            # booléen : supprimé à la source
fields:                       # cible ← pointeur
  title: /titre
  body: /description
  tags: /etiquettes           # liste de textes, ou texte seul
  tache.echeance: /echeance
  tache.priorite: /priorite
  • C’est un nouveau type de définition, chargé comme les autres :
    • méta-schéma connector.schema.json ;
    • santé de la configuration ;
    • version précédente gardée si le fichier devient invalide.
  • Contrôles au chargement.
    • type est connu ;
    • les cibles de fields sont title, body, tags, ou un champ aspect.champ existant, hors champ référence (on ne résout pas un titre reçu en nœud pour l’instant) ;
    • title est obligatoire ;
    • les pointeurs sont bien formés ;
    • le nom de la variable du secret commence par SB_WEBHOOK_, pour qu’un YAML ne puisse pas désigner un autre secret de l’application (DB_PASSWORD…) comme jeton ;
    • deux connecteurs n’ont pas la même source.
  • Secret.
    • Le jeton n’est jamais dans config/, qui est versionnable : le YAML ne nomme que la variable d’environnement.
    • Variable absente ou vide : le connecteur est inactif. La page Santé le dit, et le webhook répond 503.

Webhook : POST /api/connectors/{nom}/webhook

  • Authentification.
    • En-tête Authorization: Bearer <jeton>, comparé en temps constant.
    • Ni session ni CSRF : le chemin est ouvert dans la chaîne OIDC, et seul le jeton protège.
    • Mauvais jeton : 401 (WWW-Authenticate: Bearer, et un avertissement au journal de l’application, sans le jeton). Connecteur inconnu ou d’un autre genre : 404.
    • Le corps n’est lu qu’après le jeton, et jamais au-delà de 256 Ko : un appel anonyme ne fait rien mettre en mémoire.
  • Charge.
    • Un objet JSON, au plus 256 Ko (413 au-delà).
    • id absent ou vide donne 400 ; title absent à la création, 400 aussi.
    • Toute erreur de validation des aspects revient en 400, comme pour l’API des nœuds, et rien n’est écrit.
  • Réponse. { result, nodeId }, où result vaut :
    • created ;
    • updated ;
    • unchanged (même ETag, ou rien de différent) ;
    • conflict (la version locale est gardée) ;
    • deleted ;
    • ignored (suppression d’un élément inconnu).
  • Écriture. Une réception, une transaction. Elle passe par NodeService (création, titre et corps, aspects, tags) : validation, valeurs par défaut, workflow et journal habituels. Une transition refusée par le workflow d’un aspect est donc refusée aussi (400). Un champ que la charge porte à null est retiré ; un champ absent de la charge reste tel quel.

Conflits : la dernière modification gagne, l’autre reste dans l’historique

Un nœud a été modifié localement si le journal porte, après la dernière version appliquée (sync.created, sync.updated, sync.deleted), un événement dont l’auteur n’est pas un connecteur. La date de la modification locale est celle du dernier de ces événements. Le journal plutôt que updated_at : les écritures du connecteur lui-même (ETag d’un conflit) ne comptent pas, une version écartée ne remet pas le compteur à zéro, et l’ordre du journal départage une édition concurrente. La réception verrouille le nœud (index wiki, puis ligne du nœud, ADR 0009) avant de lire : une édition en cours passe avant la lecture, ou après l’application.

  • Pas de modification locale. La version reçue s’applique. Les événements habituels gardent l’ancienne valeur (before/after).
  • Modification locale, version reçue plus récente (updatedAt reçu postérieur à la modification locale). La version reçue s’applique. La version locale reste dans before.
  • Modification locale plus récente. La version locale est gardée. L’événement sync.conflict enregistre la version reçue entière (champs déjà traduits), pour qu’elle reste lisible dans l’historique.

Quand la version reçue s’applique, syncedAt et l’ETag avancent ; pour un conflit perdu par la source, seul l’ETag avance. Un nœud dans la corbeille n’est pas une cible : la réception recrée un nœud. La corbeille (ADR 0010) reste le choix de l’utilisateur.

Suppression à la source : marquer, jamais supprimer

deleted: true renseigne deletedAt (événement sync.deleted). Le nœud reste où il est, avec ses liens et son contenu. Les vues peuvent l’écarter (externe.supprimeALaSource == null). Une réception ultérieure non supprimée efface la marque.

Journal : événements sync.*

  • sync.created : { connector, source, externalId }.
  • sync.updated : { connector, externalId, remoteUpdatedAt }.
  • sync.conflict : { connector, externalId, remoteUpdatedAt, localUpdatedAt, remote: { title, body, tags, aspects } }.
  • sync.deleted : { connector, externalId }.

Ils s’ajoutent aux événements ordinaires de la même transaction (node.created, field.changed…) et ont un libellé dans l’historique de la fiche.

État des connecteurs : table connector_state, page Santé

  • Table. Migration V11 : connector_state(name, last_received_at, last_result, last_error, received, created, updated, unchanged, conflicts, deleted, errors).
  • Mise à jour. Elle se fait dans sa propre transaction après chaque réception, réussie ou refusée (sauf 401, 404 et 503, pour qu’un appel anonyme ne puisse pas écrire). Une base indisponible à ce moment ne masque pas la réponse.
  • Lecture. GET /api/connectors liste les connecteurs avec leur état : actif ou inactif et pourquoi, dernière réception, compteurs, dernière erreur. Le jeton n’est jamais renvoyé.
  • Affichage. La page Santé (Définitions) gagne une section « Connecteurs ».

Reporté

  • Connecteurs tirés (CalDAV, CardDAV, Jira) : même cœur, avec un ordonnanceur et un client HTTP, quand les secrets seront là.
  • Champ référence alimenté par un titre reçu (comme +projet de la capture en une ligne).
  • Webhooks sortants : jalon des automatisations.
  • Signature HMAC de la charge, plutôt qu’un jeton porteur.
  • Renvoi d’une réception en erreur.
  • Bandeau « Supprimé à la source » sur la fiche ; pour l’instant, le champ de l’aspect suffit.

Conséquences

  • Une source n’a besoin que d’un YAML et d’une variable d’environnement : aucun code par source tant que JSON et pointeurs suffisent.
  • Le chemin du webhook est le seul de l’API ouvert sans session en production. Il n’écrit qu’à travers le connecteur nommé, avec son jeton, et ne lit rien d’autre.
  • Les définitions ont un neuvième genre, connectors, que la page Santé et l’export de la configuration listent comme les autres.