MemberJunction / MemberJunction/MJ
Server Modernization: Apollo Server v5, Middleware Extensibility, and Multi-Tenant Data Separation Framework
- Dominant language
- TSQL
- Stars
- 29
- Forks
- 6
- Avg merge
- 2d 1h
- Merged PRs (30d)
- 323
Description
## Overview
MemberJunction's server infrastructure (`@memberjunction/server`) has three interconnected gaps limiting production readiness for multi-tenant SaaS deployments:
1. **Apollo Server 4 is EOL** (January 26, 2026). MJ pins `@apollo/server: ^4.9.1`. Additionally, MJ uses Express 5 (`^5.2.1`) but Apollo v4 bundles only Express v4 types — hence the `as unknown as express.RequestHandler` cast on line 378 of `packages/MJServer/src/index.ts`.
2. **No extensibility hooks.** `MJServerOptions` only exposes `onBeforeServe` and `restApiOptions`. End-users cannot inject Express middleware, Apollo plugins, or provider-level hooks.
3. **Multi-tenant data separation exists in pieces but is not wired.** RLS filters, scope metadata, and `PreRunView` hooks all exist independently but nothing auto-injects tenant WHERE clauses.
---
## Workstream 1: Apollo Server v5 Upgrade
**Scope:** Upgrade `@apollo/server` from `^4.9.1` to `^5.x` and switch to `@as-integrations/express5` (since MJ uses Express 5).
**Key changes:**
- `packages/MJServer/package.json`: `@apollo/server: ^5.x`, add `@as-integrations/express5`
- `packages/MJServer/src/index.ts` line 5: change `import { expressMiddleware } from '@apollo/server/express4'` to `import { expressMiddleware } from '@as-integrations/express5'`
- Remove the `as unknown as express.RequestHandler` type assertion (line 378) — `@as-integrations/express5` is natively typed for Express 5
- `ApolloServerPluginDrainHttpServer` import path unchanged in v5
- `type-graphql` and `@graphql-tools/schema` unaffected (both depend on `graphql ^16.x` which MJ already has at `^16.12.0`)
**Risk: Low.** Apollo team states v5 has "almost no" breaking changes. The main change is the import path.
---
## Workstream 2: Server Middleware Extensibility
**Scope:** Expand `MJServerOptions` and `MJServerConfig` so end-users of `createMJServer()` can inject middleware at three layers.
### Proposed API additions to `MJServerOptions`
| Property | Type | Purpose |
|----------|------|---------|
| `expressMiddlewareBefore` | `RequestHandler[]` | Runs after compression, before OAuth/REST/GraphQL routes |
| `expressMiddlewareAfter` | `(RequestHandler \| ErrorRequestHandler)[]` | Runs after all routes (error handlers, catch-alls) |
| `configureExpressApp` | `(app: Application) => void \| Promise` | Escape hatch for advanced Express customization |
| `apolloPlugins` | `ApolloServerPlugin[]` | Merged with built-in plugins in `buildApolloServer` |
| `schemaTransformers` | `((schema: GraphQLSchema) => GraphQLSchema)[]` | Applied after built-in directive transformers |
| `preRunViewHooks` | `PreRunViewHook[]` | Modifies `RunViewParams` before execution (filter injection) |
| `postRunViewHooks` | `PostRunViewHook[]` | Modifies results after execution |
| `preSaveHooks` | `PreSaveHook[]` | Validates/rejects Save/Update/Delete operations |
### Hook type signatures
```typescript
type PreRunViewHook = (params: RunViewParams, contextUser: UserInfo) => RunViewParams | Promise;
type PostRunViewHook = (params: RunViewParams, results: RunViewResult, contextUser: UserInfo) => RunViewResult | Promise;
type PreSaveHook = (entity: BaseEntity, contextUser: UserInfo) => boolean | string | Promise;
```
### Provider hook registration
Static hook registry on `ProviderBase` (global, not per-request) — hooks like tenant filtering should always apply. Registered at startup by `serve()`.
### Insertion points in `serve()` (`packages/MJServer/src/index.ts`)
1. After compression (~line 280): `expressMiddlewareBefore` + `configureExpressApp`
2. `buildApolloServer` call (~line 257): merge `apolloPlugins`
3. After directive transformers (~line 226): apply `schemaTransformers`
4. After GraphQL middleware (~line 379): `expressMiddlewareAfter`
5. `ProviderBase` static registry: `preRunViewHooks`, `postRunViewHooks`, `preSaveHooks`
### `MJServerConfig` passthrough
`MJServerConfig` in `packages/ServerBootstrap/src/index.ts` passes all new options through to `MJServerOptions`.
---
## Workstream 3: Multi-Tenant Data Separation Framework
**Scope:** Generic, configuration-driven tenant isolation using Workstream 2's hooks. No hardcoded data model assumptions.
### Proposed configuration in `mj.config.cjs`
```javascript
module.exports = {
multiTenancy: {
enabled: false, // Opt-in
contextSource: 'header', // 'header' | 'linkedEntity' | 'custom'
tenantHeader: 'X-Tenant-ID', // When contextSource is 'header'
scopingStrategy: 'denylist', // 'allowlist' | 'denylist'
scopedEntities: [], // Entities to include/exclude
autoExcludeCoreEntities: true, // Skip __mj schema entities
defaultTenantColumn: 'OrganizationID',
entityColumnMappings: {}, // Per-entity column overrides
adminRoles: ['Admin', 'System'], // Roles that bypass filtering
writeProtection: 'strict', // 'strict' | 'log' | 'off'
}
};
```
### Custom extractor (for 'custom' mode) via server options
```typescript
createMJServer({
multiTenancy: {
extractTenantContext: async (user: UserInfo, req: Request) => {
const orgId = await lookupUserOrg(user.ID);
return orgId ? { tenantId: orgId } : null;
}
}
});
```
### Runtime flow
1. Express middleware extracts tenant context → sets on request
2. Context function stores `TenantContext` on `UserInfo` (new optional field)
3. `PreRunViewHook` checks if entity is scoped, user isn't admin → appends WHERE clause
4. `PreSaveHook` validates tenant column matches context on writes
5. Cache fingerprint includes tenant context to prevent cross-tenant cache hits
### Existing infrastructure to leverage
- `RowLevelSecurityFilterInfo` with `{{UserFieldName}}` tokens (already works, tenant filter is additive)
- `EntityInfo.SchemaName` for auto-excluding core MJ entities
- `UserInfo.LinkedEntityID` / `LinkedEntityRecordID` for `linkedEntity` mode
- `PreRunView` in `ProviderBase` (lines 560-638) — hook injection point
---
## Dependencies
```
Workstream 1 (Apollo v5) ─── Independent ──► Workstream 2 (Extensibility)
│
│ HARD DEPENDENCY
▼
Workstream 3 (Multi-Tenancy)
```
Recommended order: WS1 → WS2 → WS3 (or WS1 + WS2 in parallel, then WS3).
---
## Critical Files
| File | Role |
|------|------|
| `packages/MJServer/src/index.ts` | Core server startup, all middleware/hook insertion points |
| `packages/MJServer/src/apolloServer/index.ts` | Apollo Server construction, plugin injection |
| `packages/MJServer/package.json` | Apollo version, new integration package |
| `packages/MJCore/src/generic/providerBase.ts` | PreRunView/PreRunViews hooks for filter injection |
| `packages/MJServer/src/config.ts` | Config schema (Zod) for multiTenancy section |
| `packages/ServerBootstrap/src/index.ts` | `MJServerConfig` interface passthrough |
| `packages/MJCore/src/generic/securityInfo.ts` | `UserInfo`, `RowLevelSecurityFilterInfo`, `TenantContext` |
---
## Verification
- [ ] MJAPI starts with `npm run start:api` after v5 upgrade
- [ ] Existing GraphQL queries, mutations, subscriptions work unchanged
- [ ] When no hooks registered, zero behavior change (backward compatible)
- [ ] Integration test: two tenant users see different data via RunView
- [ ] Integration test: save rejected in strict mode for wrong tenant
- [ ] Package builds cleanly: `cd packages/MJServer && npm run build`
- [ ] Existing tests pass: `cd packages/MJServer && npm run test`
Contributor guide
Assessment
This issue has not been assessed yet.