# Database Documentation MiauInv uses SQLite for persistent storage. The schema is initialized in `storage.InitDB` and foreign-key enforcement is explicitly enabled with: ```sql PRAGMA foreign_keys = ON; ``` The database stores users, refresh tokens, 2FA recovery codes, passkey credentials, passkey challenge state, inventory items, locations, projects, stock mappings, and project allocations. ## Entity Overview ```text [users] 1 ──── N [refresh_tokens] [users] 1 ──── N [two_factor_recovery_codes] [users] 1 ──── N [passkey_credentials] [passkey_challenges] stores short-lived WebAuthn ceremony state [items] 1 ──── N [stock] N ──── 1 [locations] [items] 1 ──── N [project_items] N ──── 1 [projects] ``` ## Tables ### `users` Stores account credentials, roles, and 2FA state. | Column | Type | Constraints | Description | | --- | --- | --- | --- | | `id` | `TEXT` | Primary key | User UUID. | | `username` | `TEXT` | Not null, unique | Lowercased account name. | | `password` | `TEXT` | Not null | bcrypt password hash. | | `role` | `TEXT` | Not null | User role, for example `user` or `admin`. | | `two_factor_enabled` | `INTEGER` | Not null, default `0` | Boolean flag for TOTP 2FA state. | | `two_factor_secret` | `TEXT` | Not null, default `''` | TOTP secret used to validate authenticator codes. Empty when 2FA is disabled. During setup the secret is held in a short-lived signed setup token and is only stored after the first valid TOTP code. | Migration note: existing databases are migrated with `ALTER TABLE` statements for `two_factor_enabled` and `two_factor_secret` if those columns do not exist yet. ### `refresh_tokens` Stores refresh-token sessions. Tokens are stored as hashes, not plaintext. | Column | Type | Constraints | Description | | --- | --- | --- | --- | | `id` | `TEXT` | Primary key | Refresh-token row UUID. | | `user_id` | `TEXT` | Not null, foreign key to `users(id)` | Owning user. | | `token_hash` | `TEXT` | Not null | Hash of the refresh token. | | `expires_at` | `INTEGER` | Not null | Unix timestamp for expiry. | | `created_at` | `INTEGER` | Not null | Unix timestamp for creation. | | `revoked` | `INTEGER` | Not null, default `0` | Boolean revocation flag. | | `device_info` | `TEXT` | Optional | User-Agent string recorded when the session is created. | Refresh-token rotation revokes the used refresh token and inserts a new row for the next token. ### `two_factor_recovery_codes` Stores recovery-code hashes for 2FA fallback login. | Column | Type | Constraints | Description | | --- | --- | --- | --- | | `id` | `TEXT` | Primary key | Recovery-code row UUID. | | `user_id` | `TEXT` | Not null, foreign key to `users(id)` with `ON DELETE CASCADE` | Owning user. | | `code_hash` | `TEXT` | Not null, unique with `user_id` | Hash of the normalized recovery code. | | `created_at` | `INTEGER` | Not null | Unix timestamp for creation. | | `used_at` | `INTEGER` | Nullable | Unix timestamp when the code was consumed. `NULL` means unused. | Recovery codes are deleted and replaced when the user regenerates them. A recovery code is consumed with an atomic update that only matches unused codes. ### `passkey_credentials` Stores WebAuthn passkey credentials for account login. Private keys are not stored by MiauInv; they remain in the authenticator, platform passkey provider, browser, or security key. | Column | Type | Constraints | Description | | --- | --- | --- | --- | | `id` | `TEXT` | Primary key | Passkey row UUID. | | `user_id` | `TEXT` | Not null, foreign key to `users(id)` with `ON DELETE CASCADE` | Owning user. | | `credential_id` | `TEXT` | Not null, unique | Base64url-encoded WebAuthn credential ID. | | `name` | `TEXT` | Not null | User-visible passkey name. | | `credential_data` | `TEXT` | Not null | Serialized WebAuthn credential data, including public-key material and authenticator metadata. | | `created_at` | `INTEGER` | Not null | Unix timestamp for creation. | | `last_used_at` | `INTEGER` | Nullable | Unix timestamp when the passkey was last used successfully. | ### `passkey_challenges` Stores short-lived server-side WebAuthn session data for registration and login ceremonies. The browser receives only an opaque `session_token`. | Column | Type | Constraints | Description | | --- | --- | --- | --- | | `token` | `TEXT` | Primary key | Opaque challenge token returned to the client. | | `user_id` | `TEXT` | Not null, default `''` | Owning user for registration challenges. Empty for discoverable login challenges. | | `ceremony` | `TEXT` | Not null | Challenge type, for example `register` or `login`. | | `session_data` | `TEXT` | Not null | Serialized WebAuthn session data. | | `expires_at` | `INTEGER` | Not null | Unix timestamp after which the challenge is rejected. | Challenge rows are consumed once during the finish step and expired rows are cleaned up opportunistically. ### `items` Stores tracked inventory items. | Column | Type | Constraints | Description | | --- | --- | --- | --- | | `id` | `INTEGER` | Primary key, autoincrement | Item ID. | | `name` | `TEXT` | Not null | Item name. | | `category` | `TEXT` | Optional | Category label. | | `description` | `TEXT` | Optional | Item description. | | `total_quantity` | `INTEGER` | Not null, default `0` | Global quantity baseline. | ### `locations` Stores physical or logical storage locations. | Column | Type | Constraints | Description | | --- | --- | --- | --- | | `id` | `INTEGER` | Primary key, autoincrement | Location ID. | | `name` | `TEXT` | Not null, unique | Location name. | ### `projects` Stores project contexts for item allocation. | Column | Type | Constraints | Description | | --- | --- | --- | --- | | `id` | `INTEGER` | Primary key, autoincrement | Project ID. | | `name` | `TEXT` | Not null, unique | Project name. | | `description` | `TEXT` | Optional | Project description. | ### `stock` Maps item quantities to locations. | Column | Type | Constraints | Description | | --- | --- | --- | --- | | `id` | `INTEGER` | Primary key, autoincrement | Stock row ID. | | `item_id` | `INTEGER` | Not null, foreign key to `items(id)` | Item reference. | | `location_id` | `INTEGER` | Not null, foreign key to `locations(id)` | Location reference. | | `quantity` | `INTEGER` | Not null | Quantity at this location. | ### `project_items` Maps item quantities to projects. | Column | Type | Constraints | Description | | --- | --- | --- | --- | | `id` | `INTEGER` | Primary key, autoincrement | Association row ID. | | `item_id` | `INTEGER` | Not null, foreign key to `items(id)` | Item reference. | | `project_id` | `INTEGER` | Not null, foreign key to `projects(id)` | Project reference. | | `quantity` | `INTEGER` | Not null | Quantity allocated to the project. | ## Data Integrity ### Foreign keys Foreign keys are enabled per connection. Because most inventory foreign keys do not define explicit cascade behavior, SQLite blocks deletion of referenced items, locations, or projects while dependent rows exist. `two_factor_recovery_codes.user_id` and `passkey_credentials.user_id` use `ON DELETE CASCADE`, so deleting a user also deletes their recovery-code and passkey rows. ### Uniqueness The following values are unique: - `users.username` - `locations.name` - `projects.name` - `two_factor_recovery_codes(user_id, code_hash)` - `passkey_credentials.credential_id` ### Current schema limitations The current schema does not yet enforce all business rules at the database level. In particular: - `items.total_quantity` has no explicit `CHECK(total_quantity >= 0)` constraint. - `stock.quantity` has no explicit `CHECK(quantity >= 0)` constraint. - `project_items.quantity` has no explicit `CHECK(quantity >= 0)` constraint. - Duplicate stock rows for the same `(item_id, location_id)` pair are not prevented by a unique constraint. - Duplicate project allocations for the same `(item_id, project_id)` pair are not prevented by a unique constraint. These constraints should be added in a future migration once the desired application behavior is finalized. ## Important Queries and Behaviors ### User lookup Users can be loaded by lowercased username or ID. Username updates store the new username lowercased. ### Password update When the password is updated: 1. The new password is bcrypt-hashed. 2. The `users.password` field is updated. 3. Existing refresh tokens for that user are revoked. 4. A new session is issued. ### Passkey registration When a passkey is registered: 1. The current password is verified. 2. A WebAuthn registration challenge is stored in `passkey_challenges`. 3. The finish step consumes the challenge. 4. The WebAuthn credential is verified. 5. A row is inserted into `passkey_credentials`. 6. Existing refresh tokens for the user are revoked. 7. A new session is issued for the current browser. ### Passkey login When passkey login completes: 1. A discoverable WebAuthn login challenge is consumed from `passkey_challenges`. 2. The credential assertion is verified against the stored credential data. 3. The stored credential data and `last_used_at` value are updated. 4. A normal access/refresh session is issued. ### Passkey removal When a passkey is removed or all passkeys are disabled: 1. The current password is verified. 2. The matching passkey row, or all rows for the user, are deleted. 3. Existing refresh tokens for the user are revoked. 4. A new session is issued for the current browser. ### 2FA enable When 2FA is enabled: 1. The temporary setup token is validated. 2. The supplied TOTP code is validated against the setup secret. 3. Existing recovery codes are deleted. 4. New recovery-code hashes are inserted. 5. `users.two_factor_enabled` is set to `1` and `users.two_factor_secret` is set to the confirmed secret. 6. Existing refresh tokens are revoked and a new session is issued for the current browser. ### 2FA disable When 2FA is disabled: 1. `two_factor_enabled` is set to `0`. 2. `two_factor_secret` is cleared. 3. Recovery-code rows for the user are deleted. 4. Refresh tokens for the user are revoked. ### Recovery-code use A recovery code is consumed with an update equivalent to: ```sql UPDATE two_factor_recovery_codes SET used_at = ? WHERE user_id = ? AND code_hash = ? AND used_at IS NULL; ``` The login succeeds only if exactly one row is updated.