Case study · Automation
ahmdmshazly.me: this portfolio and its content studio
The site you're on: Next.js 15 + Firestore with a private admin studio, security enforced by tested rules, and CI that checks pixels and accessibility on every route.

Role
Design, code, copy, and operations
Timeline
June 2026 - ongoing
Stack
Next.js 15 · React 19 · TypeScript · Cloud Firestore
Output
Four surfaces · 397 automated checks on every push
This page is served by the system it describes. Its content comes from the documented database and cache, under the security policy explained below. You can check the claims against the site you are using.
a394685 (9 July 2026) or a value displayed by the site. Test counts come from runner output, contrast ratios from the WCAG formula, and security bounds from source constants. Values are not rounded up.Why build a portfolio like a production system
A statement about engineering quality is more useful when a reader can verify it. This site provides tested security rules, measured accessibility, reproducible counts, and maintenance processes designed for one busy owner.
One person maintains this system alongside research and coursework, so automated checks carry the routine verification. The repository runs 397 checks on every push, leaving the maintainer to review their evidence and exceptions.
A portfolio makes claims about its author. This one is built so the claims can be checked.
The system, in one figure

Four surfaces share one data layer. The public site is server-rendered React, so content arrives as HTML without a client fetch. The admin studio at /admin has seven views and realtime Firestore feeds. Seven API routes handle authentication, the identity bridge, contact delivery, cache purging, uploads, and repository snapshots. A 30-document architecture handbook ships in a static reader behind a publish switch.
The browser accesses Cloud Firestore directly through the Web SDK. Content does not pass through an API, so Firestore Security Rules provide the authorization boundary. Much of the engineering below follows from that choice.
Identity without an identity database
Google sign-in uses NextAuth v5 with JWT sessions and no session database. Firestore rules require a Firebase identity, which a NextAuth session does not provide. The application bridges the two systems in four steps:
BRIDGE 01
Session, stamped
Google sign-in yields a NextAuth JWT. An allow-list stamps its role claim. The system has two roles: member and admin.
BRIDGE 02
Token requested
Once authenticated, the client POSTs to /api/firebase-token. The route verifies the session on the server.
BRIDGE 03
Custom token minted
The Firebase Admin SDK mints a short-lived custom token carrying the admin claim. The uid is derived, not stored:
// src/lib/firebaseUid.ts: derive the Firebase uid
export function uidFromEmail(email: string): string {
return createHash("sha256")
.update(email.trim().toLowerCase())
.digest("hex")
.slice(0, 32);
}BRIDGE 04
Rules can see you
signInWithCustomToken completes the bridge. From here, every read and write is judged by Security Rules that can finally evaluate request.auth.token.admin.
The uid keeps 128 bits of SHA-256. By the birthday bound, the probability that any two of n users collide is about n²∕2¹²⁹. For one million users, far more than this site expects, that is roughly 1.5 × 10⁻²⁷. The deterministic uid also makes user records idempotent without an id-mapping table.
Authorization you can falsify
Because the browser accesses the database directly, the rules file is the authorization boundary. Public readers see only status == "Published" documents. Members can create comments only in pending state with zero likes. A non-admin can update only their own like, expressed as set algebra:
// firestore.rules: the only non-admin comment update
// the difference between the new and old likedBy sets must be
// exactly {your own uid}, in one direction or the other.
allow update: if isAdmin() || (
isSignedIn()
&& changed().hasOnly(['likedBy'])
&& (
request.resource.data.likedBy.toSet()
.difference(resource.data.likedBy.toSet()) == [request.auth.uid].toSet()
|| resource.data.likedBy.toSet()
.difference(request.resource.data.likedBy.toSet()) == [request.auth.uid].toSet()
)
);The rule turns "you can only add or remove yourself" into a set difference. It prevents counter races, duplicate likes, and changes to another user's like. A 48-case allow/deny suite runs against the Firebase emulator on every push, and rule changes require tests.
Small decisions, made with arithmetic
Three examples show how the code puts bounds around routine risks:
BOUND 01 · ABUSE
The contact form can be worst-cased
A fixed-window limiter allows K = 5 requests per W = 10 minutes per IP. A burst across a window boundary can admit 2K = 10 requests, while sustained throughput stays capped at 30 per hour per IP. Windows live in per-instance memory. A multi-instance deployment would need a shared store behind the same interface.
BOUND 02 · INJECTION
Scripts need a 122-bit invitation
Middleware creates a fresh CSP nonce for each response from a v4 UUID's 122 random bits and emits script-src 'nonce-<value>' 'strict-dynamic'. Only the nonced bootstrap and scripts it loads may execute. An injected script would have a 2⁻¹²² chance of guessing each new nonce.
BOUND 03 · INPUT
Every field has a ceiling
The contact pipeline rejects bodies above 16 KiB before JSON parsing. It caps name at 100 characters, email at 200, topic at 120, and message at 5,000. It also strips newlines from values used in email headers and includes a honeypot field.
Accessibility is arithmetic, not taste
WCAG computes relative luminance from linearised sRGB channels. For AA body text, (L₁ + 0.05)∕(L₂ + 0.05) must reach 4.5:1.
The first warm-paper theme failed the axe scan. The terracotta accent #bd5630 measured 4.33:1 against #fbf7ef, below the threshold. I changed it to #b0502d (4.87:1) and raised the decorative micro-label ink from 4.35:1 to 4.70:1. The design-token comments record those measurements.
Caching that doesn't lie about freshness
Every public page renders per request through Next's Data Cache with a shared tag, so Firestore is not queried for every view. Saving content in the studio purges the tag; the next request reads and caches the new value. A one-hour TTL backs up the explicit purge.
At steady state, Firestore reads scale with edits plus at most 24 TTL refills per tag per day, independent of visitor traffic. A studio edit appears after the next refresh without requiring a deployment.

