diff --git a/README.md b/README.md index 6e05618..ba6ed40 100644 --- a/README.md +++ b/README.md @@ -275,9 +275,10 @@ Légende : **✅ livré** (câblé de bout en bout) · **🟡 partiellement livr * ✅ **Import/Export playlists (JSON)** — `GET /playlists/export` (dump complet + items, format `newtube-playlists-v1`), `POST /playlists/import` (création des listes manquantes, dédoublonnage par titre au réimport, compteurs détaillés ; bornes 200 listes / 5 000 vidéos par liste) ; boutons **[Exporter] [Importer]** sur `/#/library/playlists`. * ✅ **Page Administration** (`/#/admin`, entrée « Administration » de la section Informations dans la barre latérale) — `/healthz` + `/api/providers/metrics` rendus lisibles : statut API, mode YouTube, version et santé yt-dlp, clés actives / bannies (+ suffixes), anti-ban (cookies / PO token / proxy), cache de recherche par provider, compteurs 1 h par provider, quota YouTube du jour. Au passage : `healthz` servi aussi sous `/proxy/api/healthz` (le chemin que le front utilise en prod). *Journaux et exposition Prometheus livrés — voir Observabilité.* * ✅ **Cache TTL par provider** — `server/env-ttl.mjs` (`_` → `` → défaut, valeurs invalides ignorées, jamais de TTL nul) branché sur les caches **details** (`DETAILS_CACHE_TTL_MS_

`) et **transcripts** (`TRANSCRIPT_CACHE_TTL_

`) en plus de la recherche (`SEARCH_CACHE_TTL_MS_

`). Le cache **suggest** reste global par conception : sa clé agrégée couvre plusieurs providers d'un coup, un TTL par provider demanderait de scinder le cache. Tests : `npm run test:cache` -* ✅ **Menu du compte refondu** (bouton avatar, haut à droite du header) — panneau sombre à lignes « icône + titre gras + sous-titre gris » : **Thème** (thème réellement appliqué en sous-titre, pastilles dépliées au clic), **Administration** (`/#/admin`), **API** (`/proxy/api/openapi.json` dans un nouvel onglet), **Préférences** (`/#/account/preferences`), **Guide d'utilisation** (`/#/info/utilisation`), **À propos** (version, tagline, lien code source), **Sessions** (`/#/account/sessions`), **Déconnexion** ; pied de page **Version** = version officielle de l'application (`src/app/version.ts`, alignée sur le tag semver publié par `docker/deploy-img.sh`). Clés i18n déclarées des deux côtés (FR + EN) +* ✅ **Menu du compte refondu** (bouton avatar, haut à droite du header) — panneau sombre à lignes « icône + titre gras + sous-titre gris » : **Thème** (thème réellement appliqué en sous-titre, pastilles dépliées au clic), **Administration** (`/#/admin`), **API** (`/proxy/api/docs` : Swagger UI dans un nouvel onglet), **Préférences** (`/#/account/preferences`), **Guide d'utilisation** (`/#/info/utilisation`), **À propos** (version, tagline, lien code source), **Sessions** (`/#/account/sessions`), **Déconnexion** ; pied de page **Version** = version officielle de l'application (`src/app/version.ts`, alignée sur le tag semver publié par `docker/deploy-img.sh`). Clés i18n déclarées des deux côtés (FR + EN) * ✅ **Observabilité** — `GET /healthz` (mode `YT_SEARCH_MODE`, binaire yt-dlp, cache, quota du jour, clés masquées — testé par `test:api`), `GET /api/providers/metrics` et la **page Admin** qui les rend, **journal JSON structuré en prod** (une ligne par requête : `ts`, `reqId`, `method`, `route`, `status`, `ms`, adossé au header `X-Request-Id` renvoyé sur chaque réponse) et **`GET /metrics`** — exposition Prometheus sans dépendance (`http_requests_total` par route et code, somme/nombre de durées, démarrage + mémoire du processus ; `METRICS_TOKEN` verrouille l'accès si défini) * ✅ **API production-ready (P0 + P1)** — **plus aucun secret servi au navigateur** : `/assets/config.local.js` est généré par le serveur **avant** les montages statiques (il l'emporte donc sur le fichier local) et ne contient plus `YOUTUBE_API_KEY(S)`, le fichier local n'est plus copié dans l'image Docker ni embarqué dans `dist`, et les appels YouTube passent par `/api/yt` uniquement (clé, rotation, quota et cache côté serveur — suppression des appels directs à googleapis et des gardes « pas de clé ⇒ écran vide ») ; **CORS piloté par `API_ALLOWED_ORIGINS`** (CSV) + méthode `PATCH` (requis par `/user/preferences`) ; **clés d'API longue durée** : `GET/POST /api/keys` + `DELETE /api/keys/:id`, jeton `ntk_…` affiché une seule fois, stocké en SHA-256 avec préfixe affichable, header `X-API-Key` accepté par les **deux** middlewares d'auth (toutes les routes protégées deviennent scriptables), `last_used_at` renseigné à chaque usage ; **`X-Request-Id`** en réponse sur chaque requête ; **`/api/details` rate-limité** (`DETAILS_RATE_LIMIT`, 60/min, réponse JSON — `/api/transcript` avait déjà le sien) ; **version unique `package.json`** (menu du compte + `info.version` de l'OpenAPI). Tests : `npm run test:api` +* ✅ **Documentation des API (Swagger UI)** — lien **API** du menu → `/proxy/api/docs` : Swagger UI auto-hébergé (`swagger-ui-dist`, aucun CDN, thème sombre) branché sur `/openapi.json`, **contrat exhaustif généré depuis l'arbre Express** — 93 chemins / 115 opérations contre 20 chemins avant : les entrées écrites à la main gardent paramètres et réponses, le reste est dérivé du code (`:param` → `{param}`, `*` → `{path}`, miroirs `/proxy/*` et métadonnées écartés, `app.all` réduit à GET — les 35 méthodes HTTP faisaient planter Swagger), tags + descriptions de groupe, **3 schémas d'authentification** (JWT `Authorization: Bearer`, clé `X-API-Key`, cookie httpOnly) avec bouton **Authorize**, et une section **« Serveur MCP »** dans la présentation : outils **lus dans `mcp/server.mjs` au démarrage** (aucune liste à maintenir), snippet `mcpServers`, `NEWTUBE_API_URL` / `NEWTUBE_TOKEN` ; `mcp/` est copié dans l'image pour que cette lecture existe en prod. Tests : `npm run test:api` (+3 cas : page + assets, exhaustivité/méthodes/tags/sécurité, auth + MCP) ### 🟡 Partiellement livré diff --git a/docker/Dockerfile.origi b/docker/Dockerfile.origi index 399fe49..28c3f74 100644 --- a/docker/Dockerfile.origi +++ b/docker/Dockerfile.origi @@ -37,6 +37,9 @@ RUN apt-get update \ # Copy runtime server and built frontend COPY --from=builder /app/server ./server +# Serveur MCP (stdio) : référencé par la doc /api/docs (liste des outils lue +# dans ce fichier au démarrage) et utilisable depuis le conteneur. +COPY --from=builder /app/mcp ./mcp COPY --from=builder /app/dist ./dist # Copy the DB schema AND migrations; the actual DB file will be created on first run COPY --from=builder /app/db/schema.sql ./db/schema.sql diff --git a/package-lock.json b/package-lock.json index 0777106..7ada1c2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "newtube", - "version": "0.0.0", + "version": "1.0.60", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "newtube", - "version": "0.0.0", + "version": "1.0.60", "dependencies": { "@angular/build": "^20.1.0", "@angular/cdk": "^20.2.4", @@ -32,6 +32,7 @@ "helmet": "^7.1.0", "jsonwebtoken": "^9.0.2", "rxjs": "^7.8.2", + "swagger-ui-dist": "^5.33.1", "tailwindcss": "latest", "youtube-dl-exec": "^3.0.0", "youtubei.js": "18.1.0", @@ -3762,6 +3763,13 @@ "win32" ] }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, "node_modules/@schematics/angular": { "version": "20.2.2", "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-20.2.2.tgz", @@ -9139,6 +9147,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/swagger-ui-dist": { + "version": "5.33.1", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.33.1.tgz", + "integrity": "sha512-H872wWkA53bFIsGgi7OWgmq+CRWw3nFQGdJWRqOB9wNwTm6e5ol34+qPDkV4AJlK+gglM2EsJiOOzsGsEGbluA==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } + }, "node_modules/tailwindcss": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.13.tgz", diff --git a/package.json b/package.json index a12fc67..7e98385 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "newtube", "private": true, - "version": "1.0.60", + "version": "1.0.61", "type": "module", "scripts": { "dev": "ng serve", @@ -76,6 +76,7 @@ "helmet": "^7.1.0", "jsonwebtoken": "^9.0.2", "rxjs": "^7.8.2", + "swagger-ui-dist": "^5.33.1", "tailwindcss": "latest", "youtube-dl-exec": "^3.0.0", "youtubei.js": "18.1.0", diff --git a/server/index.mjs b/server/index.mjs index 2874af7..23bcf7e 100644 --- a/server/index.mjs +++ b/server/index.mjs @@ -4191,6 +4191,260 @@ r.put('/playlists/:id/reorder', authMiddlewareCookieAware, (req, res) => { } }); +// --- Documentation web (Swagger UI) + OpenAPI exhaustif --------------------- +// Le lien « API » du menu ouvre /docs : Swagger UI auto-hébergé (swagger-ui-dist) +// branché sur /openapi.json, qui décrit TOUTES les routes du serveur. +const SWAGGER_DIR = path.join(process.cwd(), 'node_modules', 'swagger-ui-dist'); +r.use('/docs/assets', express.static(SWAGGER_DIR, { index: false, fallthrough: false })); + +r.get('/docs', (req, res) => { + const base = req.baseUrl || '/api'; // /api ou /proxy/api (les deux préfixes du router) + res.type('html').send(` + + + + +NewTube — Documentation API + + + + + +

