Skip to content

eXeLearning Architecture

This document describes the technical architecture of eXeLearning, an educational content authoring tool built with modern web technologies.

1. Overview

Design Philosophy

eXeLearning follows a browser-first architecture where:

  • Client is the source of truth: The browser holds the authoritative state in IndexedDB and in-memory Yjs documents
  • Server is for synchronization: The server acts as a stateless relay, coordinating between clients without storing document state
  • Explicit saves only: Content is persisted to the server only when the user explicitly saves
  • Offline-capable: Users can work without network connectivity; sync happens when online

Key Principles

  1. Zero server memory overhead: No Y.Doc stored on server per document
  2. P2P asset coordination: Assets shared between collaborating clients via server coordination
  3. Content-addressable assets: Same file content = same ID across all users
  4. Dependency injection: All services use DI pattern for testability

Runtime Modes (Server, Static, Embedded)

eXeLearning supports two primary runtime modes and one optional embedding mode:

Mode What runs Persistence Collaboration Typical use
Server (online) Bun + Elysia API + WebSocket Server DB + client IndexedDB Yes Multi-user, hosted installs
Static (offline) Pure static assets (no API) Client IndexedDB + file save/export No Desktop installers, static hosting
Embedded (iframe) Server or Static in iframe Delegated to host via postMessage Depends on mode LMS, CMS, plugin embeds

Static mode is built from the online app by removing database and API dependencies to stay stateless. The build bundles required catalog data (iDevices, themes, translations, parameters) into dist/static and runs fully in the browser. It is generated with:

make build-static

This is the same runtime used by the current offline (installable) eXeLearning desktop app. It improves simplicity and performance by avoiding any server persistence.

Embedding Example (Static or Server)

When eXeLearning runs inside an iframe, it can coordinate file operations with the host page via postMessage. The app announces readiness with EXELEARNING_READY and can request open/save operations. A minimal host integration:

<iframe
  id="exe"
  src="https://your-host/exelearning/"
  style="width: 100%; height: 100vh; border: 0;"
></iframe>

<script>
  const iframe = document.getElementById('exe');

  function postToExe(type, data = {}) {
    const requestId = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
    iframe.contentWindow.postMessage({ type, requestId, ...data }, '*');
    return requestId;
  }

  window.addEventListener('message', async (event) => {
    const { type, requestId, bytes } = event.data || {};
    if (!type) return;

    if (type === 'EXELEARNING_READY') {
      // Optional: set trusted origins or request info
      postToExe('GET_PROJECT_INFO');
    }

    if (type === 'EXELEARNING_SAVE_REQUEST') {
      // Host handles file persistence
      // Example: upload bytes to your backend, then respond
      event.source.postMessage({ type: 'EXELEARNING_SAVE_RESPONSE', requestId, path: 'project.elpx' }, event.origin);
    }

    if (type === 'EXELEARNING_OPEN_REQUEST') {
      // Host provides bytes for a .elpx file (from disk or backend)
      const fileBytes = new Uint8Array(); // replace with real data
      event.source.postMessage({ type: 'EXELEARNING_OPEN_RESPONSE', requestId, bytes: fileBytes }, event.origin);
    }
  });
</script>

For production, restrict origins and validate all messages (see EmbeddedFileSystem and EmbeddingBridge for supported message types and security checks).

2. Technology Stack

Component Technology Purpose
Runtime Bun Fast JavaScript runtime and package manager
Framework Elysia Type-safe web framework
ORM Kysely Type-safe SQL query builder
Database SQLite / PostgreSQL / MySQL Multi-database support
Real-time WebSocket + Yjs Collaborative editing
Frontend Vanilla JavaScript No framework dependencies
Storage IndexedDB Client-side asset and document storage
Templates Nunjucks Server-side HTML rendering
Desktop Electron Cross-platform desktop application

3. System Architecture

