Skip to main content

Overview

BunShip is a monorepo built with Turborepo that separates concerns into apps (deployable services) and packages (shared libraries). Every package is written in TypeScript, and the entire stack runs on Bun for both development and production.

Monorepo Structure

bunship/
├── apps/
│   ├── api/                  # Elysia HTTP server (the main deliverable)
│   │   ├── src/
│   │   │   ├── index.ts      # Entry point, mounts plugins and routes
│   │   │   ├── routes/       # Grouped route handlers
│   │   │   ├── middleware/   # Auth, organization, permission chains
│   │   │   ├── services/     # Business logic (no HTTP concerns)
│   │   │   ├── lib/          # JWT, crypto, email, password utilities
│   │   │   └── jobs/         # BullMQ background workers
│   │   └── package.json
│   └── docs/                  # Mintlify documentation (this site)

├── packages/
│   ├── config/                # App, feature, billing, and permission config
│   ├── database/              # Drizzle ORM schema, migrations, client
│   ├── emails/                # React Email templates
│   ├── eden/                  # Type-safe Elysia client for frontends
│   └── utils/                 # Shared error classes, validators, helpers

├── docker/                    # Docker and Compose files
├── turbo.json                 # Turborepo pipeline config
└── package.json               # Workspace root

Package Descriptions

PackagePathPurpose
@bunship/configpackages/config/Centralized configuration — app settings, feature flags, billing plans, and RBAC permissions. Imported by every other package that needs runtime config.
@bunship/databasepackages/database/Drizzle ORM schema definitions, migration scripts, and the database client factory. All tables (users, organizations, memberships, sessions, API keys, etc.) are defined here.
@bunship/utilspackages/utils/Shared error classes (AuthenticationError, ValidationError, NotFoundError), password strength validation, and general-purpose helpers.
@bunship/emailspackages/emails/React Email templates for transactional messages: verification, password reset, team invitations, and billing notifications.
@bunship/edenpackages/eden/A thin wrapper around Elysia’s Eden Treaty client that provides end-to-end type safety between the API and any TypeScript consumer.

Package Dependency Graph

The dependency flow is intentionally one-directional. Packages at the bottom of the graph never import from packages above them.
  apps/api
  ├── @bunship/config
  ├── @bunship/database
  │     └── @bunship/config   (for feature flags)
  ├── @bunship/utils
  └── @bunship/emails

  apps/docs  (no runtime dependencies)

  @bunship/eden
  └── (depends on apps/api type exports only -- no runtime import)
@bunship/eden depends on the API’s types, not its runtime code. This means your frontend gets full autocomplete without bundling the server.

Request Lifecycle

Every HTTP request to the API passes through a predictable middleware chain before reaching the route handler.
1

Elysia receives the request

Bun’s HTTP server hands the request to Elysia, which parses the URL, method, headers, and body.
2

Global middleware runs

CORS, rate limiting, request logging, and body size validation are applied to all routes.
3

Auth middleware resolves the user

The authMiddleware extracts the Bearer token from the Authorization header, verifies it with jose, and loads the user from the database.
// apps/api/src/middleware/auth.ts
export const authMiddleware = new Elysia({ name: "auth" }).derive(
  { as: "scoped" },
  async ({ headers, set }): Promise<{ user: AuthUser }> => {
    const authorization = headers.authorization;

    if (!authorization?.startsWith("Bearer ")) {
      set.status = 401;
      throw new AuthenticationError(
        "Missing or invalid authorization header"
      );
    }

    const token = authorization.slice(7);
    const payload = await verifyAccessToken(token);

    const db = getDatabase();
    const dbUser = await db.query.users.findFirst({
      where: eq(users.id, payload.userId),
    });

    if (!dbUser || !dbUser.isActive) {
      set.status = 401;
      throw new AuthenticationError("User not found or inactive");
    }

    return {
      user: {
        id: dbUser.id,
        email: dbUser.email,
        fullName: dbUser.fullName,
        isActive: dbUser.isActive,
        sessionId: payload.sessionId,
      },
    };
  }
);
4

Organization middleware resolves the tenant

