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.

DecisionChoice
ShapeOne fork of Rukh, one process, one domain
AuthenticationEdifice OAuth 2.0 only. SIWE and wallets are removed
BackendNestJS and TypeScript, as in Rukh
FrontendVite, React, React Router, Chakra UI
Routes/ serves the interface, /api serves Swagger UI
HostingOne OVHcloud VPS
OptionalAn 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 typeBasis
Edifice behaviorEdifice technical documentation and the master branch of entcore. The version deployed on enthdf.fr may differ
Rukh behaviorThe main branch of Rukh, version 0.2.0, and of rukh-ui
Code in this documentIllustrative. 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

AreaUpstream RukhRukh ENT
Access to POST /askPublicENT session required, visibility checked
Access to GET /contextPublic, lists every contextENT session required, filtered by school and class
Context ownershipcreatorAddress, proven by a SIWE signature (guard)ownerId, the ENT user id from the session
Dependenciesw3pk, ethersRemoved where nothing else uses them
sessionId on /askAny client-supplied value is accepted (source)Owned by the server, bound to the user and the assistant
Rate limit on /ask50 per hour per IP, hardcoded (source)Per user, configurable
CORSAny origin, credentials allowed (source)Disabled: the interface is same-origin
GET /Redirects to /apiServes the interface
Context settingsModel fixed at creationEditable: model, visibility, description
Context files.md only, 5 MBSame storage, plus conversion from PDF and office formats
InterfaceSeparate Next.js appVite 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
└── .env

Route map

Upstream API paths are kept, so the fork stays close to Rukh and Swagger stays at /api.