graph TB
    subgraph "Browser (Client)"
        UI[User Interface]
        YDoc["Y.Doc (in-memory)"]
        IDB[(IndexedDB)]
        AM[AssetManager]
        WSH[WebSocket Handler]

        UI --> YDoc
        YDoc <--> IDB
        AM <--> IDB
        UI --> AM
        WSH --> YDoc
        WSH --> AM
    end

    subgraph "Server (Elysia + Bun)"
        Routes[REST Routes]
        WS[WebSocket Relay]
        RM[Room Manager]
        AC[Asset Coordinator]
        SM[Session Manager]
        Kysely[Kysely ORM]

        Routes --> SM
        Routes --> Kysely
        WS --> RM
        WS --> AC
        AC --> Kysely
    end

    subgraph "Storage"
        DB[(Database)]
        FS[File System]
    end

    Browser <-->|"REST API"| Routes
    Browser <-->|"WebSocket (Yjs Binary)"| WS
    Kysely --> DB
    SM --> FS

Request Flow

  1. Page Load: Browser fetches HTML from REST API, renders workarea
  2. Document Open: YjsDocumentManager initializes Y.Doc, syncs from IndexedDB
  3. WebSocket Connect: y-websocket provider connects for real-time sync
  4. Editing: Changes update Y.Doc → replicated to peers via WebSocket
  5. Save: User clicks save → REST API persists Yjs state to database

4. WebSocket & Yjs Collaboration

4.1 Stateless Relay Architecture

The WebSocket server does NOT maintain Y.Doc state:

┌─────────────────────────────────────────────────────────────┐
│                    Traditional Architecture                  │
│  Client A ──► Server Y.Doc ◄── Client B                     │
│              (memory overhead)                               │
└─────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────┐
│                eXeLearning Stateless Relay                   │
│  Client A ──────► Relay ◄────── Client B                    │
│  (Y.Doc)     (zero memory)      (Y.Doc)                     │
└─────────────────────────────────────────────────────────────┘

Benefits: - No memory per document on server - Scales to many concurrent documents - Client IndexedDB provides offline persistence

4.2 Connection Flow

sequenceDiagram
    participant Client
    participant Server
    participant Room

    Client->>Server: WebSocket connect<br/>/yjs/project-{uuid}?token=JWT
    Server->>Server: Validate JWT token
    Server->>Server: Check project access
    Server->>Room: getOrCreateRoom()
    Room->>Room: Add connection
    Server->>Client: Connection established

    Client->>Server: Yjs sync step 1 (binary)
    Server->>Room: relayMessage() to other clients

    Note over Client,Server: Continuous sync during editing

    Client->>Server: Close connection
    Server->>Room: removeConnection()
    Room->>Room: Schedule cleanup (30s delay)

4.3 Message Protocol

The server distinguishes between two message types:

Type Detection Handler
Yjs sync Binary, starts with bytes 0-2 Relayed to room peers
Asset protocol Binary with 0xFF prefix + JSON Handled by AssetCoordinator
// Message discrimination in message-parser.ts
if (message[0] === 0xFF) {
    // Asset protocol message
    const json = message.subarray(1);
    return { kind: 'asset', message: JSON.parse(json) };
}
// Default: Yjs binary message
return { kind: 'yjs', data: message };

4.4 Room Management

Rooms are created per document and track connected clients:

interface Room {
    name: string;                    // "project-{uuid}"
    conns: Set<WebSocket>;           // Connected clients
    projectUuid: string;
    cleanupController?: AbortController;  // For cancellable cleanup
}

Lifecycle: 1. Create: First client connects → room created 2. Active: Messages relayed between clients 3. Cleanup Scheduled: Last client disconnects → 30s timer starts 4. Cleanup Cancelled: Client reconnects before timer → cancel cleanup 5. Destroyed: Timer fires → room deleted, assets cleaned up

4.5 Heartbeat & Keep-Alive

WebSocket connections use ping/pong for health checking:

Environment Ping Interval Cleanup Delay
Desktop 60s 5s
Server 30s 30s

The shorter server interval avoids proxy timeout issues (typically 60s).

5. Asset Management System

5.1 Browser-First Storage

Assets are stored primarily in IndexedDB on the client:

// IndexedDB schema (exelearning-assets-v2)
{
    id: string,           // Content-addressable UUID
    projectId: string,    // Project reference
    blob: Blob,           // Actual file data
    mime: string,         // MIME type
    hash: string,         // SHA-256 hex
    size: number,
    uploaded: boolean,    // Server sync status
    filename: string
}

5.2 Content-Addressable URLs

Assets use deterministic IDs based on content hash:

