Complete API reference for developing applications on the Ephemera platform.
- Quick Start
- App Structure
- Core APIs
- System APIs
- UI Components
- Events
- Storage & Files
- App Lifecycle
- AI Integration
- Network & Proxies
- Best Practices
- Testing
- Contributing
- Open Code Editor from the desktop or start menu
- Click New App or press
Ctrl+N - Fill in your app details (ID, name, description)
- Start coding!
/**
* Ephemera App: [Your App Name]
*
* Available APIs (see Programmers Guide for details):
* - EphemeraFS.* - Virtual file system operations
* - EphemeraStorage.* - Key-value storage with encryption
* - EphemeraWM.* - Window management
* - EphemeraApps.* - App registry and management
* - EphemeraNetwork.* - HTTP client with CORS proxy
* - EphemeraDialog.* - Modal dialogs
* - EphemeraNotifications.* - Toast notifications
* - EphemeraState.* - Global state management
* - EphemeraEvents.* - Event pub/sub system
* - EphemeraSanitize.* - XSS protection utilities
*
* Global variables in your app:
* - container - Your app's root DOM element
* - windowId - Unique ID for your window instance
*/
function init() {
container.innerHTML = `
<div style="padding: 20px;">
<h1>Hello, Ephemera!</h1>
<p>This is my first app.</p>
<button id="myBtn">Click Me</button>
</div>
`;
document.getElementById('myBtn').addEventListener('click', () => {
EphemeraNotifications.success('Button Clicked', 'You clicked the button!');
});
}
init();/home/user/apps/
└── myapp/ # Your app directory
├── app.js # Main application code (required)
└── app.json # App metadata (required)
{
"id": "com.user.myapp",
"name": "My App",
"type": "app",
"version": "1.0.0",
"description": "A custom Ephemera application",
"icon": "<svg>...</svg>",
"category": "user",
"permissions": [],
"window": {
"width": 600,
"height": 400,
"resizable": true,
"minWidth": 400,
"minHeight": 300
},
"singleton": false
}| Property | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | Unique identifier (e.g., com.user.myapp) |
name |
string | Yes | Display name |
type |
string | No | Extension type (app, system, editor, theme, widget) |
version |
string | No | Semantic version (default: "1.0.0") |
description |
string | No | Short description |
icon |
string | No | SVG icon markup |
category |
string | No | Category for grouping (user, system, etc.) |
permissions |
array | No | Requested permissions (least-privilege) |
window.width |
number | No | Initial window width (default: 600) |
window.height |
number | No | Initial window height (default: 400) |
window.resizable |
boolean | No | Allow window resizing (default: true) |
window.minWidth |
number | No | Minimum window width |
window.minHeight |
number | No | Minimum window height |
singleton |
boolean | No | Only one instance allowed (default: false) |
Virtual file system with IndexedDB persistence.
// Read file content (returns string or null)
const content = await EphemeraFS.readFile('/home/user/document.txt');
// Read with stats
const file = await EphemeraFS.stat('/home/user/document.txt');
// Returns: { path, name, type, size, modifiedAt, createdAt }// Write content to file
await EphemeraFS.writeFile('/home/user/newfile.txt', 'Hello World');
// Create directory
await EphemeraFS.mkdir('/home/user/newfolder');
// Append to file
const existing = await EphemeraFS.readFile('/home/user/log.txt') || '';
await EphemeraFS.writeFile('/home/user/log.txt', existing + '\nNew line');// List directory contents
const files = await EphemeraFS.readdir('/home/user');
// Returns: [{ path, name, type: 'file'|'directory', ... }]
// Check if path exists
const exists = await EphemeraFS.exists('/home/user/file.txt');
// Get file stats
const stat = await EphemeraFS.stat('/home/user/file.txt');// Delete file or directory (moves entry to Trash)
await EphemeraFS.delete('/home/user/oldfile.txt');
// Move/rename file
await EphemeraFS.move('/home/user/old.txt', '/home/user/new.txt');
// Copy file
await EphemeraFS.copy('/home/user/source.txt', '/home/user/dest.txt');
// Restore files via the built-in Trash app.// Get file extension
const ext = EphemeraFS.getExtension('/path/to/file.txt'); // 'txt'
// Get filename without extension
const name = EphemeraFS.getBasename('/path/to/file.txt'); // 'file'
// Check if text file
const isText = EphemeraFS.isTextFile('/path/to/file.js'); // true
// Search files
const results = await EphemeraFS.search('query');
// Get icon SVG for file type
const icon = EphemeraFS.getIcon(file);Persistent key-value storage with optional encryption.
// Store value
await EphemeraStorage.put('metadata', {
key: 'myapp.settings',
value: JSON.stringify({ theme: 'dark' })
});
// Retrieve value
const data = await EphemeraStorage.get('metadata', 'myapp.settings');
const settings = data ? JSON.parse(data.value) : null;
// Delete value
await EphemeraStorage.delete('metadata', 'myapp.settings');
// Get all values in a store
const all = await EphemeraStorage.getAll('metadata');| Store | Purpose |
|---|---|
files |
File content (auto-encrypted) |
apps |
App code (auto-encrypted) |
metadata |
General key-value data |
When a user is logged in with a password, sensitive data is automatically encrypted:
// Sensitive keys are auto-encrypted when user is logged in
await EphemeraStorage.put('metadata', {
key: 'myapp.apiKey', // Contains 'ApiKey' - will be encrypted
value: 'secret-key-here'
});Shared application state that persists across sessions.
// Read settings
const theme = EphemeraState.settings.theme;
const proxyUrl = EphemeraState.settings.proxyUrl;
// Update setting (auto-saves)
EphemeraState.updateSetting('myapp.option', 'value');
// Access user info
const userName = EphemeraState.user.name;
const userDir = EphemeraState.user.homeDir;
// Direct access
EphemeraState.settings.myCustomSetting = 'value';
EphemeraState.save(); // Manual saveEphemeraState = {
settings: {
proxyUrl: string,
proxyEnabled: boolean,
theme: 'dark' | 'light',
notifications: boolean,
accentColor: string,
sounds: boolean,
// ... custom app settings
},
user: {
name: string,
homeDir: string
},
wallpaper: string,
// ... other state
}Control windows and open other applications.
// Open app by ID
EphemeraWM.open('notepad');
// Open with options
EphemeraWM.open('files', {
startPath: '/home/user/Documents'
});
// Open app with file
EphemeraWM.open('notepad', {
filePath: '/home/user/notes.txt'
});// Close specific window
EphemeraWM.close(windowId);
// Minimize window
EphemeraWM.minimize(windowId);
// Maximize/restore
EphemeraWM.toggleMaximize(windowId);
// Focus window
EphemeraWM.focus(windowId);
// Get window info
const info = EphemeraWM.getWindow(windowId);// Get registered app
const app = EphemeraApps.get('notepad');
// Get all apps
const allApps = EphemeraApps.getAll();
// Check if app exists
const exists = EphemeraApps.has('myapp');Make HTTP requests with automatic CORS proxy support.
// Simple GET
const html = await EphemeraNetwork.get('https://example.com');
// GET JSON
const data = await EphemeraNetwork.getJSON('https://api.example.com/data');
// With options
const response = await EphemeraNetwork.get('https://api.example.com', {
headers: { 'Authorization': 'Bearer token' },
timeout: 10000
});// POST JSON
const result = await EphemeraNetwork.post('https://api.example.com', {
title: 'Hello',
body: 'World'
});
// POST with options
const result = await EphemeraNetwork.post(url, data, {
headers: { 'Content-Type': 'application/json' }
});// Get Response object
const response = await EphemeraNetwork.fetch('https://example.com/file', {
method: 'PUT',
body: fileContent
});
if (response.ok) {
const text = await response.text();
}Display modal dialogs for user interaction.
// Alert dialog
await EphemeraDialog.alert('Operation completed!', 'Success');
// Confirm dialog
const confirmed = await EphemeraDialog.confirm(
'Are you sure you want to delete this file?',
'Confirm Delete'
);
if (confirmed) {
// User clicked OK
}
// Confirm with danger styling
const confirmed = await EphemeraDialog.confirm(
'This cannot be undone!',
'Dangerous Action',
true // isDanger = true
);
// Prompt for input
const name = await EphemeraDialog.prompt(
'Enter your name:',
'Name Required',
'Default value'
);
if (name !== null) {
// User entered something (empty string is valid)
}Show non-intrusive notifications.
// Success notification
EphemeraNotifications.success('File saved!', 'Success');
// Error notification
EphemeraNotifications.error('Failed to connect', 'Error');
// Info notification
EphemeraNotifications.info('New update available', 'Update');
// Warning notification
EphemeraNotifications.warning('Low disk space', 'Warning');
// With options
EphemeraNotifications.success('Done!', 'Complete', {
timeout: 5000 // Auto-dismiss after 5 seconds
});Your app runs inside a window with Ephemera's theme. Use CSS variables for consistent styling:
/* Available CSS Variables */
:root {
--bg-primary: #0a0a0f;
--bg-secondary: #12121a;
--bg-tertiary: #1a1a24;
--fg-primary: #e8e8f0;
--fg-secondary: #9898a8;
--fg-muted: #58586a;
--accent: #00d4aa;
--accent-hover: #00b894;
--accent-glow: rgba(0, 212, 170, 0.3);
--border: rgba(255, 255, 255, 0.08);
--danger: #ff4d6a;
--warning: #ffb84d;
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 16px;
--font-mono: 'JetBrains Mono', monospace;
}function init() {
container.innerHTML = `
<style>
.my-app {
height: 100%;
display: flex;
flex-direction: column;
padding: 16px;
}
.my-app h1 {
color: var(--fg-primary);
margin-bottom: 16px;
}
.my-app input {
padding: 8px 12px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--fg-primary);
margin-bottom: 12px;
}
.my-app button {
padding: 8px 16px;
background: var(--accent);
color: var(--bg-primary);
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
}
.my-app button:hover {
background: var(--accent-hover);
}
</style>
<div class="my-app">
<h1>My App</h1>
<input type="text" id="input-field" placeholder="Enter text...">
<button id="submit-btn">Submit</button>
</div>
`;
document.getElementById('submit-btn').addEventListener('click', handleSubmit);
}Communicate between components and apps.
// Listen for events
const unsubscribe = EphemeraEvents.on('myapp.data_updated', (data) => {
console.log('Data updated:', data);
});
// Later: stop listening
unsubscribe();
// Or: EphemeraEvents.off('myapp.data_updated', handler);// Emit event
EphemeraEvents.emit('myapp.data_updated', {
id: 123,
changes: ['name', 'email']
});| Event | Data | Description |
|---|---|---|
desktop:ready |
- | Desktop initialization complete |
window:opened |
{ windowId, appId } |
Window opened |
window:closed |
{ windowId } |
Window closed |
app:installed |
{ appId } |
App installed |
app:uninstalled |
{ appId } |
App uninstalled |
setting:changed |
{ key, value } |
Setting updated |
workspace:changed |
{ workspace } |
Active workspace changed |
file:changed |
{ path } |
File modified |
EphemeraEvents.once('myapp.ready', (data) => {
// Only fires once
});// Store app settings
async function saveSettings(settings) {
await EphemeraStorage.put('metadata', {
key: `myapp.settings.${windowId}`,
value: JSON.stringify(settings)
});
}
// Load app settings
async function loadSettings() {
const data = await EphemeraStorage.get('metadata', `myapp.settings.${windowId}`);
return data ? JSON.parse(data.value) : getDefaultSettings();
}
// Store in app's directory
async function saveAppData(filename, content) {
const appDir = '/home/user/myapp';
await EphemeraFS.mkdir(appDir);
await EphemeraFS.writeFile(`${appDir}/${filename}`, content);
}// File picker pattern
async function openFile() {
// Open files app to let user pick
EphemeraWM.open('files');
// Or read from known location
const recent = await EphemeraStorage.get('metadata', 'myapp.recentFiles');
return recent ? JSON.parse(recent.value) : [];
}
// Auto-save pattern
let saveTimeout;
function scheduleAutoSave(content) {
clearTimeout(saveTimeout);
saveTimeout = setTimeout(async () => {
await EphemeraFS.writeFile('/home/user/myapp/autosave.txt', content);
}, 2000);
}// Always use an init function
function init() {
renderUI();
loadSavedState();
attachEventListeners();
}
init();
// Or use async init
async function init() {
const data = await loadData();
renderUI(data);
}
init().catch(console.error);// Store event listeners for cleanup
const handlers = [];
function init() {
const handler = () => doSomething();
document.getElementById('btn').addEventListener('click', handler);
handlers.push({ element: document.getElementById('btn'), event: 'click', handler });
// Listen for window close
const unsub = EphemeraEvents.on('window:closed', ({ windowId: closedId }) => {
if (closedId === windowId) {
cleanup();
}
});
}
function cleanup() {
handlers.forEach(({ element, event, handler }) => {
element.removeEventListener(event, handler);
});
handlers.length = 0;
}async function loadFile(path) {
try {
const content = await EphemeraFS.readFile(path);
if (content === null) {
EphemeraNotifications.warning('File not found', 'Error');
return null;
}
return content;
} catch (error) {
console.error('Failed to load file:', error);
EphemeraNotifications.error(`Failed to load: ${error.message}`, 'Error');
return null;
}
}// Always sanitize user input
function displayUserContent(content) {
const safe = EphemeraSanitize.escapeHtml(content);
container.innerHTML = `<div>${safe}</div>`;
}
// Validate URLs
function openUrl(url) {
const safe = EphemeraSanitize.sanitizeUrl(url);
if (!safe) {
EphemeraNotifications.error('Invalid URL', 'Error');
return;
}
// Use safe URL
}// Check window size
function updateLayout() {
const width = container.clientWidth;
container.classList.toggle('compact', width < 400);
}
// Use ResizeObserver for responsive apps
const observer = new ResizeObserver(updateLayout);
observer.observe(container);/**
* Ephemera App: Todo List
*
* A simple todo list application demonstrating:
* - UI creation and styling
* - Event handling
* - Persistent storage
* - Window lifecycle management
*/
const STORAGE_KEY = 'todo.items';
let items = [];
let handlers = [];
function init() {
loadItems();
render();
attachEventListeners();
}
function loadItems() {
const saved = localStorage.getItem(STORAGE_KEY);
items = saved ? JSON.parse(saved) : [];
}
function saveItems() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
}
function render() {
container.innerHTML = `
<style>
.todo-app {
height: 100%;
display: flex;
flex-direction: column;
padding: 16px;
}
.todo-header {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.todo-input {
flex: 1;
padding: 8px 12px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--fg-primary);
outline: none;
}
.todo-input:focus {
border-color: var(--accent);
}
.todo-add {
padding: 8px 16px;
background: var(--accent);
color: var(--bg-primary);
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-weight: 600;
}
.todo-list {
flex: 1;
overflow-y: auto;
}
.todo-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
background: var(--bg-secondary);
border-radius: var(--radius-sm);
margin-bottom: 8px;
}
.todo-item.done .todo-text {
text-decoration: line-through;
color: var(--fg-muted);
}
.todo-checkbox {
width: 18px;
height: 18px;
cursor: pointer;
}
.todo-text {
flex: 1;
color: var(--fg-primary);
}
.todo-delete {
background: none;
border: none;
color: var(--danger);
cursor: pointer;
padding: 4px;
opacity: 0.6;
}
.todo-delete:hover {
opacity: 1;
}
.todo-empty {
color: var(--fg-muted);
text-align: center;
padding: 40px;
}
</style>
<div class="todo-app">
<div class="todo-header">
<input type="text" class="todo-input" id="todo-input" placeholder="Add a task...">
<button class="todo-add" id="todo-add">Add</button>
</div>
<div class="todo-list" id="todo-list"></div>
</div>
`;
renderList();
}
function renderList() {
const list = document.getElementById('todo-list');
if (items.length === 0) {
list.innerHTML = '<div class="todo-empty">No tasks yet. Add one above!</div>';
return;
}
list.innerHTML = items.map((item, index) => `
<div class="todo-item ${item.done ? 'done' : ''}" data-index="${index}">
<input type="checkbox" class="todo-checkbox" ${item.done ? 'checked' : ''}>
<span class="todo-text">${escapeHtml(item.text)}</span>
<button class="todo-delete">×</button>
</div>
`).join('');
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function attachEventListeners() {
const input = document.getElementById('todo-input');
const addBtn = document.getElementById('todo-add');
const list = document.getElementById('todo-list');
const addHandler = () => {
const text = input.value.trim();
if (text) {
items.push({ text, done: false });
saveItems();
renderList();
input.value = '';
}
};
addBtn.addEventListener('click', addHandler);
handlers.push({ element: addBtn, event: 'click', handler: addHandler });
const keyHandler = (e) => {
if (e.key === 'Enter') addHandler();
};
input.addEventListener('keydown', keyHandler);
handlers.push({ element: input, event: 'keydown', handler: keyHandler });
const listHandler = (e) => {
const item = e.target.closest('.todo-item');
if (!item) return;
const index = parseInt(item.dataset.index);
if (e.target.classList.contains('todo-checkbox')) {
items[index].done = e.target.checked;
saveItems();
item.classList.toggle('done', e.target.checked);
} else if (e.target.classList.contains('todo-delete')) {
items.splice(index, 1);
saveItems();
renderList();
}
};
list.addEventListener('click', listHandler);
handlers.push({ element: list, event: 'click', handler: listHandler });
// Cleanup on window close
const unsubClose = EphemeraEvents.on('window:closed', ({ windowId: closedId }) => {
if (closedId === windowId) {
cleanup();
}
});
}
function cleanup() {
handlers.forEach(({ element, event, handler }) => {
element.removeEventListener(event, handler);
});
handlers = [];
}
init();Apps should implement a destroy() method to clean up resources when the window closes. The window manager automatically calls this method.
// In your app's content function
return {
html: `...`,
init: () => {
const handlers = [];
const intervals = [];
// Track event listeners
const clickHandler = () => doSomething();
document.getElementById('btn').addEventListener('click', clickHandler);
handlers.push({ element: document.getElementById('btn'), event: 'click', handler: clickHandler });
// Track intervals
const timerId = setInterval(updateClock, 1000);
intervals.push(timerId);
// Return destroy method
return {
destroy: () => {
// Clean up listeners
handlers.forEach(({ element, event, handler }) => {
element.removeEventListener(event, handler);
});
// Clean up intervals
intervals.forEach(id => clearInterval(id));
}
};
}
};For complex apps, use the EphemeraLoading lifecycle helper:
import { createAppLifecycle } from '../system/app-lifecycle.js';
return {
html: `...`,
init: () => {
const lifecycle = createAppLifecycle();
// Automatically tracked cleanup
lifecycle.addListener(document, 'click', handleClick);
lifecycle.addInterval(setInterval(pollServer, 5000));
lifecycle.addSubscription(EphemeraEvents.on('data', handleData));
return {
destroy: () => lifecycle.destroy()
};
}
};Integrate AI capabilities into your apps.
// Check if AI is configured (works for both API key and session-auth providers)
const isConfigured = await EphemeraAI.isConfigured();
if (!isConfigured) {
EphemeraNotifications.warning('Please configure an AI provider in Settings');
return;
}
// Simple completion
const response = await EphemeraAI.complete('Write a haiku about code');
// Chat with context
const messages = [
{ role: 'system', content: 'You are a helpful coding assistant.' },
{ role: 'user', content: 'How do I center a div?' }
];
const answer = await EphemeraAI.chat(messages, 'openrouter/free');
// Streaming response
await EphemeraAI.chat(messages, model, (chunk, accumulated) => {
outputEl.textContent = accumulated;
});When using the chatgpt provider, model lists are dynamic and account-scoped.
Do not hardcode gpt-* model IDs.
// Fetch currently available ChatGPT models for the signed-in session
const chatgptModels = await EphemeraAI.getModels(true, 'chatgpt');
if (!chatgptModels.length) {
throw new Error('No ChatGPT models available for this account/workspace');
}
const selectedModel = chatgptModels[0].id;
const answer = await EphemeraAI.chat(messages, selectedModel);For direct endpoint integrations (without EphemeraAI), /api/ai-oauth/models can return 401 with error: "not_connected" when the server clears an invalid/expired auth session; handle that by starting sign-in again.
On successful catalog reads, catalogError is intentionally empty and should only be treated as a diagnostic signal when non-empty.
EphemeraAI also enforces a validated max-token setting range of 100..128000.
The AI API has built-in rate limiting (10 requests/minute, 500ms minimum between requests):
// Check rate limit status
const status = EphemeraAI.getRateLimitStatus();
if (!status.canRequest) {
console.log(`Please wait ${Math.ceil(status.waitTimeMs / 1000)} seconds`);
}
// Rate limit errors
try {
await EphemeraAI.chat(messages, model);
} catch (e) {
if (e.message.includes('Rate limited')) {
// Show user a message
}
}Ephemera uses multiple CORS proxies with automatic failover:
// Enable proxy in settings
EphemeraState.updateSetting('proxyEnabled', true);
// Proxy is used automatically for cross-origin requests
const html = await EphemeraNetwork.get('https://example.com');
// Custom proxy URL
EphemeraState.updateSetting('proxyUrl', 'https://my-proxy.com/?url=');The network system automatically:
- Tries multiple proxies on failure
- Marks unhealthy proxies and switches to backups
- Notifies users when switching proxies
// Check proxy health
await EphemeraNetwork.checkProxyHealth(proxy);
// Manual health reset
EphemeraNetwork._resetProxyHealth();The sync manager handles bidirectional file sync between the local virtual filesystem and a remote sync server.
// Trigger a full sync (push local changes, pull remote changes)
await EphemeraSyncManager.syncAll();
// Test that the configured provider can connect
await EphemeraSyncManager.testConnection();| Event | Payload | When |
|---|---|---|
sync:status |
{ status, error, lastSyncAt } |
Status changes (idle, syncing, synced, error) |
sync:conflict |
{ path, conflictPath, remoteModifiedAt } |
A local file was saved as a conflict copy before overwrite |
EphemeraEvents.on('sync:status', ({ status, error }) => {
console.log('Sync status:', status, error);
});All sync providers implement this interface:
class SyncProvider {
async list() // → [{ path, modifiedAt, type }]
async push(path, content, metadata) // Upload a file
async pull(path) // → { content, mimeType, modifiedAt }
async delete(path) // Remove a file
async mkdir(path) // Create a directory
async testConnection() // → true or throws
}// Export current profile as encrypted .ephx download (prompts for a passphrase)
await EphemeraDataManagement.exportProfile();
// Import a profile export (.ephx). Prompts for passphrase + new profile name + new password.
await EphemeraDataManagement.importProfile(file);# Run all tests
npm test
# Run tests in watch mode
npm test -- --watch
# Run specific test file
npm test -- tests/filesystem.test.js
# Generate coverage
npm test -- --coverageTests use Vitest with jsdom:
import { describe, it, expect, beforeEach, vi } from 'vitest';
describe('MyApp', () => {
beforeEach(() => {
// Setup test environment
});
it('should do something', async () => {
// Test code
expect(result).toBe(expected);
});
});// In tests/setup.js, mocks are provided for:
// - EphemeraStorage
// - EphemeraFS
// - EphemeraEvents
// - EphemeraState
// Use mocks in tests
vi.mocked(EphemeraFS.readFile).mockResolvedValue('test content');
const result = await EphemeraFS.readFile('/path/to/file');
expect(result).toBe('test content');- Clone the repository
- Install dependencies:
npm install - Start dev server:
npm run dev - Run tests:
npm test
- Use ES6+ JavaScript
- Use
const/letinstead ofvar - Prefer
async/awaitover raw promises - Use CSS variables for theming
- Sanitize all user input with
EphemeraSanitize
- Create a feature branch from
master - Make your changes with clear commit messages
- Add tests for new functionality
- Ensure all tests pass:
npm test - Submit PR with description of changes
js/
├── apps/ # Application modules
├── core/ # Core system (crypto, storage, state)
├── system/ # System services (wm, network, ai)
├── main.js # Entry point
└── ...
css/
├── core.css # Base styles and CSS variables
├── desktop.css # Desktop and icon styles
├── taskbar.css # Taskbar and start menu
└── windows.css # Window styles
tests/
├── setup.js # Test setup and mocks
├── *.test.js # Test files
└── ...
- Never bypass
EphemeraSanitizefor user content - Use
EphemeraSanitize.sanitizeUrl()for external URLs - Encrypt sensitive data with
EphemeraStorage - Don't expose API keys in client code
- Validate all file paths before operations
Ephemera implements multiple security layers:
- File content is encrypted using AES-GCM when a user password is set
files.contentandapps.codeare encrypted at rest when the session is unlockedmetadata.valueis encrypted for a fixed allowlist of sensitive keys (seeEphemeraStorage.SENSITIVE_METADATA_KEYSinjs/core/storage.js)- Encryption keys are derived from the user's password using PBKDF2
// Check whether a key is encrypted by policy
EphemeraStorage.shouldEncrypt('metadata', 'openaiApiKey'); // true
EphemeraStorage.shouldEncrypt('metadata', 'myapp.settings'); // false
// Keys on the sensitive allowlist are auto-encrypted (when session is unlocked)
await EphemeraStorage.put('metadata', {
key: 'openaiApiKey',
value: 'secret-value'
});- Sessions lock after inactivity (configurable in Settings)
- Account lockout after 5 failed password attempts
- Queued writes are deferred until session unlock
- DOMPurify sanitization on all user HTML
- URL validation blocks javascript: and data: schemes
- CSP headers enforced in production
The Service Worker provides offline support and automatic updates:
// Users are notified when updates are available
// Clicking "Refresh Now" activates the new version
// The app automatically reloads after update- All new features must have corresponding tests
- Aim for >80% code coverage on core modules
- Test both success and error paths
// Test async operations
it('should load file content', async () => {
vi.mocked(EphemeraFS.readFile).mockResolvedValue('test content');
const result = await loadFile('/path/to/file');
expect(result).toBe('test content');
});
// Test error handling
it('should handle file not found', async () => {
vi.mocked(EphemeraFS.readFile).mockResolvedValue(null);
const result = await loadFile('/nonexistent');
expect(result).toBeNull();
});
// Test rate limiting
it('should enforce rate limits', async () => {
// First request succeeds
await EphemeraAI.chat([...], model);
// Exhaust rate limit
for (let i = 0; i < 10; i++) {
await EphemeraAI.chat([...], model);
}
// Should throw rate limit error
await expect(EphemeraAI.chat([...], model))
.rejects.toThrow('Rate limited');
});- GitHub Issues: Report bugs or request features
- Built-in Apps: Check the Code Editor to see how system apps work
- Example Apps: Look in
/home/user/apps/for examples
Last updated: Ephemera 2.0