Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
name: CI
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm install
- run: npm run typecheck
- run: npm test
- run: npm run build -w @kladrichat/web
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
node_modules/
.next/
.expo/
dist/
coverage/
*.log
.env
.env.local

Empty file removed .gitkeep
Empty file.
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Kladrichat

Messagerie privée et d’équipe, mobile en priorité, qui transforme les échanges explicitement autorisés en mémoire de travail par projet.

## État de cette première tranche

Cette branche initialise le monorepo et une **tranche technique de KC-001** : modèle multi-organisation, invitations, conversations privées, enveloppes chiffrées côté client, livraison idempotente et diffusion WebSocket. Il ne s’agit pas encore d’une livraison de KC-001 complète : l’authentification de production, la persistance PostgreSQL, le protocole E2EE audité, l’association d’appareils et les builds signés restent à réaliser.

## Démarrage

Prérequis : Node.js 22+ et npm 11+.

```bash
npm install
npm run dev:api
# dans un second terminal
npm run dev:web
```

- API : `http://localhost:4000`
- Web : `http://localhost:3000`
- Mobile Expo : `npm run dev:mobile`

Les détails, limites et commandes par application sont dans [`docs/developpement.md`](docs/developpement.md).

## Vérifications

```bash
npm run typecheck
npm test
```

## Documentation

- [Cadrage produit](docs/cadrage-produit.md)
- [Architecture](docs/architecture.md)
- [Backlog](docs/backlog.md)
- [Décisions d’architecture](docs/adr/README.md)
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"name":"@kladrichat/api","version":"0.1.0","private":true,"type":"module","scripts":{"dev":"tsx watch src/server.ts","start":"tsx src/server.ts","test":"tsx --test test/**/*.test.ts","typecheck":"tsc --noEmit"},"dependencies":{"@fastify/cors":"10.1.0","@fastify/websocket":"11.2.0","@kladrichat/contracts":"0.1.0","@kladrichat/domain":"0.1.0","fastify":"5.6.0","zod":"3.25.76"},"devDependencies":{"tsx":"4.20.5","typescript":"5.9.2"}}
72 changes: 72 additions & 0 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import cors from "@fastify/cors";
import websocket from "@fastify/websocket";
import { SendMessageSchema, type RealtimeEvent } from "@kladrichat/contracts";
import { DomainError, InMemoryKladrichat } from "@kladrichat/domain";
import Fastify from "fastify";
import { z } from "zod";

const identitySchema = z.object({ email: z.string().email(), displayName: z.string().min(1).max(80) });
const organizationSchema = z.object({ name: z.string().min(1).max(120) });
const inviteSchema = z.object({ email: z.string().email(), role: z.enum(["admin", "member"]).default("member") });
const privateConversationSchema = z.object({ otherIdentityId: z.string().uuid() });

