Add user accounts with first-run onboarding and per-user preferences

Replaces the optional shared-password auth (AUTH_PASSWORD env var,
in-memory sessions) with account-based auth: a users table (scrypt
password hashes via node:crypto — no native dep), SQLite-backed sessions
that survive restarts, a one-time setup screen that creates the first
account, and a Settings → Users card for adding/removing users and
resetting passwords (everyone equal, shared herd — no roles).

Theme and weight-unit become per-user preferences behind the unchanged
/api/settings shape (NULL inherits the global value); rabbitry name, kit
prefix, and default breed stay shared. Failed logins get a flat 350 ms
delay and timing-safe comparison. MYKINDLEBOX_PASSWORD is retired —
upgrading deployments see the setup screen once, data untouched.

Also: configurable host port (MYKINDLEBOX_PORT), compose healthcheck
gating nginx on API readiness, nginx body-size headroom for 10 MB
imports, server .dockerignore hardening, ESLint ignore for vendored
tesseract assets, react-hooks rule fixes, exact tesseract.js pin with SW
cache-upgrade contract and failure-isolated precache, and OCR date
parsing fixes (2-digit-year pivot, impossible-date rejection).

Design doc: memory-bank/FEATURE_User_Accounts.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-07-02 16:38:16 -04:00
parent 1368dc5ec4
commit 2a0e347a19
21 changed files with 2095 additions and 152 deletions

View File

@ -30,7 +30,7 @@ Frontend (`mykindlebox/client`):
- `npm run build` — production build to `dist/`
- `npm run lint` — ESLint
Deployment: `docker compose up --build` from `mykindlebox/` serves the app on port 8080 by default (nginx serves the built client and reverse-proxies `/api` + `/uploads` to the Node server). Configure via `mykindlebox/.env`: `MYKINDLEBOX_PORT` overrides the host port, and `MYKINDLEBOX_PASSWORD` requires a login (if unset, auth is disabled).
Deployment: `docker compose up --build` from `mykindlebox/` serves the app on port 8080 by default (nginx serves the built client and reverse-proxies `/api` + `/uploads` to the Node server). `MYKINDLEBOX_PORT` in `mykindlebox/.env` overrides the host port. Auth is account-based (first visit shows a one-time account-creation screen; more users added in Settings) — the old `MYKINDLEBOX_PASSWORD`/`AUTH_PASSWORD` env var is retired and ignored.
### Conventions
@ -39,7 +39,7 @@ Deployment: `docker compose up --build` from `mykindlebox/` serves the app on po
## What MyKindlebox is
A self-hosted rabbitry (rabbit breeding herd) management web app, replacing an existing PHP app (`belowland.com/rabbitsite`). It is single-user, self-hosted, and out of scope for v1 includes multi-tenancy, public listing pages, show tracking, and payments.
A self-hosted rabbitry (rabbit breeding herd) management web app, replacing an existing PHP app (`belowland.com/rabbitsite`). It is self-hosted with **shared-herd user accounts** (users are separate logins to the same rabbitry — see `memory-bank/FEATURE_User_Accounts.md`; everyone equal, no roles). Out of scope: multi-tenancy (per-user herds), public listing pages, and payments.
The core differentiator: **adding a rabbit starts from a pedigree tree, not a blank form** — the 3-generation ancestor tree (sire/dam, their sires/dams) is the primary data-entry surface, with inline autocomplete search against existing herd records and graceful fallback to freeform text for unknown/bought-in ancestors.
@ -53,7 +53,7 @@ The core differentiator: **adding a rabbit starts from a pedigree tree, not a bl
| Styling | Tailwind CSS with custom theme tokens |
| Backend | Node.js + Express (REST API) |
| Database | SQLite via Drizzle ORM (single-file DB) |
| Auth | Single-user session token (no OAuth) |
| Auth | User accounts, shared herd (scrypt + SQLite sessions, no OAuth) |
| Hosting | Docker Compose (Nginx + Node + SQLite volume) |
## Data model

View File

@ -0,0 +1,89 @@
# Feature: User Accounts & Onboarding
Replaces the single shared-password auth (`AUTH_PASSWORD` env var, in-memory
session Set) with real user accounts. Decided with the user on 2026-07-02.
## Scope decisions (user-confirmed)
| Question | Decision |
|---|---|
| Data scope | **Shared herd** — users are separate logins to the same rabbitry. No ownership columns on rabbits/litters/shows; nothing about the data model changes. |
| Account creation | **In-app, no self-registration** — a fresh install shows a one-time "create your account" setup screen; after that, any signed-in user can add more users from Settings. No email/invite infrastructure. |
| Roles | **Everyone equal** — no admin flag. Any user can manage users and settings. (Trust model: family/partners on a LAN.) |
| Onboarding extras | **Per-user preferences only** — `theme` and `weight_unit` become per-user; `rabbitry_name`, `kit_prefix`, `default_breed` (and the internal `kit_counter`) stay global. No first-run settings wizard, no guided tour. |
## Consequences to be aware of
- **Auth becomes mandatory.** Today, unset `AUTH_PASSWORD` = open access. After
this feature, a fresh install requires creating an account, and every request
requires a session. The "no password = open" mode is retired.
- **`MYKINDLEBOX_PASSWORD` / `AUTH_PASSWORD` is retired.** Removed from
docker-compose and the README. An existing deployment that upgrades sees the
setup screen on next visit (its `users` table is empty); herd data is
untouched. The old env var is simply ignored.
- Because you must be signed in to delete a user and you **cannot delete
yourself**, the last account can never be deleted — no lockout state exists.
## Data model
```
users:
id integer PK autoincrement
username text NOT NULL UNIQUE COLLATE NOCASE
password_hash text NOT NULL -- "scrypt:<salt hex>:<hash hex>"
theme text -- NULL = inherit global settings value
weight_unit text -- NULL = inherit global settings value
created_at text NOT NULL default current_timestamp
auth_sessions:
token text PK -- randomUUID
user_id integer NOT NULL FK -> users.id ON DELETE CASCADE
expires_at text NOT NULL -- ISO; 30 days from login
created_at text NOT NULL default current_timestamp
```
Sessions move from the in-memory Set to SQLite so a server restart/redeploy no
longer signs everyone out. Expired rows are deleted lazily whenever a session
check touches them. Deleting a user cascades their sessions (they're signed out
everywhere immediately).
## Password hashing
`node:crypto` `scryptSync` (N=16384 default params), 16-byte random salt,
64-byte key, stored as `scrypt:<salt>:<hash>`; verified with `timingSafeEqual`.
Deliberately **not** bcrypt/argon2 — no new native dependency, so the Alpine
Docker build stays exactly as it is. Failed logins get a flat ~350 ms delay as
cheap brute-force damping.
## API
- `GET /api/auth/status` → `{ configured, authenticated, user? }` —
`configured` now means "at least one user exists". `user` = `{ id, username }`
when authenticated.
- `POST /api/auth/setup` `{ username, password }` — creates the **first** user
and signs them in; 409 once any user exists. This is the onboarding endpoint.
- `POST /api/auth/login` `{ username, password }` (username case-insensitive).
- `POST /api/auth/logout` — deletes the session row.
- `GET /api/users` → `[{ id, username, created_at }]` (no hashes).
- `POST /api/users` `{ username, password }`.
- `PATCH /api/users/:id` `{ password }` — reset any user's password (everyone
equal; family trust model — also the "someone forgot theirs" path).
- `DELETE /api/users/:id` — 400 when `id` is the caller.
- **`/api/settings` keeps its exact shape**: GET merges the global rows with the
current user's non-NULL `theme`/`weight_unit` overrides; PATCH routes those two
keys to the user row and everything else to the settings table. Client
consumers (ThemeSync, useWeightUnit, Settings page) need no changes for prefs.
## Client
- `AuthProvider` gains a third gate: `configured: false` → `SetupScreen`
(create first account), else unauthenticated → `LoginScreen` (now
username + password), else app. Context exposes `{ user, logout }`.
- Settings gets a **Users** card: list users, add user, reset password, delete
(self-delete disabled). The Account card shows who is signed in.
- Password minimum: 8 chars, enforced on both setup and user creation.
## Out of scope (deliberate)
Roles/permissions, invite links, email anything, per-user data visibility,
password-strength meters, 2FA, audit logs.

