Build Your First App¶
In this tutorial, you will build a simple task manager REST API using Express.js and the CyberEco data layer. By the end, you will have a working API with CRUD endpoints, permission checks, and tests that run without Firebase emulators.
What You Will Build¶
- An Express.js server with REST endpoints for task management
- Full CRUD operations backed by the CyberEco data layer
- Permission checks on every operation
- Unit tests using
MockStorageAdapter(no Firebase emulators required)
1. Create the Project¶
2. Install Dependencies¶
npm install express @cyber-eco/types @cyber-eco/firebase @cyber-eco/services firebase
npm install -D typescript @types/express @types/node vitest tsx
Initialize TypeScript:
npx tsc --init --target ES2022 --module NodeNext --moduleResolution NodeNext \
--outDir dist --rootDir src --strict true --esModuleInterop true
3. Set Up Firebase Config¶
import { initializeApp } from 'firebase/app';
import { getFirestore } from 'firebase/firestore';
const firebaseConfig = {
apiKey: process.env.FIREBASE_API_KEY || '',
authDomain: process.env.FIREBASE_AUTH_DOMAIN || '',
projectId: process.env.FIREBASE_PROJECT_ID || '',
storageBucket: process.env.FIREBASE_STORAGE_BUCKET || '',
messagingSenderId: process.env.FIREBASE_MESSAGING_SENDER_ID || '',
appId: process.env.FIREBASE_APP_ID || '',
};
let app: ReturnType<typeof initializeApp> | null = null;
export function getDb() {
if (!app) {
app = initializeApp(firebaseConfig);
}
return getFirestore(app);
}
4. Initialize the Data Layer¶
import { FirebaseStorageAdapter } from '@cyber-eco/firebase';
import { createDataLayer } from '@cyber-eco/services';
import type { CyberEcoDataLayer } from '@cyber-eco/services';
import type { StorageAdapter } from '@cyber-eco/types';
import { getDb } from './firebase';
let dataLayer: CyberEcoDataLayer | null = null;
/**
* Initialize the data layer with a given adapter.
* Accepts an adapter parameter for testing with MockStorageAdapter.
*/
export function initDataLayer(adapter?: StorageAdapter): CyberEcoDataLayer {
const storageAdapter = adapter || new FirebaseStorageAdapter(() => getDb());
dataLayer = createDataLayer({
adapter: storageAdapter,
cache: {},
permissions: { enabled: true },
sync: { enabled: false },
webhooks: { enabled: false },
});
return dataLayer;
}
export function getDataLayer(): CyberEcoDataLayer {
if (!dataLayer) {
throw new Error('Data layer not initialized. Call initDataLayer() first.');
}
return dataLayer;
}
Dependency injection
The initDataLayer() function accepts an optional StorageAdapter parameter. In production, it defaults to FirebaseStorageAdapter. In tests, you pass MockStorageAdapter. This is the key pattern that makes CyberEco testable without emulators.
5. Create the Task Router¶
import { Router, Request, Response } from 'express';
import { getDataLayer } from '../data-layer';
const router = Router();
interface Task {
id: string;
title: string;
description?: string;
status: 'pending' | 'in-progress' | 'completed';
assignee: string;
createdAt: string;
updatedAt: string;
}
/**
* Helper to extract userId from request.
* In production, this would come from an auth middleware.
*/
function getUserId(req: Request): string {
return req.headers['x-user-id'] as string || 'anonymous';
}
// GET /tasks - List all tasks for user
router.get('/', async (req: Request, res: Response) => {
try {
const userId = getUserId(req);
const { core } = getDataLayer();
const result = await core.query<Task>(userId, 'tasks', [
{ field: 'assignee', operator: '==', value: userId },
]);
res.json({ tasks: result.data, hasMore: result.hasMore });
} catch (error) {
res.status(500).json({ error: 'Failed to list tasks' });
}
});
// GET /tasks/:id - Get a single task
router.get('/:id', async (req: Request, res: Response) => {
try {
const userId = getUserId(req);
const { core } = getDataLayer();
const task = await core.get<Task>(userId, 'tasks', req.params.id);
if (!task) {
res.status(404).json({ error: 'Task not found' });
return;
}
res.json(task);
} catch (error) {
res.status(500).json({ error: 'Failed to get task' });
}
});
// POST /tasks - Create a new task
router.post('/', async (req: Request, res: Response) => {
try {
const userId = getUserId(req);
const { core } = getDataLayer();
const { title, description } = req.body;
if (!title) {
res.status(400).json({ error: 'Title is required' });
return;
}
const taskId = await core.create(userId, 'tasks', {
title,
description: description || '',
status: 'pending' as const,
assignee: userId,
});
const task = await core.get<Task>(userId, 'tasks', taskId);
res.status(201).json(task);
} catch (error) {
res.status(500).json({ error: 'Failed to create task' });
}
});
// PUT /tasks/:id - Update a task
router.put('/:id', async (req: Request, res: Response) => {
try {
const userId = getUserId(req);
const { core } = getDataLayer();
const { title, description, status } = req.body;
await core.update(userId, 'tasks', req.params.id, {
...(title && { title }),
...(description !== undefined && { description }),
...(status && { status }),
});
const task = await core.get<Task>(userId, 'tasks', req.params.id);
res.json(task);
} catch (error) {
res.status(500).json({ error: 'Failed to update task' });
}
});
// DELETE /tasks/:id - Delete a task
router.delete('/:id', async (req: Request, res: Response) => {
try {
const userId = getUserId(req);
const { core } = getDataLayer();
await core.delete(userId, 'tasks', req.params.id);
res.status(204).send();
} catch (error) {
res.status(500).json({ error: 'Failed to delete task' });
}
});
export default router;
6. Create the Server¶
import express from 'express';
import { initDataLayer } from './data-layer';
import taskRouter from './routes/tasks';
const app = express();
app.use(express.json());
// Initialize data layer (uses Firebase in production)
initDataLayer();
// Mount routes
app.use('/tasks', taskRouter);
// Health check
app.get('/health', (_req, res) => {
res.json({ status: 'ok' });
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Task API running on port ${PORT}`);
});
7. Add Permission Checks¶
The DataLayerService automatically checks permissions on every operation when permissions.enabled is true. To make this work, you need to wire the PermissionService to grant users access.
Here is an example middleware that grants initial access when a user first interacts with the API:
import { Request, Response, NextFunction } from 'express';
import { getDataLayer } from '../data-layer';
import type { AppRole } from '@cyber-eco/types';
/**
* Middleware that ensures the current user has basic access.
* In a real app, this would be handled during user registration.
*/
export async function ensureAccess(req: Request, res: Response, next: NextFunction) {
const userId = req.headers['x-user-id'] as string;
if (!userId) {
res.status(401).json({ error: 'x-user-id header is required' });
return;
}
try {
const { permissions } = getDataLayer();
const hasAccess = await permissions.hasAppAccess(userId, 'task-api');
if (!hasAccess) {
// Auto-grant member access for new users
await permissions.grantAppAccess(
userId,
'task-api',
['member'] as AppRole[],
'system',
['read', 'write', 'delete'],
);
}
next();
} catch (error) {
// If permission check fails, allow request (graceful degradation)
next();
}
}
Apply the middleware to your server:
import express from 'express';
import { initDataLayer } from './data-layer';
import { ensureAccess } from './middleware/ensure-access';
import taskRouter from './routes/tasks';
const app = express();
app.use(express.json());
initDataLayer();
app.use(ensureAccess);
app.use('/tasks', taskRouter);
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Task API running on port ${PORT}`);
});
8. Test with MockStorageAdapter¶
The most powerful feature of the CyberEco data layer is that you can test without Firebase emulators. The MockStorageAdapter provides a complete in-memory implementation of the StorageAdapter interface.
import { describe, it, expect, beforeEach } from 'vitest';
import { MockStorageAdapter } from '@cyber-eco/services/testing';
import { initDataLayer, getDataLayer } from '../data-layer';
describe('Task operations', () => {
let adapter: MockStorageAdapter;
const userId = 'test-user';
beforeEach(() => {
// Create a fresh mock adapter for each test
adapter = new MockStorageAdapter();
initDataLayer(adapter);
});
it('should create a task', async () => {
const { core } = getDataLayer();
const taskId = await core.create(userId, 'tasks', {
title: 'Test task',
status: 'pending',
assignee: userId,
});
expect(taskId).toBeTruthy();
const task = await core.get(userId, 'tasks', taskId);
expect(task).toMatchObject({
title: 'Test task',
status: 'pending',
assignee: userId,
});
});
it('should query tasks by status', async () => {
const { core } = getDataLayer();
await core.create(userId, 'tasks', {
title: 'Task 1',
status: 'pending',
assignee: userId,
});
await core.create(userId, 'tasks', {
title: 'Task 2',
status: 'completed',
assignee: userId,
});
const pending = await core.query(userId, 'tasks', [
{ field: 'status', operator: '==', value: 'pending' },
]);
expect(pending.data).toHaveLength(1);
});
it('should update a task status', async () => {
const { core } = getDataLayer();
const taskId = await core.create(userId, 'tasks', {
title: 'Do something',
status: 'pending',
assignee: userId,
});
await core.update(userId, 'tasks', taskId, { status: 'completed' });
const updated = await core.get<{ status: string }>(userId, 'tasks', taskId);
expect(updated?.status).toBe('completed');
});
it('should delete a task', async () => {
const { core } = getDataLayer();
const taskId = await core.create(userId, 'tasks', {
title: 'Temporary task',
status: 'pending',
assignee: userId,
});
await core.delete(userId, 'tasks', taskId);
const deleted = await core.get(userId, 'tasks', taskId);
expect(deleted).toBeNull();
});
it('should seed test data directly', async () => {
const { core } = getDataLayer();
// Use adapter.seed() for fast test data setup
adapter.seed('tasks', 'task-1', {
title: 'Pre-existing task',
status: 'in-progress',
assignee: userId,
});
const task = await core.get(userId, 'tasks', 'task-1');
expect(task).toMatchObject({ title: 'Pre-existing task' });
});
});
Run the tests:
No emulators needed
Tests execute in milliseconds because MockStorageAdapter is a pure in-memory implementation. No Firebase emulators, no network calls, no setup overhead.
9. Run the API¶
Test the API with curl:
# Create a task
curl -X POST http://localhost:3000/tasks \
-H "Content-Type: application/json" \
-H "x-user-id: user-1" \
-d '{"title": "My first task", "description": "Built with CyberEco"}'
# List tasks
curl http://localhost:3000/tasks -H "x-user-id: user-1"
# Update a task
curl -X PUT http://localhost:3000/tasks/TASK_ID \
-H "Content-Type: application/json" \
-H "x-user-id: user-1" \
-d '{"status": "completed"}'
# Delete a task
curl -X DELETE http://localhost:3000/tasks/TASK_ID -H "x-user-id: user-1"
Project Structure¶
After completing this tutorial, your project should look like this:
cybereco-task-api/
src/
__tests__/
tasks.test.ts
middleware/
ensure-access.ts
routes/
tasks.ts
data-layer.ts
firebase.ts
server.ts
package.json
tsconfig.json
Key Takeaways¶
- StorageAdapter decouples your code from Firebase. Your routes and tests never import
firebase/*directly. createDataLayer()wires everything together. One factory call gives you CRUD, permissions, caching, and domain services.MockStorageAdaptermakes testing fast. No emulators, no network -- tests run in milliseconds.- Permissions are built in. The
DataLayerServicechecks permissions on every operation automatically. - Dependency injection is the key pattern. Pass
MockStorageAdapterin tests,FirebaseStorageAdapterin production.
Next Steps¶
- StorageAdapter Pattern -- Deep dive into the adapter interface
- Permissions Guide -- Set up role hierarchies and conditional access
- Testing Guide -- Advanced testing patterns
- Deployment Guide -- Deploy your app to production