CampusSync
PROTOTYPEMulti-tenant certificate verification for universities and recruiters, rebuilt after a security audit.
Personal project · Sep 2025 – Oct 2026 · 234 commits
THE PROBLEM
Certificates are checked by hand and are easy to forge, so recruiters can't trust the ones they receive.
HOW IT WORKS
- Students upload certificates; Gemini vision extracts the fields and regular expressions normalise them; faculty approve.
- Approved certificates are issued as signed credentials (RS256 JWS in a W3C-VC-shaped JSON, with a did:web identifier) with revocation and public verification. One verifyCredential() service answers every public endpoint and fails closed.
- Roles live in one table (user_roles), credentials and the audit log are written only by the backend, and schema changes ship as staged migrations applied by a gated runner and replayed on PostgreSQL 17 in CI.
THE PIPELINE
- Student uploadThe student uploads a certificate.
- Gemini visionExtracts the fields; regular expressions normalise them.
- Faculty approvalA reviewer verifies or rejects.
- Signed credentialRS256 JWS in a W3C-VC-shaped JSON, with revocation.
- Public verificationAnyone can check a credential.
ENGINEERING EVIDENCE
- 100 API route files. A test fails the build when a route is neither guarded nor on an explicit public list. route-inventory.test.ts ↗
- 80 test files and 1,432 passing tests, run on main on 16 Oct 2026. They use in-memory fakes for the database.
- A review of commit 91d0c77 (static reading plus targeted tests; not a penetration test and no outside reviewer) tracked 19 findings, 5 of them P1, in a public closure report. 17 are fixed in code and deployed with regression tests, one is partly fixed, and one needs an owner action outside the code. The P1s: every public credential verification failed, revocation was enforced on one path only, reviewers could approve their own certificates, and an admin of one organization could act on another. AUDIT-CLOSURE.md ↗
- Credential lookup went from 1,172 ms to 0.207 ms (6 to 37,280 queries per second at about 280,000 credentials) by replacing a JSON scan with an indexed column. PostgreSQL 17, median of 3 rounds on a GitHub Actions runner, synthetic data, database time only. The production pending-list query is about 9 percent slower, and an insert speed-up from an earlier run did not reproduce and was withdrawn in writing. BENCHMARKS.md ↗
- After the fixes: HttpOnly server-managed sessions, certificate files served by 5-minute signed URLs from a now-private bucket (an old public URL went from HTTP 200 to blocked), and a PostgreSQL-backed global rate limiter on the high-value endpoints (30 requests allowed, then 429 with Retry-After on the live site). AUDIT-CLOSURE.md ↗
- An earlier Sep 2026 review of every route had already found and fixed real holes: role assignment and signup that did not require a session, credential issuing for arbitrary claims, unauthenticated document-status routes that used the service role, unsigned webhooks and open redirects. Dependency audit findings went from 71 to 2. safeRedirect.ts ↗
DECISIONS & INVESTIGATIONS
One external dependency is now on the critical path: a missing Gemini key or an unparseable reply gives a 500 with no fallback.
- user_roles row-level security: self-referencing policies replaced by a SECURITY DEFINER functionPARTIAL
Five policy names recur in every version; only the admin check changed.
- Public credential verification failed for every credential: the verifier was handed the private keyCONFIRMED
Every public verification failed.
- An admin of one organization could act on another: a global role plus membership in any organizationCONFIRMED
Yes: an admin of one organization could act on another, because authorization combined a global role check with 'the user has some role row in some organization', so being admin in A and a member anywhere in B was...
The credential lookup (find the credential for a certificate) was a JSON-containment scan on the signed credential, with no index that could serve it.
- Roles live in one table: profiles.role and user_roles.is_super_admin were removed, not kept in syncADOPTED
The migration is larger and had to be deployed in order (see the expand, deploy, contract record).
The runner and the model are not production: the model replays the migrations on a reconstruction of the schema, and the original baseline schema is still not in the repository (older files are marked baseline and...
- Credentials and the audit log are written only by the backend; one active credential per certificateADOPTED
Every write goes through the server, so a bug in the server's authorization check is no longer backed by a database rule on those tables.
A signed URL is a bearer link: anyone who receives it can read the file for five minutes.
- Rate limits are counted in PostgreSQL, with no new service, and fail open to the in-memory limiterADOPTED
Fail-open means a database outage removes the global limit rather than blocking users, and the windows are fixed, not sliding.
WHAT ISN'T DONE
- Credentials are W3C-style (JWS, did:web), not a conformance-tested Verifiable Credentials implementation. The revocation status list, the transparency log and SD-JWT selective disclosure are implemented and unit-tested but switched off in production. Nothing here is zero-knowledge.
- About 50 server files use the service-role client, which bypasses row-level security, so on those routes tenant isolation rests on the application checks alone. A stricter profiles policy migration is deliberately on hold.
- There are no browser end-to-end tests, and a full live pass of the login, upload, review and recruiter flows is not finished. The migrations are tested against a model of the production schema, not against production itself, and the original baseline schema is still not in the repository.
- Rate limiting is global only on the endpoints listed in the repository's rate-limiting document; the rest is per instance. There is no tracing, metrics or dashboards, only an optional alert webhook and a deep health check.
- The recruiter and organization-admin invitation flow is incomplete: invited users stay pending. The Next.js major-version upgrade is pending.
- The early version was much less secure than its README suggested; the public closure report says which claims were corrected.
NEXT STEPS
Each one comes from a gap listed above. It says what fixing the gap would take; it is not a promise.
- Add browser end-to-end tests and finish the live pass of the login, upload, review and recruiter flows.
- Move the remaining endpoints onto the global rate limiter.
- Finish the invitation flow so invited users can be activated.
- Take the stricter profiles row-level-security migration off hold once the service-role routes are narrowed.
STACK
- Next.js
- TypeScript
- Supabase
- PostgreSQL
- Gemini
- Vitest
- GitHub Actions