Skip to main content
Brainbox uses Fastify with type-safe Zod schemas for API routes. This guide shows how to add new endpoints to the server.

Overview

API routes are organized in apps/server/src/api/client/routes/ and grouped by feature:
  • accounts/ - Authentication and account management
  • workspaces/ - Workspace operations
  • avatars/ - File upload/download
  • sockets/ - WebSocket connections
Each route group is registered with a prefix in the main router.

Architecture

API structure:
Client → HTTP/WebSocket → Fastify → Plugins → Routes → Database

                            Auth middleware
                            Rate limiting
                            Error handling

Step-by-step guide

1

Define request/response schemas

Create Zod schemas in packages/core/src/api/ for type-safe validation.
packages/core/src/api/tasks/task-create.ts
import { z } from 'zod/v4';

export const taskCreateInputSchema = z.object({
  name: z.string().min(1).max(200),
  parentId: z.string(),
  status: z.enum(['todo', 'in_progress', 'done']).default('todo'),
  assigneeId: z.string().optional(),
});

export const taskCreateOutputSchema = z.object({
  id: z.string(),
});

export type TaskCreateInput = z.infer<typeof taskCreateInputSchema>;
export type TaskCreateOutput = z.infer<typeof taskCreateOutputSchema>;
Export from packages/core/src/api/index.ts:
export * from './tasks/task-create';
2

Create the route handler

Implement the route in apps/server/src/api/client/routes/.
apps/server/src/api/client/routes/tasks/task-create.ts
import { FastifyPluginCallbackZod } from 'fastify-type-provider-zod';
import {
  taskCreateInputSchema,
  taskCreateOutputSchema,
  ApiErrorCode,
  apiErrorOutputSchema,
  generateId,
  IdType,
} from '@brainbox/core';
import { database } from '@brainbox/server/data/database';
import { eventBus } from '@brainbox/server/lib/event-bus';

export const taskCreateRoute: FastifyPluginCallbackZod = (
  instance,
  _,
  done
) => {
  instance.route({
    method: 'POST',
    url: '/',
    schema: {
      body: taskCreateInputSchema,
      response: {
        200: taskCreateOutputSchema,
        400: apiErrorOutputSchema,
        403: apiErrorOutputSchema,
      },
    },
    handler: async (request, reply) => {
      const input = request.body;

      // Validate parent exists
      const parent = await database
        .selectFrom('nodes')
        .where('id', '=', input.parentId)
        .where('workspace_id', '=', request.user.workspace_id)
        .selectAll()
        .executeTakeFirst();

      if (!parent) {
        return reply.code(400).send({
          code: ApiErrorCode.NodeNotFound,
          message: 'Parent node not found.',
        });
      }

      // Check permissions
      if (request.user.role === 'viewer' || request.user.role === 'guest') {
        return reply.code(403).send({
          code: ApiErrorCode.Forbidden,
          message: 'You do not have permission to create tasks.',
        });
      }

      const id = generateId(IdType.Task);
      
      await database
        .insertInto('nodes')
        .values({
          id,
          type: 'task',
          parent_id: input.parentId,
          root_id: parent.root_id,
          workspace_id: request.user.workspace_id,
          attributes: JSON.stringify({
            type: 'task',
            name: input.name,
            parentId: input.parentId,
            status: input.status,
            assigneeId: input.assigneeId,
          }),
          created_by: request.user.id,
          created_at: new Date(),
        })
        .execute();

      eventBus.publish({
        type: 'node.created',
        nodeId: id,
        rootId: parent.root_id,
        workspaceId: request.user.workspace_id,
      });

      return { id };
    },
  });

  done();
};
3

Register the route

Create an index file to group related routes:
apps/server/src/api/client/routes/tasks/index.ts
import { FastifyPluginCallback } from 'fastify';
import { workspaceAuthenticator } from '@brainbox/server/api/client/plugins/workspace-auth';

import { taskCreateRoute } from './task-create';
import { taskUpdateRoute } from './task-update';
import { taskDeleteRoute } from './task-delete';

export const taskRoutes: FastifyPluginCallback = (instance, _, done) => {
  instance.register(workspaceAuthenticator);

  instance.register(taskCreateRoute);
  instance.register(taskUpdateRoute);
  instance.register(taskDeleteRoute);

  done();
};
Add to main router in apps/server/src/api/client/routes/index.ts:
import { taskRoutes } from '@brainbox/server/api/client/routes/tasks';

export const clientRoutes: FastifyPluginCallback = (instance, _, done) => {
  instance.register(taskRoutes, { prefix: '/tasks' });
  // ... other routes
  done();
};
4

Add authentication (if needed)

Use plugins for authentication and authorization.
import { workspaceAuthenticator } from '@brainbox/server/api/client/plugins/workspace-auth';
import { accountAuthenticator } from '@brainbox/server/api/client/plugins/account-auth';

