Skip to content

Commit 4eab7ba

Browse files
AmmarSaleh50claude
andcommitted
docs: refresh README, INSTALL, CONTRIBUTING, .env.example for v0.7.0 multi-tenant
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 53551ba commit 4eab7ba

4 files changed

Lines changed: 137 additions & 74 deletions

File tree

.env.example

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
# ─── Backend (.env at repo root — read by the openstudy container) ─────────
22

3-
# Bootstrap-only: the scripts/seed_operator_password.py script reads this on
4-
# first deploy to set users.password_hash for the operator account in the DB.
5-
# After that, login uses users.password_hash from the DB — this env var is no
6-
# longer read at request time. Generate the hash with:
7-
# uv run app/tools/hashpw.py
3+
# Bootstrap-only: scripts/seed_operator_password.py reads this on each deploy
4+
# to set users.password_hash for the operator account. It only writes the hash
5+
# if the existing DB row has none — so redeploying never clobbers a password
6+
# you changed through the UI. Generate the hash with:
7+
# uv run python -m app.tools.hashpw
88
APP_PASSWORD_HASH=
99

1010
# Random secret for session signing. REQUIRED — app refuses to start without one.
@@ -40,29 +40,34 @@ INTERNAL_API_SECRET=
4040
# bot token, chat ID, and webhook secret via the Settings UI; secrets are
4141
# stored encrypted per-user in the `user_secrets` table.
4242

43-
# Symmetric encryption key for at-rest secrets (Telegram tokens, Moodle
44-
# creds, etc. — Phase 6 uses this). Mint once with:
45-
# python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())'
43+
# Symmetric encryption key for at-rest per-user secrets (Telegram tokens, etc.).
44+
# REQUIRED for production — the app will refuse Telegram operations without it.
45+
# Mint once with:
46+
# python3 -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())'
4647
SECRETS_ENCRYPTION_KEY=
4748

48-
# Phase 1+ — operator identity. Defaults match the seed row created by
49-
# the users-table migration. Self-hosters can override these — but if you
50-
# do, also UPDATE the users row in DB to match (or wait for Phase 3's
51-
# signup endpoints).
49+
# Operator identity. scripts/seed_operator_password.py reads these on every
50+
# deploy to reconcile the operator row in the users table. Set OPERATOR_EMAIL
51+
# to your login address before the first deploy — that is the username you
52+
# will use at /login. OPERATOR_USER_ID and OPERATOR_DISPLAY_NAME are optional.
5253
OPERATOR_USER_ID=00000000-0000-0000-0000-000000000001
53-
OPERATOR_EMAIL=operator@local
54+
OPERATOR_EMAIL=
5455
OPERATOR_DISPLAY_NAME=Operator
5556

56-
# Email (Phase 3+). Default backend is 'console' which prints to stdout (tests).
57-
# For prod: set EMAIL_BACKEND=gmail_smtp and provide app-password creds from
58-
# https://myaccount.google.com/apppasswords
57+
# Email backend. Default 'console' prints email bodies to stdout (fine for
58+
# local dev and testing). For real email delivery:
59+
# EMAIL_BACKEND=gmail_smtp
60+
# GMAIL_SMTP_USER=you@gmail.com
61+
# GMAIL_SMTP_APP_PASSWORD=<16-char app password from myaccount.google.com/apppasswords>
5962
EMAIL_BACKEND=console
6063
GMAIL_SMTP_USER=
6164
GMAIL_SMTP_APP_PASSWORD=
6265
EMAIL_FROM=hello@openstudy.dev
6366
EMAIL_FROM_NAME=OpenStudy
67+
68+
# Allow public registration. Default false — only the operator account (seeded
69+
# from OPERATOR_EMAIL above) can log in. Set to true to open signup.
6470
SIGNUPS_ENABLED=false
65-
PUBLIC_URL=http://localhost:5173
6671

6772
# ─── Frontend (web/.env.production for prod, web/.env.local for dev) ───────
6873

CONTRIBUTING.md

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,7 @@ phases is easier to reason about.
1818
multi-tenant migration). Created via `/auth/signup` (post-Phase 3) or
1919
by the operator via CLI. Many users per deployment.
2020

21-
Pre-multi-tenant (≤ v0.6) the operator and user are the same person.
22-
Post v0.7, they diverge: the operator administers the server; users sign
21+
As of v0.7, they diverge: the operator administers the server; users sign
2322
up and use the product. Auth code paths use `User` (a dataclass in
2423
`app/auth.py`) to carry the user identity through requests.
2524

@@ -33,7 +32,7 @@ Basically anything that makes the app better for someone self-hosting it. A non-
3332
- **i18n / localization** — the Slot `kind` labels are German (`Vorlesung`, `Übung`) by default. Making those user-configurable or translatable would help non-EU users a lot.
3433
- **New MCP tools** — if you find yourself wishing Claude could do `X` and there's a natural way to expose it, just add it. Pattern is in `app/mcp_tools.py`.
3534
- **Performance / bundle size** — the frontend chunk is bigger than it needs to be; someone who knows their way around Vite code-splitting could shave a lot.
36-
- **Tests**there's no suite yet. Adding pytest + Vitest scaffolding is its own welcome PR.
35+
- **Tests**the backend has 318 pytest tests; the frontend has none yet. A Vitest suite for the frontend is its own welcome PR.
3736

3837
## Things worth a quick issue first
3938

@@ -42,7 +41,7 @@ Not "no" — just "let's talk first so you don't waste a weekend":
4241
- Major framework swaps (React → Svelte, FastAPI → Django).
4342
- Replacing the `psycopg` async pool with a different DB driver (SQLAlchemy, asyncpg, etc.) — the pool is small but it's load-bearing; every service file goes through it.
4443
- New top-level entities beyond the current data model (Course / Schedule slot / Lecture / Study topic / Deliverable / Task / Klausur).
45-
- Multi-user / team / sharing features — the single-user-per-deploy assumption is load-bearing in a bunch of places, and shifting it is a big project.
44+
- Replacing the multi-tenant data model — every owned table has a `user_id` FK; any change to that assumption touches a lot of files and is a big conversation first.
4645

4746
A one-liner issue like *"would you take a PR that X?"* is all it takes.
4847

@@ -51,7 +50,7 @@ A one-liner issue like *"would you take a PR that X?"* is all it takes.
5150
See [INSTALL.md](./INSTALL.md) for the full walkthrough. TL;DR:
5251

5352
```bash
54-
cp .env.example .env # fill APP_PASSWORD_HASH, SESSION_SECRET
53+
cp .env.example .env # fill OPERATOR_EMAIL, APP_PASSWORD_HASH, SESSION_SECRET, SECRETS_ENCRYPTION_KEY
5554
cat > .env.docker <<EOF # Postgres credentials
5655
POSTGRES_USER=openstudy
5756
POSTGRES_PASSWORD=$(openssl rand -hex 24)
@@ -75,7 +74,7 @@ cd web && pnpm install && pnpm dev
7574

7675
## Testing
7776

78-
The backend has a pytest suite (213 tests as of v0.6.0) that runs against a real Postgres testcontainer with per-test transaction rollback — every test gets a clean DB state without paying for container churn. Service-layer, MCP-tool, and end-to-end OAuth/login flows are all covered.
77+
The backend has a pytest suite (318 tests as of v0.7.0) that runs against a real Postgres testcontainer with per-test transaction rollback — every test gets a clean DB state without paying for container churn. Service-layer, MCP-tool, and end-to-end OAuth/login flows are all covered.
7978

8079
```bash
8180
uv run --no-sync pytest -q # full suite

INSTALL.md

Lines changed: 74 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -55,27 +55,47 @@ guide. Anywhere else works; just adjust the paths below.
5555

5656
OpenStudy reads two env files, both kept out of git:
5757

58-
- **`.env`** — application secrets used by the FastAPI container
59-
(login password hash, session secret, optional integrations).
60-
- **`.env.docker`** — Postgres credentials only, used to spin up the
61-
database container.
58+
- **`.env`** — application secrets and operator identity, read by the
59+
FastAPI container at startup and at deploy time by
60+
`scripts/seed_operator_password.py`.
61+
- **`.env.docker`** — Postgres credentials only, used by Docker Compose
62+
to provision and reach the database container.
6263

63-
Create them:
64+
### `.env` — required vars
65+
66+
Copy the template, then fill in at minimum these five variables:
6467