export function buildApp(service = new InMemoryKladrichat()) {
const app = Fastify({ logger: false });
const sockets = new Map<string, Set<{ send(data: string): void; readyState: number }>>();
app.register(cors, { origin: true });
app.register(websocket);

app.setErrorHandler((error, _request, reply) => {
if (error instanceof DomainError) {
const status = error.code === "FORBIDDEN" ? 403 : error.code === "NOT_FOUND" ? 404 : 409;
return reply.status(status).send({ error: error.code, message: error.message });
}
if (error instanceof z.ZodError) return reply.status(400).send({ error: "INVALID", issues: error.issues });
return reply.status(500).send({ error: "INTERNAL" });
});

function actor(request: { headers: { authorization?: string } }): string {
const token = request.headers.authorization?.replace(/^Bearer\s+/i, "");
if (!token || !service.identities.has(token)) throw new DomainError("FORBIDDEN", "Session requise");
return token;
}

app.get("/health", async () => ({ status: "ok", storage: "memory" }));
// Session de développement explicite : à remplacer par OIDC/passkeys avant production.
app.post("/dev/sessions", async (request) => {
const input = identitySchema.parse(request.body);
const identity = service.createIdentity(input.email, input.displayName);
return { accessToken: identity.id, identity };
});
app.post("/organizations", async (request) => ({ organization: service.createOrganization(actor(request), organizationSchema.parse(request.body).name) }));
app.post<{ Params: { organizationId: string } }>("/organizations/:organizationId/invitations", async (request) => {
const input = inviteSchema.parse(request.body);
return { invitation: service.invite(actor(request), request.params.organizationId, input.email, input.role) };
});
app.post<{ Params: { token: string } }>("/invitations/:token/accept", async (request) => ({ membership: service.acceptInvitation(actor(request), request.params.token) }));
app.get<{ Params: { organizationId: string } }>("/organizations/:organizationId/directory", async (request) => ({ identities: service.directory(actor(request), request.params.organizationId) }));
app.post<{ Params: { organizationId: string } }>("/organizations/:organizationId/private-conversations", async (request) => ({ conversation: service.createPrivateConversation(actor(request), request.params.organizationId, privateConversationSchema.parse(request.body).otherIdentityId) }));
app.get<{ Params: { conversationId: string }; Querystring: { after?: string } }>("/conversations/:conversationId/messages", async (request) => ({ messages: service.listMessages(actor(request), request.params.conversationId, Number(request.query.after ?? 0)) }));
app.post<{ Params: { conversationId: string } }>("/conversations/:conversationId/messages", async (request) => {
const actorId = actor(request);
const input = SendMessageSchema.parse(request.body);
const result = service.sendMessage(actorId, request.params.conversationId, input.clientMessageId, input.envelope);
if (result.created) {
const conversation = service.conversations.get(request.params.conversationId)!;
const event: RealtimeEvent = { version: 1, type: "message.created", organizationId: conversation.organizationId, message: result.message };
for (const participant of conversation.participantIds) for (const socket of sockets.get(participant) ?? []) if (socket.readyState === 1) socket.send(JSON.stringify(event));
}
return { message: result.message, duplicated: !result.created };
});
app.get("/realtime", { websocket: true }, (socket, request) => {
const url = new URL(request.url, "http://localhost");
const actorId = url.searchParams.get("access_token");
if (!actorId || !service.identities.has(actorId)) return socket.close(1008, "Session requise");
const identitySockets = sockets.get(actorId) ?? new Set();
identitySockets.add(socket);
sockets.set(actorId, identitySockets);
socket.on("close", () => identitySockets.delete(socket));
});

return { app, service };
}
6 changes: 6 additions & 0 deletions apps/api/src/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { buildApp } from "./app.ts";

const port = Number(process.env.PORT ?? 4000);
const { app } = buildApp();
await app.listen({ host: "0.0.0.0", port });
console.log(`Kladrichat API listening on http://localhost:${port}`);
35 changes: 35 additions & 0 deletions apps/api/test/api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildApp } from "../src/app.ts";

test("parcours API invitation, dialogue chiffré et reprise idempotente", async () => {
const { app } = buildApp();
const session = async (email: string) => (await app.inject({ method: "POST", url: "/dev/sessions", payload: { email, displayName: email } })).json();
const owner = await session("owner@example.com");
const member = await session("member@example.com");
const auth = (token: string) => ({ authorization: `Bearer ${token}` });
const organization = (await app.inject({ method: "POST", url: "/organizations", headers: auth(owner.accessToken), payload: { name: "Kladriva" } })).json().organization;
const invitation = (await app.inject({ method: "POST", url: `/organizations/${organization.id}/invitations`, headers: auth(owner.accessToken), payload: { email: "member@example.com" } })).json().invitation;
assert.equal((await app.inject({ method: "POST", url: `/invitations/${invitation.token}/accept`, headers: auth(member.accessToken) })).statusCode, 200);
const conversation = (await app.inject({ method: "POST", url: `/organizations/${organization.id}/private-conversations`, headers: auth(owner.accessToken), payload: { otherIdentityId: member.identity.id } })).json().conversation;
const payload = { clientMessageId: "69818865-18d7-49c4-97fe-45288a7d38c6", envelope: { algorithm: "AES-256-GCM.v1", ciphertext: "opaque", nonce: "nonce" } };
const first = await app.inject({ method: "POST", url: `/conversations/${conversation.id}/messages`, headers: auth(owner.accessToken), payload });
const retry = await app.inject({ method: "POST", url: `/conversations/${conversation.id}/messages`, headers: auth(owner.accessToken), payload });
assert.equal(first.statusCode, 200);
assert.equal(retry.json().duplicated, true);
const received = (await app.inject({ method: "GET", url: `/conversations/${conversation.id}/messages?after=0`, headers: auth(member.accessToken) })).json().messages;
assert.equal(received.length, 1);
assert.equal(received[0].envelope.ciphertext, "opaque");
await app.close();
});

