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
project_rate_limitPer-project weekly/yearly execution limits with hard/soft stop behaviour
project_execution_usagePer-project execution counters per weekly/yearly period (composite PK)

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, countByProjectIds
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.

Runtime coupling to n8n internals​

external-hooks has no npm dependency on n8n. Beyond the repositories above, it also require()s a fixed set of n8n's compiled modules by absolute path at runtime (credential decryption, the DI container, the execution engine behind the WIL Trigger node). Those paths and exports are private n8n internals, so an n8n version bump can break them and silently degrade features. The n8n internals check guards against this: a manifest in src/api/constants/n8n-paths.ts declares every required module and export, a unit test keeps the manifest complete, and a container smoke check (verify-n8n-internals.cjs, run during provisioning) fails the build when the installed n8n version no longer matches.


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
ProjectRateLimitRepositoryproject_rate_limitget, upsert, listByProjectIds
ProjectExecutionUsageRepositoryproject_execution_usagegetCounts, increment, listCountsByProjectIds

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
ProjectRateLimitServicePer-project execution rate limitingN8nRepositories, 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.