Reciprok Docs
API

Design d'API

Elysia + Eden Treaty, best practices, contrats, organisation

L'API expose le domaine. C'est aussi la surface utilisée par l'IA, donc tout ce qui peut être fait par l'utilisateur doit être un endpoint propre, sans logique métier dans le frontend ni dans les tools IA.

Stack

  • Serveur : Elysia (Bun-native, le plus rapide du marché)
  • Client typé : Eden Treaty, type inference end-to-end sans codegen
  • Validation : t (TypeBox) côté Elysia, partagé via Eden au client
  • Auth équipe : Better Auth (handler monté sur Elysia)
  • Auth membres/organisateurs : magic tokens custom (voir operations/auth)

Pourquoi Eden Treaty plutôt que tRPC : voir l'ADR-04. TL;DR : Bun-first, type inference plus directe, pas de couche client/server intermédiaire, intégration native avec lifecycle Elysia.

Principe directeur

L'IA est un client de l'API comme un autre. Elle appelle les mêmes routes que le frontend, avec les mêmes schémas TypeBox, les mêmes guards, les mêmes vérifications. Cela garantit :

  • Une seule source de vérité pour la logique métier
  • L'IA ne peut faire que ce que l'utilisateur peut faire
  • Tout ce qui passe par l'IA est traçable comme une action utilisateur

Best practices Elysia (à respecter)

Ces règles ne sont pas optionnelles, elles conditionnent le bon fonctionnement du type inference Eden et la maintenabilité.

1. Method chaining obligatoire

Elysia infère les types du retour de chaque méthode. Sans chaining, le client perd le typage.

// ✅ Bon
const app = new Elysia()
  .state('version', 1)
  .decorate('db', db)
  .get('/health', () => 'ok');

// ❌ Mauvais, les types se perdent
const app = new Elysia();
app.state('version', 1);
app.decorate('db', db);
app.get('/health', () => 'ok');

2. Une instance Elysia par module métier

Chaque module est une new Elysia({ prefix, name }). On compose ensuite avec .use().

// modules/members/members.routes.ts
export const membersRoutes = new Elysia({ prefix: '/members', name: 'members' })
  .use(authMacro)
  .get('/', ({ query }) => listMembers(query), { query: ListMembersQuery })
  .get('/:id', ({ params }) => getMember(params.id))
  .post('/', ({ body }) => createMember(body), { body: CreateMemberBody });

Le name permet le plugin deduplication d'Elysia : si le module est mounté plusieurs fois (pour les tests par ex), il n'est instancié qu'une seule fois.

3. Service Locator via derive / decorate

Au lieu d'importer manuellement les services dans chaque handler, on les injecte via le contexte. Pattern recommandé par la doc Elysia.

// lib/context.ts
export const appContext = new Elysia({ name: 'app-context' })
  .decorate('db', db)                   // services stables (singleton)
  .decorate('claude', claudeClient)
  .decorate('mailer', resendClient)
  .derive(({ request }) => ({          // dérivés par requête
    requestId: crypto.randomUUID(),
    startedAt: Date.now(),
  }));

Utilisé dans n'importe quel module :

export const requestsRoutes = new Elysia({ prefix: '/requests', name: 'requests' })
  .use(appContext)
  .post('/', async ({ db, body, requestId }) => {
    return db.insert(request).values({ ...body, traceId: requestId });
  });

4. Macros pour les guards transverses

Les macro permettent d'attacher des comportements réutilisables aux routes (auth, permissions, rate limiting). Plus propre qu'un beforeHandle répété.

// lib/auth-macro.ts
export const authMacro = new Elysia({ name: 'auth-macro' })
  .use(appContext)
  .macro({
    auth: (mode: 'team' | 'member' | 'organizer' | 'public') => ({
      resolve: async ({ headers, cookie, error }) => {
        if (mode === 'public') return {};
        if (mode === 'team') {
          const session = await betterAuth.api.getSession({ headers });
          if (!session) return error(401, 'Unauthenticated');
          return { user: session.user, session };
        }
        if (mode === 'member') {
          const token = headers['x-magic-token'];
          const member = await verifyMemberToken(token);
          if (!member) return error(401, 'Invalid member token');
          return { member };
        }
        if (mode === 'organizer') {
          const token = headers['x-magic-token'];
          const organizer = await verifyOrganizerToken(token);
          if (!organizer) return error(401, 'Invalid organizer token');
          return { organizer };
        }
      },
    }),
  });