asset://8f3e9c2a-1b4d-4f7e-9a3c-2d1e5f7a9b3c
asset://8f3e9c2a-1b4d-4f7e-9a3c-2d1e5f7a9b3c/photo.jpg

Benefits: - Same file = same ID (deduplication) - Idempotent uploads - Efficient P2P sharing

5.3 P2P Asset Coordination

When multiple users collaborate, assets are shared via server coordination:

sequenceDiagram
    participant Alice as Alice (has asset)
    participant Server as Server
    participant Bob as Bob (needs asset)

    Alice->>Server: awareness-update<br/>["asset-1", "asset-2"]
    Note over Server: Track: Alice has asset-1, asset-2

    Bob->>Server: request-asset<br/>{assetId: "asset-1", priority: HIGH}
    Server->>Server: Check DB (not found)
    Server->>Server: Check peers (Alice has it)
    Server->>Alice: upload-request<br/>{assetId: "asset-1"}

    Alice->>Server: Upload asset-1 via REST
    Server->>Bob: asset-ready<br/>{assetId: "asset-1", url: "/api/..."}

    Bob->>Server: Download asset-1
    Bob->>Bob: Store in IndexedDB

5.4 Priority Queue System

Asset uploads are prioritized to ensure responsive UI:

Priority Value Use Case
CRITICAL 100 Asset needed for current render
HIGH 75 Asset on current page
MEDIUM 50 Asset on nearby pages
LOW 25 Background prefetch
IDLE 0 Normal save upload

Preemption rules: - Max 3 concurrent uploads per project - Higher priority can preempt if: priority diff ≥ 50 AND upload running ≥ 2s

5.5 Save Flow

flowchart LR
    subgraph Browser
        A[User clicks Save] --> B[Get pending assets]
        B --> C[Create priority batches]
        C --> D[Upload batches in parallel]
        D --> E[Save Yjs state]
        E --> F[Mark assets uploaded]
    end

    subgraph Server
        D --> G[Store assets on disk]
        E --> H[Store Yjs snapshot in DB]
    end

Batch configuration: - Critical: 1 asset per batch (fastest) - High priority: max 5 files or 5MB per batch - Normal: max 15 files or 10MB per batch - Max concurrent batches: 5

6. Session Management

6.1 Session Lifecycle

Each opened project gets a unique session:

interface ProjectSession {
    sessionId: string;        // UUID
    projectId: number;        // Database ID
    projectUuid: string;      // Project UUID
    sessionPath: string;      // tmp/{sessionId}/
    contentPath: string;      // Path to extracted content
    createdAt: Date;
    modifiedAt: Date;
}

Sessions are stored in-memory (Map<sessionId, ProjectSession>).

6.2 File System Structure

FILES_DIR/
├── tmp/{sessionId}/          # Temporary session directory
│   ├── content.xml           # Extracted ELP content
│   ├── assets/               # Uploaded assets
│   └── dist/                 # Export output
│
├── perm/odes/{projectId}/    # Permanent storage
│   └── assets/
│
└── data/
    └── chunks/{projectId}/   # Chunked upload temp storage

7. Export System

7.1 Supported Formats

Format Description Use Case
HTML5 Self-contained website Web hosting
SCORM 1.2 LMS package Moodle, Blackboard (legacy)
SCORM 2004 LMS package Modern LMS
EPUB3 E-book format E-readers
IMS CP Content package Standards compliance

7.2 Export Strategy Pattern

interface ExportStrategy {
    export(session: ProjectSession, options: ExportOptions): Promise<string>;
    getManifest(): Promise<string>;
    copyAssets(): Promise<void>;
}

// Usage
const exporter = ExportFactory.create('scorm12');
const zipPath = await exporter.export(session, options);

8. Preview System (Service Worker Architecture)

The workarea preview uses a Service Worker to serve exported HTML files directly, ensuring the preview exactly matches the exported output.

8.1 Architecture Overview

┌─────────────────────────────────────────────────────────────┐
│                      WORKAREA                                │
├─────────────────────────────────────────────────────────────┤
│  Yjs Bridge                    PreviewPanelManager          │
│      │                               │                       │
│      │ onStructureChange()           │                       │
│      └──────────────────────────────►│                       │
│                                      │                       │
│                            1. Generate export                │
│                               (Html5Exporter)                │
│                                      │                       │
│                            2. Send to SW                     │
│                               (SET_CONTENT)                  │
│                                      │                       │
│                            3. Load iframe                    │
│                               /viewer/index.html             │
└─────────────────────────────────────────────────────────────┘
                                       │
                                       ▼
┌─────────────────────────────────────────────────────────────┐
│                   SERVICE WORKER                             │
├─────────────────────────────────────────────────────────────┤
│  contentFiles: Map<path, ArrayBuffer>                        │
│                                                              │
│  fetch('/viewer/*') → handleViewerRequest()                  │
│      ├── Look up path in contentFiles                        │
│      ├── Return Response with correct MIME type              │
│      └── Inject external link handler for HTML               │
└─────────────────────────────────────────────────────────────┘
                                       │
                                       ▼
┌─────────────────────────────────────────────────────────────┐
│                   PREVIEW IFRAME                             │
├─────────────────────────────────────────────────────────────┤
│  <iframe src="/viewer/index.html">                          │
│      │                                                       │
│      ├── CSS: /viewer/theme/style.css                       │
│      ├── JS:  /viewer/libs/common.js                        │
│      ├── IMG: /viewer/content/resources/image.jpg           │
│      └── All served by SW from memory!                       │
└─────────────────────────────────────────────────────────────┘

8.2 How It Works

  1. Generate Export: When preview is requested, Html5Exporter.generateForPreview() generates all export files in memory as Map<string, Uint8Array | string>

  2. Send to Service Worker: Files are sent to the SW via postMessage({ type: 'SET_CONTENT', files })

  3. Load Preview: The iframe loads /viewer/index.html - all requests intercepted by SW

  4. Serve Files: SW serves files from its in-memory cache with correct MIME types

8.3 Key Components

