Skip to main content

Backend Architecture

The external-hooks service follows a layered architecture with five distinct tiers. Each layer has a single responsibility and depends only on the layers below it.

┌─────────────────────────────────────────────────────┐
│ Routes & Controllers │
│ src/api/routes/ │
├─────────────────────────────────────────────────────┤
│ Middlewares │
│ src/api/middlewares/ │
├─────────────────────────────────────────────────────┤
│ Services │
│ src/api/services/ │
├──────────────────────────┬──────────────────────────┤
│ n8n DB Repository │ Custom DB Repository │
│ src/db/repository/n8n/ │ src/db/repository/custom│
├──────────────────────────┴──────────────────────────┤
│ DB Schemas │
│ src/db/schema/ │
└─────────────────────────────────────────────────────┘

1. DB Schema Layer

Location: src/db/schema/

Defines the project's own PostgreSQL tables using Drizzle ORM (drizzle-orm/pg-core). These tables exist alongside the n8n database tables but are managed independently.

Tables

TablePurpose
user_workflowMaps users to workflows with a status and form progression
tenant_project_relationMaps tenants (UUID) to n8n project IDs (composite PK)
messagesIn-app messages scoped to a project, with actor info and status
action_requestsAction requests with callback URLs, priority, due dates, and lifecycle status
audit_logSchema-only audit trail for messages and action requests

Conventions

  • Every table has created_at / updated_at timestamps with automatic defaults.
  • actor_type and status columns use CHECK constraints for allowed values.
  • Partial indexes on frequently queried filtered sets (e.g., pending actions, active messages).
  • Exported as Drizzle InferSelectModel / InferInsertModel types for type-safe queries.

2. n8n DB Repository Layer

Location: src/db/repository/n8n/

Adapter classes that wrap n8n's native TypeORM repositories. These provide a typed, safe interface over the n8n runtime's database layer without modifying n8n source code.

Design

Each repository receives its corresponding n8n base repository via constructor (composition, not inheritance). Base repository interfaces are defined in src/api/types/n8n-adapters.ts.

// Example: wrapping a base n8n repository
export class UserRepository {
constructor(private readonly userRepository: BaseN8nUserRepository) {}
get metadata() {
return this.userRepository.metadata;
}
async findByEmail(email: string) {
/* ... */
}
}

Repositories

ClassWrapsKey Methods
UserRepositoryBaseN8nUserRepositoryfindByEmail, getUserForApiKey
ProjectRepositoryBaseN8nProjectRepositoryfindOneBy, getPersonalProjectForUser, create, save
ProjectRelationRepositoryBaseN8nProjectRelationRepositoryfindAllByUser, listUserEmailsByProjectIds
WorkflowRepositoryBaseN8nWorkflowRepositoryfindOneBy
SharedWorkflowRepositoryBaseN8nSharedWorkflowRepositoryfindProjectIds, findWorkflowRowsByProjectIds
CredentialRepositoryBaseN8nCredentialRepositoryfindOneBy
SharedCredentialRepositoryBaseN8nSharedCredentialRepositoryexposes manager for transactions
ExecutionRepositoryBaseN8nExecutionRepositoryloadMetadataOrNull (safe wrapper)

Patterns

  • Error absorption: ExecutionRepository.loadMetadataOrNull() catches Postgres errors (e.g., malformed UUIDs) and returns null instead of throwing.
  • Dynamic SQL: SharedWorkflowRepository and ProjectRelationRepository build raw SQL JOINs from entity metadata at runtime, avoiding hard-coded column names.
  • sql.ts utility: Shared helpers getColumnName(metadata, property) and quoteIdentifier(id) for metadata-driven SQL generation.

3. Custom DB Repository Layer

Location: src/db/repository/custom/

Standalone repository classes that query the project's own PostgreSQL tables using Drizzle ORM. No relationship to n8n repositories.

Repositories

ClassTableKey Methods
MessageRepositorymessageslist, create
ActionRequestRepositoryaction_requestslist, getById, create, updateStatus
TenantProjectRelationRepositorytenant_project_relationgetProjectIdsByTenantId, getTenantIdByProjectId, listDistinctTenantIds, insert

