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
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
dialect: "sqlite",
schema: "./src/db/schema.ts",
out: "./drizzle",
});
+25
View File
@@ -0,0 +1,25 @@
CREATE TABLE `external_cache` (
`key` text PRIMARY KEY NOT NULL,
`value` text NOT NULL,
`fetched_at` integer NOT NULL,
`expires_at` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE `sessions` (
`token_hash` text PRIMARY KEY NOT NULL,
`user_id` integer NOT NULL,
`created_at` integer NOT NULL,
`expires_at` integer NOT NULL,
`absolute_expiry_at` integer NOT NULL,
`last_activity_at` integer NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `users` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`username` text NOT NULL,
`password_hash` text NOT NULL,
`created_at` integer NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `users_username_unique` ON `users` (`username`);
+168
View File
@@ -0,0 +1,168 @@
{
"version": "6",
"dialect": "sqlite",
"id": "c52dc70f-9d5e-4c32-9abd-2d6e25ee3124",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"external_cache": {
"name": "external_cache",
"columns": {
"key": {
"name": "key",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"fetched_at": {
"name": "fetched_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions": {
"name": "sessions",
"columns": {
"token_hash": {
"name": "token_hash",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"absolute_expiry_at": {
"name": "absolute_expiry_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"last_activity_at": {
"name": "last_activity_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"users_username_unique": {
"name": "users_username_unique",
"columns": [
"username"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1787524356275,
"tag": "0000_heavy_saracen",
"breakpoints": true
}
]
}
+33
View File
@@ -0,0 +1,33 @@
{
"name": "seiche-server",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "src/main.ts",
"scripts": {
"dev": "tsx watch src/main.ts",
"start": "node --import tsx src/main.ts",
"setup": "tsx src/setup.ts",
"db:generate": "drizzle-kit generate",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@fastify/cookie": "^11.1.2",
"@fastify/sensible": "^6.0.5",
"@fastify/static": "^10.1.3",
"@libsql/client": "^0.17.4",
"argon2": "^0.45.1",
"dotenv": "^17.4.2",
"drizzle-orm": "^0.45.2",
"fastify": "^5.12.1"
},
"devDependencies": {
"@types/node": "^26.2.0",
"drizzle-kit": "^0.31.10",
"pino-pretty": "^13.1.3",
"tsx": "^4.23.12",
"typescript": "^5.9.3",
"vitest": "^4.1.11"
}
}
+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 } };
}
+210
View File
@@ -0,0 +1,210 @@
import { eq } from "drizzle-orm";
import type { FastifyInstance } from "fastify";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { buildApp } from "../src/app";
import { hashPassword } from "../src/auth/password";
import { CSRF_COOKIE, CSRF_HEADER, SESSION_COOKIE } from "../src/auth/session";
import type { Database } from "../src/db/client";
import { users } from "../src/db/schema";
import { makeConfig, openTestDb } from "./helpers";
// Tests : pas de worker pino-pretty, error-handler en mode production.
process.env.NODE_ENV = "production";
process.env.SEICHE_LOG_LEVEL = "silent";
let db: Database;
let app: FastifyInstance;
beforeEach(async () => {
db = await openTestDb();
app = await buildApp({ config: makeConfig(), db });
});
afterEach(async () => {
await app.close();
});
function asSetCookieList(raw: unknown): string[] {
if (Array.isArray(raw)) return raw.filter((x): x is string => typeof x === "string");
if (raw === undefined) return [];
return [String(raw)];
}
function cookieValue(raw: unknown, name: string): string | undefined {
for (const item of asSetCookieList(raw)) {
const pair = item.split(";")[0] ?? "";
if (pair.startsWith(`${name}=`)) return pair.slice(name.length + 1);
}
return undefined;
}
function cookieHeader(raw: unknown, name: string): string | undefined {
return asSetCookieList(raw).find((item) => item.startsWith(`${name}=`));
}
async function seedUser(username: string, password: string): Promise<void> {
await db.insert(users).values({ username, passwordHash: await hashPassword(password) });
}
describe("API /api (buildApp)", () => {
it("GET /api/health → 200 ok", async () => {
const res = await app.inject({ method: "GET", url: "/api/health" });
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ status: "ok", uptimeSec: expect.any(Number) });
});
it("POST /api/auth/login : succès → 200, cookies httpOnly + csrfToken", async () => {
await seedUser("alice", "correct-horse-battery");
const res = await app.inject({
method: "POST",
url: "/api/auth/login",
payload: { username: "alice", password: "correct-horse-battery" },
});
expect(res.statusCode).toBe(200);
const body = res.json() as {
user: { username: string };
csrfToken: string;
csrfHeader: string;
};
expect(body.user.username).toBe("alice");
expect(body.csrfToken).toMatch(/^[a-f0-9]{64}$/);
expect(body.csrfHeader).toBe(CSRF_HEADER);
const raw = res.headers["set-cookie"];
expect(cookieValue(raw, SESSION_COOKIE)).toMatch(/^[a-f0-9]{64}$/);
expect(cookieValue(raw, CSRF_COOKIE)).toBe(body.csrfToken);
expect(cookieHeader(raw, SESSION_COOKIE)).toContain("HttpOnly");
expect(cookieHeader(raw, SESSION_COOKIE)).toContain("SameSite=Lax");
expect(cookieHeader(raw, CSRF_COOKIE)).not.toContain("HttpOnly");
});
it("POST /api/auth/login : mauvais mdp ou user inconnu → 401 identique", async () => {
await seedUser("alice", "correct-horse-battery");
const wrong = await app.inject({
method: "POST",
url: "/api/auth/login",
payload: { username: "alice", password: "nope" },
});
expect(wrong.statusCode).toBe(401);
expect(wrong.json()).toEqual({ error: "invalid_credentials" });
const unknown = await app.inject({
method: "POST",
url: "/api/auth/login",
payload: { username: "ghost", password: "nope" },
});
expect(unknown.statusCode).toBe(401);
expect(unknown.json()).toEqual({ error: "invalid_credentials" });
});
it("POST /api/auth/login : corps invalide → 400 invalid_body", async () => {
const empty = await app.inject({ method: "POST", url: "/api/auth/login", payload: {} });
expect(empty.statusCode).toBe(400);
expect(empty.json()).toEqual({ error: "invalid_body" });
const missing = await app.inject({
method: "POST",
url: "/api/auth/login",
payload: { username: "alice" },
});
expect(missing.statusCode).toBe(400);
});
it("POST /api/auth/login : 6e tentative dans la minute → 429 (rate limit par IP)", async () => {
await seedUser("alice", "correct-horse-battery");
for (let i = 0; i < 5; i++) {
const res = await app.inject({
method: "POST",
url: "/api/auth/login",
payload: { username: "alice", password: "wrong" },
});
expect(res.statusCode).toBe(401);
}
const blocked = await app.inject({
method: "POST",
url: "/api/auth/login",
payload: { username: "alice", password: "correct-horse-battery" },
});
expect(blocked.statusCode).toBe(429);
expect((blocked.json() as { error: string }).error).toBe("too_many_attempts");
});
it("POST /api/auth/logout sans session → 401 not_authenticated", async () => {
const res = await app.inject({ method: "POST", url: "/api/auth/logout" });
expect(res.statusCode).toBe(401);
expect(res.json()).toEqual({ error: "not_authenticated" });
});
it("CSRF double-submit : 403 sans/mauvais header, 200 avec le bon, session détruite", async () => {
await seedUser("alice", "correct-horse-battery");
const login = await app.inject({
method: "POST",
url: "/api/auth/login",
payload: { username: "alice", password: "correct-horse-battery" },
});
expect(login.statusCode).toBe(200);
const body = login.json() as { csrfToken: string };
const session = cookieValue(login.headers["set-cookie"], SESSION_COOKIE);
const csrf = cookieValue(login.headers["set-cookie"], CSRF_COOKIE);
if (session === undefined || csrf === undefined) {
throw new Error("cookies de session manquants");
}
const cookies = { [SESSION_COOKIE]: session, [CSRF_COOKIE]: csrf };
const noHeader = await app.inject({ method: "POST", url: "/api/auth/logout", cookies });
expect(noHeader.statusCode).toBe(403);
expect(noHeader.json()).toEqual({ error: "csrf_invalid" });
const wrongHeader = await app.inject({
method: "POST",
url: "/api/auth/logout",
cookies,
headers: { [CSRF_HEADER]: "0".repeat(64) },
});
expect(wrongHeader.statusCode).toBe(403);
const ok = await app.inject({
method: "POST",
url: "/api/auth/logout",
cookies,
headers: { [CSRF_HEADER]: body.csrfToken },
});
expect(ok.statusCode).toBe(200);
expect(ok.json()).toEqual({ ok: true });
expect(cookieValue(ok.headers["set-cookie"], SESSION_COOKIE)).toBe("");
const after = await app.inject({ method: "GET", url: "/api/auth/session", cookies });
expect(after.statusCode).toBe(401);
});
it("error-handler : exception de route → 500 internal_error, sans fuite (prod)", async () => {
app.post("/api/test-crash", async () => {
throw new Error("boom interne");
});
const res = await app.inject({ method: "POST", url: "/api/test-crash" });
expect(res.statusCode).toBe(500);
const body = res.json() as { error: string; details?: string };
expect(body.error).toBe("internal_error");
expect(body.details).toBeUndefined();
});
it("DB round-trip : insert → read → update → delete", async () => {
const hash = await hashPassword("roundtrip");
const inserted = await db
.insert(users)
.values({ username: "bob", passwordHash: hash })
.returning();
const user = inserted[0];
if (user === undefined) throw new Error("insertion ratée");
const [found] = await db.select().from(users).where(eq(users.id, user.id)).limit(1);
expect(found?.username).toBe("bob");
expect(found?.passwordHash).toBe(hash);
const newHash = await hashPassword("roundtrip2");
await db.update(users).set({ passwordHash: newHash }).where(eq(users.id, user.id));
const [updated] = await db.select().from(users).where(eq(users.id, user.id)).limit(1);
expect(updated?.passwordHash).toBe(newHash);
await db.delete(users).where(eq(users.id, user.id));
const remaining = await db.select().from(users).where(eq(users.id, user.id)).limit(1);
expect(remaining).toHaveLength(0);
});
});
+33
View File
@@ -0,0 +1,33 @@
import path from "node:path";
import type { Config } from "../src/config";
import { type Database, openInMemoryDatabase } from "../src/db/client";
export const MIGRATIONS_FOLDER = path.resolve(import.meta.dirname, "../drizzle");
export function makeConfig(overrides: Partial<Config> = {}): Config {
return {
port: 8787,
host: "127.0.0.1",
dataDir: "/tmp/seiche-tests",
sessionTtlMs: 8 * 60 * 60 * 1000,
sessionAbsoluteTtlMs: 7 * 24 * 60 * 60 * 1000,
maxLoginAttemptsPerMinute: 5,
publicDir: null,
external: {
adapter: "example",
baseUrl: "",
apiKey: "",
healthPath: "/health",
listPath: "/items",
timeoutMs: 10_000,
retries: 2,
cacheTtlMs: 30_000,
},
...overrides,
};
}
/** Base SQLite in-memory avec les migrations jouées. */
export async function openTestDb(): Promise<Database> {
return openInMemoryDatabase(MIGRATIONS_FOLDER);
}
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { hashPassword, verifyPassword } from "../src/auth/password";
describe("hashPassword / verifyPassword (argon2id)", () => {
it("roundtrip", async () => {
const hash = await hashPassword("correct horse battery staple");
expect(hash).toMatch(/^\$argon2id\$/);
expect(await verifyPassword(hash, "correct horse battery staple")).toBe(true);
});
it("mauvais mot de passe → false", async () => {
const hash = await hashPassword("correct horse battery staple");
expect(await verifyPassword(hash, "nope")).toBe(false);
});
it("hash invalide → false (jamais d'exception)", async () => {
expect(await verifyPassword("$argon2id$garbage", "x")).toBe(false);
expect(await verifyPassword("", "x")).toBe(false);
});
it("salé : même mot de passe → hashes différents", async () => {
const a = await hashPassword("même mot de passe");
const b = await hashPassword("même mot de passe");
expect(a).not.toBe(b);
expect(await verifyPassword(a, "même mot de passe")).toBe(true);
expect(await verifyPassword(b, "même mot de passe")).toBe(true);
});
});
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { RateLimiter } from "../src/auth/rate-limit";
const T0 = new Date("2026-01-01T00:00:00Z");
const at = (ms: number) => new Date(T0.getTime() + ms);
describe("RateLimiter (fenêtre glissante)", () => {
it("autorise jusqu'à maxHits puis bloque", () => {
const rl = new RateLimiter(3, 60_000);
expect(rl.hit("a", T0)).toBe(true);
expect(rl.hit("a", at(1000))).toBe(true);
expect(rl.hit("a", at(2000))).toBe(true);
expect(rl.hit("a", at(3000))).toBe(false);
});
it("les hits anciens sortent de la fenêtre", () => {
const rl = new RateLimiter(2, 60_000);
expect(rl.hit("a", T0)).toBe(true);
expect(rl.hit("a", at(30_000))).toBe(true);
expect(rl.hit("a", at(31_000))).toBe(false);
// Le hit de T0 a expiré de la fenêtre (60 s).
expect(rl.hit("a", at(61_001))).toBe(true);
});
it("reset vide la clé", () => {
const rl = new RateLimiter(1, 60_000);
expect(rl.hit("a", T0)).toBe(true);
expect(rl.hit("a", at(1000))).toBe(false);
rl.reset("a");
expect(rl.hit("a", at(2000))).toBe(true);
});
it("remaining reflète les tentatives restantes", () => {
const rl = new RateLimiter(2, 60_000);
expect(rl.remaining("a", T0)).toBe(2);
rl.hit("a", T0);
expect(rl.remaining("a", at(1000))).toBe(1);
});
it("les clés sont isolées", () => {
const rl = new RateLimiter(1, 60_000);
expect(rl.hit("a", T0)).toBe(true);
expect(rl.hit("b", T0)).toBe(true);
});
});
+125
View File
@@ -0,0 +1,125 @@
import { createHash, randomBytes } from "node:crypto";
import { eq } from "drizzle-orm";
import { beforeAll, describe, expect, it } from "vitest";
import { SessionManager } from "../src/auth/session";
import type { Database } from "../src/db/client";
import { sessions, users } from "../src/db/schema";
import { makeConfig, openTestDb } from "./helpers";
function sha256Hex(value: string): string {
return createHash("sha256").update(value).digest("hex");
}
function newToken(): string {
return randomBytes(32).toString("hex");
}
describe("SessionManager", () => {
let db: Database;
let manager: SessionManager;
let userId: number;
beforeAll(async () => {
db = await openTestDb();
manager = new SessionManager(db, makeConfig());
const inserted = await db
.insert(users)
.values({ username: "alice", passwordHash: "x" })
.returning();
const user = inserted[0];
if (user === undefined) throw new Error("utilisateur de test non inséré");
userId = user.id;
});
it("create : token + CSRF 256 bits, getValid renvoie le contexte", async () => {
const { token, csrfToken } = await manager.create(userId);
expect(token).toMatch(/^[a-f0-9]{64}$/);
expect(csrfToken).toMatch(/^[a-f0-9]{64}$/);
const ctx = await manager.getValid(token);
expect(ctx?.userId).toBe(userId);
expect(ctx?.username).toBe("alice");
expect(ctx?.sessionExpiresAt).toBeInstanceOf(Date);
});
it("getValid : token absent ou inconnu → null", async () => {
expect(await manager.getValid(undefined)).toBeNull();
expect(await manager.getValid(newToken())).toBeNull();
});
it("le token est stocké haché (pas en clair)", async () => {
const { token } = await manager.create(userId);
const rows = await db
.select()
.from(sessions)
.where(eq(sessions.tokenHash, sha256Hex(token)));
expect(rows).toHaveLength(1);
expect(rows[0]?.tokenHash).not.toBe(token);
});
it("TTL glissant : expiration = maintenant + ttl", async () => {
const { token } = await manager.create(userId);
const before = Date.now();
const ctx = await manager.getValid(token);
expect(ctx?.sessionExpiresAt.getTime()).toBeGreaterThanOrEqual(before + 8 * 3600 * 1000 - 2000);
expect(ctx?.sessionExpiresAt.getTime()).toBeLessThanOrEqual(before + 8 * 3600 * 1000 + 5000);
});
it("TTL glissant borné par l'expiration absolue", async () => {
const m = new SessionManager(
db,
makeConfig({ sessionTtlMs: 3_600_000, sessionAbsoluteTtlMs: 300_000 }),
);
const { token } = await m.create(userId);
const before = Date.now();
const ctx = await m.getValid(token);
expect(ctx?.sessionExpiresAt.getTime()).toBeGreaterThanOrEqual(before + 300_000 - 1_000);
expect(ctx?.sessionExpiresAt.getTime()).toBeLessThanOrEqual(before + 300_000 + 1_000);
});
it("expiration absolue dépassée → null + ligne supprimée", async () => {
const token = newToken();
const past = new Date(Date.now() - 1000);
await db.insert(sessions).values({
tokenHash: sha256Hex(token),
userId,
createdAt: past,
expiresAt: past,
absoluteExpiryAt: past,
lastActivityAt: past,
});
expect(await manager.getValid(token)).toBeNull();
const rows = await db
.select()
.from(sessions)
.where(eq(sessions.tokenHash, sha256Hex(token)));
expect(rows).toHaveLength(0);
});
it("destroy invalidate le token", async () => {
const { token } = await manager.create(userId);
await manager.destroy(token);
expect(await manager.getValid(token)).toBeNull();
});
it("5 sessions max par utilisateur (exactement 5 des 6 restent valides)", async () => {
const inserted = await db
.insert(users)
.values({ username: "bob", passwordHash: "x" })
.returning();
const bob = inserted[0];
if (bob === undefined) throw new Error("utilisateur bob non inséré");
const m2 = new SessionManager(db, makeConfig());
const tokens: string[] = [];
for (let i = 0; i < 6; i += 1) {
tokens.push((await m2.create(bob.id)).token);
}
const rows = await db.select().from(sessions).where(eq(sessions.userId, bob.id));
expect(rows).toHaveLength(5);
let validCount = 0;
for (const t of tokens) {
const ctx = await m2.getValid(t);
if (ctx !== null) validCount += 1;
}
expect(validCount).toBe(5);
});
});
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2023"],
"types": ["node"],
"noEmit": true,
"rootDir": "."
},
"include": ["src", "tests", "drizzle.config.ts"]
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
include: ["tests/**/*.test.ts"],
testTimeout: 20000,
hookTimeout: 20000,
},
});