Component File Purpose
Service Worker public/preview-sw.js Intercepts /viewer/* requests
Preview Panel public/app/workarea/interface/elements/previewPanel.js UI and orchestration
Export Generator src/shared/export/exporters/Html5Exporter.ts Generates export files
Browser API src/shared/export/browser/index.ts Browser-side export functions

8.4 Benefits

  • Exact match: Preview IS the exported HTML (same code path, same output)
  • No workarounds: No blob:// URL complexity for assets
  • Native PDF viewer: PDFs work without PDF.js workaround (not in nested blob context)
  • External links: Handled correctly (SW injects click handler)
  • Multi-page navigation: Works exactly like the real export

8.5 Service Worker Protocol

// Messages sent to Service Worker
{ type: 'SET_CONTENT', files: Map<path, ArrayBuffer> }  // Set preview content
{ type: 'CLEAR_CONTENT' }                                // Clear preview cache
{ type: 'CLAIM_CLIENTS' }                                // Activate SW for page
{ type: 'GET_STATUS' }                                   // Health check; replies STATUS { version, appVersion, ready, fileCount }

8.6 Public View (Read-Only) & Untrusted Content Isolation

The public view (GET /view/:publicViewId) exposes a published project at an opaque, shareable URL with no authentication. It serves author-provided HTML/JS, which must be treated as untrusted: a teacher can paste AI-generated or copied code, and the link may be opened by anyone — including a logged-in admin.

Why it cannot reuse the Service Worker preview

The workarea preview (§8.1–8.5) serves content from the same origin as the app. That is acceptable for the editor (the author previews their own content), but it is unsafe for a public link: same-origin content can read the auth cookie surface, call the authenticated API with the viewer's session, reach window.parent, and open the app's IndexedDB / Cache API. The danger scales with the role of whoever opens the link. This is exactly the same-origin failure mode documented in the "untrusted content in educational resources" security study (§4.5, §6.2).

A naive fix ("just add sandbox") does not work here: a sandbox without allow-same-origin gives the document an opaque origin, and a Service Worker cannot control opaque-origin clients — the content would never load. True isolation therefore requires serving the public content from the server instead of via the SW.

Design (opaque origin, no subdomain)

┌──────────────────────────────────────────────────────────────┐
│  /view/:publicViewId   (trusted loader page, app origin)      │
│  views/viewer/viewer.njk                                      │
│      └── <iframe sandbox="allow-scripts …"  (NO same-origin)  │
│              src="/view/:publicViewId/_/index.html">          │
└──────────────────────────────────────────────────────────────┘
                               │  (opaque origin)
                               ▼
┌──────────────────────────────────────────────────────────────┐
│  GET /view/:publicViewId/_/*        (untrusted content)       │
│  src/routes/pages.ts → getPublicViewFile()                    │
│      ├── buildHtml5PreviewExport() (cached by updated_at)     │
│      ├── path-traversal-safe file lookup                      │
│      └── Response headers:                                    │
│            Content-Security-Policy: sandbox … (R3)            │
│            Permissions-Policy / X-Content-Type-Options        │
└──────────────────────────────────────────────────────────────┘

The published files are served directly by the server from the project's HTML5 export (built once, then cached in memory keyed by publicViewId:updated_at). Both the iframe sandbox attribute and a response-level Content-Security-Policy: sandbox … directive are applied, so the document stays in an opaque origin even if the content URL is opened directly (new tab, popup, fullscreen, raw URL) — not only when embedded in the loader iframe.

This implements requirements R1–R4 of the study:

Req Implementation
R1 — opaque origin by default sandbox without allow-same-origin
R2 — single source of truth for tokens src/shared/security/publicViewSandbox.ts (used by both the iframe attribute and the CSP header)
R3 — sandbox in the response CSP Content-Security-Policy: sandbox … on every /_/ * response
R4 — restrictive CSP + Permissions-Policy object-src 'none', base-uri 'none', frame-ancestors 'self', camera/mic/geo/payment disabled

With the opaque origin, untrusted content cannot: reach window.parent/top (SecurityError), read or send the auth cookie (cross-site → SameSite=lax not sent), call /api/... as the viewer, or open the app's IndexedDB / Cache API.

Site-wide switch. The whole feature is off by default. PUBLIC_VIEW_ENABLED sets the installation default and the /admin > Sharing toggle (stored in app_settings) overrides it; both are read through isPublicViewFeatureEnabled() in src/services/app-settings.ts. While it is off, both /view/* routes return 404, the enable/regenerate endpoints answer 403 FEATURE_DISABLED (turning a link off is still allowed), and the sharing payload reports publicViewAvailable: false, so the Share dialog hides the public-link card. Per-project flags are kept, so re-enabling the feature restores the links that were already on.

CSP profile. Selectable via the PUBLIC_VIEW_CSP_PROFILE env var. The default compatible profile relies on the opaque origin to protect the session and still allows external https: assets (CDN, MathJax, external images, YouTube/Vimeo embeds) to avoid breaking legitimate content. Setting it to strict additionally cuts data exfiltration (connect-src 'none', no open https:, 'unsafe-eval' dropped) for sensitive deployments, at the cost of breaking content that depends on external resources (study §6.3).

Cost protection. The export for a public view is built once and the unzipped result cached in memory (LRU-capped). Concurrent first-time requests for the same project are coalesced onto a single in-flight build (no thundering herd), and an export exceeding the size/file-count bounds is rejected. Per-IP rate limiting is left to the reverse proxy / a dedicated middleware.

Component File Purpose
Isolation policy src/shared/security/publicViewSandbox.ts Single source of truth for sandbox tokens + CSP/Permissions-Policy headers
Content service src/services/public-view-content.ts Builds/caches the export, path-safe file lookup
Routes src/routes/pages.ts /view/:publicViewId (loader) and /view/:publicViewId/_/* (content)
Loader template views/viewer/viewer.njk Sandboxed iframe

Note: the workarea preview (§8.1–8.5) keeps the same-origin Service Worker, since the author is previewing their own content. Only the public view is isolated.

8.7 Registration Lifecycle and Self-Healing

Browser profiles keep Service Worker registrations indefinitely, so the app never trusts a stored worker blindly (see ADR-2429-01). App.registerPreviewServiceWorker() in public/app/app.js:

  1. Unregisters preview-sw.js registrations whose scope contains the preview scope (<basePath>viewer/), e.g. the root scope used by pre-4.0.0 builds. Sibling scopes of other deployments and the PWA service-worker.js are left alone.
  2. Registers preview-sw.js?v=<app version> at <basePath>viewer/, runs registration.update() when register() did not already start an install, and waits for the installed worker to activate. A new release therefore installs a new worker before any content is sent.
  3. Pings the worker with GET_STATUS (3 s). A worker that does not answer is unregistered and registered again once; if it still does not answer the promise resolves null and the preview panel falls back to the blob URL renderer.

App.sendContentToPreviewSW(files, options, { regenerateFiles }) applies the same recovery when SET_CONTENT is never confirmed: it re-registers the worker once and, because the ArrayBuffers were transferred, resends files produced by the caller's regenerateFiles callback. If that recovery fails the worker is marked unavailable, as after a failed registration, and the preview panel falls back to the blob URL renderer. CLAIM_CLIENTS is only awaited when the page is inside the registration scope; the editor page is not, so no time is spent waiting for a controllerchange.

9. Database Architecture

9.1 Multi-Database Support

Kysely ORM with dialect adapters:

// src/db/dialect.ts
function createDialect(): Dialect {
    switch (process.env.DB_DRIVER) {
        case 'pdo_sqlite':
            return new BunWorkerDialect({ /* SQLite config */ });
        case 'pdo_pgsql':
            return new PostgresDialect({ /* PostgreSQL config */ });
        case 'pdo_mysql':
            return new MysqlDialect({ /* MySQL config */ });
    }
}

