Initial commit : monorepo seiche (serveur Fastify + web Vite/React, Docker, config VS Code)
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
export default defineConfig({
|
||||
dialect: "sqlite",
|
||||
schema: "./src/db/schema.ts",
|
||||
out: "./drizzle",
|
||||
});
|
||||
@@ -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`);
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1787524356275,
|
||||
"tag": "0000_heavy_saracen",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 } };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2023"],
|
||||
"types": ["node"],
|
||||
"noEmit": true,
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["src", "tests", "drizzle.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["tests/**/*.test.ts"],
|
||||
testTimeout: 20000,
|
||||
hookTimeout: 20000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user