Patterns

  • Constructor injection: Each receives a Drizzle db instance.
  • Composed WHERE clauses: list methods accept an array of Drizzle conditions, composed by the caller.
  • Pagination: pagination.ts provides buildPaginationClauses for cursor-based keyset pagination, and nextCursorFromPagedItems for generating the next page token.

4. Service Layer

Location: src/api/services/

Business logic and access control. Services compose both n8n repositories and custom repositories, enforcing tenant/project scoping on every operation.

Services

ServicePurposeDependencies
ActionServiceCRUD for action requestsN8nRepositories, CustomRepositories
ChefsServiceCHEFS form token exchange (external)None (reads env vars)
MessageServiceCRUD for messagesN8nRepositories, CustomRepositories
UiApiServiceFacade for UI-facing operationsN8nRepositories
UiWorkflowQueryServiceUser context, workflow listsN8nRepositories
UiWorkflowSharingServiceShare/unshare workflowsN8nRepositories
project-access.tsPure access-control functionsIndividual repositories

Access Control Pattern

Every mutation and query goes through project-scope validation:

  1. Resolve tenant projects: TenantProjectRelationRepository.getProjectIdsByTenantId(tenantId)
  2. Resolve caller projects: listProjectIdsAccessibleToUser(projectRepo, projectRelationRepo, userId)
  3. Intersect: The intersection becomes allowedProjectIds
  4. Validate scope: For operations tied to a workflow execution, resolveWorkflowProjectScope intersects the workflow's n8n projects with allowedProjectIds

project-access.ts (Pure Functions)

FunctionPurpose
listProjectIdsAccessibleToUserUnion of personal + relation project IDs
verifyCallerHasN8nProjectAccessCheck relation or personal ownership
resolveWorkflowProjectScopeIntersect workflow projects with allowed projects
validateN8nExecutionMatchesWorkflowExecution exists and matches claimed workflowId
validateN8nExecutionInTenantScopeExecution workflow intersects tenant scope
resolveProjectIdForCreateValidate execution + pick a project for create
requireExecutionInTenantScopeOptional scope check for list filters
requireChwfAllowedProjectIdsGuard that scope was attached by middleware

5. Routes & Controllers Layer

Location: src/api/routes/

Express routers built via factory functions. Each build*Router(context) receives an ApiRouteContext bag and returns a configured Router.

Route Context (ApiRouteContext)

The context bag is the dependency container passed to all route factories:

type ApiRouteContext = {
apiKeyAuthMiddleware: RequestHandler;
adminAuthMiddleware: RequestHandler;
workflowInteractionTenantMiddleware: RequestHandler;
n8nRepositories: N8nRepositories;
customRepositories: CustomRepositories;
services: ApiServices;
};

Route Files

FileMount PathEndpoints
actions.ts/rest/custom/v1CRUD for action requests
actors.ts/rest/custom/v1Actor-scoped message/action queries
messages.ts/rest/custom/v1CRUD for messages
admin.ts/rest/custom/v1/adminUser projects, workflow/credential association, tenant-project mapping
ui-api.ts/ui-apiSession, whoami, workflows, share/unshare
oidc.ts/rest/auth/oidcOIDC login/callback (SSO)

Standard Middleware Stack

apiKeyAuthMiddleware → tenantMiddleware → Zod schema validation → handler

For admin routes: adminAuthMiddleware (includes API key check + role verification) replaces apiKeyAuthMiddleware.

Async Error Handling

Route handlers should NOT use try/catch. There are two reasons:

  1. Centralized error middlewarehandleErrorResponse already handles all errors uniformly; duplicating that logic per-handler is pointless.
  2. Reduced codebase and nesting — Removing try/catch eliminates boilerplate and keeps handlers flat and readable.

Existing routes (/messages, /actions, etc.) follow this pattern — no try/catch.

// ✅ Correct — bare async handler
router.get('/resource', async (_req, res) => {
const data = await services.domain.list();
OkResponse(res, { data });
});

// ❌ Wrong — redundant try/catch
router.get('/resource', async (_req, res, next) => {
try {
const data = await services.domain.list();
OkResponse(res, { data });
} catch (error) {
next(error);
}
});

The only place next(error) is needed is inside middleware where you intentionally pass control to the error middleware.

Response Format

All error responses use a standardized shape:

{ "error": { "message": "Description", "details": [...] } }

Response helpers in routes/responses/index.ts: BadRequestResponse, UnauthorizedResponse, ForbiddenResponse, NotFoundResponse, OkResponse, CreatedResponse, NoContentResponse.


6. Middleware

Location: src/api/middlewares/

MiddlewarePurpose
auth.tsAPI key validation (X-N8N-API-KEY header) + admin role check
workflow-interaction-tenant.tsTenant ID validation + project scope intersection
oidc-jwt.tsBearer token validation against remote JWKS

Communication via res.locals

Middleware communicates with route handlers through typed res.locals:

PropertySet ByUsed By
callerapiKeyAuthMiddlewareAll authenticated routes
chwfTenantIdtenantMiddlewareMessage/action routes
chwfAllowedProjectIdstenantMiddlewareMessage/action routes
chwfInternaltenantMiddlewareCreate endpoints (internal auth)
oidcTokenoidcJwtMiddlewareOIDC-protected routes

Types are augmented in src/api/types/express-context.d.ts.


7. Bootstrap / Dependency Wiring

Location: src/api/bootstrap/

No DI framework. Dependencies are assembled in order via bootstrap functions:

Step 1: buildN8nRuntimeContext()
└─ Gets raw TypeORM repos from n8n Container
└─ Wraps each in adapter class → N8nRepositories

Step 2: buildCustomRepositories(databaseUrl)
└─ Creates Drizzle instance
└─ Instantiates custom repos → CustomRepositories

Step 3: buildApiServices(n8nRepositories, customRepositories)
└─ Creates ActionService, MessageService, UiApiService → ApiServices

Step 4: buildRouteContext({ services, n8nRepositories, customRepositories, ... })
└─ Creates auth middleware + tenant middleware
└─ Returns complete ApiRouteContext

Step 5: Mount routers
└─ mountCustomApi(app, routeContext)
└─ mountUi(app, routeContext, ...)
└─ mountOidc({ app, ... })
└─ mountAssets(app, assetsPath)
└─ applyOidcFrontendSettings(frontendSettings)

Key Files

FileResponsibility
n8n-repositories.tsStep 1: wraps n8n TypeORM repos in typed adapters
custom-repositories.tsStep 2: builds Drizzle-backed custom repos
services.tsStep 3: creates service instances
route-context.tsStep 4: assembles middleware + context
custom-api.tsStep 5a: mounts REST API routers
ui.tsStep 5b: serves UI static files + UI API
oidc.tsStep 5c: mounts OIDC auth routes
assets.tsStep 5d: serves static JS assets
frontend-settings.tsPatches n8n frontend config for OIDC

8. Type System

Location: src/api/types/

FileDefines
n8n-adapters.tsBase* interfaces for n8n TypeORM repos, N8nRepositories aggregate
routes.tsApiRouteContext
services.tsApiServices, UiApiServiceContract
repositories.tsRe-exports N8nRepositories
auth.tsAuthMiddlewareConfig
oidc.tsOidcTokenDetails
user.tsN8nUser, N8nRole, N8nScope
ui-api.tsUiWorkflowSummary, UiApiContext, WorkflowRow
express-context.d.tsExpress Request/Locals augmentation

9. Testing

Location: tests/

  • Unit tests per layer: repositories, services, middleware, routes.
  • Shared helpers in tests/helpers/:
    • mocks.ts: Factory functions for mock repositories and contexts (createMockN8nRepositories, createMockRouteContext, etc.)
    • test-utils.ts: getRouteHandlers, runHandlerChain, expectNextAppError, expectRejectsAppError
  • Tests import from src/ directly (not from built output).
  • Vitest with vitest.config.mts for path aliases.