MemberJunction / MemberJunction/MJ

Server Modernization: Apollo Server v5, Middleware Extensibility, and Multi-Tenant Data Separation Framework

Open
#1,963 0 comments 0 reactions 0 assignees View on GitHub
enhancement
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

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.