how this site is built

This portfolio is a small backend, built with the same care I put into production systems. There is no database: every word on the site lives in JSON files under /content. Visitor messages are delivered server-side — emailed to me over SMTP and, where the host has a persistent disk, also appended to a JSON file.

Pages are composed from configuration: content/routes.json maps a URL to a page template, and each template is a list of sections made of typed components. Every file is validated against a strict schema at build time (the build fails on a typo) and again at runtime (a bad edit on the server keeps serving the last known good version).

  1. proxyEvery request gets a request id and a fresh CSP nonce. Only scripts carrying the nonce can run.
  2. routingURL segments are checked against a strict pattern, then matched exactly against routes.json. Anything else is a 404.
  3. content storePath allow-list + containment check, size cap, JSON parse, schema validation, deep-freeze, mtime-keyed cache, in-flight de-duplication, last-known-good fallback.
  4. renderingEach component type in the JSON maps to exactly one React component through an exhaustively typed registry. React escapes all text; links pass an allow-list.
  5. apiOne response envelope, request ids, strong ETags with 304s, rate-limit headers, generic error messages, and logs with PII redacted.

live health

GET /api/health

Overall: ok · rendered server-side at 2026-11-11T18:25:59.408Z · uptime 931s

checkstatusdetail
contentok11 files validated & cached
storageokwritable: false (not required: email delivery is configured) · probe 5ms · ENOENT
emailokSMTP configured (server-side only)
contact formokaccepting messages: true via email
admin apiyesenabled (bearer token)

GET /api/health · liveness: /api/health?probe=liveness

public api

GET /api/v1
methodpathreturns
GET/api/v1Index of endpoints and API conventions
GET/api/v1/content/profileIdentity, summary, avatar config and public contact links
GET/api/v1/content/experienceRoles, dates, highlights and stack
GET/api/v1/content/systemsCase studies: problem, design, safeguards, outcome
GET/api/v1/content/skillsGrouped skills
GET/api/v1/content/educationEducation
GET/api/v1/content/achievementsAwards and recognition
GET/api/v1/content/threat-modelThreats considered for this site and how each is mitigated
POST/api/v1/contactStore a message (same-origin, signed form token, rate-limited)
GET/api/v1/admin/messagesRead messages (bearer token; 404 when not configured)
# conditional GET: the second request returns 304 Not Modified
curl -si https://your-project.vercel.app/api/v1/content/experience | grep -i etag
curl -si https://your-project.vercel.app/api/v1/content/experience -H 'If-None-Match: "<etag>"'

# every response carries a request id for log correlation
curl -si https://your-project.vercel.app/api/health -H 'X-Request-Id: my-trace-123456'

threat model

GET /api/v1/content/threat-model
threatvectormitigationscode
Cross-site scriptingScript injected through content/config (e.g. a javascript: link) or through a visitor's message.
  • React escapes all rendered text; the only raw HTML is JSON-LD, serialised with <, >, & and line separators escaped.
  • Every href passes an allow-list: #anchor, /path, https://, mailto:, tel: — javascript:, data: and //host are rejected by the schema.
  • Nonce-based Content-Security-Policy with 'strict-dynamic': injected markup cannot execute script.
  • Visitor messages are never rendered on the site; the admin API returns them as JSON.
src/server/content/schemas.tssrc/proxy.tssrc/app/layout.tsx
Cross-site request forgeryAnother site makes a visitor's browser POST to the contact endpoint.
  • Origin must exactly match the configured site URL or the request's own host; Sec-Fetch-Site: cross-site is rejected.
  • Only application/json is accepted, which forces a CORS preflight this app never approves.
  • A signed form token issued by this server is required.
src/server/security/origin.tssrc/server/http/body.tssrc/server/security/form-token.ts
Spam and automated submissionsBots posting the contact form, replaying captured requests, or resubmitting the same message.
  • Off-screen honeypot field; bots that fill it get a convincing 201 and nothing is stored.
  • HMAC-signed form token enforces a minimum fill time and maximum age, using server time only.
  • Token nonces are single-use (checked inside the write transaction); identical messages within 24h are de-duplicated.
  • Per-client token-bucket rate limit with RateLimit and Retry-After headers.
