Skip to content

MemberJunction Server Extensions Guide

MemberJunction Server Extensions (@memberjunction/server-extensions-core) provide a modular, decoupled mechanism to mount custom HTTP routes, background workers, WebSocket connections, and external platform integrations into MJServer (the MemberJunction API server) without modifying core server source code.

This guide explains the architecture, security model, lifecycle phases, shared service registry, and step-by-step authoring patterns for server extensions.


1. When to Use What: Extension vs. Resolver vs. Action vs. Remote Operation

Section titled “1. When to Use What: Extension vs. Resolver vs. Action vs. Remote Operation”

Before writing a server extension, choose the right abstraction for your capability:

MechanismPurposeTransport / Calling PatternAuth Model
Server ExtensionCustom HTTP webhooks, external provider endpoints, WebSocket servers, streaming protocols, or non-GraphQL protocolsRaw Express endpoints (HTTP / WebSockets / long-lived connections)Configurable: Pre-Auth (provider-verified signature / bearer) or Post-Auth (MJ JWT)
TypeGraphQL ResolverCustom business logic query/mutation exposed to browser UI or internal clientsGraphQL /graphql endpointStandard MJ JWT (UserPayload, RBAC, Field-Level Security)
ActionReusable unit of work runnable by workflows, AI agents, triggers, or UI buttonsIn-process execution via ActionEngineCaller context (User, transaction context)
Remote OperationPolymorphic execution runnable both locally on the server or remotely from the browserIn-process or auto-marshalled over GraphQLMJ API Key or User session
  • You need a dedicated HTTP path for an external webhook provider (e.g., Slack Event API, Microsoft Teams Bot Framework, Twilio Voice webhooks).
  • You are implementing custom protocol brokers (e.g., WebRTC SDP exchange, Realtime proxies, binary file streaming).
  • You need persistent background workers or outbound connection clients (e.g., Slack Socket Mode, external event polling).
  • You are packaging a third-party vendor integration or OpenApp that supplies server-side middleware and route handlers.

2. Extension Architecture & Lifecycle Phases

Section titled “2. Extension Architecture & Lifecycle Phases”

Server extensions are mounted during MJServer bootstrap. They are separated into two distinct lifecycle phases to maintain strict security boundaries:

┌────────────────────────────────────────────┐
│ MJServer Bootstrap │
└─────────────────────┬──────────────────────┘
│
▼
┌────────────────────────────────────────────┐
│ 1. Core Services Setup │
│ - ClassFactory, DB connections, Redis │
│ - Telephony & Broker services registered│
└─────────────────────┬──────────────────────┘
│
▼
┌────────────────────────────────────────────┐
│ 2. Pre-Auth Phase Extensions │
│ - Mounted BEFORE MJ auth middleware │
│ - Webhooks with external signatures │
│ - Public callbacks, WebRTC SDP brokers │
└─────────────────────┬──────────────────────┘
│
▼
┌────────────────────────────────────────────┐
│ 3. Core Authentication Middleware │
│ - JWT token verification │
│ - UserPayload context resolution │
│ - Rate limiting & security headers │
└─────────────────────┬──────────────────────┘
│
▼
┌────────────────────────────────────────────┐
│ 4. Post-Auth Phase Extensions │
│ - Mounted AFTER MJ auth middleware │
│ - Guaranteed valid req.user context │
│ - Authenticated REST APIs & utilities │
└─────────────────────┬──────────────────────┘
│
▼
┌────────────────────────────────────────────┐
│ 5. OnAllExtensionsMounted Hook │
│ - Awaited across all loaded extensions │
│ - Cross-extension & service wiring │
└─────────────────────┬──────────────────────┘
│
▼
┌────────────────────────────────────────────┐
│ 6. Server Listening & Health Checks │
│ - Periodic HealthCheck() monitoring │
│ - Shutdown() on SIGTERM/SIGINT │
└────────────────────────────────────────────┘
  • Execution Timing: Mounted directly onto the Express application before MJ’s unified authentication middleware (createUnifiedAuthMiddleware).
  • Use Cases:
    • Webhooks from external providers that sign payloads using their own shared secrets (e.g., Slack HMAC signatures, Teams Bot Framework JWTs, Twilio request signatures).
    • Single-use ticket exchange routes (e.g., WebRTC SDP broker).
    • Public status or challenge endpoints.
  • Security Responsibility: The extension must validate incoming requests itself (e.g., via HMAC verification or token validation). Unverified requests have not been checked by MJ auth.
  • Execution Timing: Mounted after MJ’s authentication and post-auth context middleware (mwPostAuth).
  • Use Cases:
    • REST endpoints consumed by authenticated frontend users or internal services.
    • File upload/download endpoints operating under active user sessions.
    • Endpoints requiring req.user and permissions context.
  • Security Guarantee: Any request hitting a post-auth extension has already been authenticated as a valid MJ user; unauthorized requests are rejected with 401 Unauthorized before reaching the extension.