Usage :

.get('/me', ({ user }) => user, {
  auth: 'team',
})

Le resolve du macro typiquement injecte des valeurs dans le contexte, donc user est typé en sortie. Eden propage ce typage côté client.

5. Lifecycle hooks dans l'ordre

Elysia applique : onRequestparsetransformbeforeHandlehandlerafterHandlemapResponseonAfterResponseonError.

Notre usage :

HookUsage Reciprok
onRequestTrace ID, log d'entrée (Logixlysia)
transformCoercion / normalisation des inputs
beforeHandlePermissions fines (déjà couvertes par auth macro)
afterHandleAppend TimelineEvent pour les mutations métier
onErrorLog GlitchTip, format d'erreur unifié, masquage des erreurs internes
onAfterResponseMétriques Prometheus (durée, status), audit log

6. Schémas TypeBox réutilisables via Elysia.model

Les schémas sont déclarés une fois avec t.Object(...) et référencés par nom. Ça réduit la duplication dans la doc OpenAPI générée et améliore le caching de validation.

export const models = new Elysia({ name: 'models' })
  .model({
    'member.create': t.Object({
      name: t.String({ minLength: 2 }),
      postalCode: t.String({ pattern: '^\\d{5}$' }),
      email: t.String({ format: 'email' }),
    }),
    'member.public': t.Object({
      id: t.String({ format: 'uuid' }),
      code: t.String(),
      name: t.String(),
    }),
  });
.post('/', ({ body }) => createMember(body), {
  body: 'member.create',
  response: 'member.public',
})

7. Pas de logique métier dans les handlers

Le handler route → service → repository. Le service contient la règle métier, le repository touche Drizzle. Cohérent avec le standard interne (cf. CLAUDE.md).

Organisation du code

apps/server/src/
├── app.ts                          # Composition racine de l'Elysia
├── index.ts                        # Entry point (app.listen)
├── lib/
│   ├── context.ts                  # appContext
│   ├── auth-macro.ts               # requireTeam() + resolveUser()
│   ├── error-format.ts             # onError unifié (pino logger)
│   ├── logger.ts                   # Logger pino partagé
│   ├── rate-limit.ts               # Global + createScopedLimiter(scope, cfg)
│   ├── rate-limit-presets.ts       # authLimit, webhookLimit, sendLimit, aiLimit
│   └── s3.ts                       # Client RustFS/S3 partagé
├── modules/
│   ├── auth/                       # Handler Better Auth monté sur Elysia
│   ├── members/                    # Lieux, photos, tags, groupes
│   ├── rooms/                      # Salles + capacités Kactus
│   ├── organizers/                 # Organisateurs d'événements
│   ├── requests/                   # Demandes + pipeline + results
│   ├── catalog/                    # Compose/send + accès public magic token
│   ├── timeline/                   # Events append-only + emit()
│   ├── notifications/              # Notifications équipe
│   ├── availability/               # Disponibilités des membres
│   ├── commissions/                # Commissions + règles de calcul
│   ├── magic-tokens/               # Tokens d'accès membres/organisateurs
│   ├── communications/             # Emails Resend + webhook signature Svix
│   ├── whatsapp/                   # Meta Cloud API + webhook verify/status
│   ├── ai/                         # Chat Claude, tools, knowledge, training
│   ├── search/                     # Pipeline filtrage SQL + pgvector
│   ├── uploads/                    # RustFS/S3 photos + audios
│   ├── tags/                       # Tags globaux + member tags
│   ├── stats/                      # Overview, funnel, revenue, top, etc.
│   └── embeddings/                 # Pipeline refresh pgvector
├── scripts/
│   └── embeddings-refresh.ts       # CLI exécuté en cron
└── __tests__/                      # Tests d'intégration (vraie Postgres)

