Spunto CMSespace de travail · CMS headless

Là où votre équipe écrit.
Ce que votre site lit.

Des kanbans, des tableaux et des pages en blocs que vos rédacteurs aiment vraiment ouvrir — et chaque carte servie en REST, avec un client TypeScript généré depuis une spec OpenAPI versionnée. Ni export, ni tâche de synchronisation : les lignes que votre équipe déplace sont celles que votre site récupère.

kanban · tableau · listeéditeur de blocsREST + client typéévénements & webhooks
Le kanban Planning d'une équipe éditoriale dans Spunto CMS : quatre colonnes de statut — Idée, En rédaction, Relecture, Prêt — avec des articles de blog, leurs images de couverture, rubriques et étiquettes en couleur, et les avatars des auteurs.
le planning d'un blog — couvertures, statuts, étiquettes et auteurs, tel quel dans l'app
01 — L'espace de travail

La partie qui ressemble à Notion.

Des listes de cartes, un registre de propriétés typées que votre équipe déclare, et autant de vues enregistrées que de questions. Rien ici n'a été dessiné pour une landing : ce sont des captures de l'app.

tableau

Un tableur qui connaît ses types.

Chaque cellule s’édite sur place — un statut, une personne, une date, des étiquettes — et la valeur est vérifiée contre le type de la propriété avant d’être écrite, pas quand une vue essaie plus tard de la lire.

  • Les colonnes se choisissent, s’ordonnent et se dimensionnent par vue, et s’enregistrent avec elle.
  • Les flèches pour se déplacer, Entrée pour éditer, Espace pour sélectionner ; une ligne nouvelle, c’est un titre et Entrée.
  • Les sous-cartes se rangent sous leur parent, avec la clé du parent à côté.
La vue tableau Calendrier éditorial : des articles de blog avec leurs colonnes statut, rubrique, auteur, relecteurs et date de publication, chaque cellule modifiable sur place.

carte

Chaque carte est un document.

Ouvrez une carte et vous obtenez une page, pas une zone de texte : une couverture, une icône, des titres, des cases à cocher, des encadrés, du code et des images — avec ses propriétés sur le côté et ses sous-cartes en dessous.

  • Elle s’enregistre seule 1,2 s après la dernière frappe. Pas de bouton Enregistrer à oublier.
  • Un format de blocs aux marques nommées — il ne peut pas transporter de HTML, donc il n’y a rien à y assainir quand votre site l’affiche.
  • Dupliquer, archiver ou copier son lien depuis le menu ; le fil d’Ariane dit où elle vit.
Un article de blog ouvert en pleine page : une couverture orange, une icône en éclair, son titre, son statut, une liste à cocher, un encadré et un bloc de code, avec rubrique, auteur, date et étiquettes dans une colonne latérale.

vues

Une liste, autant de vues que de questions.

Un kanban pour la réunion de la semaine, un tableau pour le calendrier éditorial, une liste groupée par rubrique pour la newsletter. Les mêmes cartes, trois vues enregistrées — chacune un filtre, un tri et un regroupement.

  • Groupez par statut, par un select ou par personne ; les colonnes vides restent affichées, pour qu’on puisse y déposer une carte.
  • Glisser une carte dans une autre colonne écrit la nouvelle valeur — c’est une modification, pas un effet d’affichage.
  • La définition que votre équipe enregistre est celle que votre site peut envoyer à l’API.
La vue liste Par rubrique : des articles de blog groupés sous Guide, Étude de cas, Annonce et Coulisses, chaque ligne avec son statut, sa date, ses étiquettes et son auteur.

ce que contient un espace

Pas d'assigné ni d'échéance intégrés : une carte porte un titre, un statut et une place dans un arbre, et le reste est ce que votre équipe déclare — une base de contenu n'est pas un gestionnaire de tâches.