test("refuse l’accès d’une autre organisation", async () => {
const { app, service } = buildApp();
const alice = service.createIdentity("alice@example.com", "Alice");
const bob = service.createIdentity("bob@example.com", "Bob");
const org = service.createOrganization(alice.id, "A");
service.createOrganization(bob.id, "B");
const response = await app.inject({ method: "GET", url: `/organizations/${org.id}/directory`, headers: { authorization: `Bearer ${bob.id}` } });
assert.equal(response.statusCode, 403);
await app.close();
});
1 change: 1 addition & 0 deletions apps/api/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"extends":"../../tsconfig.base.json","compilerOptions":{"noEmit":true,"types":["node"],"allowImportingTsExtensions":true},"include":["src","test"]}
1 change: 1 addition & 0 deletions apps/mobile/app.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"expo":{"name":"Kladrichat","slug":"kladrichat","version":"0.1.0","scheme":"kladrichat","plugins":["expo-router"],"ios":{"bundleIdentifier":"com.kladriva.kladrichat"},"android":{"package":"com.kladriva.kladrichat"}}}
2 changes: 2 additions & 0 deletions apps/mobile/app/_layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import { Stack } from "expo-router";
export default function Layout() { return <Stack screenOptions={{ headerStyle: { backgroundColor: "#176b51" }, headerTintColor: "#fff" }} />; }
6 changes: 6 additions & 0 deletions apps/mobile/app/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { FlatList, StyleSheet, Text, View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";

const conversations = [{ name: "Awa Mensah", message: "Le document est prêt pour validation", time: "09:42" }, { name: "Équipe produit", message: "Fatou : je vérifie le besoin", time: "Hier" }, { name: "David Koffi", message: "Message vocal · 0:34", time: "Lun." }];
export default function Home() { return <SafeAreaView style={styles.screen}><View style={styles.title}><Text style={styles.eyebrow}>ESPACE KLADRIVA</Text><Text style={styles.heading}>Kladrichat</Text></View><View style={styles.status}><Text style={styles.statusText}>✓ Connecté · messages synchronisés</Text></View><FlatList data={conversations} keyExtractor={(item) => item.name} renderItem={({ item }) => <View style={styles.row}><View style={styles.avatar}><Text style={styles.initial}>{item.name.slice(0, 2).toUpperCase()}</Text></View><View style={styles.copy}><Text style={styles.name}>{item.name}</Text><Text numberOfLines={1} style={styles.message}>{item.message}</Text></View><Text style={styles.time}>{item.time}</Text></View>} /></SafeAreaView>; }
const styles = StyleSheet.create({ screen:{flex:1,backgroundColor:"#fff"},title:{paddingHorizontal:20,paddingVertical:16},eyebrow:{fontSize:10,letterSpacing:1.5,fontWeight:"700",color:"#176b51"},heading:{fontSize:27,fontWeight:"700",color:"#17201d"},status:{backgroundColor:"#e9f7f0",padding:8,alignItems:"center"},statusText:{fontSize:12,color:"#176b51"},row:{flexDirection:"row",alignItems:"center",gap:12,paddingHorizontal:20,paddingVertical:15,borderBottomWidth:1,borderBottomColor:"#edf1ef"},avatar:{width:48,height:48,borderRadius:24,backgroundColor:"#d8eee5",alignItems:"center",justifyContent:"center"},initial:{fontWeight:"700",color:"#176b51"},copy:{flex:1,gap:4},name:{fontWeight:"700",fontSize:16,color:"#17201d"},message:{color:"#62706a"},time:{fontSize:11,color:"#738079"}});
1 change: 1 addition & 0 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"name":"@kladrichat/mobile","version":"0.1.0","private":true,"main":"expo-router/entry","scripts":{"start":"expo start","android":"expo start --android","ios":"expo start --ios","typecheck":"tsc --noEmit"},"dependencies":{"expo":"54.0.1","expo-router":"6.0.0","react":"19.1.0","react-native":"0.81.1","react-native-safe-area-context":"5.6.0","react-native-screens":"4.16.0"},"devDependencies":{"@types/react":"19.1.10","typescript":"5.9.2"}}
1 change: 1 addition & 0 deletions apps/mobile/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"extends":"expo/tsconfig.base","compilerOptions":{"strict":true},"include":["**/*.ts","**/*.tsx",".expo/types/**/*.ts","expo-env.d.ts"]}
1 change: 1 addition & 0 deletions apps/recorder/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"name":"@kladrichat/recorder","version":"0.1.0","private":true,"type":"module","scripts":{"start":"tsx src/index.ts","typecheck":"tsc --noEmit"},"devDependencies":{"tsx":"4.20.5","typescript":"5.9.2"}}
2 changes: 2 additions & 0 deletions apps/recorder/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// Frontière inactive : ce processus n'enregistre rien tant que consentement, média et crypto ne sont pas validés.
console.log("Kladrichat recorder: désactivé");
1 change: 1 addition & 0 deletions apps/recorder/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"extends":"../../tsconfig.base.json","compilerOptions":{"noEmit":true},"include":["src"]}
5 changes: 5 additions & 0 deletions apps/web/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import type { Metadata } from "next";
import "./styles.css";

