Quiet money. Calm month.
A focused bill and debt dashboard for people who'd rather spend five calm minutes a week than a frantic afternoon every payday. Track recurring bills, credit cards (including 0% promo periods), monthly budget, payment history, and debt-payoff strategies — all behind a real account with server-side sync.
| Layer | What |
|---|---|
| Frontend pages | Svelte 5 (runes) for each dashboard tab, vanilla JS for navbar / modals / auth / theme |
| Build | Vite 8 multi-page, with the @sveltejs/vite-plugin-svelte plugin |
| Styling | Hand-written CSS split into themed files (tokens, components, theme-dark, pages, marketing, budget) + a small Tailwind v4 utility build |
| Server | Node 22 + Express 5, better-sqlite3 for storage |
| Auth | bcrypt password hashing, opaque server-side sessions in SQLite, HttpOnly cookies, CSRF double-submit token, Cloudflare Turnstile bot protection, in-memory login rate limiter |
| Per-user data sync | One JSON blob per user in SQLite, PUT /api/data with debounced client writes, localStorage as offline cache, custom-event reactivity bridge to Svelte |
Single deployable unit — Express serves the API and the static
client (raw client/ in dev, the Vite-built dist/ in production).
Requires Node ≥ 22 (for native fetch, --watch, and the
better-sqlite3 / bcrypt prebuilds).
git clone <repo> cleartab
cd cleartab
npm install
npm run dev
Then open http://localhost:5173. Vite serves the client with HMR
on :5173 and proxies /api/* to the Express server on :5222.
Sign in with the seeded dev account:
[email protected] |
|
| Password | demopassword11 |
The seed lives in .env.development and is
created automatically on first server start (only when
NODE_ENV !== 'production').
You can also hit Express directly at http://localhost:5222 if you don't need HMR — same content, same auth flow, no Vite layer.
cleartab/
├── client/
│ ├── *.html page entries (home, login, terms,
│ │ privacy, dashboard, settings, 404, 500)
│ ├── css/
│ │ ├── styles.css manifest — @imports the five below
│ │ ├── tokens.css design tokens + body bg
│ │ ├── components.css nav, buttons, badges, cards, modals…
│ │ ├── theme-dark.css dark-mode overrides
│ │ ├── pages.css page-frame, auth, legal, footer
│ │ ├── marketing.css home/landing styles
│ │ ├── budget.css Budget tab
│ │ └── tailwind-input.css (Tailwind source for utility classes)
│ ├── js/
│ │ ├── app.js dashboard entry — imports the lot
│ │ ├── settings.js /settings entry
│ │ ├── public-entry.js /, /login, /terms, /privacy entry
│ │ ├── auth.js /api/auth client + page gating
│ │ ├── storage.js shared bills/cards/payments/settings
│ │ │ + debounced sync + cleartab:data-changed
│ │ ├── utils.js formatters, helpers, renderer registry
│ │ ├── modals.js bill/card/pay/confirm modal logic
│ │ ├── navbar.js renders the appbar into <header data-app-nav>
│ │ ├── theme.js light/dark theme handling
│ │ ├── export.js CSV builders for the dashboard tabs
│ │ └── dashboard.js / bills.js / cards.js / budget.js /
│ │ history.js / payoff.js thin mount shims for each Svelte tab
│ ├── svelte/ Svelte 5 components for each tab
│ │ ├── DashboardView.svelte
│ │ ├── BillsList.svelte
│ │ ├── CardsList.svelte
│ │ ├── BudgetView.svelte
│ │ ├── HistoryList.svelte
│ │ └── PayoffView.svelte
│ ├── public/ copied verbatim to dist root
│ │ ├── robots.txt
│ │ ├── sitemap.xml
│ │ ├── site.webmanifest
│ │ ├── icon.svg
│ │ └── og-image.svg
│ └── svelte.config.js
├── server/
│ ├── index.js Express entry — env loading, routes,
│ │ static, 404/500, dev-user seed
│ ├── db.js better-sqlite3 + schema + statements
│ ├── session.js loadSession / requireAuth / requireCsrf
│ ├── captcha.js Cloudflare Turnstile siteverify
│ ├── rateLimit.js in-memory IP+email throttle (5 / 15 min)
│ ├── util.js email + password policy, BCRYPT_COST
│ └── routes/
│ ├── auth.js signup, login, logout, me
│ ├── data.js GET/PUT /api/data
│ └── account.js change-email, change-password, delete,
│ export, export/<type>.csv
├── data/ SQLite file lives here (gitignored)
├── dist/ Vite build output (gitignored)
├── .env local secrets (gitignored)
├── .env.development dev defaults (committed — TEST keys)
├── .env.example template
├── vite.config.js multi-page + Svelte + clean URLs
└── tailwind.config.js
| Script | What it does |
|---|---|
npm run dev |
Express (:5222) + Vite (:5173) concurrently. Vite proxies /api → Express. Use this for normal development. |
npm run dev:server |
Express only, with node --watch. |
npm run dev:client |
Vite only. |
npm run dev:css |
Watch-rebuild the Tailwind utility classes into client/css/tailwind-built.css. |
npm run build:css |
One-shot Tailwind utility build (minified). |
npm run build |
vite build → dist/. Strips HTML comments and minifies CSS/JS. |
npm run preview |
vite preview of the built dist/. |
npm start |
NODE_ENV=production node server/index.js — serves dist/ + the API. |
Variables are loaded in this order; the first match per variable wins:
.env.<NODE_ENV>.local # local-only overrides for this mode
.env.local # local-only overrides, any mode
.env.<NODE_ENV> # committed defaults for this mode
.env # local catch-all
So npm run dev (default NODE_ENV=development) picks up the
committed test keys in .env.development, and
your private .env is only consulted as a fallback. In production
(npm start), .env.production.local, .env.local, and .env all
get a shot — but .env.development is skipped.
| Variable | Required | Default (dev) | Notes |
|---|---|---|---|
NODE_ENV |
no | development |
Drives env-file loading + cookie Secure flag |
PORT |
no | 5222 |
Express port |
TURNSTILE_SECRET |
yes | test key | Cloudflare Turnstile server-side secret |
TURNSTILE_SITEKEY |
yes | test key | Cloudflare Turnstile public sitekey |
SESSION_COOKIE |
no | ct_sid |
Cookie name |
SESSION_TTL_HOURS |
no | 12 |
Session lifetime |
DEV_USER_EMAIL |
no | [email protected] |
Seeded on first dev start (skipped in prod) |
DEV_USER_PASSWORD |
no | demopassword11 |
Same as above |
Real Turnstile keys come from
https://dash.cloudflare.com/?to=/:account/turnstile — create a
widget, copy the sitekey + secret into your .env.
Clean URLs throughout. Everything *.html legacy URL 301-redirects
to its clean form, both on Express and the Vite dev middleware.
| URL | Page | Auth | Indexed |
|---|---|---|---|
/ |
Marketing landing | public | ✅ |
/login |
Log-in / sign-up | public | ✅ |
/terms |
Terms of Use | public | ✅ |
/privacy |
Privacy Policy | public | ✅ |
/dashboard |
App dashboard (Bills / Cards / Budget / History / Payoff) | required | ❌ noindex |
/settings |
Email / password / export / import / delete | required | ❌ noindex |
/404 |
Not-found page | public | ❌ |
/500 |
Server-error page | public | ❌ |
All under /api. JSON bodies, JSON responses (except CSV / JSON
export endpoints).
| Method | Path | Purpose |
|---|---|---|
POST |
/api/auth/signup |
Create account (Turnstile + honeypot + timing + rate-limit checks) |
POST |
/api/auth/login |
Sign in |
POST |
/api/auth/logout |
Destroy session (requires X-CSRF-Token) |
GET |
/api/auth/me |
Session check — returns {user, csrfToken} or {user: null} |
GET |
/api/config |
Public config (currently just turnstileSitekey) |
| Method | Path | Purpose |
|---|---|---|
GET |
/api/data |
Whole snapshot — {email, bills, cards, payments, settings} |
PUT |
/api/data |
Replace the snapshot (auth + CSRF) |
| Method | Path | Purpose |
|---|---|---|
POST |
/api/account/change-email |
Change email (re-verifies password) |
POST |
/api/account/change-password |
Change password (also signs out other devices) |
POST |
/api/account/delete |
Delete account + all data |
GET |
/api/account/export |
Full JSON download |
GET |
/api/account/export/bills.csv |
Bills CSV |
GET |
/api/account/export/cards.csv |
Cards CSV |
GET |
/api/account/export/history.csv |
Payment history CSV |
All mutating routes (everything except the four GETs) require the
session cookie and the X-CSRF-Token header — its value is the
csrfToken returned by /api/auth/me (or by signup / login).
HttpOnly, SameSite=Lax, Secure (in
prod) cookie — unreadable from JS.X-CSRF-Token on mutating requests.storage.bootstrapData() → GET /api/data →
populates the live arrays (bills, cards, payments,
settings) imported by every module.storage.save(key, value) →
writes localStorage and schedules a debounced (800 ms) PUT.applyData), storage dispatches
window.dispatchEvent(new CustomEvent('cleartab:data-changed')).onMount and re-pulls its $state
snapshot from the live arrays — no prop wiring between vanilla
modules and components.pagehide /
visibilitychange:hidden via fetch(keepalive: true).Marking a card payment as paid (confirmPay) decrements
card.balance (and card.promoBalance if present). Edit-payment
applies the delta. Delete-payment from the History tab adds the
amount back. Balances never go negative.
client/ directly + client/public/ as a
fallback (so robots.txt etc. work on :5222). Vite serves the
same content from :5173 with HMR + proxy.NODE_ENV=production): Express serves dist/ —
which Vite has already merged with client/public/ contents — and
the Secure cookie flag is enabled..env (or your hosting provider's env panel).Secure in production and will simply be
dropped over plain HTTP.npm install (build deps included for better-sqlite3 / bcrypt
native modules).npm run build:css && npm run build — produces dist/.npm start — Express serves dist/ + the API on the configured
PORT.data/ between deploys (it holds cleartab.db).https://cleartab.app placeholders with your real
domain anywhere it appears
(sitemap.xml,
robots.txt, every public page's
canonical / og:url / twitter:image /
JSON-LD URL). A one-shot replace:grep -rl 'cleartab.app' client/ | xargs sed -i '' 's|https://cleartab.app|https://your-domain.com|g'
robots.txt allows everything except /dashboard, /settings,
/api/* and points to the sitemap.sitemap.xml lists the four public pages.WebApplication schema. Private pages set noindex,nofollow.AGPL-3.0-or-later. If you host a modified version, you need to make your source available to its users.