For organization-scoped routes (/api/v1/organizations/:orgId/*), the organizationMiddleware loads the organization and the user’s membership in a single pass.
// apps/api/src/middleware/organization.ts
const organization = await db.query.organizations.findFirst({
  where: and(
    eq(organizations.id, orgId),
    isNull(organizations.deletedAt)
  ),
});

const membership = await db.query.memberships.findFirst({
  where: and(
    eq(memberships.userId, user.id),
    eq(memberships.organizationId, orgId)
  ),
});
5

Permission middleware checks RBAC

requirePermission() or requireRole() verifies the user’s role grants the specific permission needed for this operation.
6

Route handler executes

The handler calls into a service function that contains the business logic. Services interact with the database through Drizzle and return plain objects.
7

Response is serialized

Elysia validates the response against the route’s TypeBox schema, serializes it to JSON, and sends it back to the client.

Key Design Decisions

BunShip targets Bun exclusively rather than maintaining Node.js compatibility. This unlocks Bun’s native crypto.subtle API, the built-in SQLite driver, faster startup times, and a single tool for runtime, package management, and test execution. The trade-off is that Bun must be available in your deployment environment.
Instead of PostgreSQL, BunShip uses SQLite locally and Turso (a libSQL-based distributed SQLite service) in production. Benefits:
  • Zero infrastructure for local development — the database is a file
  • Edge replication through Turso for global low-latency reads
  • Simpler operational model compared to managed PostgreSQL
  • Full SQL support through Drizzle ORM
The database schema uses integer timestamps and text-based IDs (CUID2) to stay compatible with SQLite’s type system.
The API defines request/response schemas using Elysia’s TypeBox integration. These types flow through to:
  1. Route validation — Elysia rejects invalid payloads at the boundary
  2. Service layer — TypeScript enforces correct data shapes
  3. Database — Drizzle infers column types from the schema
  4. Client — Eden Treaty derives client types from the server’s route tree
No code generation step is needed. Types update automatically when you change a route definition.
Elysia plugins compose using .use(). BunShip chains middleware as independent Elysia instances:
app
  .use(authMiddleware)          // adds `user` to context
  .use(organizationMiddleware)  // adds `organization` and `membership`
  .use(requirePermission("projects:read"))
  .get("/projects", handler)
Each middleware reads from and writes to the shared context (store), keeping individual pieces testable and replaceable.
All feature flags, billing plans, role permissions, and app settings live in @bunship/config as typed TypeScript objects. This means:
  • IDE autocomplete for every config value
  • Compile-time errors when you reference a config key that does not exist
  • No YAML/JSON parsing at runtime
  • A single import (@bunship/config) for any package that needs configuration

Application Configuration

The @bunship/config package exports four configuration modules:
Core application settings including the API prefix, JWT expiry times, CORS origins, and rate limits.
// packages/config/src/app.ts
export const appConfig = {
  name: "BunShip",
  url: process.env.API_URL ?? "http://localhost:3000",
  api: {
    prefix: "/api/v1",
    port: parseInt(process.env.PORT ?? "3000", 10),
    rateLimit: {
      enabled: true,
      windowMs: 60_000,
      maxRequests: 100,
    },
  },
  jwt: {
    accessTokenExpiry: "15m",
    refreshTokenExpiry: "7d",
    issuer: "bunship",
  },
} as const;

Database Layer

BunShip uses Drizzle ORM with SQLite. The schema is defined in packages/database/src/schema/ with one file per table:
TableFileDescription
usersusers.tsUser accounts, password hashes, 2FA secrets, lockout state
sessionssessions.tsRefresh token hashes, IP address, user agent, expiry
organizationsorganizations.tsTenant workspaces with name, slug, settings
membershipsmemberships.tsUser-to-organization link with role assignment
invitationsinvitations.tsPending team invitations with token and expiry
api_keysapiKeys.tsScoped API keys per organization
subscriptionssubscriptions.tsStripe subscription state per organization
webhookswebhooks.tsOutgoing webhook endpoint configurations
webhook_deliverieswebhookDeliveries.tsDelivery attempts and status per webhook
audit_logsauditLogs.tsImmutable activity log entries
filesfiles.tsUploaded file metadata
projectsprojects.tsExample resource table (replace with your domain)
verification_tokensverificationTokens.tsEmail verification and password reset tokens
backup_codesbackupCodes.tsHashed 2FA recovery codes
All IDs use CUID2 for collision-resistant, URL-safe identifiers. Timestamps are stored as SQLite integers (Unix epoch).