Skip to content

paulfxyz/hollr

Folders and files

NameName
Last commit message
Last commit date

Latest commit

ย 

History

56 Commits
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 

Repository files navigation

๐Ÿ“ข hollr

Version License: MIT Node SQLite Deployed on Fly.io Open Source Vibe Coded

Live: hollr.to ยท Example canvas: hollr.to/paulfxyz ยท API health: hollr-api.fly.dev/health


What is hollr?

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.


Table of Contents

  1. The product
  2. Feature list
  3. Architecture
  4. Onboarding flow
  5. Deep-dive: Authentication
  6. Deep-dive: Encryption
  7. Deep-dive: Database design
  8. Deep-dive: The HTTP API
  9. Deep-dive: Frontend architecture
  10. Deep-dive: Infrastructure
  11. Deep-dive: Security decisions
  12. Deep-dive: Handle ownership & registration security
  13. API reference
  14. Environment variables
  15. Versioning guideline
  16. Roadmap
  17. Issues, bottlenecks & lessons learned
  18. Self-hosting
  19. Contributing
  20. License
  21. A note on vibe coding

The product

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):

  1. Sign in with X (Twitter) OAuth or a magic-link email โ€” no password ever.
  2. Pick hollr.to/yourname โ€” permanent, first-come first-served.
  3. Add a notification email so messages reach your inbox.
  4. 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.
  5. Share hollr.to/yourname everywhere โ€” bio, email footer, business card.

As a sender:

  1. Visit hollr.to/someone.
  2. Click Start โ€” the timer begins. It only runs while you type.
  3. Write. Attach a file or record a voice note if you want.
  4. Hit Send. Drop your name and how they can reach you back.
  5. 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 list

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.

Architecture

โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•—        โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•—
โ•‘        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         โ”‚
                                               โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜           โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Request flow (complete trace)

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 } โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”‚

Auth state machine

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)

Encryption layers

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

Database schema

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 0

Onboarding flow

The 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):

  1. Landing modal โ†’ enter desired handle + email โ†’ modal calls POST /api/handle/check first. If taken, shows inline error โ€” no email sent.
  2. If available: POST /api/auth/magic-link stores {email, token, pending_handle} in magic_links table (after re-checking availability at the backend).
  3. Magic link arrives in inbox โ€” user clicks it in their email client (new tab).
  4. GET /api/auth/verify/:token returns { ok, session_token, pending_handle }.
  5. pending_handle pre-fills step 1 of the wizard โ€” this survives the new-tab context switch because it came from the API response, not sessionStorage.
  6. Step 1: confirm/change the handle. verify.html re-calls /api/handle/check before allowing proceed. Live availability check fires at 450ms debounce โ€” "Continue" button is blocked until confirmed available.
  7. Step 2: choose a PIN (4โ€“8 digits, confirmed, rejects 1234). Dot-fill visualizer.
  8. Step 3: display name (optional) + notification email (optional). Live preview of "Message to [name]".
  9. Single POST /api/handle/claim โ€” all data in one request. Backend enforces COLLATE NOCASE uniqueness at the database level.
  10. Redirect to /:handle?setup=1 โ†’ canvas opens with Settings modal auto-opened.

Via X OAuth (new user):

  1. Landing modal โ†’ "Continue with X" โ†’ redirect to GET /api/auth/x.
  2. Backend generates code_verifier, code_challenge (SHA-256 โ†’ base64url), state. Stored in express-session.
  3. Redirect to https://twitter.com/i/oauth2/authorize?... with PKCE params.
  4. X redirects back to /api/auth/x/callback. Backend validates state from session, exchanges code + verifier for tokens.
  5. Fetch /2/users/me โ€” get X numeric ID and username.
  6. If no email on file: redirect to auth/verify?x_session=TOKEN&needs_email=1.
  7. stateNeedEmail collects an email. Skippable (notifications disabled until set).
  8. Same 3-step wizard, handle pre-filled with X username.

Deep-dive: Authentication

Magic links โ€” why and how

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.

X OAuth 2.0 PKCE โ€” full explanation

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.


Deep-dive: Encryption

AES-256-CBC โ€” text secrets

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.