View File

@ -19,7 +19,7 @@ docker-compose.yml — orchestrates both services for self-hosted deployment
|---|---|
| `index.ts` | Entry point. Creates the Express app, applies `cors()` (allow-all — added in Phase 4 Step 4.3 so the Vite dev server on port 5173 can call this API cross-origin; revisit with a stricter allowlist once auth exists) and `express.json()`, imports `db/client` (so the DB connection/file is initialized on startup), mounts all routers, and starts listening on port 3001. |
| `db/client.ts` | Opens the SQLite connection and exports the Drizzle `db` instance used by all query code. Ensures `server/data/` exists before opening the file — `better-sqlite3` will not create missing parent directories on its own. The DB file lives at `server/data/mykindlebox.db`. On startup it now (Step 2.5) calls `drizzle-orm/better-sqlite3/migrator`'s `migrate()` to apply any pending migrations, then seeds the two default `settings` rows (`rabbitry_name`, `weight_unit`) via raw `INSERT OR IGNORE` keyed on the `key` primary key — idempotent across restarts. Also exports `dataDir` and `uploadsDir` (`data/uploads/`, created here) so other modules (`routes/rabbits.ts`'s multer config, `index.ts`'s static file serving) share one canonical path instead of each recomputing it. |
| `db/schema.ts` | Drizzle schema definitions: `rabbits` (24 base fields incl. `acquired_date`/`death_date` — the two timeline anchors that aren't derivable, see the rabbit-timeline note below; 7 genetics loci columns, 14 chain-authority `_name_text` ancestor columns; `sex`/`status` enforced via SQLite `CHECK` constraints, not just TS union types; `sire_id`/`dam_id` self-referencing FKs), `litters` (`sire_id`/`dam_id` FKs to `rabbits.id`, `breed_date`, `kindle_date`, `kit_count`, `notes`, `created_at` — a litter row spans the breeding→kindle lifecycle: `breed_date` is set when a breeding is recorded, `kindle_date` is nullable until the doe kindles; at least one of the two is required, enforced in the route), `rabbit_events` (`rabbit_id` FK with `ON DELETE cascade`, `type` (`show`/`sale`/`other`, CHECK-constrained), `event_date`/`title` notNull, `notes`, plus `show_class`/`placement` which are only meaningful for `type='show'` — the manually-logged events behind the rabbit timeline; see the rabbit-timeline note below), and `settings` (`key` text primary key, `value` text — simple key-value store, not a typed config object). The `rabbits` table also carries a nullable `breed` column (added for the Shows feature's entries/report, but surfaced everywhere a rabbit is edited/displayed). `arba_shows_cache` (`id`, `fetched_at`, `data` — a JSON blob of the normalized ARBA show list; only ever one row, the latest scrape; see `lib/arba.ts` + `routes/shows.ts`). `show_events` (`name` notNull, `notes`, `created_at`/`updated_at` — a user-created grouping of ARBA shows; **no `user_id`**, single-user app) and `show_event_shows` (`show_event_id` FK with `ON DELETE cascade`, plus a **snapshot** of the ARBA row at add-time: `sanction_number`/`club_name` notNull, `show_date`/`show_type`/`specialty_breed`/`city`/`state`/`address`/`secretary`/`secretary_email`/`catalog_url`/`maps_url`/`display_order` — copied rather than referencing the cache because ARBA listings are transient). `show_event_entries` (a herd rabbit entered into a specific show: `show_event_show_id` FK `ON DELETE cascade`, `rabbit_id` FK to `rabbits` **without** cascade, `entry_class`/`entry_number`/`notes` free text, `created_at`). `show_event_judges` (`show_event_show_id` cascade FK, `judge_name` notNull, `judge_arba_number`/`notes`). `show_event_placements` (one per entry — `entry_id` is a **UNIQUE** cascade FK, making the PUT endpoint a clean upsert; `placement` free text, `legs_earned` int 0–3, `bob`/`bos`/`bis`/`ris` boolean-mode integers, `notes`). The Shows feature's data model is now complete (Phases 1–4). |
| `db/schema.ts` | Drizzle schema definitions: `rabbits` (24 base fields incl. `acquired_date`/`death_date` — the two timeline anchors that aren't derivable, see the rabbit-timeline note below; 7 genetics loci columns, 14 chain-authority `_name_text` ancestor columns; `sex`/`status` enforced via SQLite `CHECK` constraints, not just TS union types; `sire_id`/`dam_id` self-referencing FKs), `litters` (`sire_id`/`dam_id` FKs to `rabbits.id`, `breed_date`, `kindle_date`, `kit_count`, `notes`, `created_at` — a litter row spans the breeding→kindle lifecycle: `breed_date` is set when a breeding is recorded, `kindle_date` is nullable until the doe kindles; at least one of the two is required, enforced in the route), `rabbit_events` (`rabbit_id` FK with `ON DELETE cascade`, `type` (`show`/`sale`/`other`, CHECK-constrained), `event_date`/`title` notNull, `notes`, plus `show_class`/`placement` which are only meaningful for `type='show'` — the manually-logged events behind the rabbit timeline; see the rabbit-timeline note below), and `settings` (`key` text primary key, `value` text — simple key-value store, not a typed config object). The `rabbits` table also carries a nullable `breed` column (added for the Shows feature's entries/report, but surfaced everywhere a rabbit is edited/displayed). `arba_shows_cache` (`id`, `fetched_at`, `data` — a JSON blob of the normalized ARBA show list; only ever one row, the latest scrape; see `lib/arba.ts` + `routes/shows.ts`). `show_events` (`name` notNull, `notes`, `created_at`/`updated_at` — a user-created grouping of ARBA shows; **no `user_id`**, single-user app) and `show_event_shows` (`show_event_id` FK with `ON DELETE cascade`, plus a **snapshot** of the ARBA row at add-time: `sanction_number`/`club_name` notNull, `show_date`/`show_type`/`specialty_breed`/`city`/`state`/`address`/`secretary`/`secretary_email`/`catalog_url`/`maps_url`/`display_order` — copied rather than referencing the cache because ARBA listings are transient). `show_event_entries` (a herd rabbit entered into a specific show: `show_event_show_id` FK `ON DELETE cascade`, `rabbit_id` FK to `rabbits` **without** cascade, `entry_class`/`entry_number`/`notes` free text, `created_at`). `show_event_judges` (`show_event_show_id` cascade FK, `judge_name` notNull, `judge_arba_number`/`notes`). `show_event_placements` (one per entry — `entry_id` is a **UNIQUE** cascade FK, making the PUT endpoint a clean upsert; `placement` free text, `legs_earned` int 0–3, `bob`/`bos`/`bis`/`ris` boolean-mode integers, `notes`). The Shows feature's data model is now complete (Phases 1–4). **User accounts** (`FEATURE_User_Accounts.md`): `users` (`username` notNull UNIQUE — case-sensitive index, case-insensitive enforcement lives in `lower()` queries; `password_hash` notNull `"scrypt:<salt>:<hash>"`; nullable per-user `theme`/`weight_unit` prefs — NULL inherits the global settings value; `created_at`) and `auth_sessions` (`token` text PK, `user_id` FK with a **declarative-only** cascade — the app runs with SQLite `foreign_keys` OFF, so `routes/users.ts` deletes sessions explicitly; `expires_at` ISO, purged lazily on session checks). |
| `db/migrations/` | Generated by `drizzle-kit generate`, applied either manually via `drizzle-kit migrate` (CLI) or automatically on server startup via `db/client.ts`'s in-code `migrate()` call. Both share the same `__drizzle_migrations` tracking table, so there's no double-apply risk mixing the two paths. Contains the SQL migration files (`0000_modern_power_pack.sql` — rabbits, `0001_military_lord_tyger.sql` — litters, `0002_luxuriant_the_stranger.sql` — settings) — these are committed and intentionally **not** excluded by `.dockerignore` (only `node_modules` and `data` are). |
| `drizzle.config.ts` | Points `drizzle-kit` at `./db/schema.ts` for migration generation and `./data/mykindlebox.db` as the target DB. |
| `tsconfig.json` | TypeScript config: CommonJS module output (matches `package.json`'s `"type": "commonjs"`), strict mode on, compiles to `dist/`. |
@ -109,4 +109,5 @@ docker-compose.yml — orchestrates both services for self-hosted deployment
- **Rabbit timeline = derived events + column-backed dates + a generic events table** (`RabbitTimeline.jsx`, post-Phase-11 feature): the per-rabbit timeline is built in `buildEvents()` by **merging** three sources, and which source a given event kind comes from is a deliberate design line — don't collapse them. (1) **Derived** (no storage): **birth** (`rabbit.dob`) and **breedings/kindles** (the `litters` where this rabbit is sire/dam — one "Bred with {partner}" event per `breed_date`, one "… kindled" event per `kindle_date`). Because breedings are auto-derived, the "Add Event" dropdown deliberately **omits Breeding** — logging one means creating a litter via the existing Add Breeding/Litter flow, not a manual timeline note (this was an explicit user decision). (2) **Column-backed**: **acquired** and **death** are nullable date columns on `rabbits` (`acquired_date` only renders when set *and* different from `dob`, since a same-day acquired date is just birth), edited through the normal rabbit `PATCH` (they're in `EditRabbit.jsx`'s `DETAIL_KEYS` and rendered by `RabbitDetailsForm.jsx`). (3) **Logged events**: the `rabbit_events` table (`routes/events.ts`) holds `show`/`sale`/`other` — the only kinds with their own CRUD UI, inline in the timeline card via the "Add Event" dropdown. **NOTE the history here**: this started as a `shows`-only table, then the user asked for a generic "Add Event" dropdown, so it was generalized to `rabbit_events` (migrations `0005_drop_shows` + `0006_add_rabbit_events` — drizzle-kit refused to auto-detect the rename non-interactively, so the table was dropped and recreated in two clean generates rather than one). A **sale** event also flips the rabbit's `status` to `sold` server-side (so the client invalidates `['rabbit']`/`['stats']` after one). Sorting is lexicographic on the ISO date string with `EVENT_TYPES[type].order` as the same-day tiebreaker (birth < acquired < breeding < kindle < show < sale < other < death).
- **ARBA scraping (Shows feature)** — the show listing comes from scraping `https://arba.net/show-search/`, and two facts about that page are load-bearing; don't "simplify" them away (`lib/arba.ts`): (1) ARBA is behind **Cloudflare** — a bare request returns a "you have been blocked" challenge page, so the scraper sends browser `User-Agent` + `Accept` headers (and deliberately omits `Accept-Encoding` so undici/global-fetch negotiates + decompresses). (2) The data is a WordPress **Ninja Tables** table; its ~2,000 rows are server-rendered into the page HTML as **positional `<td>` cells** (cells have no column classes — read by index 0–10: sanction, date, state, district, city, showInformation, address, maps-`<a>`, secretary, email, catalog-`<a>`). It was tempting to hit the Ninja Tables `admin-ajax.php` data endpoint instead, but for this CSV-backed table that endpoint returns an empty array — the rows only exist in the rendered HTML. Normalization drops cavy shows and non-US rows and parses `showInformation` into club/type/breed by anchoring on the show-type phrase (club names contain commas). Results are cached in `arba_shows_cache` (one row) and only re-scraped when >24h stale or force-refreshed; this matches ARBA's Mon–Fri update cadence and keeps us light on their Cloudflare. **All four phases are built** (the Shows feature is feature-complete per `memory-bank/mykindlebox-shows-design-doc.md`): Phase 1 = browse + cache (`routes/shows.ts`, `Shows.jsx`); Phase 2 = show events + the "Add to Event" flow (`routes/showEvents.ts`, `ShowEvents.jsx`/`ShowEventDetail.jsx`/`AddToEventModal.jsx`); Phase 3 = entries + entries-report (`show_event_entries`, the entry/report endpoints, the Entries tab + `RabbitPicker.jsx`/`ReportModal.jsx`); Phase 4 = judges + placements + Results tab (`show_event_judges`/`show_event_placements`, the judge/placement endpoints, the `JudgesSection`/`PlacementEditor`/`ResultsTab` UI). Not built (explicitly out of scope or deferred): the design doc's optional **Report Settings** (custom header/footer, toggles), **bulk multi-select** "Add selected to Event" from the browser (single per-row add is implemented instead), and judge **editing** UI (the PATCH endpoint exists but the card only adds/removes). Note throughout that the doc's `user_id`/`users` table and `tattoo`/`variety` fields don't exist here — single-user, with `ear_number`/`color_name`/the `breed` column instead.
- **OCR/Tesseract assets are vendored, not CDN-fetched, and the `eng.traineddata` is stored uncompressed** (OCR Pedigree Scanning feature, `public/tesseract/`): tesseract.js by default downloads its worker, WASM core, and language data from jsDelivr on first use — unacceptable for a self-hosted offline-first app, so all of it lives under `public/tesseract/` and the worker must be configured with explicit local paths. Two load-bearing facts when wiring up `ocrRun.js` (OCR Phase 3): (1) **`gzip: false` is required** alongside `langPath: '/tesseract/'`, because the vendored `eng.traineddata` is decompressed — the default `gzip: true` makes the worker request `eng.traineddata.gz`, which 404s. (2) **`corePath` should point at the directory `'/tesseract/'`, not a specific file**, so tesseract.js's `wasm-feature-detect` step picks the matching core (`relaxedsimd-lstm` → `simd-lstm` → `lstm`) for the running browser; all three `-lstm` `.wasm.js` cores are vendored for exactly this reason (the app uses the default `LSTM_ONLY` OEM, so the non-`-lstm`/`.asm` cores are never requested and aren't vendored). `workerPath` is `'/tesseract/worker.min.js'`. Don't re-point any of these at a CDN or "slim" the core set down to one file without re-checking the feature-detect branch in `tesseract.js/src/worker-script/browser/getCore.js`. The service worker precaches + cache-firsts these (see `public/sw.js` above).
- **Auth is account-based and always on** (`FEATURE_User_Accounts.md`, replacing the original optional shared-password mode): accounts live in `users`, sessions in `auth_sessions` (so restarts don't sign anyone out), passwords are scrypt-hashed via `node:crypto` (**deliberately not bcrypt/argon2** — no native dependency, so the Alpine Docker build is unchanged). All users share the herd; everyone equal (no roles). The first account is created through `POST /api/auth/setup`, which only works while the `users` table is empty — the client's `AuthProvider` shows a `SetupScreen` when `GET /api/auth/status` reports `configured: false`. Any signed-in user manages users at `/api/users` (Settings → Users card); you can't delete yourself, which transitively protects the last account. **`/api/settings` kept its exact single-object shape** but storage split: `theme`/`weight_unit` read/write the caller's `users` row (NULL falls back to the global settings value), the rest stay in the `settings` table — client consumers (ThemeSync, `useWeightUnit`, the Settings page) required no changes. The old `AUTH_PASSWORD`/`MYKINDLEBOX_PASSWORD` env var is retired and ignored (a startup warning fires if it's still set); an upgrading deployment sees the setup screen once and keeps all data. Failed logins get a flat 350 ms delay as brute-force damping.
- **Offline handling is one app-wide banner, not per-query state**: `Layout.jsx`'s `OfflineBanner` listens to the `online`/`offline` `window` events directly — it isn't wired to React Query's error state. This was a deliberate scope choice: every page already has its own `isError` fallback (so nothing crashes when a fetch fails offline), and threading a distinct "offline" message through every individual query would duplicate that existing handling for a cosmetic improvement only. If a future feature needs to distinguish "offline" from "other network error" *within* a specific page's content (not just a banner), that's new logic, not an extension of this banner.

View File

@ -300,3 +300,38 @@ All four modules are pure client-side libs under `client/src/lib/` with no serve
- **Quadrant split deviation (documented)**: the plan says split the grandparent column "at the horizontal and vertical midpoints of the column." Image *height* isn't carried through the pipeline, so `splitGrandparentQuadrants` instead takes the midpoints of the grandparent **words' own bounding extent** (min/max of their `x0/x1`, `y0/y1`) and assigns each word by its center — top-left→`sire_sire`, bottom-left→`sire_dam`, top-right→`dam_sire`, bottom-right→`dam_dam`, per the plan's mapping. Using the text extent (vs. band geometry) also better reflects where the four cells actually sit.
- **Validation so far**: `npm test` — 34 pass / 0 fail (15 new across the two test files). `npx eslint` clean on all six new files. `npm run build` succeeds (the new `tesseract.js` import resolves; it isn't pulled into the app bundle yet since nothing imports the pipeline until plan Phase 5). **Per the user's request, stopping here for them to validate the Phase 3 tests (especially the browser-only `runOCR`/`extractPedigree` paths against a real pedigree image) before OCR Phase 4 — the UI components — begins.**
## User Accounts & Onboarding (2026-07-02)
Replaced the optional shared-password auth with real user accounts — see
`memory-bank/FEATURE_User_Accounts.md` for the user-confirmed scope (shared
herd, in-app account creation, everyone equal, per-user theme/weight-unit).
- **Schema** (`0011_add_users.sql`): `users` (username UNIQUE, scrypt
`password_hash`, nullable per-user `theme`/`weight_unit`) + `auth_sessions`
(token PK, `user_id`, `expires_at` — persisted so restarts don't sign anyone
out; cascade is declarative-only since `foreign_keys` is OFF, so user deletion
removes sessions explicitly).
- **Server**: `auth.ts` rewritten — `hashPassword`/`verifyPassword`
(`node:crypto` scrypt, `timingSafeEqual`; deliberately no bcrypt/argon2 native
dep), DB sessions with lazy expiry purge, `requireAuth` attaches
`req.user`. New `POST /api/auth/setup` (first account only — 409 after),
username+password login with a flat 350 ms failed-login delay, and
`routes/users.ts` (list/create/reset-password/delete; can't delete self;
password reset invalidates the target's other sessions). `/api/settings`
kept its exact shape but `theme`/`weight_unit` now read/write the caller's
user row (NULL inherits the global value) — zero client-consumer changes.
- **Client**: `AuthProvider` gained a third gate (`configured: false` →
`SetupScreen`), the login screen takes username+password, context exposes
`user`, and Settings gained a `UsersCard` (add/reset/remove) plus
"Signed in as …" in the Account card.
- **Retired**: `AUTH_PASSWORD`/`MYKINDLEBOX_PASSWORD` (removed from compose +
README; the server logs a warning if it's still set). Upgrading deployments
see the one-time setup screen; herd data untouched.
- **Validation**: server tsc clean; client lint/41-tests/build clean. Full API
lifecycle driven over HTTP against a scratch DB: fresh-install status →
401 on protected routes → weak-password 400 → setup → second setup 409 →
wrong-password 401 (~400 ms) → case-insensitive login → duplicate-username
409 → per-user theme divergence with shared `rabbitry_name` → self-delete
400 → password reset killing the target's session → delete → logout →
**session surviving a server restart**.

View File

@ -13,5 +13,5 @@
| Styling | Tailwind CSS | Custom theme tokens |
| Backend | Node.js + Express | REST API |
| Database | SQLite (via Drizzle ORM) | Single-file DB, easy backup |
| Auth | Single-user session token | Self-hosted, no OAuth complexity |
| Auth | User accounts, shared herd (scrypt + SQLite sessions) | Self-hosted, no OAuth complexity |
| Hosting | Docker Compose | Nginx + Node + SQLite volume |

View File

@ -1,8 +1,9 @@
# MyKindlebox
A self-hosted rabbitry (rabbit breeding herd) management web app — track your
rabbits, their pedigrees, litters, and genetics. Single-user, self-hosted, and
designed to replace a legacy PHP herd-management site.
rabbits, their pedigrees, litters, and genetics. Self-hosted, with simple user
accounts sharing one herd, and designed to replace a legacy PHP
herd-management site.
The core differentiator: **adding a rabbit starts from a pedigree tree, not a
blank form.** The 3-generation ancestor tree (sire/dam and their sires/dams) is
@ -35,7 +36,7 @@ herd and graceful fallback to freeform text for bought-in or unknown ancestors.
| Styling | Tailwind CSS |
| Backend | Node.js + Express 5 (REST API) |
| Database | SQLite via Drizzle ORM (single-file DB) |
| Auth | Single-user session token (optional password) |
| Auth | User accounts, shared herd (scrypt + DB sessions) |
| Hosting | Docker Compose (Nginx + Node + SQLite volume) |
## Project layout
@ -101,15 +102,15 @@ listens on 80.)
### Authentication
Auth is **optional** and off by default. To require a login, set
`MYKINDLEBOX_PASSWORD` (e.g. in a `.env` file next to `docker-compose.yml`):
The app uses **user accounts** — everyone shares the same herd; accounts are
separate logins with their own theme and weight-display preference. On first
launch the app shows a one-time **create your account** screen; add more people
later under **Settings → Users**. Passwords are hashed (scrypt) and sessions
last 30 days, surviving server restarts.
```env
MYKINDLEBOX_PASSWORD=your-password-here
```
If left unset, the app is open to anyone who can reach it — only acceptable on a
fully trusted network.
> **Upgrading from the password-only version?** The old `MYKINDLEBOX_PASSWORD`
> env var is no longer used — remove it from your `.env`. On your next visit
> you'll see the one-time account-creation screen; your herd data is untouched.
## Data & backups

View File

@ -143,8 +143,19 @@ export const importData = (file) => {
return request('/import', { method: 'POST', body: formData })
}
// Auth
// Auth (user accounts — see memory-bank/FEATURE_User_Accounts.md)
export const getAuthStatus = () => request('/auth/status')
export const login = (password) =>
request('/auth/login', { method: 'POST', body: JSON.stringify({ password }) })
export const login = (username, password) =>
request('/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) })
export const logout = () => request('/auth/logout', { method: 'POST' })
// First-run onboarding: creates the first account (409 once any user exists).
export const setupAccount = (username, password) =>
request('/auth/setup', { method: 'POST', body: JSON.stringify({ username, password }) })
// Users (shared herd — users are logins, not data owners)
export const getUsers = () => request('/users')
export const createUser = (username, password) =>
request('/users', { method: 'POST', body: JSON.stringify({ username, password }) })
export const resetUserPassword = (id, password) =>
request(`/users/${id}`, { method: 'PATCH', body: JSON.stringify({ password }) })
export const deleteUser = (id) => request(`/users/${id}`, { method: 'DELETE' })

View File

@ -1,14 +1,61 @@
import { useCallback, useEffect, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import {
getAuthStatus,
login as apiLogin,
logout as apiLogout,
setupAccount,
setUnauthorizedHandler,
} from './api'
import { AuthContext } from './authContext'
const MIN_PASSWORD_LENGTH = 8 // mirrors the server's MIN_PASSWORD_LENGTH
function AuthShell({ title, subtitle, onSubmit, error, children }) {
return (
<div className="flex min-h-screen items-center justify-center bg-surface px-4">
<form
onSubmit={onSubmit}
className="w-full max-w-sm space-y-4 rounded-md border border-border bg-surface-raised p-6"
>
<div>
<h1 className="font-display text-2xl text-text-primary">{title}</h1>
{subtitle && <p className="mt-1 font-body text-sm text-text-secondary">{subtitle}</p>}
</div>
{children}
{error && <p className="font-body text-sm text-danger">{error}</p>}
</form>
</div>
)
}
function Field({ label, hint, ...inputProps }) {
return (
<div>
<label className="block font-body text-sm font-medium text-text-primary">{label}</label>
<input
className="mt-1 w-full rounded-md border border-border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent"
{...inputProps}
/>
{hint && <p className="mt-1 font-body text-xs text-text-secondary">{hint}</p>}
</div>
)
}
function SubmitButton({ disabled, children }) {
return (
<button
type="submit"
disabled={disabled}
className="w-full rounded-md bg-accent px-4 py-2 font-body text-sm text-surface hover:bg-accent-hover disabled:opacity-50"
>
{children}
</button>
)
}
function LoginScreen({ onSuccess }) {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState(null)
const [submitting, setSubmitting] = useState(false)
@ -18,8 +65,8 @@ function LoginScreen({ onSuccess }) {
setSubmitting(true)
setError(null)
try {
await apiLogin(password)
onSuccess()
const result = await apiLogin(username, password)
onSuccess(result.user)
} catch (err) {
setError(err.message || 'Login failed')
} finally {
@ -28,76 +75,164 @@ function LoginScreen({ onSuccess }) {
}
return (
<div className="flex min-h-screen items-center justify-center bg-surface px-4">
<form
onSubmit={handleSubmit}
className="w-full max-w-sm space-y-4 rounded-md border border-border bg-surface-raised p-6"
>
<h1 className="font-display text-2xl text-text-primary">MyKindlebox</h1>
<div>
<label className="block font-body text-sm font-medium text-text-primary">Password</label>
<input
type="password"
<AuthShell title="MyKindlebox" onSubmit={handleSubmit} error={error}>
<Field
label="Username"
type="text"
autoFocus
autoComplete="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
<Field
label="Password"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1 w-full rounded-md border border-border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
{error && <p className="font-body text-sm text-danger">{error}</p>}
<button
type="submit"
disabled={submitting || !password}
className="w-full rounded-md bg-accent px-4 py-2 font-body text-sm text-surface hover:bg-accent-hover disabled:opacity-50"
>
<SubmitButton disabled={submitting || !username.trim() || !password}>
{submitting ? 'Signing in…' : 'Sign in'}
</button>
</form>
</div>
</SubmitButton>
</AuthShell>
)
}
// First-run onboarding: shown only while the server reports `configured: false`
// (no users exist yet). Creates the first account and signs it in. More
// accounts are added later from Settings → Users.
function SetupScreen({ onSuccess }) {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [confirm, setConfirm] = useState('')
const [error, setError] = useState(null)
const [submitting, setSubmitting] = useState(false)
async function handleSubmit(e) {
e.preventDefault()
if (password !== confirm) {
setError('Passwords do not match')
return
}
setSubmitting(true)
setError(null)
try {
const result = await setupAccount(username.trim(), password)
onSuccess(result.user)
} catch (err) {
setError(err.message || 'Setup failed')
} finally {
setSubmitting(false)
}
}
const valid =
username.trim().length > 0 && password.length >= MIN_PASSWORD_LENGTH && confirm.length > 0
return (
<AuthShell
title="Welcome to MyKindlebox"
subtitle="Create your account to get started. You can add accounts for other people later, under Settings."
onSubmit={handleSubmit}
error={error}
>
<Field
label="Username"
type="text"
autoFocus
autoComplete="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
<Field
label="Password"
type="password"
autoComplete="new-password"
hint={`At least ${MIN_PASSWORD_LENGTH} characters.`}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<Field
label="Confirm password"
type="password"
autoComplete="new-password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
/>
<SubmitButton disabled={submitting || !valid}>
{submitting ? 'Creating account…' : 'Create account'}
</SubmitButton>
</AuthShell>
)
}
// Gates the whole app behind the server's auth state. The server is the source
// of truth (fetched via React Query, like every other server read); `session`
// is a local override for client-driven transitions — login, logout, or a 401
// on any protected request — that shouldn't wait on a refetch to take effect.
// is a local override for client-driven transitions — login, setup, logout, or
// a 401 on any protected request — that shouldn't wait on a refetch to take
// effect. Three gates: no users yet → SetupScreen (first-run onboarding),
// unauthenticated → LoginScreen, else the app with `user` in context.
export function AuthProvider({ children }) {
const { data, isLoading } = useQuery({
const queryClient = useQueryClient()
const { data, isLoading, isError } = useQuery({
queryKey: ['auth-status'],
queryFn: getAuthStatus,
retry: false,
staleTime: Infinity,
})
const [session, setSession] = useState(null)
const [session, setSession] = useState(null) // { authenticated, user } override
// Subscribing to an external signal (the api layer's 401 hook) — the callback
// sets state, the effect body itself doesn't.
useEffect(() => {
setUnauthorizedHandler(() => setSession(false))
setUnauthorizedHandler(() => setSession({ authenticated: false, user: null }))
return () => setUnauthorizedHandler(null)
}, [])
const handleAuthed = useCallback(
(user) => {
setSession({ authenticated: true, user })
// Refresh the canonical copy — after first-run setup this also flips
// `configured` to true so a later logout lands on Login, not Setup.
queryClient.invalidateQueries({ queryKey: ['auth-status'] })
},
[queryClient],
)
const logout = useCallback(async () => {
try {
await apiLogout()
} finally {
setSession(false)
setSession({ authenticated: false, user: null })
queryClient.invalidateQueries({ queryKey: ['auth-status'] })
}
}, [])
}, [queryClient])
if (isLoading) return null
// No data (server unreachable) is treated as "auth not configured" so the app
// still renders and can show its own offline/error states per page.
const configured = data?.configured ?? false
if (!configured) {
return <AuthContext.Provider value={{ mode: 'open', logout }}>{children}</AuthContext.Provider>
// Server unreachable: render the app anyway so each page can show its own
// offline/error state instead of a dead-end auth screen.
if (isError) {
return (
<AuthContext.Provider value={{ mode: 'open', user: null, logout }}>
{children}
</AuthContext.Provider>
)
}
const authenticated = session ?? data?.authenticated ?? false
if (!authenticated) {
return <LoginScreen onSuccess={() => setSession(true)} />
const authenticated = session?.authenticated ?? data?.authenticated ?? false
const user = session?.user ?? data?.user ?? null
if (authenticated) {
return (
<AuthContext.Provider value={{ mode: 'authed', user, logout }}>
{children}
</AuthContext.Provider>
)
}
return <AuthContext.Provider value={{ mode: 'authed', logout }}>{children}</AuthContext.Provider>
if (!data?.configured) {
return <SetupScreen onSuccess={handleAuthed} />
}
return <LoginScreen onSuccess={handleAuthed} />
}

View File

@ -3,7 +3,7 @@ import { createContext, useContext } from 'react'
// Split out from auth.jsx so that file can export only components (the
// react-refresh lint rule), and so pages can import useAuth without pulling in
// the provider.
export const AuthContext = createContext({ mode: 'open', logout: () => {} })
export const AuthContext = createContext({ mode: 'open', user: null, logout: () => {} })
export function useAuth() {
return useContext(AuthContext)

View File

@ -0,0 +1,203 @@
import { useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import Card from './Card'
import Button from './Button'
import { getUsers, createUser, resetUserPassword, deleteUser } from '../api'
import { useAuth } from '../authContext'
const MIN_PASSWORD_LENGTH = 8 // mirrors the server's MIN_PASSWORD_LENGTH
const inputClass =
'mt-1 w-full rounded-md border border-border px-3 py-2 font-body text-sm focus:outline-none focus:ring-2 focus:ring-accent'
// Settings → Users (memory-bank/FEATURE_User_Accounts.md): everyone-equal user
// management for the shared herd. Any signed-in user can add users, reset any
// password (that's the "someone forgot theirs" path — no current-password
// check), and delete accounts other than their own.
export default function UsersCard({ onToast }) {
const { user: me } = useAuth()
const queryClient = useQueryClient()
const { data: users, isLoading } = useQuery({ queryKey: ['users'], queryFn: getUsers })
const [newUsername, setNewUsername] = useState('')
const [newPassword, setNewPassword] = useState('')
const [addError, setAddError] = useState(null)
const [resetTarget, setResetTarget] = useState(null) // user id with the reset form open
const [resetPassword, setResetPassword] = useState('')
const [deleteTarget, setDeleteTarget] = useState(null) // user object pending confirm
const [busy, setBusy] = useState(false)
async function handleAdd(e) {
e.preventDefault()
setBusy(true)
setAddError(null)
try {
await createUser(newUsername.trim(), newPassword)
queryClient.invalidateQueries({ queryKey: ['users'] })
onToast(`Added ${newUsername.trim()}`)
setNewUsername('')
setNewPassword('')
} catch (err) {
setAddError(err.message || 'Could not add user')
} finally {
setBusy(false)
}
}
async function handleReset(user) {
setBusy(true)
try {
await resetUserPassword(user.id, resetPassword)
onToast(`Password updated for ${user.username}`)
setResetTarget(null)
setResetPassword('')
} catch (err) {
onToast(err.message || 'Could not update password')
} finally {
setBusy(false)
}
}
async function handleDelete() {
setBusy(true)
try {
await deleteUser(deleteTarget.id)
queryClient.invalidateQueries({ queryKey: ['users'] })
onToast(`Removed ${deleteTarget.username}`)
setDeleteTarget(null)
} catch (err) {
onToast(err.message || 'Could not remove user')
} finally {
setBusy(false)
}
}
return (
<Card className="space-y-4 p-6">
<div>
<h2 className="font-display text-lg text-text-primary">Users</h2>
<p className="mt-1 font-body text-sm text-text-secondary">
Everyone here shares the same herd and can manage users. Theme and weight display are
per-user; the rest of Settings is shared.
</p>
</div>
{isLoading ? (
<p className="font-body text-sm text-text-secondary">Loading…</p>
) : (
<ul className="divide-y divide-border">
{(users ?? []).map((user) => (
<li key={user.id} className="py-3 first:pt-0 last:pb-0">
<div className="flex items-center justify-between gap-3">
<span className="min-w-0 truncate font-body text-sm font-medium text-text-primary">
{user.username}
{me?.id === user.id && (
<span className="ml-2 rounded bg-surface-sunken px-1.5 py-0.5 font-body text-xs text-text-secondary">
you
</span>
)}
</span>
<span className="flex shrink-0 gap-2">
<Button
variant="secondary"
onClick={() => {
setResetTarget(resetTarget === user.id ? null : user.id)
setResetPassword('')
}}
>
{resetTarget === user.id ? 'Cancel' : 'Reset password'}
</Button>
{me?.id !== user.id && (
<Button variant="danger" onClick={() => setDeleteTarget(user)}>
Remove
</Button>
)}
</span>
</div>
{resetTarget === user.id && (
<div className="mt-2 flex items-end gap-2">
<div className="flex-1">
<label className="block font-body text-xs text-text-secondary">
New password (min {MIN_PASSWORD_LENGTH} characters)
</label>
<input
type="password"
autoFocus
autoComplete="new-password"
value={resetPassword}
onChange={(e) => setResetPassword(e.target.value)}
className={inputClass}
/>
</div>
<Button
onClick={() => handleReset(user)}
disabled={busy || resetPassword.length < MIN_PASSWORD_LENGTH}
>
Save
</Button>
</div>
)}
</li>
))}
</ul>
)}
<form onSubmit={handleAdd} className="space-y-3 border-t border-border pt-4">
<h3 className="font-body text-sm font-medium text-text-primary">Add a user</h3>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="block font-body text-xs text-text-secondary">Username</label>
<input
type="text"
autoComplete="off"
value={newUsername}
onChange={(e) => setNewUsername(e.target.value)}
className={inputClass}
/>
</div>
<div>
<label className="block font-body text-xs text-text-secondary">
Password (min {MIN_PASSWORD_LENGTH} characters)
</label>
<input
type="password"
autoComplete="new-password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className={inputClass}
/>
</div>
</div>
{addError && <p className="font-body text-sm text-danger">{addError}</p>}
<Button
type="submit"
disabled={busy || !newUsername.trim() || newPassword.length < MIN_PASSWORD_LENGTH}
>
Add User
</Button>
</form>
{deleteTarget && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<Card className="w-full max-w-md space-y-4 p-6">
<h2 className="font-display text-lg text-text-primary">
Remove {deleteTarget.username}?
</h2>
<p className="font-body text-sm text-text-secondary">
They will be signed out everywhere and can no longer log in. The herd data is shared,
so nothing else is deleted.
</p>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={() => setDeleteTarget(null)} disabled={busy}>
Cancel
</Button>
<Button variant="danger" onClick={handleDelete} disabled={busy}>
{busy ? 'Removing…' : 'Remove User'}
</Button>
</div>
</Card>
</div>
)}
</Card>
)
}

View File

@ -4,6 +4,7 @@ import PageHeader from '../components/PageHeader'
import Card from '../components/Card'
import Button from '../components/Button'
import BreedSelect from '../components/BreedSelect'
import UsersCard from '../components/UsersCard'
import { useAuth } from '../authContext'
import { getSettings, updateSettings, exportDatabaseUrl, importData } from '../api'
import { THEMES, applyTheme, DEFAULT_THEME } from '../lib/theme'
@ -23,7 +24,7 @@ function Toast({ message }) {
}
function Settings() {
const { mode, logout } = useAuth()
const { mode, user, logout } = useAuth()
const queryClient = useQueryClient()
const { data: settings, isLoading } = useQuery({ queryKey: ['settings'], queryFn: getSettings })
const [form, setForm] = useState(null)
@ -257,17 +258,20 @@ function Settings() {
</Card>
{mode === 'authed' && (
<>
<UsersCard onToast={setToast} />
<Card className="space-y-3 p-6">
<div>
<h2 className="font-display text-lg text-text-primary">Account</h2>
<p className="mt-1 font-body text-sm text-text-secondary">
End this session on this device.
{user ? `Signed in as ${user.username}. ` : ''}End this session on this device.
</p>
</div>
<Button variant="secondary" onClick={logout}>
Log Out
</Button>
</Card>
</>
)}
</div>

View File

@ -2,10 +2,10 @@ services:
server:
build: ./server
environment:
# Set MYKINDLEBOX_PASSWORD (e.g. in a .env file next to this compose file)
# to require a login. If left empty, auth is DISABLED and the app is open
# to anyone who can reach it — only acceptable on a fully trusted network.
AUTH_PASSWORD: ${MYKINDLEBOX_PASSWORD:-}
# Auth is account-based (users live in the database): the first visit
# shows a one-time account-creation screen, and more users are added in
# Settings. The old MYKINDLEBOX_PASSWORD/AUTH_PASSWORD env var is no
# longer used.
DATA_DIR: /app/data
volumes:
- server-data:/app/data

View File

@ -1,36 +1,91 @@
import { randomUUID } from 'crypto';
import { randomUUID, randomBytes, scryptSync, timingSafeEqual } from 'crypto';
import { NextFunction, Request, Response } from 'express';
import { eq, lt, sql } from 'drizzle-orm';
import { db } from './db/client';
import { users, authSessions } from './db/schema';
// Single-user session-token auth (no OAuth, per tech-stack.md).
// User-account session auth (see memory-bank/FEATURE_User_Accounts.md).
//
// A single shared password is read from the AUTH_PASSWORD env var. If it's
// unset/empty, auth is DISABLED — every request passes through — which keeps
// local dev frictionless. When set, clients must POST it to /api/auth/login to
// receive an httpOnly session cookie, checked on every protected route.
//
// Tokens live in memory only: a server restart invalidates all sessions
// (you log in again). That's an acceptable trade for a single-user, self-hosted
// app and avoids persisting a session table.
// Replaces the original single shared-password mode (AUTH_PASSWORD env var,
// in-memory session Set): accounts live in the `users` table, sessions in
// `auth_sessions` (so a server restart no longer signs everyone out), and the
// first account is created through POST /api/auth/setup while the users table
// is empty. Auth is always on once a user exists — there is no open mode.
const COOKIE_NAME = 'mkb_session';
const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
const password = process.env.AUTH_PASSWORD ?? '';
export const authEnabled = password.length > 0;
// Flat pause on a failed login — cheap brute-force damping without the
// bookkeeping of per-IP counters (single-family LAN app).
const FAILED_LOGIN_DELAY_MS = 350;
const sessions = new Set<string>();
export interface SessionUser {
id: number;
username: string;
}
export function logAuthStatus() {
if (authEnabled) {
console.log('Auth: enabled (AUTH_PASSWORD is set).');
} else {
console.warn(
'Auth: DISABLED — AUTH_PASSWORD is not set, so the API is open to anyone ' +
'who can reach it. Set AUTH_PASSWORD to require a login.'
);
// requireAuth attaches the session's user here; protected routes can cast.
export interface AuthedRequest extends Request {
user?: SessionUser;
}
// --- Password hashing (node:crypto scrypt — no native dependency) ---
export function hashPassword(password: string): string {
const salt = randomBytes(16);
const hash = scryptSync(password, salt, 64);
return `scrypt:${salt.toString('hex')}:${hash.toString('hex')}`;
}
export function verifyPassword(password: string, stored: string): boolean {
const [scheme, saltHex, hashHex] = stored.split(':');
if (scheme !== 'scrypt' || !saltHex || !hashHex) return false;
const expected = Buffer.from(hashHex, 'hex');
const actual = scryptSync(password, Buffer.from(saltHex, 'hex'), expected.length);
return timingSafeEqual(actual, expected);
}
// --- Users ---
export function anyUsersExist(): boolean {
return db.select({ id: users.id }).from(users).limit(1).get() !== undefined;
}
// Case-insensitive lookup: the schema's UNIQUE index is case-sensitive, so both
// login and the duplicate-username guard go through lower() instead.
export function findUserByUsername(username: string) {
return db
.select()
.from(users)
.where(sql`lower(${users.username}) = lower(${username})`)
.get();
}
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export const failedLoginDelayMs = FAILED_LOGIN_DELAY_MS;
export const MIN_PASSWORD_LENGTH = 8;
// Shared by POST /api/auth/setup and POST /api/users — returns an error message
// for the 400 response, or null when the pair is acceptable.
export function validateNewCredentials(username: unknown, password: unknown): string | null {
if (typeof username !== 'string' || username.trim().length === 0) {
return 'Username is required';
}
if (username.trim().length > 64) {
return 'Username is too long (max 64 characters)';
}
if (typeof password !== 'string' || password.length < MIN_PASSWORD_LENGTH) {
return `Password must be at least ${MIN_PASSWORD_LENGTH} characters`;
}
return null;
}
// --- Cookie / session plumbing ---
function parseCookies(header: string | undefined): Record<string, string> {
const out: Record<string, string> = {};
if (!header) return out;
@ -43,41 +98,65 @@ function parseCookies(header: string | undefined): Record<string, string> {
return out;
}
function readSessionToken(req: Request): string | undefined {
export function readSessionToken(req: Request): string | undefined {
return parseCookies(req.headers.cookie)[COOKIE_NAME];
}
export function isAuthenticated(req: Request): boolean {
if (!authEnabled) return true;
const token = readSessionToken(req);
return token != null && sessions.has(token);
}
export function requireAuth(req: Request, res: Response, next: NextFunction) {
if (isAuthenticated(req)) {
next();
return;
}
res.status(401).json({ error: 'Unauthorized' });
}
export function login(res: Response, attempt: unknown): boolean {
if (!authEnabled) return true;
if (typeof attempt !== 'string' || attempt !== password) return false;
export function createSession(res: Response, userId: number): void {
const token = randomUUID();
sessions.add(token);
const expiresAt = new Date(Date.now() + SESSION_TTL_MS).toISOString();
db.insert(authSessions).values({ token, userId, expiresAt }).run();
res.cookie(COOKIE_NAME, token, {
httpOnly: true,
sameSite: 'lax',
path: '/',
maxAge: SESSION_TTL_MS,
});
return true;
}
export function logout(req: Request, res: Response) {
export function destroySession(req: Request, res: Response): void {
const token = readSessionToken(req);
if (token) sessions.delete(token);
if (token) db.delete(authSessions).where(eq(authSessions.token, token)).run();
res.clearCookie(COOKIE_NAME, { path: '/' });
}
// Resolve the request's session to its user, lazily purging expired sessions.
// ISO strings compare lexicographically, so `lt` against now-ISO is correct.
export function getSessionUser(req: Request): SessionUser | null {
const token = readSessionToken(req);
if (!token) return null;
db.delete(authSessions).where(lt(authSessions.expiresAt, new Date().toISOString())).run();
const row = db
.select({ id: users.id, username: users.username })
.from(authSessions)
.innerJoin(users, eq(authSessions.userId, users.id))
.where(eq(authSessions.token, token))
.get();
return row ?? null;
}
export function requireAuth(req: Request, res: Response, next: NextFunction) {
const user = getSessionUser(req);
if (!user) {
res.status(401).json({ error: 'Unauthorized' });
return;
}
(req as AuthedRequest).user = user;
next();
}
export function logAuthStatus() {
if (process.env.AUTH_PASSWORD) {
console.warn(
'Auth: AUTH_PASSWORD is set but no longer used — auth is account-based now. ' +
'Remove MYKINDLEBOX_PASSWORD/AUTH_PASSWORD from your environment.'
);
}
if (anyUsersExist()) {
console.log('Auth: user accounts active.');
} else {
console.log('Auth: no users yet — the app will show the first-run setup screen.');
}
}

View File

@ -0,0 +1,18 @@
CREATE TABLE `auth_sessions` (
`token` text PRIMARY KEY NOT NULL,
`user_id` integer NOT NULL,
`expires_at` text NOT NULL,
`created_at` text DEFAULT (current_timestamp) NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `users` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`username` text NOT NULL,
`password_hash` text NOT NULL,
`theme` text,
`weight_unit` text,
`created_at` text DEFAULT (current_timestamp) NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `users_username_unique` ON `users` (`username`);

File diff suppressed because it is too large Load Diff

View File

@ -78,6 +78,13 @@
"when": 1782183462143,
"tag": "0010_add_judges_placements",
"breakpoints": true
},
{
"idx": 11,
"version": "6",
"when": 1783023645526,
"tag": "0011_add_users",
"breakpoints": true
}
]
}

View File

@ -112,6 +112,37 @@ export const settings = sqliteTable('settings', {
value: text('value'),
});
// User accounts (see memory-bank/FEATURE_User_Accounts.md). All users share the
// same herd — nothing else references users; they are logins, not data owners.
// `theme`/`weightUnit` are the per-user preferences: NULL means "inherit the
// global settings-table value" (routes/settings.ts does the merge), so existing
// global values keep applying until a user picks their own.
export const users = sqliteTable('users', {
id: integer('id').primaryKey({ autoIncrement: true }),
// The UNIQUE index is case-sensitive (drizzle can't emit COLLATE NOCASE);
// case-insensitive lookups and the "Rory" vs "rory" duplicate guard are both
// enforced with lower() comparisons in auth.ts / routes/users.ts instead.
username: text('username').notNull().unique(),
passwordHash: text('password_hash').notNull(), // "scrypt:<salt hex>:<hash hex>" — see auth.ts
theme: text('theme'),
weightUnit: text('weight_unit'),
createdAt: text('created_at').notNull().default(sql`(current_timestamp)`),
});
// Persistent sessions (replaces the old in-memory Set): a server restart or
// redeploy no longer signs everyone out. Expired rows are purged lazily during
// session checks. NOTE: the FK cascade is declarative only — the app runs with
// SQLite foreign_keys OFF (better-sqlite3 default), so user deletion removes
// sessions explicitly in routes/users.ts.
export const authSessions = sqliteTable('auth_sessions', {
token: text('token').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
expiresAt: text('expires_at').notNull(),
createdAt: text('created_at').notNull().default(sql`(current_timestamp)`),
});
// Cached snapshot of the ARBA show-search listing (see routes/shows.ts and
// lib/arba.ts). ARBA sits behind Cloudflare and only updates Mon–Fri, so we
// scrape at most once a day and serve everything else from here. `data` is the

View File

@ -3,6 +3,7 @@ import cors from 'cors';
import { uploadsDir } from './db/client';
import { logAuthStatus, requireAuth } from './auth';
import authRouter from './routes/auth';
import usersRouter from './routes/users';
import rabbitsRouter from './routes/rabbits';
import littersRouter from './routes/litters';
import eventsRouter from './routes/events';
@ -35,8 +36,9 @@ app.get('/health', (req, res) => {
// Auth endpoints must stay open so the login screen can reach them.
app.use('/api/auth', authRouter);
// Everything past here requires a valid session (a no-op when auth is disabled).
// Everything past here requires a valid session.
app.use('/uploads', requireAuth, express.static(uploadsDir));
app.use('/api/users', requireAuth, usersRouter);
app.use('/api/rabbits', requireAuth, rabbitsRouter);
app.use('/api/litters', requireAuth, littersRouter);
app.use('/api/events', requireAuth, eventsRouter);

View File

@ -1,25 +1,75 @@
import { Router } from 'express';
import { authEnabled, isAuthenticated, login, logout } from '../auth';
import { db } from '../db/client';
import { users } from '../db/schema';
import {
anyUsersExist,
createSession,
destroySession,
failedLoginDelayMs,
findUserByUsername,
getSessionUser,
hashPassword,
sleep,
validateNewCredentials,
verifyPassword,
} from '../auth';
const router = Router();
// Lets the client decide whether to show a login screen at all:
// `configured: false` means auth is disabled, so the app loads straight through.
// Drives the client's auth gate: `configured: false` → show the first-run
// setup screen; unauthenticated → login screen; otherwise `user` identifies
// the session for the UI.
router.get('/status', (req, res) => {
res.json({ configured: authEnabled, authenticated: isAuthenticated(req) });
const user = getSessionUser(req);
res.json({
configured: anyUsersExist(),
authenticated: user !== null,
user: user ?? null,
});
});
router.post('/login', (req, res) => {
const ok = login(res, req.body?.password);
if (!ok) {
res.status(401).json({ error: 'Incorrect password' });
// First-run onboarding: creates the first account and signs it in. Locked to
// the empty-users state so it can never be used to add accounts later (that's
// POST /api/users, which requires a session).
router.post('/setup', (req, res) => {
if (anyUsersExist()) {
res.status(409).json({ error: 'Setup has already been completed' });
return;
}
res.json({ authenticated: true });
const { username, password } = req.body ?? {};
const invalid = validateNewCredentials(username, password);
if (invalid) {
res.status(400).json({ error: invalid });
return;
}
const created = db
.insert(users)
.values({ username: (username as string).trim(), passwordHash: hashPassword(password as string) })
.returning({ id: users.id, username: users.username })
.get();
createSession(res, created.id);
res.status(201).json({ authenticated: true, user: created });
});
router.post('/login', async (req, res) => {
const { username, password } = req.body ?? {};
const user = typeof username === 'string' ? findUserByUsername(username) : undefined;
if (!user || typeof password !== 'string' || !verifyPassword(password, user.passwordHash)) {
await sleep(failedLoginDelayMs); // flat delay = cheap brute-force damping
res.status(401).json({ error: 'Incorrect username or password' });
return;
}
createSession(res, user.id);
res.json({ authenticated: true, user: { id: user.id, username: user.username } });
});
router.post('/logout', (req, res) => {
logout(req, res);
destroySession(req, res);
res.json({ authenticated: false });
});

View File

@ -1,40 +1,69 @@
import { Router } from 'express';
import { Router, Request } from 'express';
import { eq } from 'drizzle-orm';
import { db } from '../db/client';
import { settings } from '../db/schema';
import { settings, users } from '../db/schema';
import { AuthedRequest } from '../auth';
const router = Router();
const ALLOWED_KEYS = ['rabbitry_name', 'weight_unit', 'kit_prefix', 'theme', 'default_breed'] as const;
// The API shape is unchanged from the single-user days — one flat object with
// these keys — but storage is now split (FEATURE_User_Accounts.md): `theme` and
// `weight_unit` are per-user preferences on the caller's `users` row, the rest
// stay global in the settings table. A NULL user pref falls back to the global
// value, so a fresh account inherits the rabbitry-wide defaults until the user
// picks their own. Client consumers (ThemeSync, useWeightUnit, Settings page)
// are agnostic to the split.
const GLOBAL_KEYS = ['rabbitry_name', 'kit_prefix', 'default_breed'] as const;
const USER_KEYS = ['theme', 'weight_unit'] as const;
const ALLOWED_KEYS = [...GLOBAL_KEYS, ...USER_KEYS] as string[];
router.get('/', (req, res) => {
const rows = db.select().from(settings).all();
function mergedSettings(userId: number): Record<string, string | null> {
const result: Record<string, string | null> = {};
for (const row of rows) {
for (const row of db.select().from(settings).all()) {
result[row.key] = row.value;
}
res.json(result);
const prefs = db
.select({ theme: users.theme, weightUnit: users.weightUnit })
.from(users)
.where(eq(users.id, userId))
.get();
if (prefs?.theme != null) result.theme = prefs.theme;
if (prefs?.weightUnit != null) result.weight_unit = prefs.weightUnit;
return result;
}
function currentUserId(req: Request): number {
return (req as AuthedRequest).user!.id;
}
router.get('/', (req, res) => {
res.json(mergedSettings(currentUserId(req)));
});
router.patch('/', (req, res) => {
const keys = Object.keys(req.body);
const unknownKey = keys.find((key) => !ALLOWED_KEYS.includes(key as (typeof ALLOWED_KEYS)[number]));
const unknownKey = keys.find((key) => !ALLOWED_KEYS.includes(key));
if (unknownKey) {
res.status(400).json({ error: `Unknown setting: ${unknownKey}` });
return;
}
const userId = currentUserId(req);
const userPatch: { theme?: string; weightUnit?: string } = {};
if ('theme' in req.body) userPatch.theme = req.body.theme;
if ('weight_unit' in req.body) userPatch.weightUnit = req.body.weight_unit;
if (Object.keys(userPatch).length > 0) {
db.update(users).set(userPatch).where(eq(users.id, userId)).run();
}
for (const key of keys) {
if ((USER_KEYS as readonly string[]).includes(key)) continue;
db.update(settings).set({ value: req.body[key] }).where(eq(settings.key, key)).run();
}
const rows = db.select().from(settings).all();
const result: Record<string, string | null> = {};
for (const row of rows) {
result[row.key] = row.value;
}
res.json(result);
res.json(mergedSettings(userId));
});
export default router;

View File

@ -0,0 +1,113 @@
import { Router, Request } from 'express';
import { and, asc, eq, ne } from 'drizzle-orm';
import { db } from '../db/client';
import { users, authSessions } from '../db/schema';
import {
AuthedRequest,
findUserByUsername,
hashPassword,
readSessionToken,
validateNewCredentials,
MIN_PASSWORD_LENGTH,
} from '../auth';
import { toSnakeCase } from '../utils/case';
// User management (memory-bank/FEATURE_User_Accounts.md). Mounted behind
// requireAuth; everyone-equal trust model — any signed-in user can add users,
// reset passwords, and remove accounts other than their own. Password hashes
// never leave the server.
const router = Router();
function callerId(req: Request): number {
return (req as AuthedRequest).user!.id;
}
router.get('/', (req, res) => {
const rows = db
.select({ id: users.id, username: users.username, createdAt: users.createdAt })
.from(users)
.orderBy(asc(users.username))
.all();
res.json(rows.map(toSnakeCase));
});
router.post('/', (req, res) => {
const { username, password } = req.body ?? {};
const invalid = validateNewCredentials(username, password);
if (invalid) {
res.status(400).json({ error: invalid });
return;
}
// Case-insensitive duplicate guard (the UNIQUE index is case-sensitive).
if (findUserByUsername((username as string).trim())) {
res.status(409).json({ error: 'That username is already taken' });
return;
}
const created = db
.insert(users)
.values({ username: (username as string).trim(), passwordHash: hashPassword(password as string) })
.returning({ id: users.id, username: users.username, createdAt: users.createdAt })
.get();
res.status(201).json(toSnakeCase(created));
});
// Password reset — also the "someone forgot theirs" path, so no current-password
// check (everyone equal). Signs the target out everywhere except the caller's
// own current session (relevant when resetting your own password).
router.patch('/:id', (req, res) => {
const id = Number(req.params.id);
const target = db.select().from(users).where(eq(users.id, id)).get();
if (!target) {
res.status(404).json({ error: 'User not found' });
return;
}
const { password } = req.body ?? {};
if (typeof password !== 'string' || password.length < MIN_PASSWORD_LENGTH) {
res.status(400).json({ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters` });
return;
}
db.update(users).set({ passwordHash: hashPassword(password) }).where(eq(users.id, id)).run();
const currentToken = readSessionToken(req);
db.delete(authSessions)
.where(
currentToken
? and(eq(authSessions.userId, id), ne(authSessions.token, currentToken))
: eq(authSessions.userId, id)
)
.run();
res.json({ id, username: target.username });
});
// You can't delete yourself — which also means the last account can never be
// deleted (you'd have to be signed in as someone else), so no lockout state.
router.delete('/:id', (req, res) => {
const id = Number(req.params.id);
if (id === callerId(req)) {
res.status(400).json({ error: 'You cannot delete your own account' });
return;
}
const target = db.select({ id: users.id }).from(users).where(eq(users.id, id)).get();
if (!target) {
res.status(404).json({ error: 'User not found' });
return;
}
// Explicit session cleanup: better-sqlite3 runs with foreign_keys OFF, so the
// schema's ON DELETE CASCADE is not enforced at runtime.
db.transaction((tx) => {
tx.delete(authSessions).where(eq(authSessions.userId, id)).run();
tx.delete(users).where(eq(users.id, id)).run();
});
res.status(204).end();
});
export default router;