{"id":50835092,"url":"https://github.com/greigh/fihaven","last_synced_at":"2026-06-14T03:00:45.664Z","repository":{"id":362496912,"uuid":"1248007300","full_name":"Greigh/FiHaven","owner":"Greigh","description":"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.","archived":false,"fork":false,"pushed_at":"2026-06-08T11:40:19.000Z","size":800,"stargazers_count":0,"open_issues_count":6,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-08T13:24:06.921Z","etag":null,"topics":["budgeting","client","credit-card","css","database","express","html","javascript","npm","seo","server","sql","sqlite3","svelte","tailwind","vite"],"latest_commit_sha":null,"homepage":"","language":"JavaScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"agpl-3.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/Greigh.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":".github/CONTRIBUTING.md","funding":null,"license":"LICENSE","code_of_conduct":".github/CODE_OF_CONDUCT.md","threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":".github/SECURITY.md","support":".github/SUPPORT.md","governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-05-24T04:13:23.000Z","updated_at":"2026-06-08T11:38:08.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/Greigh/FiHaven","commit_stats":null,"previous_names":["greigh/cleartab","greigh/fihaven"],"tags_count":null,"template":false,"template_full_name":null,"purl":"pkg:github/Greigh/FiHaven","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/Greigh%2FFiHaven","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/Greigh%2FFiHaven/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/Greigh%2FFiHaven/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/Greigh%2FFiHaven/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/Greigh","download_url":"https://codeload.github.com/Greigh/FiHaven/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/Greigh%2FFiHaven/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34307685,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-14T02:00:07.365Z","response_time":62,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["budgeting","client","credit-card","css","database","express","html","javascript","npm","seo","server","sql","sqlite3","svelte","tailwind","vite"],"created_at":"2026-06-14T03:00:35.235Z","updated_at":"2026-06-14T03:00:45.648Z","avatar_url":"https://github.com/Greigh.png","language":"JavaScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"\u003cdiv align=\"center\"\u003e\n\n\u003cimg src=\"client/public/icon.svg\" alt=\"FiHaven logo\" width=\"96\" height=\"96\" /\u003e\n\n# FiHaven\n\n**Quiet money. Calm month.**\n\nA calm, manual-first money dashboard — bills, cards, loans, budget, and\ndebt payoff — with full native iOS/macOS and Android apps on a shared\nbackend.\n\n[![CI](https://img.shields.io/github/actions/workflow/status/Greigh/FiHaven/ci.yml?branch=main\u0026label=CI)](https://github.com/Greigh/FiHaven/actions/workflows/ci.yml) [![Android](https://img.shields.io/github/actions/workflow/status/Greigh/FiHaven/android.yml?branch=main\u0026label=Android)](https://github.com/Greigh/FiHaven/actions/workflows/android.yml) [![iOS](https://img.shields.io/github/actions/workflow/status/Greigh/FiHaven/ios.yml?branch=main\u0026label=iOS)](https://github.com/Greigh/FiHaven/actions/workflows/ios.yml) [![CodeQL](https://img.shields.io/github/actions/workflow/status/Greigh/FiHaven/codeql.yml?branch=main\u0026label=CodeQL)](https://github.com/Greigh/FiHaven/actions/workflows/codeql.yml) [![Dependencies](https://img.shields.io/github/actions/workflow/status/Greigh/FiHaven/dependency-review.yml?branch=main\u0026label=Dependencies)](https://github.com/Greigh/FiHaven/actions/workflows/dependency-review.yml) [![Coverage](https://img.shields.io/codecov/c/gh/Greigh/FiHaven?branch=main\u0026label=Coverage)](https://codecov.io/gh/Greigh/FiHaven)\n\n[![Version](https://img.shields.io/badge/version-1.2.0-brightgreen)](https://github.com/Greigh/FiHaven/releases) [![License](https://img.shields.io/badge/license-GNU%20AGPLv3-blue)](LICENSE) [![Node](https://img.shields.io/badge/node-%3E%3D22.14.0-green)](https://nodejs.org/) [![Swift](https://img.shields.io/badge/Swift-5.0-orange)](https://swift.org) [![Kotlin](https://img.shields.io/badge/Kotlin-2.1.20-blue)](https://kotlinlang.org) [![GitHub stars](https://img.shields.io/github/stars/Greigh/FiHaven?style=flat-square)](https://github.com/Greigh/FiHaven/stargazers) [![Last commit](https://img.shields.io/github/last-commit/Greigh/FiHaven?style=flat-square)](https://github.com/Greigh/FiHaven/commits)\n\n\u003c/div\u003e\n\n---\n\nA focused bill and debt dashboard for people who'd rather spend five\ncalm minutes a week than a frantic afternoon every payday. Track\nrecurring bills, credit cards (including 0% promo periods), **loans**,\nmonthly budget, **individual transactions**, payment history,\ndebt-payoff strategies, and a month-grid calendar of upcoming due\ndates — all behind a real account with server-side sync, optional\nmulti-factor sign-in (TOTP, passkeys, or email codes), and an iCal feed\nyou can subscribe to from any calendar app.\n\nIt stays **manual-first**: you own every number. Optional **Plaid**\nbank linking is just a safety net that surfaces transactions you may\nhave missed — it never overwrites what you entered. A **rewards\noptimizer** tells you which card to reach for per spending category\n(and pointedly *won't* recommend a card mid-0%-promo, since carrying a\nreward purchase at the back of your payoff queue costs more in interest\nthan the rewards are worth). Premium features live behind a unified\n**FiHaven Pro** entitlement across web (Stripe), iOS (StoreKit), and\nAndroid (Play).\n\n---\n\n## Contents\n\n- [Highlights](#highlights)\n- [Free vs Pro](#free-vs-pro)\n- [Stack](#stack)\n- [Quick start](#quick-start)\n- [Native apps (iOS / macOS / Android)](#native-apps-ios--macos--android)\n- [Project structure](#project-structure)\n- [npm scripts](#npm-scripts)\n- [Environment](#environment)\n- [URLs](#urls)\n- [API](#api)\n- [Admin \u0026 promo codes](#admin--promo-codes)\n- [How a few things work](#how-a-few-things-work)\n- [Production deploy](#production-deploy)\n- [SEO + standards](#seo--standards)\n- [License](#license)\n\nChangelog: [CHANGELOG.md](CHANGELOG.md).\n\n---\n\n## Highlights\n\n- **Bills, Cards \u0026 Loans** — recurring bills with variance sparklines,\n  credit cards with 0% promo tracking, and loans/mortgages in their own\n  tab (recommended payment is the minimum, not the whole balance —\n  payoff-in-full stays an option).\n- **Budget suite** — income sources, period-aware budgeting (calendar,\n  start-day, or rolling K-day periods), and a \"cushion after bills\"\n  runway.\n- **Transactions** — log individual spend, grouped and categorized;\n  optionally augmented (never replaced) by Plaid bank sync.\n- **Rewards optimizer** — per-category multipliers, a built-in preset\n  database of popular cards, and 0%-promo-aware recommendations.\n- **Debt payoff** — avalanche / snowball planners with a split view.\n- **Calendar + iCal** — month grid of due dates and a subscribe-anywhere\n  feed.\n- **Security** — opaque server sessions, CSRF, Turnstile, per-IP rate\n  limiting (express-rate-limit), MFA (TOTP / passkeys / email codes),\n  AES-256-GCM at rest, and a hardware-KeyStore-backed biometric app lock\n  on Android.\n\n---\n\n## Free vs Pro\n\nThe free tier is genuinely useful on its own — all manual tracking. Pro\nadds the automation and insight tools. The `pro` entitlement is\nserver-authoritative and identical across web, iOS, and Android.\n\n| Free | Pro |\n|---|---|\n| Bills, Cards \u0026 Loans (track, mark paid, due dates) | Debt-payoff planner |\n| Budget with manual transactions | Due-date calendar + iCal feed |\n| Savings goals | Full payment history |\n| Net worth | Rewards optimizer + card preset database |\n| Light/dark, time zones, MFA, export/import | Subscription finder · Autopay auto-mark |\n| | Bank sync (Plaid) · spending-category budgets |\n\nGating is centralized: web via `PRO_TABS` in `client/js/app.js` +\n`requirePro` on the server, iOS via `ProGate(feature:)`, Android via\n`ProGate(vm, ProFeature.X)`.\n\n---\n\n## Stack\n\n| Layer | What |\n|---|---|\n| **Frontend pages** | Svelte 5 (runes) for each dashboard tab, vanilla JS for navbar / modals / auth / theme |\n| **Build** | [Vite 8](https://vitejs.dev) multi-page, with the [@sveltejs/vite-plugin-svelte](https://www.npmjs.com/package/@sveltejs/vite-plugin-svelte) plugin |\n| **Styling** | Hand-written CSS split into themed files (`tokens`, `components`, `theme-dark`, `pages`, `marketing`, `budget`, `mobile`) + a small Tailwind v4 utility build. Fully responsive — phones get a hamburger drawer and stacked-card tables |\n| **Server** | Node 22 + Express 5, [better-sqlite3](https://github.com/WiseLibs/better-sqlite3) for storage |\n| **Auth** | bcrypt password hashing, opaque server-side sessions in SQLite, HttpOnly cookies, CSRF double-submit token, [Cloudflare Turnstile](https://www.cloudflare.com/products/turnstile/) bot protection, per-IP rate limiting via [express-rate-limit](https://www.npmjs.com/package/express-rate-limit) plus an in-memory login throttle keyed by IP + email |\n| **MFA** | TOTP via [otpauth](https://www.npmjs.com/package/otpauth) + QR codes, WebAuthn passkeys via [@simplewebauthn](https://simplewebauthn.dev/), email sign-in codes via [nodemailer](https://nodemailer.com/), bcrypt-hashed backup codes; TOTP secrets encrypted at rest with AES-256-GCM. Native app lock uses platform biometrics (Android binds it to a hardware AndroidKeyStore key) |\n| **Billing** | Unified **FiHaven Pro** entitlement (server-authoritative) across web [Stripe](https://stripe.com), iOS StoreKit 2, and Android Play Billing, plus server-issued promo codes |\n| **Bank sync** | Optional, Pro-gated [Plaid](https://plaid.com) linking (Link + OAuth redirect, `transactionsSync`, webhooks). Access tokens AES-256-GCM-encrypted at rest; synced transactions are **additive only** and never overwrite manual entries |\n| **Per-user data sync** | One JSON blob per user in SQLite, `PUT /api/data` with debounced client writes, Svelte 5 `$state` proxies as the in-memory store, localStorage as offline cache |\n| **Deploy** | A local, gitignored `upload.sh` (not shipped in this repo) builds, rsyncs `dist/` + `server/` + a sanitized `.env` to the VPS, then `npm ci --omit=dev` + `pm2 restart` on the remote |\n\nSingle deployable unit — Express serves the API *and* the static\nclient (raw `client/` in dev, the Vite-built `dist/` in production),\nall mounted under the `/fihaven` URL prefix so it can sit next to\nother apps on the same host.\n\n---\n\n## Quick start\n\nRequires **Node ≥ 22** (for native `fetch`, `--watch`, and the\nbetter-sqlite3 / bcrypt prebuilds).\n\n```bash\ngit clone \u003crepo\u003e fihaven\ncd fihaven\nnpm install\nnpm run dev\n```\n\nThen open \u003chttp://localhost:5173/fihaven/\u003e. Vite serves the client\nwith HMR on `:5173` and proxies `/fihaven/api/*` to the Express\nserver on `:5222`.\n\n**Sign in with the seeded dev account:**\n\n| | |\n|---|---|\n| Email | `demo@fihaven.app` |\n| Password | `demopassword11` |\n\nThe seed lives in [`.env.development`](.env.development) and is\ncreated automatically on first server start (only when\n`NODE_ENV !== 'production'`).\n\n\u003e You can also hit Express directly at\n\u003e \u003chttp://localhost:5222/fihaven/\u003e if you don't need HMR — same\n\u003e content, same auth flow, no Vite layer.\n\n---\n\n## Native apps (iOS / macOS / Android)\n\nFiHaven also ships native clients that talk to this same backend over\ntoken/Bearer auth and reproduce the web's business logic, look, and\nFiHaven Pro subscription. Each has its own README:\n\n- **[iOS / macOS](ios/README.md)** — SwiftUI app on a shared Swift core\n  (`ios/`), StoreKit 2 subscriptions, dark-mode toggle, bundled fonts.\n- **[Android](android/README.md)** — Jetpack Compose app on a shared\n  Kotlin core (`android/`), Play Billing, encrypted token storage.\n\nThe shared API + data + design + billing contract both apps follow lives\nin **[`docs/native-contract.md`](docs/native-contract.md)**. FiHaven Pro\nentitlement is server-authoritative and unified across web (Stripe), iOS\n(StoreKit), and Android (Play) — see [the API section](#api).\n\n---\n\n## Project structure\n\n```\nfihaven/\n├── client/\n│   ├── *.html                       page entries: home, login, dashboard,\n│   │                                settings, plaid-oauth, welcome (onboarding),\n│   │                                verify-email, reset (password),\n│   │                                recover (lost-2FA), terms, privacy, 404, 500\n│   ├── css/\n│   │   ├── styles.css               manifest — @imports the others\n│   │   ├── tokens.css               design tokens + body bg\n│   │   ├── components.css           nav, buttons, badges, cards, modals…\n│   │   ├── theme-dark.css           dark-mode overrides\n│   │   ├── pages.css                page-frame, auth, legal, footer, settings\n│   │   ├── marketing.css            home/landing styles\n│   │   ├── budget.css               Budget tab\n│   │   ├── mobile.css               responsive layer (loaded last): hamburger\n│   │   │                            drawer, stacked-card tables, touch sizing\n│   │   └── tailwind-input.css       (Tailwind source for utility classes)\n│   ├── js/\n│   │   ├── app.js                   dashboard entry — imports the lot\n│   │   ├── settings.js              /settings entry (tabbed sections)\n│   │   ├── public-entry.js          /, /login, /terms, /privacy entry\n│   │   ├── auth.js                  /api/auth client, MFA second-step UI\n│   │   ├── welcome.js               onboarding flow (/welcome)\n│   │   ├── verify-email.js          email-verification page\n│   │   ├── reset.js                 forgot / reset-password page\n│   │   ├── recover.js               lost-2FA recovery page\n│   │   ├── admin.js                 admin dashboard panel\n│   │   ├── utils.js                 formatters (currency-aware) + due-date math\n│   │   ├── tz.js                    IANA-timezone `today()` helper\n│   │   ├── income.js                shared frequency-to-monthly math\n│   │   ├── modals.js                bill/card/pay/confirm modal logic\n│   │   ├── navbar.js                appbar + mobile drawer + FiHaven Pro entry\n│   │   ├── theme.js                 light/dark theme handling\n│   │   ├── export.js                CSV builders for the dashboard tabs\n│   │   ├── rewards.js               per-category rewards ranking engine\n│   │   ├── cardPresets.js           preset DB of popular cards + reward defaults\n│   │   ├── period.js                period model (calendar / start-day / rolling)\n│   │   ├── plaid-oauth.js           /plaid-oauth redirect resume handler\n│   │   ├── storage.svelte.js        shared `$state` proxies + debounced sync\n│   │   ├── snoozes.svelte.js        per-bill snooze state\n│   │   └── dashboard.js / bills.js / cards.js / loans.js /\n│   │       budget.js / history.js / payoff.js / rewards.js /\n│   │       calendar.js               thin mount shims for each Svelte view\n│   ├── svelte/                      Svelte 5 components\n│   │   ├── DashboardView.svelte\n│   │   ├── BillsList.svelte         + variance sparklines, stale-bill audit\n│   │   ├── CardsList.svelte         shared by Cards \u0026 Loans via a `kind` prop\n│   │   ├── RewardsView.svelte       \"which card should I use?\" optimizer\n│   │   ├── BudgetView.svelte        + \"Cushion after bills\" runway\n│   │   ├── SpendingPanel.svelte     transactions entry + recent spend\n│   │   ├── SubscriptionsPanel.svelte recurring-charge detection\n│   │   ├── NetWorthPanel.svelte     accounts → net-worth rollup\n│   │   ├── GoalsPanel.svelte        savings goals\n│   │   ├── CalendarView.svelte      month-grid of upcoming due dates\n│   │   ├── HistoryList.svelte\n│   │   ├── PayoffView.svelte\n│   │   ├── Sparkline.svelte         tiny inline SVG sparkline\n│   │   └── MfaSection.svelte        Settings → 2FA UI (TOTP/passkey/email)\n│   ├── public/                      copied verbatim to dist root\n│   │   ├── robots.txt\n│   │   ├── sitemap.xml\n│   │   ├── site.webmanifest\n│   │   ├── icon.svg\n│   │   └── og-image.svg\n│   └── svelte.config.js\n├── server/\n│   ├── index.js                     Express entry — env, routes, static,\n│   │                                page gates, scheduler boot, /fihaven base\n│   ├── db.js                        better-sqlite3 + schema + statements\n│   ├── session.js                   loadSession / requireAuth / requireVerified / requireCsrf\n│   ├── tokens.js                    single-use email tokens (verify / reset / recover)\n│   ├── emails.js                    branded HTML emails (verify, reset, recovery, reminders)\n│   ├── scheduler.js                 tz-aware bill-reminder + monthly-summary mailer\n│   ├── captcha.js                   Cloudflare Turnstile siteverify\n│   ├── mfa.js                       AES-256-GCM, TOTP, backup codes, passkeys, email codes\n│   ├── billing.js                   Stripe + entitlement (FiHaven Pro)\n│   ├── plaid.js                     optional Plaid bank-linking helpers\n│   ├── mail.js                      thin nodemailer wrapper\n│   ├── rateLimit.js                 in-memory login throttle, IP+email (5 / 15 min)\n│   │                                (per-IP flood guard is express-rate-limit in index.js)\n│   ├── util.js                      email + password policy, BCRYPT_COST\n│   └── routes/\n│       ├── auth.js                  signup, login, logout, me, verify, reset, recover\n│       ├── data.js                  GET/PUT /api/data (verified-gated)\n│       ├── account.js               change-email/password/name, delete, export,\n│       │                            export/\u003ctype\u003e.csv, iCal token CRUD, onboarded\n│       ├── mfa.js                   /api/account/mfa (enroll/manage second factors)\n│       ├── billing.js               Stripe checkout / portal / webhook + entitlement\n│       ├── plaid.js                 Pro-gated bank linking (link / exchange /\n│       │                            refresh / item-remove / repaired / webhook)\n│       ├── admin.js                 admin-only stats + user management\n│       └── calendar.js              public `/api/calendar/\u003ctoken\u003e.ics` feed\n├── data/                            SQLite file + mfa.key live here (gitignored)\n├── dist/                            Vite build output (gitignored)\n├── upload.sh                        local deploy script — gitignored, not in repo\n├── .env                             local secrets (gitignored)\n├── .env.development                 dev defaults (committed — TEST keys)\n├── .env.example                     template\n├── vite.config.js                   multi-page + Svelte, base=/fihaven/, envDir=..\n└── tailwind.config.js\n```\n\n---\n\n## npm scripts\n\n| Script | What it does |\n|---|---|\n| `npm run dev` | Express (`:5222`) + Vite (`:5173`) concurrently. Vite proxies `/fihaven/api` → Express. **Use this for normal development.** |\n| `npm run dev:server` | Express only, with `node --watch`. |\n| `npm run dev:client` | Vite only. |\n| `npm run dev:css` | Watch-rebuild the Tailwind utility classes into `client/css/tailwind-built.css`. |\n| `npm run build:css` | One-shot Tailwind utility build (minified). |\n| `npm run build` | `build:css` + `vite build` → `dist/`. Strips HTML comments and minifies CSS/JS. |\n| `npm run preview` | `vite preview` of the built `dist/`. |\n| `npm start` | `NODE_ENV=production node server/index.js` — serves `dist/` + the API. |\n| `npm run deploy` | Runs `bash upload.sh` (a local, gitignored deploy script — **not included in this repo**; bring your own) — builds, rsyncs `dist/` + `server/` + sanitized `.env`, `npm ci --omit=dev` + `pm2 restart` on the remote. |\n\n---\n\n## Environment\n\nVariables are loaded in this order; the first match per variable wins:\n\n```\n.env.\u003cNODE_ENV\u003e.local     # local-only overrides for this mode\n.env.local                # local-only overrides, any mode\n.env.\u003cNODE_ENV\u003e           # committed defaults for this mode\n.env                      # local catch-all\n```\n\nSo `npm run dev` (default `NODE_ENV=development`) picks up the\ncommitted test keys in [`.env.development`](.env.development), and\nyour private `.env` is only consulted as a fallback. In production\n(`npm start`), `.env.production.local`, `.env.local`, and `.env` all\nget a shot — but `.env.development` is skipped.\n\n### Variables\n\n| Variable | Required | Default (dev) | Notes |\n|---|---|---|---|\n| `NODE_ENV` | no | `development` | Drives env-file loading + cookie `Secure` flag |\n| `PORT` | no | `5222` | Express port |\n| `TURNSTILE_SECRET` | **yes** | test key | Cloudflare Turnstile server-side secret |\n| `TURNSTILE_SITEKEY` | **yes** | test key | Cloudflare Turnstile public sitekey |\n| `VITE_TURNSTILE_SITEKEY` | **yes** | test key | Same sitekey, exposed to Vite so it can inline into `login.html` at build time |\n| `SESSION_COOKIE` | no | `ct_sid` | Cookie name |\n| `SESSION_TTL_HOURS` | no | `12` | Session lifetime |\n| `SMTP_HOST` | for email-MFA | `localhost` | Outbound SMTP host (production VPS runs Postfix on loopback) |\n| `SMTP_PORT` | for email-MFA | `25` | `465`/`587` enable TLS automatically |\n| `SMTP_USER` / `SMTP_PASS` | optional | — | Only if your relay requires auth |\n| `MAIL_FROM` | for email-MFA | `FiHaven \u003cno-reply@danielhipskind.com\u003e` | RFC 5322 `From:` header for outbound mail |\n| `MFA_ENCRYPTION_KEY` | no | auto | 32-byte hex; if unset a key is generated and persisted to `data/mfa.key` |\n| `DEV_USER_EMAIL` | no | `demo@fihaven.app` | Seeded on first dev start (skipped in prod) |\n| `DEV_USER_PASSWORD` | no | `demopassword11` | Same as above |\n\nReal Turnstile keys come from\n\u003chttps://dash.cloudflare.com/?to=/:account/turnstile\u003e.\n\n### Deploy-only variables (read by `upload.sh`)\n\n| Variable | Default | Notes |\n|---|---|---|\n| `SSH_HOST` | — | VPS IP / hostname |\n| `SSH_USER` | `root` | SSH login |\n| `SSH_PASSWORD` | — | Used via `sshpass` — `brew install hudochenkov/sshpass/sshpass` on macOS |\n| `DEPLOY_PATH` | `/var/www/danielhipskind.com/fihaven` | Remote app root |\n| `REMOTE_RESTART_CMD` | `pm2 restart fihaven --update-env …` | Override if you don't use PM2 |\n\n`upload.sh` reads these from your local `.env`, strips them (along\nwith `DEV_USER_*` and any legacy `HCAPTCHA_*`) from the file it\nuploads, and pins `NODE_ENV=production` on the remote `.env`.\n\n---\n\n## URLs\n\nEverything is mounted under `/fihaven`. Clean URLs throughout; old\n`*.html` URLs 301-redirect to their clean form on both Express and\nthe Vite dev middleware.\n\n| URL | Page | Auth | Indexed |\n|---|---|---|---|\n| `/fihaven/` | Marketing landing | public | ✅ |\n| `/fihaven/login` | Log-in / sign-up | public | ✅ |\n| `/fihaven/terms` | Terms of Use | public | ✅ |\n| `/fihaven/privacy` | Privacy Policy | public | ✅ |\n| `/fihaven/dashboard` | App dashboard (Dashboard / Bills / Cards / Loans / Budget / Calendar / History / Payoff / Rewards) | required | ❌ noindex |\n| `/fihaven/settings` | Profile / Preferences / Payments — time zone, name, 2FA, iCal, bank linking, email, password, export, import, delete | required | ❌ noindex |\n| `/fihaven/plaid-oauth` | Plaid OAuth return handler (resumes bank Link after the redirect) | required | ❌ noindex |\n| `/fihaven/404` | Not-found page | public | ❌ |\n| `/fihaven/500` | Server-error page | public | ❌ |\n\n---\n\n## API\n\nAll under `/fihaven/api`. JSON bodies, JSON responses (except the\nCSV / JSON export endpoints and the public `.ics` feed).\n\n### Auth\n\n| Method | Path | Purpose |\n|---|---|---|\n| `POST` | `/api/auth/signup` | Create account (Turnstile + honeypot + timing + rate-limit checks) |\n| `POST` | `/api/auth/login` | Sign in (returns `{mfaRequired, mfaToken, methods}` when a second factor is enrolled) |\n| `POST` | `/api/auth/mfa/verify` | Complete a TOTP / backup-code / email-code second step |\n| `POST` | `/api/auth/mfa/email/send` | Issue an email sign-in code for the pending `mfaToken` |\n| `POST` | `/api/auth/mfa/passkey/start` / `.../finish` | WebAuthn second-factor handshake |\n| `POST` | `/api/auth/logout` | Destroy session (requires `X-CSRF-Token`) |\n| `GET` | `/api/auth/me` | Session check — returns `{user, csrfToken}` or `{user: null}` |\n| `GET` | `/api/config` | Public config (currently just `turnstileSitekey`) |\n\n### Per-user data\n\n| Method | Path | Purpose |\n|---|---|---|\n| `GET` | `/api/data` | Whole snapshot — `{email, bills, cards, payments, accounts, goals, transactions, settings, entitlement}` (cards include loans; `entitlement` carries the effective Pro status) |\n| `PUT` | `/api/data` | Replace the snapshot (auth + CSRF) |\n\n### Account management\n\n| Method | Path | Purpose |\n|---|---|---|\n| `POST` | `/api/account/change-email` | Change email (re-verifies password) |\n| `POST` | `/api/account/change-password` | Change password (also signs out other devices) |\n| `POST` | `/api/account/change-name` | Set the display name shown in the navbar |\n| `POST` | `/api/account/delete` | Delete account + all data |\n| `GET` | `/api/account/export` | Full JSON download |\n| `GET` | `/api/account/export/bills.csv` | Bills CSV |\n| `GET` | `/api/account/export/cards.csv` | Cards CSV |\n| `GET` | `/api/account/export/history.csv` | Payment history CSV |\n| `GET` | `/api/account/ical-token` | Read the current iCal subscription token (creates one if none) |\n| `POST` | `/api/account/ical-token` | Rotate the iCal token (invalidates old subscriptions) |\n| `DELETE` | `/api/account/ical-token` | Revoke the iCal token entirely |\n\n### MFA management\n\n| Method | Path | Purpose |\n|---|---|---|\n| `GET` | `/api/account/mfa/status` | Snapshot of enrolled factors + remaining backup codes |\n| `POST` | `/api/account/mfa/totp/setup` | Begin TOTP enrollment — returns QR + base32 secret (requires password) |\n| `POST` | `/api/account/mfa/totp/confirm` | Confirm with a 6-digit code; on success returns 10 backup codes |\n| `POST` | `/api/account/mfa/totp/disable` | Disable TOTP (requires password + current code) |\n| `POST` | `/api/account/mfa/backup-codes/regenerate` | Reissue the 10-code set (requires password + current code) |\n| `POST` | `/api/account/mfa/passkey/register-start` / `.../register-finish` | Enroll a WebAuthn passkey (Touch ID / Face ID / Windows Hello / security key) |\n| `GET` | `/api/account/mfa/passkey/list` | List enrolled passkeys |\n| `POST` | `/api/account/mfa/passkey/delete` | Remove a passkey (requires password) |\n| `POST` | `/api/account/mfa/email/enable` | Start email-MFA enrollment — sends a code to the account email |\n| `POST` | `/api/account/mfa/email/confirm` | Confirm with the emailed code |\n| `POST` | `/api/account/mfa/email/disable` | Disable email-MFA (requires password) |\n\n### Calendar\n\n| Method | Path | Purpose |\n|---|---|---|\n| `GET` | `/api/calendar/\u003ctoken\u003e.ics` | Public iCal feed (6-month lookahead, per-event `VALARM` at –1 day) — auth is the unguessable token in the URL |\n\n### Billing \u0026 entitlement (FiHaven Pro)\n\nThe server is the single source of truth for the `pro` entitlement,\nunified across web (Stripe), iOS (StoreKit), and Android (Play) — it's\nalso embedded in `GET /api/data`. Full spec:\n[`docs/native-contract.md` §10](docs/native-contract.md).\n\n| Method | Path | Purpose |\n|---|---|---|\n| `GET` | `/api/billing/status` | Current entitlement `{ pro, source, plan, expiresAt }` |\n| `GET` | `/api/billing/stripe/config` | Publishable key + whether Stripe is live |\n| `POST` | `/api/billing/stripe/checkout` | Create a hosted Checkout Session (web) |\n| `POST` | `/api/billing/stripe/portal` | Stripe Billing Portal (manage/cancel) |\n| `POST` | `/api/billing/stripe/webhook` | Stripe-signed events → entitlement |\n| `POST` | `/api/billing/{apple,google}/verify` | Verify a native store transaction |\n| `POST` | `/api/billing/promo/redeem` | Redeem a server promo code |\n| `POST` | `/api/billing/promo` | Create a promo code (admin; `ADMIN_EMAILS`) |\n\n### Bank linking (Plaid — Pro-gated)\n\nManual-first overlay: Plaid only *adds* transactions you may have\nmissed. All routes require Pro (`402` otherwise); access tokens are\nAES-256-GCM-encrypted at rest.\n\n| Method | Path | Purpose |\n|---|---|---|\n| `GET` | `/api/plaid/status` | Linked items + last-sync state |\n| `POST` | `/api/plaid/link/token` | Create a Link token (pass `{itemId}` for update-mode reconnect) |\n| `POST` | `/api/plaid/link/exchange` | Exchange the public token; dedupes against already-linked banks (`409 already-linked`) |\n| `POST` | `/api/plaid/refresh` | `transactionsSync` → additively merge new outflows |\n| `POST` | `/api/plaid/item/:id/repaired` | Mark a reconnected (update-mode) item healthy |\n| `POST` | `/api/plaid/item/:id/remove` | Unlink a bank (manual data untouched) |\n| `POST` | `/api/plaid/webhook` | Plaid webhooks (ES256 JWT-verified in production) |\n\nAll mutating routes (every `POST` / `PUT` / `DELETE` above) require\nthe session cookie **and** the `X-CSRF-Token` header — its value is\nthe `csrfToken` returned by `/api/auth/me` (or by `signup` / `login`\n/ `mfa/verify`). Exceptions: native (Bearer-token) clients are\nCSRF-exempt, and the store webhooks (`stripe/webhook`,\n`apple`/`google` notifications) authenticate by their provider\nsignature instead of a session.\n\n---\n\n## Admin \u0026 promo codes\n\nPro entitlement is server-authoritative. Beyond Stripe / StoreKit / Play\npurchases, you can grant it manually two ways.\n\n### Admin role + dashboard panel\n\nEvery user has a `role` (`user` | `admin`). Admins are bootstrapped from the\n`ADMIN_EMAILS` env var (comma-separated) — those accounts are re-promoted to\n`admin` on **every server start**, so there's always a way back in even if\nroles get edited. Additional admins are then managed in-app.\n\nSigned in as an admin, **Settings → Admin** reveals a user-management panel:\nsearch users, **grant/revoke Pro** (a \"comp\" entitlement, optionally\ntime-limited), and **make/remove admin**. It's backed by the admin-only,\nCSRF-protected `/api/admin/*` routes:\n\n| Method | Path | Purpose |\n|---|---|---|\n| `GET` | `/api/admin/users?q=\u0026limit=` | List/search users with role + Pro status |\n| `POST` | `/api/admin/users/:id/role` | Set `admin` / `user` (can't demote yourself) |\n| `POST` | `/api/admin/users/:id/pro` | Grant (`{grant:true,days?}`) or revoke a comp Pro |\n\nThe panel stays hidden for non-admins, and the endpoints return `403`.\n\n### Promo codes (server CLI)\n\nServer-issued codes that users redeem in-app (**Settings → Redeem a code**),\nmanaged from the command line. This has **no network surface** — it's the\nleast-exploitable path (the admin HTTP endpoint exists but the CLI is\npreferred):\n\n```sh\nnpm run promo -- create LAUNCH30 --free --days 30 --max 200\nnpm run promo -- create FRIENDS --free            # lifetime\nnpm run promo -- create WELCOME --store-offer --platform apple \\\n  --product com.danielhipskind.fihaven.pro.yearly --offer WELCOME50\nnpm run promo -- list\nnpm run promo -- show LAUNCH30\nnpm run promo -- disable LAUNCH30\n```\n\n`scripts/promo.js` talks straight to the SQLite DB, so for **production** run\nit on the server (deployed by `upload.sh` alongside `server/`):\n\n```sh\nssh root@\u003chost\u003e \"cd /var/www/danielhipskind.com/fihaven \u0026\u0026 \\\n  node scripts/promo.js create LAUNCH30 --free --days 30\"\n```\n\n- `free_sub` codes grant Pro directly (no payment); `store_offer` codes map\n  to an Apple Offer / Play promo code for a *discounted purchase*.\n- For a discount on the **web**, create a Stripe coupon + promotion code in\n  the Stripe Dashboard — web checkout already accepts promo codes.\n\n---\n\n## How a few things work\n\n### Session + CSRF model\n\n- Login creates a session row in SQLite with an opaque random ID and\n  a separate random CSRF token.\n- The session ID rides in an `HttpOnly`, `SameSite=Lax`, `Secure` (in\n  prod) cookie scoped to `/fihaven` — unreadable from JS.\n- The CSRF token is returned in JSON bodies; client keeps it in\n  memory and echoes it in `X-CSRF-Token` on mutating requests.\n- Changing your password also deletes every *other* session for the\n  same user, leaving only the current device signed in.\n\n### Multi-factor sign-in\n\nIf the account has any second factor enrolled, `POST /login` returns\n`{mfaRequired:true, mfaToken, methods}` (where `methods` is some\nsubset of `['totp','passkey','email']`) — *no* session cookie yet.\nThe client then calls:\n\n- `/mfa/verify` with `{mfaToken, kind:'totp'|'backup', code}`,\n- or `/mfa/passkey/start` → user authenticates with their authenticator\n  → `/mfa/passkey/finish`,\n- or `/mfa/email/send` → email arrives → `/mfa/verify` with\n  `{mfaToken, kind:'email', code}`.\n\nOnly on a successful second step does the server create the session\ncookie + CSRF token. The `mfaToken` is a short-lived\nchallenge-bound id stored in SQLite (`mfa_challenges`), not a real\nsession — it can't be used to fetch data.\n\nTOTP secrets are encrypted with AES-256-GCM before insert; the key\nlives in `MFA_ENCRYPTION_KEY` or, if unset, in `data/mfa.key` (mode\n`600`, gitignored). Backup codes are bcrypt-hashed and single-use.\n\n### Calendar tab + iCal subscription\n\nThe Calendar tab renders a month-grid `CalendarView.svelte` showing\nevery bill / card payment due in the next 6 months, color-coded by\ntype. Each cell links back to the source row.\n\n`Settings → Calendar subscription` exposes a per-user random token\nand a webcal URL — point Apple/Google/Outlook Calendar at it and the\nserver returns a fresh `.ics` on every fetch. Rotating the token\ninvalidates any existing subscription instantly.\n\n### Live snapshot + variance + cushion + audit\n\n- **HeroPanel.svelte** sits at the top of the dashboard and shows\n  monthly income, due-this-month bills, cushion, and the next bill\n  due, all derived live from `$state` proxies.\n- **Sparkline.svelte** is rendered next to each bill, showing the\n  amount actually paid each of the last 6 months — a quick visual on\n  variable bills.\n- **Cushion after bills** in the Budget tab is income minus\n  fixed-monthly bills, telling you how much of next month is\n  uncommitted.\n- **Stale-bill audit** in BillsList flags rows that haven't been paid\n  in 60+ days, with a quick \"mark dormant\" / \"delete\" affordance.\n\n### Per-user data flow\n\n1. Dashboard boots → `storage.bootstrapData()` → `GET /api/data` →\n   populates the `$state` proxies (`bills`, `cards`, `payments`,\n   `settings`) re-exported by `client/svelte/storage.svelte.js`.\n2. Any mutation goes through `storage.save(key, value)` →\n   writes localStorage **and** schedules a debounced (800 ms) PUT.\n3. Svelte components read the `$state` proxies directly — Svelte 5's\n   fine-grained reactivity handles re-renders. No event bridge.\n4. Offline writes get flushed on `pagehide` /\n   `visibilitychange:hidden` via `fetch(keepalive: true)`.\n\n### Time zones\n\nAll due-date math (`utils.js`: `daysUntilDue`, `nextDueDate`, …) goes\nthrough `today()` in `client/js/tz.js`, which returns midnight in\nthe user's chosen IANA zone via `Intl.DateTimeFormat`. Pick the zone\nin `Settings → Time zone` — defaults to whatever the browser\nreports. This fixes the otherwise-classic \"Due tomorrow\" off-by-one\nwhen the server-side date doesn't match the user's wall clock.\n\n### Card balances on payments\n\nMarking a card payment as paid (`confirmPay`) decrements\n`card.balance` (and `card.promoBalance` if present). Edit-payment\napplies the delta. Delete-payment from the History tab adds the\namount back. Balances never go negative.\n\n### Rewards optimizer\n\nThe Rewards tab ranks your cards for a chosen spending category. Each\ncard's effective rate is `rewardCategories[category] ?? rewardBase`, and\nthe engine (`client/js/rewards.js`, mirrored by the native cores) returns\nthe best card plus the rest, **with one deliberate exclusion**: any card\ninside an active 0% APR promo is dropped (and shown with a reason).\nBecause payoff strategies pay 0% balances *last*, a reward purchase made\non a promo card sits at the back of the queue and starts accruing\ninterest before it's cleared — which almost always costs more than the\nrewards are worth. A preset database of popular cards\n(`client/js/cardPresets.js`) auto-fills sensible reward defaults.\n\n### Bank sync (manual-first)\n\nFiHaven is **manual-first** — Plaid is an optional safety net, never the\nsource of truth. Synced transactions are persisted *additively* (tagged\n`source:'plaid'`, deduped by Plaid id, outflows only) and shown alongside\nyour manual entries with a 🏦 marker; they're non-deletable from the row\n(manage the link in Settings) and a dropped connection never breaks the\ndashboard. OAuth banks redirect the whole browser out and back to\n`/plaid-oauth`, which resumes Link from a stashed token. Webhooks are\nES256-JWT-verified in production, and re-auth (\"update mode\") is a\nfirst-class Reconnect flow on web, iOS, and Android.\n\n### Responsive / mobile layout\n\nThe whole app is built to work down to small phones. All the\nresponsive rules live in one place — `client/css/mobile.css`,\n`@import`ed **last** by `styles.css` so it overrides the base files\nat equal specificity. It only targets global classes; component-\nscoped styles (e.g. `CalendarView.svelte`) carry their own media\nqueries. Three breakpoints do the work:\n\n- **≤ 900px** — the appbar's tab row is replaced by a hamburger.\n  `navbar.js` injects a `.appbar-burger` button and a body-level\n  `.mnav-overlay` + `.mnav-drawer`, then *clones* the existing nav\n  links into the drawer so their `onclick` / `href` keep working.\n  The clones drop the `tab-btn` class so `app.js`'s index-based\n  active-tab toggle still maps to the original buttons only. Tap the\n  scrim, hit Escape, or pick an item to close; body scroll locks\n  while it's open. Works on the dashboard, settings, and public\n  navbars.\n- **≤ 768px** — the dense **Bills / Budget / Payoff** tables stop\n  scrolling sideways and collapse into a stack of cards: each row\n  becomes a card, each cell a \"Label → value\" row (the label comes\n  from a `data-label` attribute via `::before`, the first cell is the\n  card header). The `\u003cthead\u003e` is visually hidden but kept for screen\n  readers. Buttons also get comfortable tap heights and form inputs\n  jump to 16px so iOS Safari doesn't zoom on focus.\n- **≤ 560px** — grids drop to one or two columns and modals become\n  full-width bottom sheets.\n\nA set of overflow guards (`min-width: 0` on the flex/grid containers\nthat hold long unbroken strings, plus `overflow-wrap` and letting\nthe alert banner's content wrap) keeps the layout from ever exceeding\nthe viewport — important because `\u003cbody\u003e` sets `overflow-x: hidden`,\nso anything wider would be clipped and unreachable rather than\nscrollable.\n\n### Dev vs production static serving\n\n- **Dev**: Express serves `client/` directly + `client/public/` as a\n  fallback (so `robots.txt` etc. work on `:5222`). Vite serves the\n  same content from `:5173` with HMR + proxy.\n- **Production** (`NODE_ENV=production`): Express serves `dist/` —\n  which Vite has already merged with `client/public/` contents — and\n  the `Secure` cookie flag is enabled.\n\n---\n\n## Production deploy\n\nDeploys run through a local `upload.sh` script (invoked by `npm run\ndeploy`). The script is **gitignored and intentionally not included in\nthis repo** — it carries host-specific paths and credentials. Bring\nyour own; the reference implementation handles the full local-build →\nremote-restart flow for a Node + PM2 + nginx VPS by:\n\n1. Builds the Tailwind utility CSS and the Vite client into `dist/`.\n2. Pre-gzips static assets for `gzip_static`.\n3. rsyncs `dist/`, `server/`, `package.json`, `package-lock.json`,\n   and a **sanitized** `.env` (drops `SSH_*` / `DEV_USER_*`, pins\n   `NODE_ENV=production`) — never touches the remote `data/` or\n   `node_modules/`.\n4. SSHes in and runs `npm ci --omit=dev` (installing\n   `build-essential` once if missing, so `better-sqlite3` + `bcrypt`\n   can compile against the system's Node) and `pm2 restart\n   fihaven --update-env`.\n\n### One-time remote setup\n\n```bash\nssh root@\u003cyour-host\u003e\nmkdir -p /var/www/\u003cyour-domain\u003e/fihaven/data\ncd /var/www/\u003cyour-domain\u003e/fihaven\n# Create .env on the remote with NODE_ENV=production, real\n# TURNSTILE_SECRET + TURNSTILE_SITEKEY, SESSION_COOKIE,\n# SESSION_TTL_HOURS, PORT, and (for email-MFA) SMTP_* + MAIL_FROM.\npm2 start server/index.js --name fihaven --update-env\npm2 save\n```\n\nnginx should reverse-proxy `/fihaven/` to the Node port (default\n`5222`):\n\n```nginx\nlocation /fihaven/ {\n  proxy_pass http://127.0.0.1:5222/fihaven/;\n  proxy_http_version 1.1;\n  proxy_set_header Host              $host;\n  proxy_set_header X-Real-IP         $remote_addr;\n  proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;\n  proxy_set_header X-Forwarded-Proto $scheme;\n}\n```\n\nThe Node process trusts the first proxy hop\n(`app.set('trust proxy', 1)`), so the `Secure` cookie flag fires\nwhen nginx terminates HTTPS upstream. Persist `data/` between\ndeploys — it holds `cleartab.db` and the MFA key.\n\n### Email-MFA on the VPS\n\nEmail sign-in codes need outbound SMTP. The production box runs\n**Postfix** bound to loopback (`inet_interfaces = loopback-only`)\nwith **OpenDKIM** signing every message; nodemailer connects to\n`127.0.0.1:25`. SPF / DKIM / DMARC records are published in DNS so\nthe messages pass alignment at the receiving server. If you stand up\na fresh VPS, either replicate that setup or point `SMTP_HOST` /\n`SMTP_PORT` at any relay (Mailgun, Postmark, SES, your ISP) and pass\n`SMTP_USER` / `SMTP_PASS` if it requires auth.\n\n---\n\n## SEO + standards\n\n- `robots.txt` allows everything except `/fihaven/dashboard`,\n  `/fihaven/settings`, `/fihaven/api/*` and points to the sitemap.\n- `sitemap.xml` lists the four public pages.\n- Every public page carries Open Graph + Twitter cards, a canonical\n  URL, and a description. The home page also ships a JSON-LD\n  `WebApplication` schema. Private pages set `noindex,nofollow`.\n- A web manifest + maskable SVG icon make the app installable.\n\n---\n\n## License\n\n[AGPL-3.0-or-later](LICENSE). If you host a modified version, you\nneed to make your source available to its users.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fgreigh%2Ffihaven","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fgreigh%2Ffihaven","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fgreigh%2Ffihaven/lists"}