Skip to main content

Architecture

Detailed technical architecture of PBS Knowledge.

System Overview​

┌──────────────────────────────────────────────────────────────────┐
│ NGINX (Reverse Proxy) │
│ SSL Termination, Static Files │
└─────────────────────────────┬────────────────────────────────────┘
│
┌───────────────────┼───────────────────┐
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Frontend │ │ Backend │ │ Worker │
│ (Svelte 5) │ │ (Fastify) │ │ (BullMQ) │
│ │ │ │ │ │
│ • Dashboard │ │ • REST API │ │ • Jobs │
│ • Planning │ │ • Auth │ │ • Sync │
│ • Admin │ │ • Services │ │ • Email │
└─────────────────┘ └────────┬────────┘ └────────┬────────┘
│ │
▼ ▼
┌──────────────────────────────────────────────────────────────────┐
│ Data Layer │
│ │
│ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ │
│ │ TerminusDB │ │ Redis │ │ Firebase │ │
│ │ (Graph DB) │ │ (Cache/Queue)│ │ (Auth/Docs) │ │
│ └───────────────┘ └───────────────┘ └───────────────┘ │
└──────────────────────────────────────────────────────────────────┘

Frontend Architecture​

Technology Stack​

  • Framework: Svelte 5 with runes
  • Build Tool: Vite 6
  • Styling: Tailwind CSS 3
  • Forms: Felte + AJV
  • Visualization: Cytoscape.js

Component Structure​

frontend/src/
├── lib/
│ ├── api.ts # Centralized API client
│ ├── components/ # Reusable UI components
│ │ ├── common/ # Generic components
│ │ ├── forms/ # Form components
│ │ ├── faculty/ # Faculty-specific
│ │ └── milestones/ # Milestone components
│ ├── forms/ # Schema-driven form system
│ │ ├── fields/ # Field renderers
│ │ ├── validators/ # Custom validators
│ │ └── loaders/ # Data loaders
│ ├── stores/ # Svelte stores
│ └── types/ # TypeScript types
├── pages/ # Full-page components
└── routes/ # Route components

State Management​

State is managed through:

  • Svelte Stores - Global application state
  • Component State - Local component state
  • URL State - Route parameters and query strings
  • Form State - Felte form management

API Communication​

All API calls go through lib/api.ts:

// Centralized fetch with auth
const api = {
get: path => fetch(`/api${path}`, { headers: authHeaders() }),
post: (path, data) => fetch(`/api${path}`, { method: 'POST', body: JSON.stringify(data) }),
// ...
};

Backend Architecture​

Technology Stack​

  • Framework: Fastify 5
  • Language: TypeScript 5
  • Database Client: Custom TerminusDB client
  • Job Queue: BullMQ 5
  • Authentication: SAML 2.0 + Firebase

Layered Architecture​

HTTP Request
│
▼
┌─────────────┐
│ Routes │ ← Request handling, validation
└─────────────┘
│
▼
┌─────────────┐
│ Services │ ← Business logic
└─────────────┘
│
▼
┌─────────────┐
│ Data Access │ ← TerminusDB GraphQL
└─────────────┘

Route Structure​

backend/src/routes/
├── auth.ts # Authentication endpoints
├── people.ts # Person CRUD
├── courses.ts # Course management
├── publications.ts # Publication endpoints
├── degrees.ts # Degree configuration
├── milestones.ts # Milestone management
├── studentPlans.ts # Academic planning
├── dartmouthSync/ # Dartmouth API integration
└── ...

Service Pattern​

Services encapsulate business logic:

class PersonService {
constructor(fastify, request) {
this.db = fastify.terminusdb;
this.user = request.user;
}

async getPerson(id: string) {
// Business logic here
}
}

Database Architecture​

TerminusDB​

Graph database with:

  • Classes - Entity types (Person, Course, etc.)
  • Properties - Fields on entities
  • Links - Relationships between entities
  • Enums - Controlled vocabularies

Schema Organization​

{
"@context": {...},
"@graph": [
{
"@id": "@schema:Person",
"@type": "Class",
"first_name": "xsd:string",
"email": "xsd:string",
"affiliations": {
"@type": "Set",
"@class": "@schema:Lab"
}
}
]
}

GraphQL Interface​

TerminusDB exposes GraphQL:

query {
Person(filter: { email: { eq: "user@dartmouth.edu" } }) {
_id
first_name
last_name
affiliations {
name
}
}
}
GraphQL gotchas

Two TerminusDB GraphQL behaviors that have caused production bugs: (1) querying a parent class by id: does not match subclass documents — use Person(filter: { _id }); (2) _or filters return one row per matching branch, so a document matching multiple branches comes back multiple times — dedupe by _id. The integration test suite exists to catch this class of issue.

Publication Storage (Firestore)​

As of June 2026, publications live in Firebase Firestore, not TerminusDB. The author-set model and full-replace sync in TerminusDB caused severe commit-history bloat (24 GB reclaimed by the migration). The publication service reads/writes Firestore (PUBLICATIONS_READ_SOURCE / PUBLICATIONS_WRITE_SOURCE env flags govern the source), and the OpenAlex sync pipeline writes there. Email templates are likewise Firestore-only, seeded from embedded defaults at boot.

Workflow Engine​

All multi-stage approval flows (honors thesis, independent research, fellowships, award nominations, major plans, graduate milestones, mentoring forms) run on a single unified engine:

Workflow (definition, seeded at boot)
└── WorkflowInstance (one per submitted entity)
└── StageInstance (one per stage)
└── StageApproval (one per approver per stage)