Chaque module suit le même pattern strict :

  • [feature].routes.ts, HTTP uniquement, jamais de logique métier
  • [feature].service.ts, règles métier, jamais de Drizzle direct
  • [feature].repository.ts, accès DB via Drizzle uniquement
  • [feature].service.test.ts, unit tests (repo mocké)
  • index.ts, barrel export uniquement

Composition de l'app

L'app monte les routes dans trois surfaces :

  1. Hors-groupe : /health et webhooks publics (/webhooks/resend, /webhooks/whatsapp)
  2. /api : toutes les routes métier protégées par requireTeam()
  3. /api/public : endpoints magic tokens (catalog, qualification, etc.)
apps/server/src/app.ts (extrait)
import { Elysia } from "elysia";
import { cors } from "@elysiajs/cors";
import { swagger } from "@elysiajs/swagger";
import logixlysia from "logixlysia";

import { appContext } from "./lib/context";
import { errorFormat } from "./lib/error-format";
import { rateLimit } from "./lib/rate-limit";

import { authRoutes } from "./modules/auth";
import { membersRoutes } from "./modules/members";
// ... 19 modules au total

export const app = new Elysia()
  .use(logixlysia({ /* access logs */ }))
  .use(cors({ origin: env.CORS_ORIGIN, credentials: true }))
  .use(swagger({ path: "/docs", /* scalar UI */ }))
  .use(appContext)
  .use(errorFormat)
  .use(rateLimit({ maxRequests: 100, windowMs: 60_000 }))    // Global
  .get("/health", () => ({ status: "ok", timestamp: new Date().toISOString() }))
  .use(resendWebhookRoutes)                                   // POST /webhooks/resend
  .use(whatsappWebhookRoutes)                                 // GET/POST /webhooks/whatsapp
  .group("/api", (api) =>
    api
      .use(authRoutes)           // /api/auth/*
      .use(membersRoutes)         // /api/members/*
      .use(roomsRoutes)
      .use(organizersRoutes)
      .use(requestsRoutes)
      .use(timelineRoutes)
      .use(commissionsRoutes)
      .use(availabilityRoutes)
      .use(notificationsRoutes)
      .use(magicTokensRoutes)
      .use(communicationsRoutes)
      .use(aiRoutes)
      .use(searchRoutes)
      .use(uploadsRoutes)
      .use(catalogRoutes)
      .use(tagsRoutes)
      .use(statsRoutes)
      .use(embeddingsRoutes)
      .use(whatsappRoutes)
      .use(publicCatalogRoutes), // /api/public/catalogs/:token
  );

export type App = typeof app;

Le export type App = typeof app est ce qui alimente Eden Treaty côté client. Il est exposé via apps/server/package.json avec un exports map :

{
  "exports": {
    "./app": { "types": "./src/app.ts", "default": "./src/app.ts" }
  }
}

Côté web, apps/web/package.json dépend de "server": "workspace:*" et l'import devient import type { App } from "server/app".

Client Eden Treaty

apps/web/src/lib/api.ts
import { treaty } from "@elysiajs/eden";
import type { App } from "server/app";

export const api = treaty<App>(
  process.env.NEXT_PUBLIC_SERVER_URL ?? "http://localhost:3000",
  {
    fetch: { credentials: "include" },
  },
);

export type Api = typeof api;

Important : tous les imports dans apps/server/src/ doivent être relatifs (pas de @/). Le web résout @/ vers son propre src/, donc un import @/lib/context dans le serveur casse l'inférence Eden côté client. Les tests sous __tests__/ peuvent garder @/ puisqu'ils ne sont jamais traversés par le web.

Usage côté composant React (React Query) :

import { useQuery, useMutation } from '@tanstack/react-query';
import { api } from '@/lib/api';

export function MemberCard({ id }: { id: string }) {
  const { data, isLoading } = useQuery({
    queryKey: ['member', id],
    queryFn: async () => {
      const { data, error } = await api.members({ id }).get();
      if (error) throw error;
      return data;
    },
  });
  // …
}

export function useCreateMember() {
  return useMutation({
    mutationFn: async (body: CreateMemberInput) => {
      const { data, error } = await api.members.post(body);
      if (error) throw error;
      return data;
    },
  });
}

Type inference end-to-end : body est inféré depuis le t.Object côté serveur, data est inféré depuis le retour du handler. Aucun codegen.

Conventions

Nommage des routes

REST-ish, pluriels, verbes HTTP :

ActionMéthodePath
ListerGET/members
DétailGET/members/:id
Détail par codeGET/members/by-code/:code
CréerPOST/members
Mettre à jour (partial)PATCH/members/:id
SupprimerDELETE/members/:id
Sous-ressourceGET/members/:id/rooms
Action métierPOST/requests/:id/advance

Pour les actions qui ne sont pas du CRUD (ex: advance, compose-catalog), on garde un verbe explicite dans le path. C'est plus lisible que de tordre une sémantique REST.

Format d'erreur unifié

// lib/error-format.ts
export const errorFormat = new Elysia({ name: 'error-format' })
  .onError(({ code, error, set, request }) => {
    captureException(error, { request });
    if (code === 'VALIDATION') {
      set.status = 400;
      return { code: 'VALIDATION_ERROR', message: error.message, details: error.all };
    }
    if (code === 'NOT_FOUND') {
      set.status = 404;
      return { code: 'NOT_FOUND', message: error.message };
    }
    if (code === 'INTERNAL_SERVER_ERROR') {
      set.status = 500;
      return { code: 'INTERNAL_ERROR', message: 'Une erreur interne est survenue' };
    }
    set.status = error.status ?? 500;
    return { code: error.code ?? 'UNKNOWN', message: error.message };
  });

Pour les erreurs métier, on lance des erreurs typées :

import { error } from 'elysia';

if (request.status === 'lost') {
  return error(409, {
    code: 'INVALID_STATUS_TRANSITION',
    message: "Impossible d'avancer une demande perdue",
  });
}

Pagination

Convention systématique :

t.Object({
  items: t.Array(t.Ref('member.public')),
  total: t.Number(),
  page: t.Number(),
  pageSize: t.Number(),
})

Réponses des mutations

Une mutation retourne l'entité complète mise à jour, pas un { ok: true }. Permet au frontend de mettre à jour son cache React Query directement.

Surfaces principales