export const metadata: Metadata = { title: "Kladrichat", description: "Messagerie d’équipe privée" };
export default function Layout({ children }: Readonly<{ children: React.ReactNode }>) { return <html lang="fr"><body>{children}</body></html>; }
22 changes: 22 additions & 0 deletions apps/web/app/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
const conversations = [
["AM", "Awa Mensah", "Le document est prêt pour validation", "09:42"],
["ÉP", "Équipe produit", "Fatou : je vérifie le besoin", "Hier"],
["DK", "David Koffi", "Message vocal · 0:34", "Lun."],
];

export default function Home() {
return <main className="shell">
<aside className="sidebar">
<header><div><span className="eyebrow">ESPACE KLADRIVA</span><h1>Kladrichat</h1></div><button aria-label="Nouvelle conversation">+</button></header>
<label className="search"><span>⌕</span><input aria-label="Rechercher" placeholder="Rechercher une conversation" /></label>
<nav aria-label="Conversations">{conversations.map(([initials, name, preview, time], index) => <a className={index === 0 ? "active" : ""} href="#conversation" key={name}><span className="avatar">{initials}</span><span className="summary"><strong>{name}</strong><small>{preview}</small></span><time>{time}</time></a>)}</nav>
<footer><span className="avatar me">NK</span><span><strong>Nadia K.</strong><small>Disponible</small></span><button aria-label="Réglages">⚙</button></footer>
</aside>
<section className="conversation" id="conversation">
<header><span className="avatar">AM</span><div><strong>Awa Mensah</strong><small><i /> En ligne</small></div><div className="actions"><button aria-label="Appel audio">⌕</button><button aria-label="Informations">ⓘ</button></div></header>
<div className="network">✓ Connecté · messages synchronisés</div>
<div className="messages"><p className="date">AUJOURD’HUI</p><article><p>Bonjour Nadia, peux-tu confirmer les priorités de cette semaine&nbsp;?</p><time>09:34</time></article><article className="mine"><p>Oui. La validation du parcours d’invitation passe en premier. Je t’envoie le détail.</p><time>09:36 ✓✓</time></article><article><p>Parfait, le document est prêt pour validation.</p><time>09:42</time></article></div>
<form className="composer"><button type="button" aria-label="Joindre un fichier">+</button><input aria-label="Message" placeholder="Écrire un message" /><button type="submit" aria-label="Envoyer">➤</button></form>
</section>
</main>;
}
1 change: 1 addition & 0 deletions apps/web/app/styles.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions apps/web/next-env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"name":"@kladrichat/web","version":"0.1.0","private":true,"scripts":{"dev":"next dev","build":"next build","start":"next start","typecheck":"tsc --noEmit"},"dependencies":{"@kladrichat/design-tokens":"0.1.0","next":"15.5.2","react":"19.1.1","react-dom":"19.1.1"},"devDependencies":{"@types/node":"22.18.0","@types/react":"19.1.12","@types/react-dom":"19.1.9","typescript":"5.9.2"}}
Loading
Loading