mirror of
https://github.com/thegeneralist01/archivr
synced 2026-07-21 18:55:36 +02:00
chore: remove docs/superpowers from repo and gitignore whitelist
This commit is contained in:
parent
917b4371a6
commit
5e7ce4774b
4 changed files with 0 additions and 2602 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -7,8 +7,6 @@
|
|||
!docs/
|
||||
!docs/LICENSE
|
||||
!docs/README*
|
||||
!docs/superpowers/
|
||||
!docs/superpowers/**
|
||||
!crates
|
||||
!crates/**
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,220 +0,0 @@
|
|||
# Playlist / Channel Captures + Parent–Child Entry Display
|
||||
|
||||
**Date:** 2026-07-20
|
||||
**Branch:** `feat-captures-with-children`
|
||||
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
1. **YouTube (Music) playlists and channels** — when the user submits a playlist or channel URL/shorthand, archive every video in it as a tree of entries: one root "container" entry for the playlist/channel, one child entry per video.
|
||||
2. **Parent–child display** — the entry list and detail view can show children of a container entry in a collapsible inline section, replacing the current flat/single-entry-only model.
|
||||
|
||||
---
|
||||
|
||||
## Schema Contract (no migrations needed — columns already exist)
|
||||
|
||||
| Column | Meaning |
|
||||
|---|---|
|
||||
| `archived_entries.parent_entry_id` | `NULL` for root entries; points to container id for children |
|
||||
| `archived_entries.root_entry_id` | Same as `parent_entry_id` for depth-1 children (direct children of a container) |
|
||||
| `archive_run_items.parent_item_id` | `NULL` for the container item; points to container item id for video items |
|
||||
| `archive_runs.requested_count` | Always `1` (user submitted one locator) |
|
||||
| `archive_runs.discovered_count` | `N + 1` — container item + N video items (from `refresh_run_counters`) |
|
||||
| `archive_runs.completed_count` | Items completed (container + each successful video) |
|
||||
| `archive_runs.failed_count` | Items that failed to download |
|
||||
|
||||
**Counter note:** `refresh_run_counters` counts every `archive_run_items` row, so `discovered_count = 1 + N` when a playlist has N videos. This is intentional and accurate — the container item itself counts as a discovered/completed item.
|
||||
|
||||
---
|
||||
|
||||
## yt-dlp Probe Strategy
|
||||
|
||||
For container sources (playlist, channel, YouTube Music playlist), use:
|
||||
|
||||
```bash
|
||||
yt-dlp -J --flat-playlist <url>
|
||||
```
|
||||
|
||||
`-J` (`--dump-single-json`) returns a **single JSON object** for the whole container — reliable `title` and `uploader` fields at the top level, plus an `entries` array (shallow per-item objects with at minimum `id`, `url`, `title`). This avoids the per-item `playlist_title` reliability issue that `--dump-json` (multi-line) has.
|
||||
|
||||
Individual child video downloads stay single-item (`--no-playlist`), same as current video capture.
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| File | Action | What changes |
|
||||
|---|---|---|
|
||||
| `crates/archivr-core/src/downloader/ytdlp.rs` | Modify | Add `PlaylistItem`, `PlaylistInfo` structs; `fetch_playlist_info()` |
|
||||
| `crates/archivr-core/src/capture.rs` | Modify | `record_media_entry` gains `parent_entry_id`/`root_entry_id` params; add `record_container_entry()`; implement playlist/channel/YTM-playlist capture paths |
|
||||
| `crates/archivr-core/src/archive.rs` | Modify | Add `child_count: i64` to `EntrySummary`; add `get_entry_summary()`; fix `get_entry_detail()` to handle child entries; add `list_child_entries()` |
|
||||
| `crates/archivr-server/src/routes.rs` | Modify | Add `GET /api/archives/:id/entries/:uid/children` route + handler |
|
||||
| `frontend/src/api.js` | Modify | Add `fetchEntryChildren()` |
|
||||
| `frontend/src/components/EntryRow.jsx` | Modify | Expand toggle when `child_count > 0`; inline child sub-rows on expand |
|
||||
| `frontend/src/styles.css` | Modify | Child row indentation + expand toggle styling |
|
||||
|
||||
---
|
||||
|
||||
## Detailed Design
|
||||
|
||||
### `ytdlp.rs` additions
|
||||
|
||||
```rust
|
||||
pub struct PlaylistItem {
|
||||
pub id: String,
|
||||
pub url: String,
|
||||
pub title: Option<String>,
|
||||
pub uploader: Option<String>,
|
||||
}
|
||||
|
||||
pub struct PlaylistInfo {
|
||||
pub playlist_id: String,
|
||||
pub title: Option<String>,
|
||||
pub uploader: Option<String>,
|
||||
pub items: Vec<PlaylistItem>,
|
||||
}
|
||||
|
||||
/// Runs `yt-dlp -J --flat-playlist <url>` and parses the result.
|
||||
/// Returns Err if yt-dlp fails or the output isn't a playlist object.
|
||||
pub fn fetch_playlist_info(url: &str, cookies: &HashMap<String, String>) -> Result<PlaylistInfo>
|
||||
```
|
||||
|
||||
### `capture.rs` changes
|
||||
|
||||
**`record_media_entry` signature extension:**
|
||||
```rust
|
||||
fn record_media_entry(
|
||||
...,
|
||||
title: Option<String>,
|
||||
parent_entry_id: Option<i64>, // NEW
|
||||
root_entry_id: Option<i64>, // NEW
|
||||
) -> Result<database::ArchivedEntry>
|
||||
```
|
||||
All existing call sites pass `None, None`. Playlist child calls pass the container entry's ids.
|
||||
|
||||
**New `record_container_entry()`:**
|
||||
- Creates an entry with no blob and no `primary_media` artifact.
|
||||
- `source_kind/entity_kind` from `source_metadata(source)`.
|
||||
- `parent_entry_id: None, root_entry_id: None`.
|
||||
- Stores playlist metadata in `source_metadata_json`.
|
||||
- Returns the `ArchivedEntry` (needed for child `parent_entry_id`).
|
||||
- Calls `database::complete_archive_run_item()` on the container run item.
|
||||
|
||||
**Playlist/channel capture path (replaces `return Err(...)` stubs):**
|
||||
```
|
||||
1. fetch_playlist_info(url, cookies) → PlaylistInfo
|
||||
2. create_archive_run(conn, user_id, 1) → run (requested_count=1)
|
||||
3. create_archive_run_item(run, None, 0, ..., "playlist"/"channel", "container")
|
||||
4. record_container_entry(...) → container_entry
|
||||
5. complete_archive_run_item(container_item, container_entry.id)
|
||||
6. for (ordinal, item) in playlist_info.items:
|
||||
a. create_archive_run_item(run, Some(container_item.id), ordinal, item.url, ...)
|
||||
b. fetch_metadata(item.url, cookies) → metadata_json (for title)
|
||||
c. ytdlp::download(item.url, ...) → (hash, ext)
|
||||
d. record_media_entry(..., Some(container.id), Some(container.id))
|
||||
OR: fail_archive_run_item(child_item, error) and continue
|
||||
7. finish_archive_run(conn, run.id)
|
||||
```
|
||||
|
||||
Error handling: if a child video fails, call `fail_archive_run_item` and continue — partial success is correct for playlists.
|
||||
|
||||
### `archive.rs` changes
|
||||
|
||||
**`EntrySummary` new field:**
|
||||
```rust
|
||||
pub child_count: i64, // number of direct children; 0 for non-container entries
|
||||
```
|
||||
|
||||
**`ENTRY_SELECT_COLS` extension** (adds col 12):
|
||||
```sql
|
||||
(SELECT COUNT(*) FROM archived_entries child WHERE child.parent_entry_id = e.id) AS child_count
|
||||
```
|
||||
|
||||
**`list_root_entries` inline SQL** also extended the same way (col 12).
|
||||
|
||||
All `query_map` closures that build `EntrySummary` get `child_count: row.get(12)?`.
|
||||
|
||||
**`get_entry_summary(conn, entry_uid)`** — new private helper:
|
||||
- Fetches one entry by uid without the `parent_entry_id IS NULL` constraint.
|
||||
- Used by the fixed `get_entry_detail`.
|
||||
|
||||
**`get_entry_detail` fix:**
|
||||
```rust
|
||||
// BEFORE (broken for child entries):
|
||||
let summary = list_root_entries(conn, u32::MAX)?
|
||||
.into_iter()
|
||||
.find(|entry| entry.entry_uid == entry_uid)
|
||||
.context("entry disappeared")?;
|
||||
|
||||
// AFTER:
|
||||
let summary = get_entry_summary(conn, entry_uid)?
|
||||
.context("entry disappeared")?;
|
||||
```
|
||||
|
||||
**New `list_child_entries(conn, parent_uid) -> Result<Vec<EntrySummary>>`:**
|
||||
```sql
|
||||
ENTRY_SELECT_COLS ENTRY_FROM_JOINS
|
||||
WHERE e.parent_entry_id = (SELECT id FROM archived_entries WHERE entry_uid = ?1)
|
||||
GROUP BY e.id
|
||||
ORDER BY e.archived_at ASC, e.id ASC
|
||||
```
|
||||
(ascending order — preserves playlist ordinal feel)
|
||||
|
||||
### `routes.rs` addition
|
||||
|
||||
```
|
||||
.route(
|
||||
"/api/archives/:archive_id/entries/:entry_uid/children",
|
||||
get(list_entry_children),
|
||||
)
|
||||
```
|
||||
|
||||
```rust
|
||||
async fn list_entry_children(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path((archive_id, entry_uid)): Path<(String, String)>,
|
||||
) -> Result<Json<Vec<archive::EntrySummary>>, ApiError> {
|
||||
auth.require_auth()?;
|
||||
let mounted = mounted_archive(&state, &archive_id)?;
|
||||
let conn = database::open_or_initialize(&mounted.archive_path)?;
|
||||
Ok(Json(archive::list_child_entries(&conn, &entry_uid)?))
|
||||
}
|
||||
```
|
||||
|
||||
### Frontend changes
|
||||
|
||||
**`api.js`:**
|
||||
```js
|
||||
export async function fetchEntryChildren(archiveId, entryUid) {
|
||||
return getJson(`/api/archives/${archiveId}/entries/${entryUid}/children`);
|
||||
}
|
||||
```
|
||||
|
||||
**`EntryRow.jsx`:**
|
||||
- Accept optional `archiveId` for child fetching (already passed).
|
||||
- When `entry.child_count > 0`: render a chevron expand button in `col-title` area.
|
||||
- Local state: `expanded` (bool), `children` (array | null), `loading` (bool).
|
||||
- On chevron click: toggle; if expanding and `children === null`, call `fetchEntryChildren` and store result.
|
||||
- Render children as `<div className="child-entries">` containing simplified `<ChildEntryRow>` elements (or reuse `EntryRow` without nesting).
|
||||
|
||||
**`styles.css`:**
|
||||
- `.child-entries` — slight left indent, separator line.
|
||||
- `.entry-expand-btn` — minimal chevron button.
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- `cargo test -p archivr-core` green.
|
||||
- `cargo check -p archivr-server` clean.
|
||||
- Submitting `yt:playlist/PLxxx` archives the playlist as a container entry with N child video entries in the DB.
|
||||
- Submitting `yt:@handle` or `yt:channel/UC...` does the same for channel uploads.
|
||||
- Submitting `ytm:playlist/PLxxx` does the same for YouTube Music playlists.
|
||||
- Individual video/track captures (`yt:video/ID`, `ytm:ID`) unchanged.
|
||||
- If one video in a playlist fails, the run status is `failed` but other children are still archived.
|
||||
- `GET /api/archives/:id/entries` returns root entries only, each with correct `child_count`.
|
||||
- `GET /api/archives/:id/entries/:uid` works for both root and child entries.
|
||||
- `GET /api/archives/:id/entries/:uid/children` returns the child entries for a container.
|
||||
- Frontend expand button appears on container entries; clicking it fetches and shows children inline.
|
||||
|
|
@ -1,403 +0,0 @@
|
|||
# Auth Foundation Design
|
||||
|
||||
**Track:** 4 of the roadmap (inserted after Track 3: Async capture jobs)
|
||||
**Date:** 2026-06-25
|
||||
**Status:** Approved for implementation
|
||||
|
||||
---
|
||||
|
||||
## Context & Roadmap Position
|
||||
|
||||
Archivr is evolving from a local-only tool (single hard-coded user, 127.0.0.1 binding) into a
|
||||
self-hosted multi-user platform — think ArchiveBox but with real accounts, roles, and
|
||||
public/private visibility. This track lays the foundation. All subsequent tracks depend on it.
|
||||
|
||||
**Full decomposition:**
|
||||
|
||||
| Track | Scope | Depends on |
|
||||
|---|---|---|
|
||||
| 4 (this) | Auth foundation | — |
|
||||
| 5 | User management — registration, custom roles, admin panel | Track 4 |
|
||||
| 6 | Permissions & visibility — collection model, per-membership visibility | Track 5 |
|
||||
| 7 | Settings — account profile, instance-wide toggles | Track 5 |
|
||||
| 8 | Collections UI | Tracks 5–6 |
|
||||
|
||||
---
|
||||
|
||||
## Goals
|
||||
|
||||
- Password-protected login with cookie sessions and API tokens
|
||||
- Role table with bitmask-based visibility (extensible to custom roles in Track 5)
|
||||
- Auth middleware that protects write/admin routes
|
||||
- First-run owner setup wizard
|
||||
- Frontend login page and session-aware API calls
|
||||
|
||||
## Non-Goals (explicitly deferred)
|
||||
|
||||
- Custom role creation UI → Track 5
|
||||
- User registration flow → Track 5
|
||||
- Visibility enforcement on queries → Track 6
|
||||
- Collection model (replacing `archived_entries.visibility`) → Track 6
|
||||
- Account settings page → Track 7
|
||||
- API token management UI → Track 7
|
||||
|
||||
---
|
||||
|
||||
## Schema
|
||||
|
||||
### New table: `roles`
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS roles (
|
||||
id INTEGER PRIMARY KEY,
|
||||
role_uid TEXT NOT NULL UNIQUE,
|
||||
slug TEXT NOT NULL UNIQUE, -- 'guest', 'user', 'admin', 'owner', or custom
|
||||
name TEXT NOT NULL,
|
||||
level INTEGER NOT NULL, -- ordering: guest=0, user=1, admin=3, owner=4
|
||||
bit_position INTEGER NOT NULL UNIQUE, -- position in visibility bitmask
|
||||
is_builtin INTEGER NOT NULL DEFAULT 0 CHECK (is_builtin IN (0, 1))
|
||||
);
|
||||
```
|
||||
|
||||
**Built-in rows seeded at schema init:**
|
||||
|
||||
| slug | level | bit_position | bit value | is_builtin |
|
||||
|---|---|---|---|---|
|
||||
| guest | 0 | 0 | 1 | 1 |
|
||||
| user | 1 | 1 | 2 | 1 |
|
||||
| admin | 3 | 2 | 4 | 1 |
|
||||
| owner | 4 | 3 | 8 | 1 |
|
||||
|
||||
Bit position 2 (value 4) is reserved for `admin`. Bit positions 4+ (values 16, 32, …) are assigned
|
||||
to custom roles in Track 5. Level 2 is reserved for custom roles sitting between `user` and `admin`.
|
||||
|
||||
**role_bits computation — implicit guest floor:**
|
||||
|
||||
`role_bits` for any **authenticated** user is computed as:
|
||||
```
|
||||
role_bits = ROLE_GUEST | (OR of bit values for all rows in user_roles)
|
||||
```
|
||||
The `ROLE_GUEST` bit (1) is always included for authenticated users so they can access
|
||||
public (guest-visible) content. Example: an owner assigned only the `owner` role gets
|
||||
`role_bits = 1 | 8 = 9`, which passes `role_bits & ROLE_USER (2) = 0` — still broken.
|
||||
|
||||
**Therefore, role assignment is cumulative by level.** When a role is assigned, all
|
||||
built-in roles at lower levels are also assigned:
|
||||
- Assigning `owner` (level 4) → also assign `admin`, `user` in `user_roles`
|
||||
- Assigning `admin` (level 3) → also assign `user` in `user_roles`
|
||||
- Assigning `user` (level 1) → no additional rows
|
||||
- `guest` is never assigned; it is the implicit unauthenticated floor
|
||||
|
||||
Setup creates owner with three `user_roles` rows: `user`, `admin`, `owner`.
|
||||
Resulting `role_bits = ROLE_GUEST | ROLE_USER | ROLE_ADMIN | ROLE_OWNER = 1|2|4|8 = 15`.
|
||||
|
||||
**Visibility check:** `viewer.role_bits & content.visibility != 0` passes if the viewer
|
||||
has any bit the content requires. Owner (15) can see everything. User (1|2=3) can see
|
||||
guest-visible (1) and user-visible (2) content but not admin-only (4). ✓
|
||||
|
||||
`is_builtin = 1` rows cannot be deleted.
|
||||
|
||||
### New table: `sessions`
|
||||
|
||||
```sql
|
||||
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, -- snapshot of bitmask at login time; role changes take effect on next login
|
||||
created_at TEXT NOT NULL,
|
||||
last_seen_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL, -- 30 days from last_seen_at
|
||||
user_agent TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
|
||||
```
|
||||
|
||||
### New table: `api_tokens`
|
||||
|
||||
```sql
|
||||
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, -- SHA-256 of raw token; raw token never stored
|
||||
name TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
last_used_at TEXT,
|
||||
expires_at TEXT -- NULL = never expires
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_tokens_user_id ON api_tokens(user_id);
|
||||
```
|
||||
|
||||
### `users` table — existing, minimally changed
|
||||
|
||||
The existing `role TEXT NOT NULL CHECK (role IN ('admin','user'))` column is **kept but inert** —
|
||||
auth middleware reads from `user_roles`, not this column. It will be removed in Track 5 cleanup.
|
||||
`ensure_owner_exists` must supply a value for this column; use `'admin'` as the placeholder.
|
||||
|
||||
`ensure_default_user` is replaced by `ensure_owner_exists` which returns `false` if no owner
|
||||
row exists in `user_roles` (triggers setup mode). The old local-admin stub is never created on
|
||||
fresh instances. Session lookup JOINs `users` and checks `users.status = 'active'`; a session
|
||||
belonging to a disabled user resolves to `AuthUser::Guest`.
|
||||
|
||||
### `instance_settings` — one new column
|
||||
|
||||
The column is added **inside** the existing `CREATE TABLE IF NOT EXISTS instance_settings` DDL,
|
||||
not via `ALTER TABLE` (which is not idempotent in `initialize_schema`):
|
||||
|
||||
```sql
|
||||
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 -- 2 = user-visible by default
|
||||
);
|
||||
```
|
||||
|
||||
The existing `INSERT OR IGNORE INTO instance_settings … VALUES (1, 0, 0, 0)` seed row must be
|
||||
updated to include the new column: `VALUES (1, 0, 0, 0, 2)`.
|
||||
|
||||
### `archived_entries.visibility` — deprecated, not removed
|
||||
|
||||
Flagged with a `-- DEPRECATED: replaced by collection_entries.visibility in Track 6` comment in
|
||||
`initialize_schema`. No data migration needed yet; Track 6 handles it.
|
||||
|
||||
---
|
||||
|
||||
## Auth Flow
|
||||
|
||||
### Login
|
||||
|
||||
```
|
||||
POST /api/auth/login
|
||||
Body: { username: string, password: string }
|
||||
```
|
||||
|
||||
1. Look up user by username.
|
||||
2. Verify password with Argon2id (`argon2` crate).
|
||||
3. Compute `role_bits = ROLE_GUEST | (OR of bit values for all user_roles rows)` (cumulative; see Schema § role_bits computation).
|
||||
4. Insert `sessions` row (`session_uid` = UUID, `expires_at` = now + 30 days).
|
||||
5. Set-Cookie: `session=<session_uid>; HttpOnly; SameSite=Strict; Path=/; Max-Age=2592000`.
|
||||
Add `Secure` flag when the request arrived over HTTPS (detected via `X-Forwarded-Proto: https`
|
||||
header or TLS connection info). Omit `Secure` for plain HTTP to support local dev without TLS.
|
||||
6. Return `200 { user_uid, username, role_bits }`.
|
||||
|
||||
On failure: `401 { error: "invalid_credentials" }` — same message for unknown user and wrong
|
||||
password (no user enumeration).
|
||||
|
||||
### Logout
|
||||
|
||||
```
|
||||
POST /api/auth/logout
|
||||
```
|
||||
|
||||
Deletes the `sessions` row for the current session cookie. Responds with
|
||||
`Set-Cookie: session=; Max-Age=0` to clear the browser cookie. Returns `204`.
|
||||
|
||||
### Current user
|
||||
|
||||
```
|
||||
GET /api/auth/me
|
||||
```
|
||||
|
||||
Returns `200 { user_uid, username, role_bits }` for an authenticated request, or `401` for a
|
||||
guest. The frontend calls this once on mount to restore session state.
|
||||
|
||||
### First-run setup
|
||||
|
||||
```
|
||||
GET /api/auth/setup → 200 { setup_required: bool }
|
||||
POST /api/auth/setup → 201 { user_uid, username }
|
||||
Body: { username: string, password: string }
|
||||
```
|
||||
|
||||
`setup_required` is `true` when no user has the `owner` role in `user_roles`. On `POST`:
|
||||
- If setup is **already complete** (an owner exists): return `409 { error: "already_configured" }`.
|
||||
- Otherwise: create the user (with `users.role = 'admin'` as placeholder), assign `user_roles`
|
||||
rows for `user`, `admin`, `owner` (cumulative), seed `instance_settings` row if absent.
|
||||
Return `201 { user_uid, username }`. Normal login flow applies immediately after.
|
||||
|
||||
All non-setup API routes return `503 { error: "setup_required" }` until setup is complete.
|
||||
The following routes are **exempt** from the 503 check: `GET /api/auth/setup`,
|
||||
`POST /api/auth/setup`, `GET /` (static), `GET /assets/*` (static).
|
||||
|
||||
### API tokens
|
||||
|
||||
```
|
||||
POST /api/auth/tokens → 201 { token_uid, raw_token, name, created_at }
|
||||
GET /api/auth/tokens → 200 [{ token_uid, name, created_at, last_used_at }]
|
||||
DELETE /api/auth/tokens/:token_uid → 204
|
||||
```
|
||||
|
||||
`raw_token` is a cryptographically random 32-byte value, base64url-encoded, returned once. The
|
||||
server stores only its SHA-256 hash. The management UI for these endpoints is in Track 7; the
|
||||
endpoints are implemented here.
|
||||
|
||||
### Password hashing
|
||||
|
||||
Argon2id with default parameters from the `argon2` crate (memory=19 MiB, iterations=2,
|
||||
parallelism=1). The current `"disabled-local-password"` sentinel in `ensure_default_user` becomes
|
||||
irrelevant once setup is required on fresh instances.
|
||||
|
||||
### Session expiry & cleanup
|
||||
|
||||
`last_seen_at` is updated on every authenticated request using a **conditional update**: the
|
||||
session row is already read during extraction; if `now() - last_seen_at > 60s`, issue an UPDATE.
|
||||
This adds no extra query — only an extra UPDATE when the threshold is crossed.
|
||||
`expires_at` = `last_seen_at + 30 days`, recalculated on each UPDATE. A background task in
|
||||
`archivr-server/src/main.rs` runs `DELETE FROM sessions WHERE expires_at < now()` at startup
|
||||
and every 24 hours via `tokio::time::interval`.
|
||||
|
||||
---
|
||||
|
||||
## Auth Extractor
|
||||
|
||||
New file: `crates/archivr-server/src/auth.rs`
|
||||
|
||||
```rust
|
||||
pub enum AuthUser {
|
||||
Guest,
|
||||
Authenticated { user_id: i64, role_bits: u32 },
|
||||
}
|
||||
|
||||
impl AuthUser {
|
||||
pub fn require_auth(&self) -> Result<(i64, u32), ApiError> // 401 if Guest
|
||||
pub fn require_role(&self, bit: u32) -> Result<(), ApiError> // 403 if bit not set
|
||||
pub fn has_role(&self, bit: u32) -> bool
|
||||
}
|
||||
|
||||
// Role bit constants
|
||||
pub const ROLE_GUEST: u32 = 1;
|
||||
pub const ROLE_USER: u32 = 2;
|
||||
pub const ROLE_ADMIN: u32 = 4;
|
||||
pub const ROLE_OWNER: u32 = 8;
|
||||
```
|
||||
|
||||
Implemented as an Axum `FromRequestParts` extractor. Tries `session` cookie first, then
|
||||
`Authorization: Bearer` header.
|
||||
- **Cookie path**: look up `sessions` row JOIN `users` WHERE `session_uid = ?`
|
||||
AND `users.status = 'active'` AND `expires_at > now()`. Use cached `role_bits` from the
|
||||
session row.
|
||||
- **Bearer path**: SHA-256 the token, look up `api_tokens` row JOIN `users` WHERE
|
||||
`token_hash = ?` AND `users.status = 'active'` AND (`expires_at IS NULL OR expires_at > now()`).
|
||||
Compute `role_bits` live: `ROLE_GUEST | (OR of user_roles bit values for that user)`.
|
||||
Update `api_tokens.last_used_at`.
|
||||
- Missing or invalid credential → `AuthUser::Guest` (never a hard error at extraction time).
|
||||
|
||||
---
|
||||
|
||||
## Route Protection Tiers
|
||||
|
||||
The existing security-boundary comment block in `routes.rs` is updated:
|
||||
|
||||
| Tier | Requirement | Examples |
|
||||
|---|---|---|
|
||||
| `STATIC` | none | `GET /`, `GET /assets/*` |
|
||||
| `PUBLIC_READ` | none (visibility filtering deferred to Track 6) | `GET /api/archives/:id/entries` |
|
||||
| `AUTH_READ` | `ROLE_USER` bit | authenticated entry access |
|
||||
| `WRITE` | `ROLE_USER` bit | `POST /api/archives/:id/captures`, tag mutations |
|
||||
| `ADMIN` | `ROLE_ADMIN` bit | `GET /api/admin/archives`, user management |
|
||||
| `OWNER` | `ROLE_OWNER` bit | instance settings, ownership transfer |
|
||||
|
||||
**Error responses:**
|
||||
- No/invalid session → `401` (frontend redirects to login)
|
||||
- Valid session, insufficient role → `403`
|
||||
- Private resource accessed without sufficient role → `404` (do not reveal existence)
|
||||
|
||||
Track 4 applies `ROLE_USER` enforcement to all existing `WRITE` routes and `ROLE_ADMIN` to
|
||||
`/api/admin/*`. `PUBLIC_READ` routes return all data for now; Track 6 adds visibility filters.
|
||||
|
||||
---
|
||||
|
||||
## Frontend Changes
|
||||
|
||||
### New components
|
||||
|
||||
| Component | Purpose |
|
||||
|---|---|
|
||||
| `SetupPage.jsx` | First-run owner account creation wizard |
|
||||
| `LoginPage.jsx` | Username/password login form |
|
||||
|
||||
### App.jsx changes
|
||||
|
||||
- On mount: call `GET /api/auth/setup`; if `setup_required`, render `<SetupPage>` and nothing else.
|
||||
- Otherwise: call `GET /api/auth/me`; store result as `currentUser` state (null = guest).
|
||||
- Pass `currentUser` down via React context (`AuthContext`).
|
||||
- Any `401` response from any API call sets `currentUser` to null → triggers `<LoginPage>`.
|
||||
|
||||
### api.js changes
|
||||
|
||||
- Thin response interceptor: if status is `401`, dispatch a global `auth:expired` event that
|
||||
`App.jsx` listens to and handles by clearing `currentUser`.
|
||||
- No token storage in JS — cookies are handled entirely by the browser.
|
||||
|
||||
### Topbar.jsx changes
|
||||
|
||||
- When `currentUser` is set: show `username` and a **Log out** button.
|
||||
- Log out calls `POST /api/auth/logout`, then clears `currentUser`.
|
||||
|
||||
### What is NOT in Track 4 frontend
|
||||
|
||||
- Settings page (Track 7)
|
||||
- User management UI (Track 5)
|
||||
- Role or visibility controls (Track 6)
|
||||
- API token management UI (Track 7)
|
||||
|
||||
---
|
||||
|
||||
## New Dependencies
|
||||
|
||||
| Crate | Purpose |
|
||||
|---|---|
|
||||
| `argon2` | Password hashing (Argon2id) |
|
||||
| `rand` | Cryptographically random token generation |
|
||||
| `tower-cookies` | Cookie extraction in Axum (or use `axum-extra`) |
|
||||
|
||||
Add to `archivr-server/Cargo.toml` and workspace `Cargo.toml` as needed.
|
||||
|
||||
---
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `crates/archivr-core/src/database.rs` | Add `roles`, `user_roles`, `sessions`, `api_tokens` tables; seed built-in roles; add `instance_settings.default_entry_visibility`; replace `ensure_default_user` with `ensure_owner_exists`; add session/token CRUD helpers |
|
||||
| `crates/archivr-server/src/auth.rs` | New: `AuthUser` extractor, role bit constants, session/token lookup |
|
||||
| `crates/archivr-server/src/routes.rs` | Add auth endpoints (`/api/auth/*`); apply `AuthUser` extractor to WRITE/ADMIN routes; update security-boundary comment |
|
||||
| `crates/archivr-server/src/main.rs` | Session cleanup background task |
|
||||
| `frontend/src/App.jsx` | Setup check, auth state, `AuthContext` |
|
||||
| `frontend/src/api.js` | 401 interceptor |
|
||||
| `frontend/src/components/LoginPage.jsx` | New |
|
||||
| `frontend/src/components/SetupPage.jsx` | New |
|
||||
| `frontend/src/components/Topbar.jsx` | User menu + logout |
|
||||
| `Cargo.toml` | Add `argon2`, `rand`, `tower-cookies` (or `axum-extra`) |
|
||||
|
||||
---
|
||||
|
||||
## Test Coverage
|
||||
|
||||
- `database.rs`: role seeding, `ensure_owner_exists`, session CRUD, token hash round-trip
|
||||
- `auth.rs`: extractor resolves cookie → session → user; extractor resolves Bearer → token → user;
|
||||
missing credential → Guest; expired session → Guest
|
||||
- `routes.rs`: login happy path; login wrong password returns 401; logout clears session;
|
||||
setup endpoint returns 503 after setup complete; WRITE route returns 401 for Guest;
|
||||
WRITE route returns 403 for insufficient role; setup flow end-to-end
|
||||
|
||||
---
|
||||
|
||||
## Track Numbering Update for NEXT.md
|
||||
|
||||
Original tracks 4 and 5 shift to 8 and 9. Collections is a named future track (no number until
|
||||
scoped):
|
||||
|
||||
| # | Track |
|
||||
|---|---|
|
||||
| 3 | Async capture jobs |
|
||||
| **4** | **Auth foundation (this spec)** |
|
||||
| **5** | **User management** |
|
||||
| **6** | **Permissions & visibility (collection model)** |
|
||||
| **7** | **Settings** |
|
||||
| **8** | **Collections UI** |
|
||||
| 9 | Cloud backup (was 4) |
|
||||
| 10 | Cloud storage (was 5) |
|
||||
Loading…
Add table
Add a link
Reference in a new issue