6568
```bash
66-
# .env — copy the template, then fill in the placeholders
6769
cp .env.example .env
70+
```
71+
72+
| Variable | How to generate / what to put |
73+
|---|---|
74+
| `OPERATOR_EMAIL` | Your login email address. This is the username you'll use at `/login`. |
75+
| `APP_PASSWORD_HASH` | Run `uv run python -m app.tools.hashpw` and paste the output (`$argon2id$...`). |
76+
| `SESSION_SECRET` | `python3 -c 'import secrets; print(secrets.token_urlsafe(48))'` |
77+
| `SECRETS_ENCRYPTION_KEY` | `python3 -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())'` |
78+
| `PUBLIC_URL` | The public origin of your deploy, e.g. `https://your-domain.tld`. Used in OAuth callbacks and email links. Leave blank for local dev (derived from the inbound request). |
6879

69-
# Argon2id-hash a login password you'll use to sign in
70-
docker run --rm python:3.12-slim sh -c \
71-
'pip install -q argon2-cffi && python -c "from argon2 import PasswordHasher; print(PasswordHasher().hash(input(\"password: \")))"'
72-
# → paste the resulting hash as APP_PASSWORD_HASH in .env
80+
`OPERATOR_DISPLAY_NAME` defaults to `"Operator"` — set it to your name if you like.
7381

74-
# Generate a session-cookie signing secret
75-
python3 -c 'import secrets; print(secrets.token_urlsafe(48))'
76-
# → paste as SESSION_SECRET in .env
82+
`SIGNUPS_ENABLED` defaults to `false` (operator-only). Set to `true` to open public registration.
7783

78-
# Generate a strong Postgres password and write the docker-only env file
84+
`EMAIL_BACKEND` defaults to `console` (emails print to stdout). For real email:
85+
86+
```
87+
EMAIL_BACKEND=gmail_smtp
88+
GMAIL_SMTP_USER=you@gmail.com
89+
GMAIL_SMTP_APP_PASSWORD=<16-char app password from myaccount.google.com/apppasswords>
90+
EMAIL_FROM=you@gmail.com
91+
EMAIL_FROM_NAME=Your Name
92+
```
93+
94+
Telegram credentials are per-user — each user configures their own bot token and chat ID via **Settings → Telegram** after logging in. No env vars needed.
95+
96+
### `.env.docker` — Postgres credentials
97+
98+
```bash
7999
cat > .env.docker <<EOF
80100
POSTGRES_USER=openstudy
81101
POSTGRES_PASSWORD=$(openssl rand -hex 24)
@@ -84,10 +104,13 @@ EOF
84104
chmod 600 .env .env.docker
85105
```
86106

87-
The other variables in `.env` are optional and document themselves —
88-
the internal API secret used by webhooks, the public URL the app
89-
advertises in OAuth flows. Telegram credentials are configured per-user
90-
via **Settings → Telegram** after first login (no env vars needed).
107+
Optionally bake your domain into the frontend image at build time (affects canonical tags, OG metadata, sitemap, and manifest):
108+
109+
```
110+
PUBLIC_SITE_URL=https://your-domain.tld
111+
PUBLIC_SITE_NAME=Your Site Name
112+
PUBLIC_SHOW_LANDING=false
113+
```
91114

92115
---
93116

