Personal study organization - calendar, focus timer, notes, and learning goals in one Blazor WebAssembly app. Multi-user with passkey login, designed to run on your own hardware, and the hub of a family of add-ons (native app, Home Assistant, AI assistant, MCP server, browser extensions, CLI, VS Code, Telegram, Alexa, an e-paper display, webhooks) that all talk to this one API.
brag-v3.mp4
Live demo — read-only, running the actual
ghcr.io/lukislp/studylife-server:latest image published by this repo's own CI/CD pipeline
(DEMO_MODE=true plus the explicit DEMO_MODE_CONFIRM_DATA_LOSS confirmation, see
Services/DemoModeGuard.cs): signs you in automatically as a demo student mid-way through the
built-in study program, with weeks of study history, a live streak, and planned sessions ahead.
Edits apply locally but are never saved; the dataset reseeds itself relative to "today" on every
container restart.
flowchart LR
subgraph Clients["Clients"]
BlazorUI["Blazor WASM UI\n(StudyLife.Client, PWA)"]
MauiApp["studylife-app\nMAUI native shell\niOS / Android / Windows / macOS"]
end
subgraph StudyLife["StudyLife (this repo, one image)"]
API["ASP.NET Core REST API\n(StudyLife.Server, Worker:Enabled=false)\nTTS (Piper) + STT (Whisper) in-process"]
AiProxy["AiProxyController\n(mints short-lived proxy tokens)"]
Worker["Worker\n(reminders, reports, nudges,\nachievements, APNs, capture enrichment)\nWorker:Enabled=true"]
DB[("SQLite / PostgreSQL\n(CloudNativePG)")]
Cache[("In-memory / Redis")]
end
subgraph Platform["Platform services"]
WebPush["Web Push\n(browser notifications)"]
APNs["Apple Push Notification service\n(Live Activity)"]
Telemetry["Prometheus scrape (/metrics)\nOTLP traces to the collector\n(opt-in, Telemetry:*)"]
end
subgraph AddOns["Add-ons (separate repos)"]
HomeAssistant["studylife-hacs\n(Home Assistant)"]
Mcp["studylife-mcp\n(MCP server)"]
StudyLifeAI["studylife-ai\n(RAG assistant + agent)"]
Capture["studylife-capture\n(browser extension)"]
StudyLifeFocus["studylife-focus\n(browser extension:\nGuard + Tune)"]
Cli["studylife-cli\n(terminal client)"]
VsCode["studylife-vscode\n(editor extension)"]
Telegram["studylife-telegram\n(bot)"]
Alexa["studylife-alexa\n(voice skill)"]
Display["studylife-display\n(e-paper dashboard)"]
Webhooks["studylife-webhooks\n(outbound fan-out)"]
Developers["studylife-developers\n(add-on registration portal)"]
Marketplace["studylife-marketplace\n(public catalog on GitHub)"]
end
MauiApp -- "BlazorWebView\n(project reference, no copy)" --> BlazorUI
BlazorUI -- "passkey session\n(X-Session-Token)" --> API
BlazorUI -- "passkey session\n(X-Session-Token)" --> AiProxy
BlazorUI -- "fetches listings\n(read-only)" --> Marketplace
AiProxy -- "signed proxy token" --> StudyLifeAI
API --> DB
API --> Cache
Worker --> DB
Worker -- "shard coordination" --> Cache
Worker -- "reminders, reports" --> WebPush
Worker -- "Live Activity updates" --> APNs
Worker -- "shared secret\n(capture enrichment)" --> StudyLifeAI
API -- "GET /metrics, OTLP" --> Telemetry
Worker -- "GET /metrics, OTLP" --> Telemetry
API -- "shared secret\n(publishes 21 event types)" --> Webhooks
Webhooks -- "signed webhooks (outbound)" --> Telegram
API -- "shared secret\n(registers portal key)" --> Developers
Developers -- "X-Api-Key\n(fixed slot: registrations only)" --> API
StudyLifeAI -- "X-Api-Key\n(fixed slot, registered on toggle)" --> API
HomeAssistant -- "X-Api-Key\n(fixed slot, key from Setup page)" --> API
Mcp -- "X-Api-Key\n(fixed slot via consent flow)" --> API
Capture -- "X-Api-Key\n(fixed slot via consent flow)" --> API
StudyLifeFocus -- "X-Api-Key\n(read-only, 2 fixed slots)" --> API
Cli -- "X-Api-Key\n(dynamic OAuth client)" --> API
VsCode -- "X-Api-Key\n(dynamic OAuth client)" --> API
Telegram -- "X-Api-Key\n(dynamic OAuth client, per chat)" --> API
Alexa -- "account linking\n(dynamic OAuth client)" --> API
Display -- "X-Api-Key\n(dynamic OAuth client, read-only)" --> API
API and Worker are the same container image, just started with a different Worker:Enabled
flag — a single-container deployment (docker run/dotnet run, Worker:Enabled defaulting to
true) runs both roles combined in one process; production and the horizontally-scalable setup
(Kubernetes/K3s via GitOps, see docs/SCALING.md; docker-compose.scale.yml for
local testing of that same split) run them as separately scaled replicas instead, so that N web
replicas never fire the same push reminder N times — the worker replicas claim shards via Redis
and each user is handled by exactly one worker per 5-second tick (Worker:ReplicaCountSource=Kubernetes
reads the live replica count, so the worker can sit behind a HorizontalPodAutoscaler too).
The browser client and the native app authenticate exclusively via a passkey session (the MAUI
shell logs in through the system browser and receives its token via a PKCE-style handoff, never
in the redirect URL itself). Every add-on instead holds a long-lived, revocable, per-user API
key (X-Api-Key) that is scoped to exactly the endpoints that add-on's real client code calls
(Auth/ApiKeyScopes.cs, one auditable map). Those keys come in two shapes: a fixed slot per
first-party add-on (Home Assistant, studylife-ai, studylife-mcp, studylife-capture, the two
studylife-focus features, studylife-webhooks, studylife-developers) whose scope is hardcoded and
code-reviewed here, and a dynamic OAuth client registered through studylife-developers, which
gets an OAuth-style consent screen listing the scopes it asked for (studylife-cli, studylife-vscode,
studylife-telegram, studylife-alexa, studylife-display, and any third-party add-on from the
marketplace). AiProxyController mints a short-lived, HMAC-signed token per chat request instead
of forwarding a stored key (which only ever exists as a hash server-side, see
Security) — studylife-ai verifies that signature locally against a shared secret, no
round-trip back here. Worker reaches studylife-ai the same way for capture enrichment
(POST /internal/enrich-capture), authenticated with a shared secret rather than a per-request
user token, since it runs outside any user's live session. studylife-app
(the native iOS/Android/Windows/macOS shell) is a separate repo that pulls in this repo's entire
Blazor UI via a project reference — no copy, so the native app and the browser/PWA client are
always pixel-identical.
Product tour (1 min): focus timer, exam planner, dashboard, notes with dictation and read-aloud, the AI assistant, the command palette and passkey sign-in.
brag-v4.mp4
- Passwordless login via passkey (WebAuthn) - no password to remember or leak
- Multiple users (e.g. family members): each has their own courses, sessions, notes, and settings, completely separated; self-registration is gated by the instance owner (
Registration__Mode:open/invite/closed, defaultinvite) with invite links created and managed on the Setup page - Add another device of your own either directly on the device itself or via a linking code from an already logged-in device (no cross-device Bluetooth fumbling needed)
- New devices/additional passkeys must first be approved from an already logged-in device before they can be used
- Device management: rename passkeys, view the list, remove individually, and "sign out everywhere else" (removing a passkey or using a recovery code revokes every other session automatically)
- Emergency access via recovery codes: generate 8 one-time codes in Setup (shown only once) to sign in if the only registered device/passkey is ever lost
- Native app sign-in through the system browser with a PKCE-style token handoff (the bearer token never travels in the app's redirect URL)
- Fully usable in 26 languages: all 24 official EU languages (English, German, French, Spanish, Italian, Portuguese, Dutch, Danish, Swedish, Finnish, Greek, Polish, Czech, Slovak, Hungarian, Romanian, Bulgarian, Croatian, Slovenian, Estonian, Latvian, Lithuanian, Maltese, Irish) plus Ukrainian and Russian
- Switcher in the top right (positioned the same way on desktop and mobile): click the current flag, choose a language from the popup; the selection persists across restarts/browser sessions
- Localized weekday and month names everywhere, 12-hour clock for English and 24-hour clock for every other language
- Week view with a full 24-hour timeline, with an optional day view on mobile devices
- Create, edit, and delete study sessions
- Course, topic, start and end time per session
- Create recurring appointments (e.g. lectures) up to an end date in a single step - selectable interval (weekly/every 2/3/4 weeks) and multiple weekdays at once (e.g. Mon+Wed+Fri)
- Session templates (quick-add): save frequently recurring appointments (e.g. "Calculus lecture, 90 min, Mondays 10:00") as a template and apply it to the calendar with a click
- Import an
.icsfile (e.g. a university timetable export): the appointments are parsed server-side and shown for review, you pick the course per appointment, and only confirmed ones become sessions - Current-time indicator
- Horizontally scrollable on mobile devices - times stay fixed
- Subscribable iCalendar feed for external calendar apps (Google/Apple Calendar, see Setup)
- Search (course/topic) and course filter (show/hide by clicking course pills)
- Warning for time-overlapping sessions (can still be saved)
- Delete confirmation for sessions (two clicks instead of immediate deletion)
- For recurring appointments: selectively delete "just this session", "this and all following", or "the entire series"
- Print the weekly schedule/export as PDF
- Automatic topic suggestion when creating a new session (from the course's still-open topics)
- Swipe gesture on mobile devices (swipe the header or calendar grid left/right) for quick week/day switching
- Exam planner: choose a course and exam date - automatically distributes the still-open topics as study sessions across free time slots in the calendar up to the date (session length and total hours configurable, suggestion editable/removable before acceptance)
- Weekly plan assistant: suggests study sessions for the current week to fill the weekly quota - weighted toward courses that haven't been studied for the longest or whose exam is approaching soonest
- Both suggestions respect existing calendar appointments and the study windows set in Setup, and are only created as actual sessions after confirmation
- Six built-in modes (Pomodoro Classic, Flow State, Ultradian Rhythm, Claude Mode, Sprint Bursts, Micro Focus) plus your own custom modes (focus/break length and rounds, defined in Setup)
- Animated timer with progress ring
- Keeps running in the background when switching pages (singleton service)
- The timer state lives on the server: a session started in the browser, the native app, VS Code, or Telegram is the same session everywhere, and the worker keeps the iOS Live Activity in step with it while the app is closed
- Automatic switching between focus and break phases; optional automatic switch to the Focus page when a planned session starts
- Browser tab title shows the remaining time while the timer is running
- Sound and vibration feedback when a session completes
- Reflection prompt after a session ends ("What did you learn?"), saved directly as a linked note
- Movement-break reminder (native app only): after ~25 minutes of continuous, uninterrupted focus, a dismissible banner + notification suggests a short break if Apple HealthKit step data shows barely any movement
- Distraction blocking and automatic Spotify playlist switching while a session runs (via the studylife-focus browser extension's independently-toggleable Guard and Tune features): allowlist or blocklist specific sites with automatic tab redirect on session start and restore on session end, and/or switch to a focus/break Spotify playlist
- Live updates: sessions, notes, settings, and every other change appear across your devices within a second, pushed by the server (Server-Sent Events) — no fixed polling interval to wait out; the summary itself is computed server-side (
GET /api/dashboard/summary) and renders progressively - Daily overview with active/next session
- Weekly statistics (sessions, hours, streak)
- Weekly quota (default 25-30 h, configurable) with progress bar and warning if falling short
- Monthly quota (default 100-130 h, configurable independently of the weekly goal)
- Course overview as pills
- Weekly trend of the last 8 weeks as a bar chart
- Upcoming course goals: the next 5 open goals with countdown or overdue notice
- Study progress tile: ECTS progress and weighted grade average at a glance
- Mini donut chart: course time distribution over the last 30 days
- Today tile: progress ring (hours today vs. daily goal) and 7-day streak bar
- Weekly comparison: delta in study hours versus the previous week
- Most recently completed sessions as a mini list
- Preview of the most recently edited note
- Balance check: which active course hasn't been studied for the longest
- Achievements: permanent milestone badges (total hours, longest streak, sessions, completed courses, all courses completed)
- Topic progress: checked-off course topics across all courses
- Inactivity notice directly in the dashboard (visible even without push notifications enabled)
- Series icon on today's sessions that are part of a recurring series
- ECTS forecast: expected completion date at the current study pace
- Target-completion tile: given a self-set target date, how many hours/week are needed for it
- Productivity hint: suggests the best time of day for the next session based on your study rhythm so far
- Month/year comparison: study hours versus the previous month and (if enough data is available) the same month in the previous year
- Course tags as small badges on the course pills (e.g. "exam soon")
- Global search / command palette (Ctrl+K / Cmd+K, or the speed-dial button on mobile) across courses, notes (full-text), and upcoming sessions
- Study readiness score (native app only): a personal Heart Rate Variability baseline comparison via Apple HealthKit - today's HRV against your own 30-day rolling average, with the raw values shown alongside the score
- Sleep consistency tile (native app only): how variable your bedtime has been over the last 30 nights, via Apple HealthKit sleep data
- Free-text notes, optionally assigned to a course
- Togglable Markdown mode per note (headings, bold/italic, lists, quotes, code, tables, links) with a live preview — plain text stays the default, nothing changes for existing notes
- Read a note aloud: natively synthesized (German/English) via a self-hosted Piper voice running inside the server (
StudyLife.Tts, ONNX Runtime + espeak-ng), no cloud TTS service involved — every other language falls back to the browser's own built-in speech synthesis, so it works everywhere, just with native voice quality only for the two baked-in languages (see docs/TTS-VOICES.md for the full coverage matrix and voice licenses) - Dictate a note instead of typing it: the recording is transcribed on your own server by a self-hosted Whisper model (
StudyLife.Stt, the multilingualbasemodel baked into the image) — audio never leaves your instance - Automatic saving while typing; the open editor refreshes when another device changed the same note
- Full-text search (SQLite FTS5 / PostgreSQL tsvector, server-side) and course filter
- Delete confirmation (two clicks instead of immediate deletion)
- Link to the triggering focus session visible (🔗), if created from the reflection prompt
- Web capture (via the studylife-capture browser extension): save a selection or a whole article from any page as a note, auto-enriched in the background with a course match, tags, a one-sentence summary, and related-notes links
- AI study assistant (via studylife-ai, opened from the speed dial): questions over your own notes/courses/sessions with source citations, and an agent that can create sessions or summarize-and-save notes after an explicit confirmation step
- Hours studied and sessions per course
- ECTS-weighted grade average
- Study progress (achieved / total ECTS)
- Study heatmap: year view of daily study intensity (GitHub-style)
- Course time distribution as a donut chart
- Study rhythm: distribution by weekday and time of day
- Monthly course history (last 6 months) as a stacked bar chart
- Year in review: "Wrapped"-style summary (total hours, strongest course, most productive day/time, longest streak, total sessions)
- ECTS forecast and month comparison (previous month) in the study progress tile
- Study report as a printable PDF: total hours, hours per course, ECTS progress including grade average, course goal status - for scholarship applications or academic advising
- Cardio fitness (VO2max) trend (native app only): chart of Apple Watch-measured cardio fitness over the last year, via Apple HealthKit
- All of these numbers come from one shared metrics implementation (
StudyLife.Shared/StudyMetrics.cs) that the dashboard, the evaluation page, the report, and theGET /api/metrics/*API used by Home Assistant and other add-ons all share
- Optional, public read-only link (no login) with a compact progress snapshot (ECTS, grade average, topic progress of active courses) - for sharing with parents or a mentor
- Deliberately shows no notes, calendar details, or settings
- Can be disabled at any time or reissued with a new link
- JSON export of your own data (sessions, notes, course goals, settings) and re-import
- Full database download, optionally encrypted with a self-chosen password (AES-256)
- Restore from a previously downloaded backup, including the encrypted variant
- Weekly automatic background backup (the last 4 weeks are retained; SQLite deployments)
- Database download/restore is reserved for the account's original setup user; the JSON export is available to every account
- Session reminders at configurable lead times before a planned study phase (default 60/30/10/5/3/2/1 minutes)
- Permission is requested on the first app start
- Each reminder is sent only once (duplicate protection)
- Reminders before a course's target date (default 14/7/3/1/0 days ahead)
- Motivating reminder if no study session has taken place for several days (default 5 days), optionally per course
- Weekly review via push (Sunday evening) and a monthly report on the 1st
- Achievement push when a new milestone badge is unlocked
- Opt-in nudges: streak at risk, weekly goal falling behind, a course that is almost done, your historically best study time, a gentle comeback nudge after one idle day, and a daily motivational quote
- All reminder thresholds and every nudge individually configurable in Setup
- Native app: local notifications, home screen widgets, and an iOS Live Activity kept up to date by the server via APNs
- Manually switch theme: System / Light / Dark, plus an accent color
- Activate/complete courses, set a target date per course
- Optionally record a grade (German grading system) and completion note when completing
- Topic checklist per course: check off individual topics, progress display (N/M)
- Set a course tag (free text, e.g. "exam soon") per course
- Course resources: a list of links (lecture notes, scripts, videos) per course
- Choose a preferred motivation profile
- Switch between the built-in study program (a real degree program shipped as example content) and your own custom ones (create, mark complete, delete); once you have a program of your own, the built-in one can be hidden from the switcher for good
- Calendar subscription URL for copying
- Weekly and monthly study goals, a desired graduation date, and custom timer modes
- Reminder thresholds (session lead time, course goal lead time, inactivity threshold) individually adjustable, plus on/off toggles for every optional nudge
- Study windows: hours (from/to) and weekdays adjustable, in which the exam planner and weekly plan assistant are allowed to suggest sessions
- Opt-in anonymous usage & error reports (performance and crash data only, never notes/sessions/health data) - off by default, asked once via a consent modal, changeable anytime
- Manage passkeys (rename, add more, remove, sign out everywhere else)
- Generate recovery codes for emergency access (shown once, invalidates previous codes)
- Invite links for new accounts (instance owner)
- Integrations: the Home Assistant key, the AI assistant and developer-portal toggles, named webhooks keys and webhook subscriptions, the Marketplace browser, and an "External connections" card that disconnects any add-on (fixed slot or dynamic client) with one click
- Manage backup/export/restore and the share-progress link
- App icon shortcuts (long-press on the home screen icon): start focus, new note, calendar
- Offline write queue: sessions and settings saved while offline are replayed once the connection is back
- StudyLife App is a separate .NET MAUI Blazor Hybrid shell (iOS/Android/Mac/Windows) built on top of this repo's Blazor Client, adding native notifications, home screen widgets, Live Activities, Siri Shortcuts, an Apple Watch companion app, and Apple HealthKit integration (HRV-based study readiness, sleep consistency, a movement-break reminder, and a cardio fitness trend chart) - all read-only and processed entirely on-device, never leaving it.
| Layer | Technology |
|---|---|
| Frontend | Blazor WebAssembly (.NET 10), PWA; i18n via Toolbelt.Blazor.I18nText |
| Backend | ASP.NET Core (.NET 10), controllers + service layer, OpenAPI contract committed under docs/api/openapi.json |
| Login | Passkey/WebAuthn (Fido2NetLib) |
| Database | SQLite via Entity Framework Core (default) - optionally PostgreSQL (Npgsql) for horizontally scalable operation, with twin migrations for both providers |
| Cache / coordination | In-memory (default) or Redis (Cache:Provider=Redis): response caching, session-token cache, rate-limit buckets, worker shard claims, Data Protection key ring |
| Speech | Piper voices via ONNX Runtime + espeak-ng (StudyLife.Tts), Whisper via Whisper.net (StudyLife.Stt) - both run inside the server, no external service |
| Push | Web Push (VAPID) and APNs (Live Activities) |
| Telemetry | OpenTelemetry: Prometheus exposition on a dedicated port, OTLP trace export, client beacon - all opt-in |
| Deployment | Kubernetes/K3s via GitOps (Flux), see below - docker-compose.scale.yml for local testing of that same setup |
| CI/CD | GitHub Actions with Semantic Release, Sigstore keyless signing, Trivy, CodeQL, OpenSSF Scorecard |
- A Kubernetes cluster (K3s is what production actually runs on; any conformant cluster works) with
kubectlconfigured against it, plus the operators the manifests assume: CloudNativePG, cert-manager, and NGINX Gateway Fabric (Gateway API). docs/SCALING.md walks through a from-scratch reference setup (MetalLB, gateway, TLS, monitoring) on a Raspberry Pi K3s cluster.
git clone https://github.com/lukislp/studylife.git
cd studylife
kubectl apply -f k8s/The manifests under k8s/ deploy the full stack pulling the public ghcr.io/lukislp/studylife-server image - no registry login needed:
04-web.yaml/05-worker.yaml- the two Deployments (same image,Worker__Enableddiffers), each with a CPU-based HorizontalPodAutoscaler (04c/05c: web 2-4, worker 1-4 replicas), surge-only rollouts, read-only root filesystems, and a PodDisruptionBudget (10-...)02-postgres.yaml- a 3-instance PostgreSQL 16 cluster via CloudNativePG,11-pooler.yamla PgBouncer pooler in front of it,08-scheduled-backup.yamla daily 03:00 backup to S3-compatible object storage (30-day retention)03-redis.yaml- a 6-node Redis Cluster StatefulSet with client TLS and a dedicated ACL user07d-httproutes.yaml- Gateway APIHTTPRoutefor NGINX Gateway Fabric with backend TLS, asset caching, and an edge rate limit12-network-policies.yaml- default-deny ingress plus explicit allow rules (gateway to web, studylife-ai/-mcp/-developers to web, app to Postgres/pooler/Redis, Prometheus to the metrics ports)01-config-and-secret.yaml- the ConfigMap/Secret pair;sealed-secrets/holds the production values encrypted in Gitflux/- the FluxGitRepository+Kustomizationthat reconcilesk8s/flux/deploy/(web + worker) frommain
k8s/bootstrap-cluster.ps1 automates this end-to-end on a cluster that already has the operators above (your own Postgres password substituted in place of the repo's test placeholder, plus the one-time Redis cluster bootstrap and, optionally, the R2 backup and Flux) - see its header comment and docs/SCALING.md for the full walkthrough. k8s/dev/ contains the plaintext placeholder secrets for the local kind learning cluster described there.
On the very first start (no user registered yet), the server outputs a one-time setup code to its logs (kubectl -n studylife-scale logs -l app=studylife-web) - this code is requested during the first passkey registration and protects against someone else on the same network claiming the initial registration before the actual operator. Every subsequent registration (e.g. family members) does not need this code, but by default needs an invite link from the owner.
A single-container deployment (docker run ghcr.io/lukislp/studylife-server, or plain dotnet run for local dev) works too and needs no Kubernetes at all - Worker:Enabled defaults to true, so one process/container handles both web traffic and the background tick, SQLite lives under app_data/, and the in-memory cache replaces Redis.
Server configuration is the ConfigMap/Secret pair in k8s/01-config-and-secret.yaml (prod manages the real values as encrypted-in-Git SealedSecrets instead, see docs/SCALING.md, "Sealed Secrets") - the same appsettings/environment-variable keys apply to every deployment shape, k8s included. The typed sections live in src/StudyLife.Server/Configuration/ and are validated at startup: Database (Provider, ConnectionString), Cache (Provider, Redis endpoints/TLS/password), Worker (Enabled, ReplicaCount, ReplicaCountSource), Registration (Mode), Fido2 (relying-party pinning), Consent (PKCE enforcement, per-audience redirect allow-lists), Vapid, Apns, Apple, StudyLifeAi, StudyLifeWebhooks, StudyLifeDevelopers, Speech/Tts/Stt, Telemetry (MetricsPort, OtlpEndpoint, sample ratios), WebBackendTls, ForwardedHeaders. Every integration section is optional: with it unset, that integration is simply off. See docs/SCALING.md for the deployment-shape keys and docs/ARCHITECTURE.md for the rest.
The k8s setup above already runs this way by default: PostgreSQL instead of SQLite, Redis instead of the in-memory cache, web and worker as separately scaled replicas (Database:Provider=Postgres, Cache:Provider=Redis). docker-compose.scale.yml reproduces the same topology locally, disposably, for testing/learning without a real cluster - see docs/SCALING.md for both. A complete, production-operated reference architecture (K3s cluster on Raspberry Pi, Postgres HA via CloudNativePG, Redis Cluster, NGINX Gateway Fabric, HorizontalPodAutoscalers, network policies, sealed secrets, Prometheus/Grafana/Loki/Tempo), including all lessons learned, is documented there too.
Everything is opt-in and off by default. Telemetry:MetricsPort opens a dedicated, non-public Kestrel listener answering only GET /metrics in Prometheus format (ASP.NET Core, Kestrel, EF Core/Npgsql, .NET runtime, rate limiter, HttpClient meters plus StudyLife's own: cache outcomes, SSE streams, TTS, webhooks, worker tick). Telemetry:OtlpEndpoint exports request/HttpClient/Npgsql traces via OTLP to a collector (production forwards to Grafana Tempo), head-sampled at Telemetry:TraceSampleRatio. Client-side, POST /api/telemetry accepts a small batch of boot/vitals/API/SSE/error events - only after the user explicitly accepted the consent modal, never on the demo instance, and never carrying a user id, IP, or content. Details in docs/ARCHITECTURE.md.
Production uses GitOps, not a polling updater: Flux (k8s/flux/) reconciles k8s/flux/deploy/ (the web and worker Deployments) from this repository's main branch every 5 minutes. The image tag in k8s/04-web.yaml/k8s/05-worker.yaml is not bumped by Flux's image automation anymore but by the pipeline itself: after a release, the deploy-bump job writes the released version into those manifests and pushes it over a deploy key, so Flux (read-only) rolls the new image out - the exact deployed manifest is always what's checked into Git, and the image is only ever one that passed the Trivy gate and was signed with cosign. This replaced an older single-container setup where Watchtower polled for new images and restarted the container itself, and the intermediate Flux image-update automation; see docs/SCALING.md, "GitLab Integration: Kubernetes Agent + Flux Image Automation" for the history.
- .NET 10 SDK
- Visual Studio 2022+ or Rider
- Python 3 for the i18n/date-format checks (
tools/check-i18n.py,tools/check-date-formats.py)
cd src/StudyLife.Server
dotnet runThe app is then reachable at https://localhost:53963. The SQLite database is automatically created under app_data/studylife.db. The speech features need the Piper voices (tts-voices/) and the Whisper model (stt-model/) next to the server, which the Dockerfile downloads at image build time; without them the TTS/dictation endpoints answer 404 and everything else works. Run dotnet format StudyLife.sln before pushing (CI verifies it), and commit the regenerated docs/api/openapi.json after any controller/DTO change.
src/
|-- StudyLife.Client/ # Blazor WebAssembly frontend (PWA)
| |-- Pages/ # Dashboard, Calendar, Focus, Notes, Planner, WeekPlan, Stats, Wrapped, Report,
| | # Setup, Login/Register/Link, SharedProgress, Connect* consent pages
| |-- Components/ # Dashboard/Calendar/Focus/Setup/Stats cards, consent + telemetry modals
| |-- Services/ # AppStateService (offline write queue), TimerService, TelemetryService,
| | # MarketplaceClient, SessionHandler, native-platform interfaces
| |-- i18ntext/ # 26 languages x every text table
| `-- wwwroot/ # Static assets, CSS, index.html, service worker
|-- StudyLife.Server/ # ASP.NET Core backend + background worker (one image)
| |-- Auth/ # Authentication handler, policies, ApiKeyScopes (the per-slot scope map)
| |-- Configuration/ # Typed options sections, validated at startup
| |-- Controllers/ # One controller per API area (AuthController split into numbered partials)
| |-- Services/ # Domain services, BackgroundTaskService subtasks, proxies to the add-on services
| |-- Data/ + Migrations/ # EF Core DbContexts, twin migrations for SQLite and PostgreSQL
| `-- Dockerfile # Multi-arch runtime image; pulls the Piper voices + Whisper model
|-- StudyLife.Shared/ # DTOs, course catalog, shared metrics + summary builders, planner, JSON context
|-- StudyLife.Tts/ # Piper text-to-speech: voice registry, espeak-ng phonemizer, Markdown-to-speech
`-- StudyLife.Stt/ # Whisper speech-to-text (Whisper.net)
tests/
|-- StudyLife.Server.Tests/ # Integration tests against the real pipeline (auth, scopes, demo mode, worker, ...)
|-- StudyLife.Shared.Tests/ # Metrics, planner, golden fixtures, DTO contracts
|-- StudyLife.Client.Tests/ # bUnit tests for client services and dialogs
`-- StudyLife.Tts.Tests/ # Text chunking and Markdown-to-speech
k8s/ # Namespace, config/secret, CNPG Postgres, Redis Cluster, web + worker (+ HPAs),
| # HTTPRoutes, scheduled backup, PDBs, pooler, network policies
|-- flux/ # Flux GitRepository + Kustomization (reconciles flux/deploy/)
|-- sealed-secrets/ # Production secrets, encrypted in Git
|-- dev/ # Placeholder secrets for the local kind learning cluster
`-- bootstrap-cluster.ps1 # App-stack bootstrap on a prepared cluster
docs/
|-- ARCHITECTURE.md # Architecture, API, security model, telemetry, notes for changes
|-- SCALING.md # Postgres/Redis/K8s reference setup and every lesson learned
|-- TTS-VOICES.md # Voice coverage matrix and licenses
`-- api/ # openapi.json (committed contract), metrics fixtures
.github/workflows/
|-- ci-cd.yml # Test, build, release, image, Trivy, cosign, deploy-bump
|-- scorecard.yml # OpenSSF Scorecard
|-- dependency-review.yml # Advisory check on PR dependency changes
`-- dependabot-*.yml # Lock-file regeneration and auto-merge for Dependabot PRs
docker-compose.scale.yml # Local Postgres + Redis + N web replicas + worker + load balancer
Directory.Build.props # Locked restores, warnings as errors
Architecture, API reference, and notes for changes: docs/ARCHITECTURE.md.
Every add-on lives in its own repository and extends this app without its own database or user
system: each one that talks to the API does so with a per-user, narrowly scoped API key
(X-Api-Key) whose allowed endpoints are fixed in Auth/ApiKeyScopes.cs. Two
provisioning shapes exist. Fixed slot: a first-party integration whose scope is hardcoded and
code-reviewed here - the key is either generated manually on the Setup page (Home Assistant,
webhooks), registered automatically when you flip a toggle (AI assistant, developer portal), or
obtained through a browser sign-in/consent flow on your own instance (MCP, Capture, Focus).
Dynamic OAuth client: an add-on registered through studylife-developers with its own client
ID, redirect URIs, and requested scopes; connecting it shows the generic consent screen with
exactly those scopes, and the resulting key can only reach what it asked for.
| Add-on | What it does | Auth model | Read-only? |
|---|---|---|---|
| studylife-app | .NET MAUI Blazor Hybrid shell (iOS/Android/Mac/Windows) that reuses this repo's Blazor UI; native notifications, widgets, Live Activities, Siri, Apple Watch, HealthKit tiles | Passkey session via the system browser, PKCE-style token handoff | No (it is the app) |
| studylife-hacs | Home Assistant custom integration: 30+ sensors, calendars, a course picker, six services | Fixed slot ha, key generated once on the Setup page |
No (creates/edits sessions and goals, switches programs) |
| studylife-ai | RAG study assistant with citations, LangGraph agent with a confirmation flow, capture enrichment; FastAPI + LiteLLM + Qdrant | Chat: short-lived HMAC proxy token minted per request. Indexing worker: fixed slot ai, registered automatically on the Setup toggle. Enrichment: shared secret |
No (creates sessions and notes after confirmation) |
| studylife-mcp | MCP server for Claude and other MCP clients; stdio or Streamable HTTP with its own OAuth 2.1 server | Fixed slot mcp via the /connect/mcp consent flow |
No (create note, create session; no update/delete) |
| studylife-capture | Chrome extension (MV3) saving selections or whole articles as notes, enriched by studylife-ai | Fixed slot capture via the /connect/capture consent flow |
No (creates notes only) |
| studylife-focus | Chrome extension (MV3): Guard blocks/allows sites during a session, Tune switches a Spotify playlist | Two fixed slots focusguard/focustunes, each via its own consent flow |
Yes (GET /api/timerstate only) |
| studylife-cli | Terminal client for notes, sessions, goals, timer, courses, programs, webhooks; --json on every command |
Dynamic OAuth client (studylife login opens the browser) |
No (whichever write scopes you grant) |
| studylife-vscode | VS Code sidebar: control the focus timer, log coding time as study sessions | Dynamic OAuth client | No (TimerState.Save, Sessions.Create) |
| studylife-telegram | Telegram bot: start/pause/stop the timer, today/agenda, quick notes, session-event and reminder messages | Dynamic OAuth client, one key per chat obtained with /login (PKCE, callback on the bot); receives events as a studylife-webhooks subscriber |
No (timer, sessions, notes) |
| studylife-alexa | Alexa Skill backend: study time, next session, goals, program progress, note search, create a note by voice | Account linking: its own OAuth 2.0 server wraps the dynamic-client consent flow | Mostly (only Notes.Create writes) |
| studylife-display | Raspberry Pi e-paper dashboard: today's hours, streak, next exam countdown, weekly goal, heatmap, timer line | Dynamic OAuth client with three read scopes | Yes |
| studylife-webhooks | Outbound fan-out: signed HTTP callbacks for session/note/goal/... events to Zapier, n8n, Discord, bots | StudyLife calls it with a shared secret; subscriptions are managed from the Setup page or with a named fixed-slot webhooks key |
n/a (never calls back into study data) |
| studylife-developers | Portal for registering your own add-on (client ID, scopes, redirect URIs) against your instance | Fixed slot developer, registered automatically on the Setup toggle; can only manage your registrations |
n/a (no study data) |
| studylife-marketplace | Public catalog of add-on manifests, validated in CI | None - your browser fetches the listings read-only from GitHub | n/a |
Keys of both shapes are shown in plaintext exactly once (or never, for the toggle and consent flows), stored only as a hash, and revocable from the Setup page at any time; a leaked key compromises exactly one account and only the endpoints its slot or grant allows. The Security section has the details.
Beyond the first-party integrations above, StudyLife has a generic, data-driven client registry so any third-party add-on can plug in without a code change to StudyLife itself: a developer registers their own add-on's name, description, redirect URIs, and the exact API scopes it needs, and it gets the same OAuth-style consent screen the first-party consent flows use, generalized to any registered client (AuthController.10.OAuthClients.cs, Pages/ConnectClient.razor).
- Browse & install: the Setup page's Marketplace card lists every add-on published to studylife-marketplace, fetched directly from GitHub by your own browser (cached for 24 hours). Installing registers the add-on on your instance and takes you straight to a consent screen showing exactly what it's asking for.
- studylife-marketplace - the public catalog: one JSON manifest per add-on (name, developer, requested scopes, redirect URI, a link to the add-on's own repository - never code), added via pull request and validated in CI against a JSON schema. Its
known-scopes.jsonmirrorsApiKeyScopes.PubliclyGrantable. - studylife-developers - the portal for registering your own add-on against your own instance: a client ID, the scopes it needs (drawn from
ApiKeyScopes.PubliclyGrantable, an explicit allowlist that excludes anything owner-only like settings, invites, or backups), and its redirect URIs. Pairs with your instance the same toggle-and-forget way as the AI assistant. - Granted scopes are snapshotted onto the issued key at the moment the user consents (
ClientApiKeyEntity.GrantedScopes), never re-read live from the add-on's registration - a developer widening their add-on's requested scopes later never silently escalates access for users who already installed it; the consent page also echoes the scope list it rendered, and the server rejects the connect if the registration changed in between. - The one publicly grantable write scope that no fixed slot has is
TimerState.Save("Start, pause and stop the live timer"): it lets an add-on drive the timer where the user actually is - editor, chat, voice - and is safe to grant because the worst case is a session the user ends with one click.
studylife-cli is a terminal client for notes, sessions, course goals, the focus timer, courses, study programs, and webhooks - with a --json flag on every command for scripting, study-time reports and exports, and a live dashboard. It registers itself through the same generic dynamic-client mechanism as the Add-on Marketplace above (studylife login opens your browser to approve the connection, the key lands in a local config file) rather than through any dedicated server-side code, so it needs nothing StudyLife itself doesn't already expose.
studylife-webhooks delivers signed HTTP callbacks (X-StudyLife-Webhook-Signature, HMAC-SHA256 over the raw body) to your own automations (Zapier, n8n, Discord, and similar) or to add-ons such as studylife-telegram. Unlike the add-ons above it's an outbound event pipe rather than something that reads study data: StudyLife's backend publishes 21 event types (Services/WebhookEventTypes.cs - timer started/ended, session created/completed/deleted, new record, note created/updated/deleted, course goal created/updated/completed/deleted, course resource and session template created/deleted, study program created/completed/deleted, plan generated) to the service with a shared secret, and the service fans them out to every matching subscription. Subscriptions are created, listed, and deleted from the Setup page; alternatively, one or more independently-named webhooks API keys let an external program manage its own subscriptions through api/webhooks - and nothing else. Private, loopback, and cluster-internal target URLs are rejected before they are ever registered.
StudyLife for Home Assistant is a separate HACS custom integration that maps dashboard and evaluation data (active/next session, weekly/monthly statistics, streak including the longest ever achieved series, quotas, grade average, ECTS progress, ECTS forecast, month comparison, achievements, topic progress, course tags, course catalog, live timer phase, weekly review as an event) as sensors, binary sensors (including inactivity warning), and calendars (sessions plus course goals) in Home Assistant - one device per study program - plus a dropdown of active courses (select.studylife_active_course) and six services for creating/editing/deleting sessions and course goals, generating an exam plan, and switching the active program. All numbers come pre-computed from GET /api/metrics/summary and GET /api/metrics/achievements, the same shared metrics code the dashboard uses. The pairing runs via a per-user API key generated once on the Setup page (see Security). Installation and details are in that repo's README.
Pull requests are welcome - CONTRIBUTING.md describes the process (issue first for bigger changes, Conventional Commits, tests for new functionality, the required checks) and how to run everything locally.
Login runs exclusively via passkey (WebAuthn) - there is no password and no unauthenticated API access. The very first registration on a fresh installation additionally requires the setup code output once to the server logs (see Deployment above); every subsequent registration (e.g. for family members) creates its own account, completely separate from other users - by default it requires an invite link created by the instance owner on the Setup page (Registration__Mode: open/invite/closed, default invite). A session token extends on a sliding basis with active use (90 days), but forces a fresh login after 180 days at the latest. An additional device can either be registered directly or paired via a time-limited linking code from an already logged-in device - in both cases an already logged-in device must first approve the new device via device management before it can be used. Removing a passkey, a recovery-code login, and the explicit "sign out everywhere else" button all revoke every other session; the recovery-code login is additionally throttled per IP and by an instance-wide bucket that forged client IPs cannot bypass.
For non-interactive integrations like Home Assistant, which cannot maintain a passkey session, a long-lived, per-user API key can be generated on the Setup page (X-Api-Key header) - it does not rotate automatically, but can be revoked immediately at any time; a leaked key therefore only ever compromises exactly one account. Every key slot is scoped to only the endpoints its integration actually calls (Auth/ApiKeyScopes.cs: ha is the widest because it manages the calendar and settings on the user's behalf, focusguard/focustunes the narrowest with GET /api/timerstate alone) and a request outside that scope answers 403; the enforcement is unconditional, there is no configuration switch that turns it off. The other first-party add-ons (AI, MCP, Capture, Focus, developers portal) receive their equally scoped keys without any manual copying - via a Setup toggle or a browser sign-in/consent flow whose redirect targets are allow-listed per audience - and can be disconnected from the Setup page at any time. Dynamically registered add-ons can only request scopes from ApiKeyScopes.PubliclyGrantable (never settings, invites, backups, or restore), the consent flow supports PKCE (mandatory with Consent:RequirePkce), the granted scopes are frozen onto the key at consent time, re-consenting supersedes the previous key, and every issued key is listed and revocable per user. The subscribable iCalendar feed and the optional public share-progress link each use their own separate tokens that can reach nothing but their single endpoint.
Around that: per-IP rate limiting on /api (shared across replicas via Redis in the scaled deployment, plus an edge limit on the gateway), a per-user concurrency cap on the CPU-heavy endpoints (transcription, synthesis, AI proxy, export, exam planner), bounded request bodies and inputs everywhere, an outbound-URL policy that refuses private/loopback/cluster-internal targets for push subscriptions and webhooks, a strict Content-Security-Policy, HSTS, X-Frame-Options: DENY, and a Permissions-Policy that allows only the microphone (for dictation). The demo instance blocks every mutating request in middleware and only arms itself when DEMO_MODE_CONFIRM_DATA_LOSS is set explicitly, so a stray DEMO_MODE=true can never wipe a real database. Details on the complete security model (including the audits that shaped it): docs/ARCHITECTURE.md.
Vulnerabilities: please report them privately via GitHub's private vulnerability reporting, see SECURITY.md. Supply chain: NuGet restores run in locked mode against committed packages.lock.json files, GitHub Actions are SHA-pinned and kept current by Dependabot, every pull request runs a dependency review, CodeQL scans the code, the OpenSSF Scorecard runs weekly, the published image is scanned by Trivy and signed keyless with Sigstore cosign (with SBOM and SLSA provenance attached), and the release ZIP carries a cosign bundle plus GitHub build provenance.
Runs as GitHub Actions (.github/workflows/ci-cd.yml) on every push to main, every pull request targeting it, and every merge-queue build.
| Stage | Job | Description |
|---|---|---|
| test | test-unit |
dotnet test for all four test projects (Shared, Server, Tts, Client), plus a self-hosted coverage badge (.github/badges/coverage.json) generated from the merged coverage report |
| test | test-i18n |
check-i18n.py (all 26 languages, every table) and check-date-formats.py |
| test | test-lint |
dotnet format --verify-no-changes |
| test | test-security |
NuGet vulnerability scan; High/Critical findings fail the build |
| test | test-openapi-contract |
Fails if the committed docs/api/openapi.json differs from the built API |
| test | test-ef-migrations |
Fails if the SQLite or the PostgreSQL model has changes without a migration |
| test | test-k8s-manifests |
kubeconform schema validation of k8s/ and of the kustomize output Flux actually applies |
| test | test-compose-scale |
Syntax/interpolation check of docker-compose.scale.yml |
| build | build |
Restore (--locked-mode against the committed packages.lock.json files, like every restore in CI) and build all projects (needs all test jobs to pass) |
| version | get-version |
Semantic Release dry run against Conventional Commits; the rest of the chain skips itself when no release is due. Push events only |
| publish | publish-server |
dotnet publish (linux-x64 + linux-arm64) + ZIP artifact |
| release | semantic-release |
Real semantic-release run: publishes the GitHub release + changelog, commits the coverage badge, then signs server.zip (keyless Sigstore bundle + GitHub build provenance uploaded next to it) |
| docker | docker-server |
Per-architecture (amd64/arm64) image builds pushed by digest to the public ghcr.io/lukislp/studylife-server registry, with SBOM and SLSA provenance attestations |
| docker | trivy-server |
Container vulnerability scan of the freshly built image; reports HIGH+CRITICAL informationally, blocks the manifest merge (and thereby the deploy) on any fixable CRITICAL finding |
| docker | docker-manifest-merge |
Creates the multi-arch manifest, signs it keyless with cosign, and verifies the signature |
| deploy | deploy-bump |
Writes the released version into the k8s/ manifests and pushes it over the deploy key, so Flux (read-only) rolls the new image out |
get-version through deploy-bump form a serialized release chain (concurrency: studylife-release-chain) and only run on pushes to main, never on pull requests. Pull requests merge through GitHub's merge queue: every queued PR is rebuilt on top of the current main (the merge_group trigger runs the test stage again) before it lands, so a merge can never be tested against a stale base. Alongside: dependency-review.yml (advisory check on every PR's dependency changes), scorecard.yml (weekly OpenSSF Scorecard), dependabot-lockfiles.yml/dependabot-auto-merge.yml (lock-file regeneration and auto-merge of patch/minor Dependabot updates), and CodeQL via GitHub's default setup. The shared actions and reusable workflows come from lukislp/ci-workflows, pinned by SHA.
Versioning via Conventional Commits:
- feat: minor version
- fix: patch version
- BREAKING CHANGE: major version
Copyright (C) 2026 Lukas Koerber
AGPL-3.0 - if you run a modified version of this app as a network service, you must make your modified source available to its users. The Home Assistant integration is maintained as a separate, MIT-licensed repository: see Home Assistant Integration.
The English text-to-speech voice (en_US-amy-low, used by the "read note aloud" feature) is
built on the Mimic 3 voices dataset, licensed
CC-BY-SA-4.0 - attribution required. The
German voice (de_DE-thorsten-low, Thorsten-Voice)
is CC0. Both via rhasspy/piper-voices; see
docs/TTS-VOICES.md for the full coverage matrix and licenses.
