Skip to content

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

mkdir cybereco-task-api
cd cybereco-task-api
npm init -y

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

src/firebase.ts
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

src/data-layer.ts
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

src/routes/tasks.ts
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

src/server.ts
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:

src/middleware/ensure-access.ts
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:

src/server.ts (updated)
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.

src/__tests__/tasks.test.ts
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:

npx vitest run

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

npx tsx src/server.ts
FIREBASE_API_KEY=... \
FIREBASE_AUTH_DOMAIN=... \
FIREBASE_PROJECT_ID=... \
npx tsx src/server.ts

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

  1. StorageAdapter decouples your code from Firebase. Your routes and tests never import firebase/* directly.
  2. createDataLayer() wires everything together. One factory call gives you CRUD, permissions, caching, and domain services.
  3. MockStorageAdapter makes testing fast. No emulators, no network -- tests run in milliseconds.
  4. Permissions are built in. The DataLayerService checks permissions on every operation automatically.
  5. Dependency injection is the key pattern. Pass MockStorageAdapter in tests, FirebaseStorageAdapter in production.

Next Steps