+ NewTube — Documentation API + + openapi.json · + /metrics · + ← retour à l'application + +
+
+ + + + +`); +}); + +// ---- Inventaire automatique des routes (source de vérité : Express) -------- +// Les ~20 entrées détaillées du document sont écrites à la main (paramètres, +// réponses) ; TOUTE le reste est généré à partir de l'arbre Express, ce qui +// empêche la doc de dériver du code. +/** Montage Express 4 : `layer.regexp.source` = `^\/api\/?(?=\/|$)`. */ +function expressMount(layer) { + if (!layer.regexp || layer.regexp.fast_slash) return ''; + return String(layer.regexp.source || '') + .replace(/^\^/, '') + .replace(/\\\/\?\(\?=\\\/\|\$\)$/, '') + .replace(/\\\//g, '/'); +} + +/** + * Routes enregistrées, en chemins OpenAPI (`:id` → `{id}`, `*` → `{path}`), + * avec le marquage d'authentification : middlewares posés SUR la route + * (authMiddleware*) ET middlewares de préfixe (`r.use('/download', …)`), dans + * l'ordre d'enregistrement — la sémantique Express. + * Les miroirs /proxy/*, les métadonnées (assets, config) et la page de doc + * sont écartés : le document est décrit sous le serveur `/api`. + */ +function collectApiRoutes(stack, prefix, out, authPrefixes = [], depth = 0) { + if (!stack || depth > 4) return out; + for (const layer of stack) { + if (layer.route) { + const paths = Array.isArray(layer.route.path) ? layer.route.path : [layer.route.path]; + const ownAuth = (layer.route.stack || []).some((l) => l?.handle?.name === 'authMiddleware' + || l?.handle?.name === 'authMiddlewareCookieAware'); + for (const p of paths) { + const full = `${prefix}${p}`; + if (full.includes('...')) continue; // route d'placeholder + if (full.startsWith('/proxy')) continue; // miroir dev/prod + if (full === '/*' || full === '*') continue; // fallback SPA + // Le document est servi avec le serveur `/api` : on retire ce préfixe + // quand la route est écrite en dur dessus (`/api/search` → `/search`). + const rel = full.startsWith('/api/') ? full.slice(4) : full; + if (rel.startsWith('/assets') || rel === '/config.js') continue; // config navigateur + if (rel === '/docs' || rel.startsWith('/docs/')) continue; // méta-doc + const oa = rel.replace(/:([A-Za-z0-9_]+)/g, '{$1}').replace(/\*/g, '{path}'); + const auth = ownAuth || authPrefixes.some((a) => full.startsWith(a)); + const registered = Object.keys(layer.route.methods || {}); + // `app.all()` enregistre les 35 méthodes HTTP : OpenAPI n'accepte que + // les standard, et Swagger plante sur `acl`, `m-search`, `propfind`… + const methods = registered.length > 7 + ? ['get'] + : registered.filter((m) => ['get', 'post', 'put', 'patch', 'delete'].includes(m)); + for (const m of methods) { + out.push({ method: m.toUpperCase(), path: oa, auth }); + } + } + } else if (layer.name === 'router' && layer.handle && Array.isArray(layer.handle.stack)) { + collectApiRoutes(layer.handle.stack, `${prefix}${expressMount(layer)}`, out, [...authPrefixes], depth + 1); + } else if (layer.regexp && (layer.handle?.name === 'authMiddleware' + || layer.handle?.name === 'authMiddlewareCookieAware')) { + // Middleware d'auth posé sur un préfixe : il couvre tout ce qui suit. + authPrefixes.push(prefix + expressMount(layer)); + } + } + return out; +} + +/** Libellés FR des routes générées (les entrées détaillées portent les leurs). */ +const API_SUMMARIES = { + 'GET /healthz': 'Santé détaillée (mode YouTube, yt-dlp, cache, quota, clés masquées)', + 'GET /health': 'Healthcheck court (Docker)', + 'GET /metrics': 'Métriques Prometheus (compteurs HTTP, durées, processus)', + 'GET /search': 'Recherche unifiée multi-providers (filtres, pagination)', + 'GET /trending': 'Tendances YouTube (scrape, sans clé)', + 'GET /ai/status': 'État du résumé IA (Gemini configuré ou non)', + 'POST /ai/summarize': 'Résumé IA d\'une vidéo', + 'GET /twitch-token': 'Jeton applicatif Twitch (client credentials côté serveur)', + 'GET /twitch/top-streams': 'Top directs Twitch', + 'GET /openapi.json': 'Spécification OpenAPI JSON (source de cette page)', + 'GET /playlists/export': 'Exporter toutes les playlists (JSON)', + 'POST /playlists/import': 'Importer des playlists (JSON, dédoublonnage par titre)', + 'GET /playlists/public': 'Playlists publiques', + 'GET /playlists/{id}/view': 'Compteur de vues d\'une playlist', + 'POST /download/{provider}/{videoId}': 'Téléverser une vidéo (crée un job)', + 'GET /download/jobs': 'Lister les jobs de téléchargement', + 'GET /download/jobs/{id}': 'État d\'un job', + 'GET /download/jobs/{id}/file': 'Récupérer le fichier téléchargé', + 'POST /download/jobs/{id}/retry': 'Reprendre un job échoué', + 'DELETE /download/jobs/{id}': 'Annuler et supprimer un job', + 'GET /user/likes/status': 'Statut « liké » d\'une vidéo', + 'GET /user/watch-later/status': 'Statut « à regarder plus tard » d\'une vidéo', + 'GET /user/history/search': 'Historique de recherche', + 'GET /user/history/watch': 'Historique de visionnage', + 'GET /user/history/transcripts': 'Transcripts sauvegardés', + 'POST /user/history/takeout': 'Exporter son historique (takeout)', + 'GET /auth/sessions': 'Sessions connectées', + 'DELETE /auth/sessions/{id}': 'Révoquer une session', + 'POST /auth/register': 'Créer un compte', + 'POST /auth/login': 'Connexion (accessToken + cookies httpOnly)', + 'POST /auth/refresh': 'Renouveler l\'accessToken via les cookies', + 'POST /auth/logout': 'Déconnexion', + 'GET /channels/{provider}/{externalId}': 'Méta-données d\'une chaîne (avatar, abonnés)', + 'GET /channels/{provider}/{externalId}/content': 'Vidéos d\'une chaîne', + 'POST /channels/resolve': 'Résoudre une URL de chaîne en identifiant', + 'GET /subscriptions': 'Abonnements aux chaînes', + 'POST /subscriptions': 'S\'abonner à une chaîne', + 'POST /subscriptions/batch': 'S\'abonner en lot (import OAuth)', + 'DELETE /subscriptions/{subscriptionId}': 'Se désabonner', + 'GET /subscription-groups': 'Groupes d\'abonnements (façon PocketTube)', + 'GET /providers/health': 'Santé des fournisseurs (sonde + métriques 1 h)', + 'GET /providers/metrics': 'Métriques par fournisseur (recherche, erreurs)', + 'GET /telemetry/events': 'Événements UX anonymes', + 'GET /telemetry/summary': 'Résumé des événements UX', + 'GET /img/odysee': 'Proxy d\'image Odysee', + 'GET /peertube/{instance}/{path}': 'Proxy générique vers une instance PeerTube', + 'GET /yt/{path}': 'Proxy YouTube Data API avec clé, rotation et cache serveur', + 'GET /rumble/browse': 'Rumble : page « browse » (scraping)', + 'GET /rumble/search': 'Rumble : recherche (scraping)', + 'GET /rumble/video/{id}': 'Rumble : métadonnées d\'une vidéo', + 'GET /rumble/live': 'Rumble : directs (page SSR)', + 'GET /oauth/status': 'Connecteurs OAuth configurés (Google, Twitch)', +}; + +/** Tags : premier segment du chemin + descriptions affichées par Swagger. */ +const TAG_OF = { + healthz: 'health', health: 'health', metrics: 'health', + trending: 'video', details: 'video', transcript: 'video', peertube: 'video', + yt: 'video', img: 'video', twitch: 'video', + user: 'account', keys: 'account', + subscriptions: 'social', 'subscription-groups': 'social', + rumble: 'video', + dm: 'video', odysee: 'video', 'twitch-api': 'video', 'twitch-auth': 'video', 'twitch-token': 'video', + providers: 'admin', telemetry: 'admin', ai: 'admin', + openapi: 'meta', 'openapi.json': 'meta', +}; +const TAG_DESCRIPTIONS = [ + { name: 'meta', description: 'Découverte de l\'API : spécification OpenAPI, métriques.' }, + { name: 'health', description: 'Santé du service et métriques d\'exploitation.' }, + { name: 'search', description: 'Recherche unifiée et suggestions multi-providers.' }, + { name: 'video', description: 'Lecture : métadonnées, transcripts, tendances, proxies fournisseurs.' }, + { name: 'channels', description: 'Chaînes : métadonnées et contenu.' }, + { name: 'playlists', description: 'Playlists utilisateur (CRUD, export/import JSON).' }, + { name: 'account', description: 'Espace utilisateur : profil, préférences, likes, historique, clés d\'API.' }, + { name: 'auth', description: 'Inscription, connexion, sessions, jetons.' }, + { name: 'social', description: 'Abonnements aux chaînes et groupes.' }, + { name: 'oauth', description: 'Connexions OAuth et import de favoris/abonnements.' }, + { name: 'download', description: 'Téléchargements : formats, jobs, fichiers.' }, + { name: 'admin', description: 'Administration : santé providers, métriques, IA, télémétrie.' }, +]; + +/** Outils MCP lus dans mcp/server.mjs (aucune liste à maintenir à la main). */ +const MCP_TOOL_NAMES = (() => { + try { + const src = fs.readFileSync(new URL('../mcp/server.mjs', import.meta.url), 'utf8'); + const start = src.indexOf('const TOOLS = ['); + const end = src.indexOf('\n];', start); + if (start < 0 || end < 0) return []; + return [...src.slice(start, end).matchAll(/name:\s*'([a-z0-9_]+)'/g)].map((m) => m[1]); + } catch { + return []; + } +})(); + +const OPENAPI_DESCRIPTION = `JSON consommé par le front NewTube et par des clients externes (scripts, +Postman, collecteurs). **Toutes les routes ci-dessous sont préfixées par le serveur choisi** : \`/api\` (direct) +ou \`/proxy/api\` (miroir utilisé par le front en production) — même contrats des deux côtés. + +## Authentification +Trois mécanismes interchangeables sur les routes protégées (bouton **Authorize**) : + +1. **JWT (navigateur)** — \`POST /auth/login\` renvoie \`accessToken\` (15 min) : + \`Authorization: Bearer \`. +2. **Clé d'API (clients externes)** — \`POST /keys\` (avec un Bearer) renvoie \`ntk_…\` **une seule fois** : + \`X-API-Key: ntk_…\`. Empreinte SHA-256 en base, révocation immédiate par \`DELETE /keys/{id}\`, + dernier usage horodaté. Recommandé pour tout script longue durée. +3. **Cookies httpOnly (le front)** — \`sid\` + \`refreshToken\` posés par \`/auth/login\`, renouvelés par + \`POST /auth/refresh\`. Un client script n'a pas besoin de les imiter : utiliser (1) ou (2). + +## Erreurs et limites de débit +Corps d'erreur homogène : \`{ "error": "" }\` — 400 saisie, 401 non authentifié, 403 interdit, +404 introuvable, 429 \`rate_limited\` (headers \`RateLimit-*\`), 503 fournisseur indisponible +(ex. \`rumble_cloudflare_challenge\`). Seaux notables : login 5/min, \`/details\` 60/min, +\`/transcript\` 10/min. Chaque réponse porte \`X-Request-Id\` (à citer dans un rapport de bug) et +\`GET /metrics\` expose les compteurs Prometheus. + +## Serveur MCP +Un serveur MCP en stdio (\`mcp/server.mjs\`, \`npm run mcp\`) expose NewTube aux clients type +Claude / VS Code / opencode :${MCP_TOOL_NAMES.length ? `\n\n\`${MCP_TOOL_NAMES.join('` `')}\`` : ''} + +\`\`\`json +{ "mcpServers": { "newtube": { + "command": "node", + "args": ["…/NewTube/mcp/server.mjs"], + "env": { + "NEWTUBE_API_URL": "http://localhost:4000/api", + "NEWTUBE_TOKEN": "" + } } } } +\`\`\` + +\`NEWTUBE_TOKEN\` n'est requis que pour les outils d'écriture (playlists, likes, abonnements, +historique, téléchargements) ; la lecture publique fonctionne sans jeton. Guide détaillé : +\`docs/API_MCP_GUIDE.md\`.`; + // --- OpenAPI (référence machine : lecture publique + espace utilisateur) --- // Version = package.json, source unique partagée avec le front (src/app/version.ts). const OPENAPI_VERSION = (() => { @@ -4207,10 +4461,11 @@ r.get('/openapi.json', (_req, res) => { const bearer = [{ bearerAuth: [] }, { apiKeyAuth: [] }]; const doc = { openapi: '3.0.0', + servers: [{ url: '/api' }, { url: '/proxy/api' }], info: { title: 'NewTube API', version: OPENAPI_VERSION, - description: "Agrégateur vidéo multi-fournisseurs. Authentification des routes protégées : soit `Authorization: Bearer ` (obtenu sur /auth/login), soit `X-API-Key: ntk_…` (clé créée sur /keys, révocable).", + description: OPENAPI_DESCRIPTION, }, paths: { '/healthz': { @@ -4323,9 +4578,40 @@ r.get('/openapi.json', (_req, res) => { } }, components: { securitySchemes: { - bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' }, - apiKeyAuth: { type: 'apiKey', in: 'header', name: 'X-API-Key' }, - } } + bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT', description: 'accessToken renvoyé par POST /auth/login (15 min).' }, + apiKeyAuth: { type: 'apiKey', in: 'header', name: 'X-API-Key', description: 'Clé ntk_… créée par POST /keys, affichée une seule fois, révocable via DELETE /keys/{id}.' }, + cookieAuth: { type: 'apiKey', in: 'cookie', name: 'refreshToken', description: 'Cookies httpOnly (sid + refreshToken) posés par le front — usage navigateur uniquement.' }, + } }, + tags: TAG_DESCRIPTIONS, }; + + // ---- Inventaire Express : complète le document -------------------------- + // Tout ce qui n'est pas décrit à la main ci-dessus (paramètres, réponses) + // est ajouté depuis l'arbre Express : le document ne peut pas dériver du code. + const documented = new Set(Object.entries(doc.paths) + .flatMap(([p, ops]) => Object.keys(ops).map((m) => `${m.toUpperCase()} ${p}`))); + const tagOf = (p) => { + const seg = String(p).split('/')[1] || 'meta'; + return TAG_OF[seg] || seg; + }; + for (const route of collectApiRoutes((app._router || app.router || {}).stack || [], '', [])) { + const key = `${route.method} ${route.path}`; + if (documented.has(key)) continue; + documented.add(key); + const op = { tags: [tagOf(route.path)] }; + const summary = API_SUMMARIES[key]; + if (summary) op.summary = summary; + if (route.auth) op.security = bearer; + if (!doc.paths[route.path]) doc.paths[route.path] = {}; + doc.paths[route.path][route.method.toLowerCase()] = op; + } + + // Les entrées détaillées écrites à la main ne portent pas de tag : on complète, + // sinon Swagger les groupe sous « default ». + for (const [p, ops] of Object.entries(doc.paths)) { + const t = tagOf(p); + for (const op of Object.values(ops)) if (!Array.isArray(op.tags) || !op.tags.length) op.tags = [t]; + } + res.json(doc); }); diff --git a/server/tests/api_coverage.test.mjs b/server/tests/api_coverage.test.mjs index 817c958..433f20c 100644 --- a/server/tests/api_coverage.test.mjs +++ b/server/tests/api_coverage.test.mjs @@ -310,3 +310,59 @@ describe('production-ready (P0/P1)', () => { assert.equal(r.status, 401); }); }); + +// Documentation web : la page /docs (Swagger UI) et l'exhaustivité du contrat. +describe('documentation web (/docs)', () => { + it('GET /api/docs sert la page Swagger et ses assets', async () => { + const r = await fetch(`${base}/api/docs`); + assert.equal(r.status, 200); + assert.match(r.headers.get('content-type') || '', /html/); + const html = await r.text(); + assert.ok(html.includes('swagger-ui-bundle.js'), 'bundle swagger absent de la page'); + assert.ok(html.includes('/api/openapi.json'), 'point d\'entrée spec absent'); + for (const asset of ['swagger-ui.css', 'swagger-ui-bundle.js', 'swagger-ui-standalone-preset.js']) { + const a = await fetch(`${base}/api/docs/assets/${asset}`); + assert.equal(a.status, 200, `asset ${asset}`); + } + }); + + it('openapi : inventaire exhaustif, méthodes valides, tags partout', async () => { + const r = await J(`${base}/api/openapi.json`); + const paths = Object.keys(r.body.paths); + assert.ok(paths.length >= 80, `seulement ${paths.length} chemins documentés`); + for (const p of ['/download/jobs', '/user/likes/status', '/rumble/browse', '/auth/sessions', + '/metrics', '/keys', '/yt/{path}', '/playlists/export', '/providers/health', '/twitch-token']) { + assert.ok(r.body.paths[p], `${p} absent du contrat`); + } + assert.ok(!paths.some((p) => p.includes('/proxy')), 'chemin /proxy/ présent (miroir non documenté)'); + // Régression app.all() : 35 méthodes HTTP (acl, propfind…) ne doivent pas + // entrer dans le document — Swagger plante et n'affiche plus rien. + const VALID = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options']; + const invalid = paths.flatMap((p) => Object.keys(r.body.paths[p]).filter((m) => !VALID.includes(m))); + assert.deepEqual(invalid, [], `méthodes invalides: ${invalid.slice(0, 5).join(', ')}`); + const noTag = paths.filter((p) => !Object.values(r.body.paths[p]).some((op) => Array.isArray(op.tags) && op.tags.length)); + assert.deepEqual(noTag, [], `opérations sans tag: ${noTag.slice(0, 5).join(', ')}`); + // Sécurité : route couverte par un middleware de préfixe marquée protégée, + // route publique laissée telle quelle. + assert.ok(r.body.paths['/download/jobs'].get.security, '/download/jobs doit être marqué protégé'); + assert.ok(!r.body.paths['/search'].get.security, '/search doit rester public'); + assert.ok(Array.isArray(r.body.tags) && r.body.tags.length >= 10, 'descriptions de tags absentes'); + }); + + it('openapi : 3 schémas d\'authentification + MCP décrit avec ses outils', async () => { + const r = await J(`${base}/api/openapi.json`); + const schemes = r.body.components.securitySchemes; + assert.ok(schemes.bearerAuth && schemes.apiKeyAuth && schemes.cookieAuth, '3 schémas d\'auth attendus'); + const desc = r.body.info.description; + assert.ok(desc.includes('Authorization: Bearer'), 'méthode JWT non décrite'); + assert.ok(desc.includes('X-API-Key'), 'méthode clé d\'API non décrite'); + assert.ok(desc.includes('Serveur MCP') && desc.includes('mcp/server.mjs'), 'section MCP absente'); + // La liste des outils est lue dans mcp/server.mjs : aucun décalage possible. + const mcp = fs.readFileSync(path.resolve(import.meta.dirname, '..', '..', 'mcp', 'server.mjs'), 'utf8'); + const start = mcp.indexOf('const TOOLS = ['); + assert.ok(start > 0, 'bloc TOOLS introuvable dans mcp/server.mjs'); + const names = [...mcp.slice(start, mcp.indexOf('\n];', start)).matchAll(/name:\s*'([a-z0-9_]+)'/g)].map((m) => m[1]); + assert.ok(names.length >= 10, `outils MCP trouvés: ${names.length}`); + for (const n of names) assert.ok(desc.includes(n), `outil MCP ${n} absent de la description`); + }); +}); diff --git a/src/components/header/header.component.ts b/src/components/header/header.component.ts index 14f6011..76679d2 100644 --- a/src/components/header/header.component.ts +++ b/src/components/header/header.component.ts @@ -677,10 +677,10 @@ export class HeaderComponent implements AfterViewInit { } } - /** Doc OpenAPI : même règle /proxy/api que les services (dev sur 4200 → proxy Angular). */ + /** Documentation des API : même règle /proxy/api que les services (dev sur 4200 → proxy Angular). */ apiDocUrl(): string { const port = typeof window !== 'undefined' ? (window.location.port || '') : ''; - return `${port && port !== '4000' ? '/proxy/api' : '/api'}/openapi.json`; + return `${port && port !== '4000' ? '/proxy/api' : '/api'}/docs`; } @HostListener('document:click', ['$event'])