src/server/contact/service.tssrc/server/security/form-token.tssrc/server/security/rate-limit.ts
Admin token brute forceGuessing the bearer token for the admin messages endpoint.
  • Endpoint returns 404 unless ADMIN_TOKEN (≥ 32 chars) is configured.
  • Constant-time comparison over SHA-256 digests (no length or prefix timing leak).
  • Failed attempts are rate-limited per client, and even a correct token is refused during lockout.
src/app/api/v1/admin/messages/route.tssrc/server/security/crypto.ts
Email abuse and credential exposureUsing the contact form to inject email headers, send HTML/phishing content to the owner, spoof senders, or leak SMTP credentials and the owner's address.
  • SMTP host, credentials and recipient exist only in server environment variables; the browser only ever sees "received" and a reference id.
  • Plain-text body only; name and subject reject newlines before they reach headers, and nodemailer encodes headers as well.
  • The visitor is set as Reply-To, never as From, so nothing is sent pretending to be them.
  • STARTTLS is required on non-TLS ports; TLS 1.2 minimum; file and URL access disabled in the mailer; hard timeouts.
  • Provider errors are logged server-side and never returned; health shows only whether email is configured.
src/server/contact/mailer.tssrc/server/config/env.tssrc/server/contact/service.ts
Resource exhaustion (DoS)Huge bodies, chunked bodies with a lying Content-Length, many source IPs, or filling the disk.
  • Body size enforced while streaming; the stream is cancelled as soon as the cap is crossed.
  • Checks run cheapest-first: rate limit before reading the body, schema before any I/O.
  • Rate-limit state has a hard key cap with LRU eviction; IPv6 is bucketed per /64.
  • Mailbox has a configurable maximum size; content files have a size cap.
src/server/http/body.tssrc/server/security/rate-limit.tssrc/server/security/client-ip.ts
Path traversalA crafted URL or config value reading files outside /content.
  • The public API maps collection names through a fixed registry; user input never becomes a path.
  • Template paths must match pages/<slug>.json, and every resolved path is checked to stay inside the content root.
  • URL segments must match [a-z0-9-] before routing.
src/server/content/paths.tssrc/server/content/manifest.tssrc/server/content/store.ts
Lost or corrupted dataConcurrent writes, crashes mid-write, or a damaged storage file.
  • Read-modify-write is serialized through an in-process queue.
  • Temp file (O_EXCL, 0600) → fsync → atomic rename → directory fsync.
  • Data is schema-validated before it is written and when it is read; a corrupt file is quarantined, never deleted.
src/server/storage/json-file-store.ts
Broken or unsafe configurationA typo in JSON content, or a limit set so high it disables protection.
  • Strict schemas reject unknown keys; the build runs validate:content and fails with every error listed.
  • Abuse limits are configurable only within safe bounds (e.g. contact maxRequests ≤ 20).
  • At runtime, an invalid edit keeps the last known good version live and marks health as degraded.
scripts/validate-content.tssrc/server/content/integrity.tssrc/server/content/store.ts
Personal data leakageLogs, error messages, API responses or stored data revealing visitor or owner details.
  • IPs are stored only as an HMAC with a derived key; logs redact email, name, message, IP and credentials by key.
  • Errors returned to clients are generic and carry a request id; stack traces stay in server logs (and only outside production).
  • Links marked public: false (e.g. phone) are never rendered or returned by the API.
  • Admin responses are Cache-Control: no-store, private.
  • /api/health never includes the SMTP host, user or recipient address.
src/server/http/logger.tssrc/server/content/manifest.tssrc/server/http/handler.ts
Header spoofing and log injectionForged X-Forwarded-For to dodge rate limits, or a crafted X-Request-Id to inject log lines.
  • X-Forwarded-For is read from the right, trusting only the configured number of proxy hops.
  • Incoming request ids are accepted only if they match a short safe pattern; otherwise a UUID is generated.
src/server/security/client-ip.tssrc/server/http/request-id.ts
Unicode tricksBidi overrides, zero-width or control characters hiding content, or newlines used for header injection.
  • Input is NFC-normalized; control, zero-width and bidi-override characters are stripped before length checks.
  • Single-line fields (name, subject) reject newlines.
src/server/contact/schema.ts
Clickjacking and browser-level leaksFraming the site, or leaking referrers and capabilities.
  • frame-ancestors 'none' and X-Frame-Options: DENY.
  • Referrer-Policy, Permissions-Policy, COOP/CORP, nosniff and HSTS on every response; X-Powered-By removed.
next.config.tssrc/proxy.ts

failure modes considered

known limitations