Toutes les routes ci-dessous vivent sous /api/* sauf mention contraire. Les webhooks publics sont hors du groupe /api pour des URLs stables.

Members, Rooms, Tags

GET    /api/members                       ?search&status&limit&offset
GET    /api/members/:id
GET    /api/members/by-code/:code
POST   /api/members
PATCH  /api/members/:id
DELETE /api/members/:id
POST   /api/members/:id/tags              { tagId }
DELETE /api/members/:id/tags/:tagId

GET    /api/rooms/by-member/:memberId
GET    /api/rooms/:id
POST   /api/rooms
PATCH  /api/rooms/:id
DELETE /api/rooms/:id
POST   /api/rooms/:id/capacities          { configuration, maxCapacity, notes? }
DELETE /api/rooms/:id/capacities/:configuration

GET    /api/tags                          ?category
GET    /api/tags/by-member/:memberId
GET    /api/tags/:id
POST   /api/tags                          { name, category }
DELETE /api/tags/:id

Organizers, Requests, Pipeline

GET    /api/organizers                    ?search&limit&offset
GET    /api/organizers/:id
POST   /api/organizers
PATCH  /api/organizers/:id
DELETE /api/organizers/:id

GET    /api/requests                      ?status&limit&offset
GET    /api/requests/:id
GET    /api/requests/by-code/:code
POST   /api/requests
PATCH  /api/requests/:id

POST   /api/requests/:id/results                          { memberId }
DELETE /api/requests/:id/results/:memberId
POST   /api/requests/:id/results/:memberId/advance        { toStage }

Catalog, Commissions, Availability

GET    /api/catalogs/by-request/:requestId
POST   /api/catalogs/:requestId/compose
POST   /api/catalogs/:requestId/send

GET    /api/commissions/:id
POST   /api/commissions                  { requestId, memberId, amount, ... }
POST   /api/commissions/:id/invoice
POST   /api/commissions/:id/mark-paid

GET    /api/availability/by-member/:memberId?from&to
POST   /api/availability                  { memberId, roomId?, date, slot?, status, ... }
DELETE /api/availability/:id

Search + Embeddings

POST   /api/search/members                { semanticQuery?, postalCodes?, minCapacity?,
                                            configurations?, dates?, center? }

GET    /api/embeddings/status              → counts staleMembers + staleKnowledge
POST   /api/embeddings/refresh             { batch? } → force refresh

Le pipeline de search combine hard filter SQL (géo via Haversine, capacité, config, dispos), ranking pgvector sur member.embedding, et à terme un LLM ranking. Voir intelligence/search-strategy pour les détails.

AI (Claude chat + knowledge + training)

POST   /api/ai/chat/messages               { requestId, content }
GET    /api/ai/chat/messages               ?requestId

GET    /api/ai/knowledge/by-member/:memberId
POST   /api/ai/knowledge/entries           { memberId, type, content, source }
DELETE /api/ai/knowledge/entries/:id

POST   /api/ai/training/correction         { requestId?, originalSuggestion, correction, notes? }

Le chat passe par @anthropic-ai/sdk avec le beta.messages.toolRunner et 3 business tools déclarés en Zod : search_members, advance_pipeline, draft_email. Quand ANTHROPIC_API_KEY est vide, un mock déterministe prend le relais pour que les tests et le dev local restent fonctionnels. Voir intelligence/ai-architecture.

Communications (email + threads + WhatsApp + notifications)

GET    /api/communications/threads/by-request/:requestId
GET    /api/communications/threads/:id
POST   /api/communications/email/send      { requestId, to, subject, bodyHtml, ... }
POST   /api/communications/email/inbound   ← endpoint manuel (test)

GET    /api/notifications                  ?unreadOnly
POST   /api/notifications/:id/read
POST   /api/notifications/read-all

GET    /api/whatsapp/by-request/:requestId
POST   /api/whatsapp/send                  { requestId?, to, body }

Envoi Resend : communications.service.sendEmail() appelle @resend.com/emails, persiste le providerId, émet EMAIL_SENT. Fallback mock si RESEND_API_KEY absent.

Envoi WhatsApp : whatsapp.service.sendText() appelle graph.facebook.com/{version}/{phone_id}/messages, persiste le wamid, émet WHATSAPP_SENT. Fallback mock si credentials absents, prêt pour le jour où Meta Business valide le compte.

Timeline + Stats (dashboard)

GET    /api/timeline/by-request/:requestId ?limit&offset

GET    /api/stats/overview                 snapshot complet du dashboard
GET    /api/stats/activity                 ?limit → events récents
GET    /api/stats/funnel                   ?from&to → funnel cumulatif
GET    /api/stats/revenue                  ?months → revenue mensuel paid
GET    /api/stats/top-members              ?limit&from&to
GET    /api/stats/top-organizers           ?limit&from&to
GET    /api/stats/response-time            ?from&to → avg/median/sampleSize
GET    /api/stats/win-rate                 ?from&to → won/lost/rate

Uploads (RustFS / Cloudflare R2)

POST   /api/uploads/member-photos/:memberId           → multipart, image/jpeg|png|webp, 10MB
DELETE /api/uploads/member-photos/photo/:photoId
POST   /api/uploads/member-photos/:memberId/reorder   { photoIds: uuid[] }
POST   /api/uploads/audio/:entityId                   → multipart, audio/*, 50MB

Magic tokens (accès public membres/orgas)

POST   /api/magic-tokens                  { scope, scopeId, action?, expiresInSeconds? }
POST   /api/magic-tokens/:id/revoke
GET    /api/magic-tokens/by-scope/:scopeId

GET    /api/public/catalogs/:token        → accès anonyme au catalogue envoyé

Webhooks publics (hors /api)

POST   /webhooks/resend                   ← events Resend (email.sent, delivered, opened, bounced...)
                                            Signature Svix vérifiée si RESEND_WEBHOOK_SECRET est set

GET    /webhooks/whatsapp                 ← verification challenge Meta (hub.verify_token)
POST   /webhooks/whatsapp                 ← messages inbound + status updates (delivered/read/failed)

Health probes (hors /api)

GET    /health                            ← { status: "ok", timestamp }
GET    /health/db                         ← SELECT 1 + latencyMs, 503 si erreur
GET    /health/ai                         ← provider: "anthropic" | "mock", degraded flag

Consommés par Uptime Kuma pour le monitoring externe (voir operations/monitoring). /health/ai ne fait pas d'appel à l'API Claude, il vérifie juste que ANTHROPIC_API_KEY est présent et retourne degraded: true quand on tourne en mode mock (dev/CI). Zéro coût par probe.

Rate limiting

Deux couches complémentaires :

Global, rateLimit({ maxRequests: 100, windowMs: 60_000 }) mounted à la racine. 100 req/min par IP sur toute l'API.

Scoped, createScopedLimiter(scope, config) dans lib/rate-limit.ts, attaché par route sensible via beforeHandle. Les scopes actuels sont dans lib/rate-limit-presets.ts :

ScopeLimiteAttaché à
auth10/min/api/auth/* (brute force login)
webhook60/min/webhooks/resend, /webhooks/whatsapp (retries Meta/Resend)
send20/min/api/communications/email/send, /api/whatsapp/send
ai30/min/api/ai/chat/messages (cap le LLM spend par IP)

L'état est stocké en mémoire par process, quand on scalera horizontalement, on swappera pour Redis.

Logger structuré

pino centralisé dans lib/logger.ts. Trois modes :

  • dev : pino-pretty colorisé, level debug
  • test : silent
  • prod : JSON sur stdout, level info (prêt pour Loki/Grafana scraping)

Convention d'utilisation :

import { logger } from "../../lib/logger";

logger.info({ userId, requestId }, "ai: message sent");
logger.warn({ code }, "webhook: signature missing");
logger.error({ err, context }, "provider: call failed");

Jamais de console.* dans apps/server/src/. Les logs HTTP (access log) restent gérés par logixlysia qui compose au-dessus.

Streaming (chat IA)

Pas encore wire, le chat retourne la réponse finale en one-shot, pas en SSE. Le SDK @anthropic-ai/sdk supporte stream: true + beta.messages.toolRunner avec streaming, qu'on branchera quand le front aura son UI de chat prête.

OpenAPI / Swagger

Auto-généré via @elysiajs/swagger (UI Scalar). Disponible à /docs avec toutes les routes groupées par tag module (Members, Rooms, Stats, WhatsApp, Embeddings, etc.). Sert aussi de documentation pour l'IA quand on lui décrit l'outillage disponible.

Tests

Elysia s'instancie sans serveur HTTP, on appelle directement app.handle(new Request(...)). Pas besoin de supertest.

import { app } from "@/app";

test("GET /api/stats/overview returns a snapshot", async () => {
  const res = await app.handle(
    new Request("http://localhost/api/stats/overview"),
  );
  expect(res.status).toBe(200);
});

La vraie stratégie de test actuelle :

  • Tests unitaires dans chaque module (*.service.test.ts) qui mockent le repository local avec mock.module("./xxx.repository", ...). Jamais de mock.module("@reciprok/db") car le mock persiste globalement dans Bun et contamine les autres fichiers de test.
  • Tests d'intégration dans src/__tests__/*.integration.test.ts qui tapent dans une vraie Postgres de test (DATABASE_URL=postgres://postgres:password@localhost:5432/reciprok_test), avec les providers externes (Claude, Resend, WhatsApp, embeddings) en mode mock automatique.
  • 228 tests au total, tous verts, aucun .skip non tracké.

Voir aussi

  • ADR-04 : Eden Treaty au lieu de tRPC
  • intelligence/domain-events : codes d'action déterministes utilisés par les tools IA
  • operations/auth : modes d'authentification
  • operations/concurrency : optimistic locking sur les mutations critiques

On this page