Initial commit : monorepo seiche (serveur Fastify + web Vite/React, Docker, config VS Code)

This commit is contained in:
bot-claude
2026-08-25 09:55:13 +02:00
commit 5174c37a31
61 changed files with 7117 additions and 0 deletions
+81
View File
@@ -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;
}
+20
View File
@@ -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;
}
}
+36
View File
@@ -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);
}
}
+157
View File
@@ -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 };
+64
View File
@@ -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),
},
};
}
+36
View File
@@ -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;
}
+45
View File
@@ -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
View File
@@ -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 };
}
}
+104
View File
@@ -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
View File
@@ -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)}`);
}
}
}
+43
View File
@@ -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));
}
}
+76
View File
@@ -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));
}
}
+32
View File
@@ -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>;
}
+40
View File
@@ -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);
});
+98
View File
@@ -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(),
});
});
}
+43
View File
@@ -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 });
},
);
}
+7
View File
@@ -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()) });
});
}
+52
View File
@@ -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);
});
+24
View File
@@ -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 } };
}