Key rules:

  • In-review entities carry the unified status submitted; per-approver progress lives on StageApproval records (pending / approved / rejected / revision_requested / delegated)
  • Each domain has a thin *WorkflowService bridge (e.g., thesisWorkflowService.ts) that owns submit/approve/reject/request-revision, entity status sync, and the pending queue
  • Legacy per-domain *Approval entities remain in the schema as read-only history
  • New flows are added by seeding a Workflow at boot, registering an approver resolver and a stage-completion hook, and writing a bridge service — never by building a parallel state machine

See the Workflows guide for the full developer walkthrough.

Authentication Flow​

SAML SSO​

User → Login Page → Dartmouth IdP → SAML Response → Backend → Session
  1. User clicks login
  2. Redirected to Dartmouth SSO
  3. User authenticates
  4. SAML assertion returned
  5. Backend validates and creates session
  6. JWT issued for API access

Authorization​

Permissions checked at multiple levels:

  1. Route middleware (authentication)
  2. Service layer (authorization)
  3. Database layer (field-level)

Job Processing​

BullMQ Architecture​

Event/Schedule → Job Queue (Redis) → Worker → Result

Queue Types​

QueuePurpose
emailSend transactional emails
syncData synchronization
publicationPublication import
backupDatabase backups

Worker Process​

const worker = new Worker(
'email',
async job => {
const { to, template, data } = job.data;
await emailService.send(to, template, data);
},
{ connection: redis }
);

Deployment Architecture​

Container Structure​

services:
frontend: # Svelte app via NGINX
backend: # Fastify API
worker: # BullMQ processor
terminusdb: # Graph database
redis: # Cache and queues
nginx: # Reverse proxy

Network Flow​

Internet → NGINX:443 → Backend:3000 → TerminusDB:6363
→ Frontend assets
→ Static files

Security Architecture​

Defense Layers​

  1. NGINX - Rate limiting, headers
  2. Backend - Authentication, validation
  3. Services - Authorization
  4. Database - Schema validation

Protected/Public Storage Boundary​

PBS Knowledge separates identifiable education records from public catalog and requirement data. This is a technical control supporting the institution's FERPA program; FERPA does not prescribe a particular database.

┌─────────────────────────────────────────────────────────────┐
│ Firebase Firestore │
│ (Encrypted at Rest - Google Cloud) │
│ │
│ ┌─────────────────────────┐ ┌─────────────────────────┐ │
│ │ student_grades │ │ audit_logs │ │
│ │ ──────────────── │ │ ──────────────── │ │
│ │ • course/section IDs │ │ • userId │ │
│ │ • term/status/credits │ │ • resourceType │ │
│ │ • grade/grade points │ │ • resourceId │ │
│ │ • allocation overrides│ │ • action, timestamp │ │
│ └─────────────────────────┘ └─────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│
│ joins public metadata
▼
┌─────────────────────────────────────────────────────────────┐
│ TerminusDB │
│ (No identifiable student-course relationships) │
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Course, Section, Degree, RequirementGroup/Rule │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘

Key controls

ConcernSolution
Protected records at restFirestore encryption and restricted project access
Legitimate-interest enforcementBackend student/program permission checks
AccountabilityProtected API access logged to audit_logs
Data minimizationTerminusDB contains no student-course relationship
Direct client accessDenied by Firestore rules; server API only

Access Control Middleware​

All student data endpoints use the canAccessStudent middleware:

// backend/src/middleware/studentAccess.ts
export async function canAccessStudent(request, reply) {
const { id: studentId } = request.params;
const user = request.user;

// Only allow: the student themselves, their advisor, or admin
const canAccess =
user.personId === studentId ||
user.role === 'admin' ||
(await isAdvisorOf(user.personId, studentId));

if (!canAccess) {
return reply.code(403).send({ error: 'Access denied' });
}
}

Audit Logging​

Every access to FERPA-protected data is logged:

// backend/src/middleware/auditMiddleware.ts
export function auditStudentAccess(resourceType: string) {
return async (request, reply) => {
reply.addHook('onSend', async () => {
await request.server.auditService.logAccess({
userId: request.user?.id,
resourceType, // 'enrollment', 'grade', 'transcript', 'degree-progress'
resourceId: request.params.id,
action: 'view',
ipAddress: request.ip,
success: reply.statusCode < 400,
});
});
};
}

Protected Endpoints:

EndpointResource Type
GET /api/students/:id/transcripttranscript
GET /api/students/:id/degree-progressdegree-progress
GET /api/students/:id/enrollmentsenrollment

Sensitive Data Handling​

  • Credentials - Environment variables only, never in code
  • Secrets - Not written to logs or error messages
  • PII - Removed from all application logs
  • Enrollments and grades - Stored only in server-accessed Firestore
  • Audit logs - Admin read-only, server-side write only

Performance Considerations​

Caching Strategy​

PBS Knowledge uses a multi-layer caching strategy:

  • Redis API Cache - 24-48 hour TTL with explicit invalidation on data changes
  • In-Memory Index - Publication existence checks for sync operations
  • Static Assets - NGINX caching with long TTL
  • Connection Pooling - Singleton TerminusDB client for connection reuse

GraphQL Optimization​

  • Minimal field queries - List views fetch only essential fields (60-80% smaller responses)
  • Batch operations - Publication sync uses indexed lookups instead of N+1 queries
  • Query builders - Separate minimal vs full query patterns

Background Processing​

  • BullMQ - Job queues for publication sync, email, data imports
  • Rate limiting - Respects external API limits (OpenAlex)
  • Cache invalidation - Automatic cache clearing after sync operations

For detailed implementation, see Performance & Caching.