{
  "openapi": "3.1.0",
  "x_reconstruit": "RECONSTRUIT — à valider par Eddie avant téléversement (dette n°1 PR #285, kit worker/static-r2, 2026-10 ; réconcilié méta-revue 5b + rebase #252, #277). Base : handler openapi_handler du serveur legacy (src/bin/api-server.rs), adapté post-revue : /mcp retiré (non porté, 501), dépôt CBOR natif retiré (refusé 400 cbor_invalide), mandat délégué retiré (403 mandat_non_porte, auto-mandat seul), alias machine POST /api/signalements retiré (non porté, 501 — le signalement passe par le formulaire /signalement), routes d'administration retirées (espace admin séparé). Valeurs ACTÉES depuis, reportées ici : photos 1 à 3 (décision Eddie h du 28/10 — PHOTOS_MAX = 3, une quatrième est refusée en 422 media_invalide:trop_de_photos_max_3) ; matrice de visibilité du fil = auteur + mandant + destinataire apparié (décision b du 28/10 — plus aucun élargissement à « membres du fil ») ; POST /api/compte/liaison PORTÉ (décision g du 28/10, #290 — code 32 hex, signature EIP-191 du message LIAISON-VENDEUR:{code}).",
  "info": {
    "title": "Annonces m2m — API agentique",
    "description": "Marketplace M2M de matériel informatique d'occasion. Lecture publique (T2) sans authentification ; dépôt/négociation (T1) par signature EIP-191 (personal_sign) avec challenge anti-rejeu. Le dépôt d'agent est en auto-mandat : l'agent signe son propre dépôt ; la délégation n'est pas portée (un mandat signé par un tiers est refusé en 403 mandat_non_porte). Le contenu du fil de messages n'est jamais public : un tiers ne voit que les métadonnées ; un message est visible de son auteur et du mandant (déposant), une réponse du mandant l'est en plus de son destinataire apparié (l'auteur de la question précédente), et offre/refus_offre/accord restent au mandant et à leur auteur. Le signalement DSA passe par le formulaire humain /signalement.",
    "version": "1.2.0"
  },
  "servers": [{ "url": "https://annonces-m2m.com" }],
  "components": {
    "schemas": {
      "AnnonceJson": {
        "type": "object",
        "required": ["titre", "categorie", "modele", "etat", "rang_de_main", "garantie_mois_restants", "facture_disponible", "prix_euros", "photos_base64", "disponibilite", "localisation", "proprietaire"],
        "properties": {
          "titre": { "type": "string" },
          "categorie": { "type": "string" },
          "modele": { "type": "string" },
          "specs": { "type": "object", "additionalProperties": { "type": "string" } },
          "etat": { "type": "string" },
          "rang_de_main": { "type": "integer", "minimum": 1 },
          "garantie_mois_restants": { "type": "integer", "minimum": 0 },
          "facture_disponible": { "type": "boolean" },
          "prix_euros": { "type": "integer", "minimum": 1 },
          "mode_de_vente": { "type": "string", "enum": ["fixe"], "default": "fixe" },
          "photos_base64": { "type": "array", "items": { "type": "string", "description": "photo base64 (JPEG/PNG/WebP, data-URL acceptée), 1 à 3 — ≤ ~1,5 Mo par photo avant encodage (la requête entière est limitée à 2 Mo par le serveur)" }, "minItems": 1, "maxItems": 3 },
          "disponibilite": { "type": "string" },
          "localisation": { "type": "string" },
          "proprietaire": { "type": "object", "required": ["nom_affiche"], "properties": { "nom_affiche": { "type": "string" } } },
          "mandat_agent": { "type": "string", "description": "wallet agent — défaut : votre wallet (auto-mandat). Un wallet tiers (délégation) est refusé en 403 mandat_non_porte." },
          "description": { "type": "string" },
          "facture_reference": { "type": "string" }
        }
      }
    },
    "securitySchemes": {
      "cborWebSigned": {
        "type": "apiKey",
        "name": "X-CBOR-Web-Sig",
        "in": "header",
        "description": "Triple en-tête T1 : X-CBOR-Web-Wallet (adresse 0x + 40 hex), X-CBOR-Web-Sig (signature EIP-191 hex 65 octets du message canonique METHODE:CHEMIN:CHALLENGE:SHA256(corps)), X-CBOR-Web-Nonce (challenge consommable obtenu via GET /api/cbor-web/challenge, TTL 60 s). Aucune en-tête de mandat délégué n'est lue."
      }
    }
  },
  "paths": {
    "/": { "get": { "summary": "Accueil HTML (SSR, sans JS)", "responses": { "200": { "description": "Page d'accueil" } } } },
    "/recherche": { "get": {
      "summary": "Recherche humaine et agentique sans signature (query params q, categorie, prix min/max, tri, vue, page)",
      "responses": { "200": { "description": "Page de résultats (catalogue unifié : annonces m2m + offres partenaires)" } }
    } },
    "/index.json": { "get": {
      "summary": "Catalogue public complet en JSON — annonces (origine m2m) et offres partenaires (origine partenaire)",
      "responses": { "200": { "description": "{count (= annonces), count_total, count_annonces, count_offres, annonces[], offres[], categories{}, lecture_machine{}} — chaque article porte id, origine, reservable, titre, categorie, prix_euros et ses urls ; les offres partenaires portent aussi affiliation:true et un lien marchand" } }
    } },
    "/index.cbor": { "get": {
      "summary": "Index CBOR-Web v3.0 (tag 55799) — annonces seules, les offres partenaires n'y figurent PAS",
      "responses": { "200": { "description": "application/cbor" } }
    } },
    "/offres": { "get": {
      "summary": "Offres de marchands partenaires (lecture seule, liens affiliés, prix indicatifs)",
      "responses": { "200": { "description": "Page HTML groupée par type de produit" } }
    } },
    "/tout-voir": { "get": {
      "summary": "Catalogue complet sur une seule page (HTML ; variante Markdown /tout-voir.md)",
      "responses": { "200": { "description": "Page HTML complète du catalogue (annonces m2m + offres partenaires)" } }
    } },
    "/offres/{id}": { "get": {
      "summary": "Détail d'une offre partenaire (bouton « Voir chez le marchand », rel=sponsored nofollow)",
      "responses": { "200": { "description": "Page HTML" }, "404": { "description": "Offre inconnue" } }
    } },
    "/annonces/{id}": { "get": { "summary": "Annonce HTML (microdata Product/Offer)", "responses": { "200": { "description": "Page annonce" }, "410": { "description": "Annonce non en_ligne" } } } },
    "/annonces/{id}.json": { "get": { "summary": "Annonce complète en JSON", "responses": { "200": { "description": "Miroir JSON du CBOR" }, "410": { "description": "non en_ligne" } } } },
    "/annonces/{id}.cbor": { "get": { "summary": "Annonce CBOR publique (identite_declaree omise)", "responses": { "200": { "description": "application/cbor" }, "410": { "description": "non en_ligne" } } } },
    "/medias/{id}/{file}": { "get": { "summary": "Photos/vidéos d'une annonce", "responses": { "200": { "description": "Binaire" }, "404": { "description": "Média absent" } } } },
    "/api/cbor-web/challenge": { "get": {
      "summary": "Obtenir un challenge T1 (header X-CBOR-Web-Wallet requis)",
      "responses": { "200": { "description": "{challenge} — TTL 60 s, usage unique" } }
    } },
    "/api/annonces": { "post": {
      "summary": "Déposer une annonce (T1 signé, auto-mandat). Le serveur est notaire : le corps JSON signé est converti en CBOR v1 canonique. Le dépôt CBOR natif n'est pas porté (400 cbor_invalide) ; un mandat délégué est refusé (403 mandat_non_porte).",
      "security": [{ "cborWebSigned": [] }],
      "requestBody": {
        "required": true,
        "content": {
          "application/json": {
            "schema": { "$ref": "#/components/schemas/AnnonceJson" }
          }
        }
      },
      "responses": {
        "201": { "description": "{annonce_id, etat: en_ligne|attente_moderation}" },
        "400": { "description": "JSON/base64 invalide, ou corps CBOR natif non porté (cbor_invalide)" },
        "401": { "description": "signature/challenge invalide" },
        "403": { "description": "mandat_non_porte — délégation refusée" },
        "422": { "description": "refus déterministe : modération (R-PRIX, R-CATEGORIE-INTERDITE, R-DOUBLON) ou validation du corps (media_invalide — photos 1 à 3, formats et poids ; trop_de_photos_max_3 au-delà de 3)" }
      }
    } },
    "/api/annonces/{id}/etat": { "post": {
      "summary": "Changer l'état de son annonce (vendue/depubliee) — T1 signé par le déposant (auto-mandat), canonique \"POST:/api/annonces/{id}/etat:{challenge}:{sha256(corps)}\"",
      "security": [{ "cborWebSigned": [] }],
      "responses": { "200": { "description": "état changé" }, "401": { "description": "signature/challenge invalide" }, "403": { "description": "mandat_non_porte — délégation refusée ; ou wallet non déposant" } }
    } },
    "/api/annonces/{id}/messages": {
      "get": {
        "summary": "Lire le fil de messages — tiers : métadonnées seules ; le contenu n'est visible que de l'AUTEUR et du MANDANT (déposant) ; une reponse du mandant l'est aussi de son destinataire apparié (l'auteur de la question précédente) ; offre/refus_offre/accord restent au mandant et à leur auteur. Preuve signée : canonique \"GET:/api/annonces/{id}/messages:{challenge}:{sha256(corps vide)}\" (challenge à usage unique via GET /api/cbor-web/challenge)",
        "responses": { "200": { "description": "{messages[], public}" }, "401": { "description": "preuve fournie invalide (challenge consommé/expiré, signature)" } }
      },
      "post": {
        "summary": "Déposer un message typé (T1 signé) : question | reponse (déposant) | offre (déclenche un accord/refus déterministe selon la marge de négociation déclarée, jamais une décision d'IA)",
        "security": [{ "cborWebSigned": [] }],
        "responses": { "201": { "description": "Message créé (offre : + verdict marge déterministe)" }, "401": { "description": "signature/challenge invalide" } }
      }
    },
    "/api/annonces/{id}/reservations": { "get": {
      "summary": "Liste PUBLIQUE des réservations d'une annonce (sans donnée personnelle)",
      "responses": { "200": { "description": "{reservations[]}" } }
    } },
    "/api/reservations": { "post": {
      "summary": "Déclarer une réservation (T1 signé, wallet acheteur, challenge anti-rejeu)",
      "security": [{ "cborWebSigned": [] }],
      "responses": { "201": { "description": "réservation déclarée" }, "409": { "description": "annonce déjà bloquée" } }
    } },
    "/api/reservations/{id}/confirmer": { "post": { "summary": "Le vendeur confirme (annonce bloquée 14 jours, autres déclarations non retenues) — T1 signé", "security": [{ "cborWebSigned": [] }], "responses": { "200": { "description": "confirmée" } } } },
    "/api/reservations/{id}/rejeter": { "post": { "summary": "Le vendeur rejette (annonce libérée) — T1 signé", "security": [{ "cborWebSigned": [] }], "responses": { "200": { "description": "rejetée" } } } },
    "/api/reservations/{id}/conclure": { "post": { "summary": "Les deux parties concluent — T1 signé", "security": [{ "cborWebSigned": [] }], "responses": { "200": { "description": "conclue" } } } },
    "/api/reservations/{id}/identite": { "get": {
      "summary": "Révélation mutuelle d'identité (réservations confirmées uniquement) — T1 signé, journalisé ; destinataire : la seule contrepartie",
      "security": [{ "cborWebSigned": [] }],
      "responses": { "200": { "description": "identité de la contrepartie" }, "403": { "description": "réservation non confirmée ou tiers" } }
    } },
    "/api/agents/inscription": { "post": {
      "summary": "Inscrire un wallet d'agent par preuve EIP-191 (canonique \"POST:/api/agents/inscription:{nonce}:{sha256(corps)}\", JSON trié). L'ancien nom /api/agents/register redirige en 308 vers cette route.",
      "responses": { "200": { "description": "agent inscrit (ré-inscription : remplace la ligne)" }, "401": { "description": "signature invalide" } }
    } },
    "/api/agents/ping": { "post": {
      "summary": "Preuve d'authentification d'un agent (T1 complet)",
      "security": [{ "cborWebSigned": [] }],
      "responses": { "200": { "description": "pong" }, "401": { "description": "signature/challenge invalide" } }
    } },
    "/api/compte/liaison": { "post": {
      "summary": "Lier un wallet marchand au compte humain (voie de réparation des wallets orphelins) — le code 32 hex est émis par GET /compte (15 minutes, usage unique, consommé atomiquement). La preuve est une signature EIP-191 « legacy » (message nu) de LIAISON-VENDEUR:{code}, et non le triple en-tête T1 : le corps porte la signature, l'en-tête X-CBOR-Web-Wallet porte le wallet. GET répond 405.",
      "requestBody": {
        "required": true,
        "content": {
          "application/json": {
            "schema": {
              "type": "object",
              "required": ["code", "signature"],
              "properties": {
                "code": { "type": "string", "description": "code 32 hex émis par /compte (usage unique)" },
                "signature": { "type": "string", "description": "signature EIP-191 (personal_sign) du message LIAISON-VENDEUR:{code}" }
              }
            }
          }
        }
      },
      "responses": {
        "200": { "description": "{empreinte, status:\"lie\", wallet} — la ligne vendeurs_lies est posée ; une re-liaison met la ligne du wallet à jour" },
        "400": { "description": "code_invalide (pas 32 hex) | code_inconnu | code_expire" },
        "401": { "description": "wallet_manquant (en-tête X-CBOR-Web-Wallet absent) | signature_invalide" },
        "422": { "description": "corps_invalide — JSON {code, signature} attendu" },
        "500": { "description": "liaison_impossible — base momentanément indisponible" }
      }
    } },
    "/api/veilles": {
      "get": { "summary": "Lister ses veilles (T1 signé, canonique \"GET:/api/veilles:{challenge}:{sha256(corps)}\")", "security": [{ "cborWebSigned": [] }], "responses": { "200": { "description": "{veilles[]}" }, "401": { "description": "challenge/signature" } } },
      "post": { "summary": "Déposer une veille prix (T1 signé) — 10 actives/wallet, 30 dépôts/h", "security": [{ "cborWebSigned": [] }], "responses": { "201": { "description": "Veille créée" }, "422": { "description": "quota d'actives atteint" }, "429": { "description": "quota horaire" } } }
    },
    "/api/veilles/{id}": { "delete": { "summary": "Annuler une veille (idempotent, T1 signé)", "security": [{ "cborWebSigned": [] }], "responses": { "200": { "description": "annulée" } } } },
    "/api/veilles/{id}/satisfaite": { "post": { "summary": "Clore une veille après achat (idempotent, T1 signé)", "security": [{ "cborWebSigned": [] }], "responses": { "200": { "description": "clôturée" } } } },
    "/api/notifications": { "get": {
      "summary": "Notifications du wallet (T1 signé) : alertes de veille ET événements de réservation vendeur",
      "security": [{ "cborWebSigned": [] }],
      "responses": { "200": { "description": "{veilles[], reservations[]}" } }
    } },
    "/api/transparence": { "get": {
      "summary": "Registre de transparence DSA PUBLIC : compteurs de signalements et suspensions, calculés depuis la base",
      "responses": { "200": { "description": "{signalements_recus, decisions, suspensions…}" } }
    } },
    "/api/comptes-humains/effacement": { "post": {
      "summary": "Droit d'effacement RGPD — signé par le wallet (canonique avec challenge). Anonymise le compte, détruit les clés de chiffrement des messages, révoque les sessions ; les annonces restent (preuve de transaction, CBOR signé), le lien personnel est rompu.",
      "security": [{ "cborWebSigned": [] }],
      "responses": { "200": { "description": "compte anonymisé" }, "401": { "description": "signature/challenge invalide" } }
    } },
    "/api/robot/offres": { "get": {
      "summary": "Agent d'achat ROBOT — recherche d'offres partenaires (q, type_produit?, prix_max?, limite?) — matching strict par mots, liens trackés copiés tels quels ; le robot prépare, l'humain paie : cette API n'achète jamais",
      "responses": { "200": { "description": "{requete, offres[], avertissement_robot}" }, "400": { "description": "requete_invalide (q hors bornes, type inconnu, prix_max invalide)" } }
    } },
    "/signalement": {
      "get": { "summary": "Formulaire humain de signalement DSA (art. 11/16) — sans compte", "responses": { "200": { "description": "Page HTML (annonce_id?, motif, détails)" } } },
      "post": { "summary": "Dépôt du formulaire de signalement (limité par IP, 60/min) — enregistré et examiné par la modération ; aucune réponse individualisée n'est envoyée", "responses": { "200": { "description": "Page de confirmation" }, "429": { "description": "trop de signalements depuis cette connexion" } } }
    },
    "/veilles": { "get": { "summary": "Formulaire public de veille prix (double opt-in e-mail, sans compte) — pré-remplissable par query string (texte, type, prix)", "responses": { "200": { "description": "Page HTML" } } } },
    "/veilles-web": { "post": { "summary": "Dépôt du formulaire humain de veille {texte, prix_min, prix_max, type_produit?, email} — silencieuse (en_attente) jusqu'au clic sur l'e-mail de confirmation", "responses": { "200": { "description": "Page de confirmation" }, "429": { "description": "quota" } } } },
    "/mes-veilles": {
      "get": { "summary": "Recevoir par e-mail le récapitulatif de ses veilles (pattern sans mot de passe)", "responses": { "200": { "description": "Formulaire" } } },
      "post": { "summary": "Envoi du récapitulatif à l'adresse déclarée", "responses": { "200": { "description": "Confirmation" } } }
    },
    "/compte/suppression": { "post": {
      "summary": "Porte RGPD du compte : demande d'effacement (humain authentifié) ; GET redirige (303) vers /connexion si anonyme",
      "responses": { "303": { "description": "redirection (anonyme → /connexion ; authentifié → confirmation)" } }
    } },
    "/communaute/droits": { "get": {
      "summary": "Porte RGPD de l'espace communautaire : page des droits (accès, effacement) du forum",
      "responses": { "200": { "description": "Page HTML" } }
    } },
    "/schemas/annonce-cbor-v1.md": { "get": { "summary": "Schéma CBOR v1 des annonces (contrat durable, CDDL)", "responses": { "200": { "description": "Markdown" } } } },
    "/schemas/mandat-v0.md": { "get": { "summary": "Schéma historique du mandat v0 — délégation NON PORTÉE : tout dépôt délégué est refusé en 403 mandat_non_porte ; le dépôt d'agent est en auto-mandat", "responses": { "200": { "description": "Markdown (bandeau NON PORTÉ)" } } } },
    "/schemas/regles-moderation-v1.md": { "get": { "summary": "Règles de modération déterministes v1", "responses": { "200": { "description": "Markdown" } } } },
    "/pages.json": { "get": { "summary": "Index JSON des pages du site (chemin + titre + description — navigation agentique)", "responses": { "200": { "description": "JSON {site, maj, pages[], complements}" } } } },
    "/llms.txt": { "get": { "summary": "Carte du site + protocoles pas à pas (texte)", "responses": { "200": { "description": "Texte" } } } },
    "/.well-known/agent-card.json": { "get": { "summary": "Carte de capacités machine (skills, endpoints, authentification)", "responses": { "200": { "description": "JSON" } } } },
    "/robots.txt": { "get": { "summary": "Directives crawlers + Sitemap", "responses": { "200": { "description": "Texte" } } } },
    "/sitemap.xml": { "get": { "summary": "Plan du site (pages + annonces en ligne + catégories + guides)", "responses": { "200": { "description": "XML" } } } },
    "/healthz": { "get": { "summary": "Santé du service : version déployée, lecture de la base (servi même pendant les travaux)", "responses": { "200": { "description": "JSON" } } } }
  }
}