PathServed byAccess
/, /assistants/*, /newSingle-page app (static files, fallback to index.html)Public files; the app redirects to /auth/login without a session
/apiSwagger UI and the OpenAPI documentOff by default in production (SWAGGER_ENABLED)
/auth/login, /auth/callback, /auth/logoutent modulePublic
/meent moduleSession
/askUpstream controllerSession
/context, /context/*Upstream controllerSession; writes need the staff role and ownership
/web-reader/*Upstream controllerStaff
/mcp, /.well-known/*mcp module, optionalBearer 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 fieldValue
Identifiant (client_id)rukh
URLhttps://rukh.example/auth/login
Transmettre la sessionchecked
Scopeuserinfo
Mode d'identificationcode
Code secretgenerated, sent out of band
Allowed profilesteachers, staff, students

Endpoints

StepRequest
AuthorizeGET {ENT}/auth/oauth2/auth?response_type=code&client_id&redirect_uri&scope=userinfo&state
TokenPOST {ENT}/auth/oauth2/token, client authenticated with HTTP Basic
UserGET {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 state parameter 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).

Versiontype valuesClasses
1.0, the default (source)ENSEIGNANT, ELEVE, PERSEDUCNAT, PERSRELELEVE, SUPERADMINclassId: the first class only
2.0Teacher, Student, Personnel, Relative, SuperAdminclassNames: 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 type values;
  • read only the fields listed below and discard the rest, since version 2.0 returns most of the user's ENT session;
  • when classNames is missing, fall back to classId, 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 profileRoleRights
TeacherstaffCreate, edit, publish and delete their own assistants; chat
Non-teaching staff (librarians, CPE…)staffSame as teachers
StudentstudentChat with the assistants visible to them
Parent, super-adminnoneRefused 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.

PropertyValue
Cookie name__Host-rukh
AttributesHttpOnly, Secure, SameSite=Lax, Path=/, no Expires (cleared when the browser closes)
Idle timeout30 minutes; the cookie is re-issued on each authenticated request
Maximum age8 hours from login
ContentsENT 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

LevelBehavior
Version 1Logout button, idle timeout, browser-session cookie, and a fresh flow on every entry from the ENT
Later, if availableEdifice'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": []
}
FieldMeaning
ownerIdENT user id of the creator. Replaces creatorAddress
uaiSchool the assistant belongs to
classesClasses that can see it. Empty means the whole school
publishedDrafts are visible to their owner only
creatorNameRemoved: no names are stored

Context names match ^[a-z0-9-]+$ upstream. The fork generates them: hdf-<uai>-<slug>-<6 random characters>.

Access rules

ActionRule
Read or chatOwner; or published, and uai is one of the user's schools, and classes is empty or shares a class with the user
CreateRole staff. uai must be one of the user's schools
Edit, upload, deleteRole 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

RouteChange
GET /contextReturns only the contexts visible to the caller
POST /contextBody: name generated server-side, description, model, classes. ownerId and uai come from the session
PATCH /context/:nameNew. Updates description, model, classes, published
POST /context/upload, DELETE /context/:name/file, link routesOwnership checked against the session instead of a signature
POST /context/:name/importNew. Converts a file to Markdown and returns the draft without saving it
POST /askcontext is required. sessionId from the client is ignored
DELETE /ask/conversation/:contextNew. Starts a fresh conversation
GET /meNew. 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.

FormatConversion
.md, .txtNone
.htmlturndown
.docxmammoth to HTML, then turndown
.pdfunpdf, text extraction page by page
.odt, .doc, .rtf, .pptx, .odpHeadless 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

PieceChoice
BuildVite
UIReact and Chakra UI 3, as in rukh-ui, so its components can be copied over
RoutingReact Router
Markdownreact-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

PathScreenRole
/Assistants visible to the user; for staff, their own assistants and drafts firstAll
/assistants/:nameChatAll
/newCreate an assistant: title, model, classesStaff
/assistants/:name/editInstructions, documents with import and review, links, visibility, publish, deleteOwner
// 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 /api in app.controller.ts;
  • remove enableCors in main.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

ToolRoleMaps to
list_assistantsAllGET /context
ask_assistantAllPOST /ask
create_assistantStaffPOST /context
update_assistantOwnerPATCH /context/:name
put_documentOwnerPOST /context/upload
add_linkOwnerPOST /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:

  1. The MCP client discovers /.well-known/oauth-protected-resource, then the authorization server metadata.
  2. It registers, then opens Rukh ENT's /authorize with a PKCE challenge.
  3. Rukh ENT sends the user through the ENT flow described above.
  4. Rukh ENT shows a consent screen naming the client, as the specification requires of a server that proxies to a third-party authorization server.
  5. 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

LayerChoice
ServerOne OVHcloud VPS in a French datacenter, Debian stable
RuntimeNode.js LTS, pnpm
ProcessA systemd service under a dedicated user, listening on 127.0.0.1:3000
TLS and proxyCaddy, with certificates from Let's Encrypt
FirewallPorts 22, 80 and 443 only
Extra packagesLibreOffice 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.target

Rukh 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-ent

Data 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=staff

Upstream 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

RiskMeasure
A route left openGlobal guard, deny by default, @Public() listed in one place and covered by a test that enumerates all routes
CSRFSameSite=Lax, an Origin check on every non-GET request, no CORS
Forged login callbackRandom state in a short-lived cookie
Script injectionhelmet with a Content Security Policy; model output rendered as Markdown without raw HTML
One user reading another's chatServer-owned sessionId; no route returns a conversation by id
Students overriding the instructionsRukh already delimits the user message and restates that the context wins
Shared classroom computersFresh flow on entry, idle timeout, browser-session cookie
Uploaded filesSize limit, timeout, isolated conversion process
SecretsIn /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.

  1. 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=Lax cookie is not sent.
  2. redirect_uri. Whether the host rule read in the source applies on enthdf.fr.
  3. userinfo?version=2.0. Whether an OAuth client with the userinfo scope gets it, and the exact shape of classNames and uai.
  4. type values for a teacher, a non-teaching staff member, a student and a parent.
  5. Connector scope. Whether one client_id and secret can serve several schools. If each school has its own, /auth/login needs a school parameter and the configuration becomes a table keyed by school code.
  6. 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

  1. Fork, remove SIWE, add the ent module: OAuth flow, session, global guard. Verify items 1 to 5.
  2. Ownership and visibility on contexts, PATCH /context/:name, server-owned conversations, per-user limits.
  3. Vite app: list, chat with streaming.
  4. Staff screens: create, instructions, import and review, publish.
  5. VPS: Caddy, systemd, backups, load test.
  6. Pilot with one class.
  7. Optional: MCP endpoint.

Further reading