9.2 Query Pattern with Dependency Injection

All queries accept db as first parameter for testability:

// src/db/queries/projects.ts
export async function findProjectByUuid(
    db: Kysely<Database>,
    uuid: string
): Promise<Project | undefined> {
    return db.selectFrom('projects')
        .where('uuid', '=', uuid)
        .selectAll()
        .executeTakeFirst();
}

Testing with DI:

// In tests
configure({
    queries: { findProjectByUuid: mockFindProjectByUuid }
});

afterEach(() => resetDependencies());

10. Configuration

10.1 Environment Variables

Key configuration in .env:

# Server
APP_PORT=8080
APP_SECRET=your-jwt-secret

# Database
DB_DRIVER=pdo_sqlite          # pdo_sqlite | pdo_pgsql | pdo_mysql
DB_PATH=/mnt/data/exelearning.db

# File Storage
FILES_DIR=/mnt/data/

# Optional
BASE_PATH=/exelearning        # URL prefix for subdirectory install
APP_AUTH_METHODS=password     # password,cas,openid,guest

10.2 Desktop vs Server Configuration

Setting Desktop (Electron) Server
Ping interval 60s 30s
Cleanup delay 5s 30s
Compact threshold 100 updates 50 updates
WebSocket timeout 70s 40s

11. Key File Locations

Backend (src/)

Path Purpose
src/index.ts Elysia entry point
src/routes/ REST API routes
src/services/ Business logic
src/db/client.ts Kysely instance
src/db/queries/ Database queries
src/websocket/yjs-websocket.ts WebSocket handler
src/websocket/room-manager.ts Room lifecycle
src/websocket/asset-coordinator.ts P2P coordination
src/shared/export/exporters/Html5Exporter.ts HTML5 export (also used for preview)
src/shared/export/browser/index.ts Browser-side export API

Frontend (public/app/)

Path Purpose
public/app/yjs/YjsDocumentManager.js Y.Doc management
public/app/yjs/AssetManager.js IndexedDB storage
public/app/yjs/AssetWebSocketHandler.js Asset protocol
public/app/yjs/SaveManager.js Save orchestration
public/app/yjs/YjsProjectBridge.js Coordination hub
public/preview-sw.js Preview Service Worker
public/app/workarea/interface/elements/previewPanel.js Preview panel UI

Configuration

Path Purpose
.env Environment configuration
tsconfig.json TypeScript config
bunfig.toml Bun configuration
Makefile Build commands

12. Testing

Test Strategy

  • Unit tests: Next to source files (*.spec.ts)
  • Integration tests: test/integration/
  • E2E tests: Playwright
  • Coverage target: 90%

Running Tests

# Run all unit tests
make test-unit

# Run with coverage
make test-coverage

# Run specific test
DB_PATH=:memory: ELYSIA_FILES_DIR=/tmp/test bun test src/path/to/file.spec.ts

DI Pattern for Testing

Never use mock.module(). Use dependency injection:

// Configure mocks
configure({
    db: testDb,
    queries: { findById: mockFindById }
});

