All notable changes to OpenStudy will be documented here. Format loosely follows Keep a Changelog, versions follow SemVer.
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.