3. MJAPI’s Base Path, Reserved Roots & Route Safety (G2)

Section titled “3. MJAPI’s Base Path, Reserved Roots & Route Safety (G2)”

Every RootPath lives under MJAPI_BASE_PATH

Section titled “Every RootPath lives under MJAPI_BASE_PATH”

MJAPI does not know where it is exposed. When a proxy forwards a prefix without stripping it (MJ Central’s CloudFront sends /api/* to MJAPI), the host sets MJAPI_BASE_PATH=/api and every route MJAPI serves moves under it, so the CDN forwards one prefix instead of a patch per route. The default is /.

  • A configured RootPath is relative to the base path. prepareServerExtensionConfigs({ apiRootPath }) composes the mount path once, so context.config.RootPath is already /api/telephony/twilio at MJAPI_BASE_PATH=/api (and /telephony/twilio at /). Mount on it as-is; never add the base path again.
  • GRAPHQL_ROOT_PATH is only where Apollo listens. It is not a base for extension routes. Extensions take precedence over GraphQL because Apollo mounts last.
  • context.publicUrl is where the world reaches MJAPI (MJAPI_PUBLIC_URL, prefix included); context.apiRootPath is the base path ('' at /). Build outward URLs as publicUrl + a path relative to the base path. Appending the composed RootPath to publicUrl doubles the prefix.
  • Never mount on the Express app at a root-relative literal (app.use('/x', …)). pnpm run check:api-root fails CI on it. Core code uses ApiRoute() from @memberjunction/server.

To stop extensions colliding with or shadowing core routes, MJServer keeps a Reserved Roots Registry (serverExtensionReservedRoots.ts), relative to the base path like every RootPath: /auth, /magic-link, /widget, /healthcheck, /esignature, /media, /oauth, /health, /realtime, /realtime-proxy, plus the static baseline in @memberjunction/server-extensions-core (/graphql, …) and the exact root /. Matching is case-insensitive and bidirectional (claiming /telephony collides with /telephony/twilio).

graphqlRootPath itself is no longer reserved (it was until #5674); an extension may sit beside or under it.

An extension that claims a reserved root is dropped at startup with a descriptive error (fail closed).


Extensions often need to communicate with one another or interact with server services (such as telephony providers or media brokers) without circular npm dependencies.

ServerExtensionInitContext provides access to a unified ServerExtensionServiceRegistry:

export interface ServerExtensionServiceRegistry {
RegisterService<T extends object>(key: string, service: T): void;
GetService<T extends object>(key: string): T | undefined;
HasService(key: string): boolean;
GetAllServices(): ReadonlyMap<string, object>;
}

Auto-Registration via ExtensionInitResult.Service

Section titled “Auto-Registration via ExtensionInitResult.Service”

If an extension returns a Service object in its ExtensionInitResult, ServerExtensionLoader automatically registers that instance into the registry under:

  1. config.Name (if specified in mj.config.cjs)
  2. The concrete extension class name
  3. The configured DriverClass name

Some extensions depend on services registered by other extensions (or core services registered during bootstrap). Because pre-auth and post-auth extensions load sequentially, extensions should perform cross-service wiring inside the OnAllExtensionsMounted lifecycle hook:

export abstract class BaseServerExtension {
// ...
OnAllExtensionsMounted?(context: ServerExtensionInitContext): Promise<void>;
}

This method is guaranteed to run after all extensions across both phases are loaded and their services are registered.


5. Authoring a Server Extension: Step-by-Step Recipe

Section titled “5. Authoring a Server Extension: Step-by-Step Recipe”

Extend BaseServerExtension and decorate it with @RegisterClass:

import { Router, Request, Response } from 'express';
import { RegisterClass } from '@memberjunction/global';
import { LogError, LogStatus } from '@memberjunction/core';
import {
BaseServerExtension,
ServerExtensionInitContext,
ExtensionInitResult,
ExtensionHealthResult,
ServerExtensionPhase
} from '@memberjunction/server-extensions-core';
export interface MyWebhookService {
ProcessEvent(payload: Record<string, unknown>): Promise<void>;
}
@RegisterClass(BaseServerExtension, 'MyCustomWebhookExtension')
export class MyCustomWebhookExtension extends BaseServerExtension {
/** Declare default lifecycle phase */
public override get DefaultPhase(): ServerExtensionPhase {
return 'pre-auth';
}
private _service: MyWebhookService | null = null;
/**
* Called once during MJServer startup.
*/
async Initialize(context: ServerExtensionInitContext): Promise<ExtensionInitResult> {
const { app, config, services } = context;
// Already composed under MJAPI_BASE_PATH by core — mount on it as-is.
const rootPath = config.RootPath || '/webhook/custom';
// 1. Initialize internal services
this._service = {
async ProcessEvent(payload: Record<string, unknown>) {
LogStatus(`Processing event: ${JSON.stringify(payload)}`);
}
};
// 2. Mount routes
const router = Router();
router.post('/', async (req: Request, res: Response) => {
try {
// Verify custom signature...
await this._service?.ProcessEvent(req.body);
res.status(200).json({ status: 'ok' });
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
LogError(`Error in webhook: ${message}`);
res.status(500).json({ error: message });
}
});
app.use(rootPath, router);
return {
Success: true,
Message: `Custom webhook mounted at ${rootPath}`,
RegisteredRoutes: [`POST ${rootPath}`],
Service: this._service // Automatically registered into ServerExtensionServiceRegistry
};
}
/**
* Cross-extension dependency resolution hook.
*/
async OnAllExtensionsMounted(context: ServerExtensionInitContext): Promise<void> {
// Look up optional telephony or messaging services
const twilio = context.services.GetService('TwilioTelephonyService');
if (twilio) {
LogStatus('MyCustomWebhookExtension: discovered active Twilio service');
}
}
/**
* Regular health status reporting.
*/
async HealthCheck(): Promise<ExtensionHealthResult> {
return {
Healthy: this._service !== null,
Name: 'MyCustomWebhookExtension',
Details: {
active: true
}
};
}
/**
* Graceful shutdown cleanup.
*/
async Shutdown(): Promise<void> {
this._service = null;
}
}

