← Back to work

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.

Source of the numbers. Every count comes from repository commit 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

Architecture diagram of ahmdmshazly.me with client, edge middleware, Next.js server, and data lanes, plus the NextAuth-to-Firebase bridge and CI checks.
Fig. 01: The four application surfaces and the identity bridge, with the verified commit shown in the corner.

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:

ts
// 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.

Accessibility lint rules are errors, and axe scans 11 routes, including every public page and the studio, against WCAG 2.1 A/AA on every push.

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 admin studio's content library: a table of every article and case study with status, index visibility and edit controls.
Fig. 02: The studio content library manages case studies and articles over Firestore.

The two surfaces that watch the other two

The Code Observatory at /code with counts for 41 public repositories, 23 languages, 231 commits this year, and three featured case studies.
Fig. 03: The Code Observatory lists public repositories and aggregate counts for private work.

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 reader with a document sidebar, corpus statistics, and a reading path split into three sessions.
Fig. 04: The handbook reader marks documents verified against an older commit.

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

0

unit tests · 25 files

0

Playwright checks · desktop + mobile

0

security-rules cases · emulated

0%

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.

Check the work. Read the handbook, browse the public repository catalogue, or inspect the CSP header in your browser. The nonce changes on the next response. Treat the running site as current if this page falls behind, and tell me about the mismatch.

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.