← All work
Active · 2024 – now  Case study

OpenPath

A free learning platform for small education nonprofits. Each organization gets its own workspace for courses, assessments, files, and messaging, and its classroom data lives in a Firebase project the organization owns.

20+
nonprofits hosted
0
records lost moving off Flask
80%
faster page loads after the rewrite
$0
cost to each organization
Role · Lead engineer Stack · Next.js 15 · React 19 · TypeScript · Prisma · Postgres · Auth.js · Firebase Admin
First version
Aug 2024Flask, private repo
Rewrite public
Jun 2026
Code
16.8k linesTypeScript and TSX
Files
159
Data model
13 modelsPrisma schema, 271 lines
01 · Timeline

Two years, two versions

OpenPath started as a small Flask app in 2024 and got rebuilt from scratch in 2026. The Flask history is in private repos, so those entries show dates without links. The public repo is a cleaned-up export, which is why GitHub shows the whole rewrite as one commit.

  1. Version 1 · Flask and Firebase
  2. Aug 2, 2024
    First version, under the name EducationHub

    A Flask app on a single Firebase project: course pages, video classes through Jitsi Meet, chat with the teacher, and uploads for notes and worksheets. It had separate dashboards for admins, teachers, and students.

    Private repo16 commits over two days
  3. Aug 13, 2024
    Set up to be open source

    Added a license, a contributing guide, a code of conduct, a security policy, and issue templates, so other organizations could run it and report problems.

    Private repo10 commits
  4. Feb 2025
    OpenPath becomes an ongoing project

    The start date on my résumé. From here OpenPath runs for StuImpact's partner nonprofits.

    Résumé
  5. Jun 14–15, 2025
    Interface overhaul on the Flask version

    Reworked the main page and the teacher dashboard. It still ran every organization out of one shared Firebase project.

    Private repo9 commits
  6. Version 2 · Next.js rewrite
  7. Jun 14, 2026
    Rewrite published

    The Next.js, TypeScript, Prisma, and Postgres version goes public as one squashed commit: multi-tenant workspaces, roles, encrypted per-tenant credentials, and the full set of course tools. The Flask code stays private.

    GitHub159 files · +25,987 lines9be342b ↗
  8. Now
    Hardening

    It typechecks and builds. What's left is production work: integration tests around tenant permissions, a demo workspace for reviewers, deployment docs, and properly reviewed legal pages.

    README
02 · The problem

Real programs with nowhere to run them

Small education nonprofits run actual courses, with cohorts, assignments, and assessments. Commercial LMS platforms price them out, and the free ones assume someone has time to administer them. I ran into this through StuImpact's partner organizations.

The first OpenPath was one Flask app on one Firebase project. That worked for a single organization, but it had no clean way to keep one organization's data away from another's, so adding a new nonprofit meant manual setup and real risk of mixing data. For the rewrite I wanted a nonprofit to be able to sign up, get its own isolated workspace in a few minutes, and pay nothing.

03 · Architecture

A shared control plane, with each org's data in its own Firebase

The rewrite splits the platform in two. Postgres, through Prisma, holds the platform itself: users, sessions, workspaces, memberships, invitations, join codes, and audit logs. Each workspace's teaching data (courses, assignments, submissions, files, discussions, attendance, gradebooks) lives in that organization's own Firebase project, which an admin connects from the workspace settings.

That second half is the unusual choice. An organization owns its classroom data outright instead of sharing a database with twenty other nonprofits. The cost is that I'm holding every tenant's Firebase service-account key, so those keys needed careful handling.

Org A
Org B
Org C…
→
Next.js appworkspace resolver
+ role checks
→
Postgres via Prismausers · workspaces · roles · audit
Each org's Firebasecourses · files · grades · chat

Every request resolves the workspace and the user's membership before it touches any data.

Workspace routes all sit under /[workspace]. A cached helper looks up the workspace and the user's membership in a single query. Non-members get a 404 instead of a 403, so nobody can confirm a workspace exists by guessing its URL, and suspended members are treated the same way.

04 · Keys and roles

Encrypting every tenant's credentials

Each service-account key is encrypted with its own random data key using AES-256-GCM. That data key is then encrypted with a master key that only exists in a server environment variable. The database stores ciphertext and nothing else. When a request needs a tenant's Firebase, the server decrypts the key, starts a Firebase Admin app named after the tenant, and caches it. The key never reaches the browser.

lib/crypto.ts
export function seal(plaintext: string): SealedSecret {
  const dek = crypto.randomBytes(32);
  const iv = crypto.randomBytes(12);
  const cipher = crypto.createCipheriv(ALGO, dek, iv);
  const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
  const authTag = cipher.getAuthTag();

  // Wrap the DEK with the master key.
  const kekIv = crypto.randomBytes(12);
  const kekCipher = crypto.createCipheriv(ALGO, masterKey(), kekIv);
  const wrapped = Buffer.concat([kekCipher.update(dek), kekCipher.final()]);
  const kekTag = kekCipher.getAuthTag();
  const wrappedKey = Buffer.concat([kekIv, kekTag, wrapped]);

  return { ciphertext: ct.toString("base64"), /* iv, authTag, wrappedKey */ };
}

Excerpt from lib/crypto.ts, return value trimmed.

Permissions work the same way everywhere: one table maps each role to what it can do, and the rest of the app calls can(role, capability) instead of checking role names inline. The settings page renders its roles grid from this same table, so what the UI shows always matches what the server enforces.

lib/permissions.ts
const MATRIX: Record<MembershipRole, Capability[]> = {
  OWNER:   ["course.create", "content.author", "grade.write",
            "people.manage", "files.manage", "settings.manage", "billing"],
  TEACHER: ["course.create", "content.author", "grade.write",
            "people.manage", "files.manage"],
  TA:      ["content.author", "grade.write", "files.manage"],
  STUDENT: [],
};

export function can(role: MembershipRole, capability: Capability): boolean {
  return MATRIX[role]?.includes(capability) ?? false;
}

From lib/permissions.ts, reformatted to fit.

05 · The migration

Moving live data off Flask without losing any of it

Changing frameworks was the easy part. The hard part was moving live nonprofit data across without dropping a record or letting it cross a tenant boundary, while redesigning the data model around tenant isolation. Everything came across with zero data loss, and pages load 80% faster than they did on Flask.

The rewrite also grew the product well past the original four dashboards. A workspace today has:

Courses

Modules, assignments with submissions, assessments, discussions, and a gradebook you can export as CSV.

Running a program

Calendar, meetings, messaging, file storage, people management, join codes, and invitations.

For students

A learning path, a portfolio, resources, and an opportunities feed backed by a separate MongoDB database.

For admins

Branding, Firebase connection, AI settings, a roles grid, impact reports with their own API route, and an audit log.

06 · What's next

Production hardening still to do

See it live, or read the code.

Running for StuImpact's partner nonprofits.

More work