Live: hollr.to ยท Example canvas: hollr.to/paulfxyz ยท API health: hollr-api.fly.dev/health
hollr is a personal encrypted message canvas. Claim hollr.to/yourname, share the link, and anyone can write you a thoughtful message โ no account required, no DMs, no algorithm deciding whether you see it.
The sender gets a timed, distraction-free writing space. The clock only runs while they type, so every second on the timer is a second actually spent composing. Messages arrive in your inbox, optionally PGP-encrypted end-to-end so not even hollr's servers can read them. Files and voice recordings are encrypted with AES-256-GCM before they touch disk. Resend API key optional โ the platform delivers from [email protected] by default.
The project is deliberately free of framework overhead: Node.js + Express + SQLite on the backend, static HTML/CSS/JS on the frontend. No build step. No bundler. No ORM. Deployed to Fly.io (backend) and SiteGround via FTP (frontend). The entire codebase is MIT-licensed, self-hostable, and documented in this README at a level where you can understand every decision.
- The product
- Feature list
- Architecture
- Onboarding flow
- Deep-dive: Authentication
- Deep-dive: Encryption
- Deep-dive: Database design
- Deep-dive: The HTTP API
- Deep-dive: Frontend architecture
- Deep-dive: Infrastructure
- Deep-dive: Security decisions
- Deep-dive: Handle ownership & registration security
- API reference
- Environment variables
- Versioning guideline
- Roadmap
- Issues, bottlenecks & lessons learned
- Self-hosting
- Contributing
- License
- A note on vibe coding
hollr started as a personal contact form and grew into a small SaaS platform in the span of a few days. Here is what it does, from the perspective of the two people involved in any hollr interaction.
As a handle owner (you):
- Sign in with X (Twitter) OAuth or a magic-link email โ no password ever.
- Pick
hollr.to/yournameโ permanent, first-come first-served. - Add a notification email so messages reach your inbox.
- Optionally: paste your PGP public key so messages are end-to-end encrypted from the sender's browser. Add a Resend key and verified domain for custom delivery. That's it.
- Share
hollr.to/yournameeverywhere โ bio, email footer, business card.
As a sender:
- Visit
hollr.to/someone. - Click Start โ the timer begins. It only runs while you type.
- Write. Attach a file or record a voice note if you want.
- Hit Send. Drop your name and how they can reach you back.
- Done. Your message, voice note, or files land in their inbox, encrypted.
No account. No login. No app. Just a link and a canvas.
| Feature | Detail | |
|---|---|---|
| ๐ | X OAuth 2.0 PKCE | One-click sign-in with Twitter/X. PKCE code verifier lives server-side in express-session โ not the browser. |
| ๐ฌ | Magic-link email auth | No passwords. Enter email โ click link โ you're in. Tokens are UUID v4, 15-min TTL, single-use. |
| ๐ | PGP end-to-end encryption | Paste your OpenPGP public key in Settings. Senders encrypt in-browser via OpenPGP.js v5 (lazy-loaded from esm.sh). You decrypt offline. |
| ๐ | AES-256-GCM file & voice encryption | Every upload encrypted with a fresh 32-byte key + 12-byte IV server-side before touching disk. Key/IV returned in the email as URL hash params. |
| ๐ | In-browser decrypt viewer | /decrypt page โ Web Crypto API (SubtleCrypto.decrypt). Key never leaves the URL hash, never sent to server. |
| โฑ๏ธ | Timed writing canvas | Timer runs only while typing. Idle time doesn't count. Makes senders think before they holler. |
| ๐ค | Voice recording | Record in-browser via MediaRecorder API. Encrypted, uploaded, linked in notification email. |
| ๐ | File attachments | Drag-and-drop any file. Encrypted with AES-256-GCM. Decrypt viewer linked in email. |
| ๐ง | Optional Resend integration | Platform sends from [email protected] by default. Plug in your Resend key + verified domain for custom delivery. |
| โ๏ธ | Display name | Set a name that appears as "Message to [Name]" on your canvas. Updates live โ no page reload. |
| ๐ | 10 languages | EN, FR, DE, IT, ES, NL, ZH, HI, JA, RU. Auto-detected from browser. Persisted in localStorage. |
| ๐ | Dark / light mode | CSS custom properties. No-flash inline script reads preference before first paint. |
| ๐ก๏ธ | PIN-protected settings | 4โ8 digit PIN, bcrypt cost 12. Default 1234 forced-change on first settings open. |
| ๐ | Forgot PIN | Inline email input in the PIN gate sends a reset link โ no session required. Resets PIN to 1234 and flags pin_is_default, forcing change on next settings open. |
| ๐งฉ | 3-step onboarding wizard | Handle โ PIN (confirmed, no defaults) โ Display name + notification email. Single API call on finish. |
| ๐ก๏ธ | Handle squatting protection | Three-layer defence-in-depth: frontend pre-check, backend magic-link gate, claim-time race protection with COLLATE NOCASE. |
| ๐ ๏ธ | MIT open source | Fork it, self-host on Fly.io, extend it. No vendor lock-in. |
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ hollr.to (SiteGround) โ โ hollr-api.fly.dev (Fly.io) โ
โ Apache ยท static HTML/CSS/JS ยท FTP โ โ Node.js 20 ยท Express 4 โ
โ โ โ SQLite (WAL) ยท /data/hollr.db โ
โ / โ index.html โโโโโโโโโบโ Persistent volume: 3 GB โ
โ /auth/verify โ auth page โ REST โ Region: cdg (Paris) โ
โ /decrypt โ decrypt viewer โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ /:handle โ canvas โ โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโ โโโโโโโโโโ
โโโโโโผโโโโโโโ โโโโโโโโโผโโโโโโโ
โ Resend โ โ X (Twitter) โ
โ REST API โ โ OAuth 2.0 โ
โ emails โ โ PKCE โ
โโโโโโโโโโโโโ โโโโโโโโโโโโโโโโ
Browser SiteGround (Apache) hollr-api.fly.dev
โ โ โ
โโโ GET hollr.to/paulfxyz โโโโโโโโโโโบ โ โ
โ .htaccess routes โ โ
โโโโ handle/index.html โโโโโโโโโโโโโ โ โ
โ โ โ
โโโ GET /api/profile/paulfxyz โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โบโ
โ SELECT users โ
โโโโ { handle, display_name, pgp_public_key } โโโโโโโโโโโโโโโโโโโโ
โ โ โ
โ [sender writes, optionally PGP-encrypts in browser] โ
โ โ โ
โโโ POST /api/upload/paulfxyz โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โบโ
โ multer memoryStorage โ
โ encryptBuffer(AES-256-GCM) โ
โ write .enc to /data/uploads/ โ
โโโโ { url, file_key, file_iv, name } โโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ โ โ
โโโ POST /api/send/paulfxyz โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โบโ
โ validate, store in messages table โ
โ pick Resend key (user or platform) โ
โ forwardMessage() โ Resend REST API โ
โโโโ { ok: true } โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Landing modal
โ
โโโ "Continue with X" โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ โ
โโโ "Register with email" โ POST /api/handle/check (availability gate) โ
โ available? yes โ
POST /api/auth/magic-link โ
(handle stored in magic_links.pending_handle) โ
โ โ
โผ โผ
Email arrives GET /api/auth/x (PKCE)
โ โ
โผ X OAuth callback
GET /api/auth/verify/:token โ
โ POST /api/auth/x/callback
โโโ returning user with handle โโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ โโโ redirect /:handle โ
โ โ
โโโ new user โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ โ needs_email=1? โ
โ โ โโโโโโโโโโโโโโโโโโ โ
โ โ stateNeedEmail โ
โ โ โ โ
โ โโโโโโโโโโโโโโโโโโโ stateOnboarding โโโโโโโโโโโโโ
โ โ
โ Step 1: Pick handle (re-check + live availability)
โ Step 2: Set PIN (confirmed, rejects 1234)
โ Step 3: Display name + notification email
โ โ
โ POST /api/handle/claim (COLLATE NOCASE race guard)
โ โ
โโโโโโโโโโโโโโโโโโโโโโ /:handle?setup=1 (settings auto-opens)
| Data | Algorithm | Where |
|---|---|---|
| Resend API key | AES-256-CBC + PBKDF2-SHA256 (100k iterations, random salt per value) | SQLite users.resend_key as "salt:iv:cipher" hex |
| PIN | bcrypt (cost 12, built-in salt) | SQLite users.pin_hash |
| File / audio uploads | AES-256-GCM (random 32-byte key + 12-byte IV per file) | Fly.io persistent volume |
| Message body (optional) | OpenPGP (client-side, sender's browser, owner's public key) | SQLite messages.body |
| Magic link tokens | UUID v4 (cryptographically random) | SQLite magic_links.token |
| Session tokens | crypto.randomBytes(32).toString('hex') |
SQLite sessions.token |
users
id INTEGER PK
email TEXT UNIQUE -- notification address + magic-link login
x_id TEXT UNIQUE -- Twitter/X numeric user ID
x_username TEXT -- @handle (display only)
handle TEXT UNIQUE NOCASE -- public URL slug
display_name TEXT -- "Message to [name]" on canvas
resend_key TEXT -- AES-256-CBC encrypted Resend API key
from_email TEXT -- verified Resend sender address
pin_hash TEXT -- bcrypt(pin, 12)
pin_is_default INTEGER DEFAULT 0 -- 1 if PIN is still "1234"
pgp_public_key TEXT -- armoured OpenPGP public key
magic_links
email TEXT
token TEXT UNIQUE -- UUID v4
expires_at INTEGER -- Unix timestamp, 15-min TTL
used INTEGER DEFAULT 0 -- marked 1 immediately on click
is_pin_reset INTEGER DEFAULT 0 -- resets pin to 1234, forces change
pending_handle TEXT -- pre-fills onboarding (survives new tab)
sessions
user_id INTEGER FK โ users
token TEXT UNIQUE -- 32-byte random hex, Bearer auth
expires_at INTEGER -- 30-day TTL
messages
handle TEXT -- recipient
sender TEXT -- freeform contact field
body TEXT -- plaintext or PGP armoured block
is_pgp INTEGER DEFAULT 0
file_urls TEXT DEFAULT '[]' -- JSON array
audio_url TEXT
audio_encrypted INTEGER DEFAULT 0The registration experience is a 3-step wizard in auth/verify.html. The full flow from landing page to live canvas looks like this:
Via email (new user):
- Landing modal โ enter desired handle + email โ modal calls
POST /api/handle/checkfirst. If taken, shows inline error โ no email sent. - If available:
POST /api/auth/magic-linkstores{email, token, pending_handle}inmagic_linkstable (after re-checking availability at the backend). - Magic link arrives in inbox โ user clicks it in their email client (new tab).
GET /api/auth/verify/:tokenreturns{ ok, session_token, pending_handle }.pending_handlepre-fills step 1 of the wizard โ this survives the new-tab context switch because it came from the API response, notsessionStorage.- Step 1: confirm/change the handle.
verify.htmlre-calls/api/handle/checkbefore allowing proceed. Live availability check fires at 450ms debounce โ "Continue" button is blocked until confirmed available. - Step 2: choose a PIN (4โ8 digits, confirmed, rejects
1234). Dot-fill visualizer. - Step 3: display name (optional) + notification email (optional). Live preview of "Message to [name]".
- Single
POST /api/handle/claimโ all data in one request. Backend enforcesCOLLATE NOCASEuniqueness at the database level. - Redirect to
/:handle?setup=1โ canvas opens with Settings modal auto-opened.
Via X OAuth (new user):
- Landing modal โ "Continue with X" โ redirect to
GET /api/auth/x. - Backend generates
code_verifier,code_challenge(SHA-256 โ base64url),state. Stored inexpress-session. - Redirect to
https://twitter.com/i/oauth2/authorize?...with PKCE params. - X redirects back to
/api/auth/x/callback. Backend validatesstatefrom session, exchanges code + verifier for tokens. - Fetch
/2/users/meโ get X numeric ID and username. - If no email on file: redirect to
auth/verify?x_session=TOKEN&needs_email=1. stateNeedEmailcollects an email. Skippable (notifications disabled until set).- Same 3-step wizard, handle pre-filled with X username.
Why not passwords? Because passwords have to be stored (even hashed, they're a target), users forget them, and they create support burden. Magic links expire in 15 minutes, work once, and require access to the user's email โ which is a reasonable second factor on its own.
Token generation: uuidv4() โ cryptographically random 36-character string. Stored in the magic_links table alongside the email, expiry timestamp (unixepoch() + 900), and a used flag.
Single-use guarantee: The first thing the verify endpoint does after finding a valid token is set used = 1. This is synchronous (better-sqlite3) so there's no race condition window where two simultaneous requests could both succeed.
Pending handle survives new tabs: Early versions stored the desired handle in sessionStorage. sessionStorage is tab-scoped โ clicking a magic link in an email client opens a new tab with empty sessionStorage. The handle was lost. Fix: the handle is stored in the magic_links.pending_handle column (server-side) and returned in the verify response. The browser never needs to remember it.
Email enumeration prevention: POST /api/auth/forgot-pin always returns { ok: true } even if the email doesn't exist. Attackers cannot use the endpoint to discover registered addresses.
Platform emails: All magic links are sent from [email protected] via the platform Resend key. Users can add their own Resend key for message notifications โ but magic links always go through the platform key to ensure deliverability.
OAuth 2.0 with PKCE (Proof Key for Code Exchange, RFC 7636) protects the authorization code flow from interception attacks. Here's every step:
1. Client generates:
code_verifier = crypto.randomBytes(32).toString('base64url') // 43+ chars
code_challenge = base64url(SHA-256(code_verifier))
2. Store in express-session (server-side โ NOT the browser):
req.session.xState = crypto.randomBytes(16).toString('hex')
req.session.xCodeVerifier = code_verifier
3. Redirect to X authorize URL with:
?response_type=code
&client_id=...
&redirect_uri=https://hollr-api.fly.dev/api/auth/x/callback
&scope=tweet.read users.read
&state=<random>
&code_challenge=<sha256_base64url>
&code_challenge_method=S256
4. X redirects to callback with ?code=AUTH_CODE&state=STATE
5. Backend validates state === req.session.xState (CSRF check)
6. POST to https://api.twitter.com/2/oauth2/token:
{ code, code_verifier, grant_type: 'authorization_code', ... }
X verifies that SHA-256(code_verifier) === code_challenge from step 3
7. Exchange succeeds โ access token โ GET /2/users/me
Why PKCE matters: if an attacker intercepts the authorization code (e.g. via a malicious redirect), they still need the code_verifier to exchange it. The verifier never leaves the server.
Why express-session and not the browser: the callback hits the backend URL, not the browser. sessionStorage and localStorage are inaccessible at /api/auth/x/callback. The session cookie bridges the gap.
Used for Resend API keys stored in the database. AES in CBC mode with PBKDF2 key derivation.
encrypt(plaintext):
salt = crypto.randomBytes(16) // fresh per value
key = PBKDF2(ENCRYPTION_SECRET, salt, 100_000, 32, 'sha256')
iv = crypto.randomBytes(16) // fresh per value
cipher = AES-256-CBC(key, iv)
output = "saltHex:ivHex:ciphertextHex"
Why a salt per value? PBKDF2 with a random salt means two identical plaintexts produce different ciphertexts. An attacker with the database dump cannot find duplicate keys by comparing ciphertext.
Why 100,000 iterations? OWASP 2023 minimum for PBKDF2-SHA256. On a modern CPU this takes ~10ms to compute โ acceptable for a one-time operation, prohibitively slow for dictionary attacks.
Wire format "salt:iv:cipher" as hex strings: Safe to store in a TEXT column. No binary encoding issues. The three segments carry all information needed to decrypt.
Rotation: If ENCRYPTION_SECRET needs to change, decrypt every value with the old secret and re-encrypt with the new one, then swap the env var. There is no shortcut.
GCM (Galois/Counter Mode) provides authenticated encryption: confidentiality plus integrity in one pass. If any byte of the ciphertext is modified, decryption fails with an authentication error.
encryptBuffer(buffer):
key = crypto.randomBytes(32) // 256-bit, unique per file
iv = crypto.randomBytes(12) // 96-bit nonce (GCM recommendation)
cipher = AES-256-GCM(key, iv)
authTag = cipher.getAuthTag() // 16 bytes (128-bit)
on disk = [authTag (16 bytes)][ciphertext]
returns โ { keyHex, ivHex } // embedded in notification email
Why GCM? CBC gives you confidentiality but not integrity โ an attacker could flip bits. GCM's auth tag detects any tampering. Also: GCM is natively supported by the Web Crypto API (SubtleCrypto), enabling fully client-side decryption.
Why tag-first on disk? SubtleCrypto's decrypt expects [ciphertext][authTag] concatenated. Node's crypto API separates them. Storing the tag first (16 bytes, known length) means the decrypt viewer can slice bytes.slice(0, 16) and bytes.slice(16) deterministically, then re-concatenate in the order SubtleCrypto expects.
Why key in URL hash? https://hollr.to/decrypt#url=...&key=...&iv=... โ the hash fragment (#...) is defined in RFC 3986 ยง3.5 as client-side only. Browsers do not include it in HTTP requests. The decrypt key never reaches any server during playback.
Per-file key: Every upload gets its own fresh 256-bit key. Compromising one file's key (if someone forwards the decrypt email) reveals nothing about any other file.
PINs are 4โ8 digit numbers used to gate the settings modal. They are not used for account access, so they don't need to be recoverable โ only resettable via magic link.
bcrypt(pin, cost=12)
Cost 12 means ~250ms to hash on modern hardware. Fast enough to be invisible to the user, slow enough to make brute-forcing 10,000 PIN combinations (the full 4-digit space) take ~40 minutes per attempt โ and that's against the hash, which requires the database.
PINs that are still the default 1234 are flagged with pin_is_default = 1. The settings endpoint returns { error: 'must_change_pin' } if this flag is set, forcing the user to the PIN tab before any other changes can be saved.
PGP is asymmetric: the sender uses the recipient's public key to encrypt, only the private key (which never leaves the recipient's device) can decrypt.
// In the sender's browser, when the handle owner has a PGP key set:
const openpgp = await import('https://esm.sh/openpgp@5');
const publicKey = await openpgp.readKey({ armoredKey: profilePgpKey });
const encrypted = await openpgp.encrypt({
message: await openpgp.createMessage({ text: messageBody }),
encryptionKeys: publicKey,
});
// encrypted is an armoured PGP block โ hollr's server never saw the plaintextWhy client-side? If the server encrypted, it would have to see the plaintext first. The entire point is that even hollr cannot read PGP-encrypted messages.
Why esm.sh? OpenPGP.js is a ~300KB library. Loading it lazily via import('https://esm.sh/openpgp@5') means it only downloads when the profile has a PGP key set. No build step, no bundler. esm.sh transforms npm packages to native ESM.
Decrypt hint in email: PGP-encrypted messages arrive as an armoured block with a GPG usage hint: gpg --decrypt message.asc. hollr never attempts server-side decryption.
The standard argument against SQLite for web apps is "what if you need horizontal scaling?" The counter-argument: hollr does not need horizontal scaling. It's a single-machine deployment on Fly.io. SQLite on a persistent volume is:
- Zero operational overhead โ no connection pool, no separate process, no network roundtrip.
- Trivially backed up โ one file.
cp hollr.db hollr.db.bakis your backup strategy. - Faster than PostgreSQL for reads โ in-process, no serialization, no network.
- WAL mode โ
PRAGMA journal_mode = WALenables concurrent reads alongside writes. The health check and web requests can run simultaneously without blocking each other. - better-sqlite3 is synchronous โ every query completes before the next line runs. No callbacks. No async/await. No accidental N+1 queries from forgetting to await a promise.
The synchronous API is actually the right choice for Node.js + SQLite: the query is in-process and typically completes in microseconds, so blocking the event loop for that duration is preferable to the overhead of async scheduling.
The most common mistake with better-sqlite3 is treating it like an async ORM:
// WRONG โ db.select() returns the query builder, not a promise
const [user] = db.prepare('SELECT * FROM users WHERE id = ?');
await user; // returns the prepared statement
// CORRECT
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(userId);
// .get() โ one row or undefined
// .all() โ array (may be empty)
// .run() โ { changes, lastInsertRowid }SQLite does not support ALTER TABLE ... IF NOT EXISTS. The runtime migration loop handles this:
const migrations = [
{ table: 'users', col: 'x_id', def: 'TEXT' },
// ... more columns
];
for (const { table, col, def } of migrations) {
try { db.exec(`ALTER TABLE ${table} ADD COLUMN ${col} ${def}`); }
catch { /* column already exists โ safe to ignore */ }
}This runs on every startup. It's idempotent: if the column exists, the ALTER TABLE throws and the catch swallows it. No migration files. No migration state. No up and down. Every deploy is safe.
The index ordering trap: If you create an index on a column that doesn't exist yet, SQLite throws immediately โ it doesn't defer the index creation. Any index on a migration-added column must be created after the migration loop.
New users get a temporary __pending_xxxxxxxx handle immediately on account creation. This lets the session/user row exist in the database before onboarding completes. Every endpoint that exposes handle checks handle.startsWith('__pending') and returns null to the frontend.
handle TEXT NOT NULL UNIQUE COLLATE NOCASEThis enforces case-insensitive uniqueness at the database level. PaulFxyz and paulfxyz are treated as the same handle. Without COLLATE NOCASE, two users could claim what appear to be identical handles differing only in case. See Handle ownership & registration security for how this interacts with the multi-layer registration system.
app.set('trust proxy', 1); // 1. Trust Fly.io's reverse proxy for real IPs
// Without this, express-rate-limit throws
// ValidationError on every request
app.use(helmet({ ... })); // 2. Security headers (X-Frame-Options, etc.)
app.use(cors({ ... })); // 3. CORS โ must handle OPTIONS preflight
// before any route handler fires
app.use(express.json({ ... })); // 4. Parse JSON body before routes need it
app.use(session({ ... })); // 5. express-session for X OAuth PKCE state
// Rate limiters โ applied per route group, not globally
const authLimiter = rateLimit({ windowMs: 15*60*1000, max: 10 });
const sendLimiter = rateLimit({ windowMs: 60*1000, max: 5 });Why trust proxy: 1? Fly.io routes all traffic through a load balancer that adds X-Forwarded-For. Express defaults to trust proxy = false, which causes express-rate-limit to throw ValidationError on every request, crashing the app. Setting it to 1 tells Express to trust one level of proxy โ the Fly.io load balancer.
function requireAuth(req, res, next) {
const token = req.headers.authorization?.slice(7); // "Bearer " = 7 chars
const sess = db.prepare(`
SELECT s.token, s.user_id, s.expires_at,
u.email, u.handle, u.resend_key, u.pin_hash, u.from_email,
u.pgp_public_key, u.x_id, u.x_username, u.pin_is_default, u.display_name
FROM sessions s JOIN users u ON u.id = s.user_id
WHERE s.token = ? AND s.expires_at > unixepoch()
`).get(token);
if (!sess) return res.status(401).json({ error: 'Invalid or expired session' });
req.user = sess; // all user data available on req.user
next();
}One JOIN query fetches session + user together. unixepoch() in the WHERE clause handles TTL in the database โ no JavaScript date math. The session token is a Bearer token in the Authorization header, not a cookie, making it safe for cross-origin requests from the static frontend.
const allowedOrigins = [
'https://hollr.to', 'https://www.hollr.to',
...(process.env.NODE_ENV !== 'production'
? ['http://localhost:3000', 'http://localhost:5173']
: []),
];
app.use(cors({
origin: (origin, cb) => {
if (!origin || allowedOrigins.includes(origin)) return cb(null, true);
cb(new Error(`CORS: ${origin} not allowed`));
},
credentials: true,
}));The origin function (not a string/array) enables dynamic origin checking. credentials: true is required so the browser sends the Authorization header on cross-origin requests.
- Auth routes (magic link, forgot-pin, verify): 10 requests per 15 minutes per IP. Prevents magic-link spam and brute-force attempts.
- Send/upload routes: 5 requests per minute per IP. Prevents canvas abuse from automated senders.
- Limits are per-IP (from
X-Forwarded-For, trusted because oftrust proxy: 1) because these endpoints are unauthenticated or pre-authentication.
The frontend is plain HTML, CSS, and vanilla JavaScript. The reasons:
- Zero build tooling.
index.htmlis a file. Changes deploy immediately. There's nonpm run build, no artifact, no CI pipeline needed. - Zero dependency churn. A React app from 2023 needs dependency updates every month. An HTML file from 2023 still works perfectly.
- Auditability. The entire codebase is readable in a browser's View Source. There's no minified bundle obscuring the logic.
- Performance. The landing page is a single HTTP request (CSS and JS inlined). The canvas page loads its fonts from Google, but everything else is one file.
The trade-offs are real: more verbose JS, no component reuse, manual DOM manipulation. For a product at this scale, those trade-offs are worth it.
The landing page (index.html) has its CSS and JS inlined via a Python script:
with open('style.css') as f: css = f.read()
with open('main.js') as f: js = f.read()
html = html.replace('<link rel="stylesheet" href="proxy.php?url=https%3A%2F%2Fgithub.com.%2Fstyle.css" />', f'<style>{css}</style>')
html = html.replace('<script src="proxy.php?url=https%3A%2F%2Fgithub.com.%2Fmain.js"></script>', f'<script>{js}</script>')
# favicon embedded as base64 data URIResult: one HTTP request to load the entire page (minus Google Fonts). The source files (landing-src/) are kept in the repo for editing.
const T = {
en: { page_title: 'Message toโฆ', page_sub: 'Hit Start when you\'re ready to write.', ... },
fr: { page_title: 'Message ร โฆ', ... },
// 8 more languages
};
function applyTranslations() {
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.dataset.i18n;
// Skip page_title and welcome_eyebrow if display name is already injected
if ((key === 'page_title' || key === 'welcome_eyebrow') && window._hollrDisplayName) return;
el.innerHTML = T[currentLang][key] ?? T.en[key] ?? '';
});
}Language auto-detection: navigator.language.slice(0, 2). Persisted in localStorage('hollr_lang'). The language switcher in the welcome modal is a single flag pill button โ clicking opens a language picker overlay with a 2-column flag + name grid populated by buildLangPickerGrid().
The _hollrDisplayName guard: applyTranslations() is called on every state change (timer start, pause, language switch). Without the guard, each call would overwrite the dynamically injected "Message to Paul Fleury" with the template string "Message toโฆ". The guard ensures that once loadHollrProfile() has set the display name, translation re-renders leave it alone.
Session tokens go in sessionStorage, not localStorage:
sessionStorageis tab-scoped โ each tab has its own session. Cleared when the tab closes.localStoragepersists across tabs and browser restarts โ appropriate for preferences (theme, language), not auth tokens.- The canvas is designed to be embeddable in iframes.
sessionStorageworks correctly per-frame;localStorageis shared across the page and its iframes.
Early versions stored the desired handle (from the landing modal) in sessionStorage. This broke when the magic link opened in a new tab (empty sessionStorage). Fixed by storing pending_handle server-side in magic_links.
RewriteEngine On
RewriteRule ^auth/verify/?$ auth/verify.html [L]
RewriteRule ^decrypt/?$ decrypt/index.html [L]
RewriteCond %{REQUEST_FILENAME} -f
RewriteRule ^ - [L]
RewriteCond %{REQUEST_URI} !^/auth/
RewriteCond %{REQUEST_URI} !^/decrypt
RewriteRule ^([a-zA-Z0-9_-]+)/?$ handle/index.html [L]Order is critical. /auth/verify and /decrypt must be matched before the /:handle wildcard. Static files (-f check) are served directly. The /:handle pattern catches anything else that looks like a handle. Without this exact ordering, visiting /auth/verify would serve the canvas page for a handle named "auth".
<!-- This script runs synchronously before <body> renders -->
<script>
(function() {
let t; try { t = localStorage.getItem('hollr-theme'); } catch {}
const dark = t ? t === 'dark' : window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
})();
</script>This inline script runs before the browser paints anything, so there's no flash of the wrong theme. CSS custom properties (--bg, --text, etc.) are defined in :root (light) and [data-theme="dark"] (dark).
The /decrypt page uses SubtleCrypto to decrypt files entirely in the browser:
// URL: /decrypt#url=...&key=64hexChars&iv=24hexChars&name=file.pdf&type=application/pdf
const { url, key: keyHex, iv: ivHex, name, type } = parseHash();
// Fetch raw encrypted bytes (no credentials โ just the encrypted blob)
const encrypted = await fetch(url).then(r => r.arrayBuffer());
// Reconstruct key and IV
const rawKey = hexToBytes(keyHex);
const iv = hexToBytes(ivHex);
const cryptoKey = await crypto.subtle.importKey('raw', rawKey, { name: 'AES-GCM' }, false, ['decrypt']);
// Rearrange wire format: [authTag][cipher] โ [cipher][authTag] for SubtleCrypto
const authTag = encrypted.slice(0, 16);
const ciphertext = encrypted.slice(16);
const combined = new Uint8Array([...new Uint8Array(ciphertext), ...new Uint8Array(authTag)]);
// Decrypt
const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv, tagLength: 128 }, cryptoKey, combined);
// Detect type and render or offer download
const blob = new Blob([decrypted], { type });The key never leaves the URL fragment. Browsers do not send #fragments in HTTP requests (RFC 3986 ยง3.5). The decrypt key is therefore never logged by any server, including hollr's.
Fly.io runs Docker containers as Firecracker microVMs. Key decisions:
- Region: cdg (Paris). Lowest latency for the expected European user base.
- Persistent volume: 3 GB
hollr_data. SQLite file and uploads live on/data. The volume survives deploys, restarts, and even app destruction (if the volume isn't explicitly deleted). --remote-onlyflag. Builds the Docker image on Fly's remote builders. Avoids arm64/amd64 cross-compilation issues on Apple Silicon Macs.- Health check. Fly polls
GET /healthevery 30s. If it fails, the machine is replaced. Response:{ ok: true, version: "5.2.7" }. - Zero-downtime deploys. Rolling strategy: new machine starts, health check passes, old machine stops. Database migrations run on startup โ they're fast (ALTER TABLE) so there's no meaningful window where the old and new schemas conflict.
The CNAME trap: When you rename a Fly.io app, the old *.fly.dev hostname disappears immediately. Any DNS CNAME pointing to the old name breaks silently โ requests return DNS resolution failures, which surface in the browser as "Network error." Always update CNAMEs before or simultaneously with an app rename.
lftp -u "[email protected],PASSWORD" es61.siteground.eu << 'EOF'
set ftp:ssl-allow no # disable TLS negotiation (SiteGround plain FTP)
set net:timeout 30 # prevent hanging on connection issues
cd hollr.to/public_html
mirror --reverse --delete --verbose /tmp/stage/ ./
quit
EOFmirror --reverse --delete is a one-way sync from local to remote. Files not in the local source are deleted on the server. This ensures stale files don't accumulate.
set ftp:ssl-allow no โ SiteGround's shared hosting tier uses plain FTP (not FTPS or SFTP on the standard plan). Without this, lftp spends 30 seconds trying TLS negotiation before timing out.
Resend is used for two purposes with two different keys:
-
Platform key (
PLATFORM_RESEND_KEY): Sends all magic links and notification emails when the user hasn't set up their own key. Sender:hollr <[email protected]>. Thehollr.todomain is verified in Resend. -
User key (optional): Users can add their own Resend key in Settings. When set, notification emails go through their account with their verified domain as the sender. The key is stored encrypted (AES-256-CBC) in the database.
The reply_to: null lesson: Resend returns a 422 if reply_to is explicitly set to null. The fix is to only add the reply_to key to the payload when the sender's contact field passes an email regex. Absent is different from null in JSON APIs.
hollr has no password field anywhere in the codebase. Authentication is:
- Magic links (15-min, single-use, require email access)
- X OAuth 2.0 PKCE (requires X account access)
This eliminates an entire attack surface: no credential stuffing, no password spray, no leaked hash tables, no forgot-password flows (only PIN reset, which is much lower stakes).
- Magic links are the auth mechanism โ no traditional form POST needs CSRF tokens.
- X OAuth uses the
stateparameter for CSRF protection (standard OAuth 2.0). - Bearer tokens in
Authorizationheaders are not sent by browsers automatically (unlike cookies). No CSRF risk for authenticated API endpoints.
All user-generated content embedded in HTML email templates is escaped:
const esc = str => String(str)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>');PGP-armoured ciphertext is rendered in <pre> with the same escaping. The canvas itself does not render user content as HTML.
Sets security headers on every response: X-Frame-Options, X-Content-Type-Options, Referrer-Policy, X-XSS-Protection. CSP is disabled (contentSecurityPolicy: false) because the canvas loads OpenPGP.js from esm.sh โ a strict CSP would block this external import. This is a deliberate trade-off.
POST /api/auth/forgot-pin always returns { ok: true } regardless of whether the email exists. An attacker cannot use the endpoint to determine which emails are registered.
Handle registration in hollr is a multi-step flow: a user types a handle in the landing modal, receives a magic link, lands on the onboarding wizard, and finally claims the handle. That's four distinct moments where a race condition or missing check could allow one user to steal another's chosen โ or worse, existing โ handle.
v5.2.7 closes all four gaps with a three-layer defence-in-depth system.
Before sending any magic link, the modal calls POST /api/handle/check. If the handle is taken, an inline error is shown immediately. No email is dispatched, no backend resources are consumed, and the user can pick a different handle without leaving the page.
// Step 1: verify handle is free BEFORE touching email delivery
const checkRes = await fetch('/api/handle/check', { method: 'POST', body: JSON.stringify({ handle }) });
const { available, reason } = await checkRes.json();
if (!available) { showEmailErr(reason); return; }
// Step 2: only now send the magic link
await fetch('/api/auth/magic-link', { method: 'POST', body: JSON.stringify({ email, handle }) });This layer is UX: it gives users fast, accurate feedback without round-tripping through email. It is not the security boundary โ a determined attacker can bypass it entirely by calling the API directly. That's why Layer 2 exists.
POST /api/auth/magic-link queries the database directly for the handle before calling Resend. Even if someone bypasses the frontend entirely โ via curl, a custom script, or a browser devtools fetch โ the magic link is never sent if the handle is already registered:
const taken = db.prepare(
"SELECT id FROM users WHERE handle = ? COLLATE NOCASE AND handle NOT LIKE '__pending_%'"
).get(handle);
if (taken) return res.status(409).json({ error: `hollr.to/${handle} is already taken.`, code: 'handle_taken' });This is the hard gate. Resend is never called. The magic_links row is never created. The handle cannot be claimed via this flow.
Between the moment a user types a handle and the moment they click "Launch", time passes: an email is sent, the user reads it, opens the wizard, fills in a PIN, sets a display name. That window could be seconds or hours. Someone else could claim the handle during that window.
Two sub-layers prevent this:
3a โ verify.html re-check: verify.html calls /api/handle/check immediately before POST /api/handle/claim. If the handle was taken between magic-link send and claim, the wizard shows an error and the user is invited to pick a different handle.
3b โ Database-level uniqueness: POST /api/handle/claim uses COLLATE NOCASE AND handle NOT LIKE '__pending_%' in its own uniqueness check before writing. If two users click "Launch" in the same millisecond, the database constraint โ not application code โ decides the winner. The loser gets a 409.
SQLite, by default, treats paulfxyz and PAULFXYZ as different strings for uniqueness purposes. Without COLLATE NOCASE on the handle column, an attacker can claim PaulFxyz while paulfxyz is already taken, producing a handle that looks identical in the browser address bar.
The column declaration:
handle TEXT NOT NULL UNIQUE COLLATE NOCASEenforces case-insensitive uniqueness at the database constraint level โ not just in application code. This means it's impossible to bypass via direct SQL, tool calls, or any future code path that forgets to add the check. The database itself is the invariant holder.
New users receive a temporary __pending_xxxxxxxx handle the moment their account row is created โ before they complete onboarding and pick a real name. This row exists in the users table and is a real handle value.
Without the NOT LIKE '__pending_%' filter, POST /api/handle/check would query:
SELECT id FROM users WHERE handle = '__pending_a1b2c3d4' COLLATE NOCASEโฆand return { available: false } for the string __pending_a1b2c3d4 โ which is meaningless to anyone registering. More critically, it would cause the backend to block any handle that literally matches a pending token. The filter excludes all temporary handles from the uniqueness check so only real, claimed handles are treated as taken.
| Layer | Where | What it blocks |
|---|---|---|
| Layer 1 | Frontend (landing modal) | UX โ shows inline error before email is sent |
| Layer 2 | Backend (/api/auth/magic-link) |
Hard gate โ no magic link sent for taken handle |
| Layer 3a | Frontend (verify.html pre-claim) |
Race window โ re-checks just before claim |
| Layer 3b | Database (COLLATE NOCASE constraint) |
Case-variant squatting โ enforced at DB level |
The principle: Security checks must be enforced at the server, not trusted to the client. Frontend validation is UX. Backend validation is security. Both are needed. When the same invariant (handle is unique) must hold throughout a multi-step flow, check it at every step โ not just the last one.
All routes are on https://hollr-api.fly.dev. Authenticated routes require Authorization: Bearer <session_token>.
| Method | Path | Auth | Body | Returns |
|---|---|---|---|---|
POST |
/api/auth/magic-link |
โ | { email, handle? } |
{ ok } |
GET |
/api/auth/verify/:token |
โ | โ | { ok, session_token, is_new_user, pending_handle?, user? } |
POST |
/api/auth/forgot-pin |
โ | { email } |
{ ok } (always) |
GET |
/api/auth/x |
โ | โ | Redirect to X OAuth |
GET |
/api/auth/x/callback |
โ | โ | Redirect to frontend |
POST |
/api/auth/logout |
โ | โ | { ok } |
| Method | Path | Auth | Body | Returns |
|---|---|---|---|---|
GET |
/api/me |
โ | โ | { display_name, email, handle, has_api_key, pin_is_default, has_email, from_email, has_pgp, x_username } |
POST |
/api/handle/check |
โ | { handle } |
{ available, reason? } |
POST |
/api/handle/claim |
โ | { handle, pin, resend_key?, from_email?, pgp_public_key?, email?, display_name? } |
{ ok, handle, pin_is_default } |
| Method | Path | Auth | Body | Returns |
|---|---|---|---|---|
GET |
/api/settings |
โ | โ | { display_name, email, from_email, has_resend_key, pgp_public_key, pin_is_default } |
POST |
/api/settings |
โ | { pin, resend_key?, from_email?, pgp_public_key?, notification_email?, display_name? } |
{ ok } or { error: 'must_change_pin' } |
POST |
/api/settings/change-pin |
โ | { current_pin, new_pin } |
{ ok } |
POST |
/api/settings/email |
โ | { pin, email } |
{ ok } |
| Method | Path | Auth | Body | Returns |
|---|---|---|---|---|
GET |
/api/profile/:handle |
โ | โ | { handle, display_name, pgp_public_key, active } |
POST |
/api/send/:handle |
โ | { contact, message, is_pgp?, file_attachments?, audio_url?, audio_key?, audio_iv? } |
{ ok } |
POST |
/api/upload/:handle |
โ | multipart file |
{ url, file_key, file_iv, name } |
GET |
/api/decrypt/:handle/:filename |
โ | โ | Raw encrypted bytes |
GET |
/health |
โ | โ | { ok, version } |
| Variable | Required | Description |
|---|---|---|
ENCRYPTION_SECRET |
โ | 64-char hex. Master secret for AES key derivation. openssl rand -hex 32. Never change without re-encrypting all stored values. |
SESSION_SECRET |
โ | Any random string. Signs express-session cookies. openssl rand -hex 32. |
PLATFORM_RESEND_KEY |
โ | Resend API key for magic links + platform message notifications. |
PLATFORM_FROM_EMAIL |
โ | Platform sender address. Must be on a verified Resend domain. E.g. hollr <[email protected]>. |
FRONTEND_URL |
โ | Frontend origin. E.g. https://hollr.to. Used in magic-link URLs and X OAuth redirect. |
BASE_URL |
โ | Backend origin. E.g. https://hollr-api.fly.dev. Used in X OAuth callback URL. |
ALLOWED_ORIGINS |
โ | Comma-separated CORS origins. E.g. https://hollr.to,https://www.hollr.to. |
X_CLIENT_ID |
โ | X OAuth 2.0 client ID. Without this, X login returns 503 gracefully. |
X_CLIENT_SECRET |
โ | X OAuth 2.0 client secret. |
PORT |
โ | HTTP port. Default 3000. |
NODE_ENV |
โ | production enables secure cookies and disables dev CORS origins. |
DATA_DIR |
โ | Path for SQLite DB + uploads. Default ./data. Use /data on Fly.io. |
This is a permanent rule โ applied to every commit that changes behaviour.
hollr uses Semantic Versioning: MAJOR.MINOR.PATCH
| When | |
|---|---|
| PATCH (x.x.1) | Bug fixes, copy corrections, dependency updates |
| MINOR (x.1.0) | New features, non-breaking API additions |
| MAJOR (2.0.0) | Breaking changes, schema incompatibilities, full rewrites |
On every version bump:
- Update
"version"inbackend/package.json - Update version string in
server.js(/healthendpoint + startup log) - Update version headers in
db.js,crypto.js,mailer.js - Add
[x.y.z] โ YYYY-MM-DDentry toCHANGELOG.md - Update the version badge in
README.md - Commit:
feat/fix: description (vX.Y.Z) - Create GitHub release tag
vX.Y.Z
| Feature | Status | ETA |
|---|---|---|
| REST API | Building | Q3 2026 |
| Webhooks | Planned | Q3 2026 |
| MCP Server | Planned | Q4 2026 |
| Verification Apps โจ | New idea | 2027 |
| Self-hosted Storage | Planned | Q3 2026 |
REST API โ Full HTTP API to read hollrs, manage your handle, integrate with external tools. GET /v1/hollrs, GET /v1/hollrs/:id, handle management endpoints. OAuth tokens for third-party access.
Webhooks โ POST each incoming hollr to a URL of your choosing, the moment it arrives. No polling. Works with Zapier, Make, n8n, your own server, Notion API, anything.
MCP Server โ Model Context Protocol integration so Claude, Cursor, and other AI tools can read your hollrs as a data source. Your inbox becomes a tool your AI can query.
Verification Apps โจ โ Before a sender can reach you, they complete a micro-task you define: donate to a charity, follow your account, download your app, solve a puzzle, pay a small fee. You set the gate, hollr handles the verification. Designed to make every incoming message meaningful โ real signal, zero spam.
Self-hosted Storage โ Right now, encrypted data lives on hollr servers. Soon you'll be able to point hollr at your own storage โ FTP, S3, or a custom endpoint. Every byte on infrastructure you control. Zero trust required.
A complete record of everything that went wrong and why. Read this before you build something similar.
Symptom: Every API call from the frontend returned "Network error". Magic links failed to send. The health check still passed when called directly on hollr-api.fly.dev.
Root cause: The CNAME api.hollr.to โ howlr-api.fly.dev was configured during initial setup, when the app was named howlr-api (original typo). After renaming the app to hollr-api, the old howlr-api.fly.dev hostname ceased to exist. DNS continued resolving api.hollr.to to a dead address.
Fix: Updated all frontend API references to hollr-api.fly.dev directly. The correct CNAME target (obtained from flyctl certs setup) includes a unique prefix: xxxxxxx.hollr-api.fly.dev.
Lesson: When you rename a Fly.io app, the old *.fly.dev hostname disappears immediately. Update DNS before or simultaneously. Never assume a CNAME is stable across app renames.
Symptom: The workspace directory was named howlr/ throughout the entire development session โ a typo from the very first commit that was never caught.
Root cause: The original project was named "howlr" (mishearing of "hollr"). The code and GitHub were correctly renamed to "hollr" early in development, but the local filesystem path howlr/ was never corrected. It persisted in tool call logs, error messages, and mental model for dozens of sessions.
Fix: mv /workspace/howlr /workspace/hollr. Grep-verified zero remaining occurrences in code files.
Lesson: Typos in directory names compound. Fix them at the OS level immediately when discovered, not later.
Symptom: POST /api/auth/magic-link returned a 502 immediately after v4.5.1 deployed. Backend logs showed ValidationError: The 'X-Forwarded-For' header is set but the Express 'trust proxy' setting is false.
Root cause: Fly.io's load balancer adds X-Forwarded-For headers. express-rate-limit reads this header to identify clients. With trust proxy = false (Express default), the library detects an untrusted X-Forwarded-For and throws a ValidationError that crashed the request handler entirely.
Fix: app.set('trust proxy', 1) before any middleware. One line.
Lesson: Any Express app behind a reverse proxy (Fly.io, Nginx, Cloudflare) must set trust proxy. The default is wrong for production deployments. Add it before your first deploy, not as a hotfix.
Symptom: Backend failed to start with SqliteError: table magic_links has no column named pending_handle. This happened immediately after deploying v4.5.1.
Root cause: The CREATE INDEX IF NOT EXISTS idx_users_x_id ON users(x_id) statement was inside the initial db.exec() block, which ran before the runtime migration loop added the x_id column. CREATE INDEX IF NOT EXISTS only skips duplicate index names โ it still fails if the column doesn't exist.
Fix: Moved the index creation to after the migration loop, in its own try/catch. Additionally, for pending_handle, also ran ALTER TABLE magic_links ADD COLUMN pending_handle TEXT directly via flyctl ssh console so the running DB was fixed without waiting for a deploy.
Lesson: Any index on a migration-added column must be created after the migration runs. The IF NOT EXISTS clause does not protect against missing columns.
Symptom: Users entered their handle in the landing modal, received a magic link, clicked it in their email client โ and found the onboarding form with an empty handle field.
Root cause: The handle was stored in sessionStorage. sessionStorage is scoped to a single browser tab. Clicking an email link opens a new tab with completely empty sessionStorage. The handle was gone before the user ever saw the onboarding form.
Fix: The handle is now sent to POST /api/auth/magic-link as handle in the request body. The backend stores it as magic_links.pending_handle. The verify endpoint returns it in the response. The browser reads it from the API โ no browser storage involved.
Lesson: sessionStorage is tab-scoped. Do not use it to pass data that must survive an external link click (email, shared link, notification). Use the server.
Symptom: Message forwarding failed silently. Resend returned 422 Unprocessable Entity with no useful error message surfaced to the user.
Root cause: When a sender's contact field was a name rather than an email address, the Resend payload included reply_to: null. Resend's API treats null and omitted differently. null is an invalid value; omitting the key entirely is fine.
Fix: Only add reply_to to the payload when the contact field passes a basic email regex. Never set a key to null when omitting is the correct behaviour.
Lesson: In REST APIs, null and absent are not equivalent. Read the API docs carefully for nullable vs omittable fields. When in doubt, omit.
Symptom: The in-browser decrypt viewer failed silently. SubtleCrypto.decrypt returned a DOMException: The operation failed for an operation-specific reason.
Root cause: Node's crypto API produces the GCM auth tag separately via cipher.getAuthTag(). SubtleCrypto's decrypt expects the tag appended to the ciphertext as a single buffer: [ciphertext][authTag]. We were storing [authTag][ciphertext] on disk, and the browser was incorrectly slicing the data.
Fix: Defined a clear wire format: [16-byte authTag][ciphertext] on disk. The decrypt viewer slices bytes.slice(0, 16) (tag) and bytes.slice(16) (cipher), then re-concatenates as [ciphertext][tag] before passing to SubtleCrypto.
Lesson: When two cryptographic systems interoperate, document the wire format explicitly with byte offsets. Mismatches between "tag appended" and "tag prepended" produce silent failures in both directions.
Symptom: A GitGuardian alert arrived by email minutes after a push. A Resend API key appeared in a commit's diff.
Root cause: The key was hardcoded in a configuration file during initial development and committed before the env-var pattern was established.
Fix:
git filter-repo --path backend/.env --invert-pathsโ rewrites all commits to remove the file.- Force-push to GitHub (
git push --force). - Immediately rotate the key in the Resend dashboard (leaked keys must be considered compromised even if the window was short).
- Moved all secrets to Fly.io secrets (
flyctl secrets set ...).
Lesson: Secrets in git history are compromised even after deletion โ the history is permanent unless rewritten. Use git filter-repo (not git filter-branch). Rotate immediately. Set up pre-commit hooks or a tool like gitleaks to catch secrets before they're committed.
Symptom: After completing the full A-to-Z registration flow, the user was able to type an existing handle in the landing modal, receive a magic link, land on onboarding with that handle pre-filled, and claim it successfully. The system never checked whether the requested handle already belonged to someone.
Root cause: Three independent failures:
- The landing modal sent the magic link immediately without checking availability
POST /api/auth/magic-linkstored thepending_handlewithout checking if it was taken- The race window between the step-1 availability check and the final claim was unprotected
Fix: Three-layer defence described in Handle ownership & registration security.
Lesson: Security checks must be enforced at the server, not trusted to the client. Frontend validation is UX. Backend validation is security. Both are needed. When the same invariant (handle is unique) must hold throughout a multi-step flow, check it at every step โ not just the last one.
Symptom: hollr.to/paulfxyz showed the intro modal but clicking "Start writing" did nothing. The timer didn't start. The overlay never dismissed. The page appeared to load correctly.
Root cause: A prior edit left a broken JavaScript string in the English i18n object:
// BROKEN โ two string literals merged without closing quote or separator
page_sub: 'Hit Start when ready.'re ready to write.',
// โ this apostrophe ended the string, but 're ready...' is a syntax errorThis SyntaxError: Unexpected identifier 're' caused the entire IIFE to fail during parse. Every event listener in the file โ including the one that dismisses the welcome overlay โ was never attached. The page looked fine because the HTML rendered correctly; only the JavaScript was dead.
Fix: 'Hit Start when you\'re ready to write.' โ escape the apostrophe.
Detection: The error was invisible in the browser console until directly inspected because the IIFE was wrapped in a try/catch in some paths. Node.js new Function(src) caught it immediately.
Lesson: Test JavaScript syntax separately from browser rendering. An IIFE can fail silently if the surrounding code suppresses errors. The new Function() constructor is a fast way to check syntax without running the code. For production: a pre-deploy syntax check (node -e 'new Function(src)') would have caught this instantly.
Symptom: Every handle page showed "Message toโฆ" instead of the owner's display name. The welcome overlay, page title, and send button all showed the raw placeholder.
Root cause: loadHollrProfile() contained a loop iterating over STRINGS:
for (const lang in STRINGS) { ... } // STRINGS does not exist โ the object is named TThis threw ReferenceError: STRINGS is not defined inside the try/catch block in loadHollrProfile. The catch silently swallowed it. Display name injection never happened.
Fix: Removed the dead loop entirely. Display name is injected by direct DOM updates (which already existed correctly below the broken loop).
Lesson: Silent try/catch blocks hide bugs. Log errors even if you handle them. A console.error('hollr: profile load failed', err) would have surfaced this immediately. The entire display-name system appeared to work during unit testing but failed in production because the test environment didn't have a real profile response.
Symptom: Clicking the flag button in the welcome modal threw a ReferenceError and the language picker overlay was never populated. Language selection in the welcome modal was completely non-functional.
Root cause: The function buildLangPickerGrid() was called in 4 places (welcome flag button click, buildLangList, buildLangPickerGrid call after language switch, init). It was never defined. A dead reference.
Fix: Implemented buildLangPickerGrid() alongside buildLangList(), using LANGS (the metadata object) and T (the translation object) to populate a 2-column grid of language buttons.
Lesson: Dead function references are silent in JavaScript until the call path is executed. A lint step (eslint --no-eslintrc --rule 'no-undef: error') would catch undefined references before production. Alternatively: integration-test every interactive element, not just the happy path.
Symptom: After a profile loaded and injected "Message to Paul Fleury", the name reverted to "Message toโฆ" the moment the timer started, paused, or any other state change triggered a re-render.
Root cause: applyTranslations() re-rendered ALL [data-i18n] elements from the T translation object on every call. T.*.page_title contains 'Message toโฆ' for every language. So every state change wiped the dynamically injected name.
Fix:
// Skip page_title and welcome_eyebrow if display name is already set
if ((key === 'page_title' || key === 'welcome_eyebrow') && window._hollrDisplayName) return;The display name is stored in window._hollrDisplayName by loadHollrProfile. applyTranslations checks this flag before overwriting.
Lesson: When a templating system and a dynamic data loader both write to the same DOM elements, they will conflict. Establish clear ownership: either the template always wins (and you re-run the full inject after every language change), or the dynamic data wins (and you guard template writes). Don't have both write to the same nodes without a flag.
Symptom: Clicking "Forgot PIN?" showed the toast "No email on file โ add one in settings first." even though the user's email was correctly saved in the database.
Root cause: The forgot-PIN handler called GET /api/me to retrieve the email. This endpoint requires a valid session token (Authorization: Bearer <token>). When the user opened the page without being logged in, or in a new tab where sessionStorage was empty, getSessionToken() returned null. The code then sent Authorization: Bearer null โ which the backend correctly rejected with { error: "Invalid or expired session" }. meData.email was therefore undefined, not because the email was absent, but because the API call failed.
Fix: Replaced the api/me lookup with an inline email input field directly in the PIN gate. No session required. The user types their email, the backend sends the reset link (or silently does nothing for unknown addresses to prevent enumeration).
Lesson: Operations that should be available to unauthenticated users (password/PIN reset being the canonical example) must not depend on an authenticated API call. Always ask: "Can a logged-out user do this?" If yes, the implementation cannot require a session.
๐ก CSS display:flex overrides HTML hidden attribute
Symptom: The email registration form in the landing modal was always expanded โ visible before the user clicked the "Register with email instead" button.
Root cause:
/* WRONG โ this overrides the HTML hidden attribute */
.modal__email-form {
display: flex; /* this wins over [hidden] { display: none } */
}The HTML had <form class="modal__email-form" hidden> which sets display: none via the browser's default stylesheet. But an explicit display: flex in the author stylesheet has higher specificity and overrides it.
Fix:
.modal__email-form {
display: none; /* collapsed by default */
}
.modal__email-form:not([hidden]) {
display: flex; /* shown only when JS removes the hidden attribute */
}Lesson: The hidden attribute sets display: none via the browser's user-agent stylesheet, which has the lowest possible specificity. Any display value in your CSS overrides it. Either don't use hidden if you set display in CSS, or use [hidden] { display: none !important } to force the hidden state. The :not([hidden]) pattern is the cleanest solution.
Symptom: Users arrived on the onboarding wizard with their handle correctly pre-filled, but "Continue" was blocked by "Please wait for availability check to complete" โ even after waiting. The check never ran.
Root cause: The code pre-filled handleInput.value = pre and immediately called handleInput.dispatchEvent(new Event('input')) to trigger the availability check. But the addEventListener('input', ...) that runs the check was registered 3 lines later. The event fired before the listener existed โ it was silently discarded.
Fix: Register the event listener first, then pre-fill and dispatch:
// 1. Attach listener FIRST
hi.addEventListener('input', () => { /* availability check */ });
// 2. THEN pre-fill and trigger โ with a small delay for safety
if (pre) {
hi.value = pre;
setTimeout(() => hi.dispatchEvent(new Event('input')), 50);
}Lesson: dispatchEvent is synchronous โ it fires immediately, before the next line. If your listener hasn't been attached yet, the event is gone. Always register listeners before triggering events, especially when initializing pre-filled form state.
Symptom: Queries returned undefined or the prepared statement object itself instead of rows.
Root cause: better-sqlite3 is synchronous. There is no async interface. Code patterns like await db.prepare(...) silently return the statement. const [row] = db.prepare(...).where(...) tries to destructure a query builder object.
Fix: All queries terminated with .get() (one row), .all() (array), or .run() (for writes). No await anywhere near SQLite calls.
Lesson: Read the driver documentation before writing queries. better-sqlite3 is intentionally synchronous โ this is a feature. Treating it like an async ORM produces subtle, silent bugs.
Symptom: Clicking the "PGP" tab in the settings modal appeared to activate the button visually but showed no content below.
Root cause: The tab-switching JavaScript toggled tab-resend and tab-pin but never tab-pgp. Three mutually exclusive panels, two of which were handled.
Fix: Replaced three individual display assignments with a single switchTab(name) function that iterates all tab panels and shows only the active one:
function switchTab(name) {
['tab-resend', 'tab-pgp', 'tab-pin'].forEach(id => {
document.getElementById(id).style.display = id === `tab-${name}` ? '' : 'none';
});
}Lesson: When you have N mutually exclusive panels, use a function that resets all N and activates one. Never Nโ1 individual assignments โ the missing one will always be the one that matters.
Symptom: X OAuth callback failed with state_mismatch in production.
Root cause: The initial implementation stored code_verifier and state in sessionStorage on the frontend. But the callback URL (/api/auth/x/callback) is on the backend โ sessionStorage is inaccessible there.
Fix: Store both values in express-session (server-side):
req.session.xState = state;
req.session.xCodeVerifier = codeVerifier;The session cookie bridges the gap between the initial request and the callback.
Lesson: PKCE state must live server-side for server-side OAuth callbacks. The callback URL receives the authorization code at the backend, not the browser. Any browser storage is inaccessible.
Symptom: Running flyctl deploy returned command not found.
Root cause: The Fly.io CLI installer adds ~/.fly/bin to PATH by modifying the shell RC file, but the current shell session doesn't reload it.
Fix: Use the full path: /home/user/.fly/bin/flyctl. Or source the RC file: source ~/.bashrc.
Lesson: CLI tools that modify PATH require a new shell session to take effect. Always verify installation with the full path before debugging further.
Decision: Session tokens stored in sessionStorage.
Reasoning: sessionStorage is tab-scoped. Each browser tab gets its own auth context. This is correct for an embeddable canvas โ if a page embeds the canvas in an <iframe>, the iframe's sessionStorage is isolated from the parent page's localStorage. Tokens don't leak across tabs.
Trade-off: The user is logged out when they close the tab. For a messaging canvas (not a persistent app), this is acceptable.
Decision: Use multer.memoryStorage() instead of multer.diskStorage().
Reasoning: diskStorage would write the file to disk in plaintext before our code gets a chance to encrypt it. memoryStorage gives us the raw Buffer in memory, which we encrypt (AES-256-GCM) before writing the .enc file.
Trade-off: Large files (up to 50MB) are held in memory during upload. For the expected usage pattern, this is acceptable.
Decision: Encrypt key and IV go in the URL hash (#key=...&iv=...), not as query parameters.
Reasoning: RFC 3986 ยง3.5 defines the hash fragment as client-side only. Browsers do not include it in HTTP requests. The key is therefore never sent to hollr's server (or any server, including CDN logs) when the decrypt viewer fetches the encrypted blob.
Implication: The key exists only in the notification email and the browser's address bar. If you lose the email, you lose the key.
See INSTALL.md for the complete guide. Quick version:
git clone https://github.com/paulfxyz/hollr.git
cd hollr/backend
cp .env.example .env # fill in secrets
npm install
node server.js # http://localhost:3000Deploy to Fly.io:
flyctl apps create your-app-name
flyctl volumes create hollr_data --region cdg --size 3
flyctl secrets set \
ENCRYPTION_SECRET=$(openssl rand -hex 32) \
SESSION_SECRET=$(openssl rand -hex 32) \
PLATFORM_RESEND_KEY=re_xxxx \
PLATFORM_FROM_EMAIL="hollr <[email protected]>" \
FRONTEND_URL=https://yourdomain.com \
BASE_URL=https://your-app-name.fly.dev \
ALLOWED_ORIGINS=https://yourdomain.com
flyctl deploy --remote-onlyPRs welcome. Please open an issue first for significant changes. Follow the versioning guideline on every commit that changes behaviour.
git checkout -b feat/your-feature
# make changes
git commit -m "feat: describe your change (vX.Y.Z)"
git push origin feat/your-feature
# open a PRMIT โ see LICENSE. Fork it, self-host it, extend it, sell it. No restrictions.
Settings were locked by a PIN, but the PIN verify call required a login session (Authorization: Bearer <session_token>). Session tokens live in sessionStorage (tab-scoped) or localStorage. This created a hard failure:
- Open
hollr.to/yourhandlein incognito โ no session in storage โ settings won't open - Open in a different browser โ same problem
- PIN was correct โ the DB had the right hash โ but the API rejected it before even checking
Handle owner enters PIN
โ
โผ
POST /api/settings/verify { handle, pin } โ NO session required
โ
โโ PIN correct โ returns { token, expires_at, pin_is_default }
โ (2-hour settings token)
โ
โผ
All subsequent settings calls use that token as Bearer
POST /api/settings Authorization: Bearer <settings_token>
POST /api/settings/change-pin Authorization: Bearer <settings_token>
โ
โโ Works from any device, any browser, incognito, without login
| Property | Value |
|---|---|
| Token lifetime | 2 hours |
| Storage | sessionStorage only (tab-scoped, intentional) |
| Attack surface | Rate-limited: 10 attempts per IP per 15 minutes |
| Information leakage | Handle not found and wrong PIN return identical 403 |
| Scope | Settings only โ not messages, sessions, or auth flows |
| Backwards compat | Old clients sending pin in request body still work |
function requireAuth(req, res, next) {
const token = req.headers.authorization?.slice(7);
// Path 1: login session (30-day TTL)
const sess = db.prepare(
'SELECT ... FROM sessions JOIN users WHERE token = ? AND expires_at > unixepoch()'
).get(token);
if (sess) { req.user = sess; return next(); }
// Path 2: settings token (2-hour TTL, PIN-verified, no login needed)
const stok = db.prepare(
'SELECT ... FROM settings_tokens JOIN users WHERE token = ? AND expires_at > unixepoch()'
).get(token);
if (stok) { req.user = stok; return next(); }
return res.status(401).json({ error: 'Invalid or expired session' });
}// PIN gate submit
const resp = await fetch('/api/settings/verify', {
method: 'POST',
body: JSON.stringify({ handle: HANDLE, pin }), // no Authorization header
});
const { token, pin_is_default } = await resp.json();
// Store for tab session โ re-entry required per browser session (by design)
settingsToken = token;
sessionStorage.setItem('hollr_settings_token', token);
// All settings calls now use this token
fetch('/api/settings', {
headers: { Authorization: 'Bearer ' + settingsToken },
body: JSON.stringify({ display_name: 'Paul' }), // no pin in body
});hollr ships with full support for 10 languages, selected by the visitor (not the handle owner). Every string on the canvas โ including the owner's display name โ is rendered in the visitor's language.
All translations live in a single T object inside the IIFE in handle/index.html:
const T = {
en: { page_title: 'Message toโฆ', welcome_eyebrow: 'Message toโฆ', send_to_paul: 'Message toโฆ', โฆ },
fr: { page_title: 'Message pour โฆ', welcome_eyebrow: 'Message pour โฆ', send_to_paul: 'Message pourโฆ', โฆ },
de: { page_title: 'Nachricht an โฆ', โฆ },
// โฆ 7 more languages
};The โฆ character (U+2026 ELLIPSIS) is a runtime placeholder. When
loadHollrProfile() fetches the owner's public profile, it replaces โฆ
with the owner's display_name using a regex-aware replace:
// \s?โฆ matches an optional space before the ellipsis โ handles languages
// where the name comes first (Japanese: 'โฆใธใฎใกใใปใผใธ' โ 'Paul Fleury ใธใฎใกใใปใผใธ')
// vs languages where the name comes last (French: 'Message pour โฆ' โ 'Message pour Paul Fleury')
pageTitleEl.innerHTML = ptTemplate.replace(/\s?โฆ/g, ` <strong>${escHtml(displayName)}</strong>`);- On first visit: defaults to
en - Visitor clicks the flag button โ language picker overlay appears
- Selection stored in
localStorage('hollr_lang')โ persists across visits applyTranslations()re-renders all[data-i18n]elements fromT[currentLang]- After language switch, the owner's name is re-injected in the new language's template
localStorage throws Access is denied in some browser contexts (privacy
modes, certain CDN/bot-screening layers). Without protection, this crashes the
entire IIFE and nothing works โ no translations, no profile load, no PIN.
// WRONG โ throws in restricted contexts, kills everything
let currentLang = localStorage.getItem('hollr_lang') || 'en';
// CORRECT โ safe fallback
let currentLang;
try { currentLang = localStorage.getItem('hollr_lang') || 'en'; } catch { currentLang = 'en'; }| Code | Language | Native name | "Message to" translation |
|---|---|---|---|
en |
English | English | Message toโฆ |
fr |
French | Franรงais | Message pour โฆ |
de |
German | Deutsch | Nachricht an โฆ |
it |
Italian | Italiano | Messaggio per โฆ |
es |
Spanish | Espaรฑol | Mensaje para โฆ |
nl |
Dutch | Nederlands | Bericht aan โฆ |
zh |
Chinese | ไธญๆ | ่ดโฆ็ๆถๆฏ |
hi |
Hindi | เคนเคฟเคจเฅเคฆเฅ | โฆ เคเฅ เคฒเคฟเค เคธเคเคฆเฅเคถ |
ja |
Japanese | ๆฅๆฌ่ช | โฆใธใฎใกใใปใผใธ |
ru |
Russian | ะ ัััะบะธะน | ะกะพะพะฑัะตะฝะธะต ะดะปัโฆ |
- Add a new key to
Tinhandle/index.htmlwith all required strings - Add the language to
LANGS(flag, name, native name) - Translate the welcome-modal onboarding strings in the
T[lang]block oflanding/index.html - Done โ the picker grid and language-switch logic are automatic
All endpoints are on https://hollr-api.fly.dev. No API key required for public routes.
| Method | Endpoint | Auth | Description |
|---|---|---|---|
POST |
/api/auth/magic-link |
โ | Send a login link to an email address |
GET |
/api/auth/verify/:token |
โ | Verify magic link, get session token |
GET |
/api/auth/x |
โ | Start X (Twitter) OAuth flow |
GET |
/api/auth/x/callback |
โ | X OAuth callback |
POST |
/api/auth/forgot-pin |
โ | Send PIN reset link to email on file |
POST |
/api/auth/logout |
Session | Destroy current session |
| Method | Endpoint | Auth | Description |
|---|---|---|---|
POST |
/api/settings/verify |
โ | Verify PIN โ get 2h settings token |
GET |
/api/settings |
Session or Settings | Read current settings |
POST |
/api/settings |
Session or Settings | Update Resend key, PGP, email, name |
POST |
/api/settings/change-pin |
Session or Settings | Change PIN |
POST |
/api/settings/email |
Session or Settings | Add/update notification email |
| Method | Endpoint | Auth | Description |
|---|---|---|---|
POST |
/api/handle/check |
โ | Check if a handle is available |
POST |
/api/handle/claim |
Session | Claim a handle during onboarding |
| Method | Endpoint | Auth | Description |
|---|---|---|---|
GET |
/api/profile/:handle |
โ | Get public profile (name, PGP key) |
POST |
/api/send/:handle |
โ | Send a hollr to a handle |
POST |
/api/upload/:handle |
โ | Upload an encrypted file attachment |
โโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Token type โ Properties โ
โโโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ Session token โ Issued on login. 30-day TTL. โ
โ โ Stored in localStorage + sessionStorage. โ
โ โ Grants full account access. โ
โโโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ Settings token โ Issued by /api/settings/verify (PIN only). โ
โ โ 2-hour TTL. Stored in sessionStorage only. โ
โ โ Works from incognito, any device, no login. โ
โ โ Grants settings access only. โ
โโโโโโโโโโโโโโโโโโโโโโโดโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
| Endpoint | Limit |
|---|---|
| Auth endpoints | 10 req / IP / 15 min |
POST /api/settings/verify |
10 req / IP / 15 min |
POST /api/send/:handle |
5 req / IP / min |
POST /api/upload/:handle |
5 req / IP / min |
This project is 100% vibe-coded.
I'm Paul Fleury โ a hacker turned entrepreneur. I don't have a computer science degree. I don't claim to be a software engineer. What I do claim is a few decades of curiosity, a hacker's instinct for breaking and building things, and an entrepreneur's obsession with shipping.
hollr was built entirely by piloting AI โ specifically Perplexity Computer โ over the course of a few days. I described what I wanted, reviewed what came back, caught mistakes, redirected, iterated. The architecture decisions, the security choices, the debugging of production crashes, the CHANGELOG entries โ all of it emerged from a conversation between a non-engineer with good instincts and an AI with broad technical knowledge.
The result is a full-stack SaaS platform with:
- X OAuth 2.0 PKCE
- AES-256-GCM file encryption with in-browser decryption
- PGP end-to-end encryption via OpenPGP.js
- SQLite with WAL mode on a persistent Fly.io volume
- Magic-link authentication that survives new-tab opens
- A 3-step onboarding wizard
- 10 languages
- Three-layer handle registration security
Is the code perfect? No. Are there things a senior engineer would do differently? Absolutely. Is it running in production, handling real requests, and doing what it's supposed to do? Yes.
That's the point.
v5.x was built in a single extended session โ roughly the equivalent of a long sprint day. The session started with a broken canvas (a misplaced apostrophe in an i18n string that killed the entire page script), passed through a full security audit that found a handle-squatting vulnerability affecting every existing user, and ended with a production-hardened system with three independent layers of registration security, correct SEO meta tags, and a forgot-PIN flow that works without a session.
The debugging process looked like this: screenshot the bug, read the DOM output, read the JavaScript, form a hypothesis, write a targeted fix, verify syntax, deploy. No guesswork. No shotgun changes. Each fix addressed a specific root cause identified through evidence. That's not vibe coding in the dismissive sense โ that's engineering, piloted by someone who understands the domain well enough to direct it.
What's remarkable isn't that an AI can write code. It's that the combination of a human who understands what "correct" looks like and an AI that can reason about code at scale can converge on a genuinely well-constructed system faster than either could alone.
This README โ with its detailed explanations of PKCE, authTag wire formats, and SQLite migration strategies โ exists not because I wrote it from memory, but because I asked good questions and understood the answers well enough to direct what came next. The distinction between "knowing how to build something" and "knowing enough to guide something being built" is collapsing fast.
hollr is less a claim about my engineering skills and more a demonstration of where vibe coding stands in early 2026: capable of producing systems with real security properties, real architectural coherence, and real documentation โ when piloted by someone who knows what they want and can tell good from bad.
Take the code, learn from it, improve it. That's what MIT is for.
โ Paul
"The best way to predict the future is to build it." โ Alan Kay "The best way to build it is to holler at your AI until it works." โ Paul Fleury, probably