AES-256-GCM โ€” file and voice encryption

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.

bcrypt โ€” PINs

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 โ€” OpenPGP.js client-side encryption

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 plaintext

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


Deep-dive: Database design

Why SQLite

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.bak is your backup strategy.
  • Faster than PostgreSQL for reads โ€” in-process, no serialization, no network.
  • WAL mode โ€” PRAGMA journal_mode = WAL enables 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 better-sqlite3 trap

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 }

Migration strategy

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.

Pending handles

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.

COLLATE NOCASE on handle

handle TEXT NOT NULL UNIQUE COLLATE NOCASE

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


Deep-dive: The HTTP API

Middleware stack (order matters)

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.

requireAuth middleware

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.

CORS

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.

Rate limiting

  • 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 of trust proxy: 1) because these endpoints are unauthenticated or pre-authentication.

Deep-dive: Frontend architecture

Why no framework

The frontend is plain HTML, CSS, and vanilla JavaScript. The reasons:

  1. Zero build tooling. index.html is a file. Changes deploy immediately. There's no npm run build, no artifact, no CI pipeline needed.
  2. Zero dependency churn. A React app from 2023 needs dependency updates every month. An HTML file from 2023 still works perfectly.
  3. Auditability. The entire codebase is readable in a browser's View Source. There's no minified bundle obscuring the logic.
  4. 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.

Single-file deploy strategy

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 URI

Result: one HTTP request to load the entire page (minus Google Fonts). The source files (landing-src/) are kept in the repo for editing.

i18n system

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.

sessionStorage vs localStorage

Session tokens go in sessionStorage, not localStorage:

  • sessionStorage is tab-scoped โ€” each tab has its own session. Cleared when the tab closes.
  • localStorage persists across tabs and browser restarts โ€” appropriate for preferences (theme, language), not auth tokens.
  • The canvas is designed to be embeddable in iframes. sessionStorage works correctly per-frame; localStorage is 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.

Apache mod_rewrite

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

Dark/light mode โ€” no flash

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

Web Crypto API โ€” in-browser decrypt viewer

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.


Deep-dive: Infrastructure

Fly.io

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-only flag. Builds the Docker image on Fly's remote builders. Avoids arm64/amd64 cross-compilation issues on Apple Silicon Macs.
  • Health check. Fly polls GET /health every 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.

SiteGround FTP deployment

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
EOF

mirror --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

Resend is used for two purposes with two different keys:

  1. 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]>. The hollr.to domain is verified in Resend.

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


Deep-dive: Security decisions

No passwords, ever

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

CSRF

  • Magic links are the auth mechanism โ€” no traditional form POST needs CSRF tokens.
  • X OAuth uses the state parameter for CSRF protection (standard OAuth 2.0).
  • Bearer tokens in Authorization headers are not sent by browsers automatically (unlike cookies). No CSRF risk for authenticated API endpoints.

Content security (XSS prevention)

All user-generated content embedded in HTML email templates is escaped:

const esc = str => String(str)
  .replace(/&/g, '&amp;')
  .replace(/</g, '&lt;')
  .replace(/>/g, '&gt;');

PGP-armoured ciphertext is rendered in <pre> with the same escaping. The canvas itself does not render user content as HTML.

Helmet.js

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.

Email enumeration prevention

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.


Deep-dive: Handle ownership & registration security

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.

Layer 1 โ€” Frontend gate (landing modal)

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.

Layer 2 โ€” Backend magic-link gate

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.

Layer 3 โ€” Claim-time race protection

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.

Why COLLATE NOCASE matters

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 NOCASE

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

The __pending_ exclusion

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.

Defence-in-depth summary

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.


API reference

All routes are on https://hollr-api.fly.dev. Authenticated routes require Authorization: Bearer <session_token>.

Auth

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 }

User

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 }

Settings

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 }

Canvas (public)

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 }

Environment variables

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.

Versioning guideline

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:

  1. Update "version" in backend/package.json
  2. Update version string in server.js (/health endpoint + startup log)
  3. Update version headers in db.js, crypto.js, mailer.js
  4. Add [x.y.z] โ€” YYYY-MM-DD entry to CHANGELOG.md
  5. Update the version badge in README.md
  6. Commit: feat/fix: description (vX.Y.Z)
  7. Create GitHub release tag vX.Y.Z

