---
title: Ajouts du 28 août 2026 — guide d'intégration client
date: 2026-08-28
lang: fr-FR
author: Julien Béranger
model: Claude Opus 5
source: https://julienberanger.com/roac-guide-integration-client
---

# Ajouts du 28 août 2026 — guide d'intégration client

Récapitulatif des trois dernières PR mergées, destiné à l'équipe qui intègre l'API
côté client (UI registrar).

| PR | Titre | Apport |
|---|---|---|
| [#37](https://github.com/w3hc/roac/pull/37) | `Add IpfsModule` | Nouveau endpoint `POST /ipfs/cid` : calcul d'un CID IPFS sans upload ni pinning |
| [#39](https://github.com/w3hc/roac/pull/39) | `Add images upload and processing` | Upload du fichier source et de l'image d'aperçu dans `POST /nft/create` et `POST /nft/register` + endpoint public `GET /nft/preview/:sourceFileHash` |
| [#41](https://github.com/w3hc/roac/pull/41) | `Make NFT creation optional` | Champ `nftCreation` : enregistrer une œuvre sans frapper de NFT, et frapper plus tard |

**Rien n'est cassé.** Tous les nouveaux champs de requête sont optionnels (à une
exception près, décrite au §3). Un client existant qui n'envoie aucun de ces
champs continue de fonctionner exactement comme avant.

---

## Sommaire

1. [Rappels d'authentification](#1-rappels-dauthentification)
2. [Upload de fichiers (PR #39)](#2-upload-de-fichiers-pr-39)
   - [Format d'un fichier](#21-format-dun-fichier)
   - [`POST /nft/create` avec fichiers](#22-post-nftcreate-avec-fichiers)
   - [`POST /nft/register` avec fichiers](#23-post-nftregister-avec-fichiers)
   - [`GET /nft/preview/:sourceFileHash` (public)](#24-get-nftpreviewsourcefilehash-public)
   - [Le `sourceFileHash`](#25-le-sourcefilehash)
3. [Création de NFT optionnelle (PR #41)](#3-création-de-nft-optionnelle-pr-41)
4. [Calcul de CID IPFS (PR #37)](#4-calcul-de-cid-ipfs-pr-37)
5. [Configuration serveur](#5-configuration-serveur)
6. [Erreurs](#6-erreurs)
7. [Checklist d'intégration](#7-checklist-dintégration)

---

## 1. Rappels d'authentification

Tous les endpoints ci-dessous exigent une session valide **avec rôle Admin ou
Registrar**, à **une exception près** : `GET /nft/preview/:sourceFileHash` est
public (voir §2.4).

Obtenir un token :

```bash
curl -X POST http://localhost:3000/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"registrar@example.com","password":"..."}'
```

```json
{
  "session_token": "abc...",
  "user": { "...": "..." }
}
```

Puis, au choix, sur chaque appel :

```http
Authorization: Bearer <session_token>
```
ou
```http
x-session-token: <session_token>
```

---

## 2. Upload de fichiers (PR #39)

Un registrar manipule **deux fichiers** par œuvre. Ils sont désormais envoyés
**inline dans le corps JSON**, encodés en base64 — pas de `multipart/form-data`.

| Fichier | Obligatoire | Visibilité | Rôle |
|---|---|---|---|
| `sourceFile` | Non\* | **Privé** | L'œuvre originale. Ses octets sont stockés en base et leur SHA‑384 **est** le `sourceFileHash`. **Aucun endpoint ne le sert.** |
| `previewImage` | Non | **Public** | Image d'aperçu, servie sans authentification sur `GET /nft/preview/:sourceFileHash` et référencée comme `image` dans les métadonnées du NFT. |

\* Optionnel pour la rétro-compatibilité — sauf en mode `nftCreation: false`, où
il devient obligatoire (§3).

### 2.1 Format d'un fichier

| Champ | Type | Description |
|---|---|---|
| `filename` | string | Nom d'origine, stocké avec les octets. Max **255** caractères. |
| `mimeType` | string | Type MIME. Max **100** caractères. |
| `content` | string | Le fichier en **base64**. Un préfixe `data:<mime>;base64,` est accepté et retiré automatiquement — le résultat brut d'un `FileReader` navigateur peut donc être passé tel quel. |
| `textData` | string | Optionnel. Note libre stockée avec le fichier. |

**Limites :** 50 Mo par fichier *après décodage*. Le base64 coûte ~33 % de plus
que les octets bruts, d'où une limite de corps HTTP à 150 Mo
(`MAX_REQUEST_BODY_SIZE`).

Côté navigateur :

```js
const toBase64 = (file) =>
  new Promise((resolve, reject) => {
    const reader = new FileReader();
    // On peut envoyer le data: URI tel quel, l'API retire le préfixe.
    reader.onload = () => resolve(reader.result);
    reader.onerror = reject;
    reader.readAsDataURL(file);
  });

const sourceFile = {
  filename: file.name,
  mimeType: file.type,
  content: await toBase64(file),
};
```

### 2.2 `POST /nft/create` avec fichiers

**Requête**

```bash
curl -X POST http://localhost:3000/nft/create \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $SESSION_TOKEN" \
  -d @create.json
```

```json
{
  "blockchain": "sepolia",
  "recipientAddress": "0x1234567890123456789012345678901234567890",
  "name": "Artwork #1",
  "description": "A beautiful piece of digital art",
  "creatorName": "John Doe",
  "creatorAddress": "0x1234567890123456789012345678901234567890",
  "imageUrl": "https://example.com/image.png",
  "thumbnail": "https://example.com/thumb.png",
  "symbol": "ART",
  "resaleRights": 400,
  "redeemable": false,
  "tangible": true,
  "assetType": 0,
  "status": 0,
  "info": "Initial creation",
  "license": "https://example.com/license",
  "nftCreation": true,

  "sourceFile": {
    "filename": "artwork-scan.tif",
    "mimeType": "image/tiff",
    "content": "<base64>",
    "textData": "Scan haute résolution"
  },
  "previewImage": {
    "filename": "preview.png",
    "mimeType": "image/png",
    "content": "<base64>"
  }
}
```

**Réponse (200)**

```json
{
  "message": "NFT created, minted, and registered successfully",
  "nftCreation": true,
  "contractAddress": "0x...",
  "tokenId": 1,
  "blockchainTokenId": 0,
  "transactionHash": "0x...",
  "explorerLink": "https://sepolia.etherscan.io/tx/0x...",
  "recipient": "0x1234567890123456789012345678901234567890",
  "sourceFileHash": "abc123...",
  "metadata": {
    "name": "Artwork #1",
    "description": "...",
    "image": "https://api.example.com/nft/preview/abc123...",
    "image_ipfs": "ipfs://bafkrei...",
    "thumbnail": "https://example.com/thumb.png",
    "source_file_hash": "abc123...",
    "attributes": [
      { "trait_type": "Creator", "value": "John Doe" },
      { "trait_type": "Creator Address", "value": "0x1234..." },
      { "trait_type": "Resale Rights", "value": "4%" },
      { "trait_type": "Tangible", "value": "Yes" },
      { "trait_type": "Redeemable", "value": "No" }
    ],
    "external_url": "https://example.com/license"
  },
  "files": {
    "sourceFile": {
      "filename": "artwork-scan.tif",
      "mimeType": "image/tiff",
      "byteLength": 2481920,
      "sha384": "abc123...",
      "cid": "bafkrei...",
      "uri": "ipfs://bafkrei..."
    },
    "previewImage": {
      "filename": "preview.png",
      "mimeType": "image/png",
      "byteLength": 48210,
      "sha384": "def456...",
      "cid": "bafkrei...",
      "uri": "ipfs://bafkrei...",
      "url": "https://api.example.com/nft/preview/abc123..."
    }
  }
}
```

Points d'attention côté client :

- **`url` n'apparaît que sur `previewImage`.** Le fichier source n'a pas
  d'adresse publique, par conception.
- **Si aucun fichier n'est envoyé** : `files.sourceFile` et
  `files.previewImage` valent `null`, `metadata.image` conserve l'`imageUrl`
  fourni par l'appelant, et `image_ipfs` / `source_file_hash` sont absents des
  métadonnées.
- **Pour afficher une œuvre** : utiliser `files.previewImage.url`, ou le
  reconstruire soi-même : `{PUBLIC_BASE_URL}/nft/preview/{sourceFileHash}`.

### 2.3 `POST /nft/register` avec fichiers

Mêmes objets `sourceFile` et `previewImage`. Différence importante : ici c'est
**l'appelant qui fournit `sourceFileHash`**, donc si un `sourceFile` est joint,
**ses octets doivent hasher (SHA‑384) exactement vers cette valeur**, sinon la
requête est rejetée en **400**.

```json
{
  "blockchain": "sepolia",
  "contractAddress": "0x1234567890123456789012345678901234567890",
  "tokenId": 1,
  "sourceFileHash": "abc123...",
  "creatorWalletPublicKey": "0x1234567890123456789012345678901234567890",
  "assetType": 0,
  "tangible": true,
  "redeemable": false,
  "status": 0,
  "resaleRights": 400,
  "creatorName": "John Doe",
  "info": "Registered existing NFT",
  "metadata": "{\"name\": \"Artwork\", \"description\": \"...\"}",

  "sourceFile": {
    "filename": "artwork-scan.tif",
    "mimeType": "image/tiff",
    "content": "<base64>"
  },
  "previewImage": {
    "filename": "preview.png",
    "mimeType": "image/png",
    "content": "<base64>"
  }
}
```

**Réponse (200)**

```json
{
  "message": "NFT registered successfully",
  "nft": { "...": "objet NFT complet" },
  "asset": {
    "sourceFileHash": "abc123...",
    "tokenId": 1,
    "certIssuerId": null,
    "measurements": null,
    "creationDate": null,
    "distinguishingFeatures": null,
    "inscriptions": null
  },
  "files": {
    "sourceFile": { "...": "..." },
    "previewImage": { "...": "...", "url": "https://api.example.com/nft/preview/abc123..." }
  }
}
```

**Backfill :** enregistrer un token supplémentaire pour une œuvre déjà connue
met à jour les lignes existantes avec les fichiers portés par l'appel. Une œuvre
d'abord enregistrée sans ses fichiers peut donc être complétée plus tard.

### 2.4 `GET /nft/preview/:sourceFileHash` (public)

**Aucune authentification.** C'est délibéré : cette URL est écrite dans les
métadonnées du NFT, les wallets et marketplaces doivent pouvoir la récupérer
anonymement.

```bash
curl http://localhost:3000/nft/preview/abc123... --output preview.png
```

- Renvoie les octets de l'aperçu avec le `Content-Type` d'origine.
- `Content-Disposition: inline` (le navigateur affiche au lieu de télécharger).
- `Cache-Control: public, max-age=31536000, immutable` — le contenu est adressé
  par le hash d'un fichier immuable, il est donc cachable indéfiniment.

| Statut | Cause |
|---|---|
| 200 | Les octets de l'image d'aperçu |
| 404 | Aucune œuvre pour ce hash, ou aucun aperçu uploadé pour elle |

> **Il n'existe aucun endpoint équivalent pour le fichier source.** Il est
> stocké, hashé et référencé par son hash, mais jamais servi.

### 2.5 Le `sourceFileHash`

Quand un `sourceFile` est uploadé, `sourceFileHash` = **SHA‑384 de ses octets**.
La clé en base est donc content-addressed : deux fichiers identiques désignent la
même œuvre.

- Sur `POST /nft/create` le hash est **dérivé** de l'upload. Sans upload, on
  retombe sur le hash synthétique historique
  `sha384(contractAddress-tokenId-timestamp)`, qui ne décrit aucun fichier.
- Sur `POST /nft/register`, le hash est fourni par l'appelant et vérifié contre
  le fichier s'il est joint.

Ce hash est **public** : il apparaît dans les métadonnées (`source_file_hash`) et
dans l'URL d'aperçu. C'est voulu — il permet à quiconque détient une copie de
l'œuvre de la vérifier contre l'original enregistré, ce qui est tout l'objet d'un
certificat d'authenticité. Il ne divulgue rien du contenu du fichier.

#### CID IPFS des fichiers

Les deux fichiers passent par le calcul de CID décrit au §4, et les `cid`/`uri`
obtenus sont renvoyés dans `files`. **Rien n'est pinné.** Les CID étant
déterministes, les enregistrer maintenant permet de pousser les octets vers un
service de pinning plus tard sans que l'identifiant change.

Les métadonnées portent donc l'aperçu **deux fois** :

| Champ métadonnée | Valeur | Résout aujourd'hui ? |
|---|---|---|
| `image` | `https://<PUBLIC_BASE_URL>/nft/preview/<hash>` | Oui |
| `image_ipfs` | `ipfs://<cid>` | Pas tant que les octets ne sont pas pinnés |

Le CID du fichier source est calculé et renvoyé dans la réponse API, mais
**n'est pas** écrit dans les métadonnées : publier une URI `ipfs://` pour un
fichier volontairement jamais pinné serait un lien mort permanent. Les
métadonnées portent `source_file_hash` à la place, comme engagement d'intégrité.

---

## 3. Création de NFT optionnelle (PR #41)

Nouveau champ sur `POST /nft/create` :

```json
{ "nftCreation": false }
```

`nftCreation` (booléen, optionnel, **défaut `true`**) contrôle si un token est
créé. Il existe parce que toute œuvre enregistrée n'a pas vocation à devenir un
NFT au moment où elle est enregistrée.

### Avec `nftCreation: false`

- **Aucune interaction avec la chaîne** : pas de contrat déployé, pas de token
  frappé, pas besoin d'endpoint RPC ni de wallet système approvisionné.
- Seules les lignes au niveau œuvre sont écrites : `source_file`, `desc_image`
  et `asset_properties`. **Pas de ligne `asset` ni `nft`**, toutes deux étant
  clefées par un token ID qui n'existe pas encore.
- **`sourceFile` devient obligatoire.** Le chemin de mint peut retomber sur un
  hash synthétique construit depuis l'adresse du contrat et le token ID ; sans
  ni l'un ni l'autre, le SHA‑384 du fichier source est la seule chose qui
  identifie l'œuvre.
- **`symbol`, `imageUrl` et `thumbnail` ne sont plus requis** — ils ne servent
  qu'au token et à ses métadonnées.

**Requête minimale**

```json
{
  "name": "Artwork #1",
  "description": "A beautiful piece of digital art",
  "creatorName": "John Doe",
  "creatorAddress": "0x1234567890123456789012345678901234567890",
  "resaleRights": 400,
  "redeemable": false,
  "tangible": true,
  "assetType": 0,
  "status": 0,
  "info": "Initial creation",
  "nftCreation": false,
  "sourceFile": {
    "filename": "artwork-scan.tif",
    "mimeType": "image/tiff",
    "content": "<base64>"
  },
  "previewImage": {
    "filename": "preview.png",
    "mimeType": "image/png",
    "content": "<base64>"
  }
}
```

**Réponse (200)**

```json
{
  "message": "Artwork registered successfully. No NFT was created.",
  "nftCreation": false,
  "sourceFileHash": "abc123...",
  "asset": {
    "sourceFileHash": "abc123...",
    "title": "Artwork #1",
    "description": "A beautiful piece of digital art",
    "assetType": 0,
    "tangible": true,
    "redeemable": false
  },
  "files": {
    "sourceFile": { "filename": "artwork-scan.tif", "...": "..." },
    "previewImage": { "filename": "preview.png", "...": "...", "url": "..." }
  }
}
```

### Distinguer les deux réponses

`POST /nft/create` peut désormais renvoyer **deux formes différentes**. Le champ
`nftCreation` de la réponse les discrimine, sans avoir à inspecter les autres
champs :

```ts
const res = await createNft(body);

if (res.nftCreation) {
  // Mint effectué : res.contractAddress, res.tokenId, res.transactionHash,
  // res.explorerLink, res.recipient, res.metadata
} else {
  // Œuvre seulement : res.sourceFileHash, res.asset
  // (ni contractAddress, ni tokenId, ni transactionHash)
}
```

### Frapper le NFT plus tard

Rappeler `POST /nft/create` avec **le même `sourceFile`** et
`nftCreation: true` (ou champ omis). `asset_properties` étant clefé sur
`source_file_hash` seul, l'enregistrement retrouve l'œuvre déjà consignée et y
rattache le nouveau token, au lieu de la dupliquer.

Comme la même recherche fait aussi le backfill des octets sur une œuvre connue,
le fichier source et l'image d'aperçu peuvent également être fournis à ce second
appel s'ils ne l'avaient pas été au premier.

> **À ne pas confondre avec `POST /nft/register`**, qui enregistre un NFT
> **existant déjà on-chain** et exige donc `contractAddress` et `tokenId`.

### Comparatif des trois flux

| | `create` + `nftCreation: true` (défaut) | `create` + `nftCreation: false` | `register` |
|---|---|---|---|
| Déploie un contrat | Oui | Non | Non |
| Frappe un token | Oui | Non | Non (déjà frappé) |
| Requiert RPC + gas | Oui | **Non** | Oui (lecture `ownerOf()`) |
| Requiert `sourceFile` | Non | **Oui** | Non |
| Requiert `symbol`/`imageUrl`/`thumbnail` | Oui | **Non** | — |
| Requiert `contractAddress`/`tokenId` | Non | Non | **Oui** |
| Lignes écrites | `source_file`, `desc_image`, `asset_properties`, `asset`, `nft` | `source_file`, `desc_image`, `asset_properties` | idem create |

---

## 4. Calcul de CID IPFS (PR #37)

**Endpoint :** `POST /ipfs/cid`
**Authentification :** requise (session + rôle Admin ou Registrar)

Calcule le content identifier IPFS (**CIDv1**) qu'aurait un fichier, **sans rien
uploader ni pinner**. Les octets sont hashés dans un DAG UnixFS en mémoire, puis
jetés — l'équivalent de
`ipfs add --only-hash --cid-version=1 --raw-leaves`.

IPFS étant content-addressed, le CID renvoyé ici est celui que le contenu aura
une fois pinné sur n'importe quel backend : on peut donc enregistrer un CID
maintenant et uploader les octets plus tard sans que l'identifiant change. Les
réglages de l'importer sont alignés sur ceux du front affix-ui, donc un CID
calculé par l'API et un CID calculé dans le navigateur sont directement
comparables.

### Corps de requête

Fournir **soit `url`, soit `content`** — jamais les deux.

| Champ | Type | Description |
|---|---|---|
| `url` | string | URL du fichier à hasher. Les octets sont téléchargés, hashés, jetés. `http`/`https` uniquement, 50 Mo max. |
| `content` | string | Contenu inline, décodé selon `encoding`. |
| `encoding` | `utf8` \| `base64` | Comment décoder `content`. Défaut : `utf8`. |
| `filename` | string | Nom enregistré pour le fichier. **N'affecte pas le CID**, sert seulement à rendre les erreurs lisibles. |

### Exemple — hasher un fichier distant

```bash
curl -X POST http://localhost:3000/ipfs/cid \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $SESSION_TOKEN" \
  -d '{"url":"https://example.com/artwork.png"}'
```

```json
{
  "cid": "bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4",
  "uri": "ipfs://bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4",
  "byteLength": 12,
  "url": "https://example.com/artwork.png",
  "mimeType": "image/png"
}
```

### Exemple — hasher un contenu inline

```json
{
  "content": "aGVsbG8gd29ybGQK",
  "encoding": "base64",
  "filename": "hello.txt"
}
```

`url` et `mimeType` ne sont présents dans la réponse que si la requête a fourni
un `url`.

### Erreurs

| Statut | Cause |
|---|---|
| 400 | Ni `url` ni `content`, ou les deux à la fois |
| 400 | `url` malformé, protocole autre que `http`/`https`, injoignable, statut non‑2xx, ou taille dépassée |
| 500 | L'importer UnixFS n'a pas pu être chargé ou a échoué |

---

## 5. Configuration serveur

Deux variables d'environnement ajoutées ([.env.example](https://github.com/w3hc/roac/blob/main/.env.example)) :

```bash
# URL publique à laquelle l'API est joignable depuis Internet. Les URL d'images
# d'aperçu en sont construites et écrites dans les métadonnées NFT : ce doit
# donc être une adresse qu'un wallet ou une marketplace peut atteindre.
# Défaut : http://localhost:$PORT
PUBLIC_BASE_URL=http://localhost:3000

# Taille maximale du corps HTTP. Les registrars uploadent les fichiers inline en
# base64, ce qui coûte ~33 % au-dessus des octets bruts, en plus de la limite de
# 50 Mo par fichier.
MAX_REQUEST_BODY_SIZE=150mb
```

> `PUBLIC_BASE_URL` par défaut vaut `http://localhost:$PORT` : correct en local,
> **faux en production**. Les métadonnées NFT étant immuables une fois frappées,
> cette variable doit être correcte **avant** le premier mint.

Express plafonne les corps de requête à 100 ko par défaut ; `MAX_REQUEST_BODY_SIZE`
est appliqué au démarrage sur `json()` et `urlencoded()`
([src/main.ts](https://github.com/w3hc/roac/blob/main/src/main.ts)).

### Où atterrissent les octets

| Fichier | Table | Colonnes |
|---|---|---|
| `sourceFile` | `source_files` | `filename`, `mime_type`, `binary_data`, `source_file_hash` |
| `previewImage` | `desc_images` | `filename`, `mime_type`, `binary_data` |

`asset_properties.asset_desc_image_id` est `NOT NULL`, donc une ligne
`desc_images` est toujours écrite ; sans aperçu uploadé, son `binary_data` vaut
`NULL`.

---

## 6. Erreurs

Toutes les erreurs suivent le format de l'API, avec un message préfixé par
l'endpoint concerné.

| Statut | Message (extrait) | Cause |
|---|---|---|
| 400 | `POST /nft/create failed: sourceFile.content is empty or not valid base64` | Base64 vide ou invalide |
| 400 | `POST /nft/create failed: previewImage.content is not valid base64` | Base64 corrompu (détecté par ré-encodage) |
| 400 | `... is N bytes, over the 52428800 byte limit` | Fichier > 50 Mo après décodage |
| 400 | `POST /nft/create failed: sourceFile is required when nftCreation is false` | `nftCreation: false` sans fichier source |
| 400 | hash mismatch | Sur `register`, le `sourceFile` ne hashe pas vers le `sourceFileHash` fourni |
| 400 | `POST /ipfs/cid failed: provide either url or content, not both` | Les deux champs fournis |
| 400 | `POST /ipfs/cid failed: url or content is required` | Aucun des deux |
| 401 | `Session token is missing` / `Invalid or expired session` | Header d'auth absent ou session invalide |
| 404 | — | `GET /nft/preview/:hash` : œuvre inconnue ou sans aperçu |
| 413 | (Express) | Corps HTTP au-delà de `MAX_REQUEST_BODY_SIZE` |

---

## 7. Checklist d'intégration

- [ ] Le client envoie `sourceFile` et `previewImage` en base64 inline, dans le
      corps JSON (pas de `multipart`).
- [ ] Les fichiers > 50 Mo sont rejetés **côté client** avant l'envoi, pour
      éviter un aller-retour de 50 Mo.
- [ ] L'affichage d'une œuvre utilise `files.previewImage.url` (ou
      `{PUBLIC_BASE_URL}/nft/preview/{sourceFileHash}`), sans en-tête d'auth.
- [ ] Le client **ne tente jamais** de récupérer le fichier source par HTTP : il
      n'y a pas d'endpoint.
- [ ] Les deux formes de réponse de `POST /nft/create` sont gérées, discriminées
      par le booléen `nftCreation`.
- [ ] Le parcours « enregistrer maintenant, frapper plus tard » réutilise
      **exactement le même `sourceFile`** au second appel.
- [ ] `image_ipfs` / `files.*.cid` sont affichés comme informatifs : **rien
      n'est pinné**, ces URI ne résolvent pas encore.
- [ ] `PUBLIC_BASE_URL` est configuré sur l'environnement cible **avant** le
      premier mint.

---

## Références

- [docs/API_REFERENCE.md](https://github.com/w3hc/roac/blob/main/docs/API_REFERENCE.md) — référence complète des endpoints
- [docs/REGISTRAR_INTEGRATION.md](https://github.com/w3hc/roac/blob/main/docs/REGISTRAR_INTEGRATION.md) — guide d'intégration registrar
- [docs/DATABASE.md](https://github.com/w3hc/roac/blob/main/docs/DATABASE.md) — schéma et stockage des octets
- Swagger : `http://localhost:3000/api`
