Initial commit : monorepo seiche (serveur Fastify + web Vite/React, Docker, config VS Code)
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import cookie from "@fastify/cookie";
|
||||
import fastifyStatic from "@fastify/static";
|
||||
import Fastify, { type FastifyInstance } from "fastify";
|
||||
import { RateLimiter } from "./auth/rate-limit";
|
||||
import { buildSessionPlugin } from "./auth/session";
|
||||
import type { Config } from "./config";
|
||||
import type { Database } from "./db/client";
|
||||
import { createExternalService } from "./external/adapters";
|
||||
import { ExternalCache } from "./external/cache";
|
||||
import { buildAuthRoutes } from "./routes/auth";
|
||||
import { buildExternalRoutes } from "./routes/external";
|
||||
import { buildHealthRoutes } from "./routes/health";
|
||||
|
||||
export interface AppDeps {
|
||||
config: Config;
|
||||
db: Database;
|
||||
}
|
||||
|
||||
export async function buildApp(deps: AppDeps): Promise<FastifyInstance> {
|
||||
const { config, db } = deps;
|
||||
|
||||
const app = Fastify({
|
||||
logger: {
|
||||
level: process.env.SEICHE_LOG_LEVEL ?? "info",
|
||||
transport:
|
||||
process.env.NODE_ENV === "production"
|
||||
? undefined
|
||||
: {
|
||||
target: "pino-pretty",
|
||||
options: { translateTime: "HH:MM:ss", ignore: "pid,hostname" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await app.register(cookie);
|
||||
buildSessionPlugin(app, { db, config });
|
||||
|
||||
const loginLimiter = new RateLimiter(config.maxLoginAttemptsPerMinute, 60_000);
|
||||
const externalService = createExternalService(config);
|
||||
const externalCache = new ExternalCache(db, config.external.cacheTtlMs);
|
||||
|
||||
buildHealthRoutes(app);
|
||||
buildAuthRoutes(app, { db, config, loginLimiter });
|
||||
buildExternalRoutes(app, { service: externalService, cache: externalCache });
|
||||
|
||||
// Frontend : en production, servir le build Vite (web/dist) si présent.
|
||||
const publicDir = config.publicDir ?? path.resolve(process.cwd(), "../web/dist");
|
||||
if (existsSync(publicDir)) {
|
||||
await app.register(fastifyStatic, {
|
||||
root: publicDir,
|
||||
setHeaders(res) {
|
||||
// Le HTML ne doit jamais être mis en cache (les assets, si, via hash)
|
||||
if (res.getHeader("content-type")?.toString().includes("text/html")) {
|
||||
res.header("cache-control", "no-cache, no-store, must-revalidate");
|
||||
}
|
||||
},
|
||||
});
|
||||
// Fallback SPA : toute route non /api* renvoie index.html
|
||||
app.setNotFoundHandler(async (request, reply) => {
|
||||
if (request.url.startsWith("/api/")) {
|
||||
return reply.code(404).send({ error: "not_found" });
|
||||
}
|
||||
return reply.sendFile("index.html");
|
||||
});
|
||||
}
|
||||
|
||||
// Ne jamais fuiter les détails internes en production
|
||||
app.setErrorHandler((error, _request, reply) => {
|
||||
const isDev = process.env.NODE_ENV !== "production";
|
||||
const details = error instanceof Error ? error.message : undefined;
|
||||
app.log.error({ err: error }, "erreur interne");
|
||||
reply.code(500).send({
|
||||
error: "internal_error",
|
||||
...(isDev && details ? { details } : {}),
|
||||
});
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import argon2 from "argon2";
|
||||
|
||||
export const ARGON2_OPTIONS = {
|
||||
type: argon2.argon2id,
|
||||
memoryCost: 19_456, // 19 Mo — OWASP "modéré"
|
||||
timeCost: 2,
|
||||
parallelism: 1,
|
||||
} as const;
|
||||
|
||||
export function hashPassword(password: string): Promise<string> {
|
||||
return argon2.hash(password, ARGON2_OPTIONS);
|
||||
}
|
||||
|
||||
export async function verifyPassword(hash: string, password: string): Promise<boolean> {
|
||||
try {
|
||||
return await argon2.verify(hash, password);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Rate limiter minimaliste en mémoire (fenêtre glissante).
|
||||
* Adapté à un usage mono-utilisateur local ; pas besoin de persistance.
|
||||
*/
|
||||
export class RateLimiter {
|
||||
private readonly hits = new Map<string, number[]>();
|
||||
|
||||
constructor(
|
||||
private readonly maxHits: number,
|
||||
private readonly windowMs: number,
|
||||
) {}
|
||||
|
||||
/** Retourne true si la requête est autorisée (et l'enregistre). */
|
||||
hit(key: string, now: Date = new Date()): boolean {
|
||||
const windowStart = now.getTime() - this.windowMs;
|
||||
const timestamps = (this.hits.get(key) ?? []).filter((t) => t > windowStart);
|
||||
if (timestamps.length >= this.maxHits) {
|
||||
this.hits.set(key, timestamps);
|
||||
return false;
|
||||
}
|
||||
timestamps.push(now.getTime());
|
||||
this.hits.set(key, timestamps);
|
||||
return true;
|
||||
}
|
||||
|
||||
reset(key: string): void {
|
||||
this.hits.delete(key);
|
||||
}
|
||||
|
||||
/** Nombre de tentatives restantes pour la clé (0 = bloqué). */
|
||||
remaining(key: string, now: Date = new Date()): number {
|
||||
const windowStart = now.getTime() - this.windowMs;
|
||||
const timestamps = (this.hits.get(key) ?? []).filter((t) => t > windowStart);
|
||||
return Math.max(0, this.maxHits - timestamps.length);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import { desc, eq, inArray } from "drizzle-orm";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import type { Config } from "../config";
|
||||
import type { Database } from "../db/client";
|
||||
import { sessions, users } from "../db/schema";
|
||||
|
||||
export const SESSION_COOKIE = "seiche_session";
|
||||
export const CSRF_COOKIE = "seiche_csrf";
|
||||
export const CSRF_HEADER = "x-csrf-token";
|
||||
|
||||
export interface SessionContext {
|
||||
userId: number;
|
||||
username: string;
|
||||
sessionExpiresAt: Date;
|
||||
}
|
||||
|
||||
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
||||
|
||||
function sha256Hex(value: string): string {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
function safeEqual(a: string, b: string): boolean {
|
||||
const bufA = Buffer.from(a, "utf8");
|
||||
const bufB = Buffer.from(b, "utf8");
|
||||
return bufA.length === bufB.length && timingSafeEqual(bufA, bufB);
|
||||
}
|
||||
|
||||
export class SessionManager {
|
||||
constructor(
|
||||
private readonly db: Database,
|
||||
private readonly config: Config,
|
||||
) {}
|
||||
|
||||
/** Crée une session (token 256 bits) + un token CSRF (256 bits). */
|
||||
async create(userId: number): Promise<{ token: string; csrfToken: string }> {
|
||||
const now = new Date();
|
||||
const token = randomBytes(32).toString("hex");
|
||||
const csrfToken = randomBytes(32).toString("hex");
|
||||
await this.db.insert(sessions).values({
|
||||
tokenHash: sha256Hex(token),
|
||||
userId,
|
||||
expiresAt: new Date(now.getTime() + this.config.sessionTtlMs),
|
||||
absoluteExpiryAt: new Date(now.getTime() + this.config.sessionAbsoluteTtlMs),
|
||||
lastActivityAt: now,
|
||||
});
|
||||
await this.enforceSessionLimit(userId);
|
||||
return { token, csrfToken };
|
||||
}
|
||||
|
||||
/** Retourne la session valide pour le token, met à jour le TTL glissant. */
|
||||
async getValid(token: string | undefined): Promise<SessionContext | null> {
|
||||
if (!token) return null;
|
||||
const now = new Date();
|
||||
const tokenHash = sha256Hex(token);
|
||||
const rows = await this.db
|
||||
.select({ session: sessions, user: users })
|
||||
.from(sessions)
|
||||
.innerJoin(users, eq(sessions.userId, users.id))
|
||||
.where(eq(sessions.tokenHash, tokenHash))
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
if (!row) return null;
|
||||
const s = row.session;
|
||||
if (now >= s.absoluteExpiryAt || now >= s.expiresAt) {
|
||||
await this.db.delete(sessions).where(eq(sessions.tokenHash, tokenHash));
|
||||
return null;
|
||||
}
|
||||
const slidingExpiry = new Date(now.getTime() + this.config.sessionTtlMs);
|
||||
const effectiveExpiry =
|
||||
slidingExpiry <= s.absoluteExpiryAt ? slidingExpiry : s.absoluteExpiryAt;
|
||||
await this.db
|
||||
.update(sessions)
|
||||
.set({ expiresAt: effectiveExpiry, lastActivityAt: now })
|
||||
.where(eq(sessions.tokenHash, tokenHash));
|
||||
return {
|
||||
userId: row.user.id,
|
||||
username: row.user.username,
|
||||
sessionExpiresAt: effectiveExpiry,
|
||||
};
|
||||
}
|
||||
|
||||
async destroy(token: string | undefined): Promise<void> {
|
||||
if (!token) return;
|
||||
await this.db.delete(sessions).where(eq(sessions.tokenHash, sha256Hex(token)));
|
||||
}
|
||||
|
||||
/** Garde un maximum de 5 sessions actives par utilisateur. */
|
||||
private async enforceSessionLimit(userId: number): Promise<void> {
|
||||
const all = await this.db
|
||||
.select({ tokenHash: sessions.tokenHash, createdAt: sessions.createdAt })
|
||||
.from(sessions)
|
||||
.where(eq(sessions.userId, userId))
|
||||
.orderBy(desc(sessions.createdAt), desc(sessions.tokenHash));
|
||||
if (all.length <= 5) return;
|
||||
const toRemove = all.slice(5).map((s) => s.tokenHash);
|
||||
if (toRemove.length > 0) {
|
||||
await this.db.delete(sessions).where(inArray(sessions.tokenHash, toRemove));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
declare module "fastify" {
|
||||
interface FastifyRequest {
|
||||
session: SessionContext | null;
|
||||
}
|
||||
interface FastifyInstance {
|
||||
sessionManager: SessionManager;
|
||||
authenticate(request: FastifyRequest, reply: FastifyReply): Promise<void>;
|
||||
requireCsrf(request: FastifyRequest, reply: FastifyReply): Promise<void>;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin sessions :
|
||||
* - décore `request.session` (résilée à chaque requête /api/*)
|
||||
* - expose `app.authenticate` et `app.requireCsrf` (pre-handlers)
|
||||
*
|
||||
* À enregistrer APRÈS @fastify/cookie (le parsing des cookies doit précéder).
|
||||
*/
|
||||
export function buildSessionPlugin(
|
||||
app: FastifyInstance,
|
||||
deps: { db: Database; config: Config },
|
||||
): void {
|
||||
const manager = new SessionManager(deps.db, deps.config);
|
||||
|
||||
app.decorateRequest("session", null);
|
||||
app.decorate("sessionManager", manager);
|
||||
|
||||
app.addHook("onRequest", async (request: FastifyRequest) => {
|
||||
if (!request.url.startsWith("/api/")) return;
|
||||
request.session = await manager.getValid(request.cookies[SESSION_COOKIE]);
|
||||
});
|
||||
|
||||
app.decorate("authenticate", async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
if (!request.session) {
|
||||
reply.code(401).send({ error: "not_authenticated" });
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
app.decorate("requireCsrf", async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
if (SAFE_METHODS.has(request.method)) return;
|
||||
const cookie = request.cookies[CSRF_COOKIE];
|
||||
const header = request.headers[CSRF_HEADER];
|
||||
if (!cookie || typeof header !== "string" || header.length === 0) {
|
||||
reply.code(403).send({ error: "csrf_invalid" });
|
||||
return;
|
||||
}
|
||||
if (!safeEqual(cookie, header)) {
|
||||
reply.code(403).send({ error: "csrf_invalid" });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export { CSRF_COOKIE as csrfCookieName, SESSION_COOKIE as sessionCookieName };
|
||||
@@ -0,0 +1,64 @@
|
||||
import path from "node:path";
|
||||
import { config as dotenvConfig } from "dotenv";
|
||||
|
||||
// Chargement du .env : d'abord le .env du paquet serveur, puis celui de la racine du repo.
|
||||
// Les variables déjà présentes dans l'environnement ne sont jamais écrasées.
|
||||
dotenvConfig({ path: [".env", "../.env"] });
|
||||
|
||||
export interface Config {
|
||||
port: number;
|
||||
host: string;
|
||||
dataDir: string;
|
||||
sessionTtlMs: number;
|
||||
sessionAbsoluteTtlMs: number;
|
||||
maxLoginAttemptsPerMinute: number;
|
||||
publicDir: string | null;
|
||||
external: {
|
||||
adapter: "example" | "http";
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
healthPath: string;
|
||||
listPath: string;
|
||||
timeoutMs: number;
|
||||
retries: number;
|
||||
cacheTtlMs: number;
|
||||
};
|
||||
}
|
||||
|
||||
function envStr(name: string, fallback: string): string {
|
||||
const value = process.env[name];
|
||||
return value === undefined || value === "" ? fallback : value;
|
||||
}
|
||||
|
||||
function envInt(name: string, fallback: number): number {
|
||||
const raw = process.env[name];
|
||||
if (raw === undefined || raw === "") return fallback;
|
||||
const value = Number.parseInt(raw, 10);
|
||||
if (Number.isNaN(value) || value <= 0) {
|
||||
throw new Error(`Variable d'environnement ${name} invalide : ${raw} (entier positif attendu)`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function loadConfig(): Config {
|
||||
const dataDir = path.resolve(process.cwd(), envStr("SEICHE_DATA_DIR", "./data"));
|
||||
return {
|
||||
port: envInt("SEICHE_PORT", 8787),
|
||||
host: envStr("SEICHE_HOST", "127.0.0.1"),
|
||||
dataDir,
|
||||
sessionTtlMs: envInt("SEICHE_SESSION_TTL_MS", 8 * 60 * 60 * 1000),
|
||||
sessionAbsoluteTtlMs: envInt("SEICHE_SESSION_ABSOLUTE_TTL_MS", 7 * 24 * 60 * 60 * 1000),
|
||||
maxLoginAttemptsPerMinute: envInt("SEICHE_MAX_LOGIN_ATTEMPTS_PER_MINUTE", 5),
|
||||
publicDir: process.env.SEICHE_PUBLIC_DIR ?? null,
|
||||
external: {
|
||||
adapter: (process.env.SEICHE_EXTERNAL_ADAPTER ?? "example") as "example" | "http",
|
||||
baseUrl: envStr("SEICHE_EXTERNAL_BASE_URL", ""),
|
||||
apiKey: envStr("SEICHE_EXTERNAL_API_KEY", ""),
|
||||
healthPath: envStr("SEICHE_EXTERNAL_HEALTH_PATH", "/health"),
|
||||
listPath: envStr("SEICHE_EXTERNAL_LIST_PATH", "/items"),
|
||||
timeoutMs: envInt("SEICHE_EXTERNAL_TIMEOUT_MS", 10_000),
|
||||
retries: envInt("SEICHE_EXTERNAL_RETRIES", 2),
|
||||
cacheTtlMs: envInt("SEICHE_EXTERNAL_CACHE_TTL_MS", 30_000),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import path from "node:path";
|
||||
import { type Client, createClient } from "@libsql/client";
|
||||
import { drizzle, type LibSQLDatabase } from "drizzle-orm/libsql";
|
||||
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||
import * as schema from "./schema";
|
||||
|
||||
export type Database = LibSQLDatabase<typeof schema>;
|
||||
|
||||
export function createDbClient(url: string): Client {
|
||||
return createClient({ url });
|
||||
}
|
||||
|
||||
export function createDb(client: Client): Database {
|
||||
return drizzle(client, { schema });
|
||||
}
|
||||
|
||||
/** Applique les migrations SQL (idempotent, journal géré par Drizzle). */
|
||||
export async function runMigrations(db: Database, migrationsFolder: string): Promise<void> {
|
||||
await migrate(db, { migrationsFolder });
|
||||
}
|
||||
|
||||
/** Ouvre (ou crée) la base SQLite au format fichier. */
|
||||
export async function openDatabase(dataDir: string, migrationsFolder: string): Promise<Database> {
|
||||
const client = createDbClient(path.join("file:", dataDir, "seiche.db"));
|
||||
const db = createDb(client);
|
||||
await runMigrations(db, migrationsFolder);
|
||||
return db;
|
||||
}
|
||||
|
||||
/** Ouvre une base SQLite en mémoire (tests). */
|
||||
export async function openInMemoryDatabase(migrationsFolder: string): Promise<Database> {
|
||||
const client = createDbClient("file::memory:");
|
||||
const db = createDb(client);
|
||||
await runMigrations(db, migrationsFolder);
|
||||
return db;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
|
||||
export const users = sqliteTable("users", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
username: text("username").notNull().unique(),
|
||||
passwordHash: text("password_hash").notNull(),
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
});
|
||||
|
||||
/**
|
||||
* Session serveur : le token de session (valeur du cookie) n'est JAMAIS stocké
|
||||
* en clair — uniquement son hash SHA-256. Une fuite de la base ne permet donc
|
||||
* pas de usurper une session.
|
||||
*/
|
||||
export const sessions = sqliteTable("sessions", {
|
||||
/** SHA-256 hex du token de session (valeur du cookie `seiche_session`) */
|
||||
tokenHash: text("token_hash").primaryKey(),
|
||||
userId: integer("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
/** Fin de la fenêtre glissante (lastActivityAt + TTL) */
|
||||
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
|
||||
/** Expiration absolue (createdAt + TTL absolu), quel que soit l'activité */
|
||||
absoluteExpiryAt: integer("absolute_expiry_at", { mode: "timestamp" }).notNull(),
|
||||
lastActivityAt: integer("last_activity_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
});
|
||||
|
||||
/** Cache des réponses du service REST externe (clé → JSON) */
|
||||
export const externalCache = sqliteTable("external_cache", {
|
||||
key: text("key").primaryKey(),
|
||||
value: text("value", { mode: "json" }).notNull(),
|
||||
fetchedAt: integer("fetched_at", { mode: "timestamp" }).notNull(),
|
||||
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
|
||||
});
|
||||
|
||||
export type User = typeof users.$inferSelect;
|
||||
export type Session = typeof sessions.$inferSelect;
|
||||
export type ExternalCacheRow = typeof externalCache.$inferSelect;
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
import type { ExternalHealth, ExternalItem, ExternalListResult, ExternalService } from "../types";
|
||||
|
||||
const DEMO_ITEMS: ExternalItem[] = [
|
||||
{
|
||||
id: "demo-1",
|
||||
title: "Exemple : tâche en cours",
|
||||
subtitle: "Ceci est un élément de démonstration de l'adaptateur « example ».",
|
||||
status: "running",
|
||||
},
|
||||
{
|
||||
id: "demo-2",
|
||||
title: "Exemple : tâche terminée",
|
||||
subtitle: "Rajoutez ici le détail de ce que votre service REST gère.",
|
||||
status: "done",
|
||||
},
|
||||
{
|
||||
id: "demo-3",
|
||||
title: "Exemple : tâche en erreur",
|
||||
subtitle: "Le pipeline complet (API → cache → UI) fonctionne avec cet adaptateur.",
|
||||
status: "error",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Adaptateur de démonstration : ne contacte aucun service réel.
|
||||
* Permet de valider tout le pipeline (auth → API → cache → UI) avant
|
||||
* d'intégrer le service REST cible. À remplacer/configurer via
|
||||
* SEICHE_EXTERNAL_ADAPTER=http + variables SEICHE_EXTERNAL_*.
|
||||
*/
|
||||
export class ExampleExternalService implements ExternalService {
|
||||
readonly name = "example";
|
||||
|
||||
async health(): Promise<ExternalHealth> {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
message: "adaptateur de démonstration",
|
||||
latencyMs: 0,
|
||||
checkedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async list(): Promise<ExternalListResult> {
|
||||
return { items: DEMO_ITEMS, fetchedAt: new Date().toISOString(), fromCache: false };
|
||||
}
|
||||
}
|
||||
Vendored
+104
@@ -0,0 +1,104 @@
|
||||
import { HttpClient } from "../http-client";
|
||||
import type { ExternalHealth, ExternalItem, ExternalListResult, ExternalService } from "../types";
|
||||
|
||||
export interface HttpExternalServiceOptions {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
healthPath: string;
|
||||
listPath: string;
|
||||
timeoutMs: number;
|
||||
retries: number;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adaptateur générique vers une API REST HTTP/HTTPS.
|
||||
*
|
||||
* Convention de réponse pour /items (tolérante) :
|
||||
* - un tableau JSON, ou un objet contenant une clé items/data/results (tableau)
|
||||
* - chaque élément : id|uuid|slug, title|name|label, description|summary, status|state
|
||||
*
|
||||
* À ajuster/étendre quand les détails du service cible sont connus
|
||||
* (voir docs/service-adapter.md).
|
||||
*/
|
||||
export class HttpExternalService implements ExternalService {
|
||||
readonly name = "http";
|
||||
private readonly client: HttpClient;
|
||||
private readonly options: HttpExternalServiceOptions;
|
||||
|
||||
constructor(options: HttpExternalServiceOptions) {
|
||||
this.options = options;
|
||||
this.client = new HttpClient(options);
|
||||
}
|
||||
|
||||
async health(): Promise<ExternalHealth> {
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const res = await this.client.getJson(this.options.healthPath);
|
||||
const body = isRecord(res.body) ? res.body : undefined;
|
||||
return {
|
||||
ok: res.status >= 200 && res.status < 300,
|
||||
status: res.status,
|
||||
message:
|
||||
(body && typeof body.status === "string" ? body.status : undefined) ??
|
||||
(body && typeof body.message === "string" ? body.message : undefined),
|
||||
latencyMs: res.latencyMs,
|
||||
checkedAt: new Date().toISOString(),
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 0,
|
||||
message: err instanceof Error ? err.message : "erreur inconnue",
|
||||
latencyMs: Date.now() - startedAt,
|
||||
checkedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async list(): Promise<ExternalListResult> {
|
||||
const res = await this.client.getJson(this.options.listPath);
|
||||
if (res.status < 200 || res.status >= 300) {
|
||||
throw new Error(`Le service externe a répondu HTTP ${res.status}`);
|
||||
}
|
||||
const items = normalizeItems(res.body);
|
||||
return { items, fetchedAt: new Date().toISOString(), fromCache: false };
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeItems(body: unknown): ExternalItem[] {
|
||||
let array: unknown[] | null = null;
|
||||
if (Array.isArray(body)) {
|
||||
array = body;
|
||||
} else if (isRecord(body)) {
|
||||
for (const key of ["items", "data", "results", "entries"]) {
|
||||
const value = body[key];
|
||||
if (Array.isArray(value)) {
|
||||
array = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (array === null) {
|
||||
throw new Error("Réponse externe inattendue : tableau JSON attendu");
|
||||
}
|
||||
return array.map((element, index) => {
|
||||
if (!isRecord(element)) {
|
||||
return { id: String(index), title: String(element), raw: element };
|
||||
}
|
||||
const id = element.id ?? element.uuid ?? element.slug ?? element.key ?? String(index);
|
||||
const title = element.title ?? element.name ?? element.label ?? element.id ?? String(index);
|
||||
const subtitle = element.description ?? element.summary ?? element.subtitle;
|
||||
const status = element.status ?? element.state;
|
||||
return {
|
||||
id: String(id),
|
||||
title: String(title),
|
||||
subtitle: subtitle !== undefined ? String(subtitle) : undefined,
|
||||
status: status !== undefined ? String(status) : undefined,
|
||||
raw: element,
|
||||
};
|
||||
});
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import type { Config } from "../../config";
|
||||
import type { ExternalService } from "../types";
|
||||
import { ExampleExternalService } from "./example";
|
||||
import { HttpExternalService, type HttpExternalServiceOptions } from "./http";
|
||||
|
||||
/** Fabrique : instancie l'adaptateur configuré (SEICHE_EXTERNAL_ADAPTER). */
|
||||
export function createExternalService(config: Config): ExternalService {
|
||||
switch (config.external.adapter) {
|
||||
case "http": {
|
||||
if (config.external.baseUrl === "") {
|
||||
throw new Error("SEICHE_EXTERNAL_BASE_URL est requis quand SEICHE_EXTERNAL_ADAPTER=http");
|
||||
}
|
||||
const options: HttpExternalServiceOptions = {
|
||||
baseUrl: config.external.baseUrl,
|
||||
apiKey: config.external.apiKey,
|
||||
healthPath: config.external.healthPath,
|
||||
listPath: config.external.listPath,
|
||||
timeoutMs: config.external.timeoutMs,
|
||||
retries: config.external.retries,
|
||||
};
|
||||
return new HttpExternalService(options);
|
||||
}
|
||||
case "example":
|
||||
return new ExampleExternalService();
|
||||
default: {
|
||||
const exhaustive: never = config.external.adapter;
|
||||
throw new Error(`Adaptateur externe inconnu : ${String(exhaustive)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+43
@@ -0,0 +1,43 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { Database } from "../db/client";
|
||||
import { externalCache } from "../db/schema";
|
||||
|
||||
/**
|
||||
* Cache serveur (SQLite) des réponses du service externe.
|
||||
* Évite de solliciter le service à chaque interaction UI et donne une
|
||||
* date de fraîcheur cohérente.
|
||||
*/
|
||||
export class ExternalCache {
|
||||
constructor(
|
||||
private readonly db: Database,
|
||||
private readonly ttlMs: number,
|
||||
) {}
|
||||
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
const rows = await this.db
|
||||
.select()
|
||||
.from(externalCache)
|
||||
.where(eq(externalCache.key, key))
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
if (!row) return null;
|
||||
if (new Date() >= row.expiresAt) {
|
||||
await this.db.delete(externalCache).where(eq(externalCache.key, key));
|
||||
return null;
|
||||
}
|
||||
return row.value as T;
|
||||
}
|
||||
|
||||
async set(key: string, value: unknown): Promise<void> {
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(now.getTime() + this.ttlMs);
|
||||
await this.db
|
||||
.insert(externalCache)
|
||||
.values({ key, value, fetchedAt: now, expiresAt })
|
||||
.onConflictDoUpdate({ target: externalCache.key, set: { value, fetchedAt: now, expiresAt } });
|
||||
}
|
||||
|
||||
async invalidate(key: string): Promise<void> {
|
||||
await this.db.delete(externalCache).where(eq(externalCache.key, key));
|
||||
}
|
||||
}
|
||||
Vendored
+76
@@ -0,0 +1,76 @@
|
||||
export interface HttpClientOptions {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
timeoutMs: number;
|
||||
retries: number;
|
||||
}
|
||||
|
||||
export class HttpClient {
|
||||
constructor(private readonly options: HttpClientOptions) {}
|
||||
|
||||
/**
|
||||
* GET avec timeout et retries (backoff exponentiel + jitter) sur
|
||||
* erreurs réseau / 5xx. Le body est renvoyé tel quel (JSON parsé
|
||||
* à la main par l'appelant si nécessaire).
|
||||
*/
|
||||
async getJson(
|
||||
path: string,
|
||||
headers: Record<string, string> = {},
|
||||
): Promise<{ status: number; body: unknown; latencyMs: number }> {
|
||||
const url = new URL(path, this.options.baseUrl);
|
||||
const allHeaders: Record<string, string> = { accept: "application/json", ...headers };
|
||||
if (this.options.apiKey !== "") {
|
||||
allHeaders.authorization = `Bearer ${this.options.apiKey}`;
|
||||
}
|
||||
|
||||
let lastError: unknown = null;
|
||||
for (let attempt = 0; attempt <= this.options.retries; attempt++) {
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), this.options.timeoutMs);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: allHeaders,
|
||||
signal: controller.signal,
|
||||
});
|
||||
const latencyMs = Date.now() - startedAt;
|
||||
const text = await res.text();
|
||||
let body: unknown = null;
|
||||
if (text !== "") {
|
||||
try {
|
||||
body = JSON.parse(text);
|
||||
} catch {
|
||||
body = text;
|
||||
}
|
||||
}
|
||||
// On ne retente que sur les erreurs transitoires.
|
||||
if (res.status >= 500) {
|
||||
lastError = new Error(`HTTP ${res.status} de ${url.toString()}`);
|
||||
if (attempt < this.options.retries) {
|
||||
await this.backoff(attempt);
|
||||
continue;
|
||||
}
|
||||
return { status: res.status, body, latencyMs };
|
||||
}
|
||||
return { status: res.status, body, latencyMs };
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
if (attempt < this.options.retries) {
|
||||
await this.backoff(attempt);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError instanceof Error ? lastError : new Error("Échec de la requête externe");
|
||||
}
|
||||
|
||||
private backoff(attempt: number): Promise<void> {
|
||||
const base = 300 * 2 ** attempt;
|
||||
const delay = base + Math.floor(Math.random() * 100);
|
||||
return new Promise((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
Vendored
+32
@@ -0,0 +1,32 @@
|
||||
export interface ExternalHealth {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
message?: string;
|
||||
latencyMs: number;
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
export interface ExternalItem {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
status?: string;
|
||||
/** Payload brut du service (affiché dans l'UI, utile pour le débogage) */
|
||||
raw?: unknown;
|
||||
}
|
||||
|
||||
export interface ExternalListResult {
|
||||
items: ExternalItem[];
|
||||
fetchedAt: string;
|
||||
fromCache: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Contrat d'un adaptateur vers le service REST externe.
|
||||
* Chaque implémentation est auto-suffisante et ne connaît que sa config.
|
||||
*/
|
||||
export interface ExternalService {
|
||||
readonly name: string;
|
||||
health(): Promise<ExternalHealth>;
|
||||
list(): Promise<ExternalListResult>;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { mkdirSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { buildApp } from "./app";
|
||||
import { loadConfig } from "./config";
|
||||
import { openDatabase } from "./db/client";
|
||||
|
||||
const MIGRATIONS_FOLDER = path.resolve(import.meta.dirname, "../drizzle");
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const config = loadConfig();
|
||||
mkdirSync(config.dataDir, { recursive: true });
|
||||
|
||||
const db = await openDatabase(config.dataDir, MIGRATIONS_FOLDER);
|
||||
const app = await buildApp({ config, db });
|
||||
|
||||
app.addHook("onClose", async () => {
|
||||
// libsql : pas de close explicite requis, on garde la connexion vivante
|
||||
});
|
||||
|
||||
try {
|
||||
await app.listen({ port: config.port, host: config.host });
|
||||
console.log(`Seiche démarre sur http://${config.host}:${config.port}`);
|
||||
} catch (err) {
|
||||
app.log.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const shutdown = async (signal: string) => {
|
||||
app.log.info(`${signal} reçu, arrêt en cours…`);
|
||||
await app.close();
|
||||
process.exit(0);
|
||||
};
|
||||
process.on("SIGINT", () => void shutdown("SIGINT"));
|
||||
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Échec du démarrage de Seiche :", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { verifyPassword } from "../auth/password";
|
||||
import type { RateLimiter } from "../auth/rate-limit";
|
||||
import { CSRF_COOKIE, CSRF_HEADER, SESSION_COOKIE } from "../auth/session";
|
||||
import type { Config } from "../config";
|
||||
import type { Database } from "../db/client";
|
||||
import { users } from "../db/schema";
|
||||
import { parseLoginBody } from "../validation";
|
||||
|
||||
interface AuthDeps {
|
||||
db: Database;
|
||||
config: Config;
|
||||
loginLimiter: RateLimiter;
|
||||
}
|
||||
|
||||
export function buildAuthRoutes(app: FastifyInstance, deps: AuthDeps): void {
|
||||
const { db, config, loginLimiter } = deps;
|
||||
|
||||
app.post("/api/auth/login", async (request, reply) => {
|
||||
const parsed = parseLoginBody(request.body);
|
||||
if (!parsed.ok) {
|
||||
return reply.code(400).send({ error: "invalid_body" });
|
||||
}
|
||||
const { username, password } = parsed.data;
|
||||
|
||||
// Rate limit par IP (réinitialisé à succès)
|
||||
const ip = request.ip;
|
||||
if (!loginLimiter.hit(ip)) {
|
||||
return reply.code(429).send({ error: "too_many_attempts", retryAfterMs: 60_000 });
|
||||
}
|
||||
|
||||
const rows = await db.select().from(users).where(eq(users.username, username)).limit(1);
|
||||
const user = rows[0];
|
||||
|
||||
// Même message d'erreur que l'échec de vérification (pas d'oracle utilisateur)
|
||||
const genericFailure = () => reply.code(401).send({ error: "invalid_credentials" });
|
||||
if (!user) {
|
||||
// On exécute quand même une vérification argon2 pour homogénéiser le temps de réponse.
|
||||
await verifyPassword(
|
||||
"$argon2id$v=19$m=19456,t=2,p=1$AAAAAAAAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
||||
password,
|
||||
).catch(() => false);
|
||||
return genericFailure();
|
||||
}
|
||||
const ok = await verifyPassword(user.passwordHash, password);
|
||||
if (!ok) return genericFailure();
|
||||
|
||||
loginLimiter.reset(ip);
|
||||
const { token, csrfToken } = await app.sessionManager.create(user.id);
|
||||
const cookieOptions = {
|
||||
httpOnly: true,
|
||||
sameSite: "lax" as const,
|
||||
path: "/",
|
||||
secure: false, // TLS géré par Caddy/proxy devant (voir README)
|
||||
};
|
||||
reply.setCookie(SESSION_COOKIE, token, cookieOptions).setCookie(CSRF_COOKIE, csrfToken, {
|
||||
httpOnly: false,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
secure: false,
|
||||
});
|
||||
return reply.send({
|
||||
user: { username: user.username },
|
||||
csrfToken,
|
||||
csrfHeader: CSRF_HEADER,
|
||||
sessionExpiresAt: new Date(Date.now() + config.sessionTtlMs).toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
app.post(
|
||||
"/api/auth/logout",
|
||||
{ preHandler: [app.authenticate, app.requireCsrf] },
|
||||
async (request, reply) => {
|
||||
const token = request.cookies[SESSION_COOKIE];
|
||||
await app.sessionManager.destroy(token);
|
||||
const clearOptions = { path: "/", sameSite: "lax" as const, secure: false };
|
||||
reply
|
||||
.setCookie(SESSION_COOKIE, "", { ...clearOptions, httpOnly: true, maxAge: 0 })
|
||||
.setCookie(CSRF_COOKIE, "", { ...clearOptions, httpOnly: false, maxAge: 0 });
|
||||
return reply.send({ ok: true });
|
||||
},
|
||||
);
|
||||
|
||||
app.get("/api/auth/session", async (request, reply) => {
|
||||
if (!request.session) {
|
||||
return reply.code(401).send({ error: "not_authenticated" });
|
||||
}
|
||||
return reply.send({
|
||||
user: { username: request.session.username },
|
||||
// Double-submit : le token CSRF est la valeur du cookie (lisible par le JS),
|
||||
// qu'on renvoie dans l'en-tête x-csrf-token (vérifié par requireCsrf).
|
||||
csrfToken: request.cookies[CSRF_COOKIE] ?? null,
|
||||
csrfHeader: CSRF_HEADER,
|
||||
sessionExpiresAt: request.session.sessionExpiresAt.toISOString(),
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import type { ExternalCache } from "../external/cache";
|
||||
import type { ExternalListResult, ExternalService } from "../external/types";
|
||||
|
||||
interface ExternalRoutesDeps {
|
||||
service: ExternalService;
|
||||
cache: ExternalCache;
|
||||
}
|
||||
|
||||
function cacheKey(serviceName: string, op: string): string {
|
||||
return `${serviceName}:${op}`;
|
||||
}
|
||||
|
||||
export function buildExternalRoutes(app: FastifyInstance, deps: ExternalRoutesDeps): void {
|
||||
const { service, cache } = deps;
|
||||
const keyItems = cacheKey(service.name, "items");
|
||||
|
||||
app.get("/api/external/health", { preHandler: [app.authenticate] }, async (_request, reply) => {
|
||||
const health = await service.health();
|
||||
return reply.send(health);
|
||||
});
|
||||
|
||||
app.get("/api/external/items", { preHandler: [app.authenticate] }, async (_request, reply) => {
|
||||
const cached = await cache.get<ExternalListResult>(keyItems);
|
||||
if (cached) {
|
||||
return reply.send({ ...cached, fromCache: true });
|
||||
}
|
||||
const result = await service.list();
|
||||
await cache.set(keyItems, result);
|
||||
return reply.send(result);
|
||||
});
|
||||
|
||||
app.post(
|
||||
"/api/external/items/refresh",
|
||||
{ preHandler: [app.authenticate, app.requireCsrf] },
|
||||
async (_request, reply) => {
|
||||
await cache.invalidate(keyItems);
|
||||
const result = await service.list();
|
||||
await cache.set(keyItems, result);
|
||||
return reply.send({ ...result, fromCache: false });
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
|
||||
export function buildHealthRoutes(app: FastifyInstance): void {
|
||||
app.get("/api/health", async (_request, reply) => {
|
||||
return reply.send({ status: "ok", uptimeSec: Math.round(process.uptime()) });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { mkdirSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { hashPassword } from "./auth/password";
|
||||
import { loadConfig } from "./config";
|
||||
import { openDatabase } from "./db/client";
|
||||
import { users } from "./db/schema";
|
||||
|
||||
const MIGRATIONS_FOLDER = path.resolve(import.meta.dirname, "../drizzle");
|
||||
|
||||
/**
|
||||
* Crée le compte initial (admin) dans une base vide.
|
||||
* Idempotent : si des comptes existent déjà, rien n'est modifié.
|
||||
* Variables requises (voir .env.example) : SEICHE_ADMIN_USERNAME, SEICHE_ADMIN_PASSWORD.
|
||||
*/
|
||||
async function main(): Promise<void> {
|
||||
const config = loadConfig();
|
||||
mkdirSync(config.dataDir, { recursive: true });
|
||||
const db = await openDatabase(config.dataDir, MIGRATIONS_FOLDER);
|
||||
|
||||
const [row] = await db.select({ count: sql<number>`count(*)` }).from(users);
|
||||
const existing = row?.count ?? 0;
|
||||
if (existing > 0) {
|
||||
console.log(
|
||||
`Base ${config.dataDir}/seiche.db : ${existing} compte(s) déjà présent(s), rien à faire.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const username = process.env.SEICHE_ADMIN_USERNAME;
|
||||
const password = process.env.SEICHE_ADMIN_PASSWORD;
|
||||
if (!username || !password) {
|
||||
throw new Error(
|
||||
"Base vide : définir SEICHE_ADMIN_USERNAME et SEICHE_ADMIN_PASSWORD (voir .env.example) puis relancer pnpm setup.",
|
||||
);
|
||||
}
|
||||
if (password.length < 12) {
|
||||
throw new Error("SEICHE_ADMIN_PASSWORD doit faire au moins 12 caractères.");
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(password);
|
||||
await db.insert(users).values({ username, passwordHash });
|
||||
console.log(`Compte initial « ${username} » créé (base : ${config.dataDir}/seiche.db).`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(
|
||||
"Échec de la création du compte initial :",
|
||||
err instanceof Error ? err.message : err,
|
||||
);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
/** Validation minimaliste des corps de requête (sans dépendance externe). */
|
||||
|
||||
export interface LoginBody {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export function parseLoginBody(body: unknown): { ok: true; data: LoginBody } | { ok: false } {
|
||||
if (typeof body !== "object" || body === null) return { ok: false };
|
||||
const record = body as Record<string, unknown>;
|
||||
const username = record.username;
|
||||
const password = record.password;
|
||||
if (
|
||||
typeof username !== "string" ||
|
||||
typeof password !== "string" ||
|
||||
username.length === 0 ||
|
||||
username.length > 64 ||
|
||||
password.length === 0 ||
|
||||
password.length > 256
|
||||
) {
|
||||
return { ok: false };
|
||||
}
|
||||
return { ok: true, data: { username, password } };
|
||||
}
|
||||
Reference in New Issue
Block a user