export const taskRoutes: FastifyPluginCallback = (instance, _, done) => {
  // Workspace auth - requires valid workspace access
  instance.register(workspaceAuthenticator);
  
  instance.register(taskCreateRoute);
  
  done();
};

// Or for account-only routes
export const accountRoutes: FastifyPluginCallback = (instance, _, done) => {
  instance.register(accountAuthenticator);
  
  instance.register(accountUpdateRoute);
  
  done();
};

Route patterns

Basic CRUD operations

instance.route({
  method: 'POST',
  url: '/',
  schema: {
    body: createInputSchema,
    response: { 200: createOutputSchema },
  },
  handler: async (request, reply) => {
    const id = generateId(IdType.Task);
    await database.insertInto('tasks').values({ id, ...request.body }).execute();
    return { id };
  },
});

Error handling

Return appropriate HTTP status codes with error details:
if (!user) {
  return reply.code(404).send({
    code: ApiErrorCode.UserNotFound,
    message: 'User not found.',
  });
}

if (user.role === 'viewer') {
  return reply.code(403).send({
    code: ApiErrorCode.Forbidden,
    message: 'You do not have permission to perform this action.',
  });
}

if (await isRateLimited(user.id)) {
  return reply.code(429).send({
    code: ApiErrorCode.TooManyRequests,
    message: 'Too many requests. Please try again later.',
  });
}

Authentication patterns

import { accountAuthenticator } from '@brainbox/server/api/client/plugins/account-auth';

// Request will have request.account populated
export const accountRoutes: FastifyPluginCallback = (instance, _, done) => {
  instance.register(accountAuthenticator);
  
  instance.route({
    method: 'GET',
    url: '/me',
    handler: async (request, reply) => {
      // request.account is available
      return {
        id: request.account.id,
        email: request.account.email,
      };
    },
  });

  done();
};

Rate limiting

Apply rate limiting to sensitive endpoints:
import { authIpRateLimiter } from '@brainbox/server/api/client/plugins/auth-ip-rate-limit';

export const authRoutes: FastifyPluginCallback = (instance, _, done) => {
  instance.register(authIpRateLimiter);
  
  instance.register(emailLoginRoute);
  instance.register(emailRegisterRoute);
  
  done();
};
Or check manually:
import { isAuthEmailRateLimited } from '@brainbox/server/lib/rate-limits';

const isRateLimited = await isAuthEmailRateLimited(email);
if (isRateLimited) {
  return reply.code(429).send({
    code: ApiErrorCode.TooManyRequests,
    message: 'Too many authentication attempts. Please try again later.',
  });
}

Plugins

Available Fastify plugins in apps/server/src/api/client/plugins/:
  • account-auth - Validates JWT and populates request.account
  • workspace-auth - Validates workspace access and populates request.user
  • auth-ip-rate-limit - Rate limits by IP address
  • cors - CORS configuration
  • error-handler - Global error handling

Request context

// Available with accountAuthenticator
interface FastifyRequest {
  account: {
    id: string;
    email: string;
    status: AccountStatus;
    // ... other fields
  };
  client: {
    ip: string;
    userAgent: string;
  };
}

Database queries

Use Kysely for type-safe database queries:
import { database } from '@brainbox/server/data/database';

// Select
const tasks = await database
  .selectFrom('tasks')
  .where('workspace_id', '=', workspaceId)
  .where('deleted_at', 'is', null)
  .selectAll()
  .execute();

// Insert
await database
  .insertInto('tasks')
  .values({
    id: generateId(IdType.Task),
    name: 'New task',
    created_at: new Date(),
  })
  .execute();

// Update
await database
  .updateTable('tasks')
  .set({ name: 'Updated name', updated_at: new Date() })
  .where('id', '=', taskId)
  .execute();

// Delete (soft delete)
await database
  .updateTable('tasks')
  .set({ deleted_at: new Date(), deleted_by: userId })
  .where('id', '=', taskId)
  .execute();

Testing API routes

import { build } from '@brainbox/server/app';

test('creates task', async () => {
  const app = await build();
  
  const response = await app.inject({
    method: 'POST',
    url: '/api/tasks',
    headers: {
      authorization: `Bearer ${token}`,
    },
    payload: {
      name: 'Test task',
      parentId: 'parent-1',
    },
  });

  expect(response.statusCode).toBe(200);
  expect(response.json().id).toBeDefined();
});

File locations

  • Schemas: packages/core/src/api/[feature]/[action].ts
  • Routes: apps/server/src/api/client/routes/[feature]/[action].ts
  • Plugins: apps/server/src/api/client/plugins/[name].ts
  • Database: apps/server/src/data/database.ts
  • Event bus: apps/server/src/lib/event-bus.ts

Build docs developers (and LLMs) love