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.
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.
- Version 1 · Flask and Firebase
-
Aug 2, 2024First 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.
-
Aug 13, 2024Set 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.
-
Feb 2025OpenPath becomes an ongoing project
The start date on my résumé. From here OpenPath runs for StuImpact's partner nonprofits.
-
Jun 14–15, 2025Interface overhaul on the Flask version
Reworked the main page and the teacher dashboard. It still ran every organization out of one shared Firebase project.
- Version 2 · Next.js rewrite
-
Jun 14, 2026Rewrite 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.
-
NowHardening
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.
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.
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.
+ role checks
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.
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.
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.
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.
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:
Modules, assignments with submissions, assessments, discussions, and a gradebook you can export as CSV.
Calendar, meetings, messaging, file storage, people management, join codes, and invitations.
A learning path, a portfolio, resources, and an opportunities feed backed by a separate MongoDB database.
Branding, Firebase connection, AI settings, a roles grid, impact reports with their own API route, and an audit log.
Production hardening still to do
- Integration tests around tenant permissions and server actions. Right now isolation is enforced in code, and I want tests that prove it.
- A seeded demo workspace, so someone reviewing the repo can click around without connecting Firebase.
- Deployment docs for the Vercel, Supabase, and Firebase setup.
- Replacing the draft privacy and terms pages with reviewed copy.
- Deciding whether background jobs, email, and analytics should move into separate services.