Rukh ENT: technical specification
Julien Béranger
+ Claude Opus 5.5
Summary
Rukh ENT is a fork of Rukh that a French secondary school adds to its ENT (espace numérique de travail) as a connector.
- Staff create and edit course assistants: instructions, documents, links, model.
- Students chat with the assistants published to their school or class.
- Nobody creates an account. Identity and role come from the ENT, and from nowhere else.
The first target is ENT Hauts-de-France, which runs on the Edifice platform.
| Decision | Choice |
|---|---|
| Shape | One fork of Rukh, one process, one domain |
| Authentication | Edifice OAuth 2.0 only. SIWE and wallets are removed |
| Backend | NestJS and TypeScript, as in Rukh |
| Frontend | Vite, React, React Router, Chakra UI |
| Routes | / serves the interface, /api serves Swagger UI |
| Hosting | One OVHcloud VPS |
| Optional | An MCP endpoint at /mcp |
Status of the evidence
This document is based on reading documentation and source code. Nothing has been run against enthdf.fr.
| Claim type | Basis |
|---|---|
| Edifice behavior | Edifice technical documentation and the master branch of entcore. The version deployed on enthdf.fr may differ |
| Rukh behavior | The main branch of Rukh, version 0.2.0, and of rukh-ui |
| Code in this document | Illustrative. Only two snippets were executed: the session token and the document conversion. Each says so |
Everything that still needs a live test is listed under To verify on a test platform.
What changes from upstream Rukh
| Area | Upstream Rukh | Rukh ENT |
|---|---|---|
Access to POST /ask | Public | ENT session required, visibility checked |
Access to GET /context | Public, lists every context | ENT session required, filtered by school and class |
| Context ownership | creatorAddress, proven by a SIWE signature (guard) | ownerId, the ENT user id from the session |
| Dependencies | w3pk, ethers | Removed where nothing else uses them |
sessionId on /ask | Any client-supplied value is accepted (source) | Owned by the server, bound to the user and the assistant |
Rate limit on /ask | 50 per hour per IP, hardcoded (source) | Per user, configurable |
| CORS | Any origin, credentials allowed (source) | Disabled: the interface is same-origin |
GET / | Redirects to /api | Serves the interface |
| Context settings | Model fixed at creation | Editable: model, visibility, description |
| Context files | .md only, 5 MB | Same storage, plus conversion from PDF and office formats |
| Interface | Separate Next.js app | Vite app built into the fork and served by it |
Unchanged: the provider layer, the two-step RAG selection, streaming, the context file layout under data/contexts/, and the rule that instruction-file.md is always sent to the model.
Rukh is licensed under the LGPL-3.0, which the fork keeps. Keeping the ENT code in its own modules makes merges from upstream cheaper.
Architecture
Repository layout
rukh-ent/
├── src/ NestJS application (the fork)
│ ├── ent/ OAuth flow, session, guards, roles
│ ├── import/ conversion to Markdown
│ ├── mcp/ optional MCP endpoint
│ └── … upstream modules
├── web/ Vite single-page app
│ └── dist/ build output, served at /
├── data/ contexts, chat history, conversation index
└── .envRoute map
Upstream API paths are kept, so the fork stays close to Rukh and Swagger stays at /api.
| Path | Served by | Access |
|---|---|---|
/, /assistants/*, /new | Single-page app (static files, fallback to index.html) | Public files; the app redirects to /auth/login without a session |
/api | Swagger UI and the OpenAPI document | Off by default in production (SWAGGER_ENABLED) |
/auth/login, /auth/callback, /auth/logout | ent module | Public |
/me | ent module | Session |
/ask | Upstream controller | Session |
/context, /context/* | Upstream controller | Session; writes need the staff role and ownership |
/web-reader/* | Upstream controller | Staff |
/mcp, /.well-known/* | mcp module, optional | Bearer token |
The client router must never use a path that starts with ask, context, auth, me, api, web-reader, mcp or .well-known.
Swagger UI is mounted outside the NestJS guard chain, so a guard does not protect it. The setting above removes it entirely; exposing it to staff only would need its own middleware.
Authentication
Why OAuth 2.0 and not OpenID Connect
Edifice offers three connector types: OAuth 2.0, OpenID Connect and CAS. The standard OpenID Connect claims returned by Edifice carry a name and an email but no profile type. The OAuth 2.0 userinfo response does, so Rukh ENT uses the authorization code grant with the userinfo scope.
Connector record
Each school's ENT administrator creates an OAuth2 connector in the administration console.
| Console field | Value |
|---|---|
Identifiant (client_id) | rukh |
| URL | https://rukh.example/auth/login |
| Transmettre la session | checked |
| Scope | userinfo |
| Mode d'identification | code |
| Code secret | generated, sent out of band |
| Allowed profiles | teachers, staff, students |
Endpoints
| Step | Request |
|---|---|
| Authorize | GET {ENT}/auth/oauth2/auth?response_type=code&client_id&redirect_uri&scope=userinfo&state |
| Token | POST {ENT}/auth/oauth2/token, client authenticated with HTTP Basic |
| User | GET {ENT}/auth/oauth2/userinfo?version=2.0, Bearer token |
The access token lasts 3600 seconds. Rukh ENT uses it once, to read userinfo, and never stores or refreshes it.
Three facts from the entcore source:
redirect_uri: accepted when its host is the host of a registered application. It is neither an exact match nor a prefix match.- Scope: every requested scope must appear in the connector's declared scope.
- PKCE: the authorize handler reads no PKCE parameter. The
stateparameter and the client secret carry the protection.
OAuth client
// src/ent/ent-oauth.service.ts
const ENT = process.env.ENT_BASE_URL!; // https://enthdf.fr
const CLIENT_ID = process.env.ENT_CLIENT_ID!;
const CLIENT_SECRET = process.env.ENT_CLIENT_SECRET!;
const REDIRECT_URI = process.env.ENT_REDIRECT_URI!; // https://rukh.example/auth/callback
export function authorizeUrl(state: string): string {
const query = new URLSearchParams({
response_type: 'code',
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
scope: 'userinfo',
state,
});
return `${ENT}/auth/oauth2/auth?${query}`;
}
export async function exchangeCode(code: string): Promise<string> {
const basic = Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString('base64');
const res = await fetch(`${ENT}/auth/oauth2/token`, {
method: 'POST',
headers: {
Authorization: `Basic ${basic}`,
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json; charset=UTF-8',
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: REDIRECT_URI,
}),
});
if (!res.ok) throw new Error(`ENT token: ${res.status}`);
return ((await res.json()) as { access_token: string }).access_token;
}
export async function fetchUserInfo(accessToken: string): Promise<EntUserInfo> {
const version = process.env.ENT_USERINFO_VERSION ?? '2.0';
const res = await fetch(`${ENT}/auth/oauth2/userinfo?version=${version}`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!res.ok) throw new Error(`ENT userinfo: ${res.status}`);
return (await res.json()) as EntUserInfo;
}userinfo versions
The response shape depends on a version parameter (source).
| Version | type values | Classes |
|---|---|---|
| 1.0, the default (source) | ENSEIGNANT, ELEVE, PERSEDUCNAT, PERSRELELEVE, SUPERADMIN | classId: the first class only |
| 2.0 | Teacher, Student, Personnel, Relative, SuperAdmin | classNames: all classes |
Rukh ENT requests version 2.0, because a teacher needs all of their classes to choose who sees an assistant. Three rules follow:
- accept both sets of
typevalues; - read only the fields listed below and discard the rest, since version 2.0 returns most of the user's ENT session;
- when
classNamesis missing, fall back toclassId, and publish at school level.
Roles
// src/ent/role.ts
export interface EntUserInfo {
userId: string; // stable UUID
type: string;
uai?: string[]; // school codes
classNames?: string[]; // version 2.0
classId?: string; // version 1.0
}
export type Role = 'staff' | 'student';
const ROLE_BY_TYPE: Record<string, Role> = {
ENSEIGNANT: 'staff',
Teacher: 'staff',
PERSEDUCNAT: 'staff',
Personnel: 'staff',
ELEVE: 'student',
Student: 'student',
};
export function roleOf(info: EntUserInfo): Role | null {
return ROLE_BY_TYPE[info.type] ?? null; // null: access refused
}| ENT profile | Role | Rights |
|---|---|---|
| Teacher | staff | Create, edit, publish and delete their own assistants; chat |
| Non-teaching staff (librarians, CPE…) | staff | Same as teachers |
| Student | student | Chat with the assistants visible to them |
| Parent, super-admin | none | Refused with a clear page |
Non-teaching staff usually have no class. In version 1 they publish to the whole school.
Session
The session is a signed JWT in a cookie, built with jose. No session store is needed.
// src/ent/session.ts
import { SignJWT, jwtVerify } from 'jose';
const key = new TextEncoder().encode(process.env.SESSION_SECRET);
const IDLE = Number(process.env.SESSION_IDLE_SECONDS ?? 1800);
const MAX = Number(process.env.SESSION_MAX_SECONDS ?? 28800);
export interface SessionUser {
id: string;
role: 'staff' | 'student';
uai: string[];
classes: string[];
startedAt: number;
}
export async function seal(
user: Omit<SessionUser, 'startedAt'>,
startedAt = Math.floor(Date.now() / 1000),
): Promise<string> {
return new SignJWT({ role: user.role, uai: user.uai, classes: user.classes, sat: startedAt })
.setProtectedHeader({ alg: 'HS256' })
.setSubject(user.id)
.setIssuedAt()
.setExpirationTime(`${IDLE}s`)
.sign(key);
}
export async function unseal(token: string): Promise<SessionUser> {
const { payload } = await jwtVerify(token, key, { algorithms: ['HS256'] });
const now = Math.floor(Date.now() / 1000);
if (typeof payload.sat !== 'number' || now - payload.sat > MAX) {
throw new Error('session too old');
}
return {
id: payload.sub as string,
role: payload.role as SessionUser['role'],
uai: payload.uai as string[],
classes: payload.classes as string[],
startedAt: payload.sat,
};
}This snippet was executed with jose 6: a fresh token round-trips, a token past the maximum age is rejected, and a tampered token fails signature verification.
| Property | Value |
|---|---|
| Cookie name | __Host-rukh |
| Attributes | HttpOnly, Secure, SameSite=Lax, Path=/, no Expires (cleared when the browser closes) |
| Idle timeout | 30 minutes; the cookie is re-issued on each authenticated request |
| Maximum age | 8 hours from login |
| Contents | ENT user id, role, school codes, class names. No name, login or email |
Login and callback
// src/ent/auth.controller.ts
@Public()
@Controller('auth')
export class AuthController {
@Get('login')
login(@Res() res: Response) {
const state = randomBytes(16).toString('hex');
res.cookie('__Host-rukh-state', state, STATE_COOKIE); // HttpOnly, Secure, Lax, 10 min
res.redirect(authorizeUrl(state));
}
@Get('callback')
async callback(
@Query('code') code: string,
@Query('state') state: string,
@Req() req: Request,
@Res() res: Response,
) {
if (!code || !state || state !== req.cookies['__Host-rukh-state']) {
throw new UnauthorizedException('invalid state');
}
res.clearCookie('__Host-rukh-state', STATE_COOKIE);
const info = await fetchUserInfo(await exchangeCode(code));
const role = roleOf(info);
if (!role) throw new ForbiddenException('profile not allowed');
res.cookie(
'__Host-rukh',
await seal({
id: info.userId,
role,
uai: info.uai ?? [],
classes: info.classNames ?? (info.classId ? [info.classId] : []),
}),
SESSION_COOKIE,
);
res.redirect('/');
}
@Post('logout')
logout(@Res() res: Response) {
res.clearCookie('__Host-rukh', SESSION_COOKIE);
res.status(204).end();
}
}/auth/login always starts a new flow and overwrites any existing session. On a shared classroom computer, the next student who opens the connector from their own ENT session gets their own identity.
Guards
Access is denied by default. A global guard requires a session on every route, and routes opt out with @Public().
// src/ent/ent-session.guard.ts
@Injectable()
export class EntSessionGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
async canActivate(ctx: ExecutionContext): Promise<boolean> {
const targets = [ctx.getHandler(), ctx.getClass()];
if (this.reflector.getAllAndOverride<boolean>('public', targets)) return true;
const req = ctx.switchToHttp().getRequest<Request & { user?: SessionUser }>();
const res = ctx.switchToHttp().getResponse<Response>();
// Cookies are sent automatically, so state-changing requests must come from our own pages.
if (req.method !== 'GET' && req.headers.origin !== process.env.PUBLIC_ORIGIN) {
throw new ForbiddenException('cross-origin request');
}
let user: SessionUser;
try {
user = await unseal(req.cookies['__Host-rukh']);
} catch {
throw new UnauthorizedException();
}
const roles = this.reflector.getAllAndOverride<Role[]>('roles', targets);
if (roles && !roles.includes(user.role)) throw new ForbiddenException();
req.user = user;
res.cookie('__Host-rukh', await seal(user, user.startedAt), SESSION_COOKIE); // sliding
return true;
}
}It is registered once with APP_GUARD, so a new route added later is protected without anyone remembering to protect it.
Logout
| Level | Behavior |
|---|---|
| Version 1 | Logout button, idle timeout, browser-session cookie, and a fresh flow on every entry from the ENT |
| Later, if available | Edifice's back-channel logout: the ENT calls a URL with a signed logout token. It needs the openid scope, a logout URL on the connector record, and a server-side list of revoked sessions |
Assistants
An assistant is a Rukh context plus ENT metadata.
Context index
data/contexts/<name>/index.json gains four fields and loses two.
{
"name": "hdf-0590123a-ses-terminale-k3x9p2",
"description": "Économie, terminale : la monnaie et le financement",
"model": "mistral",
"ownerId": "2bacdfd2-b59c-4b21-a23e-f6346e02fc4a",
"uai": "0590123A",
"classes": ["TES1", "TES2"],
"published": true,
"numberOfFiles": 2,
"totalSize": 15,
"files": [],
"links": [],
"queries": []
}| Field | Meaning |
|---|---|
ownerId | ENT user id of the creator. Replaces creatorAddress |
uai | School the assistant belongs to |
classes | Classes that can see it. Empty means the whole school |
published | Drafts are visible to their owner only |
creatorName | Removed: no names are stored |
Context names match ^[a-z0-9-]+$ upstream. The fork generates them: hdf-<uai>-<slug>-<6 random characters>.
Access rules
| Action | Rule |
|---|---|
| Read or chat | Owner; or published, and uai is one of the user's schools, and classes is empty or shares a class with the user |
| Create | Role staff. uai must be one of the user's schools |
| Edit, upload, delete | Role staff and ownerId equals the session user id |
A context the user cannot see returns 404, not 403, so names do not leak.
API changes
| Route | Change |
|---|---|
GET /context | Returns only the contexts visible to the caller |
POST /context | Body: name generated server-side, description, model, classes. ownerId and uai come from the session |
PATCH /context/:name | New. Updates description, model, classes, published |
POST /context/upload, DELETE /context/:name/file, link routes | Ownership checked against the session instead of a signature |
POST /context/:name/import | New. Converts a file to Markdown and returns the draft without saving it |
POST /ask | context is required. sessionId from the client is ignored |
DELETE /ask/conversation/:context | New. Starts a fresh conversation |
GET /me | New. Role, schools, classes and allowed models, for the interface |
Model choice
Staff choose the model per assistant, among the values of ENT_ALLOWED_MODELS. Rukh supports mistral, anthropic, anthropic-web-search, openai and deepseek (details), backed by Mistral, Anthropic, OpenAI and DeepSeek. A provider without an API key on the instance is skipped.
The interface shows the provider next to each model, because student messages are sent to that provider.
Conversations
Upstream accepts any sessionId sent by the client, which would let one user continue another's conversation. In the fork, the server owns the mapping.
// src/ent/conversation.service.ts
// data/ent-conversations.json : { "<userId>:<context>": "<rukh sessionId>" }
async sessionFor(userId: string, context: string): Promise<string> {
const index = await this.store.read<Record<string, string>>();
const key = `${userId}:${context}`;
if (!index[key]) {
index[key] = randomUUID();
await this.store.write(index);
}
return index[key];
}The /ask handler sets askDto.sessionId from this service before calling the upstream ask service, which otherwise stays untouched. The store is Rukh's own JSON store with atomic writes; writes to it must be serialized.
Rate limiting
The upstream throttler tracks by IP. Students of one school often share an IP, so the fork tracks by user and reads its limits from the environment.
// src/throttler.guard.ts
protected async getTracker(req: Request & { user?: SessionUser }): Promise<string> {
return req.user?.id ?? req.ip;
}Query log
Upstream appends every question to queries in index.json, with the message text. In the fork, ENT_LOG_QUERIES=false by default: the entry keeps the timestamp and the files used, and drops the message.
Document import
Rukh stores Markdown only. The fork converts what staff upload.
| Format | Conversion |
|---|---|
.md, .txt | None |
.html | turndown |
.docx | mammoth to HTML, then turndown |
.pdf | unpdf, text extraction page by page |
.odt, .doc, .rtf, .pptx, .odp | Headless LibreOffice to .docx or .pdf, then the chain above |
// src/import/to-markdown.ts
import { extname } from 'node:path';
import { extractText, getDocumentProxy } from 'unpdf';
import mammoth from 'mammoth';
import TurndownService from 'turndown';
const turndown = new TurndownService({ headingStyle: 'atx', bulletListMarker: '-' });
export async function toMarkdown(filename: string, buffer: Buffer): Promise<string> {
switch (extname(filename).toLowerCase()) {
case '.md':
case '.txt':
return buffer.toString('utf-8');
case '.html':
return turndown.turndown(buffer.toString('utf-8'));
case '.docx': {
const { value: html } = await mammoth.convertToHtml({ buffer });
return turndown.turndown(html);
}
case '.pdf': {
const pdf = await getDocumentProxy(new Uint8Array(buffer));
const { text } = await extractText(pdf, { mergePages: false });
const pages = text.map((page) => page.trim()).filter(Boolean);
if (pages.length === 0) throw new Error('PDF has no text layer');
return pages.join('\n\n');
}
default:
throw new Error('unsupported format');
}
}This snippet was executed on a test PDF and a test DOCX. The DOCX kept its headings and lists; the PDF came out as plain text. The LibreOffice branch was not tested.
Rules:
- Review. Conversion loses layout (tables, formulas, PDF columns). The import route returns a draft that the author edits before saving.
- Scanned PDFs. A PDF with no text layer is refused with a clear message. OCR is out of scope for version 1.
- Size. Rukh recommends files of 5 to 50 KB. Above that, the interface offers to split on top-level headings.
- Description. Each file needs a description, because the RAG step selects files by their descriptions.
- Original. The source file is not kept.
- Safety. 20 MB limit on import, a timeout per conversion, and LibreOffice run in an isolated process without network access.
Interface
Stack
| Piece | Choice |
|---|---|
| Build | Vite |
| UI | React and Chakra UI 3, as in rukh-ui, so its components can be copied over |
| Routing | React Router |
| Markdown | react-markdown, as in rukh-ui |
rukh-ui is fully client-side: no API routes, no server actions, no middleware. Porting it means replacing the Next.js file routes with a router, and removing everything tied to wallets: the w3pk provider, the login button, the password modal, build verification and the settings page.
Edifice's own frontend framework is not used. It targets applications that run inside the ENT, and at least one of its packages is published under AGPL-3.0.
Routes
| Path | Screen | Role |
|---|---|---|
/ | Assistants visible to the user; for staff, their own assistants and drafts first | All |
/assistants/:name | Chat | All |
/new | Create an assistant: title, model, classes | Staff |
/assistants/:name/edit | Instructions, documents with import and review, links, visibility, publish, delete | Owner |
// web/src/main.tsx
const router = createBrowserRouter([
{
element: <Shell />, // loads /me, redirects to /auth/login on 401
children: [
{ path: '/', element: <Home /> },
{ path: '/assistants/:name', element: <Chat /> },
{ path: '/new', element: <StaffOnly><NewAssistant /></StaffOnly> },
{ path: '/assistants/:name/edit', element: <StaffOnly><EditAssistant /></StaffOnly> },
],
},
]);StaffOnly is a convenience for the interface. The server enforces every rule again.
Development proxy
// web/vite.config.ts
export default defineConfig({
plugins: [react()],
server: {
proxy: Object.fromEntries(
['/ask', '/context', '/auth', '/me', '/api', '/web-reader', '/mcp'].map((path) => [
path,
'http://localhost:3000',
]),
),
},
});See Vite's server.proxy.
Serving the build from NestJS
// src/app.module.ts
ServeStaticModule.forRoot({
rootPath: join(process.cwd(), 'web', 'dist'),
// Wildcard syntax depends on the Express version in use: test each path.
exclude: ['/ask{*any}', '/context{*any}', '/auth{*any}', '/me', '/api{*any}',
'/web-reader{*any}', '/mcp', '/.well-known{*any}'],
}),@nestjs/serve-static serves the files and falls back to index.html for client routes. Two more changes in the fork:
- remove the
GET /redirect to/apiinapp.controller.ts; - remove
enableCorsinmain.ts.
Streaming in the browser
Rukh streams with server-sent events over a POST, so the client reads the body with fetch rather than EventSource.
const form = new FormData();
form.set('message', message);
form.set('context', name);
form.set('stream', 'true');
const res = await fetch('/ask', { method: 'POST', body: form });
const reader = res.body!.pipeThrough(new TextDecoderStream()).getReader();
let buffer = '';
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buffer += value;
const frames = buffer.split('\n\n');
buffer = frames.pop()!;
for (const frame of frames) {
const event = frame.match(/^event: (.+)$/m)?.[1];
const data = frame.match(/^data: (.+)$/m)?.[1];
if (!event || !data) continue; // ": ping" keep-alive comments
if (event === 'chunk') append(JSON.parse(data).text);
if (event === 'reset') clear();
if (event === 'error') fail(JSON.parse(data));
}
}Events are chunk, thinking, reset, done and error.
Optional: MCP endpoint
With MCP_ENABLED=true, the same process also answers the Model Context Protocol, so an LLM client can use the assistants as tools. This part is independent and comes last.
Transport
Streamable HTTP on a single endpoint, /mcp, built with the official TypeScript SDK. The specification requires the server to validate the Origin header.
Tools
| Tool | Role | Maps to |
|---|---|---|
list_assistants | All | GET /context |
ask_assistant | All | POST /ask |
create_assistant | Staff | POST /context |
update_assistant | Owner | PATCH /context/:name |
put_document | Owner | POST /context/upload |
add_link | Owner | POST /context/:name/link |
Each tool calls the same services as the HTTP routes, with the same access rules.
Authorization
The MCP authorization specification expects an OAuth 2.1 authorization server with PKCE, server metadata, protected resource metadata, tokens bound to the server with a resource indicator, and preferably dynamic client registration.
Edifice cannot play that role directly: its authorize handler reads no PKCE parameter, its documentation describes no dynamic registration, and it only redirects to hosts of registered applications, which excludes an MCP client's callback.
So Rukh ENT is its own authorization server for MCP, and delegates the user's authentication to the ENT:
- The MCP client discovers
/.well-known/oauth-protected-resource, then the authorization server metadata. - It registers, then opens Rukh ENT's
/authorizewith a PKCE challenge. - Rukh ENT sends the user through the ENT flow described above.
- Rukh ENT shows a consent screen naming the client, as the specification requires of a server that proxies to a third-party authorization server.
- Rukh ENT issues its own short-lived token, bound to
/mcp, carrying the ENT user id and role.
Identity still comes only from the ENT. The ENT access token is never passed to the MCP client.
MCP_ROLES=staff by default. Opening MCP to students means minors' requests reach whichever LLM client they connect, which is a decision for the school.
Deployment on an OVHcloud VPS
Layout
| Layer | Choice |
|---|---|
| Server | One OVHcloud VPS in a French datacenter, Debian stable |
| Runtime | Node.js LTS, pnpm |
| Process | A systemd service under a dedicated user, listening on 127.0.0.1:3000 |
| TLS and proxy | Caddy, with certificates from Let's Encrypt |
| Firewall | Ports 22, 80 and 443 only |
| Extra packages | LibreOffice for conversion; Chromium if assistants use links, since Rukh's web reader drives it through Puppeteer |
Model inference runs at the provider, so the server's load comes mostly from document conversion and the web reader. Size the VPS after a load test rather than up front.
Reverse proxy
rukh.example {
encode zstd gzip
reverse_proxy 127.0.0.1:3000 {
flush_interval -1
}
}flush_interval -1 disables response buffering in Caddy's reverse_proxy, which streaming needs. In main.ts, set Express's trust proxy to 1 so the client IP is read from the proxy header.
Service
# /etc/systemd/system/rukh-ent.service
[Unit]
Description=Rukh ENT
After=network-online.target
[Service]
User=rukh
WorkingDirectory=/srv/rukh-ent
EnvironmentFile=/etc/rukh-ent.env
ExecStart=/usr/bin/node dist/main
Restart=on-failure
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=/srv/rukh-ent/data
PrivateTmp=true
[Install]
WantedBy=multi-user.targetRukh resolves data/ from the working directory, so WorkingDirectory decides where everything is stored.
Release
pnpm install --frozen-lockfile
pnpm --dir web install --frozen-lockfile
pnpm --dir web build # web/dist
pnpm build # dist
sudo systemctl restart rukh-entData and backups
Everything lives in files under data/: contexts/, chat-history.json and ent-conversations.json. Back the directory up daily to storage outside the VPS, encrypted, for example with restic. Test a restore before the pilot.
A single chat-history.json holds every conversation. Measure it under load; if it does not hold, split it per session before changing anything else.
Configuration
# ENT
ENT_BASE_URL=https://enthdf.fr
ENT_CLIENT_ID=rukh
ENT_CLIENT_SECRET=
ENT_REDIRECT_URI=https://rukh.example/auth/callback
ENT_USERINFO_VERSION=2.0
ENT_ALLOWED_MODELS=mistral,anthropic
ENT_LOG_QUERIES=false
# Session
PUBLIC_ORIGIN=https://rukh.example
SESSION_SECRET= # 32 random bytes or more
SESSION_IDLE_SECONDS=1800
SESSION_MAX_SECONDS=28800
# Limits
THROTTLE_ASK_LIMIT=60 # per user, per hour
IMPORT_MAX_BYTES=20971520
# Providers (upstream)
MISTRAL_API_KEY=
ANTHROPIC_API_KEY=
# Server
PORT=3000
NODE_ENV=production
SWAGGER_ENABLED=false
# MCP (optional)
MCP_ENABLED=false
MCP_ROLES=staffUpstream requires both MISTRAL_API_KEY and ANTHROPIC_API_KEY to boot, because Mistral also runs the RAG selection step. Removed from upstream: the SIWE_* variables.
Security and privacy
| Risk | Measure |
|---|---|
| A route left open | Global guard, deny by default, @Public() listed in one place and covered by a test that enumerates all routes |
| CSRF | SameSite=Lax, an Origin check on every non-GET request, no CORS |
| Forged login callback | Random state in a short-lived cookie |
| Script injection | helmet with a Content Security Policy; model output rendered as Markdown without raw HTML |
| One user reading another's chat | Server-owned sessionId; no route returns a conversation by id |
| Students overriding the instructions | Rukh already delimits the user message and restates that the context wins |
| Shared classroom computers | Fresh flow on entry, idle timeout, browser-session cookie |
| Uploaded files | Size limit, timeout, isolated conversion process |
| Secrets | In /etc/rukh-ent.env, readable by the service user only |
Personal data held by the server: ENT user id, role, school codes, class names, and conversation text. No name, login or email is stored.
Conversation text of students, most of them minors, is sent to the model provider chosen for the assistant. Hosting location, retention period, deletion at the end of the school year, information given to families and the impact assessment fall under the GDPR and are to be settled with the school's head and the academy's data protection officer, with CNIL guidance. This document is not legal advice.
To verify on a test platform
Ask the school's ENT administrator or Edifice support whether a test platform exists. Otherwise, test on enthdf.fr with one staff account and one student test account.
- Connector URL. What the ENT opens when the user clicks the connector, and whether it opens in a new tab or an iframe. In an iframe, a
SameSite=Laxcookie is not sent. redirect_uri. Whether the host rule read in the source applies on enthdf.fr.userinfo?version=2.0. Whether an OAuth client with theuserinfoscope gets it, and the exact shape ofclassNamesanduai.typevalues for a teacher, a non-teaching staff member, a student and a parent.- Connector scope. Whether one
client_idand secret can serve several schools. If each school has its own,/auth/loginneeds a school parameter and the configuration becomes a table keyed by school code. - Back-channel logout. Whether it is available.
Open decisions
- Logout. Keep version 1, or add back-channel logout. Depends on item 6.
- Students and MCP. Staff only, or students too.
- Swagger in production. Off, or behind a staff check.
Build order
- Fork, remove SIWE, add the
entmodule: OAuth flow, session, global guard. Verify items 1 to 5. - Ownership and visibility on contexts,
PATCH /context/:name, server-owned conversations, per-user limits. - Vite app: list, chat with streaming.
- Staff screens: create, instructions, import and review, publish.
- VPS: Caddy, systemd, backups, load test.
- Pilot with one class.
- Optional: MCP endpoint.