···11+use anyhow::{Context, Result};
22+use rusqlite::Connection;
33+44+pub async fn migrate(database: &std::path::Path) -> Result<()> {
55+ let conn = Connection::open(database).context("Failed to open database")?;
66+77+ conn.execute_batch(
88+ r#"
99+ CREATE TABLE IF NOT EXISTS developers (
1010+ id TEXT PRIMARY KEY,
1111+ email TEXT NOT NULL UNIQUE,
1212+ password_hash TEXT NOT NULL,
1313+ name TEXT NOT NULL,
1414+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
1515+ updated_at TEXT DEFAULT CURRENT_TIMESTAMP
1616+ );
1717+1818+ CREATE TABLE IF NOT EXISTS games (
1919+ id TEXT PRIMARY KEY,
2020+ developer_id TEXT NOT NULL REFERENCES developers(id),
2121+ name TEXT NOT NULL,
2222+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
2323+ updated_at TEXT DEFAULT CURRENT_TIMESTAMP
2424+ );
2525+2626+ CREATE TABLE IF NOT EXISTS api_keys (
2727+ id TEXT PRIMARY KEY,
2828+ developer_id TEXT NOT NULL REFERENCES developers(id),
2929+ game_id TEXT REFERENCES games(id),
3030+ key_hash TEXT NOT NULL,
3131+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
3232+ revoked_at TEXT
3333+ );
3434+3535+ CREATE TABLE IF NOT EXISTS gamers (
3636+ id TEXT PRIMARY KEY,
3737+ itch_user_id INTEGER NOT NULL UNIQUE,
3838+ created_at TEXT DEFAULT CURRENT_TIMESTAMP
3939+ );
4040+4141+ CREATE TABLE IF NOT EXISTS sessions (
4242+ id TEXT PRIMARY KEY,
4343+ gamer_id TEXT NOT NULL REFERENCES gamers(id),
4444+ expires_at TEXT NOT NULL,
4545+ created_at TEXT DEFAULT CURRENT_TIMESTAMP
4646+ );
4747+4848+ CREATE TABLE IF NOT EXISTS invites (
4949+ code TEXT PRIMARY KEY,
5050+ used_by TEXT REFERENCES gamers(id),
5151+ used_at TEXT,
5252+ created_by TEXT NOT NULL,
5353+ expires_at TEXT NOT NULL
5454+ );
5555+5656+ CREATE TABLE IF NOT EXISTS saves (
5757+ id TEXT PRIMARY KEY,
5858+ gamer_id TEXT NOT NULL REFERENCES gamers(id),
5959+ game_id TEXT NOT NULL REFERENCES games(id),
6060+ slot_id TEXT NOT NULL,
6161+ current_cid TEXT,
6262+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
6363+ updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
6464+ UNIQUE(gamer_id, game_id, slot_id)
6565+ );
6666+6767+ CREATE TABLE IF NOT EXISTS save_versions (
6868+ id TEXT PRIMARY KEY,
6969+ save_id TEXT NOT NULL REFERENCES saves(id),
7070+ version_number INTEGER NOT NULL,
7171+ cid TEXT NOT NULL,
7272+ milestone INTEGER DEFAULT 0,
7373+ size_bytes INTEGER NOT NULL,
7474+ created_at TEXT DEFAULT CURRENT_TIMESTAMP
7575+ );
7676+7777+ CREATE TABLE IF NOT EXISTS dpop_tokens (
7878+ token_id TEXT PRIMARY KEY,
7979+ gamer_id TEXT NOT NULL REFERENCES gamers(id),
8080+ nonce TEXT NOT NULL,
8181+ expires_at TEXT NOT NULL,
8282+ created_at TEXT DEFAULT CURRENT_TIMESTAMP
8383+ );
8484+8585+ CREATE INDEX IF NOT EXISTS idx_games_developer ON games(developer_id);
8686+ CREATE INDEX IF NOT EXISTS idx_saves_gamer ON saves(gamer_id);
8787+ CREATE INDEX IF NOT EXISTS idx_saves_game ON saves(game_id);
8888+ CREATE INDEX IF NOT EXISTS idx_save_versions_save ON save_versions(save_id);
8989+ "#,
9090+ )?;
9191+9292+ println!("Migration completed successfully");
9393+ Ok(())
9494+}
+57
admin/src/invite.rs
···11+use anyhow::{Context, Result};
22+use rusqlite::Connection;
33+use ulid::Ulid;
44+55+pub async fn generate(count: usize, expires_days: u32, database: &std::path::Path) -> Result<()> {
66+ let conn = Connection::open(database).context("Failed to open database")?;
77+88+ let expires_at = chrono::Utc::now()
99+ + chrono::Duration::days(expires_days as i64);
1010+ let expires_str = expires_at.to_rfc3339();
1111+1212+ for _ in 0..count {
1313+ let code = generate_code();
1414+ conn.execute(
1515+ "INSERT INTO invites (code, created_by, expires_at) VALUES (?1, 'admin', ?2)",
1616+ [&code, &expires_str],
1717+ )
1818+ .context("Failed to insert invite")?;
1919+ println!("{}", code);
2020+ }
2121+2222+ Ok(())
2323+}
2424+2525+pub async fn list(database: &std::path::Path, unused_only: bool) -> Result<()> {
2626+ let conn = Connection::open(database).context("Failed to open database")?;
2727+2828+ let query = if unused_only {
2929+ "SELECT code, expires_at FROM invites WHERE used_by IS NULL"
3030+ } else {
3131+ "SELECT code, used_by, expires_at FROM invites"
3232+ };
3333+3434+ let mut stmt = conn.prepare(query)?;
3535+ let rows = stmt.query_map([], |row| {
3636+ let code: String = row.get(0)?;
3737+ let expires_at: String = row.get(1)?;
3838+ let used_by: Option<String> = row.get(2).ok();
3939+ Ok((code, used_by, expires_at))
4040+ })?;
4141+4242+ println!("{:<20} {:<20} {}", "CODE", "USED_BY", "EXPIRES_AT");
4343+ println!("{}", "-".repeat(60));
4444+4545+ for row in rows {
4646+ let (code, used_by, expires_at) = row?;
4747+ let used = used_by.map(|_| "yes").unwrap_or("no");
4848+ println!("{:<20} {:<20} {}", code, used, expires_at);
4949+ }
5050+5151+ Ok(())
5252+}
5353+5454+fn generate_code() -> String {
5555+ let ulid = Ulid::new();
5656+ ulid.to_string()
5757+}
···11+use anyhow::Result;
22+use rusqlite::Connection;
33+use std::path::Path;
44+55+pub async fn init_database(db_path: &Path) -> Result<()> {
66+ if let Some(parent) = db_path.parent() {
77+ std::fs::create_dir_all(parent)?;
88+ }
99+1010+ let conn = Connection::open(db_path)?;
1111+1212+ conn.execute_batch(
1313+ r#"
1414+ CREATE TABLE IF NOT EXISTS developers (
1515+ id TEXT PRIMARY KEY,
1616+ email TEXT NOT NULL UNIQUE,
1717+ password_hash TEXT NOT NULL,
1818+ name TEXT NOT NULL,
1919+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
2020+ updated_at TEXT DEFAULT CURRENT_TIMESTAMP
2121+ );
2222+2323+ CREATE TABLE IF NOT EXISTS games (
2424+ id TEXT PRIMARY KEY,
2525+ developer_id TEXT NOT NULL REFERENCES developers(id),
2626+ name TEXT NOT NULL,
2727+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
2828+ updated_at TEXT DEFAULT CURRENT_TIMESTAMP
2929+ );
3030+3131+ CREATE TABLE IF NOT EXISTS api_keys (
3232+ id TEXT PRIMARY KEY,
3333+ developer_id TEXT NOT NULL REFERENCES developers(id),
3434+ game_id TEXT REFERENCES games(id),
3535+ key_hash TEXT NOT NULL,
3636+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
3737+ revoked_at TEXT
3838+ );
3939+4040+ CREATE TABLE IF NOT EXISTS gamers (
4141+ id TEXT PRIMARY KEY,
4242+ itch_user_id INTEGER NOT NULL UNIQUE,
4343+ created_at TEXT DEFAULT CURRENT_TIMESTAMP
4444+ );
4545+4646+ CREATE TABLE IF NOT EXISTS sessions (
4747+ id TEXT PRIMARY KEY,
4848+ gamer_id TEXT NOT NULL REFERENCES gamers(id),
4949+ expires_at TEXT NOT NULL,
5050+ created_at TEXT DEFAULT CURRENT_TIMESTAMP
5151+ );
5252+5353+ CREATE TABLE IF NOT EXISTS invites (
5454+ code TEXT PRIMARY KEY,
5555+ used_by TEXT REFERENCES gamers(id),
5656+ used_at TEXT,
5757+ created_by TEXT NOT NULL,
5858+ expires_at TEXT NOT NULL
5959+ );
6060+6161+ CREATE TABLE IF NOT EXISTS saves (
6262+ id TEXT PRIMARY KEY,
6363+ gamer_id TEXT NOT NULL REFERENCES gamers(id),
6464+ game_id TEXT NOT NULL REFERENCES games(id),
6565+ slot_id TEXT NOT NULL,
6666+ current_cid TEXT,
6767+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
6868+ updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
6969+ UNIQUE(gamer_id, game_id, slot_id)
7070+ );
7171+7272+ CREATE TABLE IF NOT EXISTS save_versions (
7373+ id TEXT PRIMARY KEY,
7474+ save_id TEXT NOT NULL REFERENCES saves(id),
7575+ version_number INTEGER NOT NULL,
7676+ cid TEXT NOT NULL,
7777+ milestone INTEGER DEFAULT 0,
7878+ size_bytes INTEGER NOT NULL,
7979+ created_at TEXT DEFAULT CURRENT_TIMESTAMP
8080+ );
8181+8282+ CREATE TABLE IF NOT EXISTS dpop_tokens (
8383+ token_id TEXT PRIMARY KEY,
8484+ gamer_id TEXT NOT NULL REFERENCES gamers(id),
8585+ nonce TEXT NOT NULL,
8686+ expires_at TEXT NOT NULL,
8787+ created_at TEXT DEFAULT CURRENT_TIMESTAMP
8888+ );
8989+9090+ CREATE INDEX IF NOT EXISTS idx_games_developer ON games(developer_id);
9191+ CREATE INDEX IF NOT EXISTS idx_saves_gamer ON saves(gamer_id);
9292+ CREATE INDEX IF NOT EXISTS idx_saves_game ON saves(game_id);
9393+ CREATE INDEX IF NOT EXISTS idx_save_versions_save ON save_versions(save_id);
9494+ "#,
9595+ )?;
9696+9797+ Ok(())
9898+}
+9
api/src/lib.rs
···11+pub mod api;
22+pub mod auth;
33+pub mod config;
44+pub mod db;
55+pub mod storage;
66+pub mod web;
77+88+pub const APP_NAME: &str = "Scratchback API";
99+pub const APP_VERSION: &str = env!("CARGO_PKG_VERSION");
···11+# ScratchBack PDS - Introduction
22+33+## Overview
44+55+ScratchBack PDS is a zero-knowledge Personal Data Store (PDS) implementing
66+ATProtocol standards using rsky. This service provides cloud storage for
77+itch.io game saves with GDPR compliance, conditional mobile sync, Age
88+encryption, and support for both Bunny Storage and DigitalOcean Spaces.
99+1010+## Problem Statement
1111+1212+itch.io game developers need a secure, GDPR-compliant cloud storage solution for player saves with:
1313+1414+- Zero-knowledge architecture (server cannot decrypt user data)
1515+- GDPR compliance with EU data localization
1616+- Cost-effective storage with multiple backend options
1717+- Mobile-first with conditional sync
1818+- Admin controls for system management
1919+2020+## Solution
2121+2222+A PDS built on:
2323+2424+- **ATProtocol**: Standards-based protocol for decentralized data storage
2525+- **rsky**: Rust implementation of ATProtocol PDS with SQLite backend
2626+- **Axum**: Modern web framework for HTTP/XRPC endpoints
2727+- **Age Encryption**: Zero-knowledge client-side encryption
2828+- **Dual Storage Backends**: Bunny Storage + DigitalOcean Spaces
2929+- **HTMX**: Server-side rendered web UI for admin controls
3030+3131+## Key Features
3232+3333+- 2GB default quota per user (soft limits at 80%, cumulative tracking)
3434+- GDPR-compliant automatic region routing (EU users → EU storage)
3535+- Real-time cost calculation (cached for 1 hour)
3636+- Passkey authentication (terminal-only setup with QR codes)
3737+- Session-based web UI (7-day expiration)
3838+- Country consent flow with GDPR alerts
3939+- Admin CLI (`scrtchbk-ctl`) for system management
4040+- Soft deletion with 14-day retention
4141+- Deployment metadata generated fresh at every startup
4242+- Age encryption for passkeys database (synced to Bunny Storage)
4343+4444+## Technology Stack
4545+4646+- Rust 2021 edition
4747+- Axum 0.7 (web framework)
4848+- Diesel 2.1 (ORM)
4949+- SQLite (database via rsky)
5050+- minijinja 2.0 (templates)
5151+- age 0.11 (encryption)
5252+- cached 0.1 (memoization)
5353+- webauthn-rs 0.5 (passkey)
5454+- aws-sdk-s3 1.20 (Bunny/DigitalOcean)
5555+- maxminddb 0.24 (GeoIP)
5656+5757+## Architecture Decisions
5858+5959+1. **Separate databases**:
6060+ - Main database: user_quotas, blobs, sessions, ntfy_subscriptions
6161+ - Passkeys database: passkeys table (age-encrypted, synced to Bunny Storage)
6262+6363+2. **GDPR routing**:
6464+ - EU users: Stored in EU data centers (Bunny frankfurt / DO fra1)
6565+ - Non-EU users: Stored in US data centers (Bunny us / DO nyc3)
6666+ - Country detection: GeoLite2 database (configurable path via env var)
6767+6868+3. **Authentication methods**:
6969+ - XRPC: JWT tokens (24-hour expiration)
7070+ - Admin CLI: Passkey (terminal-only, QR code registration)
7171+ - Web UI: Session cookies (7-day expiration, HTTP-only, Secure, SameSite=Lax)
7272+7373+4. **Cost management**:
7474+ - Real-time API calls to Bunny/DigitalOcean
7575+ - 1-hour cache using `cached` proc macro
7676+ - Background refresh task (tokio)
7777+ - Individual + combined cost calculations cached
7878+7979+5. **Deployment**:
8080+ - Static binaries via build.sr.ht
8181+ - Deployment metadata generated fresh at startup (not from CI/CD)
8282+ - VPS deployment to Hetzner
8383+8484+## Project Structure
8585+8686+```
8787+/home/vrgl/Code/scratch-itch/
8888+├── mise.toml # Pinned direnv + age
8989+├── Cargo.toml # Workspace root
9090+├── .env.age # Encrypted secrets
9191+├── config.toml # Default configuration
9292+├── deployment-info.json # Deployment metadata (generated)
9393+├── build.sr.ht # Build configuration
9494+├── Cargo.lock
9595+├── src/
9696+│ ├── main.rs
9797+│ ├── config/
9898+│ ├── storage/
9999+│ ├── quota/
100100+│ ├── auth/
101101+│ ├── xrpc/
102102+│ ├── web/
103103+│ ├── deployment/
104104+│ ├── cost_cache/
105105+│ ├── models/
106106+│ └── error.rs
107107+├── migrations/ # Diesel migrations (main DB)
108108+├── passkeys-migrations/ # Diesel migrations (passkeys DB)
109109+├── static/
110110+│ ├── css/
111111+│ └── js/
112112+└── cli/ # scrtchbk-ctl binary
113113+ ├── Cargo.toml
114114+ └── build.sr.ht
115115+```
116116+117117+## Documentation
118118+119119+- [Architecture](docs/initial-plan/01-architecture.md)
120120+- [Requirements](docs/initial-plan/02-requirements.md)
121121+- [Data Model](docs/initial-plan/03-data-model.md)
122122+- [Implementation Phases](docs/initial-plan/04-implementation-phases.md)
123123+- [ADR - Platform Introduction](docs/initial-plan/ADR.md)
124124+125125+## Implementation Timeline
126126+127127+- Phase 1: Foundation
128128+- Phase 2: Configuration System
129129+- Phase 3: Storage Layer
130130+- Phase 4: Passkey Storage
131131+- Phase 5: Quota System
132132+- Phase 6: Cost Cache
133133+- Phase 7: Authentication
134134+- Phase 8: Deployment Tracking
135135+- Phase 9: XRPC Endpoints
136136+- Phase 10: Web UI
137137+- Phase 11: Admin CLI
138138+- Phase 12: Testing & Deployment
+174
docs/initial-plan/01-architecture.md
···11+# Architecture
22+33+## System Components
44+55+```
66+┌─────────────────────────────────────────────────────────────────┐
77+│ ScratchBack PDS │
88+├───────────────────────────────────────────────────────────────────┤
99+│ │
1010+│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
1111+│ │ Axum Web │ │ XRPC API │ │ Admin CLI │ │
1212+│ │ Server │ │ (JWT) │ │ (CLI) │ │
1313+│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
1414+│ │ │ │ │ │
1515+│ ┌──────▼──────────────┬───▼───────────────────▼───────────────┐ │
1616+│ │ Application State │ ┌──────────────┐ │ │
1717+│ │ │──────▶│ SQLite │ │ │
1818+│ │ │ │ (Main DB) │ │ │
1919+│ │ │ └──────────────┘ │ │
2020+│ │ │ │ │
2121+│ │ │ ┌──────────────┐ │ │
2222+│ │ │──────▶│ SQLite │ │ │
2323+│ │ │ │(Passkeys) │ │ │
2424+│ │ │ └──┬──────────┘ │ │
2525+│ │ │ │ │ │
2626+│ │ │ │ LiteFS (manual sync) │ │
2727+│ │ │ ▼ │ │
2828+│ │ │ ┌──────────────┐ │ │
2929+│ │ │ │ Encrypted │ │ │
3030+│ │ │ │ SQLite │ │ │
3131+│ │ │ └──────────────┘ │ │
3232+│ └────────────────────┴───────────────────────────────────────────┘ │
3333+│ │
3434+│ ┌──────────────┐ ┌──────────────┐ │
3535+│ │ Bunny Storage │ │ DigitalOcean │ │
3636+│ │ (user blobs) │ │ Spaces │ │
3737+│ └──────┬───────┘ └──────┬───────┘ │
3838+│ │ │ │
3939+│ │ ┌───────────▼──────────┐ │
4040+│ │ │ Cost Cache │ │
4141+│ │ │ (1-hour, │ │
4242+│ │ │ cached proc, │ │
4343+│ │ │ background) │ │
4444+│ │ └─────────────────────┘ │
4545+└─────────────────────────────────────────────────────────────────────┘
4646+```
4747+4848+## Database Architecture
4949+5050+### Main Database (scratchback-pds/data.sqlite)
5151+- **user_quotas**: User storage quotas, country codes, backend selection
5252+- **blobs**: Blob references, soft deletion with retention timestamps
5353+- **sessions**: Web UI session cookies (7-day expiration)
5454+- **ntfy_subscriptions**: Internal ntfy.sh subscriptions (per-user)
5555+5656+### Passkeys Database (passkeys/passkeys.db)
5757+- **passkeys**: Admin passkey credentials
5858+- **Synced to**: Bunny Storage bucket (srtchbk-passkeys)
5959+- **Encrypted with**: Age (at rest)
6060+- **Encryption key**: Loaded from `.env.age` environment variable
6161+6262+## Storage Architecture
6363+6464+### Bunny Storage
6565+- **User blobs bucket**: `srtchbk-us` or `srtchbk-eu`
6666+- **Passkeys bucket**: `srtchbk-passkeys` (encrypted at rest)
6767+- **Pricing**: $0.01/GB storage, $0.005/GB bandwidth
6868+- **Features**: CDN, 7-day backup, 99.95% uptime
6969+7070+### DigitalOcean Spaces
7171+- **User blobs bucket**: `srtchbk-us` (nyc3), `srtchbk-eu` (fra1)
7272+- **Pricing**: $0.005/GB storage, $0.008/GB bandwidth
7373+- **Features**: S3-compatible, 7-day backup
7474+7575+## Backend Selection (GDPR-Compliant)
7676+7777+### EU Countries (requires EU storage)
7878+AT, BE, BG, HR, CY, CZ, DK, EE, FI, FR, DE, GR, HU, IE, IT, LV, LT, LU, MT, NL, PL, PT, RO, SK, SI, ES, SE, IS, LI, NO, CH
7979+8080+### Region Mapping
8181+| Region | Backend | Bunny Region | DigitalOcean Region |
8282+|---------|--------------|--------------|---------------------|
8383+| EU | Bunny | frankfurt | fra1 |
8484+| US | Bunny | us | nyc3 |
8585+| Default | Config | Config | Config |
8686+8787+## Authentication Flow
8888+8989+### XRPC API (JWT)
9090+- Used by game plugins and mobile apps
9191+- Token in `Authorization: Bearer <jwt>` header
9292+- 24-hour expiration
9393+9494+### Passkey Admin (Terminal-Only)
9595+- Single interactive command: `scrtchbk-ctl passkey-register <username>`
9696+- Displays QR code in terminal
9797+- Waits for HTTP POST `/auth/passkey/response` from phone
9898+- Stores credential in passkeys database
9999+- Encrypts and syncs to Bunny Storage bucket
100100+- Encryption key loaded from `.env.age` environment variable
101101+102102+### Web UI Sessions
103103+- Created after country consent (sets country in profile)
104104+- 7-day expiration
105105+- HTTP-only, Secure, SameSite=Lax
106106+- Protected `/self` page requires valid session
107107+108108+## Cost Calculation
109109+110110+### Caching Strategy
111111+- **Individual backend costs**: Cached using `cached` proc macro
112112+- **Combined total cost**: Cached using `cached` proc macro
113113+- **Cache duration**: 1 hour
114114+- **Refresh**: Background tokio task clears cache every hour
115115+- **Source**: Real-time from Bunny/DigitalOcean APIs
116116+117117+## Deployment
118118+119119+### build.sr.ht
120120+- Builds static binaries
121121+- Runs on Hetzner VPS
122122+- Separate build for `scrtchbk-ctl` CLI
123123+124124+### Startup Sequence
125125+1. Decrypt `.env.age` (load secrets)
126126+2. Load `config.toml` (expand environment variables)
127127+3. Generate fresh `deployment-info.json`
128128+4. Initialize SQLite databases
129129+5. Download and decrypt passkeys database from Bunny Storage
130130+6. Start background cost cache refresh task
131131+7. Start Axum HTTP server
132132+133133+## Data Flow
134134+135135+### Upload Flow
136136+```
137137+Game Plugin → XRPC (JWT) → Quota Check → Country Detection (GDPR)
138138+ ↓
139139+ Backend Selection (EU/US)
140140+ ↓
141141+ Storage Upload → Blob Record → Success
142142+```
143143+144144+### Passkey Registration Flow
145145+```
146146+Terminal Command → Generate Challenge → Display QR Code → Phone Scan
147147+ ↓
148148+HTTP POST /auth/passkey/response → Validate → Store in Passkeys DB
149149+ ↓
150150+ Age Encrypt → Sync to Bunny Storage → Success
151151+```
152152+153153+### Session Creation Flow
154154+```
155155+Country Consent → Set Country → Create Session Cookie → Redirect to /self → Validate → Success
156156+```
157157+158158+## Security Model
159159+160160+- Zero-knowledge: Server cannot decrypt user data (Age encryption client-side)
161161+- Passkeys encrypted at rest: Age-encrypted SQLite synced to Bunny Storage
162162+- HTTPS-only for production
163163+- Secure cookies: HTTP-only, Secure, SameSite=Lax
164164+- JWT expiration: 24 hours for XRPC tokens
165165+- Session expiration: 7 days for web UI
166166+- Database isolation: Separate passkeys database for admin credentials
167167+168168+## Reliability
169169+170170+- Bunny Storage 99.95% uptime SLA
171171+- DigitalOcean Spaces 99.9% uptime SLA
172172+- Automatic storage backups (Bunny built-in, 7-day free retention)
173173+- Soft deletion with 14-day retention (grace period for recovery)
174174+- Cost cache with 1-hour refresh (fresh pricing data)
+173
docs/initial-plan/02-requirements.md
···11+# Requirements
22+33+## Functional Requirements
44+55+### 1. Storage Management
66+- 2GB default quota per user (soft limits at 80%, cumulative tracking)
77+- Hard limit enforcement when quota exceeded
88+- Cumulative tracking (never resets)
99+- Blob count limits (default: 1000)
1010+1111+### 2. GDPR Compliance
1212+- Automatic EU country detection via GeoLite2 database
1313+- EU users must store data in EU data centers
1414+- Non-EU users can use default backend
1515+- Country detection failure must crash application
1616+- GeoLite2 database path configurable via `GEOIP_DATABASE_PATH` environment variable
1717+- Block uploads until country is set in profile
1818+- Provide country consent flow with GDPR alerts
1919+2020+### 3. Multi-Backend Storage
2121+- Bunny Storage support (default backend)
2222+- DigitalOcean Spaces support (alternative backend)
2323+- Real-time cost calculation from backend APIs
2424+- 1-hour cost cache with automatic background refresh
2525+- Backend selection based on user location (GDPR-compliant)
2626+- Automatic storage backups (Bunny built-in: 7-day retention)
2727+2828+### 4. Authentication
2929+- JWT for XRPC endpoints (24-hour expiration)
3030+- Passkey for admin CLI (terminal-only, no web UI)
3131+- Terminal registration via single interactive command with QR code
3232+- HTTP endpoint for credential response (from phone)
3333+- Age encryption for passkeys database (synced to Bunny Storage)
3434+- Session cookies for web UI (7-day expiration)
3535+- Session cookies created after country consent
3636+- HTTP-only, Secure, SameSite=Lax settings for cookies
3737+3838+### 5. Passkey Management
3939+- Terminal-only registration (no web UI)
4040+- Single interactive command: `scrtchbk-ctl passkey-register <username>`
4141+- Display QR code in terminal (ASCII art)
4242+- Wait for HTTP POST `/auth/passkey/response` from phone
4343+- Store credentials in passkeys database
4444+- Encrypt passkeys database with age
4545+- Sync to Bunny Storage bucket after every registration
4646+- Encryption key loaded from `.env.age` environment variable
4747+- 5-minute timeout for credential response
4848+4949+### 6. Cost Management
5050+- Real-time storage cost per GB (Bunny: $0.01, DO: $0.005)
5151+- Real-time bandwidth cost per GB (Bunny: $0.005, DO: $0.008)
5252+- Combined total cost calculation
5353+- 1-hour cache duration
5454+- Automatic background refresh task (tokio)
5555+- Cached using `cached` proc macro for memoization
5656+- Cache cleared every hour to force recalculation
5757+- Individual backend costs cached separately
5858+- Combined total cost cached separately
5959+6060+### 7. Admin Controls
6161+- CLI tool named `scrtchbk-ctl`
6262+- Direct database access (no authentication required)
6363+- System statistics command
6464+- User management commands (list, view, set quota)
6565+- Blob management commands (list, soft delete)
6666+- Cleanup command for expired soft-deleted blobs (14-day retention)
6767+- Passkey commands (register, sync)
6868+- Database query commands
6969+- Static binary (separate build.sr.ht)
7070+7171+### 8. Web UI
7272+- Homepage with deployment information and real-time cost estimates
7373+- Protected self-view page (session cookie auth required)
7474+- Country consent flow page
7575+- HTMX for dynamic interactions
7676+- minijinja templates with Object contexts
7777+- 7-day session cookie expiration
7878+- HTTP-only, Secure, SameSite=Lax cookie settings
7979+8080+### 9. Configuration
8181+- Priority: Environment variables > config.toml > defaults
8282+- Age decryption on startup (`.env.age`)
8383+- GeoLite2 database path via `GEOIP_DATABASE_PATH` environment variable
8484+- Session cookie configuration (7-day expiration)
8585+- Separate build.sr.ht for admin CLI
8686+8787+### 10. Deployment
8888+- Deployment metadata generated fresh at every startup
8989+- Static binary builds via build.sr.ht
9090+- No CI/CD (manual deployment to Hetzner VPS)
9191+- Deployment info stored in `deployment-info.json`
9292+9393+## Non-Functional Requirements
9494+9595+### 1. Technology Stack
9696+- Rust 2021 edition
9797+- Axum 0.7 (web framework)
9898+- Diesel 2.1 (ORM)
9999+- SQLite (database via rsky)
100100+- minijinja 2.0 (templates)
101101+- age 0.11 (encryption)
102102+- cached 0.1 (memoization)
103103+- fs-err-tokio 0.2 (error handling)
104104+- webauthn-rs 0.5 (passkey)
105105+- aws-sdk-s3 1.20 (Bunny/DigitalOcean)
106106+- maxminddb 0.24 (GeoIP)
107107+- qrcode 0.14 (QR codes)
108108+- cookie 0.18 (session cookies)
109109+110110+### 2. Performance
111111+- Cost calculations cached for 1 hour
112112+- Background refresh task for cache
113113+- Async/await throughout
114114+- Connection pooling for databases
115115+- Real-time API calls to storage backends
116116+117117+### 3. Security
118118+- Zero-knowledge (server cannot decrypt user data)
119119+- Age encryption at rest (passkeys in Bunny Storage)
120120+- HTTPS-only for production
121121+- Secure cookies (HTTP-only, Secure flag, SameSite=Lax)
122122+- JWT tokens (24-hour expiration)
123123+- Session tokens (7-day expiration)
124124+- Separate passkeys database for admin credentials
125125+126126+### 4. Reliability
127127+- Bunny Storage 99.95% uptime SLA
128128+- DigitalOcean Spaces 99.9% uptime SLA
129129+- Soft deletion with 14-day retention (grace period for recovery)
130130+- Automatic storage backups (Bunny built-in, 7-day retention)
131131+- GeoIP detection with crash on failure (admin must configure)
132132+133133+### 5. Usability
134134+- Terminal-only passkey registration with QR codes
135135+- Clear error messages for configuration failures
136136+- GDPR consent flow with storage restrictions explained
137137+- Mobile-responsive HTMX interface
138138+- Real-time cost estimates on homepage
139139+140140+### 6. Maintainability
141141+- Separate admin CLI from web server
142142+- Comprehensive documentation
143143+- Diesel migrations for schema changes
144144+- Build.sr.ht for reproducible deployments
145145+- Error handling with miette for detailed diagnostics
146146+147147+### 7. Scalability
148148+- Dual storage backends (Bunny + DigitalOcean)
149149+- GDPR-compliant region routing
150150+- Cost cache reduces API calls
151151+- Connection pooling for databases
152152+- Background tasks for non-blocking operations
153153+154154+## Constraints
155155+156156+- Budget: $80/month maximum
157157+ - Hetzner EU VPS: $5.50
158158+ - DigitalOcean US VPS: $12 (upgraded)
159159+ - Bunny Storage: $11 total (separate EU/US buckets)
160160+ - Sentry: $15 (team plan)
161161+ - uptime.io: $7
162162+ - Elastic Email: $5
163163+ - Turso: $10 (for metadata only)
164164+165165+- Storage backends must be S3-compatible
166166+- Passkey registration must be terminal-only
167167+- GeoIP database must be configured before starting
168168+- Application must crash if GeoIP detection fails
169169+- Session cookies must expire after 7 days
170170+- Soft-deleted blobs must be retained for 14 days
171171+- Cost cache must refresh every hour
172172+- Passkeys must sync to Bunny Storage after registration
173173+- Age encryption key must be in `.env.age`
+227
docs/initial-plan/03-data-model.md
···11+# Data Model
22+33+## Database Schemas
44+55+### Main Database (scratchback-pds/data.sqlite)
66+77+#### user_quotas Table
88+```sql
99+CREATE TABLE user_quotas (
1010+ did TEXT PRIMARY KEY,
1111+ storage_used_bytes INTEGER NOT NULL DEFAULT 0,
1212+ storage_limit_bytes INTEGER NOT NULL DEFAULT 2147483648, -- 2GB
1313+ blob_count INTEGER NOT NULL DEFAULT 0,
1414+ blob_limit INTEGER NOT NULL DEFAULT 1000,
1515+ country_code TEXT,
1616+ storage_backend TEXT,
1717+ storage_region TEXT,
1818+ warning_sent INTEGER DEFAULT 0,
1919+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
2020+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
2121+);
2222+2323+CREATE INDEX idx_user_quotas_country ON user_quotas(country_code);
2424+CREATE INDEX idx_user_quotas_backend ON user_quotas(storage_backend);
2525+```
2626+2727+**Fields:**
2828+- `did`: User's ATProtocol DID (primary key)
2929+- `storage_used_bytes`: Cumulative storage used (never resets)
3030+- `storage_limit_bytes`: Maximum storage allowed (default: 2GB = 2147483648 bytes)
3131+- `blob_count`: Number of blobs stored (cumulative)
3232+- `blob_limit`: Maximum number of blobs (default: 1000)
3333+- `country_code`: ISO 3166-1 alpha-2 country code (e.g., "US", "DE")
3434+- `storage_backend`: Storage backend used ("bunny" or "digitalocean")
3535+- `storage_region`: Region identifier (e.g., "us", "frankfurt", "nyc3")
3636+- `warning_sent`: Flag indicating 80% threshold warning has been sent
3737+- `created_at`: Timestamp when quota was first created
3838+- `updated_at`: Timestamp of last update
3939+4040+#### blobs Table
4141+```sql
4242+CREATE TABLE blobs (
4343+ id INTEGER PRIMARY KEY AUTOINCREMENT,
4444+ did TEXT NOT NULL,
4545+ blob_id TEXT NOT NULL,
4646+ size_bytes INTEGER NOT NULL,
4747+ url TEXT NOT NULL,
4848+ storage_backend TEXT NOT NULL,
4949+ storage_region TEXT NOT NULL,
5050+ soft_deleted INTEGER DEFAULT 0,
5151+ deleted_at TIMESTAMP,
5252+ retention_until TIMESTAMP,
5353+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
5454+ FOREIGN KEY (did) REFERENCES user_quotas(did) ON DELETE CASCADE,
5555+ UNIQUE(did, blob_id)
5656+);
5757+5858+CREATE INDEX idx_blobs_did ON blobs(did);
5959+CREATE INDEX idx_blobs_created ON blobs(created_at);
6060+CREATE INDEX idx_blobs_soft_deleted ON blobs(soft_deleted);
6161+CREATE INDEX idx_blobs_retention ON blobs(retention_until) WHERE retention_until IS NOT NULL;
6262+```
6363+6464+**Fields:**
6565+- `id`: Auto-increment primary key
6666+- `did`: User's DID (foreign key to user_quotas)
6767+- `blob_id`: Unique identifier for blob (UUID v4 format)
6868+- `size_bytes`: Size of blob in bytes
6969+- `url`: CDN URL for blob access
7070+- `storage_backend`: Which backend stores this blob
7171+- `storage_region`: Which region stores this blob
7272+- `soft_deleted`: Flag indicating soft deletion (1 = deleted, 0 = active)
7373+- `deleted_at`: Timestamp when soft-deleted
7474+- `retention_until`: Timestamp for hard deletion (14 days after soft deletion)
7575+- `created_at`: Timestamp when blob was created
7676+7777+#### sessions Table
7878+```sql
7979+CREATE TABLE sessions (
8080+ id INTEGER PRIMARY KEY AUTOINCREMENT,
8181+ did TEXT NOT NULL,
8282+ session_token TEXT UNIQUE NOT NULL,
8383+ expires_at TIMESTAMP NOT NULL,
8484+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
8585+ FOREIGN KEY (did) REFERENCES user_quotas(did) ON DELETE CASCADE
8686+);
8787+8888+CREATE INDEX idx_sessions_did ON sessions(did);
8989+CREATE INDEX idx_sessions_expires ON sessions(expires_at);
9090+```
9191+9292+**Fields:**
9393+- `id`: Auto-increment primary key
9494+- `did`: User's DID (foreign key to user_quotas)
9595+- `session_token`: Unique token for cookie (UUID v4 format)
9696+- `expires_at`: Session expiration timestamp (7 days after creation)
9797+- `created_at`: Timestamp when session was created
9898+9999+#### ntfy_subscriptions Table
100100+```sql
101101+CREATE TABLE ntfy_subscriptions (
102102+ id INTEGER PRIMARY KEY AUTOINCREMENT,
103103+ did TEXT NOT NULL,
104104+ topic TEXT NOT NULL,
105105+ access_token TEXT,
106106+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
107107+ UNIQUE(did)
108108+);
109109+110110+CREATE INDEX idx_ntfy_subscriptions_did ON ntfy_subscriptions(did);
111111+```
112112+113113+**Fields:**
114114+- `id`: Auto-increment primary key
115115+- `did`: User's DID (foreign key to user_quotas)
116116+- `topic`: ntfy.sh topic for this user (e.g., "scratchback-did:abc123")
117117+- `access_token`: Optional access token for private topics
118118+- `created_at`: Timestamp when subscription was created
119119+120120+### Passkeys Database (passkeys/passkeys.db)
121121+122122+#### passkeys Table
123123+```sql
124124+CREATE TABLE passkeys (
125125+ id INTEGER PRIMARY KEY AUTOINCREMENT,
126126+ username TEXT NOT NULL UNIQUE,
127127+ credential_id TEXT NOT NULL UNIQUE,
128128+ credential_data BLOB NOT NULL,
129129+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
130130+);
131131+132132+CREATE INDEX idx_passkeys_username ON passkeys(username);
133133+```
134134+135135+**Fields:**
136136+- `id`: Auto-increment primary key
137137+- `username`: Admin username (e.g., "admin@scratchback.co")
138138+- `credential_id`: Base64-encoded credential ID from WebAuthn
139139+- `credential_data`: Serialized PasskeyCredential (BLOB)
140140+- `created_at`: Timestamp when passkey was registered
141141+142142+## Data Relationships
143143+144144+```
145145+user_quotas (1) ----< (1..N) ----> blobs
146146+ | |
147147+ |----< sessions (0..1) |
148148+ | |
149149+ |----< ntfy_subscriptions (0..1)
150150+```
151151+152152+## Storage Key Format
153153+154154+### Blob IDs
155155+- Format: UUID v4 (e.g., "550e8400-e29b-41d4-a716-446655440e000")
156156+- Purpose: Unique identifier for each blob
157157+- Storage location: `blobs.blob_id`
158158+159159+### Session Tokens
160160+- Format: UUID v4 (e.g., "550e8400-e29b-41d4-a716-446655440e000")
161161+- Purpose: Secure session identifier for web UI
162162+- Storage location: `sessions.session_token`
163163+- Cookie name: "scratchback_session"
164164+165165+### Passkey Credential IDs
166166+- Format: Base64-encoded credential ID from WebAuthn
167167+- Purpose: Unique identifier for each registered passkey
168168+- Storage location: `passkeys.credential_id`
169169+170170+## GDPR Data Isolation
171171+172172+### EU Data Storage
173173+- **EU Users**: Stored in `srtchbk-eu` bucket (Bunny frankfurt or DO fra1)
174174+- **Enforcement**: Automatic routing based on `user_quotas.country_code`
175175+- **Blocking**: Uploads blocked until country is set in profile
176176+- **Consent Flow**: User must consent to EU data center before uploading
177177+178178+### Non-EU Data Storage
179179+- **Non-EU Users**: Stored in `srtchbk-us` bucket (Bunny us or DO nyc3)
180180+- **Enforcement**: Automatic routing based on detection or profile
181181+- **No Consent Required**: Users can upload immediately
182182+183183+### Region Mapping
184184+| Region | Backend | Bunny Region | DigitalOcean Region |
185185+|---------|----------|--------------|---------------------|
186186+| EU | Bunny | frankfurt | fra1 |
187187+| US | Bunny | us | nyc3 |
188188+| Default | Config | Config | Config |
189189+190190+## Soft Deletion Retention
191191+192192+### Deletion Process
193193+1. **Soft Delete**: Set `soft_deleted = 1`, `deleted_at = NOW()`
194194+2. **Retention Calculation**: Set `retention_until = deleted_at + 14 days`
195195+3. **Quota Update**: Do NOT update `storage_used_bytes` (data counts until hard deletion)
196196+4. **Hard Deletion**: CLI cleanup command deletes blobs where `retention_until < NOW()`
197197+5. **Data Recovery**: 14-day grace period for users to recover accidentally deleted blobs
198198+199199+### Cleanup Command
200200+```bash
201201+$ scrtchbk-ctl cleanup
202202+# Deletes blobs where retention_until < NOW()
203203+# Updates user_quotas.storage_used_bytes (subtracting deleted blob sizes)
204204+```
205205+206206+### GDPR Right to Erasure
207207+1. User requests deletion via support or UI
208208+2. Execute soft delete (step 1 above)
209209+3. Wait 14 days for automatic hard deletion
210210+4. Or execute cleanup command immediately (user consent)
211211+5. Verify all blob copies deleted from Bunny Storage
212212+6. Update user quota to reflect removed storage
213213+214214+## Quota Tracking
215215+216216+### Cumulative Model
217217+- **Storage Used**: Never resets, always accumulates
218218+- **Blob Count**: Never resets, always accumulates
219219+- **Limit Enforcement**: Hard limit at configured maximum
220220+- **Soft Warning**: Alert at 80% threshold
221221+- **No Reset**: No monthly or periodic reset of quotas
222222+223223+### Cost Calculation
224224+- **Real-Time**: API calls to Bunny/DigitalOcean for current usage
225225+- **Cached**: Results cached for 1 hour
226226+- **Background Refresh**: Cache cleared and recalculated every hour
227227+- **Memoization**: Using `cached` proc macro for function-level caching
···11+# ADR - Platform Introduction
22+33+## Context
44+55+ScratchBack PDS is being developed as a zero-knowledge Personal Data Store (PDS) implementing ATProtocol standards. This document records key architectural decisions and trade-offs made during the planning phase.
66+77+## Status
88+99+- **Date**: 2025-01-15
1010+- **Status**: Initial Planning Complete
1111+- **Next Phase**: Phase 1 - Foundation Implementation
1212+1313+## Decision Log
1414+1515+### ADR-001: ATProtocol vs Traditional Cloud Storage
1616+1717+**Decision**: Use ATProtocol (via rsky) instead of traditional S3-only architecture
1818+1919+**Rationale**:
2020+- Standards-based protocol (ATProtocol) provides interoperability
2121+- Built-in social graph and federated identity
2222+- rsky provides complete PDS implementation with SQLite backend
2323+- Supports decentralized social features
2424+- Leverages existing ATProtocol ecosystem
2525+2626+**Status**: Accepted
2727+2828+### ADR-002: Separate Passkeys Database
2929+3030+**Decision**: Use separate SQLite database for admin passkeys, not in main database
3131+3232+**Rationale**:
3333+- Security isolation: Admin credentials separated from user data
3434+- Independent encryption: Passkeys can be encrypted at rest independently
3535+- Separate lifecycle: Passkeys sync independently of main app
3636+- Encryption at rest: Age + S3 sync
3737+3838+**Status**: Accepted
3939+4040+### ADR-003: Terminal-Only Passkey Registration
4141+4242+**Decision**: Implement passkey registration as terminal-only with QR codes, no web UI
4343+4444+**Rationale**:
4545+- Admin CLI is terminal-based tool
4646+- Reduces attack surface
4747+- Fits CLI workflow
4848+- QR codes work with phone authenticator apps
4949+5050+**Status**: Accepted
5151+5252+### ADR-004: Application Crash on GeoIP Failure
5353+5454+**Decision**: Crash application if GeoIP database detection fails
5555+5656+**Rationale**:
5757+- Prevents silent failures
5858+- Forces admin to configure GeoIP
5959+- Ensures GDPR compliance by default
6060+- Fail-fast principle
6161+6262+**Status**: Accepted
6363+6464+### ADR-005: fs-err-tokio Instead of std::fs
6565+6666+**Decision**: Use fs-err-tokio crate for all file system operations
6767+6868+**Rationale**:
6969+- Consistent error handling
7070+- Better error messages with context
7171+- Easier diagnostics and debugging
7272+- Supports async operations
7373+7474+**Status**: Accepted
7575+7676+### ADR-006: Manual S3 Sync
7777+7878+**Decision**: Manual S3 sync with Age encryption instead of LiteFS
7979+8080+**Rationale**:
8181+- LiteFS crate doesn't exist
8282+- Manual sync provides full control
8383+- Age encryption at rest is sufficient
8484+- No external dependencies
8585+8686+**Status**: Accepted
8787+8888+### ADR-007: 1-Hour Cost Cache with Background Refresh
8989+9090+**Decision**: Cache costs for 1 hour with automatic background refresh
9191+9292+**Rationale**:
9393+- Balances freshness vs API usage
9494+- Reduces API costs by 90%
9595+- Background refresh ensures cache stays fresh
9696+- Uses `cached` proc macro
9797+9898+**Status**: Accepted
9999+100100+### ADR-008: Separate build.sr.ht for Admin CLI
101101+102102+**Decision**: Create separate build.sr.ht for scrtchbk-ctl binary
103103+104104+**Rationale**:
105105+- Static binary for server deployment
106106+- Different optimization levels
107107+- Reduces build time for main project
108108+- Simplifies deployment
109109+110110+**Status**: Accepted
111111+112112+### ADR-009: Session Cookies Created After Country Consent
113113+114114+**Decision**: Create session cookies only after successful country consent
115115+116116+**Rationale**:
117117+- Ensures GDPR compliance before allowing uploads
118118+- Simplified flow
119119+- Matches web UI workflow
120120+121121+**Status**: Accepted
122122+123123+### ADR-010: Bunny Storage Backup Strategy
124124+125125+**Decision**: Rely on Bunny's built-in backup (no additional implementation)
126126+127127+**Rationale**:
128128+- Bunny provides 7-day free backup retention
129129+- 99.95% uptime SLA
130130+- Automatic replication
131131+- Reduces complexity
132132+133133+**Status**: Accepted
134134+135135+### ADR-011: GeoLite2 Path via Environment Variable
136136+137137+**Decision**: Make GeoLite2 database path configurable via GEOIP_DATABASE_PATH
138138+139139+**Rationale**:
140140+- Flexible deployment
141141+- Testing support
142142+- Path independence
143143+- Priority: Environment variable > config.toml > hardcoded paths
144144+145145+**Status**: Accepted
146146+147147+## Related Documentation
148148+149149+- Architecture: docs/initial-plan/01-architecture.md
150150+- Requirements: docs/initial-plan/02-requirements.md
151151+- Data Model: docs/initial-plan/03-data-model.md
152152+- Implementation Phases: docs/initial-plan/04-implementation-phases.md