Heads up — this is work in progress. Scaffold only. No app is wired up end-to-end yet; nothing here is ready to run in production, and not even a
v0release has been cut. Expect breaking changes on every commit. Pin nothing.
A self-hosted "fleet of family apps" platform. Single Bun process, single Docker image, mobile-first PWA, behind Caddy on a Hetzner VPS. First app: grocery list. Not publicly branded — internal infrastructure only.
License: Elastic License 2.0 — free for self-hosted personal / internal use; cannot be offered as a managed third-party service.
See the full architecture spec for context and rationale.
- Runtime: Bun (latest stable)
- HTTP: Hono v4 — serves API + MCP + static React in one process
- Frontend: React 19 + Vite + TanStack (Router, Query, Form) + Tailwind v4
- UI: shadcn/ui (copy-paste, in
apps/web/src/components/ui/) + Vaul (drawers) + Sonner (toasts) + Phosphor Icons - DB:
bun:sqlitevia Drizzle ORM (beta) - Auth: Better Auth — Google sign-in (+ One Tap) for people; an OAuth 2.1
provider (
@better-auth/oauth-provider+ JWT) for MCP clients like Claude - Validation: Valibot at HTTP boundaries, Zod inside MCP tool defs only
- Tests:
bun:test; Playwright for end-to-end - Package manager: pnpm 11 (hardened defaults — see
.npmrcandpnpm-workspace.yaml)
onehouse/
├── apps/ # Leaves in the import DAG (nothing imports these)
│ ├── server/ # Single Bun runtime. Composition only.
│ │ └── src/
│ │ ├── composition.ts
│ │ └── composition.test.ts # ← tests sit next to the code they cover
│ └── web/ # React + Vite + PWA
│ ├── src/{components,features,routes,lib}/
│ └── e2e/ # Playwright — the only non-colocated tests
├── packages/ # Internal libraries — imported by apps and each other
│ ├── core/ # Platform plumbing
│ │ └── src/
│ │ ├── shared/ # Branded IDs, Result, isomorphic helpers (browser-safe)
│ │ │ ├── ids.ts
│ │ │ └── ids.test.ts
│ │ └── server/ # Better Auth, db, MCP plumbing, middleware
│ └── app-grocery/ # First app — four subpaths enforce the boundary
│ └── src/
│ ├── shared/ # State machines, Valibot, types (browser-safe)
│ ├── server/ # Drizzle schema, Hono routes, services
│ ├── tools/ # MCP tool definitions
│ └── ui/ # React components reused across entry points
├── drizzle/ # Generated migrations (committed)
├── data/ # SQLite + WAL (Docker volume)
├── scripts/ # migrate.ts, backup, etc.
├── .claude/ # Agent rules (root + per-topic)
└── .github/workflows/ # CI + deploy
Unit and integration tests are colocated: foo.test.ts lives next to
foo.ts in the same directory. The only exception is Playwright E2E, which
runs against the built Docker image and lives in apps/web/e2e/.
Each packages/app-* exports four subpaths:
| Path | What | Browser? |
|---|---|---|
./shared |
state machines, types, Valibot schemas | yes |
./server |
Drizzle schema, Hono routes, services | no |
./tools |
MCP tool definitions | no |
./ui |
React components reusable across entry points | yes |
The frontend imports /shared and /ui only. Importing /server or /tools
from browser code fails to type-check.
- Bun (latest stable)
- pnpm 11+ (
npm install -g pnpm@latest) - Docker + Docker Compose (for the dev container or prod build)
- A Google Cloud project with an OAuth 2.0 Client ID (for sign-in)
# 1. Install (uses pnpm; Bun runs the code)
pnpm install
# 2. Configure
cp .env.example .env
# edit .env — see "Auth setup" below
# 3. Install git hooks (one-time; lefthook writes .git/hooks/* — `.npmrc`
# has `ignore-scripts=true`, so this is NOT done automatically by install)
pnpm hooks:install
# 4. Run pending migrations (auth schema is committed, drizzle/0000_*.sql too)
pnpm db:migrate
# 5. Dev (host) — server (3000) + Vite (5173) with hot reload
pnpm dev
# OR Dev (Docker) — bind-mounted hot reload
docker compose --profile dev upOpen http://localhost:5173.
Sign-in goes through Google with an email allowlist enforced inside Better
Auth. Without the allowlist, a Google login is rejected with 403 before
any user row is created.
- Open https://console.cloud.google.com/apis/credentials
- Create an OAuth 2.0 Client ID, type Web application
- Authorized JavaScript origins:
http://localhost:5173
- Authorized redirect URIs:
http://localhost:5173/api/auth/callback/google
- Copy the Client ID + Client Secret into
.env:GOOGLE_ID=<client id> GOOGLE_SECRET=<client secret>
In production, repeat with your real public URL (e.g. https://onehouse.example.de).
BETTER_AUTH_URL=http://localhost:5173 # public origin the browser hits
BETTER_AUTH_SECRET=<openssl rand -hex 32> # 32+ random hex chars
GOOGLE_ID=<from Google Cloud>
GOOGLE_SECRET=<from Google Cloud>
# Comma-separated allowlist. Sign-in is rejected for any other email.
# Matched case-insensitively after trimming.
ONEHOUSE_ALLOWED_EMAILS=basile@example.com,partner@example.com
# Public Host the MCP endpoint is reached at (DNS-rebinding allowlist). The
# server also allows the BETTER_AUTH_URL host + localhost:$PORT automatically,
# so local dev needs no change. In prod set it to your domain.
MCP_HOST=localhost
DATABASE_PATH=./data/app.dbBETTER_AUTH_URL is the user-visible origin. In dev it's the Vite server
(:5173), which proxies /api/* → the Hono server on $PORT. Cookies are
scoped to that origin, so Google's redirect must come back there too.
Better Auth runs databaseHooks.user.create.before immediately after Google
returns a verified profile, before any user row is inserted. The hook
checks the email against ONEHOUSE_ALLOWED_EMAILS (parsed once at boot)
and throws APIError("FORBIDDEN") if it isn't a match.
Result: an unauthorised email gets a 403 on the callback, the users /
accounts / sessions tables stay clean, and no session cookie is issued.
To add a new family member:
- Append their Google account email to
ONEHOUSE_ALLOWED_EMAILSin.env - Restart the server (
pnpm devre-reads env onbun --hotrestart) - They click "Continue with Google"
pnpm dev(ordocker compose --profile dev up)- Open http://localhost:5173
- Click "Continue with Google"
- Approve on Google's screen
- Redirected back to
/; subsequent/api/*requests carry theonehouse.session_tokencookie
If you're not on the allowlist, step 5 returns 403; the URL will show
the Better Auth error code. Fix: add your email to ONEHOUSE_ALLOWED_EMAILS
and try again.
The grocery list is exposed to Claude (Desktop or claude.ai) as a remote MCP
server at /mcp, secured with OAuth 2.1. Better Auth is the authorization
server: Claude self-registers via Dynamic Client Registration, you sign in with
Google and approve a consent screen (/consent), and Claude receives a scoped
bearer token. Each tool call is recorded in the audit log as { userId, via: "mcp" }.
Tools: grocery.list_items, grocery.add_item, grocery.mark_purchased,
grocery.mark_pending, grocery.update_item, grocery.remove_item.
pnpm dev # Hono :3000 + Vite :5173
pnpm dlx @modelcontextprotocol/inspector # opens the Inspector UIConnect to http://localhost:5173/mcp (transport: Streamable HTTP). The
Inspector runs the OAuth flow in your browser — Google sign-in → /consent →
back — then lists and calls the grocery tools. localhost is exempt from the
MCP HTTPS requirement, so no TLS is needed locally.
Settings → Connectors → "Add custom connector", URL https://<your-domain>/mcp.
Set BETTER_AUTH_URL=https://<your-domain> and MCP_HOST=<your-domain> (behind
Caddy). Claude finds the OAuth endpoints via
/.well-known/oauth-protected-resource, registers itself, and walks you through
the same Google sign-in + consent. Remote connectors require HTTPS.
| Command | What |
|---|---|
pnpm dev |
Server + Vite, hot reload |
pnpm test |
bun test |
pnpm typecheck |
tsc -b --noEmit across the workspace |
pnpm check |
Biome lint + format check (incl. import order) |
pnpm check:source |
No-bare-as + sorted-named-exports (TS AST) |
pnpm lint:fix |
Biome auto-fix (organizes imports, etc.) |
pnpm hooks:install |
Install lefthook git hooks (one-time) |
pnpm auth:generate |
Regenerate Better Auth Drizzle schema; commit |
pnpm db:generate |
Generate Drizzle migration from schema diff |
pnpm db:migrate |
Apply pending migrations |
pnpm license:check |
Verify all deps are permissive (MIT/Apache/BSD) |
bunx shadcn@latest add X |
Add a shadcn component (run in apps/web) |
docker build --target runtime -t onehouse .
docker run -v ./data:/data --env-file .env -p 3000:3000 onehouseOr via compose (default profile is prod):
docker compose up -dThe production image runs one Bun process serving /api/auth/*, /api/*,
/mcp, and the static React bundle.
Managed by lefthook (lefthook.yml).
Run pnpm hooks:install once after pnpm install (.npmrc has
ignore-scripts=true, so lefthook does NOT install itself automatically).
pre-commit (parallel):
- Biome lint + format on staged
.ts/.tsx/.json/.css(auto-fix, re-stages) check-sourceAST check (no bareas, sorted named exports) on staged.ts/.tsxtsc -b --noEmitacross the workspace (types are cross-file)
Every UI component is designed for a 360×780 viewport first and adapts up via
Tailwind breakpoints. Drawer (Vaul) is the default modal pattern; centered
Dialog is desktop-only. Full checklist in .claude/rules/mobile.md.
Working with Claude Code? Read .claude/CLAUDE.md first. Per-topic rules live
in .claude/rules/. AGENTS.md is a symlink to the root rules for cross-tool
compatibility.
mkdir packages/app-<name>, copy theapp-groceryskeleton- Add tables in
src/server/schema.ts, thenpnpm db:generate - State machine in
src/shared/state.ts; Valibot schemas insrc/shared/validation.ts - Add the four
exportssubpaths topackage.json - Three one-liners:
apps/server/src/composition.ts→.route("/api/<name>", routes)apps/server/src/mcp.ts→register<Name>Tools(server, ctx)apps/web/src/router.tsx→ register/<name>routes
- Rebuild image, deploy
No core changes required.