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).
- proxyEvery request gets a request id and a fresh CSP nonce. Only scripts carrying the nonce can run.
- routingURL segments are checked against a strict pattern, then matched exactly against routes.json. Anything else is a 404.
- 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.
- 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.
- apiOne response envelope, request ids, strong ETags with 304s, rate-limit headers, generic error messages, and logs with PII redacted.
live health
GET /api/healthOverall: ok · rendered server-side at 2026-11-11T18:25:59.408Z · uptime 931s
| check | status | detail |
|---|---|---|
| content | ok | 11 files validated & cached |
| storage | ok | writable: false (not required: email delivery is configured) · probe 5ms · ENOENT |
| ok | SMTP configured (server-side only) | |
| contact form | ok | accepting messages: true via email |
| admin api | yes | enabled (bearer token) |
GET /api/health · liveness: /api/health?probe=liveness
public api
GET /api/v1| method | path | returns |
|---|---|---|
| GET | /api/v1 | Index of endpoints and API conventions |
| GET | /api/v1/content/profile | Identity, summary, avatar config and public contact links |
| GET | /api/v1/content/experience | Roles, dates, highlights and stack |
| GET | /api/v1/content/systems | Case studies: problem, design, safeguards, outcome |
| GET | /api/v1/content/skills | Grouped skills |
| GET | /api/v1/content/education | Education |
| GET | /api/v1/content/achievements | Awards and recognition |
| GET | /api/v1/content/threat-model | Threats considered for this site and how each is mitigated |
| POST | /api/v1/contact | Store a message (same-origin, signed form token, rate-limited) |
| GET | /api/v1/admin/messages | Read 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| threat | vector | mitigations | code |
|---|---|---|---|
| Cross-site scripting | Script injected through content/config (e.g. a javascript: link) or through a visitor's message. |
| src/server/content/schemas.tssrc/proxy.tssrc/app/layout.tsx |
| Cross-site request forgery | Another site makes a visitor's browser POST to the contact endpoint. |
| src/server/security/origin.tssrc/server/http/body.tssrc/server/security/form-token.ts |
| Spam and automated submissions | Bots posting the contact form, replaying captured requests, or resubmitting the same message. |
| src/server/contact/service.tssrc/server/security/form-token.tssrc/server/security/rate-limit.ts |
| Admin token brute force | Guessing the bearer token for the admin messages endpoint. |
| src/app/api/v1/admin/messages/route.tssrc/server/security/crypto.ts |
| Email abuse and credential exposure | Using 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. |
| 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. |
| src/server/http/body.tssrc/server/security/rate-limit.tssrc/server/security/client-ip.ts |
| Path traversal | A crafted URL or config value reading files outside /content. |
| src/server/content/paths.tssrc/server/content/manifest.tssrc/server/content/store.ts |
| Lost or corrupted data | Concurrent writes, crashes mid-write, or a damaged storage file. |
| src/server/storage/json-file-store.ts |
| Broken or unsafe configuration | A typo in JSON content, or a limit set so high it disables protection. |
| scripts/validate-content.tssrc/server/content/integrity.tssrc/server/content/store.ts |
| Personal data leakage | Logs, error messages, API responses or stored data revealing visitor or owner details. |
| src/server/http/logger.tssrc/server/content/manifest.tssrc/server/http/handler.ts |
| Header spoofing and log injection | Forged X-Forwarded-For to dodge rate limits, or a crafted X-Request-Id to inject log lines. |
| src/server/security/client-ip.tssrc/server/http/request-id.ts |
| Unicode tricks | Bidi overrides, zero-width or control characters hiding content, or newlines used for header injection. |
| src/server/contact/schema.ts |
| Clickjacking and browser-level leaks | Framing the site, or leaking referrers and capabilities. |
| next.config.tssrc/proxy.ts |
failure modes considered
- A content file is edited into invalid JSON on the serverThe previous valid version keeps being served, the error is logged, and /api/health reports content as degraded until it is fixed.
- Invalid content is committednpm run build runs validate:content first; the build fails with every schema and cross-reference error listed.
- Two messages are submitted at the same momentWrites are serialized through a queue, so neither read-modify-write can overwrite the other.
- The process crashes mid-writeWrites go to a temp file that is fsync'd and atomically renamed; the store is either the old file or the new one, never half of each.
- The email provider is down, slow or rejects the loginSending has hard timeouts and a 12s overall budget. If the message was also saved to disk, the visitor still gets a 201 and the failure is logged; if email was the only channel, the visitor gets a generic 503 and can retry the same form — nothing is reported as received unless it was actually delivered.
- SMTP settings are incomplete or mistypedEmail is disabled (the site keeps running) and /api/health names the missing or invalid variables — by name only, never by value.
- The messages file gets corruptedIt is moved aside with a timestamp (not deleted) and the store starts fresh, so the form keeps working and nothing is lost.
- The disk is read-only (e.g. serverless hosting)A writability probe detects it and messages are delivered by email only. If neither email nor storage is available, the form is replaced by a notice instead of accepting messages it cannot deliver.
- A visitor double-clicks submit, or the network retriesThe button locks while sending, the form token is single-use, and an identical message within 24h returns the original id instead of a duplicate.
- Someone floods the endpoint from many addressesRate-limit state has a hard key cap with LRU eviction, so memory stays bounded; the mailbox also has a size cap.
- The secret is missing in productionThe app refuses to invent one (it would break on restart and across instances) and disables the form instead.
known limitations
- Single instance by designRate limits, the write queue and the replay guard live in process memory. Several instances (or many serverless invocations) need a shared store such as Redis for these — the interfaces are already isolated for that swap.
- Email-only mode is best-effort against replaysWithout a disk, single-use tokens and duplicate detection are tracked in memory per instance, so a replay that lands on a different serverless instance within the token lifetime could send a second email. Signature, fill-time and expiry checks still apply everywhere.
- Message history needs a persistent diskOn serverless hosts messages exist only as emails; the admin API lists stored messages only where data/ is on a persistent volume.