listes
Des listes de cartes avec une clé lisible — SPR-12 — numérotée par l’API à partir du préfixe de la liste. Une carte peut être rattachée à plusieurs listes à la fois sans quitter celle où elle est née.
propriétés
Dix types, définis par espace et réutilisés d’une liste à l’autre : text, number, select, multi_select, date, checkbox, url, person, multi_person, file. Ils sont validés à l’écriture, pas à la lecture — une valeur mal typée est refusée à la porte plutôt que de casser plus tard toutes les vues qui la lisent.
corps des cartes
Le corps d’une carte est un document en blocs : titres, listes, cases à cocher, citations, encadrés, code, séparateurs et images. Les marques sont nommées (bold, italic, code, strike, link), donc le format ne peut pas transporter de HTML et il n’y a rien à y assainir.
hiérarchie
Les cartes s’imbriquent. Les sous-tâches, un sous-arbre entier ou les ancêtres d’une carte s’obtiennent chacun en une requête, et déplacer une carte réécrit son sous-arbre au lieu de l’orpheliner.
vues
Une vue enregistrée est une donnée, jamais un fragment de SQL : un arbre de filtres, des tris, un regroupement. La même définition pilote l’écran, l’API et l’édition en masse — compilée en SQL dans un seul et unique fichier.
statuts
Votre propre workflow — Idée, Relecture, Prêt — chacun avec une couleur tirée d’une palette fermée de huit, partagée avec les options des select : une étiquette verte est le vert du statut vert d’à côté.
recherche
⌘K dans toutes les listes : titres des cartes et noms des listes, et une clé exacte comme BLG-12 remonte en tête.
fichiers
Les pièces jointes et les blocs image portent un identifiant de fichier, jamais une URL. L’API sert elle-même les octets depuis une route scopée : ni bucket public, ni lien signé à faire suivre.
depuis Notion
Un import qui se relance : ce qui n’a pas changé n’est pas réécrit, donc un import à moitié raté se répare en le relançant.
02 — La moitié headless

Votre site lit les mêmes lignes.

Pas d'export, pas de second stockage à garder synchronisé. Une requête renvoie les cartes qu'un rédacteur modifie — ou, si elle demande la version publiée, exactement ce qui a été publié en dernier, pendant que le brouillon suivant s'écrit.

fetch-published-posts.ts
import createClient from "openapi-fetch"
// types generated from /openapi.json by openapi-typescript
import type { paths } from "./spunto-api"

// SPUNTO_TOKEN holds an API key: spk_…, one workspace
const api = createClient<paths>({
  baseUrl: "https://cms.spunto.net/api",
  headers: { Authorization: `Bearer ${SPUNTO_TOKEN}` },
})

// STAGE and DUE_DATE are ids from the workspace registry. Nothing but the
// title, the body and the status is built in — the rest your team declares.

// A view is data — a filter tree, sorts, an optional group-by.
const definition = {
  filter: {
    and: [
      { op: "eq", field: { fieldId: STAGE }, value: "shipped" },
      { op: "is_not_empty", field: { fieldId: DUE_DATE } },
    ],
  },
  sort: [
    { field: { fieldId: "sys:created_at" }, direction: "desc" },
  ],
}

const { data } = await api.POST(
  "/workspaces/{workspaceId}/collections/{collectionId}/query",
  { params: { path: { workspaceId, collectionId } },
    body: { definition, limit: 50 } }
)

data.rows        // "SPR-12", its title, its typed properties
data.nextCursor  // keyset — no page number to ask for

Le client est généré depuis le fichier OpenAPI versionné : noms des routes, opérateurs de filtre et forme d’une ligne sont tous vérifiés par tsc — une propriété renommée est une erreur de build, pas une page blanche en production.

la surface

