A backend API for collecting verified client reviews through single-use tokens. Only clients who receive a private link can submit a review — making every testimonial cryptographically unforgeable.
Project completed → generate a unique token tied to the client
→ send the client a private link containing that token
→ client opens the link and submits a review
→ system validates the token (exists, not expired, not yet used)
→ review is saved as pending, token is permanently burned
→ you review it and approve it (rejecting deletes it for good; the token stays burned)
→ once approved, the review appears in the portfolio with a "verified" badge
The system rests on two guarantees:
- Authenticity comes from the token. A UUID v4 has 2¹²² possible values, so guessing a valid one by brute force is computationally infeasible. Even a randomly generated UUID is rejected — only explicitly created tokens exist in the database, and each one is single-use and time-limited.
- Editorial control comes from manual moderation. No review goes public automatically. Every submission lands in a
pendingstate and appears on the portfolio only after you approve it — so even a leaked token can't push spam live without your sign-off.
| Layer | Technology |
|---|---|
| Framework | NestJS |
| Language | TypeScript |
| ORM | TypeORM |
| Database | SQLite (single file, no server) — Postgres-ready |
| Auth | JWT via Passport.js |
| Validation | class-validator |
| Admin UI | React + Vite + Tailwind + shadcn/ui — separate SPA in admin/, built in CI, served by the API (see Admin dashboard) |
| Manual for v1 — Resend integration planned for v2 |
Some choices here are driven by the real scale of the problem; others are deliberate portfolio showcases. Being explicit about which is which:
- SQLite, not PostgreSQL. The workload is tiny — one freelancer, a handful of tokens and reviews per month, a single writer. SQLite is a single file with no server to run, no Docker, no connection config, and nothing to "keep running". TypeORM keeps the code database-agnostic, so moving to Postgres later is a one-line driver change. Relational modelling (entities, foreign keys, one-to-one relations, atomic transactions) is demonstrated exactly the same way.
- JWT + Passport.js for auth — a showcase, not a necessity. With a single admin, a static bearer key in an environment variable would be enough. Full JWT authentication (login endpoint, bcrypt-hashed password, signed short-lived tokens, Passport strategy) is implemented because it is a standard backend skill worth demonstrating, and because it makes the security reasoning behind the project concrete.
- Manual review-link delivery for v1.
POST /tokensreturns the private link; you send it to the client however you already talk to them. Automated email (Resend) adds a third-party account, an API key, DNS records for deliverability, and send-failure handling — disproportionate for a few clients a year. It is planned as a v2 feature. - Manual moderation. Not overhead — it is the second half of the trust model. Even a leaked token cannot publish anything without an explicit approval.
- The link secret is its own column, not the primary key. A token row has
an internal
id(uuid) and a separatesecret(uuid, unique, indexed). Onlysecretever travels in a URL; the primary key stays out of links, logs and foreign-key noise, so the value a client could leak is isolated from everything internal. - Reviews have two states, not three.
statusispendingorapproved. There is norejected: rejecting a review deletes the row. The token it used stays burned, so a rejected submission cannot be retried. Keeping rejected rows would only accumulate moderated-away content with no consumer. - The admin dashboard is a separate front end, and the API is built first.
The backend is the foundation and this README is its contract; the dashboard
(
admin/) is designed against a stable API afterwards. See Admin dashboard.
Two entities, one optional one-to-one relation.
| Field | Type | Notes |
|---|---|---|
id |
uuid PK | internal only — never leaves the server |
secret |
uuid, unique, indexed | the value carried in the private link |
clientName |
varchar(120) | who the token is for — never exposed publicly |
clientEmail |
varchar(255), nullable | your reference only |
project |
varchar(160), nullable | which project it was issued for — never public |
createdAt |
timestamp | |
expiresAt |
timestamp | createdAt + TOKEN_TTL_HOURS |
usedAt |
timestamp, nullable | set on use → "burned" |
review |
OneToOne → Review, nullable |
the review created from this token |
Derived state (not columns): active = not used and not past expiresAt;
used = usedAt set; expired = unused and past expiresAt.
| Field | Type | Notes |
|---|---|---|
id |
uuid PK | |
authorName |
varchar(120) | shown publicly |
role |
varchar(120), nullable | e.g. "CTO" — shown publicly if present |
company |
varchar(120), nullable | e.g. "Acme" — shown publicly if present |
rating |
int 1–5 | validated in the DTO |
body |
varchar(2000) | review text; explicit max length |
status |
enum pending | approved |
default pending |
createdAt |
timestamp | |
approvedAt |
timestamp, nullable | |
token |
OneToOne → Token |
FK, onDelete: RESTRICT |
No users table: the single admin is authenticated from ADMIN_EMAIL /
ADMIN_PASSWORD_HASH. Schema is managed by TypeORM synchronize in
development and by committed migrations in production.
| Method | Endpoint | Description |
|---|---|---|
GET |
/reviews |
List approved reviews only |
POST |
/reviews |
Submit a review using a valid token |
| Method | Endpoint | Description |
|---|---|---|
POST |
/auth/login |
Obtain a JWT |
POST |
/tokens |
Generate a token for a client and return the private review link |
GET |
/tokens |
List all tokens (active and used) |
DELETE |
/tokens/:id |
Delete a token |
GET |
/reviews/pending |
List reviews awaiting approval |
PATCH |
/reviews/:id/approve |
Approve a pending review |
DELETE |
/reviews/:id |
Reject and remove a review |
The only interactive surface is small — log in, read the pending reviews and
approve/reject them, mint a token for a client and copy its link, glance at
active tokens. It is built as a separate React SPA (Vite + Tailwind +
shadcn/ui) in admin/, not a CLI: moderation is a visual task, and the
dashboard is one of the most-looked-at parts of a portfolio project.
- Served by the API. The build output is static; NestJS serves it (same origin, no CORS). No second host, no separate deploy.
- Auth. The SPA calls
POST /auth/login, keeps the JWT client-side, and sends it asAuthorization: Bearer. No cookies (so CSRF stays out of scope). - Built in CI, not on the device. A GitHub Actions workflow builds
admin/and publishes the static bundle; the Orange Pi only pulls the prebuilt assets — a Vite/React build would be slow and memory-tight on an H3. - Sequenced after the API. Its own design doc, written once the API contract above is stable.
The NestJS API lives at the repository root; the admin front end is a
self-contained sub-project in admin/ with its own package.json. One repo,
one deploy — a lightweight monorepo, no workspace tooling.
├── src/ # NestJS API
│ ├── app.module.ts
│ ├── main.ts # bootstrap: helmet, CORS, global ValidationPipe
│ ├── config/
│ │ └── configuration.ts # reads + validates env vars at startup (fail-fast)
│ ├── auth/
│ │ ├── auth.module.ts
│ │ ├── auth.controller.ts
│ │ ├── auth.service.ts
│ │ ├── jwt.strategy.ts
│ │ └── jwt-auth.guard.ts
│ ├── tokens/
│ │ ├── tokens.module.ts
│ │ ├── tokens.controller.ts
│ │ ├── tokens.service.ts # exposes consume(secret) — used by ReviewsService
│ │ ├── token.entity.ts
│ │ └── dto/
│ │ └── create-token.dto.ts
│ └── reviews/
│ ├── reviews.module.ts
│ ├── reviews.controller.ts
│ ├── reviews.service.ts
│ ├── review.entity.ts
│ └── dto/
│ └── create-review.dto.ts
└── admin/ # React + Vite admin SPA (added after the API)
# Install dependencies
npm install
# Copy and configure environment variables
cp .env.example .env
# Start in development mode
npm run start:devNODE_ENV=development
# SQLite database file. In production, point this at a path on a persistent
# volume, or switch the driver to Postgres — the code stays database-agnostic.
DB_FILE=vouched.sqlite
# Generate with: openssl rand -hex 32
JWT_SECRET=
JWT_EXPIRES_IN=8h
ADMIN_EMAIL=your@email.com
# bcrypt hash of the password, not the plaintext
ADMIN_PASSWORD_HASH=$2b$12$...
# How long a token stays valid after it's issued
TOKEN_TTL_HOURS=168
# Base URL of the portfolio page that opens the review form. Fixed value —
# never derived from the request Host header.
FRONTEND_URL=https://yourportfolio.comThe admin password is never stored in plaintext — only its bcrypt hash. Generate the hash once and put it in
ADMIN_PASSWORD_HASH. TheJWT_SECRETmust be long and random; anything guessable lets an attacker forge admin tokens.
The reference target is a low-power ARM SBC kept on 24/7 — an Orange Pi PC
(Allwinner H3, 4× Cortex-A7, ~1 GB RAM, armv7l) running Armbian (Debian 13
"trixie"). SQLite fits this profile exactly: no database server to run, the
whole state is one file next to the app, and RAM headroom stays wide (Node +
NestJS ≈ 100–150 MB).
What actually gets deployed is the NestJS application — this repo, plus
dependencies installed on the device. SQLite is not a separate service: it
arrives as a library in node_modules, and the .sqlite file is created by
TypeORM on first boot. node_modules/, dist/, .env and *.sqlite are all
git-ignored and never leave the device.
The NestJS 12 build toolchain requires Node ≥ 22. Debian trixie only ships
Node 20 (nest build fails on it with ERR_REQUIRE_CYCLE_MODULE), so install
the official ARMv7 build over it:
cd /tmp
curl -fLO https://nodejs.org/dist/v22.20.0/node-v22.20.0-linux-armv7l.tar.xz
sudo tar -xJf node-v22.20.0-linux-armv7l.tar.xz -C /usr/local --strip-components=1
sudo apt purge -y nodejs npm && sudo apt autoremove -y # avoid PATH conflicts
hash -r && node -v # v22.x, at /usr/local/bin/nodeAlso install the build toolchain — sudo apt install -y git build-essential python3 sqlite3 — so better-sqlite3 can compile if no armv7l prebuild is
available (a few minutes, once).
git clone https://github.com/EmaLica/Vouched.git ~/vouched && cd ~/vouched
npm ci
cp .env.example .env # then fill in real values
npm run build
node dist/main.js # smoke test — expect "Nest application successfully started"# /etc/systemd/system/vouched.service
[Unit]
Description=Vouched API
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=ema
WorkingDirectory=/home/ema/vouched
ExecStart=/usr/local/bin/node /home/ema/vouched/dist/main.js
Environment=NODE_ENV=production
Environment=PORT=3000
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.targetsudo systemctl daemon-reload
sudo systemctl enable --now vouchedcd ~/vouched && git pull && npm ci && npm run build && sudo systemctl restart vouchedThe admin/ SPA is not built on the device. CI builds it and publishes the
static bundle; the deploy step fetches that bundle to the path the API serves
it from. The H3 never runs a Vite/React build.
- SD card wear. Enable WAL mode on the SQLite connection
(
PRAGMA journal_mode=WAL) to cut write amplification. Armbian already puts/var/logon zram, which helps. For real safety, keep the.sqlitefile on a USB SSD rather than the SD card. - Backups. The database is a single small file — a cron job running
sqlite3 vouched.sqlite ".backup '/path/backup.sqlite'"(orVACUUM INTO) and syncing it off-device is enough. - Clock. The H3 has no battery-backed RTC; keep
systemd-timesyncdenabled oraptsignature checks fail after a reboot without network. - Reachability. Over a private tailnet the API is reachable at the device's Tailscale IP with no router ports opened. Clients who receive a review link are not on the tailnet, so public exposure (Tailscale Funnel, or a reverse proxy) is needed before the review flow works end to end.
- Admin endpoints are protected by JWT bearer authentication, with short-lived tokens signed by a high-entropy secret
- Admin credentials are never stored in plaintext — only a bcrypt hash of the password
- The login endpoint is rate-limited to blunt brute-force attempts
- The private link carries a dedicated
secretcolumn, never the token row's primary key, so the one value a client could leak is isolated from internal identifiers and foreign keys - Review submission requires a valid token that is single-use and time-limited; tokens are burned immediately upon use, so replay attacks are not possible
- Token validation and burn happen in a single transaction, so a token can never be consumed twice under concurrent requests
- Reviews are moderated: nothing is publicly visible until approved, and the public endpoint filters by approval status at the query level
- Public review responses never expose internal data (token secret, client email, project)
- Input validation enforced via
ValidationPipewithwhitelist: trueandforbidNonWhitelisted: true, blocking mass-assignment of fields like the approval flag - Every string field has an explicit maximum length, so a valid token cannot be used to store an oversized payload
- The JWT strategy pins the signing algorithm (
HS256) and the token is only ever read from theAuthorizationheader, never a cookie — so CSRF does not apply - The review link is single-use and short-lived; the portfolio page sends
Referrer-Policy: no-referrerand strips the token from the URL on load, so it does not leak through logs or theRefererheader - Review text is stored raw and escaped by the portfolio at render time (never
innerHTML); incoming HTML is also stripped as defense in depth - CORS is restricted to the portfolio origin, and
helmetsets baseline security headers including HSTS
The reasoning behind each of these — the attack it stops and where the defense lives in the request pipeline — is documented alongside the implementation.
MIT © EmaLica