Vibecode Senja
track this build6 phases, 14 steps, beginner friendly0%A form, an embed widget, and a wall page. $300/yr for what is essentially a guestbook.
You are building a lean indie version of Senja.
Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt.
===== README.md =====
# Senja · indie build
Testimonial collection you own: a public submit page, avatars resized safely on your server, a moderation queue where nothing goes public until you approve it, a wall page, and an embeddable widget that renders the wall into any site without an iframe. No third-party hosting a page about you.
Estimated effort: **one sitting**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts.
## Stack
| Part | Choice | Why |
| --- | --- | --- |
| Runtime | Node 22, node:http and node:sqlite | a submission is one INSERT; the wall is one SELECT |
| Images | sharp | the one dependency worth taking: resize, strip EXIF, reject fakes |
| Widget | One small vanilla-JS file, no iframe | inherits the host page's font and scopes its own styles |
| Hosting | A VPS behind Caddy | uploads need disk and the widget needs an HTTPS origin |
## Before you start
Have every one of these ready. The plan assumes them from step one.
- [ ] **Node.js 22 or newer** · free
- Why: Everything in this build runs on it: the server, the scripts, the tests.
- Get it: Download the LTS installer from nodejs.org, or install with your package manager (brew install node, or nvm install 22). Restart the terminal afterwards.
- Verify: node --version prints v22 or higher
- [ ] **A terminal and a code editor** · free
- Why: Every step below is a command you type or a file you edit.
- Get it: VS Code (code.visualstudio.com), Cursor or Zed. Open a folder for the project and use the editor's built-in terminal.
- Verify: You can open a folder and run a command in its terminal
- [ ] **Git** · free
- Why: History for your code, and the way most hosts deploy.
- Get it: Install from git-scm.com or with your package manager, then run git init in the project folder once it exists.
- Verify: git --version prints a version
- [ ] **The site you will embed the wall into** · free
- Why: Phase 5 is tested by rendering the widget on a different origin.
- Get it: Your existing site, or a blank HTML page served locally on another port.
- [ ] **A random salt for hashing IPs** · free
- Why: For rate limiting without storing addresses.
- Get it: openssl rand -hex 32 into .env as IP_SALT.
- [ ] **A small always-on server (VPS)** (optional) · about $5 a month
- Why: This needs one process running all the time with a public address. Uploaded avatars need disk that persists, and the widget needs an HTTPS origin.
- Get it: Hetzner Cloud (from about 4 EUR), DigitalOcean or Fly.io. Ubuntu 24.04, the smallest size. You need SSH access and a public IP. Only needed for the deploy phase; develop locally first.
- [ ] **A domain or subdomain** (optional) · roughly $10 a year, or free on an existing domain
- Why: A public address you own, so links you share never break when a provider changes.
- Get it: Register at Cloudflare Registrar, Porkbun or Namecheap, or use a subdomain of one you already own. You add one DNS record in the deploy phase.
- [ ] **Caddy on the server** (optional) · free
- Why: Automatic HTTPS in front of the Node process. Without TLS the browser features this relies on (and your visitors' trust) do not work.
- Get it: On the VPS: follow the install steps at caddyserver.com/docs/install for Ubuntu. One Caddyfile with your domain and a reverse_proxy line is the whole config.
- Verify: caddy version prints a version on the server
## Quick start
```sh
mkdir praise && cd praise && git init && npm init -y && npm pkg set type=module
npm install sharp@0.35.3
mkdir -p data/avatars && cp .env.example .env
```
Then copy `.env.example` to `.env` and fill in the values it documents.
## Honest limits
This build deliberately does not replace:
- In-browser video testimonials: permissions, encoding, storage and playback are genuinely hard and a real reason their price exists.
- Importing reviews from G2, Capterra or Google.
- Their widget template gallery.
- video testimonial recording in-browser
- imported reviews from other platforms
- their widget templates gallery
- a hosted collection URL that isn't yours
If one of those is essential to you, that is the reason to keep paying for Senja, and the README should say so rather than pretend.
===== BRIEF.md =====
# Build brief · Senja
The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins.
Build me a testimonial collection and display tool like Senja. Build it in
phases, in the order below. Do not write the whole system in one pass. Finish a
phase, run its "Done when" check, fix what fails, and only then start the next
phase.
### Stack (fixed, do not substitute)
- Node 22 with `node:http` and `node:sqlite`. No Express, no framework.
- `sharp` for image processing. It is the one dependency worth taking here.
- Server-rendered HTML, inline CSS. The submit form works with JS disabled.
### Data model (create this before Phase 1)
- `testimonials`: id, name, role, company, avatar_path, rating (1-5), body,
source_url, approved (bool, default false), created_at, approved_at, ip_hash
- `approved` defaults to false and nothing untrusted is ever rendered publicly
before a human flips it. Build the flag in Phase 1 even though the public wall
arrives in Phase 4 · retrofitting moderation onto a live public page is how
strangers' spam ends up on a customer's homepage.
### Phase 1 · Submission
Build: `GET /submit` rendering the form (name, role, company, rating, body) and
`POST /submit` validating and storing it unapproved. Cap body length at 1500
characters, require a rating in 1-5, strip control characters. Store text exactly
as typed and escape at render time · never sanitize on the way in, or you will be
unable to tell a real apostrophe from an attack.
Done when: a submission stores one row with `approved = 0`, an over-length body
is rejected with a readable message, and a body containing `<script>` is stored
verbatim and later rendered as visible text rather than executing.
Do not build yet: avatars, admin, the wall, the widget.
### Phase 2 · Avatars
Build: optional avatar upload, resized server-side with sharp to a 128px square
WebP with a JPEG fallback, stripped of EXIF (it carries GPS coordinates), written
to disk under a generated filename that never reuses the uploaded name. Reject
anything that is not a real image by inspecting the decoded header, not the
extension or the declared content type. Cap the upload at 5MB and set a pixel
limit so a decompression bomb cannot exhaust memory.
Done when: a 12MP photo becomes a small square file, a renamed `.txt` is
rejected, a 20MB file is refused before being fully read, and the stored file has
no EXIF.
### Phase 3 · Moderation
Build: `/admin` behind basic auth from `.env` · a list of pending submissions
with approve, reject and delete, the approved list with an un-approve, and a
per-row view of the raw submission. Deleting removes the avatar file too rather
than orphaning it on disk.
Done when: approving makes a row eligible for the public wall, un-approving
removes it immediately, and deleting leaves no file behind.
### Phase 4 · Public wall
Build: `GET /wall` · a responsive masonry grid of approved testimonials only,
clean cards with avatar, name, role, company, stars and body. Lazy-load avatars.
Mobile-first, dark mode via prefers-color-scheme. Render a designed empty state
rather than a blank page when nothing is approved yet.
Done when: the wall shows only approved rows, reflows correctly from 375px to
1440px, and an unapproved submission is invisible in the HTML source and not
merely hidden with CSS.
### Phase 5 · Embeddable widget
Build: `/embed.js` · one script tag plus a target div renders the wall into any
page, with no iframe. Inherit the host page's font stack and scope every style
with a unique class prefix so nothing leaks in either direction. Serve the data
as JSON from a cacheable endpoint with the CORS header the embed needs. Escape
all text on injection. Keep the script under 5KB.
Done when: the widget renders on a different origin, does not alter any host page
style, and a testimonial containing HTML displays as text in the embed.
### Phase 6 · Abuse controls and deploy
Build: honeypot field, minimum fill time, per-IP rate limiting of 3 submissions
per hour held in SQLite, a `/healthz` endpoint, a nightly backup covering both the
database and the avatar directory, a systemd unit, and the README.
Done when: the honeypot silently discards while showing success, the fourth
submission in an hour is refused, and a restore from backup brings back both rows
and images.
### Out of scope (and why)
- In-browser video testimonial recording. That is genuinely hard (permissions,
encoding, storage, playback) and is a real reason their price exists.
- Importing reviews from G2, Capterra or Google.
- Their widget template gallery.
### README must contain
- The embed snippet, verbatim.
- A statement that nothing appears publicly until approved.
- The backup command, and a note that the avatar directory is not in the database
and must be backed up alongside it.
===== AGENTS.md =====
# Agent instructions · Senja indie build
- Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22, node:http and node:sqlite, sharp, One small vanilla-JS file, no iframe, A VPS behind Caddy. Do not substitute.
- Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes.
- Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name.
- Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced.
- Do not invent cryptography, security guarantees, APIs or compliance claims.
- Add a focused test for every destructive, security-sensitive or data-loss path the plan names.
- Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs".
## Known traps
- Never sanitize on the way in. You will lose the ability to tell a real apostrophe from an attack. Escape on output, every time.
===== BUILD_PLAN.md =====
# Build plan · Senja
Testimonial collection you own: a public submit page, avatars resized safely on your server, a moderation queue where nothing goes public until you approve it, a wall page, and an embeddable widget that renders the wall into any site without an iframe. No third-party hosting a page about you.
Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note.
## Phase 1 · Submission
Store submissions as unapproved, exactly as typed, escaped only on the way out.
### Steps
1. Create the project and the testimonials table
testimonials (id, name, role, company, avatar_path, rating 1-5, body, source_url, approved default false, created_at, approved_at, ip_hash). Build the approved flag now even though the wall arrives in Phase 4.
Files: `server.mjs`, `db.mjs`
```sh
mkdir praise && cd praise && git init && npm init -y && npm pkg set type=module
npm install sharp@0.35.3
mkdir -p data/avatars && cp .env.example .env
```
2. Build GET and POST /submit
Name, role, company, rating, body. Cap body at 1500 characters, require rating 1-5, strip control characters. Store text as typed; escape at render time. Works with JavaScript disabled.
### Done when
- [ ] A submission stores one row with approved = 0
- [ ] An over-length body is rejected with a readable message
- [ ] A body containing <script> is stored verbatim and later renders as visible text
### Watch out
- Never sanitize on the way in. You will lose the ability to tell a real apostrophe from an attack. Escape on output, every time.
## Phase 2 · Avatars
Uploads that cannot hurt you: sniffed, resized, stripped, capped.
### Steps
1. Accept an optional avatar and inspect the decoded header
sharp(buffer).metadata() throws on non-images; reject anything that is not a real image regardless of extension or declared type.
2. Resize to 128px square WebP with a JPEG fallback, strip EXIF
sharp(...).resize(128, 128, { fit: 'cover' }).webp(). Write under a generated filename; never reuse the uploaded name.
3. Cap the upload while streaming and set a pixel limit
Stop reading past MAX_UPLOAD_MB. sharp({ limitInputPixels }) so a decompression bomb cannot exhaust memory.
### Done when
- [ ] A 12 MP photo becomes a small square file
- [ ] A renamed .txt is rejected
- [ ] A 20 MB file is refused before being fully read
- [ ] The stored file has no EXIF
## Phase 3 · Moderation
Approve, reject, un-approve, delete, with files cleaned up.
### Steps
1. Build /admin behind basic auth
Pending list with approve, reject and delete; approved list with un-approve; a raw view per row.
2. Delete the avatar file when deleting a row
### Done when
- [ ] Approving makes a row eligible for the wall
- [ ] Un-approving removes it immediately
- [ ] Deleting leaves no file behind in UPLOAD_DIR
## Phase 4 · Public wall
Approved testimonials only, responsive, with a designed empty state.
### Steps
1. Build GET /wall
Masonry grid of approved rows: avatar, name, role, company, stars, body. Lazy-load avatars. Mobile-first, dark mode.
2. Render an empty state when nothing is approved
### Done when
- [ ] The wall shows only approved rows
- [ ] It reflows from 375px to 1440px
- [ ] An unapproved submission is absent from the HTML source, not merely hidden with CSS
## Phase 5 · Embeddable widget
One script tag renders the wall into any page with no iframe and no style leaks.
### Steps
1. Serve GET /api/wall.json with CORS for EMBED_ORIGINS
Cache-Control with a short max-age.
2. Write /embed.js under 5 KB
Finds a target div, fetches the JSON, renders cards with every text escaped on injection. Scope styles with a unique class prefix. Inherit the host font.
### Done when
- [ ] The widget renders on a different origin
- [ ] It does not alter any host page style
- [ ] A testimonial containing HTML displays as text in the embed
## Phase 6 · Abuse controls and deploy
Spam turned away, backups covering both database and files, live.
### Steps
1. Add a honeypot, a minimum fill time and a per-IP limit of 3 per hour in SQLite
2. Add /healthz, systemd, Caddy and a backup covering data/ entirely
Files: `deploy/praise.service`, `Caddyfile`
```sh
tar czf /tmp/praise-$(date +%F).tgz data/
```
3. Write the README
The embed snippet verbatim, the statement that nothing appears until approved, and the note that avatars live outside the database and must be backed up with it.
Files: `README.md`
### Done when
- [ ] The honeypot silently discards while showing success
- [ ] The fourth submission in an hour is refused
- [ ] A restore from backup brings back both rows and images
## Not in this build
- In-browser video testimonials: permissions, encoding, storage and playback are genuinely hard and a real reason their price exists.
- Importing reviews from G2, Capterra or Google.
- Their widget template gallery.
## After v1, if you want it
- A per-project mode so one server collects for several sites
- Layout options (list, carousel) selected by a data attribute on the embed tag
===== .env.example =====
# Copy to .env and fill in. Never commit .env; this file documents it.
# Required. Any free port; Caddy proxies to it.
PORT=3000
# Required. SQLite file.
DATABASE_PATH=./data/testimonials.db
# Required. Where resized avatars are written. Back this up with the database.
UPLOAD_DIR=./data/avatars
# Optional. Reject files over this before reading them fully.
MAX_UPLOAD_MB=5
# Required. Public base URL for the widget script and JSON endpoint.
SITE_URL=https://praise.yourdomain.com
# Required. Comma-separated origins allowed to load the widget data.
EMBED_ORIGINS=https://yoursite.com
# Required · secret. openssl rand -hex 32, once.
IP_SALT=hex-from-openssl-rand
# Required. Any username for the basic-auth admin pages.
ADMIN_USER=admin
# Required · secret. Generate one: openssl rand -base64 24. Never reuse a real password.
ADMIN_PASS=change-me-to-a-long-random-string
You are building a lean indie version of Senja.
Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt.
===== README.md =====
# Senja · indie build
Testimonial collection you own: a public submit page, avatars resized safely on your server, a moderation queue where nothing goes public until you approve it, a wall page, and an embeddable widget that renders the wall into any site without an iframe. No third-party hosting a page about you.
Estimated effort: **one sitting**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts.
## Stack
| Part | Choice | Why |
| --- | --- | --- |
| Runtime | Node 22, node:http and node:sqlite | a submission is one INSERT; the wall is one SELECT |
| Images | sharp | the one dependency worth taking: resize, strip EXIF, reject fakes |
| Widget | One small vanilla-JS file, no iframe | inherits the host page's font and scopes its own styles |
| Hosting | A VPS behind Caddy | uploads need disk and the widget needs an HTTPS origin |
## Before you start
Have every one of these ready. The plan assumes them from step one.
- [ ] **Node.js 22 or newer** · free
- Why: Everything in this build runs on it: the server, the scripts, the tests.
- Get it: Download the LTS installer from nodejs.org, or install with your package manager (brew install node, or nvm install 22). Restart the terminal afterwards.
- Verify: node --version prints v22 or higher
- [ ] **A terminal and a code editor** · free
- Why: Every step below is a command you type or a file you edit.
- Get it: VS Code (code.visualstudio.com), Cursor or Zed. Open a folder for the project and use the editor's built-in terminal.
- Verify: You can open a folder and run a command in its terminal
- [ ] **Git** · free
- Why: History for your code, and the way most hosts deploy.
- Get it: Install from git-scm.com or with your package manager, then run git init in the project folder once it exists.
- Verify: git --version prints a version
- [ ] **The site you will embed the wall into** · free
- Why: Phase 5 is tested by rendering the widget on a different origin.
- Get it: Your existing site, or a blank HTML page served locally on another port.
- [ ] **A random salt for hashing IPs** · free
- Why: For rate limiting without storing addresses.
- Get it: openssl rand -hex 32 into .env as IP_SALT.
- [ ] **A small always-on server (VPS)** (optional) · about $5 a month
- Why: This needs one process running all the time with a public address. Uploaded avatars need disk that persists, and the widget needs an HTTPS origin.
- Get it: Hetzner Cloud (from about 4 EUR), DigitalOcean or Fly.io. Ubuntu 24.04, the smallest size. You need SSH access and a public IP. Only needed for the deploy phase; develop locally first.
- [ ] **A domain or subdomain** (optional) · roughly $10 a year, or free on an existing domain
- Why: A public address you own, so links you share never break when a provider changes.
- Get it: Register at Cloudflare Registrar, Porkbun or Namecheap, or use a subdomain of one you already own. You add one DNS record in the deploy phase.
- [ ] **Caddy on the server** (optional) · free
- Why: Automatic HTTPS in front of the Node process. Without TLS the browser features this relies on (and your visitors' trust) do not work.
- Get it: On the VPS: follow the install steps at caddyserver.com/docs/install for Ubuntu. One Caddyfile with your domain and a reverse_proxy line is the whole config.
- Verify: caddy version prints a version on the server
## Quick start
```sh
mkdir praise && cd praise && git init && npm init -y && npm pkg set type=module
npm install sharp@0.35.3
mkdir -p data/avatars && cp .env.example .env
```
Then copy `.env.example` to `.env` and fill in the values it documents.
## Honest limits
This build deliberately does not replace:
- In-browser video testimonials: permissions, encoding, storage and playback are genuinely hard and a real reason their price exists.
- Importing reviews from G2, Capterra or Google.
- Their widget template gallery.
- video testimonial recording in-browser
- imported reviews from other platforms
- their widget templates gallery
- a hosted collection URL that isn't yours
If one of those is essential to you, that is the reason to keep paying for Senja, and the README should say so rather than pretend.
===== BRIEF.md =====
# Build brief · Senja
The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins.
Build me a testimonial collection and display tool like Senja. Build it in
phases, in the order below. Do not write the whole system in one pass. Finish a
phase, run its "Done when" check, fix what fails, and only then start the next
phase.
### Stack (fixed, do not substitute)
- Node 22 with `node:http` and `node:sqlite`. No Express, no framework.
- `sharp` for image processing. It is the one dependency worth taking here.
- Server-rendered HTML, inline CSS. The submit form works with JS disabled.
### Data model (create this before Phase 1)
- `testimonials`: id, name, role, company, avatar_path, rating (1-5), body,
source_url, approved (bool, default false), created_at, approved_at, ip_hash
- `approved` defaults to false and nothing untrusted is ever rendered publicly
before a human flips it. Build the flag in Phase 1 even though the public wall
arrives in Phase 4 · retrofitting moderation onto a live public page is how
strangers' spam ends up on a customer's homepage.
### Phase 1 · Submission
Build: `GET /submit` rendering the form (name, role, company, rating, body) and
`POST /submit` validating and storing it unapproved. Cap body length at 1500
characters, require a rating in 1-5, strip control characters. Store text exactly
as typed and escape at render time · never sanitize on the way in, or you will be
unable to tell a real apostrophe from an attack.
Done when: a submission stores one row with `approved = 0`, an over-length body
is rejected with a readable message, and a body containing `<script>` is stored
verbatim and later rendered as visible text rather than executing.
Do not build yet: avatars, admin, the wall, the widget.
### Phase 2 · Avatars
Build: optional avatar upload, resized server-side with sharp to a 128px square
WebP with a JPEG fallback, stripped of EXIF (it carries GPS coordinates), written
to disk under a generated filename that never reuses the uploaded name. Reject
anything that is not a real image by inspecting the decoded header, not the
extension or the declared content type. Cap the upload at 5MB and set a pixel
limit so a decompression bomb cannot exhaust memory.
Done when: a 12MP photo becomes a small square file, a renamed `.txt` is
rejected, a 20MB file is refused before being fully read, and the stored file has
no EXIF.
### Phase 3 · Moderation
Build: `/admin` behind basic auth from `.env` · a list of pending submissions
with approve, reject and delete, the approved list with an un-approve, and a
per-row view of the raw submission. Deleting removes the avatar file too rather
than orphaning it on disk.
Done when: approving makes a row eligible for the public wall, un-approving
removes it immediately, and deleting leaves no file behind.
### Phase 4 · Public wall
Build: `GET /wall` · a responsive masonry grid of approved testimonials only,
clean cards with avatar, name, role, company, stars and body. Lazy-load avatars.
Mobile-first, dark mode via prefers-color-scheme. Render a designed empty state
rather than a blank page when nothing is approved yet.
Done when: the wall shows only approved rows, reflows correctly from 375px to
1440px, and an unapproved submission is invisible in the HTML source and not
merely hidden with CSS.
### Phase 5 · Embeddable widget
Build: `/embed.js` · one script tag plus a target div renders the wall into any
page, with no iframe. Inherit the host page's font stack and scope every style
with a unique class prefix so nothing leaks in either direction. Serve the data
as JSON from a cacheable endpoint with the CORS header the embed needs. Escape
all text on injection. Keep the script under 5KB.
Done when: the widget renders on a different origin, does not alter any host page
style, and a testimonial containing HTML displays as text in the embed.
### Phase 6 · Abuse controls and deploy
Build: honeypot field, minimum fill time, per-IP rate limiting of 3 submissions
per hour held in SQLite, a `/healthz` endpoint, a nightly backup covering both the
database and the avatar directory, a systemd unit, and the README.
Done when: the honeypot silently discards while showing success, the fourth
submission in an hour is refused, and a restore from backup brings back both rows
and images.
### Out of scope (and why)
- In-browser video testimonial recording. That is genuinely hard (permissions,
encoding, storage, playback) and is a real reason their price exists.
- Importing reviews from G2, Capterra or Google.
- Their widget template gallery.
### README must contain
- The embed snippet, verbatim.
- A statement that nothing appears publicly until approved.
- The backup command, and a note that the avatar directory is not in the database
and must be backed up alongside it.
===== AGENTS.md =====
# Agent instructions · Senja indie build
- Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22, node:http and node:sqlite, sharp, One small vanilla-JS file, no iframe, A VPS behind Caddy. Do not substitute.
- Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes.
- Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name.
- Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced.
- Do not invent cryptography, security guarantees, APIs or compliance claims.
- Add a focused test for every destructive, security-sensitive or data-loss path the plan names.
- Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs".
## Known traps
- Never sanitize on the way in. You will lose the ability to tell a real apostrophe from an attack. Escape on output, every time.
===== BUILD_PLAN.md =====
# Build plan · Senja
Testimonial collection you own: a public submit page, avatars resized safely on your server, a moderation queue where nothing goes public until you approve it, a wall page, and an embeddable widget that renders the wall into any site without an iframe. No third-party hosting a page about you.
Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note.
## Phase 1 · Submission
Store submissions as unapproved, exactly as typed, escaped only on the way out.
### Steps
1. Create the project and the testimonials table
testimonials (id, name, role, company, avatar_path, rating 1-5, body, source_url, approved default false, created_at, approved_at, ip_hash). Build the approved flag now even though the wall arrives in Phase 4.
Files: `server.mjs`, `db.mjs`
```sh
mkdir praise && cd praise && git init && npm init -y && npm pkg set type=module
npm install sharp@0.35.3
mkdir -p data/avatars && cp .env.example .env
```
2. Build GET and POST /submit
Name, role, company, rating, body. Cap body at 1500 characters, require rating 1-5, strip control characters. Store text as typed; escape at render time. Works with JavaScript disabled.
### Done when
- [ ] A submission stores one row with approved = 0
- [ ] An over-length body is rejected with a readable message
- [ ] A body containing <script> is stored verbatim and later renders as visible text
### Watch out
- Never sanitize on the way in. You will lose the ability to tell a real apostrophe from an attack. Escape on output, every time.
## Phase 2 · Avatars
Uploads that cannot hurt you: sniffed, resized, stripped, capped.
### Steps
1. Accept an optional avatar and inspect the decoded header
sharp(buffer).metadata() throws on non-images; reject anything that is not a real image regardless of extension or declared type.
2. Resize to 128px square WebP with a JPEG fallback, strip EXIF
sharp(...).resize(128, 128, { fit: 'cover' }).webp(). Write under a generated filename; never reuse the uploaded name.
3. Cap the upload while streaming and set a pixel limit
Stop reading past MAX_UPLOAD_MB. sharp({ limitInputPixels }) so a decompression bomb cannot exhaust memory.
### Done when
- [ ] A 12 MP photo becomes a small square file
- [ ] A renamed .txt is rejected
- [ ] A 20 MB file is refused before being fully read
- [ ] The stored file has no EXIF
## Phase 3 · Moderation
Approve, reject, un-approve, delete, with files cleaned up.
### Steps
1. Build /admin behind basic auth
Pending list with approve, reject and delete; approved list with un-approve; a raw view per row.
2. Delete the avatar file when deleting a row
### Done when
- [ ] Approving makes a row eligible for the wall
- [ ] Un-approving removes it immediately
- [ ] Deleting leaves no file behind in UPLOAD_DIR
## Phase 4 · Public wall
Approved testimonials only, responsive, with a designed empty state.
### Steps
1. Build GET /wall
Masonry grid of approved rows: avatar, name, role, company, stars, body. Lazy-load avatars. Mobile-first, dark mode.
2. Render an empty state when nothing is approved
### Done when
- [ ] The wall shows only approved rows
- [ ] It reflows from 375px to 1440px
- [ ] An unapproved submission is absent from the HTML source, not merely hidden with CSS
## Phase 5 · Embeddable widget
One script tag renders the wall into any page with no iframe and no style leaks.
### Steps
1. Serve GET /api/wall.json with CORS for EMBED_ORIGINS
Cache-Control with a short max-age.
2. Write /embed.js under 5 KB
Finds a target div, fetches the JSON, renders cards with every text escaped on injection. Scope styles with a unique class prefix. Inherit the host font.
### Done when
- [ ] The widget renders on a different origin
- [ ] It does not alter any host page style
- [ ] A testimonial containing HTML displays as text in the embed
## Phase 6 · Abuse controls and deploy
Spam turned away, backups covering both database and files, live.
### Steps
1. Add a honeypot, a minimum fill time and a per-IP limit of 3 per hour in SQLite
2. Add /healthz, systemd, Caddy and a backup covering data/ entirely
Files: `deploy/praise.service`, `Caddyfile`
```sh
tar czf /tmp/praise-$(date +%F).tgz data/
```
3. Write the README
The embed snippet verbatim, the statement that nothing appears until approved, and the note that avatars live outside the database and must be backed up with it.
Files: `README.md`
### Done when
- [ ] The honeypot silently discards while showing success
- [ ] The fourth submission in an hour is refused
- [ ] A restore from backup brings back both rows and images
## Not in this build
- In-browser video testimonials: permissions, encoding, storage and playback are genuinely hard and a real reason their price exists.
- Importing reviews from G2, Capterra or Google.
- Their widget template gallery.
## After v1, if you want it
- A per-project mode so one server collects for several sites
- Layout options (list, carousel) selected by a data attribute on the embed tag
===== .env.example =====
# Copy to .env and fill in. Never commit .env; this file documents it.
# Required. Any free port; Caddy proxies to it.
PORT=3000
# Required. SQLite file.
DATABASE_PATH=./data/testimonials.db
# Required. Where resized avatars are written. Back this up with the database.
UPLOAD_DIR=./data/avatars
# Optional. Reject files over this before reading them fully.
MAX_UPLOAD_MB=5
# Required. Public base URL for the widget script and JSON endpoint.
SITE_URL=https://praise.yourdomain.com
# Required. Comma-separated origins allowed to load the widget data.
EMBED_ORIGINS=https://yoursite.com
# Required · secret. openssl rand -hex 32, once.
IP_SALT=hex-from-openssl-rand
# Required. Any username for the basic-auth admin pages.
ADMIN_USER=admin
# Required · secret. Generate one: openssl rand -base64 24. Never reuse a real password.
ADMIN_PASS=change-me-to-a-long-random-string
You are building a production product version of Senja.
Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt.
===== PRODUCT.md =====
# Senja · product brief
## Problem
A form, an embed widget, and a wall page. $300/yr for what is essentially a guestbook.
## Product outcome
A testimonial service for one or a few sites: moderated by default, embeddable anywhere, with uploads handled the safe way and backups that include the files.
## Target user
A builder who needs a maintainable product foundation, not a one-off demo.
## Required capabilities
- Implement the core workflow described in ARCHITECTURE.md
## Explicit non-goals for v1
- In-browser video testimonials: permissions, encoding, storage and playback are genuinely hard and a real reason their price exists.
- Importing reviews from G2, Capterra or Google.
- Their widget template gallery.
- video testimonial recording in-browser
- imported reviews from other platforms
- their widget templates gallery
- a hosted collection URL that isn't yours
## Success criteria
- Unapproved content never appears in any public response, verified with a fixture
- Upload rejection paths verified with a fake image and an oversize file
- One restore drill including avatars performed and dated
- Widget verified on a different origin without style leakage
===== BRIEF.md =====
# Build brief · Senja
The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins.
Build me a testimonial collection and display tool like Senja. Build it in
phases, in the order below. Do not write the whole system in one pass. Finish a
phase, run its "Done when" check, fix what fails, and only then start the next
phase.
### Stack (fixed, do not substitute)
- Node 22 with `node:http` and `node:sqlite`. No Express, no framework.
- `sharp` for image processing. It is the one dependency worth taking here.
- Server-rendered HTML, inline CSS. The submit form works with JS disabled.
### Data model (create this before Phase 1)
- `testimonials`: id, name, role, company, avatar_path, rating (1-5), body,
source_url, approved (bool, default false), created_at, approved_at, ip_hash
- `approved` defaults to false and nothing untrusted is ever rendered publicly
before a human flips it. Build the flag in Phase 1 even though the public wall
arrives in Phase 4 · retrofitting moderation onto a live public page is how
strangers' spam ends up on a customer's homepage.
### Phase 1 · Submission
Build: `GET /submit` rendering the form (name, role, company, rating, body) and
`POST /submit` validating and storing it unapproved. Cap body length at 1500
characters, require a rating in 1-5, strip control characters. Store text exactly
as typed and escape at render time · never sanitize on the way in, or you will be
unable to tell a real apostrophe from an attack.
Done when: a submission stores one row with `approved = 0`, an over-length body
is rejected with a readable message, and a body containing `<script>` is stored
verbatim and later rendered as visible text rather than executing.
Do not build yet: avatars, admin, the wall, the widget.
### Phase 2 · Avatars
Build: optional avatar upload, resized server-side with sharp to a 128px square
WebP with a JPEG fallback, stripped of EXIF (it carries GPS coordinates), written
to disk under a generated filename that never reuses the uploaded name. Reject
anything that is not a real image by inspecting the decoded header, not the
extension or the declared content type. Cap the upload at 5MB and set a pixel
limit so a decompression bomb cannot exhaust memory.
Done when: a 12MP photo becomes a small square file, a renamed `.txt` is
rejected, a 20MB file is refused before being fully read, and the stored file has
no EXIF.
### Phase 3 · Moderation
Build: `/admin` behind basic auth from `.env` · a list of pending submissions
with approve, reject and delete, the approved list with an un-approve, and a
per-row view of the raw submission. Deleting removes the avatar file too rather
than orphaning it on disk.
Done when: approving makes a row eligible for the public wall, un-approving
removes it immediately, and deleting leaves no file behind.
### Phase 4 · Public wall
Build: `GET /wall` · a responsive masonry grid of approved testimonials only,
clean cards with avatar, name, role, company, stars and body. Lazy-load avatars.
Mobile-first, dark mode via prefers-color-scheme. Render a designed empty state
rather than a blank page when nothing is approved yet.
Done when: the wall shows only approved rows, reflows correctly from 375px to
1440px, and an unapproved submission is invisible in the HTML source and not
merely hidden with CSS.
### Phase 5 · Embeddable widget
Build: `/embed.js` · one script tag plus a target div renders the wall into any
page, with no iframe. Inherit the host page's font stack and scope every style
with a unique class prefix so nothing leaks in either direction. Serve the data
as JSON from a cacheable endpoint with the CORS header the embed needs. Escape
all text on injection. Keep the script under 5KB.
Done when: the widget renders on a different origin, does not alter any host page
style, and a testimonial containing HTML displays as text in the embed.
### Phase 6 · Abuse controls and deploy
Build: honeypot field, minimum fill time, per-IP rate limiting of 3 submissions
per hour held in SQLite, a `/healthz` endpoint, a nightly backup covering both the
database and the avatar directory, a systemd unit, and the README.
Done when: the honeypot silently discards while showing success, the fourth
submission in an hour is refused, and a restore from backup brings back both rows
and images.
### Out of scope (and why)
- In-browser video testimonial recording. That is genuinely hard (permissions,
encoding, storage, playback) and is a real reason their price exists.
- Importing reviews from G2, Capterra or Google.
- Their widget template gallery.
### README must contain
- The embed snippet, verbatim.
- A statement that nothing appears publicly until approved.
- The backup command, and a note that the avatar directory is not in the database
and must be backed up alongside it.
===== ARCHITECTURE.md =====
# Architecture · Senja
## Stack
| Part | Choice | Why |
| --- | --- | --- |
| Runtime | Node 22, node:http and node:sqlite | a submission is one INSERT; the wall is one SELECT |
| Images | sharp | the one dependency worth taking: resize, strip EXIF, reject fakes |
| Widget | One small vanilla-JS file, no iframe | inherits the host page's font and scopes its own styles |
| Hosting | A VPS behind Caddy | uploads need disk and the widget needs an HTTPS origin |
## Modules
Each module has one owner concern and a documented way to replace it.
| Module | Owns | How to replace it |
| --- | --- | --- |
| Intake | /submit, validation, honeypot, rate limit | Any handler writing the same rows |
| Media | sharp pipeline and UPLOAD_DIR | Object storage behind the same write/read functions |
| Moderation | /admin and the approved flag | Any UI; the flag is the contract |
| Display | /wall, /api/wall.json, /embed.js | A React widget could replace embed.js against the same JSON |
## Configuration
Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists.
- `PORT` · required · Any free port; Caddy proxies to it.
- `DATABASE_PATH` · required · SQLite file.
- `UPLOAD_DIR` · required · Where resized avatars are written. Back this up with the database.
- `MAX_UPLOAD_MB` · optional · Reject files over this before reading them fully.
- `SITE_URL` · required · Public base URL for the widget script and JSON endpoint.
- `EMBED_ORIGINS` · required · Comma-separated origins allowed to load the widget data.
- `IP_SALT` · required, secret · openssl rand -hex 32, once.
- `ADMIN_USER` · required · Any username for the basic-auth admin pages.
- `ADMIN_PASS` · required, secret · Generate one: openssl rand -base64 24. Never reuse a real password.
## Production baseline
- Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives.
- Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised.
- Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts.
- Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists.
- Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path.
## Decision records
For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
===== AGENTS.md =====
# Agent instructions · Senja product build
- Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Node 22, node:http and node:sqlite, sharp, One small vanilla-JS file, no iframe, A VPS behind Caddy.
- Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit.
- Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present.
- Never invent cryptography or silently weaken a requirement to make a check pass.
- Put every external service behind an interface with a deterministic fake for tests.
- Add migrations and rollback or recovery notes for every persistent data change.
- Log useful operational context without credentials, tokens, passwords or personal data.
- Update documentation and run every check before completing a milestone.
## Known traps
- Never sanitize on the way in. You will lose the ability to tell a real apostrophe from an attack. Escape on output, every time.
===== MILESTONES.md =====
# Delivery milestones · Senja
Estimated effort: **one sitting** for the indie phases; the production-only milestones add the trust and operability layer.
## M1 · Submission
Store submissions as unapproved, exactly as typed, escaped only on the way out.
### Steps
1. Create the project and the testimonials table
testimonials (id, name, role, company, avatar_path, rating 1-5, body, source_url, approved default false, created_at, approved_at, ip_hash). Build the approved flag now even though the wall arrives in Phase 4.
Files: `server.mjs`, `db.mjs`
```sh
mkdir praise && cd praise && git init && npm init -y && npm pkg set type=module
npm install sharp@0.35.3
mkdir -p data/avatars && cp .env.example .env
```
2. Build GET and POST /submit
Name, role, company, rating, body. Cap body at 1500 characters, require rating 1-5, strip control characters. Store text as typed; escape at render time. Works with JavaScript disabled.
### Done when
- [ ] A submission stores one row with approved = 0
- [ ] An over-length body is rejected with a readable message
- [ ] A body containing <script> is stored verbatim and later renders as visible text
### Watch out
- Never sanitize on the way in. You will lose the ability to tell a real apostrophe from an attack. Escape on output, every time.
## M2 · Avatars
Uploads that cannot hurt you: sniffed, resized, stripped, capped.
### Steps
1. Accept an optional avatar and inspect the decoded header
sharp(buffer).metadata() throws on non-images; reject anything that is not a real image regardless of extension or declared type.
2. Resize to 128px square WebP with a JPEG fallback, strip EXIF
sharp(...).resize(128, 128, { fit: 'cover' }).webp(). Write under a generated filename; never reuse the uploaded name.
3. Cap the upload while streaming and set a pixel limit
Stop reading past MAX_UPLOAD_MB. sharp({ limitInputPixels }) so a decompression bomb cannot exhaust memory.
### Done when
- [ ] A 12 MP photo becomes a small square file
- [ ] A renamed .txt is rejected
- [ ] A 20 MB file is refused before being fully read
- [ ] The stored file has no EXIF
## M3 · Moderation
Approve, reject, un-approve, delete, with files cleaned up.
### Steps
1. Build /admin behind basic auth
Pending list with approve, reject and delete; approved list with un-approve; a raw view per row.
2. Delete the avatar file when deleting a row
### Done when
- [ ] Approving makes a row eligible for the wall
- [ ] Un-approving removes it immediately
- [ ] Deleting leaves no file behind in UPLOAD_DIR
## M4 · Public wall
Approved testimonials only, responsive, with a designed empty state.
### Steps
1. Build GET /wall
Masonry grid of approved rows: avatar, name, role, company, stars, body. Lazy-load avatars. Mobile-first, dark mode.
2. Render an empty state when nothing is approved
### Done when
- [ ] The wall shows only approved rows
- [ ] It reflows from 375px to 1440px
- [ ] An unapproved submission is absent from the HTML source, not merely hidden with CSS
## M5 · Embeddable widget
One script tag renders the wall into any page with no iframe and no style leaks.
### Steps
1. Serve GET /api/wall.json with CORS for EMBED_ORIGINS
Cache-Control with a short max-age.
2. Write /embed.js under 5 KB
Finds a target div, fetches the JSON, renders cards with every text escaped on injection. Scope styles with a unique class prefix. Inherit the host font.
### Done when
- [ ] The widget renders on a different origin
- [ ] It does not alter any host page style
- [ ] A testimonial containing HTML displays as text in the embed
## M6 · Abuse controls and deploy
Spam turned away, backups covering both database and files, live.
### Steps
1. Add a honeypot, a minimum fill time and a per-IP limit of 3 per hour in SQLite
2. Add /healthz, systemd, Caddy and a backup covering data/ entirely
Files: `deploy/praise.service`, `Caddyfile`
```sh
tar czf /tmp/praise-$(date +%F).tgz data/
```
3. Write the README
The embed snippet verbatim, the statement that nothing appears until approved, and the note that avatars live outside the database and must be backed up with it.
Files: `README.md`
### Done when
- [ ] The honeypot silently discards while showing success
- [ ] The fourth submission in an hour is refused
- [ ] A restore from backup brings back both rows and images
## M7 · Operate it like a product (production only)
Only for the product path: get told about new submissions, keep the box safe, never lose an avatar.
### Steps
1. Email yourself on each new submission
SMTP credentials in .env (Fastmail, Postmark or any provider); fire-and-forget with a timeout so a mail outage never blocks a submission.
2. Add an uptime check on /healthz and structured request logs
3. Nightly off-box backup of data/ and one restore drill
```sh
rclone copy /tmp/praise-$(date +%F).tgz remote:praise-backups/
```
4. Firewall and unattended upgrades on the VPS
### Done when
- [ ] A new submission produces an email within a minute
- [ ] Stopping the service triggers an alert
- [ ] The restore drill brings back avatars as well as rows
===== OPERATIONS.md =====
# Operations · Senja
## Backup
tar of data/ (database plus avatars) nightly, off the box, thirty days kept.
## Restore
Extract into a fresh checkout, start, check the wall and one avatar.
Do a restore drill before the first real user, and write the date here when it passes.
## Monitoring
Uptime on /healthz; a spike in pending rows is a spam wave.
## Incident checklist
Spam wave: tighten the rate limit, bulk-reject from admin. Compromised box: rebuild, restore data/, rotate ADMIN_PASS and SMTP credentials.
1. Contain the issue without destroying evidence or user data.
2. Record the timeline and affected scope.
3. Rotate exposed secrets and revoke compromised sessions or credentials.
4. Restore from a verified backup when needed.
5. Document the root cause, the remediation and the regression test.
## Release gate
- [ ] Unapproved content never appears in any public response, verified with a fixture
- [ ] Upload rejection paths verified with a fake image and an oversize file
- [ ] One restore drill including avatars performed and dated
- [ ] Widget verified on a different origin without style leakage
## Launch constraint
Do not market omitted Senja capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
===== .env.example =====
# Copy to .env and fill in. Never commit .env; this file documents it.
# Required. Any free port; Caddy proxies to it.
PORT=3000
# Required. SQLite file.
DATABASE_PATH=./data/testimonials.db
# Required. Where resized avatars are written. Back this up with the database.
UPLOAD_DIR=./data/avatars
# Optional. Reject files over this before reading them fully.
MAX_UPLOAD_MB=5
# Required. Public base URL for the widget script and JSON endpoint.
SITE_URL=https://praise.yourdomain.com
# Required. Comma-separated origins allowed to load the widget data.
EMBED_ORIGINS=https://yoursite.com
# Required · secret. openssl rand -hex 32, once.
IP_SALT=hex-from-openssl-rand
# Required. Any username for the basic-auth admin pages.
ADMIN_USER=admin
# Required · secret. Generate one: openssl rand -base64 24. Never reuse a real password.
ADMIN_PASS=change-me-to-a-long-random-string
# Senja · indie build Testimonial collection you own: a public submit page, avatars resized safely on your server, a moderation queue where nothing goes public until you approve it, a wall page, and an embeddable widget that renders the wall into any site without an iframe. No third-party hosting a page about you. Estimated effort: **one sitting**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22, node:http and node:sqlite | a submission is one INSERT; the wall is one SELECT | | Images | sharp | the one dependency worth taking: resize, strip EXIF, reject fakes | | Widget | One small vanilla-JS file, no iframe | inherits the host page's font and scopes its own styles | | Hosting | A VPS behind Caddy | uploads need disk and the widget needs an HTTPS origin | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Everything in this build runs on it: the server, the scripts, the tests. - Get it: Download the LTS installer from nodejs.org, or install with your package manager (brew install node, or nvm install 22). Restart the terminal afterwards. - Verify: node --version prints v22 or higher - [ ] **A terminal and a code editor** · free - Why: Every step below is a command you type or a file you edit. - Get it: VS Code (code.visualstudio.com), Cursor or Zed. Open a folder for the project and use the editor's built-in terminal. - Verify: You can open a folder and run a command in its terminal - [ ] **Git** · free - Why: History for your code, and the way most hosts deploy. - Get it: Install from git-scm.com or with your package manager, then run git init in the project folder once it exists. - Verify: git --version prints a version - [ ] **The site you will embed the wall into** · free - Why: Phase 5 is tested by rendering the widget on a different origin. - Get it: Your existing site, or a blank HTML page served locally on another port. - [ ] **A random salt for hashing IPs** · free - Why: For rate limiting without storing addresses. - Get it: openssl rand -hex 32 into .env as IP_SALT. - [ ] **A small always-on server (VPS)** (optional) · about $5 a month - Why: This needs one process running all the time with a public address. Uploaded avatars need disk that persists, and the widget needs an HTTPS origin. - Get it: Hetzner Cloud (from about 4 EUR), DigitalOcean or Fly.io. Ubuntu 24.04, the smallest size. You need SSH access and a public IP. Only needed for the deploy phase; develop locally first. - [ ] **A domain or subdomain** (optional) · roughly $10 a year, or free on an existing domain - Why: A public address you own, so links you share never break when a provider changes. - Get it: Register at Cloudflare Registrar, Porkbun or Namecheap, or use a subdomain of one you already own. You add one DNS record in the deploy phase. - [ ] **Caddy on the server** (optional) · free - Why: Automatic HTTPS in front of the Node process. Without TLS the browser features this relies on (and your visitors' trust) do not work. - Get it: On the VPS: follow the install steps at caddyserver.com/docs/install for Ubuntu. One Caddyfile with your domain and a reverse_proxy line is the whole config. - Verify: caddy version prints a version on the server ## Quick start ```sh mkdir praise && cd praise && git init && npm init -y && npm pkg set type=module npm install sharp@0.35.3 mkdir -p data/avatars && cp .env.example .env ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - In-browser video testimonials: permissions, encoding, storage and playback are genuinely hard and a real reason their price exists. - Importing reviews from G2, Capterra or Google. - Their widget template gallery. - video testimonial recording in-browser - imported reviews from other platforms - their widget templates gallery - a hosted collection URL that isn't yours If one of those is essential to you, that is the reason to keep paying for Senja, and the README should say so rather than pretend.
# Build brief · Senja The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a testimonial collection and display tool like Senja. Build it in phases, in the order below. Do not write the whole system in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Node 22 with `node:http` and `node:sqlite`. No Express, no framework. - `sharp` for image processing. It is the one dependency worth taking here. - Server-rendered HTML, inline CSS. The submit form works with JS disabled. ### Data model (create this before Phase 1) - `testimonials`: id, name, role, company, avatar_path, rating (1-5), body, source_url, approved (bool, default false), created_at, approved_at, ip_hash - `approved` defaults to false and nothing untrusted is ever rendered publicly before a human flips it. Build the flag in Phase 1 even though the public wall arrives in Phase 4 · retrofitting moderation onto a live public page is how strangers' spam ends up on a customer's homepage. ### Phase 1 · Submission Build: `GET /submit` rendering the form (name, role, company, rating, body) and `POST /submit` validating and storing it unapproved. Cap body length at 1500 characters, require a rating in 1-5, strip control characters. Store text exactly as typed and escape at render time · never sanitize on the way in, or you will be unable to tell a real apostrophe from an attack. Done when: a submission stores one row with `approved = 0`, an over-length body is rejected with a readable message, and a body containing `<script>` is stored verbatim and later rendered as visible text rather than executing. Do not build yet: avatars, admin, the wall, the widget. ### Phase 2 · Avatars Build: optional avatar upload, resized server-side with sharp to a 128px square WebP with a JPEG fallback, stripped of EXIF (it carries GPS coordinates), written to disk under a generated filename that never reuses the uploaded name. Reject anything that is not a real image by inspecting the decoded header, not the extension or the declared content type. Cap the upload at 5MB and set a pixel limit so a decompression bomb cannot exhaust memory. Done when: a 12MP photo becomes a small square file, a renamed `.txt` is rejected, a 20MB file is refused before being fully read, and the stored file has no EXIF. ### Phase 3 · Moderation Build: `/admin` behind basic auth from `.env` · a list of pending submissions with approve, reject and delete, the approved list with an un-approve, and a per-row view of the raw submission. Deleting removes the avatar file too rather than orphaning it on disk. Done when: approving makes a row eligible for the public wall, un-approving removes it immediately, and deleting leaves no file behind. ### Phase 4 · Public wall Build: `GET /wall` · a responsive masonry grid of approved testimonials only, clean cards with avatar, name, role, company, stars and body. Lazy-load avatars. Mobile-first, dark mode via prefers-color-scheme. Render a designed empty state rather than a blank page when nothing is approved yet. Done when: the wall shows only approved rows, reflows correctly from 375px to 1440px, and an unapproved submission is invisible in the HTML source and not merely hidden with CSS. ### Phase 5 · Embeddable widget Build: `/embed.js` · one script tag plus a target div renders the wall into any page, with no iframe. Inherit the host page's font stack and scope every style with a unique class prefix so nothing leaks in either direction. Serve the data as JSON from a cacheable endpoint with the CORS header the embed needs. Escape all text on injection. Keep the script under 5KB. Done when: the widget renders on a different origin, does not alter any host page style, and a testimonial containing HTML displays as text in the embed. ### Phase 6 · Abuse controls and deploy Build: honeypot field, minimum fill time, per-IP rate limiting of 3 submissions per hour held in SQLite, a `/healthz` endpoint, a nightly backup covering both the database and the avatar directory, a systemd unit, and the README. Done when: the honeypot silently discards while showing success, the fourth submission in an hour is refused, and a restore from backup brings back both rows and images. ### Out of scope (and why) - In-browser video testimonial recording. That is genuinely hard (permissions, encoding, storage, playback) and is a real reason their price exists. - Importing reviews from G2, Capterra or Google. - Their widget template gallery. ### README must contain - The embed snippet, verbatim. - A statement that nothing appears publicly until approved. - The backup command, and a note that the avatar directory is not in the database and must be backed up alongside it.
# Agent instructions · Senja indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22, node:http and node:sqlite, sharp, One small vanilla-JS file, no iframe, A VPS behind Caddy. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Never sanitize on the way in. You will lose the ability to tell a real apostrophe from an attack. Escape on output, every time.
# Build plan · Senja
Testimonial collection you own: a public submit page, avatars resized safely on your server, a moderation queue where nothing goes public until you approve it, a wall page, and an embeddable widget that renders the wall into any site without an iframe. No third-party hosting a page about you.
Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note.
## Phase 1 · Submission
Store submissions as unapproved, exactly as typed, escaped only on the way out.
### Steps
1. Create the project and the testimonials table
testimonials (id, name, role, company, avatar_path, rating 1-5, body, source_url, approved default false, created_at, approved_at, ip_hash). Build the approved flag now even though the wall arrives in Phase 4.
Files: `server.mjs`, `db.mjs`
```sh
mkdir praise && cd praise && git init && npm init -y && npm pkg set type=module
npm install sharp@0.35.3
mkdir -p data/avatars && cp .env.example .env
```
2. Build GET and POST /submit
Name, role, company, rating, body. Cap body at 1500 characters, require rating 1-5, strip control characters. Store text as typed; escape at render time. Works with JavaScript disabled.
### Done when
- [ ] A submission stores one row with approved = 0
- [ ] An over-length body is rejected with a readable message
- [ ] A body containing <script> is stored verbatim and later renders as visible text
### Watch out
- Never sanitize on the way in. You will lose the ability to tell a real apostrophe from an attack. Escape on output, every time.
## Phase 2 · Avatars
Uploads that cannot hurt you: sniffed, resized, stripped, capped.
### Steps
1. Accept an optional avatar and inspect the decoded header
sharp(buffer).metadata() throws on non-images; reject anything that is not a real image regardless of extension or declared type.
2. Resize to 128px square WebP with a JPEG fallback, strip EXIF
sharp(...).resize(128, 128, { fit: 'cover' }).webp(). Write under a generated filename; never reuse the uploaded name.
3. Cap the upload while streaming and set a pixel limit
Stop reading past MAX_UPLOAD_MB. sharp({ limitInputPixels }) so a decompression bomb cannot exhaust memory.
### Done when
- [ ] A 12 MP photo becomes a small square file
- [ ] A renamed .txt is rejected
- [ ] A 20 MB file is refused before being fully read
- [ ] The stored file has no EXIF
## Phase 3 · Moderation
Approve, reject, un-approve, delete, with files cleaned up.
### Steps
1. Build /admin behind basic auth
Pending list with approve, reject and delete; approved list with un-approve; a raw view per row.
2. Delete the avatar file when deleting a row
### Done when
- [ ] Approving makes a row eligible for the wall
- [ ] Un-approving removes it immediately
- [ ] Deleting leaves no file behind in UPLOAD_DIR
## Phase 4 · Public wall
Approved testimonials only, responsive, with a designed empty state.
### Steps
1. Build GET /wall
Masonry grid of approved rows: avatar, name, role, company, stars, body. Lazy-load avatars. Mobile-first, dark mode.
2. Render an empty state when nothing is approved
### Done when
- [ ] The wall shows only approved rows
- [ ] It reflows from 375px to 1440px
- [ ] An unapproved submission is absent from the HTML source, not merely hidden with CSS
## Phase 5 · Embeddable widget
One script tag renders the wall into any page with no iframe and no style leaks.
### Steps
1. Serve GET /api/wall.json with CORS for EMBED_ORIGINS
Cache-Control with a short max-age.
2. Write /embed.js under 5 KB
Finds a target div, fetches the JSON, renders cards with every text escaped on injection. Scope styles with a unique class prefix. Inherit the host font.
### Done when
- [ ] The widget renders on a different origin
- [ ] It does not alter any host page style
- [ ] A testimonial containing HTML displays as text in the embed
## Phase 6 · Abuse controls and deploy
Spam turned away, backups covering both database and files, live.
### Steps
1. Add a honeypot, a minimum fill time and a per-IP limit of 3 per hour in SQLite
2. Add /healthz, systemd, Caddy and a backup covering data/ entirely
Files: `deploy/praise.service`, `Caddyfile`
```sh
tar czf /tmp/praise-$(date +%F).tgz data/
```
3. Write the README
The embed snippet verbatim, the statement that nothing appears until approved, and the note that avatars live outside the database and must be backed up with it.
Files: `README.md`
### Done when
- [ ] The honeypot silently discards while showing success
- [ ] The fourth submission in an hour is refused
- [ ] A restore from backup brings back both rows and images
## Not in this build
- In-browser video testimonials: permissions, encoding, storage and playback are genuinely hard and a real reason their price exists.
- Importing reviews from G2, Capterra or Google.
- Their widget template gallery.
## After v1, if you want it
- A per-project mode so one server collects for several sites
- Layout options (list, carousel) selected by a data attribute on the embed tag# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Any free port; Caddy proxies to it. PORT=3000 # Required. SQLite file. DATABASE_PATH=./data/testimonials.db # Required. Where resized avatars are written. Back this up with the database. UPLOAD_DIR=./data/avatars # Optional. Reject files over this before reading them fully. MAX_UPLOAD_MB=5 # Required. Public base URL for the widget script and JSON endpoint. SITE_URL=https://praise.yourdomain.com # Required. Comma-separated origins allowed to load the widget data. EMBED_ORIGINS=https://yoursite.com # Required · secret. openssl rand -hex 32, once. IP_SALT=hex-from-openssl-rand # Required. Any username for the basic-auth admin pages. ADMIN_USER=admin # Required · secret. Generate one: openssl rand -base64 24. Never reuse a real password. ADMIN_PASS=change-me-to-a-long-random-string
# Senja · product brief ## Problem A form, an embed widget, and a wall page. $300/yr for what is essentially a guestbook. ## Product outcome A testimonial service for one or a few sites: moderated by default, embeddable anywhere, with uploads handled the safe way and backups that include the files. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Implement the core workflow described in ARCHITECTURE.md ## Explicit non-goals for v1 - In-browser video testimonials: permissions, encoding, storage and playback are genuinely hard and a real reason their price exists. - Importing reviews from G2, Capterra or Google. - Their widget template gallery. - video testimonial recording in-browser - imported reviews from other platforms - their widget templates gallery - a hosted collection URL that isn't yours ## Success criteria - Unapproved content never appears in any public response, verified with a fixture - Upload rejection paths verified with a fake image and an oversize file - One restore drill including avatars performed and dated - Widget verified on a different origin without style leakage
# Build brief · Senja The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a testimonial collection and display tool like Senja. Build it in phases, in the order below. Do not write the whole system in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Node 22 with `node:http` and `node:sqlite`. No Express, no framework. - `sharp` for image processing. It is the one dependency worth taking here. - Server-rendered HTML, inline CSS. The submit form works with JS disabled. ### Data model (create this before Phase 1) - `testimonials`: id, name, role, company, avatar_path, rating (1-5), body, source_url, approved (bool, default false), created_at, approved_at, ip_hash - `approved` defaults to false and nothing untrusted is ever rendered publicly before a human flips it. Build the flag in Phase 1 even though the public wall arrives in Phase 4 · retrofitting moderation onto a live public page is how strangers' spam ends up on a customer's homepage. ### Phase 1 · Submission Build: `GET /submit` rendering the form (name, role, company, rating, body) and `POST /submit` validating and storing it unapproved. Cap body length at 1500 characters, require a rating in 1-5, strip control characters. Store text exactly as typed and escape at render time · never sanitize on the way in, or you will be unable to tell a real apostrophe from an attack. Done when: a submission stores one row with `approved = 0`, an over-length body is rejected with a readable message, and a body containing `<script>` is stored verbatim and later rendered as visible text rather than executing. Do not build yet: avatars, admin, the wall, the widget. ### Phase 2 · Avatars Build: optional avatar upload, resized server-side with sharp to a 128px square WebP with a JPEG fallback, stripped of EXIF (it carries GPS coordinates), written to disk under a generated filename that never reuses the uploaded name. Reject anything that is not a real image by inspecting the decoded header, not the extension or the declared content type. Cap the upload at 5MB and set a pixel limit so a decompression bomb cannot exhaust memory. Done when: a 12MP photo becomes a small square file, a renamed `.txt` is rejected, a 20MB file is refused before being fully read, and the stored file has no EXIF. ### Phase 3 · Moderation Build: `/admin` behind basic auth from `.env` · a list of pending submissions with approve, reject and delete, the approved list with an un-approve, and a per-row view of the raw submission. Deleting removes the avatar file too rather than orphaning it on disk. Done when: approving makes a row eligible for the public wall, un-approving removes it immediately, and deleting leaves no file behind. ### Phase 4 · Public wall Build: `GET /wall` · a responsive masonry grid of approved testimonials only, clean cards with avatar, name, role, company, stars and body. Lazy-load avatars. Mobile-first, dark mode via prefers-color-scheme. Render a designed empty state rather than a blank page when nothing is approved yet. Done when: the wall shows only approved rows, reflows correctly from 375px to 1440px, and an unapproved submission is invisible in the HTML source and not merely hidden with CSS. ### Phase 5 · Embeddable widget Build: `/embed.js` · one script tag plus a target div renders the wall into any page, with no iframe. Inherit the host page's font stack and scope every style with a unique class prefix so nothing leaks in either direction. Serve the data as JSON from a cacheable endpoint with the CORS header the embed needs. Escape all text on injection. Keep the script under 5KB. Done when: the widget renders on a different origin, does not alter any host page style, and a testimonial containing HTML displays as text in the embed. ### Phase 6 · Abuse controls and deploy Build: honeypot field, minimum fill time, per-IP rate limiting of 3 submissions per hour held in SQLite, a `/healthz` endpoint, a nightly backup covering both the database and the avatar directory, a systemd unit, and the README. Done when: the honeypot silently discards while showing success, the fourth submission in an hour is refused, and a restore from backup brings back both rows and images. ### Out of scope (and why) - In-browser video testimonial recording. That is genuinely hard (permissions, encoding, storage, playback) and is a real reason their price exists. - Importing reviews from G2, Capterra or Google. - Their widget template gallery. ### README must contain - The embed snippet, verbatim. - A statement that nothing appears publicly until approved. - The backup command, and a note that the avatar directory is not in the database and must be backed up alongside it.
# Architecture · Senja ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22, node:http and node:sqlite | a submission is one INSERT; the wall is one SELECT | | Images | sharp | the one dependency worth taking: resize, strip EXIF, reject fakes | | Widget | One small vanilla-JS file, no iframe | inherits the host page's font and scopes its own styles | | Hosting | A VPS behind Caddy | uploads need disk and the widget needs an HTTPS origin | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Intake | /submit, validation, honeypot, rate limit | Any handler writing the same rows | | Media | sharp pipeline and UPLOAD_DIR | Object storage behind the same write/read functions | | Moderation | /admin and the approved flag | Any UI; the flag is the contract | | Display | /wall, /api/wall.json, /embed.js | A React widget could replace embed.js against the same JSON | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `PORT` · required · Any free port; Caddy proxies to it. - `DATABASE_PATH` · required · SQLite file. - `UPLOAD_DIR` · required · Where resized avatars are written. Back this up with the database. - `MAX_UPLOAD_MB` · optional · Reject files over this before reading them fully. - `SITE_URL` · required · Public base URL for the widget script and JSON endpoint. - `EMBED_ORIGINS` · required · Comma-separated origins allowed to load the widget data. - `IP_SALT` · required, secret · openssl rand -hex 32, once. - `ADMIN_USER` · required · Any username for the basic-auth admin pages. - `ADMIN_PASS` · required, secret · Generate one: openssl rand -base64 24. Never reuse a real password. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
# Agent instructions · Senja product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Node 22, node:http and node:sqlite, sharp, One small vanilla-JS file, no iframe, A VPS behind Caddy. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Never sanitize on the way in. You will lose the ability to tell a real apostrophe from an attack. Escape on output, every time.
# Delivery milestones · Senja
Estimated effort: **one sitting** for the indie phases; the production-only milestones add the trust and operability layer.
## M1 · Submission
Store submissions as unapproved, exactly as typed, escaped only on the way out.
### Steps
1. Create the project and the testimonials table
testimonials (id, name, role, company, avatar_path, rating 1-5, body, source_url, approved default false, created_at, approved_at, ip_hash). Build the approved flag now even though the wall arrives in Phase 4.
Files: `server.mjs`, `db.mjs`
```sh
mkdir praise && cd praise && git init && npm init -y && npm pkg set type=module
npm install sharp@0.35.3
mkdir -p data/avatars && cp .env.example .env
```
2. Build GET and POST /submit
Name, role, company, rating, body. Cap body at 1500 characters, require rating 1-5, strip control characters. Store text as typed; escape at render time. Works with JavaScript disabled.
### Done when
- [ ] A submission stores one row with approved = 0
- [ ] An over-length body is rejected with a readable message
- [ ] A body containing <script> is stored verbatim and later renders as visible text
### Watch out
- Never sanitize on the way in. You will lose the ability to tell a real apostrophe from an attack. Escape on output, every time.
## M2 · Avatars
Uploads that cannot hurt you: sniffed, resized, stripped, capped.
### Steps
1. Accept an optional avatar and inspect the decoded header
sharp(buffer).metadata() throws on non-images; reject anything that is not a real image regardless of extension or declared type.
2. Resize to 128px square WebP with a JPEG fallback, strip EXIF
sharp(...).resize(128, 128, { fit: 'cover' }).webp(). Write under a generated filename; never reuse the uploaded name.
3. Cap the upload while streaming and set a pixel limit
Stop reading past MAX_UPLOAD_MB. sharp({ limitInputPixels }) so a decompression bomb cannot exhaust memory.
### Done when
- [ ] A 12 MP photo becomes a small square file
- [ ] A renamed .txt is rejected
- [ ] A 20 MB file is refused before being fully read
- [ ] The stored file has no EXIF
## M3 · Moderation
Approve, reject, un-approve, delete, with files cleaned up.
### Steps
1. Build /admin behind basic auth
Pending list with approve, reject and delete; approved list with un-approve; a raw view per row.
2. Delete the avatar file when deleting a row
### Done when
- [ ] Approving makes a row eligible for the wall
- [ ] Un-approving removes it immediately
- [ ] Deleting leaves no file behind in UPLOAD_DIR
## M4 · Public wall
Approved testimonials only, responsive, with a designed empty state.
### Steps
1. Build GET /wall
Masonry grid of approved rows: avatar, name, role, company, stars, body. Lazy-load avatars. Mobile-first, dark mode.
2. Render an empty state when nothing is approved
### Done when
- [ ] The wall shows only approved rows
- [ ] It reflows from 375px to 1440px
- [ ] An unapproved submission is absent from the HTML source, not merely hidden with CSS
## M5 · Embeddable widget
One script tag renders the wall into any page with no iframe and no style leaks.
### Steps
1. Serve GET /api/wall.json with CORS for EMBED_ORIGINS
Cache-Control with a short max-age.
2. Write /embed.js under 5 KB
Finds a target div, fetches the JSON, renders cards with every text escaped on injection. Scope styles with a unique class prefix. Inherit the host font.
### Done when
- [ ] The widget renders on a different origin
- [ ] It does not alter any host page style
- [ ] A testimonial containing HTML displays as text in the embed
## M6 · Abuse controls and deploy
Spam turned away, backups covering both database and files, live.
### Steps
1. Add a honeypot, a minimum fill time and a per-IP limit of 3 per hour in SQLite
2. Add /healthz, systemd, Caddy and a backup covering data/ entirely
Files: `deploy/praise.service`, `Caddyfile`
```sh
tar czf /tmp/praise-$(date +%F).tgz data/
```
3. Write the README
The embed snippet verbatim, the statement that nothing appears until approved, and the note that avatars live outside the database and must be backed up with it.
Files: `README.md`
### Done when
- [ ] The honeypot silently discards while showing success
- [ ] The fourth submission in an hour is refused
- [ ] A restore from backup brings back both rows and images
## M7 · Operate it like a product (production only)
Only for the product path: get told about new submissions, keep the box safe, never lose an avatar.
### Steps
1. Email yourself on each new submission
SMTP credentials in .env (Fastmail, Postmark or any provider); fire-and-forget with a timeout so a mail outage never blocks a submission.
2. Add an uptime check on /healthz and structured request logs
3. Nightly off-box backup of data/ and one restore drill
```sh
rclone copy /tmp/praise-$(date +%F).tgz remote:praise-backups/
```
4. Firewall and unattended upgrades on the VPS
### Done when
- [ ] A new submission produces an email within a minute
- [ ] Stopping the service triggers an alert
- [ ] The restore drill brings back avatars as well as rows# Operations · Senja ## Backup tar of data/ (database plus avatars) nightly, off the box, thirty days kept. ## Restore Extract into a fresh checkout, start, check the wall and one avatar. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Uptime on /healthz; a spike in pending rows is a spam wave. ## Incident checklist Spam wave: tighten the rate limit, bulk-reject from admin. Compromised box: rebuild, restore data/, rotate ADMIN_PASS and SMTP credentials. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] Unapproved content never appears in any public response, verified with a fixture - [ ] Upload rejection paths verified with a fake image and an oversize file - [ ] One restore drill including avatars performed and dated - [ ] Widget verified on a different origin without style leakage ## Launch constraint Do not market omitted Senja capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Any free port; Caddy proxies to it. PORT=3000 # Required. SQLite file. DATABASE_PATH=./data/testimonials.db # Required. Where resized avatars are written. Back this up with the database. UPLOAD_DIR=./data/avatars # Optional. Reject files over this before reading them fully. MAX_UPLOAD_MB=5 # Required. Public base URL for the widget script and JSON endpoint. SITE_URL=https://praise.yourdomain.com # Required. Comma-separated origins allowed to load the widget data. EMBED_ORIGINS=https://yoursite.com # Required · secret. openssl rand -hex 32, once. IP_SALT=hex-from-openssl-rand # Required. Any username for the basic-auth admin pages. ADMIN_USER=admin # Required · secret. Generate one: openssl rand -base64 24. Never reuse a real password. ADMIN_PASS=change-me-to-a-long-random-string
$ choose a build depth, inspect the files, then open the complete pack in your agent
xvideo testimonial recording in-browser
ximported reviews from other platforms
xtheir widget templates gallery
xa hosted collection URL that isn't yours
Don't feel like building it? These folks already made it free.
all 3 free alternatives to Senja →· no votes, no pay-to-list · just what's real
Senja pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| free | $0/workspace | $0/workspace | 15 testimonials for the lifetime of the account, 1 collection form, 1 project, 1 seat and 5 sizzle credits/month; unlimited widgets and walls. |
| starter | $29/workspace | $24.17/workspace | Unlimited testimonials, 3 forms, 1 project, 2 seats and 5 sizzle credits/month. |
| pro | $59/workspace | $49.17/workspace | Unlimited testimonials and forms, 5 projects, 5 seats and 5 sizzle credits/month. |
free tier15 testimonials total for the lifetime of the account, 1 form, 1 project, 1 seat and 5 sizzle credits/month; unlimited widgets and walls.
billingmonthly + annual (2 months free); no traditional trial because the Free plan is permanent
hidden costsThe 15-testimonial free cap never resets, and deleting testimonials does not restore capacity; later submissions are collected but hidden. Pro charges $10/month per extra project and $5/month per extra seat.
verified 2026-08-11 · source ↗
Is Senja free?
The free plan holds 15 testimonials with unlimited widgets and walls. Paid is the paid plan at $29/mo (checked 2026-08-07).
Vibecode Senja / Testimonial.to
Yes. A competent AI coding agent (Claude Code, Codex, Cursor) can build a usable personal Senja / Testimonial.to replacement in one session with the prompt on this page. It runs on your own machine or server with no subscription.
How much does Senja / Testimonial.to cost?
Senja / Testimonial.to costs about $29/month (paid plan, checked 2026-08-07), which is $348 per year. That's what you save by replacing it with one prompt.
What do I lose by replacing Senja / Testimonial.to?
Honestly: video testimonial recording in-browser; imported reviews from other platforms; their widget templates gallery; a hosted collection URL that isn't yours. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Senja / Testimonial.to?
Yes: Real Testimonials (Another WordPress route: forms, moderation, grids and sliders are free; video and the clever automation live upstairs.) Shosay (Unlimited text, video and audio proof, imports, walls and 25-plus widgets; the bill is zero, the tiny Powered by pill is not.) Strong Testimonials (A WordPress-only form, moderation queue, grid, slider and widget; simple enough once you already own the WordPress problem.) All 3 curated free alternatives are at vibecodeit.com/testimonial-to/alternatives. The prompt is for when you want it exactly your way.