6. Configuring Extensions in mj.config.cjs

Section titled “6. Configuring Extensions in mj.config.cjs”

Extensions are configured in the host’s mj.config.cjs file under the serverExtensions array:

module.exports = {
// ...
serverExtensions: [
{
Name: 'SlackIntegration',
DriverClass: 'SlackMessagingExtension',
PackagePath: '@memberjunction/messaging-adapters',
RootPath: '/webhook/slack',
Phase: 'pre-auth', // 'pre-auth' (default) or 'post-auth'
Enabled: true,
Settings: {
DefaultAgentName: 'Sage',
ContextUserEmail: 'bot@company.com',
BotToken: process.env.SLACK_BOT_TOKEN,
SigningSecret: process.env.SLACK_SIGNING_SECRET,
ConnectionMode: 'http'
}
},
{
Name: 'AuthenticatedReporting',
DriverClass: 'ReportingApiExtension',
PackagePath: '@my-org/reporting-extension',
RootPath: '/reporting', // relative to MJAPI_BASE_PATH: served at /api/reporting when it is /api
Phase: 'post-auth', // Protected by MJ user auth
Enabled: true,
Settings: {
MaxExportRows: 50000
}
}
]
};

MJServer mounts a global CORS middleware before every extension (so 401s from the auth middleware carry CORS headers). By default that middleware:

  • reflects any allowed origin onto your extension’s responses — including its refusals, and
  • answers every preflight (OPTIONS) with 204 itself, so your own OPTIONS handler never runs.

That is fine for webhooks, but an extension with its own origin policy (a public pre-auth edge whose allowed origins are set per resource, such as a ballot endpoint) cannot enforce it. Set OwnsCORS: true and the global middleware skips every request under your RootPath:

// Open App server package (or the host serverExtensions[] entry — the host overlay can override it)
export const MJ_SERVER_EXTENSIONS = [
{
Enabled: true,
DriverClass: 'BallotEdgeExtension',
RootPath: '/ballot',
Phase: 'pre-auth',
OwnsCORS: true, // global CORS skips /ballot/**; this extension answers its own preflights
Settings: {},
},
];

With OwnsCORS: true your extension is responsible for all CORS on its routes: answer OPTIONS, and set Access-Control-Allow-Origin (plus Vary: Origin) only for origins you allow. A route that sets nothing is not readable cross-origin, so a forgotten handler fails closed: MJServer answers a preflight under your root that no extension handled with a bare 404 (no CORS headers) rather than letting it fall through to the GraphQL endpoint’s own CORS, which would approve every origin. Matching follows Express mount semantics (case-insensitive, on a segment boundary: /ballot owns /ballot/x, not /ballots). Disabled entries own nothing, and a non-boolean value is ignored with a warning. The unified auth middleware passes OPTIONS through, so the flag also works for post-auth extensions — their 401 responses then carry no CORS headers unless you add them.

Do not reorder Express’s app.router.stack to get ahead of the global CORS middleware; OwnsCORS is the supported mechanism. MJServer also disables Express’s X-Powered-By header app-wide.

Overriding a published extension from the host

Section titled “Overriding a published extension from the host”

A host mj.config.cjs serverExtensions[] entry whose DriverClass an Open App package already publishes is an overlay: it needs only DriverClass plus the fields it changes, for example { DriverClass: 'BallotEdgeExtension', OwnsCORS: false } or { DriverClass: 'BallotEdgeExtension', Settings: { RateLimitMax: 200 } }. RootPath, Enabled and Phase come from the published entry unless the overlay sets them, and Settings are merged key by key. A host-only entry (no package publishes that DriverClass) must still give a valid RootPath; one without it is reported and not mounted.

To temporarily disable an extension without removing its configuration, set:

Enabled: false

The loader will skip initialization and log a clean bypass message.


7. Telephony & Core Integration Architecture

Section titled “7. Telephony & Core Integration Architecture”

Core server integrations (such as Twilio, Vonage, RingCentral, and Microsoft Teams meetings) register their management services directly into ServerExtensionServiceRegistry during bootstrap:

  • 'TwilioTelephonyService'
  • 'VonageTelephonyService'
  • 'RingCentralTelephonyService'
  • 'TeamsMeetingsService'

This enables custom server extensions to access telephony runtime features (e.g., initiating outbound calls, checking call status, or streaming media) without requiring direct coupling to MJServer internal modules.


  1. Explicit Phase Declaration: Always define DefaultPhase on your extension class, even though it defaults to 'pre-auth'. Make your security assumptions explicit.
  2. Never Bypass Authentication in Post-Auth: Post-auth extensions rely on the ambient req.user. Do not implement conflicting authentication middleware on post-auth routes.
  3. Graceful Shutdown: Implement Shutdown() to close persistent WebSocket connections, flush message queues, and release database pools.
  4. Resilient Health Checks: Ensure HealthCheck() returns within milliseconds; do not perform blocking network calls inside health checks.
  5. Avoid String Magic for Services: When consuming services from context.services, verify availability with HasService() or check if GetService() returns undefined.