diff --git a/CLAUDE.md b/CLAUDE.md index ba5e065..327bd67 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/memory-bank/FEATURE_User_Accounts.md b/memory-bank/FEATURE_User_Accounts.md new file mode 100644 index 0000000..26bf2e9 --- /dev/null +++ b/memory-bank/FEATURE_User_Accounts.md @@ -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::" + 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::`; 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. diff --git a/memory-bank/architecture.md b/memory-bank/architecture.md index 2af9b11..a1ebf54 100644 --- a/memory-bank/architecture.md +++ b/memory-bank/architecture.md @@ -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::"`; 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 `` cells** (cells have no column classes — read by index 0–10: sanction, date, state, district, city, showInformation, address, maps-``, secretary, email, catalog-``). 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. diff --git a/memory-bank/progress.md b/memory-bank/progress.md index b0f7a40..596f37f 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -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**. diff --git a/memory-bank/tech-stack.md b/memory-bank/tech-stack.md index 0481637..29e0ede 100644 --- a/memory-bank/tech-stack.md +++ b/memory-bank/tech-stack.md @@ -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 | diff --git a/mykindlebox/README.md b/mykindlebox/README.md index 6235351..bb6b7c0 100644 --- a/mykindlebox/README.md +++ b/mykindlebox/README.md @@ -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 diff --git a/mykindlebox/client/src/api.js b/mykindlebox/client/src/api.js index 66bf880..51376d0 100644 --- a/mykindlebox/client/src/api.js +++ b/mykindlebox/client/src/api.js @@ -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' }) diff --git a/mykindlebox/client/src/auth.jsx b/mykindlebox/client/src/auth.jsx index 5842bc1..afe386c 100644 --- a/mykindlebox/client/src/auth.jsx +++ b/mykindlebox/client/src/auth.jsx @@ -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 ( +
+
+
+

{title}

+ {subtitle &&

{subtitle}

} +
+ {children} + {error &&

{error}

} +
+
+ ) +} + +function Field({ label, hint, ...inputProps }) { + return ( +
+ + + {hint &&

{hint}

} +
+ ) +} + +function SubmitButton({ disabled, children }) { + return ( + + ) +} + 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 ( -
-
-

MyKindlebox

-
- - 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" - /> -
- {error &&

{error}

} - -
-
+ + setUsername(e.target.value)} + /> + setPassword(e.target.value)} + /> + + {submitting ? 'Signing in…' : 'Sign in'} + + + ) +} + +// 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 ( + + setUsername(e.target.value)} + /> + setPassword(e.target.value)} + /> + setConfirm(e.target.value)} + /> + + {submitting ? 'Creating account…' : 'Create account'} + + ) } // 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 {children} + // 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 ( + + {children} + + ) } - const authenticated = session ?? data?.authenticated ?? false - if (!authenticated) { - return setSession(true)} /> + const authenticated = session?.authenticated ?? data?.authenticated ?? false + const user = session?.user ?? data?.user ?? null + + if (authenticated) { + return ( + + {children} + + ) } - return {children} + if (!data?.configured) { + return + } + return } diff --git a/mykindlebox/client/src/authContext.js b/mykindlebox/client/src/authContext.js index 1784979..b523369 100644 --- a/mykindlebox/client/src/authContext.js +++ b/mykindlebox/client/src/authContext.js @@ -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) diff --git a/mykindlebox/client/src/components/UsersCard.jsx b/mykindlebox/client/src/components/UsersCard.jsx new file mode 100644 index 0000000..76cbdd7 --- /dev/null +++ b/mykindlebox/client/src/components/UsersCard.jsx @@ -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 ( + +
+

Users

+

+ Everyone here shares the same herd and can manage users. Theme and weight display are + per-user; the rest of Settings is shared. +

+
+ + {isLoading ? ( +

Loading…

+ ) : ( +
    + {(users ?? []).map((user) => ( +
  • +
    + + {user.username} + {me?.id === user.id && ( + + you + + )} + + + + {me?.id !== user.id && ( + + )} + +
    + {resetTarget === user.id && ( +
    +
    + + setResetPassword(e.target.value)} + className={inputClass} + /> +
    + +
    + )} +
  • + ))} +
+ )} + +
+

Add a user

+
+
+ + setNewUsername(e.target.value)} + className={inputClass} + /> +
+
+ + setNewPassword(e.target.value)} + className={inputClass} + /> +
+
+ {addError &&

{addError}

} + +
+ + {deleteTarget && ( +
+ +

+ Remove {deleteTarget.username}? +

+

+ They will be signed out everywhere and can no longer log in. The herd data is shared, + so nothing else is deleted. +

+
+ + +
+
+
+ )} +
+ ) +} diff --git a/mykindlebox/client/src/pages/Settings.jsx b/mykindlebox/client/src/pages/Settings.jsx index 73ddc5b..144c554 100644 --- a/mykindlebox/client/src/pages/Settings.jsx +++ b/mykindlebox/client/src/pages/Settings.jsx @@ -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() { {mode === 'authed' && ( - -
-

Account

-

- End this session on this device. -

-
- -
+ <> + + +
+

Account

+

+ {user ? `Signed in as ${user.username}. ` : ''}End this session on this device. +

+
+ +
+ )} diff --git a/mykindlebox/docker-compose.yml b/mykindlebox/docker-compose.yml index 22e6952..3fa560e 100644 --- a/mykindlebox/docker-compose.yml +++ b/mykindlebox/docker-compose.yml @@ -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 diff --git a/mykindlebox/server/auth.ts b/mykindlebox/server/auth.ts index eeea26d..a3ff74e 100644 --- a/mykindlebox/server/auth.ts +++ b/mykindlebox/server/auth.ts @@ -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(); - -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.' - ); - } +export interface SessionUser { + id: number; + username: string; } +// 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 { + 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 { const out: Record = {}; if (!header) return out; @@ -43,41 +98,65 @@ function parseCookies(header: string | undefined): Record { 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.'); + } +} diff --git a/mykindlebox/server/db/migrations/0011_add_users.sql b/mykindlebox/server/db/migrations/0011_add_users.sql new file mode 100644 index 0000000..2c213cd --- /dev/null +++ b/mykindlebox/server/db/migrations/0011_add_users.sql @@ -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`); \ No newline at end of file diff --git a/mykindlebox/server/db/migrations/meta/0011_snapshot.json b/mykindlebox/server/db/migrations/meta/0011_snapshot.json new file mode 100644 index 0000000..517d44e --- /dev/null +++ b/mykindlebox/server/db/migrations/meta/0011_snapshot.json @@ -0,0 +1,1135 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "c1ddcd85-a331-4843-93a4-43506970264e", + "prevId": "8d35ee70-3eaa-430e-b30f-3b440e7f4e32", + "tables": { + "arba_shows_cache": { + "name": "arba_shows_cache", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "fetched_at": { + "name": "fetched_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "auth_sessions": { + "name": "auth_sessions", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": { + "auth_sessions_user_id_users_id_fk": { + "name": "auth_sessions_user_id_users_id_fk", + "tableFrom": "auth_sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "litters": { + "name": "litters", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "sire_id": { + "name": "sire_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dam_id": { + "name": "dam_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "breed_date": { + "name": "breed_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kindle_date": { + "name": "kindle_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kit_count": { + "name": "kit_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": { + "litters_sire_id_rabbits_id_fk": { + "name": "litters_sire_id_rabbits_id_fk", + "tableFrom": "litters", + "tableTo": "rabbits", + "columnsFrom": [ + "sire_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "litters_dam_id_rabbits_id_fk": { + "name": "litters_dam_id_rabbits_id_fk", + "tableFrom": "litters", + "tableTo": "rabbits", + "columnsFrom": [ + "dam_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rabbit_events": { + "name": "rabbit_events", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "rabbit_id": { + "name": "rabbit_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_date": { + "name": "event_date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "show_class": { + "name": "show_class", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "placement": { + "name": "placement", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": { + "rabbit_events_rabbit_id_rabbits_id_fk": { + "name": "rabbit_events_rabbit_id_rabbits_id_fk", + "tableFrom": "rabbit_events", + "tableTo": "rabbits", + "columnsFrom": [ + "rabbit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "event_type_check": { + "name": "event_type_check", + "value": "\"rabbit_events\".\"type\" in ('show', 'sale', 'other')" + } + } + }, + "rabbits": { + "name": "rabbits", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "farm": { + "name": "farm", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sex": { + "name": "sex", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unknown'" + }, + "dob": { + "name": "dob", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "weight_lbs": { + "name": "weight_lbs", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ear_number": { + "name": "ear_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "breed": { + "name": "breed", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "color_name": { + "name": "color_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "photo_path": { + "name": "photo_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "registration_number": { + "name": "registration_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "gc_number": { + "name": "gc_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sire_id": { + "name": "sire_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dam_id": { + "name": "dam_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "acquired_date": { + "name": "acquired_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "death_date": { + "name": "death_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "locus_a": { + "name": "locus_a", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "locus_b": { + "name": "locus_b", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "locus_c": { + "name": "locus_c", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "locus_d": { + "name": "locus_d", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "locus_e": { + "name": "locus_e", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "locus_en": { + "name": "locus_en", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "locus_v": { + "name": "locus_v", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sire_name_text": { + "name": "sire_name_text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dam_name_text": { + "name": "dam_name_text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "paternal_grandsire_name_text": { + "name": "paternal_grandsire_name_text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "paternal_granddam_name_text": { + "name": "paternal_granddam_name_text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "maternal_grandsire_name_text": { + "name": "maternal_grandsire_name_text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "maternal_granddam_name_text": { + "name": "maternal_granddam_name_text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pp_grandsire_name_text": { + "name": "pp_grandsire_name_text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pp_granddam_name_text": { + "name": "pp_granddam_name_text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pm_grandsire_name_text": { + "name": "pm_grandsire_name_text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pm_granddam_name_text": { + "name": "pm_granddam_name_text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mp_grandsire_name_text": { + "name": "mp_grandsire_name_text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mp_granddam_name_text": { + "name": "mp_granddam_name_text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mm_grandsire_name_text": { + "name": "mm_grandsire_name_text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mm_granddam_name_text": { + "name": "mm_granddam_name_text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": { + "rabbits_sire_id_rabbits_id_fk": { + "name": "rabbits_sire_id_rabbits_id_fk", + "tableFrom": "rabbits", + "tableTo": "rabbits", + "columnsFrom": [ + "sire_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "rabbits_dam_id_rabbits_id_fk": { + "name": "rabbits_dam_id_rabbits_id_fk", + "tableFrom": "rabbits", + "tableTo": "rabbits", + "columnsFrom": [ + "dam_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "sex_check": { + "name": "sex_check", + "value": "\"rabbits\".\"sex\" in ('buck', 'doe', 'unknown')" + }, + "status_check": { + "name": "status_check", + "value": "\"rabbits\".\"status\" in ('active', 'for_sale', 'on_hold', 'sold', 'deceased', 'other')" + } + } + }, + "settings": { + "name": "settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "show_event_entries": { + "name": "show_event_entries", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "show_event_show_id": { + "name": "show_event_show_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rabbit_id": { + "name": "rabbit_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entry_class": { + "name": "entry_class", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "entry_number": { + "name": "entry_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": { + "show_event_entries_show_event_show_id_show_event_shows_id_fk": { + "name": "show_event_entries_show_event_show_id_show_event_shows_id_fk", + "tableFrom": "show_event_entries", + "tableTo": "show_event_shows", + "columnsFrom": [ + "show_event_show_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "show_event_entries_rabbit_id_rabbits_id_fk": { + "name": "show_event_entries_rabbit_id_rabbits_id_fk", + "tableFrom": "show_event_entries", + "tableTo": "rabbits", + "columnsFrom": [ + "rabbit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "show_event_judges": { + "name": "show_event_judges", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "show_event_show_id": { + "name": "show_event_show_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "judge_name": { + "name": "judge_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "judge_arba_number": { + "name": "judge_arba_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "show_event_judges_show_event_show_id_show_event_shows_id_fk": { + "name": "show_event_judges_show_event_show_id_show_event_shows_id_fk", + "tableFrom": "show_event_judges", + "tableTo": "show_event_shows", + "columnsFrom": [ + "show_event_show_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "show_event_placements": { + "name": "show_event_placements", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "entry_id": { + "name": "entry_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "placement": { + "name": "placement", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "legs_earned": { + "name": "legs_earned", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "bob": { + "name": "bob", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "bos": { + "name": "bos", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "bis": { + "name": "bis", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "ris": { + "name": "ris", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "show_event_placements_entry_id_unique": { + "name": "show_event_placements_entry_id_unique", + "columns": [ + "entry_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "show_event_placements_entry_id_show_event_entries_id_fk": { + "name": "show_event_placements_entry_id_show_event_entries_id_fk", + "tableFrom": "show_event_placements", + "tableTo": "show_event_entries", + "columnsFrom": [ + "entry_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "show_event_shows": { + "name": "show_event_shows", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "show_event_id": { + "name": "show_event_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sanction_number": { + "name": "sanction_number", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "show_date": { + "name": "show_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "club_name": { + "name": "club_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "show_type": { + "name": "show_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "specialty_breed": { + "name": "specialty_breed", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "city": { + "name": "city", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "secretary": { + "name": "secretary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "secretary_email": { + "name": "secretary_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "catalog_url": { + "name": "catalog_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "maps_url": { + "name": "maps_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "show_event_shows_show_event_id_show_events_id_fk": { + "name": "show_event_shows_show_event_id_show_events_id_fk", + "tableFrom": "show_event_shows", + "tableTo": "show_events", + "columnsFrom": [ + "show_event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "show_events": { + "name": "show_events", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "weight_unit": { + "name": "weight_unit", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(current_timestamp)" + } + }, + "indexes": { + "users_username_unique": { + "name": "users_username_unique", + "columns": [ + "username" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/mykindlebox/server/db/migrations/meta/_journal.json b/mykindlebox/server/db/migrations/meta/_journal.json index e9a576e..4b58c0a 100644 --- a/mykindlebox/server/db/migrations/meta/_journal.json +++ b/mykindlebox/server/db/migrations/meta/_journal.json @@ -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 } ] } \ No newline at end of file diff --git a/mykindlebox/server/db/schema.ts b/mykindlebox/server/db/schema.ts index 5a8d9d8..47011b5 100644 --- a/mykindlebox/server/db/schema.ts +++ b/mykindlebox/server/db/schema.ts @@ -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::" — 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 diff --git a/mykindlebox/server/index.ts b/mykindlebox/server/index.ts index 9a85ab3..7c8af28 100644 --- a/mykindlebox/server/index.ts +++ b/mykindlebox/server/index.ts @@ -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); diff --git a/mykindlebox/server/routes/auth.ts b/mykindlebox/server/routes/auth.ts index cba2071..3c1723b 100644 --- a/mykindlebox/server/routes/auth.ts +++ b/mykindlebox/server/routes/auth.ts @@ -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 }); }); diff --git a/mykindlebox/server/routes/settings.ts b/mykindlebox/server/routes/settings.ts index 8d5143f..0cc5b81 100644 --- a/mykindlebox/server/routes/settings.ts +++ b/mykindlebox/server/routes/settings.ts @@ -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 { const result: Record = {}; - 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 = {}; - for (const row of rows) { - result[row.key] = row.value; - } - res.json(result); + res.json(mergedSettings(userId)); }); export default router; diff --git a/mykindlebox/server/routes/users.ts b/mykindlebox/server/routes/users.ts new file mode 100644 index 0000000..00b9fd2 --- /dev/null +++ b/mykindlebox/server/routes/users.ts @@ -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;