protocole
REST. Pas « à peu près REST », et jamais GraphQL — c’est tranché, pas en suspens.
spec
OpenAPI, générée depuis les routes annotées et versionnée dans le dépôt. Servie en direct sur /openapi.json, avec la référence interactive sur /api/docs.
docs
/fr/docs est la moitié écrite — le modèle, les définitions de vues, le journal d’événements, et une page qui dit ce que le produit ne fait pas. La référence est générée ; celle-là ne l’est pas.
client
Générez-en un depuis cette spec avec openapi-typescript — des types complets pour chaque route, en une commande. C’est ainsi qu’est construit le client de cette app : une route manquante est ajoutée à la spec et regénérée, jamais écrite à la main.
pagination
Keyset, toujours. limit plafonne à 200 et la réponse rend un nextCursor opaque ; il n’y a aucun OFFSET dans le produit, donc la page 900 coûte ce que coûte la page 1.
lire une vue
POST …/collections/{id}/query prend une définition de vue et renvoie ses lignes. …/group-counts renvoie les comptes par groupe pour un kanban, et …/index-hints indique quel index cette vue aimerait avoir.
auth
Un seul en-tête Authorization: Bearer. Un appel anonyme reçoit un 401, jamais une liste silencieusement vide.
03 — Écrit par API aussi

Un agent est un auteur à part entière.

Beaucoup de CMS headless vous liront n'importe quoi en supposant qu'un humain l'a tapé. Ici, le chemin d'écriture est le même : un script, une tâche planifiée ou un agent LLM crée des cartes, renseigne des propriétés typées, écrit un corps structuré et modifie en masse avec le même client et la même clé qu'une session humaine.

Ça ne tient que si l'écriture est traçable : le journal d'événements n'est donc pas une fonctionnalité posée au-dessus de HTTP, il est écrit dans la transaction qui modifie la ligne. Un import, un seed, un champ supprimé et une mise à jour en masse de 3 000 cartes laissent la même trace qu'un clic, parce qu'aucun ne peut être validé sans elle.

ce qu'on peut faire à un espace

cartes
Créer, modifier, déplacer et archiver par les mêmes routes que l’UI. Écrivez blocks pour poser un corps structuré, ou body pour donner du texte brut et laisser l’API en dériver le document.
édition en masse
Un seul UPDATE … WHERE sur une définition de vue, prévisualisé d’abord. Au-delà de 200 cartes il faut passer le nombre attendu ; au-delà de 5 000 l’API refuse net plutôt que de faire semblant d’être atomique.
journal d'événements
Écrit dans la transaction qui a modifié la donnée : un événement existe si et seulement si le changement a été validé. Il enregistre les clés modifiées avec leur avant et leur après — pas la ressource entière.
webhooks
Des points de livraison avec leur historique, une livraison rejouable à la demande, et un point suspendu que l’on réactive une fois réparé.
apps OAuth
Une app installée reçoit son propre jeton et ne peut dépasser ni son rôle ni ses scopes. La table route → scope est fermée par défaut : une route qu’elle ne nomme pas est refusée à toutes les apps.
publish-release-notes.ts
// A body is blocks, and marks are named — the format
// cannot carry HTML, so there is nothing to sanitise.
const blocks = [
  { type: "heading", level: 2, text: [{ text: "What changed" }] },
  {
    type: "paragraph",
    text: [
      { text: "Bulk edit now emits " },
      { text: "one event per card", bold: true },
    ],
  },
  { type: "code", language: "bash", code: "npm i openapi-fetch" },
]

// Same client, same key an agent or a human would use.
const { data: item } = await api.POST(
  "/workspaces/{workspaceId}/collections/{collectionId}/items",
  { params: { path: { workspaceId, collectionId } },
    body: {
      title: "Release notes — 0.4.0",
      properties: { [STAGE]: "shipped", [TAGS]: ["api"] },
      blocks: { blocks },
    } }
)

item.key  // "REL-18" — numbered by the API, not by the caller
follow-the-log.ts
// Everything that changed the data left a row here: the UI,
// the seed, an import, and the script you just ran.
const { data: page } = await api.GET(
  "/workspaces/{workspaceId}/events",
  { params: {
      path: { workspaceId },
      query: { since: lastProcessed, type: ["item.updated"] },
    } }
)

for (const event of page.rows) {
  event.type     // "item.updated"
  event.changes  // only the keys that moved, before / after
  event.actor    // frozen: revoking a key doesn't rewrite history
}

page.nextCursor  // an event id — hand it straight back as `since`
04 — À qui est quoi

