Skip to content

Architecture

This page is for people working on or evaluating the codebase. For deployment, see Self-hosting. For user-facing flows, see Authentication.

A single Node process runs Express. Behind it: PostgreSQL (org-scoped schemas) + Apache AGE (permission graph) + an MCP/SSE transport from @modelcontextprotocol/sdk. Users authenticate via GitHub OAuth, GitLab OAuth, OIDC, SAML, or API key — all paths mint the same internal JWT, which then flows through scope resolution against the AGE graph on every request. The server hosts five surfaces (MCP/SSE, REST API, admin UI, /me portal, SSO/SCIM endpoints) on a single port behind Caddy.

src/
├── index.ts # Process entry; loads config + starts server
├── config.ts # Zod schema for all env vars + provider configs
├── server.ts # createApp() — wires every router; the spine
├── auth/ # JWT minting + verification, API key model
│ ├── token.ts # generateToken / verifyToken (HS256, dual-secret rotation)
│ ├── api-key.ts # plur_sk_... long-lived bearer; SHA-256 at rest
│ ├── middleware.ts # createAuthMiddleware: accepts JWT OR API key
│ └── types.ts # AuthUser shape
├── admin/ # Admin dashboard + /me portal + SSO
│ ├── auth.ts # createAdminAuth (cookie OR header), createUserAuth
│ ├── csrf.ts # Double-submit-cookie CSRF middleware
│ ├── rbac.ts # 3-tier viewer / editor / admin model
│ ├── router.ts # /admin/* routes
│ ├── me-router.ts # /me/* routes (portal, API keys, webhooks)
│ ├── views.ts # Server-rendered HTML (Outfit font, dark/light)
│ ├── queries.ts # Read queries
│ ├── oidc/ # OIDC SSO: registry, openid-client wrapper, routes
│ └── saml/ # SAML 2.0: registry, @node-saml/node-saml wrapper, routes
├── api/v1.ts # Public REST API + auto-generated OpenAPI spec
├── scim/ # SCIM 2.0 provisioning endpoints
│ ├── router.ts # /scim/v2/{Users,Groups,...}
│ └── tokens.ts # SCIM-specific bearer tokens
├── webhooks/dispatcher.ts # WebhookDispatcher — HMAC + retry + auto-disable
├── audit/write.ts # writeAudit() — never-throws audit helper
├── email/sender.ts # nodemailer wrapper (stub mode if no SMTP)
├── db/ # Postgres + AGE
│ ├── pool.ts # Single pg.Pool for the process
│ ├── migrate.ts # Schema migration runner
│ ├── tenant.ts # TenantManager: per-org schema isolation
│ ├── graph.ts # GraphLayer: AGE Cypher wrapper, scope resolution
│ ├── postgres-store.ts # EngramStore impl backed by Postgres
│ └── migrations/*.sql # Numbered migrations
├── permissions/
│ ├── resolver.ts # PermissionResolver: scope expansion via graph
│ └── validator.ts # validateIdentifier, validateScope, etc.
├── plur/
│ ├── enterprise-plur.ts # Per-session Plur instance with scope filter
│ └── episode-store.ts # Episodes (session timeline) backed by Postgres
├── mcp/ # MCP-specific layers on top of @plur-ai/mcp
│ ├── tool-filter.ts # Which tools are exposed to enterprise users
│ ├── permission-wrapper.ts # Pre-call scope check; sets ingest cap
│ └── read-sanitizer.ts # Strips dangerous fields from arg + response
├── github/, gitlab/ # OAuth + REST clients + per-org sync into AGE
├── middleware/ # security headers, rate limit, session manager
├── logging/ # pino logger + audit table writer
└── types/express.d.ts # Request.user augmentation

MCP tool call (Claude Code → SSE → JSON-RPC)

Section titled “MCP tool call (Claude Code → SSE → JSON-RPC)”
GET /sse (Authorization: Bearer <jwt>)
↓ requireAuth (JWT or API key)
↓ SessionManager.create() — registers session, returns sessionId
↓ MCP Server initialized with per-session EnterprisePlur instance
← stream of SSE events (endpoint, then JSON-RPC responses)
POST /messages?sessionId=<uuid> (Authorization: Bearer <jwt>)
↓ requireAuth + messageRateLimit
↓ Look up SSE session, find its transport
↓ MCP server dispatches the tool call via setRequestHandler
↓ isToolAllowed(name) — tool-filter
↓ sanitizeToolArgs(args, name) — read-sanitizer (strips llm_*, etc.)
↓ enforceWritePermission() — scope check via PermissionResolver
↓ executeToolOnPlur() — calls plur.learn() / .recall() / etc.
↓ sanitizeToolResponse() — strips storage_root, etc.
↓ writeAudit() (fire-and-forget)
↓ webhookDispatcher.fire() if engram_created/retired/etc.
← tool result streams back via SSE on the existing session
POST /api/v1/engrams (Authorization: Bearer <jwt-or-api-key>)
↓ requireAuth (same dual JWT/API-key path)
↓ permissionResolver.canWrite(user, scope)
↓ Per-request EnterprisePlur instance with user's resolved scopes
↓ plur.learn(statement, { scope, domain, type })
↓ writeAudit()
← 201 + engram body
GET /admin/users
↓ createAdminAuth (cookie session OR Bearer header)
↓ rbac.requireRole('admin')
↓ queries.getUsers()
↓ views.renderUsersPage(data)
← 200 + server-rendered HTML

Every read and write goes through PermissionResolver. Given an authenticated user and a target scope, it walks the AGE graph:

user → memberships → groups → scopes
→ parent groups → scopes
→ org → scopes
→ global

The result is the set of scopes the user can read; a second pass marks which it can write. The MCP tool-filter and the REST middleware both call this before any engine operation.

Permission is never decided client-side. Scopes coming from the client are validated against the resolved set; mismatches return 403.

PLUR Enterprise is not a generic data platform. Things it deliberately doesn’t do:

  • It doesn’t host arbitrary user files — only engrams, episodes, and pack metadata.
  • It doesn’t run LLMs server-side — clients bring their own.
  • It doesn’t have its own search UI for engrams — the admin dashboard offers list/inspect, not freeform search; recall happens through MCP/REST.
  • It doesn’t ship a notification system beyond webhooks and SMTP email.
  • It doesn’t replace your IdP — it federates to one (OIDC/SAML/GitHub/GitLab).

If you want any of those, build them on top of the REST API.

Full source: github.com/plur-ai/enterprise. The architecture doc inside the repo (ARCHITECTURE.md) is the authoritative version of this page.