@@ -123,20 +146,27 @@ You should also see the running containers (`openstudy`, `openstudy-postgres`,
123146

124147
### First-time operator login
125148

126-
After running `./deploy.sh`:
149+
`./deploy.sh` automatically invokes `scripts/seed_operator_password.py`
150+
after migrations. That script reads your `.env` and reconciles the operator
151+
row in the `users` table:
152+
153+
- If `OPERATOR_EMAIL` is set, it ensures a user row with that email exists.
154+
- If `APP_PASSWORD_HASH` is set **and** the row has no password yet, it
155+
writes the hash — so re-running `./deploy.sh` never clobbers a password
156+
you changed through the UI.
157+
- `OPERATOR_DISPLAY_NAME` is applied if provided.
158+
159+
After the first `./deploy.sh`:
127160

128-
1. Set `APP_PASSWORD_HASH` in `.env.docker` to your argon2id-hashed password
129-
(use `uv run python -m app.tools.hashpw 'your-password'` to generate).
130-
2. Set `OPERATOR_EMAIL` to your email (or leave the default `operator@local`).
131-
3. Run `./deploy.sh` — it invokes `scripts/seed_operator_password.py` which
132-
sets `users.password_hash` for the operator user from your env var.
133-
4. Log in at `https://your-domain/login` with `OPERATOR_EMAIL` + your password.
134-
5. Configure your Telegram bot in **Settings → Telegram** (per-user; no env
135-
vars needed).
161+
1. Open `https://your-domain.tld/login`.
162+
2. Sign in with the email you set as `OPERATOR_EMAIL` and the password you
163+
hashed into `APP_PASSWORD_HASH`.
164+
3. Go to **Settings → Telegram** to configure notifications (per-user, no
165+
env vars required).
136166

137-
`APP_PASSWORD_HASH` is bootstrap-only — the seed script reads it once at
138-
deploy time. Subsequent logins authenticate against `users.password_hash`
139-
in the database.
167+
If you need to reset the operator password later, update `APP_PASSWORD_HASH`
168+
in `.env` and delete the existing `password_hash` in the database, then
169+
redeploy — the seed script will write the new hash.
140170

141171
### Restoring data into a fresh box
142172

@@ -323,13 +353,20 @@ and is readable by the container. `docker exec openstudy ls /opt/courses`
323353
should show your course tree.
324354

325355
**Login returns 401 with the right password.**
326-
Re-hash the password, update `APP_PASSWORD_HASH` in `.env.docker`, then
327-
run `./deploy.sh` so `scripts/seed_operator_password.py` writes the new
328-
hash into `users.password_hash`. Common gotcha: the hash starts with
329-
`$argon2id$…` — those `$` characters are literal, not env interpolation.
330-
The compose `env_file: format: raw` directive prevents mangling, but if
331-
you've manually exported the variable in a shell, the `$` chars need to
332-
be single-quoted.
356+
Re-hash the password with `uv run python -m app.tools.hashpw`, update
357+
`APP_PASSWORD_HASH` in `.env` (not `.env.docker`), then manually clear the
358+
existing hash in the database so the seed script writes the new one:
359+
360+
```bash
361+
docker exec -it openstudy-postgres psql -U openstudy -d openstudy \
362+
-c "UPDATE users SET password_hash = NULL WHERE email = 'your-email@example.com';"
363+
./deploy.sh
364+
```
365+
366+
Common gotcha: the hash starts with `$argon2id$…` — those `$` characters
367+
are literal, not env interpolation. The compose `env_file: format: raw`
368+
directive prevents mangling, but if you've manually exported the variable
369+
in a shell, the `$` chars need to be single-quoted.
333370

334371
**MCP returns 401 from a Claude client.**
335372
The OAuth token cached by the client expired or was revoked. Reconnect:

README.md

Lines changed: 36 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ A self-hostable personal study dashboard. Track your **courses, schedule, lectur
1414
![Database: Postgres 16](https://img.shields.io/badge/db-Postgres%2016-336791)
1515
![AI: MCP-native (44 tools)](https://img.shields.io/badge/AI-MCP--native%2044%20tools-7c3aed)
1616
![Self-hosted](https://img.shields.io/badge/hosting-self--hosted-111)
17-
![Status: alpha](https://img.shields.io/badge/status-alpha-orange)
17+
![Version: v0.7.0](https://img.shields.io/badge/version-v0.7.0-green)
1818

1919
### Five themes — pick the one that fits your brain
2020

@@ -114,7 +114,7 @@ The dashboard is where I see things. Claude is how I edit them. Same database be
114114

115115
The MCP server ships with **44 tools — anything you can do in the UI, Claude can do too**. Create a study topic, mark something studied, upload a file, render a PDF as images, whatever.
116116

117-
- **Multi-tenant SaaS-capable:** per-user data isolation enforced at both the service layer (WHERE filters) and the schema layer (composite FKs + RLS policies). Self-host as single-user or invite many users — same codebase.
117+
- **Multi-tenant:** every owned table carries a `user_id` FK. Data isolation is enforced at the service layer (WHERE filters) and the schema layer (composite FKs + RLS policies). Run it solo or flip `SIGNUPS_ENABLED=true` to open registration — same codebase, same deploy.
118118

119119
Plug it into Claude.ai as a custom connector (full OAuth 2.1) and those tools are live in **Claude Code on your laptop, claude.ai in your browser, and the Claude iOS app on your phone**. Open Claude anywhere and it has the same view of your coursework that you do.
120120

@@ -143,25 +143,36 @@ cd web && pnpm install && cd ..
143143

144144
**2. Generate secrets and write `.env` files.**
145145

146+
Open `.env` and fill in the required values:
147+
146148
```bash
147149
cp .env.example .env
148150

149-
# Pick a login password, hash it with Argon2id, and paste the result as APP_PASSWORD_HASH:
150-
docker run --rm python:3.12-slim sh -c 'pip install -q argon2-cffi && python -c "from argon2 import PasswordHasher; print(PasswordHasher().hash(input(\"password: \")))"'
151+
# Set your login email (this becomes your username):
152+
# OPERATOR_EMAIL=you@example.com
153+
154+
# Hash a password for first login and paste as APP_PASSWORD_HASH:
155+
uv run python -m app.tools.hashpw
156+
# → paste the resulting $argon2id$... string as APP_PASSWORD_HASH
157+
158+
# Generate a session-cookie signing secret:
159+
python3 -c 'import secrets; print(secrets.token_urlsafe(48))'
160+
# → paste as SESSION_SECRET
151161

152-
# Generate a 48-byte session secret and paste as SESSION_SECRET:
153-
python -c 'import secrets; print(secrets.token_urlsafe(48))'
162+
# Generate the encryption key for per-user secrets (Telegram tokens, etc.):
163+
python3 -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())'
164+
# → paste as SECRETS_ENCRYPTION_KEY
154165

155166
# Generate a Postgres password and write the Docker-only env file:
156167
cat > .env.docker <<EOF
157168
POSTGRES_USER=openstudy
158169
POSTGRES_PASSWORD=$(openssl rand -hex 24)
159170
POSTGRES_DB=openstudy
160171
EOF
161-
chmod 600 .env.docker
172+
chmod 600 .env .env.docker
162173
```
163174

164-
`.env` holds your application secrets (password hash, session secret, optional Telegram tokens). `.env.docker` holds the Postgres credentials that compose injects into the postgres container. Both are in `.gitignore`.
175+
`.env` holds your application secrets. `.env.docker` holds the Postgres credentials that compose injects into the postgres container. Both are in `.gitignore`.
165176

166177
**3. Bring up the stack.**
167178

@@ -417,25 +428,36 @@ cd web && pnpm install && cd ..
417428

418429
**2. Secrets generieren und `.env`-Dateien schreiben.**
419430

431+
`.env` öffnen und die Pflichtfelder ausfüllen:
432+
420433
```bash
421434
cp .env.example .env
422435

423-
# Login-Passwort wählen, mit Argon2id hashen und als APP_PASSWORD_HASH einfügen:
424-
docker run --rm python:3.12-slim sh -c 'pip install -q argon2-cffi && python -c "from argon2 import PasswordHasher; print(PasswordHasher().hash(input(\"password: \")))"'
436+
# Login-E-Mail setzen (wird der Benutzername):
437+
# OPERATOR_EMAIL=du@beispiel.de
438+
439+
# Passwort hashen und als APP_PASSWORD_HASH einfügen:
440+
uv run python -m app.tools.hashpw
441+
# → den $argon2id$...-String als APP_PASSWORD_HASH einfügen
442+
443+
# Session-Secret generieren:
444+
python3 -c 'import secrets; print(secrets.token_urlsafe(48))'
445+
# → als SESSION_SECRET einfügen
425446

426-
# 48-Byte-Session-Secret generieren und als SESSION_SECRET einfügen:
427-
python -c 'import secrets; print(secrets.token_urlsafe(48))'
447+
# Verschlüsselungskey für Nutzer-Secrets generieren:
448+
python3 -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())'
449+
# → als SECRETS_ENCRYPTION_KEY einfügen
428450

429451
# Postgres-Passwort generieren und die Docker-spezifische env-Datei schreiben:
430452
cat > .env.docker <<EOF
431453
POSTGRES_USER=openstudy
432454
POSTGRES_PASSWORD=$(openssl rand -hex 24)
433455
POSTGRES_DB=openstudy
434456
EOF
435-
chmod 600 .env.docker
457+
chmod 600 .env .env.docker
436458
```
437459

438-
`.env` enthält deine App-Secrets (Passwort-Hash, Session-Secret, optional Telegram-Tokens). `.env.docker` enthält die Postgres-Credentials, die Compose in den Postgres-Container injiziert. Beide sind in `.gitignore`.
460+
`.env` enthält deine App-Secrets. `.env.docker` enthält die Postgres-Credentials, die Compose in den Postgres-Container injiziert. Beide sind in `.gitignore`.
439461

440462
**3. Stack starten.**
441463

0 commit comments

Comments
 (0)