The two surfaces that watch the other two

The Code Observatory lists 41 public repositories, from research code to first-year coursework, with metadata, rendered READMEs, and an inspector. It shows only aggregate counts for 16 private repositories. An admin-triggered route stores GitHub snapshots, so visitors do not call the GitHub API.

The architecture handbook contains 30 documents, about 56,800 words, 468 sections, 426 cross-links, and 15 diagrams. Each document records the commit it was checked against. The offline reader provides search, a link graph, and a reading path. A verifier fails the build if the landing page names a missing document, and the reader flags documentation checked against an older commit.
The reader builds to static files, which cannot consult the handbook's publish setting. A route handler reads the cached setting and then streams the entry document. Middleware rewrites the static-looking entry paths to that route. Hashed assets remain public and immutable, while a studio toggle controls access to the entry.
Checks and measurements
unit tests · 25 files
Playwright checks · desktop + mobile
security-rules cases · emulated
statement coverage · the gate is 80
Three CI jobs run on every push. The quality job runs lint, typecheck, 223 unit tests, an 80% coverage gate over the framework-free core (90.7% statement coverage at the measured commit), and a production build. The rules job runs 48 allow/deny cases against the emulator. The end-to-end job runs 126 desktop and mobile checks covering user flows, server-side auth gates, focus traps, axe scans, and 40 visual baselines for every route and studio view in both themes.
Any pixel change against a visual baseline fails the run and produces an image diff. Updating a baseline is an explicit, reviewed change.
What I'd still change
Three limits remain. The rate limiter stores windows in process memory and needs a shared store for a multi-instance deployment. Styles still allow 'unsafe-inline' because the UI contains inline style attributes, which nonces do not cover; the refactor is tracked, while scripts already require nonces. Handbook re-verification is manual, so the reader displays a staleness banner when its verified commit falls behind.
The handbook includes a document titled "Known gaps and open questions" that records these issues and the interfaces involved in fixing them.
I own this project's design, security model, tests, documentation, and operations. Its case study, handbook, and deployment are all on the site you are using.