Donner une clé reste une petite décision.

Une API de contenu n'est sereine qu'à la mesure de son rayon d'impact. Chaque table ici est indexée par espace, chaque index commence par lui, et la base elle-même refuse de rendre une ligne d'un tenant auquel la connexion n'est pas restreinte — une erreur dans le code applicatif est un bug, pas une fuite.

C'est ce qui rend le reste raisonnable : une clé pour votre site vitrine, une autre pour l'agent qui rédige le changelog, chacune liée à un espace et à un rôle dès son émission. Révoquez-en une, l'autre continue de fonctionner. Et comme les pièces jointes sont servies par l'API et non par un bucket, un lien qui s'échappe est un lien qui demande encore qui vous êtes.

comment l'accès se décide

appartenance
Le périmètre vient d’une appartenance vérifiée, jamais de l’URL. Nommer l’identifiant d’un espace ne donne pas le droit d’y entrer.
404, pas 403
Un non-membre reçoit un 404. Un 403 confirmerait l’existence de l’espace à qui énumère des identifiants : le produit préfère ne pas répondre du tout.
row-level security
La RLS de Postgres en dessous, fermée par défaut, avec l’API connectée sous un rôle sans privilège — ni propriétaire des tables, ni superutilisateur, sinon les politiques ne protégeraient rien.
fermé par défaut
Les gardes sont posées sur le préfixe des routes et non route par route : une route nouvelle est protégée avant que quiconque pense à la protéger. Un test balaie toute la spec et exige un 401 partout, sauf sur une courte liste de portes, chacune justifiée sur sa ligne.
clés API
Une clé spk_… porte son propre espace et son propre rôle, fixés à l’émission. Elle ne suit pas l’appartenance changeante de qui l’a créée, et la révoquer tient en un clic.
05 — Où en est le produit

Qui peut entrer est une question de configuration.

Le code ferme par défaut et la configuration ouvre : une instance est sur invitation sauf si elle dit le contraire, et une invitation est un jeton émis par un admin, lié à une adresse e-mail et à un rôle, consommé par la transaction même qui crée l'appartenance. Celle-ci est ouverte.

C’est appliqué dans l’application et non par une barrière devant elle, et c’est justement ce qui permet à l’API d’être exposée sur internet pour que les intégrations l’atteignent. « Ouvrir l’app » mène à une seule page qui porte les deux moitiés, connexion et inscription — selon le réglage de l’instance.

à ce jour

inscription
Par instance. Le code est sur invitation par défaut — un jeton spi_… lié à une adresse et à un rôle, consommé par la transaction même qui crée le compte — et celle-ci est ouverte.
vues
Une définition porte un type — tableau, kanban, liste, calendrier — et l’UI web affiche les quatre, le calendrier au mois, à la semaine ou en chronologie. L’app iOS montre encore un calendrier en tableau.
publication
Une carte est un brouillon jusqu’à ce qu’on la publie. Avec version: "published", une lecture ne rend que les cartes publiées, telles qu’elles l’ont été ; sans, elle rend le brouillon — votre aperçu. C’est un choix de lecteur, pas un droit : une clé qui lit peut lire les brouillons. Ni publication programmée, ni historique des versions.
langue
L’application, cette page et la documentation existent en français et en anglais ; la référence de l’API est en anglais. Les noms que vous donnez aux listes, statuts et propriétés sont les vôtres, dans la langue où vous les écrivez.

à lire

Rien sur cette page n'est une entrée de roadmap. Chaque route citée ici est dans le fichier OpenAPI versionné, et tout ce qui est décrit tourne aujourd'hui derrière ce domaine. Quand quelque chose manque — publication programmée, recherche dans les corps — c'est dans la liste à droite plutôt que formulé comme si c'était livré. La version complète de cette liste est sur /fr/docs/limits.

Écrivez-le une fois.
Servez-le partout.

Ouvrez un espace, déclarez les propriétés dont votre contenu a besoin, et branchez votre site sur les mêmes lignes avec un client qui connaît déjà leurs types.