// Reset after test
afterEach(() => resetDependencies());

13. Security

Authentication

  • JWT tokens for API and WebSocket authentication
  • Token passed in query string for WebSocket (?token=JWT)
  • Multiple auth methods: password, CAS, OpenID Connect, guest

Authorization

  • Project access checked before WebSocket connection
  • User ID tracked per connection for audit
  • Path traversal prevention with isPathSafe()

WebSocket Security

// Connection validation
ws.on('open', async () => {
    const token = url.searchParams.get('token');
    const decoded = await verifyToken(token);
    if (!decoded) {
        ws.close(4001, 'Invalid token');
        return;
    }
    const hasAccess = await checkProjectAccess(db, decoded.userId, projectUuid);
    if (!hasAccess) {
        ws.close(4003, 'Access denied');
        return;
    }
});

14. Theme Architecture

14.1 Theme Types

eXeLearning supports three types of themes:

Type Source Storage Served By
Base Built-in with eXeLearning Server /perm/themes/base/ Server
Site Admin-installed for all users Server /perm/themes/site/ Server
User Imported by user or from .elpx Client IndexedDB + Yjs Never server

14.2 Server Themes (Base & Site)

Base themes are included with eXeLearning and synchronized at startup: - Located in /public/files/perm/themes/base/ - Cannot be modified by users - Served directly by the server

Site themes are installed by administrators for all users: - Located in /perm/themes/site/ - Admin can activate/deactivate themes - Admin can set a default theme for new projects - Served directly by the server

14.3 User Themes (Client-Side Only)

Important: User themes are NEVER stored or served by the server.

User themes are stored entirely on the client side:

┌─────────────────────────────────────────────────────────────────────┐
│                     USER THEME STORAGE                              │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  IndexedDB (per-user isolation)                                     │
│  └── user-themes store: key = "userId:themeName"                    │
│      └── Each user's themes isolated by userId prefix               │
│      └── User "alice" cannot see user "bob"'s themes                │
│                                                                     │
│  Yjs themeFiles (project document)                                  │
│  └── Currently selected user theme (for collaboration/export)       │
│                                                                     │
│  .elpx export                                                       │
│  └── Embedded theme files (for portability)                         │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

14.4 User Theme Flow

1. IMPORT THEME
   User uploads ZIP → Stored in IndexedDB (local storage)

2. SELECT THEME
   User selects theme → Copied to Yjs themeFiles
   (enables collaboration and export)

3. CHANGE TO ANOTHER THEME
   User selects different theme → Removed from Yjs
   (but remains in IndexedDB for future use)

4. EXPORT PROJECT (.elpx)
   If user theme selected → Embedded in ZIP

5. OPEN PROJECT WITH EMBEDDED THEME
   Another user opens .elpx → Theme extracted to their IndexedDB
   (if ONLINE_THEMES_INSTALL is enabled)

14.5 Admin Configuration

# Allow users to import/install styles
ONLINE_THEMES_INSTALL=1    # 1 = enabled (default), 0 = disabled

When disabled (ONLINE_THEMES_INSTALL=0): - Users cannot import external themes via the interface - Users cannot load custom embbeded themes in .elpx files

14.6 Why User Themes Are Client-Side

This design follows the same pattern as other user-specific data (like favorite iDevices):

  1. Per-user storage: Each user's themes are private to them
  2. No server storage: Themes don't consume server disk space
  3. Collaboration via Yjs: Selected theme is shared with collaborators in real-time
  4. Portability: Themes embedded in .elpx can be opened anywhere
  5. Offline capability: Themes work without server connectivity

Architecture Decision Records and change documents

Durable architecture decisions are tracked as ADRs under doc/architecture/adr/.

Significant technical designs and design-gated proposals are tracked as change documents under doc/architecture/changes/, one directory per change.

The two complement each other: a design describes how a change will be implemented, while an ADR records a specific decision and its rationale. When a design contains a durable decision, that decision should link to an existing ADR or propose a new one.

Both are identified by their GitHub tracking issue rather than a global counter: ADRs are named ADR-<issue>-<NN>-<decision-slug>.md and changes live in changes/<issue>-<change-slug>/. The rationale is recorded in ADR-2232-01.

See:


Further Reading