Spunto CMS
Guides

Écrire un connecteur pour la marketplace

Un service qui se connecte avec Spunto CMS, reçoit les webhooks d'un espace et se publie dans la marketplace — OAuth avec PKCE, webhooks à soi, vérification des signatures.

Un connecteur est votre service, à votre adresse, qu'un admin d'espace installe depuis la marketplace. Slack et Google Drive sont construits exactement ainsi, sans rien qu'un tiers ne puisse utiliser. Cette page suit le même chemin.

À l'arrivée : un bouton « Se connecter avec Spunto CMS », un jeton limité à un espace, un webhook qui n'est qu'à vous, et une fiche dans Réglages → Marketplace de chaque espace de l'instance.

1. Enregistrer l'app

Ouvrez Développeurs (/app/developpeurs, en pied de la liste de vos espaces) et enregistrez une app :

  • URI de redirection — où le consentement renvoie le navigateur, par exemple https://mon-connecteur.exemple/oauth/callback. Comparée à l'identique, jamais par préfixe. HTTPS, sauf sur localhost.
  • Scopes — ce que l'app pourra demander. L'admin qui l'installe peut accorder moins, jamais plus.

Le client_secret ne s'affiche qu'une fois. Gardez-le côté serveur.

Demandez ce dont vous avez besoin, rien de plus. Un connecteur qui poste dans un chat a besoin de items:read, schema:read (noms des listes et des statuts), events:read, webhooks:read et webhooks:write — et du rôle viewer, puisqu'il n'écrit rien.

2. Lancer le flux depuis l'URL d'installation

Le bouton Installer de la marketplace est un simple lien vers votre URL d'installation, suivie de ?workspace_id=… : l'espace d'où vient la personne. C'est là que vous lancez OAuth, parce que vous seul pouvez garder le verifier PKCE jusqu'au retour du code.

GET /oauth/start
const verifier = base64url(randomBytes(32))
const challenge = base64url(sha256(verifier))
const state = base64url(randomBytes(16))
// Garder { state, verifier } dans un cookie signé, httpOnly, ~10 minutes.

const url = new URL("https://cms.spunto.net/install")
url.searchParams.set("client_id", CLIENT_ID)
url.searchParams.set("redirect_uri", "https://mon-connecteur.exemple/oauth/callback")
url.searchParams.set("scope", "items:read schema:read events:read webhooks:read webhooks:write")
url.searchParams.set("state", state)
url.searchParams.set("code_challenge", challenge)
url.searchParams.set("code_challenge_method", "S256")
url.searchParams.set("role", "viewer")                       // un indice
if (workspaceId) url.searchParams.set("workspace_id", workspaceId) // un indice
redirect(url)

role et workspace_id sont des indices : l'écran de consentement les présélectionne, l'admin choisit, et l'API plafonne le rôle au sien.

3. Échanger le code

Vérifiez state contre votre cookie avant toute autre chose, puis échangez le code :

GET /oauth/callback
const { data } = await createSpuntoCmsClient({ baseUrl: API }).POST("/oauth/token", {
  body: {
    grantType: "authorization_code",
    code, clientId: CLIENT_ID, clientSecret: CLIENT_SECRET,
    codeVerifier: verifier,
    redirectUri: "https://mon-connecteur.exemple/oauth/callback",
  },
})
// data.accessToken (spa_…), data.workspaceId, data.installationId, data.scope, data.role

Le jeton n'expire pas : sa durée de vie, c'est l'installation. Stockez-le chiffré. Se reconnecter plus tard rejoue le même flux : l'API renouvelle le jeton sur la même installation.

4. Brancher votre webhook

const api = createSpuntoCmsClient({ baseUrl: API, token: accessToken })
const { data: hook } = await api.POST("/workspaces/{workspaceId}/webhooks", {
  params: { path: { workspaceId } },
  body: { name: "Mon connecteur", url: "https://mon-connecteur.exemple/webhooks/spunto", eventTypes: ["item.*"] },
})
// hook.secret n'est rendu qu'une fois — à stocker chiffré, par espace.

Une app n'a pas besoin du rôle admin pour ça, mais il lui faut events:read en plus de webhooks:write : un webhook, c'est le journal poussé. L'endpoint appartient à votre installation — vous ne voyez que les vôtres, aucune autre app ne peut y toucher, et il disparaît quand l'espace vous désinstalle.

5. Recevoir les livraisons

POST /webhooks/spunto
import { SIGNATURE_HEADER, verifyWebhookSignature, type WebhookPayload } from "@spunto-cms/client"

const raw = await request.text()                       // le corps BRUT
const payload = JSON.parse(raw) as WebhookPayload
const secret = await secretFor(payload.workspaceId)    // le corps dit seulement QUEL secret
if (!(await verifyWebhookSignature(secret, raw, request.headers.get(SIGNATURE_HEADER)))) {
  return new Response("signature refusée", { status: 401 })
}
// Idempotent sur payload.event.id (ou batch.batchId), répondre 2xx vite.

Trois habitudes rendent un connecteur supportable :

  • ignorer les événements dont la cause vaut "import" — un import n'est pas une nouvelle ;
  • une livraison kind: "batch" est une édition en masse : en faire un message ;
  • si votre fournisseur échoue (un canal Slack supprimé), le noter et répondre quand même 200. Répondre 5xx fait rejouer l'API, puis mettre votre endpoint en pause pour tout l'espace.

Quand un appel répond 401, l'espace vous a désinstallé. Arrêtez-vous, gardez les réglages, et laissez une nouvelle connexion réinstaller.

6. Publier

De retour sur Développeurs, remplissez la fiche — adresse, accroche, catégorie, éditeur, URL d'installation ; description et icône sont facultatives — et cliquez Publier. S'il manque quelque chose, l'erreur le dit.

Le badge Vérifiée est posé par l'exploitant de l'instance, pas par vous : le nom d'éditeur est libre, et c'est le badge qui distingue « Slack par Spunto » d'un imposteur. Une fiche non vérifiée le dit, et rappelle de regarder où mène le bouton d'installation.

Sur cette page