Roadmap

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.


Issues, bottlenecks & lessons learned

A complete record of everything that went wrong and why. Read this before you build something similar.


๐Ÿ”ด api.hollr.to CNAME pointed to a destroyed app

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.


๐Ÿ”ด howlr directory โ€” 18 versions of the wrong name

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.


๐Ÿ”ด trust proxy missing โ€” every request returned 502

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.


๐Ÿ”ด SQLite startup crash โ€” index before column

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.


๐Ÿ”ด pending_handle lost across browser tabs

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.


๐Ÿ”ด reply_to: null causes Resend 422

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.


๐Ÿ”ด AES-GCM authTag wire format mismatch

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.


๐Ÿ”ด Resend API key leaked to git

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:

  1. git filter-repo --path backend/.env --invert-paths โ€” rewrites all commits to remove the file.
  2. Force-push to GitHub (git push --force).
  3. Immediately rotate the key in the Resend dashboard (leaked keys must be considered compromised even if the window was short).
  4. 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.


๐Ÿ”ด Handle squatting โ€” anyone could claim any existing handle

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:

  1. The landing modal sent the magic link immediately without checking availability
  2. POST /api/auth/magic-link stored the pending_handle without checking if it was taken
  3. 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.


๐Ÿ”ด Canvas permanently frozen โ€” one broken string in i18n

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 error

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


๐Ÿ”ด STRINGS is not defined โ€” display name never rendered

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 T

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


๐Ÿ”ด buildLangPickerGrid is not defined โ€” language picker broken

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.


๐Ÿ”ด Display name reset on every timer state change

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.


๐Ÿ”ด Forgot PIN showed "No email on file" despite email existing

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.


๐ŸŸก Pre-fill fired before its own listener was attached (race in your own code)

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.


๐ŸŸก better-sqlite3 async confusion

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.


๐ŸŸก PGP tab invisible โ€” tab switcher bug

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.


๐ŸŸก X OAuth PKCE state in the browser

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.


๐ŸŸก flyctl not found on first deploy

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.


๐ŸŸข sessionStorage vs localStorage for auth tokens

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.


๐ŸŸข multer memoryStorage โ€” encrypt before write

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.


๐ŸŸข URL hash params for decrypt key delivery

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.


Self-hosting

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:3000

Deploy 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-only

Contributing

PRs 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 PR

License

MIT โ€” see LICENSE. Fork it, self-host it, extend it, sell it. No restrictions.




Deep-dive: PIN & Settings authentication

The problem (pre-v5.2.7)

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/yourhandle in 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

The fix (v5.2.7) โ€” Two-token architecture

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

Security properties

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

How requireAuth works

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' });
}

Frontend flow

// 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
});

Deep-dive: Internationalisation (i18n)

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.

How it works

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>`);

Language detection & persistence

  1. On first visit: defaults to en
  2. Visitor clicks the flag button โ†’ language picker overlay appears
  3. Selection stored in localStorage('hollr_lang') โ€” persists across visits
  4. applyTranslations() re-renders all [data-i18n] elements from T[currentLang]
  5. After language switch, the owner's name is re-injected in the new language's template

Critical: localStorage guard

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'; }

Supported languages

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 ะ ัƒััะบะธะน ะกะพะพะฑั‰ะตะฝะธะต ะดะปัโ€ฆ

Adding a new language

  1. Add a new key to T in handle/index.html with all required strings
  2. Add the language to LANGS (flag, name, native name)
  3. Translate the welcome-modal onboarding strings in the T[lang] block of landing/index.html
  4. Done โ€” the picker grid and language-switch logic are automatic


API Reference

All endpoints are on https://hollr-api.fly.dev. No API key required for public routes.

Auth

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

Settings

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

Handles

Method Endpoint Auth Description
POST /api/handle/check โ€” Check if a handle is available
POST /api/handle/claim Session Claim a handle during onboarding

Canvas (public)

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 types

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ 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.                     โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Rate limits

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

A note on vibe coding

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