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 middleware — handleErrorResponse 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.