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¶
- Zero server memory overhead: No Y.Doc stored on server per document
- P2P asset coordination: Assets shared between collaborating clients via server coordination
- Content-addressable assets: Same file content = same ID across all users
- 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¶
- Page Load: Browser fetches HTML from REST API, renders workarea
- Document Open: YjsDocumentManager initializes Y.Doc, syncs from IndexedDB
- WebSocket Connect: y-websocket provider connects for real-time sync
- Editing: Changes update Y.Doc → replicated to peers via WebSocket
- 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¶
-
Generate Export: When preview is requested,
Html5Exporter.generateForPreview()generates all export files in memory asMap<string, Uint8Array | string> -
Send to Service Worker: Files are sent to the SW via
postMessage({ type: 'SET_CONTENT', files }) -
Load Preview: The iframe loads
/viewer/index.html- all requests intercepted by SW -
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:
- Unregisters
preview-sw.jsregistrations 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 PWAservice-worker.jsare left alone. - Registers
preview-sw.js?v=<app version>at<basePath>viewer/, runsregistration.update()whenregister()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. - 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 resolvesnulland 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):
- Per-user storage: Each user's themes are private to them
- No server storage: Themes don't consume server disk space
- Collaboration via Yjs: Selected theme is shared with collaborators in real-time
- Portability: Themes embedded in .elpx can be opened anywhere
- 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:
- Architecture Decision Records
- Architecture changes
- Migration map — retired identifiers and where they went
Further Reading¶
- Architecture Decision Records - Durable decisions and their rationale
- Architecture changes - Design gates for significant changes
- Real-Time Collaboration - WebSocket and Yjs details
- REST API - API endpoints
- Testing - Test patterns and coverage
- Creating Styles - How to create custom themes