All notable changes to OpenStudy will be documented here. Format loosely follows Keep a Changelog, versions follow SemVer.
The multi-tenant migration (Phases 0-7) is complete. OpenStudy can now be self-hosted as single-user OR run as a multi-user platform from the same codebase. All credentials are per-user; the operator's bot, password, and chat ID never serve as fallbacks for other users.
- Schema: every owned table has a
user_idFK with composite PKs/FKs enforcing per-user data integrity. Cross-user FK violations are structurally impossible. - RLS policies: shipped on every owned table. Inert under the current
BYPASSRLS connection role; flip to a non-bypass role to activate
defense-in-depth (see
docs/RLS.md). - Services: every public service function takes
user_idand filters by it. Storage paths resolve underSTUDY_ROOT/<user_id>/.... - Auth: home-grown signup + email verification + password reset.
Login is
email + passwordonly —APP_PASSWORD_HASHis bootstrap-only. - MCP: bearer tokens bind to user_id; tools operate on the bearer's data only.
- Per-user secrets: Telegram credentials live in
user_secrets(Fernet-encrypted), configured via Settings UI. No env-var fallbacks forTELEGRAM_BOT_TOKEN,TELEGRAM_CHAT_ID,TELEGRAM_WEBHOOK_SECRET. - Tests: 318 passing (was 213 pre-migration).
- After upgrade, run
./deploy.shonce — it applies all 6 phase migrations in order, runsscripts/migrate_study_root.sh(FS layout), andscripts/seed_operator_password.py(operator password). - Existing operators: log in with
OPERATOR_EMAIL(defaultoperator@local)- the password whose hash was in
APP_PASSWORD_HASH. Reconfigure your Telegram bot via Settings → Telegram (the env vars no longer work).
- the password whose hash was in
- Self-hosters running solo: nothing changes day-to-day. You ARE the
operator user. Multi-user signups are gated by
SIGNUPS_ENABLED=falseby default; flip it on if you want to invite others.
- Stripe billing — separate batch after this lands.
- n8n Moodle scraper multi-tenancy — operator-only today; per-user Moodle is a follow-up when first hosted user requests it.
- Connection role flip to non-BYPASSRLS — operational; documented in
docs/RLS.md; flip when ready.
- New
user_secretstable (1:1 with users):telegram_bot_token_enc bytea,telegram_chat_id text,telegram_webhook_secret_enc bytea. Future columns reserved for Moodle, Stripe. - New
app/services/user_secrets.py— encrypts on write, decrypts on read usingapp/services/secrets.pyFernet helpers. notify_telegramMCP tool reads per-user creds; falls back to env vars for operator legacy.- Telegram webhook (
/internal/telegram) routes incoming messages by chat_id → user_id lookup, setsapp.user_idGUC for the request, dispatches commands in the caller's context.
- New Telegram credentials card on the Settings page.
- 3 new backend endpoints:
GET /api/settings/secrets(returns masked status booleans + chat_id),PATCH /api/settings/secrets(per-field update + empty-string clears),POST /api/settings/telegram/test(sends a test message via the user's bot). - i18n strings (EN + DE) added under
settings.telegram.*.
tests/services/test_user_secrets.py(6 tests) covers round-trip + partial update + clear + chat_id lookup.tests/routers/test_secrets_routes.py(6 tests) covers the new endpoints incl. no-plaintext-leak.tests/routers/test_internal_telegram.py(3 tests) covers webhook routing by chat_id + operator-legacy + 403 for unknown chat.- New tests in
tests/mcp/test_files.pycover notify_telegram per-user creds + env fallback. - Suite total: 317 (was 300 at Phase 5).
- After upgrade, the operator's existing Telegram env vars continue to work via fallback.
- To migrate: operator opens Settings → Telegram, pastes bot token + chat ID + webhook secret, saves. Subsequent notify_telegram calls + webhook deliveries use the per-user creds.
OpenStudy is now multi-tenant-capable. Phase 7 (billing) and operational concerns (Moodle scraper multi-tenancy, etc.) follow when needed.
app/mcp_http.OAuthTokenVerifier.verify_tokennow stamps the bearer's user_id into a contextvar, and populatesAccessToken.expires_atfrom the token row (Bug E).app/mcp_tools.py(~46 tool call sites) now reads the per-request user via_get_mcp_user_id()instead of the global SENTINEL_USER_ID. Each MCP request now operates on its bearer's user data, not the operator's.
- Consent flow now binds
state+client_id+code_challengeto a signedoauth_consent_statecookie (HttpOnly, Secure, SameSite=Strict, 10-min TTL) issued at/oauth/authorizeand verified at/oauth/consent(Bug F). Prevents same-site CSRF on the consent step.
- Bulk-revoked all pre-Phase-5 oauth_tokens (force fresh consent so every active token has a real user_id).
- New
tests/mcp/test_bearer_user_binding.pyproves cross-user MCP isolation + expires_at population. - New
tests/test_oauth_consent_csrf.pyproves consent POSTs without/with-mismatch cookie are rejected. - New regression in
tests/services/test_oauth.py::test_bulk_revoke_invalidates_all_tokens. - Suite total: 300 (was 289 at Phase 4).
- Per-user Row Level Security policies on every owned table. USING + WITH CHECK reference
current_setting('app.user_id', true)::uuid. - Permissive policies on global tables (
oauth_clients,auth_attempts). userstable policy is self-only by id.
app/auth.pyadds a contextvar_current_user_id.optional_user/require_userstamp it on every authenticated request.app/db.pyissuesSELECT set_config('app.user_id', ?, true)on every connection acquire when the contextvar is set. Inert when unset (e.g., for unauthed health checks).- Middleware in
app/main.pyclears the contextvar at the start of each HTTP request (defense against contextvar leakage in shared async tasks).
- The app currently connects as a BYPASSRLS role; policies are inert.
- App-side
WHERE user_id = $1filters (Phase 2) remain the active enforcement. - Flipping the prod connection role to a non-BYPASSRLS role activates the policies. See
docs/RLS.md.
tests/test_phase4_guc.py— proves the contextvar reaches the GUC.tests/test_phase4_rls.py— usesSET LOCAL ROLE+ a non-BYPASSRLS test role to prove policies enforce.- Suite total: 289 (was 285 at Phase 3).
- New
app/services/email.pywith pluggable backends:console(test default) andgmail_smtp(Gmail app-password SMTP via stdlib smtplib). - Email templates in
app/templates/email/(Jinja2): verify_email + password_reset. - New
app/services/auth_signup.pywith signup, verify_email, request_password_reset, complete_password_reset. - New tables:
email_verifications+password_resets(one-shot tokens with expires_at + used_at). - New endpoints:
POST /auth/signup(gated bySIGNUPS_ENABLED),GET /auth/verify-email,POST /auth/forgot-password,POST /auth/reset-password. /auth/loginnow acceptsemail+password. Operator-legacy (no email, password againstAPP_PASSWORD_HASH) retained for upgrade safety.- Session cookie payload upgraded to JSON
{u: user_id, iat: timestamp}.optional_user/require_userlook upusersrow by id. Legacyb"authed"cookies fall back to the sentinel during rollout.
- Login form gains an email field.
- New routes:
/signup,/forgot-password,/reset-password,/verify-email. - 4 new mutations/queries in
web/src/lib/queries.ts.
- New
scripts/seed_operator_password.py— readsAPP_PASSWORD_HASHenv, setsusers.password_hashfor the operator if NULL. Idempotent. Invoked bydeploy.shafterdb push. - New env vars:
EMAIL_BACKEND,GMAIL_SMTP_USER,GMAIL_SMTP_APP_PASSWORD,EMAIL_FROM,EMAIL_FROM_NAME,SIGNUPS_ENABLED,PUBLIC_URL. All have safe defaults;EMAIL_BACKEND=consolekeeps tests silent.
- New
tests/test_integration_signup.py(4 tests): full signup → verify → login → forgot → reset flow + signup-disabled + bad-token + no-enumeration. - New
tests/services/test_email.py(3 tests) +tests/services/test_auth_signup.py(7 tests) +tests/routers/test_auth_signup.py(6 tests). - Suite total: 285 (was 262 at Phase 2).
- After upgrade: existing operators continue to log in with their old password via the legacy fallback. Once
users.password_hashis set (viaseed_operator_password.pyorforgot-passwordflow), the email-based login is the canonical path. - Operator email defaults to
operator@local(env-configurable viaOPERATOR_EMAIL). To change, either UPDATEusersdirectly or use the forgot-password flow.
- Every service function now takes
user_id: UUIDas its first parameter. - All SELECTs filter via
WHERE user_id = $1. INSERTs include user_id. UPDATEs/DELETEs constrain by user_id (defense in depth). - Intent layer forwards user_id to services (was accept-and-ignore in Phase 0).
- oauth service threads user_id from
/oauth/consentthrough token issuance. - Storage layer (
app/services/storage.py) takes user_id and resolves paths underSTUDY_ROOT/<user_id>/.... Path traversal is blocked at the user_root boundary. - file_index search filters by user_id; index_all stays operator-scoped and walks per-user directories.
- Dropped sentinel
DEFAULTon every owned table's user_id column. INSERTs must now supply user_id explicitly; absence raises a NOT NULL violation rather than silently using the operator.
- New
tests/test_phase2_isolation.py(4 tests) proves the service filters genuinely isolate data between two users. - Coverage tests in storage + file_index for cross-user traversal blocking and operator-scoped reindex.
- Suite total: 262 (was 255 at Phase 1).
- App still operates single-tenant at the entry points — routers + MCP tools pass the sentinel UUID. Phase 3 (signup endpoints) makes user identity real via the session cookie.
- New
userstable with operator seed row (sentinel UUID, "operator@local"). - Every owned table now has
user_id NOT NULL FK to users(id) ON DELETE CASCADEwith a sentinel DEFAULT — Phase 0 services continue to work unchanged. - Composite PKs and FKs lock per-user data integrity:
coursesPK is(user_id, code); downstream FKs use(user_id, course_code) → courses(user_id, code). Two users can have the same course code. app_settingsis 1:1 withusers(PKuser_id, singleton constraint dropped,idcolumn removed).- TOTP secret moved from
app_settingstousers(audit §6).app_settings.totp_*columns retained for rollback safety. file_index.pathprefixed with<user_id>/; idempotent.events.user_idpopulated by thelog_table_change()trigger.events.user_idFK to users dropped — audit logs survive cascade deletes (same precedent as Phase 0'sevents.course_codedrop).
- App still operates single-tenant via the Phase 0 sentinel. Phase 2 wires services to filter by user_id; until then every INSERT uses the DEFAULT.
- New env vars (optional, default to sentinel):
OPERATOR_USER_ID,OPERATOR_EMAIL,OPERATOR_DISPLAY_NAME. ./deploy.shinvokesscripts/migrate_study_root.shafterdb pushto move course folders into the operator subdirectory.
- New
tests/test_phase1_schema.py(4 tests) locks cascade-delete + composite-FK invariants. - Suite total: 255 (was 250 at Phase 0).
v0.6.0 — 2026-04-29
Internal hardening. No user-visible feature changes. The PostgREST
service is gone — FastAPI now talks to Postgres directly via a psycopg
async pool — and the project finally has a real backend test suite (213
tests against a real Postgres testcontainer with per-test transaction
rollback). Plus a handful of correctness + security fixes surfaced by
the post-migration audits.
- Dropped PostgREST. Backend services now use
psycopg[pool]async pool directly. Pool is opened/closed in the FastAPI lifespan; every service module migrated to the new helpers (db.fetch,db.fetchrow,db.fetchval,db.execute,db.db()). One fewer container, one fewer hop, simpler error model.POSTGREST_URL/POSTGREST_API_KEY/POSTGREST_AUTHenv vars removed; the pool readsDATABASE_URLdirectly. Stack is now three containers (postgres + openstudy + frontend), not four. - Auth code consumption is now atomic.
oauth_svc.consume_auth_codeuses a singleDELETE … RETURNING *rather than SELECT-then-UPDATE, closing the previous TOCTOU window where two parallel callers could both succeed with the same code. - Lecture-topics insertion is now transactional.
add_lecture_topicswraps the optional lecture create + every topic insert in one psycopg transaction — partial-failure rollback is now guaranteed. /api/healthno longer blocks the event loop. Storage stat call moved off the request thread.- Rate limiter prefers
CF-Connecting-IPoverX-Forwarded-For— Cloudflare's header is unspoofable from outside the tunnel; XFF is.
- pytest + testcontainers-postgres with a per-test transaction
rollback shim (
force_rollback=True+ a_TxnPoolconnection pin) so every test gets a clean DB without paying for container churn. - 213 backend tests: ~145 service-layer + 60 MCP-tool tests across
11 files (one per entity family) + 5 helper unit tests + 3 multi-step
end-to-end scenarios (
test_integration_full_flow.py— full OAuth lifecycle, login rate-limit, TOTP enroll+login). app/services/_helpers.py::validated_cols— filters dict keys to Pydantic-declared schema fields before f-stringing into INSERT/UPDATE SQL. Defence in depth against future schema bugs that might inject unexpected keys; applied at all 16 patch/insert sites across 7 service files.
POST /oauth/revoke(RFC 7009) — public clients call this on logout. Endpoint is now advertised in/.well-known/oauth-authorization-serverviarevocation_endpointandrevocation_endpoint_auth_methods_supported.
update_settingspatch flow no longer overwrites valid timezone / locale fields with NULL when the caller passesNonefor unset parameters. The MCPupdate_app_settingstool was passing every parameter (including unset ones) through toAppSettingsPatch;model_dump(exclude_none=True)now matches the convention used by every other patch service. Surfaced by the new MCP-tool tests.update_settingsfallback insert usesON CONFLICT (id) DO UPDATE, so two concurrent first-callers don't race the PK constraint into a 500./api/auth/totp/setupis now an upsert. On a fresh DB without anapp_settingsrow, the previous bare UPDATE matched zero rows but returned 200 with a generated secret —/totp/enablethen 400'd.consume_auth_coderejects non-S256 PKCE unconditionally. OAuth 2.1 mandates S256; the previous code acceptedplainif a code row somehow stored that method, which a direct POST to/oauth/consentcould trigger by skipping the/authorizeS256 check.record_eventordering tie-breaker. Two events inserted in the same transaction sharedcreated_at = now()(transaction-scoped); switched toclock_timestamp()and addedid DESCto the ORDER BY.storage._logsavepoint. Wrapped the activity-log insert in an inner transaction so swallowed FK violations don't poison the outer request transaction.- Pool-level UUID loader. psycopg returns UUID columns as
uuid.UUIDby default; Pydantic schemas expectstr. Registered a text + binary protocol loader on the pool so every fetch returns strings, no per-service conversions.
postgrest-pydependency. Gone frompyproject.toml/uv.lock.- PostgREST container from
docker-compose.yml. app.db.client()and the legacy postgrest helper. All call sites migrated.
If you're running a v0.5.0 deploy:
- Pull the new code, then run
./deploy.sh. The deploy script now runs with--remove-orphansso the PostgREST container is cleaned up automatically. DATABASE_URLmust be set (it already was for migrations; nothing new here).POSTGREST_URL/POSTGREST_API_KEY/POSTGREST_AUTHare no longer read — safe to remove from.env.- No schema changes. The migration runner has nothing new to apply.
v0.5.0 — 2026-04-26
Self-hosted by default. Big architectural shift: OpenStudy no longer
depends on Supabase or Vercel. The whole stack — Postgres, PostgREST,
FastAPI, and the React frontend — runs as four containers on any Docker
host, brought up with a single ./deploy.sh. Course files live on a
bind-mounted directory instead of object storage, indexed locally for
full-text search. On top of the architectural move, this release also
ships a public landing page, brand identity, TOTP 2FA, and a Telegram
bot integration.
docker-compose.yml— four-service stack on an internal bridge network:openstudy-postgres(Postgres 16-alpine),openstudy-postgrest(PostgREST 12.2.3, JWT auth disabled, only reachable from the network),openstudy(the FastAPI image built fromDockerfile), andopenstudy-frontend(the React SPA served by an in-container Caddy). Only the frontend (127.0.0.1:8080) and FastAPI (127.0.0.1:8000) are bound to the host; an outer reverse proxy (Caddy / nginx / Traefik) forwards a single127.0.0.1:8080upstream.Dockerfile—python:3.12-slimbase, uv-managed deps, multi-layer cache for fast rebuilds.web/Dockerfile— multi-stage build: Node 20 + pnpm builds the Vite SPA, then acaddy:alpineimage serves it. The Caddyfile inside the image does SPA fallback (try_files) plusreverse_proxy openstudy:8000for/api,/mcp,/oauthpaths../deploy.sh— single-command deploy with rollback. Pre-flight → build both images → apply migrations → health-gate (GET /api/healthpolled for 60s) → rollback to the previous image if health doesn't go green. Flags:--skip-build,--no-rollback,--status,--help.- Migrations runner (
scripts/run_migrations.py) — idempotent, transactional, sha256-tracked. State lives in a_migrationstable. Files undermigrations/apply in filename order. - Initial schema as
migrations/00000000000000_baseline.sql— canonical starting point for fresh deployments. Earlier development history preserved undermigrations/_archive/for reference. - Filesystem storage layer (
app/services/storage.py) — files live atSTUDY_ROOT(default/opt/courses); the storage service does read / write / list / move / delete directly on disk. Browser file serving via new/api/files/rawand/api/files/upload-targetendpoints (cookie-authenticated, same-origin). - Filesystem full-text index (
app/services/file_index.py,scripts/index_files.py, baked into the baseline migration): walksSTUDY_ROOT, extracts text from PDFs / notebooks / markdown / typst, upserts intofile_index. Search exposed asGET /api/files/search, backed by thesearch_filesPostgres RPC for ranking + snippet generation in one round-trip. /api/healthnow checks dependencies (DB SELECT + storage stat) instead of returning a static{ok: true}./api/internal/*router (app/routers/internal.py) — bearer-gated (X-Internal-Secret) endpoints for cron jobs to trigger reindex, plus a Telegram-bot webhook (authed via Telegram's ownX-Telegram-Bot-Api-Secret-Tokenheader) exposing/sync,/status,/helpto the operator's allowlisted chat.
- Brand assets —
web/public/brand/{mark,wordmark}/{on-light,on-dark}.svg, rendered via the new<Wordmark>React component (web/src/components/brand/wordmark.tsx) and embedded in the README header. - Landing page at
/(web/src/routes/landing.tsx+web/src/styles/landing.css): hero with auto-rotating five-theme carousel, animated MCP / Day-0 demo, real Claude Desktop screenshots, self-host terminal block, GitHub-stars CTA, floating navbar that hides on scroll-down. All CTAs link to the GitHub repo — no waitlist or signup. VITE_SHOW_LANDINGenv flag (defaultfalse) — whentrue,/renders the landing page; whenfalse,/redirects straight to the app (/appif signed in,/loginotherwise). Self-hosters typically leave it off.scripts/build-seo.mjs— Vite prebuild step that regeneratesrobots.txt,sitemap.xml, andmanifest.webmanifestfromVITE_SITE_URL/VITE_SITE_NAME. Forks deploying to a custom domain get correct canonical URLs and PWA metadata without code edits.- SEO + PWA assets —
web/public/og-card.png,apple-touch-icon.png,icon-192/256/512.png,security.txt,manifest.webmanifest. - TOTP / 2FA for the dashboard login
(
web/src/components/settings/totp-card.tsx, baked into the baseline migration). Setup-key + QR + recovery-code flow inside Settings. - Multi-language
<title>and<html lang>viaweb/src/lib/document-head.ts— switches between EN / DE based on the active i18n locale.
POSTGREST_URL/POSTGREST_API_KEYenv vars replaceSUPABASE_URL/SUPABASE_SERVICE_KEY. Breaking change for anyone upgrading from v0.3.x — see migration notes below.POSTGREST_AUTHflag — set tofalseto skip Bearer auth headers when targeting a self-hosted PostgREST that has JWT validation off.app/db.py— function renamedsupabase()→client(). All service files migrated tofrom app.db import client./api/internal/sync— runs reindexing in a FastAPI background task instead of spawning subprocesses. Themodequery parameter is still accepted (and echoed back) for caller compatibility, but no longer affects behaviour.- README, INSTALL.md, CONTRIBUTING.md,
.env.exampleall rewritten around the docker-compose deploy. README header shows the OpenStudy wordmark with auto light / dark variants instead of a plain heading; database badge updated from "Supabase Postgres" to "Postgres 16". PUBLIC_SITE_URLis the single source of truth for the domain baked into canonical / OG / sitemap / manifest tags. Previous defaultopenstudy.devremoved; default is nowhttp://localhost:8080so forks don't accidentally ship with someone else's domain.N8N_MOODLE_WEBHOOK_URLhas no default any more — endpoints that use it 503 with a helpful message when unset, instead of trying to hit a hardcoded host.
- Vercel artefacts —
vercel.json, theapi/index.pyshim, related.vercel/config. Vercel was retired as a host; the "build dist + rsync to a static web server" deploy path is gone too. - Supabase-specific layout — top-level
supabase/folder. Migrations live undermigrations/now. - Bucket-sync scripts —
force_push_to_bucket.py,sync.py,openstudy.py, the bidirectional CONFLICT-DEL-REMOTE state machine. With local filesystem storage there's nothing to mirror to a separate object store. Moved toscripts/_deprecated/for reference. TRADEMARK.md— the project ships under MIT only, with no separate trademark policy. Self-host rebranding guidance now lives in CONTRIBUTING.md (VITE_SITE_URL/VITE_SITE_NAME+ brand assets).
This is a breaking release. If you're moving an existing OpenStudy install over from Supabase + Vercel:
pg_dumpyour Supabase database and restore it into the new local Postgres before first running./deploy.shagainst real users — see INSTALL.md §4.- Rename
SUPABASE_URL→POSTGREST_URLandSUPABASE_SERVICE_KEY→POSTGREST_API_KEYin your.env. Add a new.env.dockernext to it withPOSTGRES_USER/POSTGRES_PASSWORD/POSTGRES_DBfor the database container. - Move your course files into the path you'll mount as
STUDY_ROOTin the compose file (default/opt/courses). - Make sure the
courses.folder_namecolumn is populated for every course — it's now the source of truth that/api/files/lecture-materialsand the file browser use to map a course code to its on-disk folder (replaces the previously hardcoded mapping). - Drop your Vercel deployment once the new docker host is healthy. Point your domain at the new outer reverse proxy.
v0.3.0 — 2026-04-21
Big visual + localization release. Five dashboard themes, full English/German i18n, per-course schedule CRUD, a proper file manager in the Files tab, and a pile of phone-UX fixes. All backwards compatible — just run the one new migration on upgrade.
- Five dashboard themes. Pick from Classic (the default — serif, airy), Terminal (mono, teal-on-black, hacker cockpit), Zine (pastel cream + hand-drawn stickers), Library (sepia, card-catalog aesthetic), or Swiss (12-col grid, red accent). Each one is a full reskin — its own sidebar, CSS, and dashboard route, not just a palette. Picker lives in Settings → Theme.
- Full in-app i18n — English and German. Every route, form, toast,
empty state, error message, and theme-specific prose now runs through
i18next. Language is picked explicitly in Profile → Language and persists in localStorage, decoupled from the date-format locale. - Per-course schedule CRUD. Add / edit / delete weekly slots from the course-detail Schedule tab without leaving the page.
- File manager. Rename files and folders, recursive folder delete,
create new folders, and a folder picker on the course form so each
course scopes its Files tab to a specific prefix in the bucket. New
backend endpoints
/files/move,/files/folder, and a recursive listing helper. - Claude Design prompt template under
docs/claude-design-prompt.mdplus four worked-example outputs underdocs/examples/— the starting points for the Terminal / Zine / Library / Swiss themes.
- Phone UX pass. 16 px form inputs (no more iOS zoom-on-focus), dvh for keyboard-aware layout, date-picker chrome contained inside its Field on iOS Safari, classic-theme weekly grid now renders the same multi-column time grid on phone (with horizontal scroll) instead of a stacked list — matches what the themed dashboards do.
- Course edit affordance moved from a hover overlay on the course card to an explicit Edit course button inside the course-detail header. Notes and exam editing split out into their own cards with their own edit buttons. "Scheduled" field on exams relabeled to "Exam date".
- Dashboard top strip on phone shows weekday / date / semester / week at a glance.
- Settings pickers (timezone, date format) auto-save on change; the semester-label text field gets an inline Save button while dirty. Success toasts are now neutral instead of green.
- README hero replaced with a 2×2 still collage of the four paper themes plus a looping GIF of Terminal. Mirrored in the German section.
git pull origin main
npx supabase db push # applies 20260421000001_theme.sql
cd web && pnpm install && pnpm buildThe migration adds app_settings.theme with default 'editorial', so
existing rows land on the Classic theme until you pick something else.
v0.2.0 — 2026-04-20
Rename pass: the project is now English-canonical from the database up through
the MCP tool names. Migrations moved to supabase/migrations/ so the Supabase
CLI tracks them properly. If you're upgrading an existing deploy, see the
upgrade notes below — pushing main won't fix your schema on its own.
- MCP tools renamed.
list_klausuren→list_exams,update_klausur→update_exam.upsert_schedule_slot→create_schedule_slot(signature is a pure create now; useupdate_schedule_slotto patch).now_berlinremoved — usenow_here. Any cached tool lists in Claude.ai / Claude Code will need to re-fetch after the push. - DB schema. Table
klausurenrenamed toexams. Columnscourses.klausur_weight/klausur_retriesrenamed toexam_weight/exam_retries. - Enum values. Slot / lecture kinds moved from
Vorlesung|Übung|Tutorium|Praktikumtolecture|exercise|tutorial|lab. Study-topic kinds fromvorlesung|uebung|readingtolecture|exercise|reading. Deliverable kinds fromabgabe|project|praktikum|blocktosubmission|project|lab|block. Legacy German values are still accepted at the API boundary via a PydanticBeforeValidatorand normalised on the way in — existing MCP integrations keep working. - Migration location.
db/migrations/→supabase/migrations/with timestamp-based filenames.
- Single-file README with a same-page
<details name="lang">language toggle — click 🇬🇧 English or 🇩🇪 Deutsch, the other collapses. - New migration
20260420000001_english_canonical_kinds.sqlthat normalises existing German values + renames the table/columns on upgrade. - FastMCP server-level
instructions— mental model of the domain, enum conventions, and orient-before-you-act guidance injected on everyinitialize.
- Every MCP tool description rewritten with "when to use / when NOT to use" disambiguation plus sibling pointers. Goal: Claude picks the right tool first try instead of listing + retrying. Tool count down from 46 → 44.
- UI: hardcoded German strings replaced with English (slot-kind selects,
deliverable-kind selects, sidebar
Klausuren→Exams, /klausuren → /exams, etc.). Displayed kind strings pick up acapitalizeclass for polish. INSTALL.md§4 rewritten aroundsupabase db pushwith an upgrade flow for existing DBs (supabase migration repair --status applied …) and a dashboard-SQL-editor fallback.
git pull origin main
npx supabase link --project-ref YOUR-PROJECT-REF
# If you applied 0001–0004 via the SQL editor, mark them applied first:
npx supabase migration repair --status applied 20260101000001 20260115000001 20260201000001 20260301000001
npx supabase db push # applies the English-canonical migrationThen rebuild the frontend (cd web && pnpm install && pnpm build) and redeploy.
v0.1.0 — 2026-04-20
First public release. A self-hostable personal study dashboard with an MCP connector so Claude (claude.ai, iOS, or Claude Code) can read and write your coursework.
- Web app: Dashboard, Courses (create / edit / delete with per-course accent color), Course detail, Tasks, Deliverables, Files, Klausuren, Activity, Settings (profile + semester).
- Streamable HTTP MCP server at
/mcp, OAuth 2.1-gated. ~45 tools — every UI action exposed plus convenience helpers likeget_fall_behind,mark_studied,read_course_file(renders PDF pages to PNGs for vision). - Dark visual design — Fraunces serif + Inter Tight + JetBrains Mono, OKLCH palette, ink-dot signature motif, 3 px course-accent stripes.
- Empty-by-default schema + a self-healing settings singleton so new deploys boot to an onboarding screen rather than a pre-populated dashboard.
- Docs: INSTALL.md, CONTRIBUTING.md,
CODE_OF_CONDUCT.md, plus templates for a Claude.ai
Project system prompt and a Claude Design redesign brief (under
docs/). - SQL migrations under
supabase/migrations/— the complete schema, applied viasupabase db push(or pasted into any Postgres SQL editor, in filename order). - Vercel deployment config (
vercel.json) — one project hosts both the static frontend and the Python API functions.
- Light mode is tokenised but untested.
- No automated test suite yet (manual QA only).
- Slot kinds are German-labeled by default (
Vorlesung,Übung,Tutorium,Praktikum) — not yet user-configurable. - Postgres driver is Supabase-specific; swapping it out is a fork, not a config flag.
PRs on any of the above are welcome — see CONTRIBUTING.md.