From decaef389bcee3cb82793640d75ab22c01fb6c94 Mon Sep 17 00:00:00 2001
From: TheGeneralist <180094941+thegeneralist01@users.noreply.github.com>
Date: Fri, 26 Jun 2026 11:28:21 +0200
Subject: [PATCH 01/15] docs: mark Track 4 auth foundation done, add Tracks 5-8
stubs, renumber roadmap
---
NEXT.md | 69 +++++++++++++++++++++++++++++++++++++++++++++++++--------
1 file changed, 60 insertions(+), 9 deletions(-)
diff --git a/NEXT.md b/NEXT.md
index abdfa40..221a9ab 100644
--- a/NEXT.md
+++ b/NEXT.md
@@ -83,7 +83,57 @@ A proper queue (channel + worker task) can replace it later without changing the
---
-### 4. Cloud backup — S3-compatible
+### ~~4. Auth foundation — session + role + setup~~ ✅ Done
+
+**Implemented:** `crates/archivr-server/src/auth.rs` (new; `AuthUser` extractor, Argon2id password
+hashing, token generation, role bit constants). `crates/archivr-core/src/database.rs`:
+`initialize_auth_schema` (roles, user_roles, sessions, api_tokens, instance_settings tables);
+`open_auth_db` (separate server-level auth SQLite); `create_owner`, `compute_role_bits`,
+`create_session`, `get_session`, `create_api_token`, `get_user_for_token` and related helpers.
+`crates/archivr-server/src/routes.rs`: `AppState` gains `auth_db_path`; `setup_guard` middleware
+returns 503 until owner created; `POST /api/auth/login`, `POST /api/auth/logout`,
+`GET /api/auth/me`, `GET|POST /api/auth/setup`, `GET|POST|DELETE /api/auth/tokens` endpoints;
+WRITE routes (captures, tags) guarded by `ROLE_USER`. `crates/archivr-server/src/main.rs`:
+computes auth DB path from config directory; session cleanup background task.
+Frontend: `LoginPage.jsx`, `SetupPage.jsx`, `AuthContext` in `App.jsx`, user menu in `Topbar.jsx`.
+`docs/specs/2026-06-25-auth-foundation-design.md` has the full design. Roles use a bitmask:
+guest=1, user=2, admin=4, owner=8. All tests green.
+
+---
+
+### 5. User management
+
+**What:** Registration flow, custom role creation, admin user panel, banning users.
+Depends on Track 4.
+
+---
+
+### 6. Permissions & visibility — collection model
+
+**What:** Replace `archived_entries.visibility` with a `collections` + `collection_entries`
+model where visibility is a role-bitmask per entry-in-collection. Enforce visibility on all
+API queries. Collection UI (create, set visibility, add entries).
+Depends on Track 5.
+
+---
+
+### 7. Settings
+
+**What:** Account profile page (display name, password change). Instance settings UI
+(open registration toggle, default visibility). API token management UI.
+Depends on Track 5.
+
+---
+
+### 8. Collections UI
+
+**What:** Full collection management — create, rename, delete, add/remove entries,
+set per-entry visibility within a collection, make collections public.
+Depends on Tracks 5–6.
+
+---
+
+### 9. Cloud backup — S3-compatible
**What:** A command or scheduled operation that syncs the archive store to an S3-compatible
bucket (AWS S3, Cloudflare R2, Backblaze B2). Incremental: only uploads blobs not already
@@ -123,7 +173,7 @@ region = "us-east-1"
---
-### 5. Cloud storage archiving (Google Drive, Dropbox, OneDrive)
+### 10. Cloud storage archiving (Google Drive, Dropbox, OneDrive)
Deferred. Each requires per-service OAuth, API clients, and download logic. Implement after
Tracks 1–4 are stable.
@@ -136,18 +186,19 @@ and a corresponding downloader module. Consider `rclone` as a shell-out strategy
## What to Do First
+Tracks 1, 2, and 4 are complete. Track 3 (async capture jobs) is the next priority.
+
Open the next thread with:
```text
-Read ARCHIVR-MENTAL-MODEL.md and NEXT.md. I want to implement Track 1: generic URL capture
-(plain file download over HTTP/S). Create a task-level implementation plan first, then wait
-for approval.
+Read ARCHIVR-MENTAL-MODEL.md and NEXT.md. I want to implement Track 3: async capture jobs.
+Create a task-level implementation plan first, then wait for approval.
```
-For Track 2:
+For Track 5 (user management), begin only after Track 4 is verified in production:
```text
-Read ARCHIVR-MENTAL-MODEL.md and NEXT.md. I want to implement Track 2: web page archiving
-via monolith. Start by deciding the URL classification strategy (head-first vs. extension
-heuristic vs. user prefix), then write the implementation plan.
+Read ARCHIVR-MENTAL-MODEL.md and NEXT.md. I want to implement Track 5: user management
+(registration flow, custom roles, admin panel). Create a task-level implementation plan
+first, then wait for approval.
```
From 57fc48d73cbf0bdeab78ea1cf79186d2b53fec80 Mon Sep 17 00:00:00 2001
From: TheGeneralist <180094941+thegeneralist01@users.noreply.github.com>
Date: Fri, 26 Jun 2026 11:28:51 +0200
Subject: [PATCH 02/15] feat(auth): add argon2, rand, axum-extra dependencies
---
Cargo.lock | 187 +++++++++++++++++++++++++++++++
Cargo.toml | 3 +
crates/archivr-server/Cargo.toml | 3 +
3 files changed, 193 insertions(+)
diff --git a/Cargo.lock b/Cargo.lock
index 9ce1bd3..fb81f0a 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -125,7 +125,10 @@ version = "0.1.0"
dependencies = [
"anyhow",
"archivr-core",
+ "argon2",
"axum",
+ "axum-extra",
+ "rand",
"serde",
"serde_json",
"tempfile",
@@ -135,6 +138,18 @@ dependencies = [
"tower-http",
]
+[[package]]
+name = "argon2"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
+dependencies = [
+ "base64ct",
+ "blake2",
+ "cpufeatures",
+ "password-hash",
+]
+
[[package]]
name = "async-trait"
version = "0.1.89"
@@ -213,18 +228,57 @@ dependencies = [
"tracing",
]
+[[package]]
+name = "axum-extra"
+version = "0.9.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c794b30c904f0a1c2fb7740f7df7f7972dfaa14ef6f57cb6178dc63e5dca2f04"
+dependencies = [
+ "axum",
+ "axum-core",
+ "bytes",
+ "cookie",
+ "fastrand",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "mime",
+ "multer",
+ "pin-project-lite",
+ "serde",
+ "tower",
+ "tower-layer",
+ "tower-service",
+]
+
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+[[package]]
+name = "base64ct"
+version = "1.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
+
[[package]]
name = "bitflags"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
+[[package]]
+name = "blake2"
+version = "0.10.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
+dependencies = [
+ "digest",
+]
+
[[package]]
name = "block-buffer"
version = "0.10.4"
@@ -321,6 +375,17 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
+[[package]]
+name = "cookie"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
+dependencies = [
+ "percent-encoding",
+ "time",
+ "version_check",
+]
+
[[package]]
name = "core-foundation"
version = "0.9.4"
@@ -366,6 +431,12 @@ dependencies = [
"typenum",
]
+[[package]]
+name = "deranged"
+version = "0.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
+
[[package]]
name = "digest"
version = "0.10.7"
@@ -374,6 +445,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
+ "subtle",
]
[[package]]
@@ -979,6 +1051,23 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "multer"
+version = "3.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
+dependencies = [
+ "bytes",
+ "encoding_rs",
+ "futures-util",
+ "http",
+ "httparse",
+ "memchr",
+ "mime",
+ "spin",
+ "version_check",
+]
+
[[package]]
name = "native-tls"
version = "0.2.18"
@@ -996,6 +1085,12 @@ dependencies = [
"tempfile",
]
+[[package]]
+name = "num-conv"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
+
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -1060,6 +1155,17 @@ dependencies = [
"vcpkg",
]
+[[package]]
+name = "password-hash"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
+dependencies = [
+ "base64ct",
+ "rand_core",
+ "subtle",
+]
+
[[package]]
name = "percent-encoding"
version = "2.3.2"
@@ -1087,6 +1193,21 @@ dependencies = [
"zerovec",
]
+[[package]]
+name = "powerfmt"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+
+[[package]]
+name = "ppv-lite86"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+dependencies = [
+ "zerocopy",
+]
+
[[package]]
name = "proc-macro2"
version = "1.0.101"
@@ -1111,6 +1232,36 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+[[package]]
+name = "rand"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
+dependencies = [
+ "libc",
+ "rand_chacha",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
+dependencies = [
+ "ppv-lite86",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+dependencies = [
+ "getrandom 0.2.17",
+]
+
[[package]]
name = "regex"
version = "1.12.2"
@@ -1413,6 +1564,12 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "spin"
+version = "0.9.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
+
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
@@ -1496,6 +1653,36 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "time"
+version = "0.3.51"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327"
+dependencies = [
+ "deranged",
+ "num-conv",
+ "powerfmt",
+ "serde_core",
+ "time-core",
+ "time-macros",
+]
+
+[[package]]
+name = "time-core"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
+
+[[package]]
+name = "time-macros"
+version = "0.2.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dcef1a61bdb119096e153208ec5cbec23944ce8bca13be5c7f60c634f7403935"
+dependencies = [
+ "num-conv",
+ "time-core",
+]
+
[[package]]
name = "tinystr"
version = "0.8.2"
diff --git a/Cargo.toml b/Cargo.toml
index f88610d..64e0fa4 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -29,3 +29,6 @@ tower-http = { version = "0.6.2", features = ["fs", "trace"] }
uuid = { version = "1.18.1", features = ["v4"] }
reqwest = { version = "0.12", features = ["blocking"] }
base64 = "0.22"
+argon2 = { version = "0.5", features = ["std"] }
+rand = { version = "0.8", features = ["std"] }
+axum-extra = { version = "0.9", features = ["cookie"] }
diff --git a/crates/archivr-server/Cargo.toml b/crates/archivr-server/Cargo.toml
index 4c3548e..61ba19e 100644
--- a/crates/archivr-server/Cargo.toml
+++ b/crates/archivr-server/Cargo.toml
@@ -12,6 +12,9 @@ tokio.workspace = true
toml.workspace = true
tower.workspace = true
tower-http.workspace = true
+argon2.workspace = true
+rand.workspace = true
+axum-extra.workspace = true
[dev-dependencies]
tempfile.workspace = true
From 0a5c899c5eebe7869d2bfd4f2c0de77cfb9b249a Mon Sep 17 00:00:00 2001
From: TheGeneralist <180094941+thegeneralist01@users.noreply.github.com>
Date: Fri, 26 Jun 2026 11:28:58 +0200
Subject: [PATCH 03/15] feat(auth): frontend login/setup pages + auth state in
App.jsx
---
frontend/src/App.jsx | 173 +++++++++++++++-----------
frontend/src/api.js | 51 ++++++++
frontend/src/components/LoginPage.jsx | 54 ++++++++
frontend/src/components/SetupPage.jsx | 73 +++++++++++
4 files changed, 280 insertions(+), 71 deletions(-)
create mode 100644 frontend/src/components/LoginPage.jsx
create mode 100644 frontend/src/components/SetupPage.jsx
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index f663efe..f6f119b 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -1,5 +1,8 @@
-import { useState, useEffect, useCallback, useRef } from 'react'
-import { fetchArchives, fetchEntries, searchEntries, fetchRuns, fetchTags } from './api'
+import { useState, useEffect, useCallback, useRef, createContext } from 'react'
+import { fetchArchives, fetchEntries, searchEntries, fetchRuns, fetchTags, checkSetup, fetchMe } from './api'
+import LoginPage from './components/LoginPage.jsx'
+import SetupPage from './components/SetupPage.jsx'
+
import Topbar from './components/Topbar'
import CaptureDialog from './components/CaptureDialog'
import EntriesView from './components/EntriesView'
@@ -8,7 +11,29 @@ import AdminView from './components/AdminView'
import TagsView from './components/TagsView'
import ContextRail from './components/ContextRail'
+export const AuthContext = createContext(null);
+
export default function App() {
+ const [authState, setAuthState] = useState('loading');
+ const [currentUser, setCurrentUser] = useState(null);
+
+ useEffect(() => {
+ (async () => {
+ const needsSetup = await checkSetup();
+ if (needsSetup) { setAuthState('setup'); return; }
+ const user = await fetchMe();
+ if (!user) { setAuthState('login'); return; }
+ setCurrentUser(user);
+ setAuthState('authenticated');
+ })();
+ }, []);
+
+ useEffect(() => {
+ const handler = () => { setCurrentUser(null); setAuthState('login'); };
+ window.addEventListener('auth:expired', handler);
+ return () => window.removeEventListener('auth:expired', handler);
+ }, []);
+
const [archives, setArchives] = useState([])
const [archiveId, setArchiveId] = useState(null)
const [entries, setEntries] = useState([])
@@ -127,77 +152,83 @@ export default function App() {
])
}, [archiveId, searchQuery, tagFilter, loadEntries])
+ if (authState === 'loading') return
Loading\u2026
;
+ if (authState === 'setup') return setAuthState('login')} />;
+ if (authState === 'login') return { setCurrentUser(user); setAuthState('authenticated'); }} />;
+
return (
- <>
-
-
-
- {view === 'archive' && (
-
-
setSearchQuery(e.target.value)}
- />
-
- {resultCount}
- {tagFilter && (
-
- )}
-
-
- )}
- {view === 'archive' && (
-
- )}
- {view === 'runs' &&
}
- {view === 'admin' &&
}
- {view === 'tags' && (
-
- )}
-
-
+ <>
+
-
-
- >
+
+
+ {view === 'archive' && (
+
+
setSearchQuery(e.target.value)}
+ />
+
+ {resultCount}
+ {tagFilter && (
+
+ )}
+
+
+ )}
+ {view === 'archive' && (
+
+ )}
+ {view === 'runs' &&
}
+ {view === 'admin' &&
}
+ {view === 'tags' && (
+
+ )}
+
+
+
+
+ >
+
)
}
diff --git a/frontend/src/api.js b/frontend/src/api.js
index 0ce034b..1af25c8 100644
--- a/frontend/src/api.js
+++ b/frontend/src/api.js
@@ -68,3 +68,54 @@ export async function submitCapture(archiveId, locator) {
throw new Error(msg || `HTTP ${res.status}`);
}
}
+
+// ── Auth helpers ─────────────────────────────────────────────────────────────
+
+export async function checkSetup() {
+ const r = await fetch('/api/auth/setup');
+ const data = await r.json();
+ return data.setup_required === true;
+}
+
+export async function doSetup(username, password) {
+ const r = await fetch('/api/auth/setup', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ username, password }),
+ });
+ if (!r.ok) throw new Error((await r.json()).error || 'Setup failed');
+ return r.json();
+}
+
+export async function login(username, password) {
+ const r = await fetch('/api/auth/login', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ username, password }),
+ });
+ if (!r.ok) throw new Error((await r.json()).error || 'Login failed');
+ return r.json();
+}
+
+export async function logout() {
+ await fetch('/api/auth/logout', { method: 'POST' });
+}
+
+export async function fetchMe() {
+ const r = await fetch('/api/auth/me');
+ if (r.status === 401) return null;
+ return r.json();
+}
+
+// ── 401 interceptor ───────────────────────────────────────────────────────────
+const _origFetch = window.fetch;
+window.fetch = async (...args) => {
+ const r = await _origFetch(...args);
+ if (r.status === 401) {
+ const url = typeof args[0] === 'string' ? args[0] : args[0]?.url ?? '';
+ if (!url.includes('/api/auth/')) {
+ window.dispatchEvent(new CustomEvent('auth:expired'));
+ }
+ }
+ return r;
+};
diff --git a/frontend/src/components/LoginPage.jsx b/frontend/src/components/LoginPage.jsx
new file mode 100644
index 0000000..105ed5a
--- /dev/null
+++ b/frontend/src/components/LoginPage.jsx
@@ -0,0 +1,54 @@
+import { useState } from 'react';
+import { login } from '../api.js';
+
+export default function LoginPage({ onLogin }) {
+ const [username, setUsername] = useState('');
+ const [password, setPassword] = useState('');
+ const [error, setError] = useState(null);
+ const [loading, setLoading] = useState(false);
+
+ async function handleSubmit(e) {
+ e.preventDefault();
+ setError(null);
+ setLoading(true);
+ try {
+ const user = await login(username, password);
+ onLogin(user);
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/SetupPage.jsx b/frontend/src/components/SetupPage.jsx
new file mode 100644
index 0000000..aca4cb3
--- /dev/null
+++ b/frontend/src/components/SetupPage.jsx
@@ -0,0 +1,73 @@
+import { useState } from 'react';
+import { doSetup } from '../api.js';
+
+export default function SetupPage({ onComplete }) {
+ const [username, setUsername] = useState('');
+ const [password, setPassword] = useState('');
+ const [confirm, setConfirm] = useState('');
+ const [error, setError] = useState(null);
+ const [loading, setLoading] = useState(false);
+
+ async function handleSubmit(e) {
+ e.preventDefault();
+ if (password !== confirm) {
+ setError('Passwords do not match');
+ return;
+ }
+ if (password.length < 8) {
+ setError('Password must be at least 8 characters');
+ return;
+ }
+ setError(null);
+ setLoading(true);
+ try {
+ await doSetup(username, password);
+ onComplete();
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ return (
+
+
Welcome to Archivr
+
Create your owner account to get started.
+
+
+ );
+}
From 7fcc207de71b63535f3f2fa8e293f3890acaf88c Mon Sep 17 00:00:00 2001
From: TheGeneralist <180094941+thegeneralist01@users.noreply.github.com>
Date: Fri, 26 Jun 2026 11:29:13 +0200
Subject: [PATCH 04/15] feat(auth): user menu and logout button in Topbar
---
frontend/src/components/Topbar.jsx | 24 +++++++++++++++++++++++-
1 file changed, 23 insertions(+), 1 deletion(-)
diff --git a/frontend/src/components/Topbar.jsx b/frontend/src/components/Topbar.jsx
index b81d72a..fb55ea7 100644
--- a/frontend/src/components/Topbar.jsx
+++ b/frontend/src/components/Topbar.jsx
@@ -1,4 +1,18 @@
+import { useContext, useState } from 'react';
+import { AuthContext } from '../App.jsx';
+import { logout as apiLogout } from '../api.js';
+
export default function Topbar({ archives, archiveId, onArchiveChange, view, onViewChange, onCaptureClick }) {
+ const { currentUser, setCurrentUser } = useContext(AuthContext) ?? {};
+ const [loggingOut, setLoggingOut] = useState(false);
+
+ async function handleLogout() {
+ setLoggingOut(true);
+ await apiLogout();
+ setCurrentUser(null);
+ window.location.reload();
+ }
+
return (
- )
+ );
}
From 696a5e1ac707ee0e3d19e7350b8affc0251a5b9d Mon Sep 17 00:00:00 2001
From: TheGeneralist <180094941+thegeneralist01@users.noreply.github.com>
Date: Fri, 26 Jun 2026 11:30:02 +0200
Subject: [PATCH 05/15] feat(auth): add initialize_auth_schema, open_auth_db,
and auth record types
---
crates/archivr-core/src/database.rs | 141 ++++++++++++++++++++++++++++
1 file changed, 141 insertions(+)
diff --git a/crates/archivr-core/src/database.rs b/crates/archivr-core/src/database.rs
index 69521dc..fdd80a1 100644
--- a/crates/archivr-core/src/database.rs
+++ b/crates/archivr-core/src/database.rs
@@ -73,6 +73,31 @@ pub struct TagRecord {
pub full_path: String,
}
+#[derive(Debug, Clone)]
+pub struct AuthUserRecord {
+ pub id: i64,
+ pub user_uid: String,
+ pub username: String,
+ pub password_hash: String,
+ pub status: String,
+}
+
+#[derive(Debug, Clone)]
+pub struct SessionRecord {
+ pub user_id: i64,
+ pub role_bits: u32,
+ pub last_seen_at: String,
+ pub session_uid: String,
+}
+
+#[derive(Debug, Clone, serde::Serialize)]
+pub struct ApiTokenRecord {
+ pub token_uid: String,
+ pub name: String,
+ pub created_at: String,
+ pub last_used_at: Option,
+}
+
pub fn database_path(archive_path: &Path) -> PathBuf {
archive_path.join(DATABASE_FILE_NAME)
}
@@ -230,6 +255,101 @@ pub fn initialize_schema(conn: &Connection) -> Result<()> {
Ok(())
}
+pub fn initialize_auth_schema(conn: &Connection) -> Result<()> {
+ conn.pragma_update(None, "journal_mode", "WAL")?;
+ conn.pragma_update(None, "foreign_keys", "ON")?;
+ conn.execute_batch(
+ r#"
+ CREATE TABLE IF NOT EXISTS roles (
+ id INTEGER PRIMARY KEY,
+ role_uid TEXT NOT NULL UNIQUE,
+ slug TEXT NOT NULL UNIQUE,
+ name TEXT NOT NULL,
+ level INTEGER NOT NULL,
+ bit_position INTEGER NOT NULL UNIQUE,
+ is_builtin INTEGER NOT NULL DEFAULT 0 CHECK (is_builtin IN (0, 1))
+ );
+
+ INSERT OR IGNORE INTO roles (role_uid, slug, name, level, bit_position, is_builtin) VALUES
+ ('role-guest', 'guest', 'Guest', 0, 0, 1),
+ ('role-user', 'user', 'User', 1, 1, 1),
+ ('role-admin', 'admin', 'Admin', 3, 2, 1),
+ ('role-owner', 'owner', 'Owner', 4, 3, 1);
+
+ CREATE TABLE IF NOT EXISTS user_roles (
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ role_id INTEGER NOT NULL REFERENCES roles(id),
+ assigned_at TEXT NOT NULL,
+ assigned_by_user_id INTEGER REFERENCES users(id),
+ PRIMARY KEY (user_id, role_id)
+ );
+
+ CREATE TABLE IF NOT EXISTS sessions (
+ id INTEGER PRIMARY KEY,
+ session_uid TEXT NOT NULL UNIQUE,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ role_bits INTEGER NOT NULL,
+ created_at TEXT NOT NULL,
+ last_seen_at TEXT NOT NULL,
+ expires_at TEXT NOT NULL,
+ user_agent TEXT
+ );
+ CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
+
+ CREATE TABLE IF NOT EXISTS api_tokens (
+ id INTEGER PRIMARY KEY,
+ token_uid TEXT NOT NULL UNIQUE,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ token_hash TEXT NOT NULL UNIQUE,
+ name TEXT NOT NULL,
+ created_at TEXT NOT NULL,
+ last_used_at TEXT,
+ expires_at TEXT
+ );
+ CREATE INDEX IF NOT EXISTS idx_api_tokens_user_id ON api_tokens(user_id);
+
+ CREATE TABLE IF NOT EXISTS instance_settings (
+ id INTEGER PRIMARY KEY CHECK (id = 1),
+ public_index_enabled INTEGER NOT NULL DEFAULT 0 CHECK (public_index_enabled IN (0, 1)),
+ public_entry_content_enabled INTEGER NOT NULL DEFAULT 0 CHECK (public_entry_content_enabled IN (0, 1)),
+ public_archive_submission_enabled INTEGER NOT NULL DEFAULT 0 CHECK (public_archive_submission_enabled IN (0, 1)),
+ default_entry_visibility INTEGER NOT NULL DEFAULT 2
+ );
+
+ INSERT OR IGNORE INTO instance_settings
+ (id, public_index_enabled, public_entry_content_enabled,
+ public_archive_submission_enabled, default_entry_visibility)
+ VALUES (1, 0, 0, 0, 2);
+
+ CREATE TABLE IF NOT EXISTS users (
+ id INTEGER PRIMARY KEY,
+ user_uid TEXT NOT NULL UNIQUE,
+ username TEXT NOT NULL UNIQUE,
+ email TEXT UNIQUE,
+ password_hash TEXT NOT NULL,
+ status TEXT NOT NULL CHECK (status IN ('active', 'disabled')),
+ role TEXT NOT NULL CHECK (role IN ('admin', 'user')),
+ created_at TEXT NOT NULL,
+ last_login_at TEXT
+ );
+ "#,
+ )?;
+ Ok(())
+}
+
+pub fn open_auth_db(auth_db_path: &Path) -> Result {
+ if let Some(parent) = auth_db_path.parent() {
+ std::fs::create_dir_all(parent).with_context(|| {
+ format!("failed to create auth DB directory {}", parent.display())
+ })?;
+ }
+ let conn = Connection::open(auth_db_path).with_context(|| {
+ format!("failed to open auth database at {}", auth_db_path.display())
+ })?;
+ initialize_auth_schema(&conn)?;
+ Ok(conn)
+}
+
pub fn ensure_default_user(conn: &Connection) -> Result {
if let Some(id) = conn
.query_row(
@@ -1149,4 +1269,25 @@ mod tests {
let result = get_blob_by_sha256(&conn, "0000000000000000000000000000000000000000000000000000000000000000").unwrap();
assert!(result.is_none());
}
+
+ #[test]
+ fn auth_schema_seeds_builtin_roles() {
+ let conn = Connection::open_in_memory().unwrap();
+ initialize_auth_schema(&conn).unwrap();
+ let count: i64 = conn
+ .query_row("SELECT COUNT(*) FROM roles WHERE is_builtin = 1", [], |r| r.get(0))
+ .unwrap();
+ assert_eq!(count, 4);
+ let owner_bits: i64 = conn
+ .query_row("SELECT bit_position FROM roles WHERE slug = 'owner'", [], |r| r.get(0))
+ .unwrap();
+ assert_eq!(owner_bits, 3);
+ }
+
+ #[test]
+ fn auth_schema_is_idempotent() {
+ let conn = Connection::open_in_memory().unwrap();
+ initialize_auth_schema(&conn).unwrap();
+ initialize_auth_schema(&conn).unwrap();
+ }
}
From f482707b75cce293b363014a1ccc2eda6045272d Mon Sep 17 00:00:00 2001
From: TheGeneralist <180094941+thegeneralist01@users.noreply.github.com>
Date: Fri, 26 Jun 2026 11:31:20 +0200
Subject: [PATCH 06/15] feat(auth): user and role DB helpers (create_owner,
compute_role_bits)
---
crates/archivr-core/src/database.rs | 101 ++++++++++++++++++++++++++++
1 file changed, 101 insertions(+)
diff --git a/crates/archivr-core/src/database.rs b/crates/archivr-core/src/database.rs
index fdd80a1..bbe6159 100644
--- a/crates/archivr-core/src/database.rs
+++ b/crates/archivr-core/src/database.rs
@@ -350,6 +350,74 @@ pub fn open_auth_db(auth_db_path: &Path) -> Result {
Ok(conn)
}
+/// Returns true if an owner account exists.
+pub fn ensure_owner_exists(conn: &Connection) -> Result {
+ let count: i64 = conn.query_row(
+ "SELECT COUNT(*) FROM user_roles ur
+ JOIN roles r ON r.id = ur.role_id
+ WHERE r.slug = 'owner'",
+ [],
+ |row| row.get(0),
+ )?;
+ Ok(count > 0)
+}
+
+/// Creates a user and assigns all roles from `user` up to `owner` (cumulative).
+/// `password_hash` must already be hashed by the caller.
+pub fn create_owner(conn: &Connection, username: &str, password_hash: &str) -> Result {
+ let user_uid = public_id("usr");
+ conn.execute(
+ "INSERT INTO users (user_uid, username, email, password_hash, status, role, created_at)
+ VALUES (?1, ?2, NULL, ?3, 'active', 'admin', ?4)",
+ params![user_uid, username, password_hash, now_timestamp()],
+ )?;
+ let user_id = conn.last_insert_rowid();
+ for slug in &["user", "admin", "owner"] {
+ let role_id: i64 = conn.query_row(
+ "SELECT id FROM roles WHERE slug = ?1",
+ [slug],
+ |row| row.get(0),
+ )?;
+ conn.execute(
+ "INSERT OR IGNORE INTO user_roles (user_id, role_id, assigned_at)
+ VALUES (?1, ?2, ?3)",
+ params![user_id, role_id, now_timestamp()],
+ )?;
+ }
+ Ok(user_id)
+}
+
+pub fn get_user_by_username(conn: &Connection, username: &str) -> Result