Easy-to-manage touch screens
무료 오픈소스 터치스크린으로 USB 설정 가이드, 드래그‑드롭 편집기, 로컬 제어를 제공하며 Home Assistant용으로 제작되었습니다
Collaborative apartment comparison tool for small groups hunting for rentals together.
저장소 README에서 가져옴
README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
Compare apartments side-by-side without spreadsheets. Upload PDF listings, let AI pull out the numbers, rate places together with your household, see which one is the best deal at a glance — with the data encrypted in your browser before it ever reaches the server. Add a screenshot or short GIF of the apartments overview / compare page here once you have one — it sells the rest of the README. This is the part that shapes everything else in the cod [README 설치·사용 부분 발췌] Quickstart Open, sign in with the password you set, and choose an encryptioREADME 전문 보기 ↗
| 🔐 Encrypted in your browser | Every apartment, rating, location and uploaded PDF is sealed with AES-256-GCM under a key only your household holds. The server stores ciphertext it cannot read. |
| 📄 PDF → structured data | Drop in a Swiss listing PDF, Gemini extracts rent, size, rooms, address, summary. Edits you make are preserved across re-parses. |
| ⭐ Per-user star ratings | Each member rates kitchen, balconies, location, floorplan and overall. Averages roll up automatically — computed in the browser, since the server cannot read the scores. |
| 📊 Comparison grid | Sortable side-by-side table — best price, biggest, shortest commute — with hide/show per apartment. |
| 🚲 Auto distance | Bike + transit minutes from each apartment to your "locations of interest" (work, schools, family). |
| 🗺️ Map view | A map page with every apartment and place drawn by hand on a clean street map (MapLibre + OpenFreeMap), and a "View on map" button on each apartment that opens it with that apartment highlighted. Self-hosters can use their own map file instead, so the map contacts no one. |
| 👥 Households + invitations | Invite people by email; each member gets their own account and their own copy of the household key. |
| ☁️ Cloud or self-hosted | One repo runs on Vercel + Turso + Vercel Blob, or fully self-hosted with SQLite + local disk and no third-party accounts at all. |
This is the part that shapes everything else in the codebase, so it is worth 60 seconds.
CryptoKeys. The server stores wrapped blobs and never sees the passphrase.apartments, ratings and locations each hold one envelope column of ciphertext. Names, addresses, rent, coordinates, distances, rating scores and comments all live inside it. Only what the server needs to scope and order rows stays in the clear: ids, household_id, timestamps, version, sort order./api/process/* acts as a blind proxy: plaintext goes in, a third-party result comes out, nothing is written to any table. The server sees one address, one URL or one PDF per call, in memory. We therefore do not describe Flatpare as zero-knowledge — that claim would be false.FLATPARE_ENCRYPTION=off for a self-hosted install that does not want key management. The choice is stamped into the database on first boot and cannot be changed afterwards without starting fresh.Accepted limits and the full threat model are in docs/security-notes.md.
| Layer | Technology |
|---|---|
| Framework | Next.js 16 App Router (Server Components + Route Handlers) |
| Language | TypeScript 6, React 19 |
| Styling | Tailwind CSS 4, shadcn/ui |
| Database | Drizzle ORM over libSQL — Turso (cloud) or local SQLite |
| Crypto | WebCrypto (AES-256-GCM, RSA-OAEP-3072) + hash-wasm Argon2id |
| Auth | Auth.js v5 — Google / GitHub OAuth, or a shared password when self-hosting |
| File storage | Vercel Blob (cloud) or ./uploads/ (local) — ciphertext either way |
| AI | Vercel AI SDK + Google Gemini 2.5 Flash |
| Maps | Google Maps (Geocoding + Distance Matrix) with OpenRouteService bike-distance fallback; MapLibre over OpenFreeMap for rendering; self-hosters can use a Planetiler extract of OpenStreetMap instead |
| Payments | Stripe embedded Checkout — one-time, optional, off unless configured |
| Tests | Vitest 5 + React Testing Library, coverage floors enforced in CI |
| Deploy target | Vercel (preferred) or any Node 24+ host |
cp .env.example .env.local # set AUTH_SECRET, APP_PASSWORD and FLATPARE_MAPS at minimum
docker compose up -d
FLATPARE_MAPS is where the map's streets come from: openfreemap, or local with your own map file in ./maps (how). Open http://localhost:3002, sign in with the password you set, and choose an encryption passphrase when prompted. That is the whole local path — SQLite, filesystem uploads, manual entry, no cloud accounts and no billing.
Port note: the container listens on 3000;
docker-compose.ymlpublishes that to host port 3002. Override the host side withPORT=8080 docker compose up -d. The container side is fixed.
git clone https://github.com/brlauuu/flatpare.git
cd flatpare
npm install
cp .env.example .env.local # set AUTH_SECRET + APP_PASSWORD
npm run db:push # creates ./data/flatpare.db
npm run dev # http://localhost:3002
Drizzle migrations also run automatically at boot via src/instrumentation.ts, so a first-time npm run dev is usually enough on its own.
npm run devfollowsTURSO_DATABASE_URL. If.env.localsets it, the dev server writes to that cloud database, not to./data/flatpare.db. RunTURSO_DATABASE_URL= npm run devto force the local file. The app prints which one it picked at boot, and warns when a non-production build is pointed at the cloud.
Prerequisites: Node 24 LTS, a Turso database, a Google AI Studio key, and (optional) a Google Maps Platform key.
# Turso DB
curl -sSfL https://get.tur.so/install.sh | bash
turso auth signup
turso db create flatpare
turso db show flatpare --url # libsql://...
turso db tokens create flatpare # token
Get a Gemini key at https://aistudio.google.com/apikey. For Maps Platform enable Geocoding API and Distance Matrix API in Google Cloud Console — see docs/google-apis.md. The Maps Embed API is not needed; maps are rendered with MapLibre.
Configure .env.local:
AUTH_SECRET=<generate with: npx auth secret>
APP_PASSWORD=<shared password>
TURSO_DATABASE_URL=libsql://flatpare-<you>.turso.io
TURSO_AUTH_TOKEN=<token>
GOOGLE_GENERATIVE_AI_API_KEY=<key>
GOOGLE_MAPS_API_KEY=<key, or omit>
Then:
npx drizzle-kit push
npm run dev
BLOB_READ_WRITE_TOKEN is only needed in production (Vercel adds it automatically).
Free, no credit card required. Sign up at openrouteservice.org/dev and add to .env.local:
OPENROUTESERVICE_API_KEY=<key>
Transit isn't supported — enter those minutes manually if you need them.
Import the repo in Vercel.
Project Settings → Environment Variables — see the Configuration table below for the full list. At minimum: AUTH_SECRET, an OAuth pair, TURSO_DATABASE_URL, TURSO_AUTH_TOKEN, GOOGLE_GENERATIVE_AI_API_KEY.
Register OAuth callback URLs with Google and GitHub before deploying — for the production domain and every Vercel preview URL. Nothing fails locally to warn you if this is skipped; the first production sign-in just fails at the provider's redirect.
Storage → Create → Blob to provision the blob store.
Push the schema once before first use:
TURSO_DATABASE_URL=... TURSO_AUTH_TOKEN=... npx drizzle-kit push
Deploy with vercel --prod (git-triggered deploys are disabled in vercel.json). The vercel-build script runs scripts/migrate.ts inside the build — the same preflights and migrator the app runs at boot — so a deploy that cannot migrate fails the build with the actionable message rather than going live broken.
⚠️ Upgrading an existing database
Several migrations cannot run against a populated database, and migrations execute at boot (
src/instrumentation.ts), so the app will not start until the situation is resolved. Preflight checks insrc/lib/db/migrate.tscatch each case and abort atomically with an actionable message rather than failing opaquely — existing data is untouched, just unreachable until you act.
- 0011 adds a
NOT NULL household_idto the pre-accounts data tables, which SQLite only permits on empty tables.- 0013 stamps
FLATPARE_ENCRYPTIONinto the database permanently. Ifapartmentsalready holds rows and the variable is unset, boot fails and asks you to choose explicitly —offkeeps existing rows readable,onstarts encrypting and makes them unreadable to a later release. The choice cannot be changed afterwards without a fresh database.- 0014 drops the plaintext data tables and replaces them with encrypted envelope tables. There is no data migration: a pre-encryption database must be emptied by hand first.
- 0015 adds a unique index on
users.emailand refuses to run if duplicates already exist, naming them — they cannot be merged automatically, since each owns a separate household.For a hosted deployment, wipe the database before deploying, not after: deploying first means the very first request triggers the failed boot-time migration.
The Vercel build runs the same checks (
scripts/migrate.tsviascripts/vercel-build.mjs), so on the hosted deployment they fail the build, before anything goes live (#211).
All env vars live in .env.local (loaded by Next.js) or your Vercel project settings. .env.example is the annotated source of truth.
| Variable | Required | Description |
|---|---|---|
AUTH_SECRET | yes | Auth.js session encryption key — generate with npx auth secret. |
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET | yes* | Google OAuth. Set both or neither — a CLIENT_ID with no CLIENT_SECRET registers a broken provider and disables the password fallback, locking everyone out. |
GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET | yes* | GitHub OAuth. Same both-or-neither rule. |
APP_PASSWORD | yes* | Shared password for the self-host credentials provider — registered only when no OAuth _CLIENT_ID is set. |
FLATPARE_ENCRYPTION | optional | on (default when unset) or off. Stamped into the database on first boot; changing it later means a fresh database. |
* Exactly one auth path is required: AUTH_SECRET + a Google or GitHub OAuth pair (hosted), or AUTH_SECRET + APP_PASSWORD with no OAuth vars set (self-host). There is no DISABLE_SECURE_COOKIES var — @auth/core derives the session cookie's Secure flag from the request protocol, so plain-HTTP self-hosting works with no config.
| Variable | Required | Description |
|---|---|---|
TURSO_DATABASE_URL | cloud | Turso database URL. Truthiness of this variable decides which database is live; unset (or explicitly empty) falls back to the local SQLite file. |
TURSO_AUTH_TOKEN | cloud | Turso auth token. |
BLOB_READ_WRITE_TOKEN | cloud | Vercel Blob token (auto-set by Vercel). Unset falls back to ./uploads/. |
LOCAL_DB_URL | optional | Override the SQLite path (defaults to file:./data/flatpare.db). |
| Variable | Required | Description |
|---|---|---|
AI_PROVIDER | optional | google (default) or gateway. Which backend reads PDFs and writes summaries. See AI and your data. |
GOOGLE_GENERATIVE_AI_API_KEY | optional | Gemini key for AI_PROVIDER=google. Without it, manual entry only. |
AI_GATEWAY_API_KEY | optional | Vercel AI Gateway key for AI_PROVIDER=gateway outside Vercel (on Vercel, OIDC is used). |
AI_MODEL | optional | Model override. Defaults to gemini-2.5-flash (google) or google/gemini-2.5-flash (gateway). |
AI_ZERO_DATA_RETENTION | optional | Gateway only. Unset or true requires zero data retention on every request; false opts out (needed on a Vercel Hobby plan). |
GOOGLE_MAPS_API_KEY | optional | Geocoding + Distance Matrix. |
OPENROUTESERVICE_API_KEY | optional | Bike-distance fallback when the Maps key is unset. |
PARSE_PDF_MAX_BYTES | optional | Upload ceiling for PDF extraction. Defaults to 20 MB (Gemini's inline-file limit). |
Reading a listing PDF and writing its summary is the one job sent to an AI model. Pick the backend with AI_PROVIDER:
google (default) — Gemini directly, with your GOOGLE_GENERATIVE_AI_API_KEY. No extra account. Google's Gemini API terms depend on your tier: on the unpaid tier Google may use what you send to improve its products and human reviewers may read it; on the paid tier (billing enabled) it is not used to improve products, but is logged for a limited period for abuse monitoring. The Gemini API has no zero-retention setting.gateway — Vercel AI Gateway with zero data retention required on every request: Vercel keeps nothing, and the request goes only to a provider with a zero-retention agreement (Gemini via Google Vertex AI) or fails. Needs a Vercel Pro or Enterprise plan; set AI_ZERO_DATA_RETENTION=false to use the gateway without it.The app logs which one is live at boot: [ai] gateway — google/gemini-2.5-flash, zero data retention on. The hosted flatpare.com runs gateway.
Every ceiling is opt-in, because a self-hoster spends their own resources. There is no tier dimension: these apply to every household. Anything that is not a positive integer fails loudly rather than being coerced.
| Variable | Description |
|---|---|
PROCESS_RATE_LIMIT_PER_HOUR | Per-household hourly ceiling on each /api/process/* endpoint. A hosted deployment wants a value here — these endpoints spend the host's Gemini and Maps budget. |
MAX_MEMBERS | Members per household. |
MAX_APARTMENTS | Apartments a household may hold at once. |
The hosted deployment sets
MAX_MEMBERS=10andMAX_APARTMENTS=40— that is what the landing page sells. Leaving them unset there silently grants paying customers unlimited use and makes the pricing copy untrue.
STRIPE_SECRET_KEY is set| Variable | Description |
|---|---|
STRIPE_SECRET_KEY | Enables billing. Unset (the self-host default) means no paywall, no quota enforcement, and apartments_ever_added is never incremented — so switching billing on later does not bill anyone for their history. |
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY | Client key for embedded Checkout. |
STRIPE_WEBHOOK_SECRET | Signing secret for POST /api/billing/webhook. Not injected by the Vercel integration — create the endpoint in the Stripe dashboard and copy it. The webhook is the only thing that grants credits, so a deployment missing this takes money and grants nothing. |
| Variable | Description |
|---|---|
NEXT_PUBLIC_SITE_URL | Canonical origin for metadata (canonical, Open Graph, Twitter). Falls back to Vercel's production URL, then http://localhost:3002. Nothing routes off it. |
FLATPARE_PUBLIC_ACCESS | closed refuses any sign-in that would create an account unless the browser holds a beta pass or the address has a pending invitation. Existing accounts keep signing in and the landing page stays up. Unset means open (the self-host default). Mint passes with node scripts/beta-pass.mjs create — it loads .env.local, says which database it is about to touch, and prints a /beta/<code> link to share; list and revoke <code> manage them. The tables exist only once the app has booted this release against that database. |
Flatpare uses Auth.js v5 (src/auth.ts) with real per-user accounts, backed by the users / accounts / sessions tables.
_CLIENT_ID is set — the hosted path — or a shared-password credentials provider when neither is set, so docker compose up works with zero third-party setup. The password path is never registered once OAuth is configured, so it cannot become a back door.src/proxy.ts is the primary gate (Next.js 16's replacement for middleware.ts), requiring both a user id and a household on the session. /api/auth/* is allow-listed wholesale — Auth.js owns that namespace end to end. POST /api/billing/webhook is allow-listed too and is the one endpoint unauthenticated by design: Stripe has no session, so its signature is its only authentication.requireMember() (src/lib/api-route.ts) re-checks membership against household_members on every data request, reads included, and answers 404 rather than 403 for a foreign row — a 403 would confirm the row exists in someone else's household.Full detail, including the JWT staleness reasoning and accepted advisories, lives in docs/security-notes.md and AGENTS.md.
Optional, and entirely absent unless STRIPE_SECRET_KEY is set.
mode: "payment"), never a subscription. The amount lives in src/lib/stripe.ts beside the copy that advertises it.payments primary key, which is the Stripe event id, so a redelivery is a no-op rather than a second grant.src/
app/
page.tsx # Landing page (marketing + sign-in)
_components/landing.tsx # Landing copy — see AGENTS.md before editing
login-form.tsx # Client-side sign-in form
apartments/ # List view, [id] detail, new/ upload+parse+review
compare/ # Side-by-side comparison grid
settings/ # Household, members, encryption, locations of interest
invitations/ # Accept / decline (for users without a household)
billing/ # Embedded Stripe Checkout (outside the crypto gate)
guide/ # In-app user guide (renders src/content/guide.<locale>.md)
api/ # Route handlers — see API surface below
components/
crypto/ # Setup / unlock / recovery screens + CryptoProvider
household-data/ # The client store: codec + fetch + provider
ui/ # shadcn/ui primitives
content/guide.<locale>.md # User guide source, one per language
lib/
crypto/ # KDF, envelopes, key wrapping, device key store
household-data/ # Plaintext types, schemas, wire rows, codec, derivation
db/ # Drizzle schema, client, migration runner + preflights
api-route.ts # requireMember(), parseBody(), apiErrorResponse()
session.ts # requireHousehold() — session + tenant-scope check
household.ts # assertMembership(), UnauthorizedError, ForbiddenError
billing.ts, stripe.ts # Credits ledger, Stripe client + price
rate-limit.ts, safe-url.ts # Blind-proxy hardening
limits.ts, env-int.ts # Entitlement caps
parse-pdf.ts, geocode.ts # Third-party integrations
distance.ts, storage.ts # ...
auth.ts # Auth.js v5 config — providers, JWT session, household claims
instrumentation.ts # Boot hook — runs Drizzle migrations
proxy.ts # Auth gate (Next.js 16 renamed middleware.ts)
drizzle/ # SQL migrations
docs/ # google-apis.md, security-notes.md
The three data routes — apartments, ratings and locations — are requireMember() → validate → work, and answer 404 for unknown and foreign rows. The crypto, invitation and household routes use requireHousehold() instead; the three invitation routes marked below run for a signed-in user who has no household yet.
| Method | Endpoint | Description |
|---|---|---|
* | /api/auth/* | Auth.js — sign-in, OAuth callback, session, CSRF (owned by next-auth, not app code) |
GET POST | /api/apartments | List / create (encrypted envelopes) |
PUT DELETE | /api/apartments/[id] | Update (optimistic-concurrency version) / delete |
PUT DELETE | /api/apartments/[id]/ratings/me | Upsert / clear your own rating — the only rating writes |
GET | /api/ratings | All ratings in the household |
GET POST | /api/locations | List / create locations of interest (max 5) |
PUT DELETE | /api/locations/[id] | Update / delete |
POST | /api/locations/[id]/move | Reorder |
GET | /api/crypto/status | Key state — the one crypto route that answers when encryption is off |
POST | /api/crypto/setup | Create the member key pair + household data key |
PUT POST | /api/crypto/member-keys, /api/crypto/wraps | Publish a public key / wrap the data key to a member |
POST | /api/crypto/member-keys/reset | Re-key a member |
GET | /api/crypto/pending-wraps | Members awaiting a wrap |
PUT POST | /api/crypto/recovery, /api/crypto/recover | Regenerate / redeem the recovery kit |
GET POST |
The /api/process/* routes never write to any table (the one exception is the rate-limit counter, which holds no request content). That is the privacy exception described under How the encryption works.
npm test # one-shot
npm run test:watch # watch mode
npm run test:coverage # what CI runs
npm run lint
npm run typecheck
Vitest + React Testing Library. Tests live next to their source in colocated __tests__/ directories, and each test file runs against its own copy of a pre-migrated database, in parallel.
CI fails if coverage drops below the floors in vitest.config.mts: lines ≥ 80 %, statements ≥ 80 %, functions ≥ 78 %, branches ≥ 75 %. Those floors apply to every source file, not only the ones a test imports — a wholly untested new file counts against the average.
npm run db:generate # author a new migration
npm run db:push # push the schema directly (dev convenience)
npm run db:migrate # apply pending migrations
npm run db:studio # browser-based DB inspector
Schemas in src/lib/db/schema.ts, migrations in drizzle/. The runner in src/lib/db/migrate.ts is invoked automatically by src/instrumentation.ts at server start, and carries the preflight checks described under Upgrading an existing database.
docker compose up -d # default — host 3002 → container 3000
PORT=8080 docker compose up -d # different host port
# Rebuild after pulling updates
git pull
docker compose build
docker compose up -d
Data persists in two named volumes: flatpare-data (SQLite database) and flatpare-uploads (encrypted PDFs).
The map's streets come from where FLATPARE_MAPS says, and the container will not start until you set it. openfreemap loads them from OpenFreeMap, which sees the area on screen but never your data. local reads one map file from ./maps and contacts no one; maps/README.md explains how to get a file (a ready-made one for Switzerland, or scripts/build-map.sh <region> for anywhere else).
main.npm test), lint clean (npm run lint) and types clean (npm run typecheck).main is protected and all PRs run the test workflow.The repo uses Vitest, Drizzle, and Next 16-specific conventions, and several of them are load-bearing in ways a linter will not tell you about — read AGENTS.md before touching the proxy, instrumentation, migration, crypto or billing paths. It is terse on purpose and worth the 5-minute scan.
O'SAASY — source-available. Self-hosting and modification are fine; offering Flatpare to third parties as a competing hosted service is not.
Flatpare is built and maintained by brlauuu in collaboration with Claude Code by Anthropic — designed, debugged, and refactored together over many evenings.
/api/invitations |
| List / send |
DELETE | /api/invitations/[id] | Revoke |
POST | /api/invitations/[id]/accept | Accept (no household required) |
POST | /api/invitations/decline | Decline (no household required) |
GET | /api/invitations/mine | Invitations addressed to you (no household required) |
GET | /api/household/members | Household roster |
DELETE | /api/household/members/[userId] | Remove a member |
POST | /api/process/parse-pdf | Blind proxy — PDF in, extracted fields out |
POST | /api/process/geocode | Blind proxy — address in, coordinates out |
POST | /api/process/distance | Blind proxy — two points in, minutes out |
POST | /api/process/check-listing | Blind proxy — URL in, alive/expired out |
POST | /api/files | Multipart upload of an encrypted PDF (local-disk fallback path) |
GET POST | /api/files/upload-token | Issue Vercel Blob client-upload tokens (large files) |
GET | /api/pdf/[...path] | Stream ciphertext from Blob to a member |
GET | /api/uploads/[...path] | Stream ciphertext from local disk to a member |
POST | /api/billing/checkout | Create an embedded Checkout session |
GET | /api/billing/status | Credits granted / remaining |
POST | /api/billing/webhook | Stripe events — unauthenticated by design, signature-verified |