Skip to content
EmaLicaPublic

About

Token-based review system. Only clients you invite can leave one.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Vouched Logo

Vouched

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.

How it works

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 pending state and appears on the portfolio only after you approve it — so even a leaked token can't push spam live without your sign-off.

Stack

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)
Email Manual for v1 — Resend integration planned for v2

Design decisions

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 /tokens returns 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 separate secret (uuid, unique, indexed). Only secret ever 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. status is pending or approved. There is no rejected: 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.

Data model

Two entities, one optional one-to-one relation.

Token

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.

Review

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.

API

Public

Method Endpoint Description
GET /reviews List approved reviews only
POST /reviews Submit a review using a valid token

Admin (JWT required)

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

Admin dashboard

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 as Authorization: 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.

Project structure

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)

Getting started

# Install dependencies
npm install

# Copy and configure environment variables
cp .env.example .env

# Start in development mode
npm run start:dev

Environment variables

NODE_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.com

The admin password is never stored in plaintext — only its bcrypt hash. Generate the hash once and put it in ADMIN_PASSWORD_HASH. The JWT_SECRET must be long and random; anything guessable lets an attacker forge admin tokens.

Deployment

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.

Node version

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/node

Also 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).

First deploy

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"

Run it 24/7 (systemd)

# /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.target
sudo systemctl daemon-reload
sudo systemctl enable --now vouched

Subsequent releases

cd ~/vouched && git pull && npm ci && npm run build && sudo systemctl restart vouched

Admin bundle

The 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.

Operational notes

  • SD card wear. Enable WAL mode on the SQLite connection (PRAGMA journal_mode=WAL) to cut write amplification. Armbian already puts /var/log on zram, which helps. For real safety, keep the .sqlite file 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'" (or VACUUM INTO) and syncing it off-device is enough.
  • Clock. The H3 has no battery-backed RTC; keep systemd-timesyncd enabled or apt signature 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.

Security

  • 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 secret column, 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 ValidationPipe with whitelist: true and forbidNonWhitelisted: 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 the Authorization header, never a cookie — so CSRF does not apply
  • The review link is single-use and short-lived; the portfolio page sends Referrer-Policy: no-referrer and strips the token from the URL on load, so it does not leak through logs or the Referer header
  • 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 helmet sets 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.

License

MIT © EmaLica

About

Token-based review system. Only clients you invite can leave one.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages