feat(api): production-ready P0/P1 — secrets hors navigateur, clés d'API, métriques
CI / build-and-test (push) Successful in 14m57s
CI / build-and-test (push) Successful in 14m57s
P0 - Aucun secret servi au navigateur : /assets/config.local.js est généré par le serveur AVANT les montages statiques (il l'emporte 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 (angular ignore config.local.js) ; youtube-api.service n'appelle plus googleapis directement (fetchYouTube = /api/yt -> /proxy/api/yt), gardes « pas de clé => écran vide », rotation et carte de bans côté client supprimées ; readiness YouTube via /healthz (youtube.keys.count) ; messages d'erreur orientés configuration serveur. - CORS : origines pilotées par API_ALLOWED_ORIGINS (CSV), méthode PATCH ajoutée (requis par /user/preferences), header X-API-Key accepté. - Clés d'API longue durée : table api_keys (empreinte SHA-256 + préfixe affichable), routes GET/POST /api/keys et DELETE /api/keys/:id, jeton ntk_… affiché une seule fois, last_used_at à chaque usage ; X-API-Key accepté par authMiddleware ET authMiddlewareCookieAware. P1 - X-Request-Id renvoyé sur chaque réponse + journal JSON structuré en prod (ts, reqId, method, route, status, ms). - GET /metrics : exposition Prometheus sans dépendance (http_requests_total par route/code, somme+nombre de durées, uptime/mémoire ; METRICS_TOKEN verrouille l'accès si défini). - Rate-limit sur /api/details (DETAILS_RATE_LIMIT, 60/min, réponse JSON) — /transcript avait déjà le sien. - Version unique package.json : menu du compte, info.version de l'OpenAPI (+ schéma apiKeyAuth et chemins /keys / /metrics documentés). Tests : api_coverage +7 cas « production-ready » (sentinel de fuite de clé, X-Request-Id, /metrics, CORS PATCH, contrat/version, cycle complet des clés d'API), suggest, transcript, flags, filters, kind — verts. Build OK. Vérifs instance locale : config servie sans secret, /api/yt 200 avec la clé serveur, /metrics alimenté, menu 1.0.59 et 40 cartes rendues sans clé côté client.
This commit is contained in:
+218
-45
@@ -7,6 +7,7 @@ import bcrypt from 'bcryptjs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
||||
import youtubedlPkg, { create as createYtDlp } from 'youtube-dl-exec';
|
||||
import { execFile as execFileCb } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
@@ -42,6 +43,10 @@ import {
|
||||
countTelemetryEvents,
|
||||
cryptoRandomId,
|
||||
cryptoRandomUUID,
|
||||
insertApiKey,
|
||||
listApiKeys,
|
||||
revokeApiKey,
|
||||
findApiKeyUser,
|
||||
insertSearchHistory,
|
||||
insertSearchHistoryAt,
|
||||
listSearchHistory,
|
||||
@@ -204,10 +209,16 @@ const REFRESH_TTL_DAYS = Number(process.env.REFRESH_TTL_DAYS || 2);
|
||||
const REMEMBER_TTL_DAYS = Number(process.env.REMEMBER_TTL_DAYS || 30);
|
||||
const CHANNEL_TTL_MS = Number(process.env.CHANNEL_TTL_MS || (6 * 60 * 60 * 1000));
|
||||
|
||||
// CORS : liste d'origines explicite, pilotée par env (CSV). Les origines par
|
||||
// défaut couvrent le dev ; en production, ajouter l'URL publique de l'app dans
|
||||
// API_ALLOWED_ORIGINS pour autoriser des clients tiers (sinon requête bloquée
|
||||
// côté navigateur). PATCH est requis : /user/preferences est une route PATCH.
|
||||
const ALLOWED_ORIGINS = (process.env.API_ALLOWED_ORIGINS || 'http://localhost:4200,http://localhost:4000,http://localhost:3000')
|
||||
.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
const corsOptions = {
|
||||
origin: ['http://localhost:4200', 'http://localhost:4000', 'http://localhost:3000'],
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization'],
|
||||
origin: ALLOWED_ORIGINS,
|
||||
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization', 'X-API-Key'],
|
||||
credentials: true,
|
||||
maxAge: 86400 // 24h
|
||||
};
|
||||
@@ -225,15 +236,78 @@ function sanitizeBody(body) {
|
||||
return out;
|
||||
}
|
||||
|
||||
// --- Requête : X-Request-Id, journal structuré, métriques -------------------
|
||||
// Un seul point de mesure : tout ce qui sort du serveur passe par ici, ce qui
|
||||
// donne d'un coup l'identifiant de requête (debug), le journal JSON de prod et
|
||||
// les compteurs exposés sur /metrics.
|
||||
const httpMetrics = new Map(); // "METHOD|route|status" -> { count, durSumMs }
|
||||
|
||||
/** Libellé de route stable : `req.route` quand Express a routé, sinon « other ». */
|
||||
function routeLabel(req) {
|
||||
try {
|
||||
const p = req.route && typeof req.route.path === 'string' ? req.route.path : null;
|
||||
if (p) {
|
||||
if (p.includes('*')) return 'spa';
|
||||
return `${req.baseUrl || ''}${p}` || '/';
|
||||
}
|
||||
} catch {}
|
||||
return 'other'; // static, proxy non routé…
|
||||
}
|
||||
|
||||
const METRIC_TEXT_TYPE = 'text/plain; version=0.0.4; charset=utf-8';
|
||||
function escapeLabel(v) { return String(v).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n'); }
|
||||
|
||||
/** Exposition Prometheus (compteurs HTTP + jauge mémoire ; sans dépendance). */
|
||||
function renderMetrics() {
|
||||
const out = [];
|
||||
const push = (name, type, help) => { out.push(`# HELP ${name} ${help}`, `# TYPE ${name} ${type}`); };
|
||||
push('http_requests_total', 'counter', 'Requetes HTTP traitees, par methode, route et code.');
|
||||
const rows = [...httpMetrics.entries()].map(([k, v]) => [k.split('|'), v]);
|
||||
for (const [[method, route, status], v] of rows) {
|
||||
out.push(`http_requests_total{method="${escapeLabel(method)}",route="${escapeLabel(route)}",status="${escapeLabel(status)}"} ${v.count}`);
|
||||
}
|
||||
push('http_request_duration_ms_sum', 'counter', 'Duree cumulee des requetes HTTP en millisecondes.');
|
||||
for (const [[method, route, status], v] of rows) {
|
||||
out.push(`http_request_duration_ms_sum{method="${escapeLabel(method)}",route="${escapeLabel(route)}",status="${escapeLabel(status)}"} ${v.durSumMs}`);
|
||||
}
|
||||
push('http_request_duration_ms_count', 'counter', 'Nombre de durees mesurees (meme tuple que le sum).');
|
||||
for (const [[method, route, status], v] of rows) {
|
||||
out.push(`http_request_duration_ms_count{method="${escapeLabel(method)}",route="${escapeLabel(route)}",status="${escapeLabel(status)}"} ${v.count}`);
|
||||
}
|
||||
push('process_uptime_seconds', 'gauge', 'Demenrage du processus serveur.');
|
||||
out.push(`process_uptime_seconds ${Math.round(process.uptime())}`);
|
||||
push('process_resident_memory_bytes', 'gauge', 'Memoire residente du processus.');
|
||||
out.push(`process_resident_memory_bytes ${process.memoryUsage().rss}`);
|
||||
return `${out.join('\n')}\n`;
|
||||
}
|
||||
|
||||
const requestLogger = (req, res, next) => {
|
||||
if (IS_PROD) {
|
||||
console.log(`[${new Date().toISOString()}] ${req.method} ${req.originalUrl}`);
|
||||
return next();
|
||||
const t0 = Date.now();
|
||||
const reqId = randomUUID();
|
||||
req.requestId = reqId;
|
||||
try { res.setHeader('X-Request-Id', reqId); } catch {}
|
||||
|
||||
res.on('finish', () => {
|
||||
const ms = Date.now() - t0;
|
||||
const label = routeLabel(req);
|
||||
const key = `${req.method}|${label}|${res.statusCode}`;
|
||||
const cur = httpMetrics.get(key) || { count: 0, durSumMs: 0 };
|
||||
cur.count += 1;
|
||||
cur.durSumMs += ms;
|
||||
httpMetrics.set(key, cur);
|
||||
// Prod : une ligne JSON par requête (parseable par docker/loki). Dev : ligne lisible.
|
||||
if (IS_PROD) {
|
||||
console.log(JSON.stringify({ ts: new Date().toISOString(), reqId, method: req.method, route: label, status: res.statusCode, ms }));
|
||||
} else {
|
||||
console.log(`[${new Date().toISOString()}] ${reqId} ${req.method} ${req.originalUrl} -> ${res.statusCode} (${ms}ms)`);
|
||||
}
|
||||
});
|
||||
|
||||
if (!IS_PROD) {
|
||||
console.log('Headers:', JSON.stringify({ ...req.headers, authorization: req.headers.authorization ? '[redacted]' : undefined }, null, 2));
|
||||
console.log('Query:', JSON.stringify(req.query, null, 2));
|
||||
console.log('Body:', JSON.stringify(sanitizeBody(req.body), null, 2));
|
||||
}
|
||||
console.log(`[${new Date().toISOString()}] ${req.method} ${req.originalUrl}`);
|
||||
console.log('Headers:', JSON.stringify({ ...req.headers, authorization: req.headers.authorization ? '[redacted]' : undefined }, null, 2));
|
||||
console.log('Query:', JSON.stringify(req.query, null, 2));
|
||||
console.log('Body:', JSON.stringify(sanitizeBody(req.body), null, 2));
|
||||
next();
|
||||
};
|
||||
|
||||
@@ -945,6 +1019,26 @@ r.get('/playlists/:id/view', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------- Client config from environment --------------------
|
||||
// AVANT les montages statiques : c'est ce qui garantit que le fichier
|
||||
// « généré » l'emporte sur `assets/config.local.js` (fichier local qui peut
|
||||
// contenir d'anciennes clés et ne doit JAMAIS être servi au navigateur).
|
||||
// WARNING: every value served here is exposed to the browser. Only
|
||||
// browser-safe values belong here (public client ids, player ids).
|
||||
// Server-only secrets — YOUTUBE_API_KEY(S), TWITCH_CLIENT_SECRET, GEMINI_API_KEY,
|
||||
// RUMBLE_API_KEY, VIMEO_ACCESS_TOKEN — stay on the server and are consumed
|
||||
// through dedicated endpoints (/api/yt/*, /api/twitch-token, /api/ai/*, …).
|
||||
function jsVal(v) { return JSON.stringify(v == null ? '' : v); }
|
||||
app.get(['/assets/config.local.js', '/assets/config.js', '/config.js'], (_req, res) => {
|
||||
const lines = [];
|
||||
const env = process.env || {};
|
||||
if (env.TWITCH_CLIENT_ID) lines.push(`window.TWITCH_CLIENT_ID = ${jsVal(env.TWITCH_CLIENT_ID)};`);
|
||||
if (env.DAILYMOTION_PLAYER_ID) lines.push(`window.DAILYMOTION_PLAYER_ID = ${jsVal(env.DAILYMOTION_PLAYER_ID)};`);
|
||||
// Intentionally NOT exposed: YOUTUBE_API_KEY(S), TWITCH_CLIENT_SECRET, GEMINI_API_KEY…
|
||||
res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
|
||||
res.send(lines.join('\n'));
|
||||
});
|
||||
|
||||
// Servir les fichiers statiques du dossier dist
|
||||
app.use(express.static(path.join(process.cwd(), 'dist')));
|
||||
app.use('/assets', express.static(path.join(process.cwd(), 'assets')));
|
||||
@@ -1212,7 +1306,29 @@ async function hashToken(token) {
|
||||
return bcrypt.hash(token, salt);
|
||||
}
|
||||
|
||||
// -------------------- Clés d'API (clients hors navigateur) --------------------
|
||||
// `X-API-Key: ntk_…` : identité longue durée pour appeler l'API en script,
|
||||
// révocable individuellement. Seule l'empreinte SHA-256 du jeton est stockée ;
|
||||
// le clair n'est affiché qu'une fois, à la création (route `/keys`).
|
||||
// Accepté par les DEUX middlewares : toutes les routes protégées deviennent
|
||||
// utilisables hors navigateur sans changer de mécanisme d'auth.
|
||||
function apiKeyUser(req) {
|
||||
const raw = String(req.get('x-api-key') || '').trim();
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const found = findApiKeyUser(createHash('sha256').update(raw).digest('hex'));
|
||||
return found ? { id: found.userId, sessionId: null, via: 'api_key' } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function authMiddleware(req, res, next) {
|
||||
const byKey = apiKeyUser(req);
|
||||
if (byKey) {
|
||||
req.user = byKey;
|
||||
return next();
|
||||
}
|
||||
const hdr = req.headers['authorization'] || '';
|
||||
const [, token] = hdr.split(' ');
|
||||
if (!token) return res.status(401).json({ error: 'Unauthorized' });
|
||||
@@ -1228,6 +1344,11 @@ function authMiddleware(req, res, next) {
|
||||
// For direct browser downloads (anchor tag), Authorization header is not attached.
|
||||
// Allow authentication using the httpOnly session cookies as a fallback for the file route.
|
||||
function authMiddlewareCookieAware(req, res, next) {
|
||||
const byKey = apiKeyUser(req);
|
||||
if (byKey) {
|
||||
req.user = byKey;
|
||||
return next();
|
||||
}
|
||||
const hdr = req.headers['authorization'] || '';
|
||||
const [, token] = hdr.split(' ');
|
||||
if (token) {
|
||||
@@ -1555,9 +1676,21 @@ function detailsCacheSet(key, data) {
|
||||
}
|
||||
}
|
||||
|
||||
// /details invoque yt-dlp (2-5 s par vidéo non cachée) : seau dédié — sans lui,
|
||||
// un client qui varie les ids peut faire tourner yt-dlp en boucle. Le cache
|
||||
// 6 h protège les ids déjà vus, le seau protège le reste. Réponse JSON, comme
|
||||
// transcriptLimiter (sinon Angular reçoit du texte et plante au parsing).
|
||||
const detailsLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: Number(process.env.DETAILS_RATE_LIMIT || 60),
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
handler: (req, res) => res.status(429).json({ error: 'rate_limited' }),
|
||||
});
|
||||
|
||||
// Returns metadata such as title, description, uploader, thumbnail, duration and views for a provider/videoId
|
||||
// Supports query params similar to download endpoints: instance (PeerTube), slug (Odysee), sourceUrl (direct)
|
||||
r.get('/details/:provider/:videoId', async (req, res) => {
|
||||
r.get('/details/:provider/:videoId', detailsLimiter, async (req, res) => {
|
||||
try {
|
||||
const { provider, videoId } = req.params;
|
||||
const instance = req.query.instance || undefined;
|
||||
@@ -2370,6 +2503,35 @@ r.delete('/auth/sessions/:id', authMiddleware, (req, res) => {
|
||||
return res.status(204).end();
|
||||
});
|
||||
|
||||
// -------------------- Clés d'API (gestion) --------------------
|
||||
// GET liste les clés (jamais le jeton), POST en crée une — le jeton complet
|
||||
// n'est renvoyé QUE dans cette réponse —, DELETE la révoque immédiatement.
|
||||
r.get('/keys', authMiddleware, (req, res) => {
|
||||
try {
|
||||
return res.json({ items: listApiKeys(req.user.id) });
|
||||
} catch (e) {
|
||||
return res.status(500).json({ error: 'api_key_list_failed', details: String(e?.message || e) });
|
||||
}
|
||||
});
|
||||
|
||||
r.post('/keys', authMiddleware, (req, res) => {
|
||||
try {
|
||||
const name = String(req.body?.name || '').trim().slice(0, 80) || 'clé';
|
||||
const token = `ntk_${randomBytes(24).toString('base64url')}`;
|
||||
const keyHash = createHash('sha256').update(token).digest('hex');
|
||||
const id = insertApiKey({ userId: req.user.id, name, keyHash, prefix: token.slice(0, 11) });
|
||||
return res.status(201).json({ id, name, prefix: token.slice(0, 11), createdAt: new Date().toISOString(), token });
|
||||
} catch (e) {
|
||||
return res.status(500).json({ error: 'api_key_create_failed', details: String(e?.message || e) });
|
||||
}
|
||||
});
|
||||
|
||||
r.delete('/keys/:id', authMiddleware, (req, res) => {
|
||||
const revoked = revokeApiKey(String(req.params.id), req.user.id);
|
||||
if (!revoked) return res.status(404).json({ error: 'api_key_not_found' });
|
||||
return res.json({ revoked: true });
|
||||
});
|
||||
|
||||
r.get('/user/me', authMiddleware, (req, res) => {
|
||||
const u = getUserById(req.user.id);
|
||||
if (!u) return res.status(404).json({ error: 'not_found' });
|
||||
@@ -2858,6 +3020,20 @@ r.get('/providers/metrics', async (_req, res) => {
|
||||
app.use('/api', r);
|
||||
// Health endpoint for container checks
|
||||
app.get('/api/health', (_req, res) => res.json({ status: 'ok' }));
|
||||
// -------------------- Métriques Prometheus --------------------
|
||||
// Exposition texte simple (sans dépendance) : compteurs HTTP agrégés par
|
||||
// route, durées cumulées et jauges processus. Si METRICS_TOKEN est défini,
|
||||
// l'accès exige `Authorization: Bearer <token>` ou `?token=`.
|
||||
app.get('/metrics', (req, res) => {
|
||||
const token = String(process.env.METRICS_TOKEN || '').trim();
|
||||
if (token) {
|
||||
const got = String(req.get('authorization') || '');
|
||||
const q = String(req.query.token || '');
|
||||
if (got !== `Bearer ${token}` && q !== token) return res.status(401).json({ error: 'unauthorized' });
|
||||
}
|
||||
res.set('Content-Type', METRIC_TEXT_TYPE).send(renderMetrics());
|
||||
});
|
||||
|
||||
// Step 17 : observabilité YouTube (mode, yt-dlp, cache, quota). Aucun secret exposé.
|
||||
// Trois chemins, comme le router `r` : le front appelle `/proxy/api` en prod
|
||||
// (voir apiBase()), sans quoi healthz tomberait sur le fallback SPA → HTML.
|
||||
@@ -2923,37 +3099,6 @@ app.use('/proxy/api', r);
|
||||
app.use('/api/rumble', rumbleRouter);
|
||||
app.use('/proxy/api/rumble', rumbleRouter);
|
||||
|
||||
// -------------------- Client config from environment --------------------
|
||||
// WARNING: Values served here are exposed to the browser.
|
||||
// Only browser-safe values belong here (e.g. referrer-restricted YouTube keys).
|
||||
// Secrets like TWITCH_CLIENT_SECRET / GEMINI_API_KEY stay server-side and are
|
||||
// consumed through dedicated server endpoints (/api/twitch-token, /api/ai/*).
|
||||
function jsVal(v) { return JSON.stringify(v == null ? '' : v); }
|
||||
app.get(['/assets/config.local.js', '/assets/config.js', '/config.js'], (_req, res) => {
|
||||
const lines = [];
|
||||
const env = process.env || {};
|
||||
if (env.YOUTUBE_API_KEY) lines.push(`window.YOUTUBE_API_KEY = ${jsVal(env.YOUTUBE_API_KEY)};`);
|
||||
if (env.YOUTUBE_API_KEYS) {
|
||||
try {
|
||||
// Accepte JSON array ('["k1","k2"]', fourni par ex. via compose) ou CSV ('k1,k2').
|
||||
const raw = String(env.YOUTUBE_API_KEYS).trim();
|
||||
let arr = [];
|
||||
if (raw.startsWith('[')) {
|
||||
try { arr = JSON.parse(raw); } catch { arr = []; }
|
||||
if (!Array.isArray(arr)) arr = [];
|
||||
} else {
|
||||
arr = raw.split(',');
|
||||
}
|
||||
arr = arr.map(s => String(s || '').trim().replace(/^["'\[]+|["'\]]+$/g, '')).filter(Boolean);
|
||||
if (arr.length) lines.push(`window.YOUTUBE_API_KEYS = ${JSON.stringify(arr)};`);
|
||||
} catch {}
|
||||
}
|
||||
if (env.TWITCH_CLIENT_ID) lines.push(`window.TWITCH_CLIENT_ID = ${jsVal(env.TWITCH_CLIENT_ID)};`);
|
||||
// Intentionally NOT exposed: TWITCH_CLIENT_SECRET, GEMINI_API_KEY — proxy via /api/twitch-token & /api/ai/*
|
||||
res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
|
||||
res.send(lines.join('\n'));
|
||||
});
|
||||
|
||||
// -------------------- Twitch app token (server-side) --------------------
|
||||
// Exchanges TWITCH_CLIENT_ID + TWITCH_CLIENT_SECRET server-side and returns the
|
||||
// app access token to the client. The secret never leaves the server.
|
||||
@@ -4047,11 +4192,26 @@ r.put('/playlists/:id/reorder', authMiddlewareCookieAware, (req, res) => {
|
||||
});
|
||||
|
||||
// --- 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 = (() => {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version || '0.0.0';
|
||||
} catch {
|
||||
return '0.0.0';
|
||||
}
|
||||
})();
|
||||
|
||||
r.get('/openapi.json', (_req, res) => {
|
||||
const bearer = [{ bearerAuth: [] }];
|
||||
// Les deux schémas sont acceptés sur les routes protégées : JWT (navigateur)
|
||||
// et clé d'API (clients hors navigateur, header X-API-Key).
|
||||
const bearer = [{ bearerAuth: [] }, { apiKeyAuth: [] }];
|
||||
const doc = {
|
||||
openapi: '3.0.0',
|
||||
info: { title: 'NewTube API', version: '1.1.0' },
|
||||
info: {
|
||||
title: 'NewTube API',
|
||||
version: OPENAPI_VERSION,
|
||||
description: "Agrégateur vidéo multi-fournisseurs. Authentification des routes protégées : soit `Authorization: Bearer <accessToken>` (obtenu sur /auth/login), soit `X-API-Key: ntk_…` (clé créée sur /keys, révocable).",
|
||||
},
|
||||
paths: {
|
||||
'/healthz': {
|
||||
get: { summary: 'Santé API (mode YT, yt-dlp, cache, quota)', responses: { 200: { description: 'ok' } } },
|
||||
@@ -4150,9 +4310,22 @@ r.get('/openapi.json', (_req, res) => {
|
||||
},
|
||||
'/playlists/{id}/reorder': {
|
||||
put: { summary: 'Reorder playlist items', security: [{ bearerAuth: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }] }
|
||||
},
|
||||
'/keys': {
|
||||
get: { summary: 'Lister ses clés d’API (le jeton n’apparaît jamais)', security: bearer, responses: { 200: { description: '{ items: [{ id, name, prefix, createdAt, lastUsedAt, revokedAt }] }' } } },
|
||||
post: { summary: 'Créer une clé d’API — le jeton complet n’est renvoyé qu’ici', security: bearer, requestBody: { required: false, content: { 'application/json': { schema: { type: 'object', properties: { name: { type: 'string' } } } } } }, responses: { 201: { description: '{ id, name, prefix, token } — token affiché une seule fois' } } },
|
||||
},
|
||||
'/keys/{id}': {
|
||||
delete: { summary: 'Révoquer une clé d’API (effet immédiat)', security: bearer, parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], responses: { 200: { description: '{ revoked: true }' }, 404: { description: 'api_key_not_found' } } },
|
||||
},
|
||||
'/metrics': {
|
||||
get: { summary: 'Métriques Prometheus (compteurs HTTP, durées, processus)', parameters: [{ name: 'token', in: 'query', schema: { type: 'string' }, description: 'Requis uniquement si METRICS_TOKEN est défini côté serveur' }], responses: { 200: { description: 'text/plain — exposition Prometheus' }, 401: { description: 'unauthorized' } } },
|
||||
}
|
||||
},
|
||||
components: { securitySchemes: { bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' } } }
|
||||
components: { securitySchemes: {
|
||||
bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' },
|
||||
apiKeyAuth: { type: 'apiKey', in: 'header', name: 'X-API-Key' },
|
||||
} }
|
||||
};
|
||||
res.json(doc);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user