Skip to content

Commit edf2be6

Browse files
committed
Phase 2 complete
1 parent 1601dd6 commit edf2be6

24 files changed

Lines changed: 1217 additions & 52 deletions

README.md

Lines changed: 127 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,127 @@
1-
# development
1+
# Personal Vault
2+
3+
A multi-user private storage application for notes and passwords.
4+
Built progressively across 13 phases as a full-stack learning project.
5+
6+
---
7+
8+
## Project structure
9+
10+
```
11+
development/
12+
├── vault/ ← application code
13+
├── docs/ ← planning docs, KT docs, API reference
14+
├── claude-instructions/ ← phase-by-phase implementation playbooks
15+
└── README.md ← this file
16+
```
17+
18+
---
19+
20+
## Prerequisites
21+
22+
- Python 3.11+
23+
- Docker (for PostgreSQL)
24+
25+
---
26+
27+
## First-time setup
28+
29+
```bash
30+
# 1. Clone and enter the repo
31+
cd development/vault
32+
33+
# 2. Create and activate the virtual environment
34+
python3 -m venv .venv
35+
source .venv/bin/activate
36+
37+
# 3. Install dependencies
38+
pip install -r requirements.txt
39+
40+
# 4. Copy the environment file and fill in your values
41+
cp .env.example .env
42+
# edit .env with your credentials
43+
44+
# 5. Start the PostgreSQL container
45+
docker run --name vault-db \
46+
-e POSTGRES_USER=vault_user \
47+
-e POSTGRES_PASSWORD=vault_pass \
48+
-e POSTGRES_DB=vault \
49+
-p 5432:5432 \
50+
-d postgres:16
51+
```
52+
53+
---
54+
55+
## Daily workflow
56+
57+
Every time you start working on the project:
58+
59+
```bash
60+
# 1. Start the database (if not already running)
61+
docker start vault-db
62+
63+
# 2. Activate the virtual environment
64+
cd vault
65+
source .venv/bin/activate
66+
67+
# 3. Start the server
68+
uvicorn app.main:app --reload
69+
```
70+
71+
---
72+
73+
## Stopping everything
74+
75+
```bash
76+
# Stop the server
77+
Ctrl+C
78+
79+
# Stop the database container (data is preserved)
80+
docker stop vault-db
81+
```
82+
83+
---
84+
85+
## Useful URLs (while server is running)
86+
87+
| URL | What it is |
88+
|---|---|
89+
| `http://localhost:8000/health` | Health check — confirms server + DB are up |
90+
| `http://localhost:8000/docs` | Swagger UI — interactive API explorer |
91+
| `http://localhost:8000/redoc` | ReDoc — alternative API documentation |
92+
93+
---
94+
95+
## Useful commands
96+
97+
```bash
98+
# Check if the database container is running
99+
docker ps --filter name=vault-db
100+
101+
# View database container logs
102+
docker logs vault-db
103+
104+
# Connect directly to PostgreSQL (inspect data)
105+
docker exec -it vault-db psql -U vault_user -d vault
106+
107+
# Inside psql — list tables
108+
\dt
109+
110+
# Inside psql — exit
111+
\q
112+
113+
# Check installed Python packages
114+
pip list
115+
116+
# Install new dependencies after pulling changes
117+
pip install -r requirements.txt
118+
```
119+
120+
---
121+
122+
## Current phase
123+
124+
**Phase 1 complete** — project setup, first API endpoint, PostgreSQL connection.
125+
126+
See `docs/` for planning documents and KT notes.
127+
See `docs/api/` for API reference.

docs/004-user-model-plan.md

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# 004 — User Model
2+
3+
---
4+
5+
## Part 1: What we are doing
6+
7+
**Goal:** Create the `User` database table and the API endpoints to create and fetch users. No authentication yet — passwords stored as plaintext with a clear TODO marker.
8+
9+
### Files being created / modified
10+
```
11+
vault/app/models/user.py ← new: SQLAlchemy User model (the table)
12+
vault/app/schemas/user.py ← new: Pydantic schemas (request/response shapes)
13+
vault/app/routers/users.py ← new: POST /users, GET /users/{id}
14+
vault/app/models/__init__.py ← modified: import User so create_all finds it
15+
vault/app/main.py ← modified: register users router
16+
docs/api/users.md ← updated: full endpoint documentation
17+
```
18+
19+
### The table being created
20+
```
21+
users
22+
├── id INTEGER, primary key, auto-increment
23+
├── username VARCHAR(50), unique, not null
24+
├── hashed_password TEXT, not null
25+
└── created_at TIMESTAMP, default = now
26+
```
27+
28+
### Steps
29+
1. Create `app/models/user.py`
30+
2. Import User in `app/models/__init__.py`
31+
3. Create `app/schemas/user.py`
32+
4. Create `app/routers/users.py`
33+
5. Register router in `app/main.py`
34+
6. Start server → verify table is created in PostgreSQL
35+
7. Test via Swagger, curl, and Postman
36+
37+
### What is NOT done in this step
38+
- No password hashing (plaintext for now, TODO comment added — fixed in Phase 4)
39+
- No login
40+
- No authentication
41+
- No relationship to notes or passwords yet
42+
43+
---
44+
45+
## Part 2: Concepts / KT
46+
47+
### SQLAlchemy model
48+
A Python class that maps directly to a database table. You define columns as class attributes using SQLAlchemy's `Column` type. When `create_all()` runs at startup, SQLAlchemy reads these class definitions and generates the `CREATE TABLE` SQL automatically.
49+
50+
```python
51+
class User(Base):
52+
__tablename__ = "users"
53+
id = Column(Integer, primary_key=True)
54+
username = Column(String(50), unique=True, nullable=False)
55+
```
56+
57+
SQLAlchemy generates:
58+
```sql
59+
CREATE TABLE users (
60+
id SERIAL PRIMARY KEY,
61+
username VARCHAR(50) UNIQUE NOT NULL
62+
);
63+
```
64+
65+
You write Python. SQLAlchemy writes SQL.
66+
67+
### Pydantic schema vs SQLAlchemy model
68+
Two different things that look similar but serve different purposes:
69+
70+
| | SQLAlchemy Model | Pydantic Schema |
71+
|---|---|---|
72+
| Purpose | Defines the database table | Defines the API input/output shape |
73+
| Used by | SQLAlchemy (DB operations) | FastAPI (request validation + response serialisation) |
74+
| Location | `app/models/` | `app/schemas/` |
75+
76+
Why keep them separate? The `User` model has a `hashed_password` column. You never want to return that in an API response. So `UserResponse` schema simply doesn't include it. Two shapes for two different contexts.
77+
78+
### `model_config = ConfigDict(from_attributes=True)`
79+
Pydantic v2 needs this on response schemas to read data from SQLAlchemy model objects. Without it, Pydantic wouldn't know how to convert a SQLAlchemy `User` object into a JSON-serialisable dict.
80+
81+
### `nullable=False` vs `unique=True`
82+
- `nullable=False` → the column must always have a value. Inserting a row without this column fails.
83+
- `unique=True` → no two rows can have the same value in this column. Trying to create two users with the same username fails with an `IntegrityError`.
84+
85+
### `server_default=func.now()`
86+
Instead of setting `created_at` in Python, we tell the database to set it automatically at insert time using the database's own clock. More reliable than Python time (no timezone confusion between app server and DB server).
87+
88+
### Why plaintext passwords now
89+
Hashing is a Phase 4 concern — it belongs with JWT and authentication. Introducing it now would mix two concepts at once. A `TODO` comment marks it clearly. This is how real teams work — ship the minimum, mark the gap, fix it in the right phase.

docs/005-crud-notes-plan.md

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# 005 — Notes CRUD
2+
3+
---
4+
5+
## Part 1: What we are doing
6+
7+
**Goal:** Build full CRUD for text notes. A note belongs to a user via a foreign key. No authentication yet — user_id is passed in the request body for now.
8+
9+
### Files being created / modified
10+
```
11+
vault/app/models/note.py ← new: Note SQLAlchemy model
12+
vault/app/schemas/note.py ← new: NoteCreate, NoteUpdate, NoteResponse
13+
vault/app/routers/notes.py ← new: 5 endpoints
14+
vault/app/models/__init__.py ← modified: import Note
15+
vault/app/main.py ← modified: register notes router
16+
docs/api/notes.md ← updated: full endpoint documentation
17+
```
18+
19+
### The table being created
20+
```
21+
notes
22+
├── id INTEGER, primary key, auto-increment
23+
├── user_id INTEGER, foreign key → users.id
24+
├── title VARCHAR(200), not null
25+
├── body TEXT, nullable
26+
├── created_at TIMESTAMP, default = now
27+
└── updated_at TIMESTAMP, default = now, updates on every change
28+
```
29+
30+
### Endpoints
31+
| Method | Path | Action |
32+
|---|---|---|
33+
| `POST` | `/notes` | Create a note |
34+
| `GET` | `/notes?user_id=1` | List notes for a user |
35+
| `GET` | `/notes/{id}` | Get a single note |
36+
| `PUT` | `/notes/{id}` | Update a note |
37+
| `DELETE` | `/notes/{id}` | Delete a note |
38+
39+
### What is NOT done in this step
40+
- No auth guard — any user_id can be passed freely (fixed in Phase 4)
41+
- No tags or search
42+
- No frontend
43+
44+
---
45+
46+
## Part 2: Concepts / KT
47+
48+
### Foreign key
49+
A column that points to the primary key of another table. `notes.user_id` points to `users.id`. This is how relational databases connect data.
50+
51+
```
52+
users table notes table
53+
id | username id | user_id | title
54+
1 | bob ◄─── 1 | 1 | "My first note"
55+
2 | alice 2 | 1 | "Second note"
56+
3 | 2 | "Alice's note"
57+
```
58+
59+
If you try to create a note with `user_id=99` and no user with `id=99` exists, the database rejects it with a `ForeignKeyViolation` error. The database itself enforces the relationship — your code doesn't have to check manually.
60+
61+
### `onupdate=func.now()`
62+
`updated_at` should automatically change every time the row is modified. SQLAlchemy's `onupdate` parameter does exactly this — whenever you `UPDATE` that row, SQLAlchemy sets the column to the current timestamp automatically.
63+
64+
### Query parameters vs path parameters
65+
Two different ways to pass values in a URL:
66+
67+
```
68+
GET /notes/5 ← path parameter: identifies WHICH note
69+
GET /notes?user_id=1 ← query parameter: FILTERS the list
70+
```
71+
72+
Path parameter = the identity of a specific resource. Always required.
73+
Query parameter = optional filter or option. Appears after `?`.
74+
75+
### `NoteUpdate` with Optional fields
76+
An update request shouldn't require all fields — you might only want to change the title without touching the body. Pydantic's `Optional` handles this:
77+
78+
```python
79+
class NoteUpdate(BaseModel):
80+
title: Optional[str] = None
81+
body: Optional[str] = None
82+
```
83+
84+
Only fields sent in the request get updated. Fields not sent stay as they are.
85+
86+
### `response_model` on routes
87+
Every route that returns data should declare `response_model=NoteResponse`. This tells FastAPI exactly what shape to return and strips out anything not in the schema. This is also where the deliberate mistake in this step comes from — see what happens when `model_config = ConfigDict(from_attributes=True)` is missing from the schema.

docs/006-crud-passwords-plan.md

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# 006 — Passwords CRUD
2+
3+
---
4+
5+
## Part 1: What we are doing
6+
7+
**Goal:** Build CRUD for stored password entries. Passwords are encrypted before being saved to the database and decrypted on retrieval. The encryption key lives in `.env`.
8+
9+
### Files being created / modified
10+
```
11+
vault/app/utils/__init__.py ← new: makes utils a package
12+
vault/app/utils/crypto.py ← new: encrypt / decrypt helpers
13+
vault/app/models/password_entry.py ← new: PasswordEntry SQLAlchemy model
14+
vault/app/schemas/password_entry.py ← new: request/response schemas
15+
vault/app/routers/passwords.py ← new: 5 endpoints
16+
vault/app/models/__init__.py ← modified: import PasswordEntry
17+
vault/app/main.py ← modified: register passwords router
18+
vault/requirements.txt ← modified: add cryptography
19+
vault/.env.example ← modified: add VAULT_ENCRYPTION_KEY
20+
docs/api/passwords.md ← updated: full endpoint documentation
21+
```
22+
23+
### The table being created
24+
```
25+
password_entries
26+
├── id INTEGER, primary key, auto-increment
27+
├── user_id INTEGER, foreign key → users.id
28+
├── label VARCHAR(100), not null (e.g. "Gmail", "GitHub")
29+
├── username VARCHAR(100), not null (the username for that service)
30+
├── encrypted_value TEXT, not null (the password, encrypted)
31+
├── created_at TIMESTAMP, default = now
32+
└── updated_at TIMESTAMP, default = now, updates on change
33+
```
34+
35+
### Endpoints
36+
| Method | Path | Description |
37+
|---|---|---|
38+
| `POST` | `/passwords` | Store a new password (encrypted) |
39+
| `GET` | `/passwords?user_id=1` | List entries — value NOT included |
40+
| `GET` | `/passwords/{id}` | Get single entry WITH decrypted value |
41+
| `PUT` | `/passwords/{id}` | Update (re-encrypts if value changes) |
42+
| `DELETE` | `/passwords/{id}` | Delete |
43+
44+
### What is NOT done in this step
45+
- No auth guard (Phase 4)
46+
- No key rotation
47+
- No tags
48+
49+
---
50+
51+
## Part 2: Concepts / KT
52+
53+
### Hashing vs Encryption — the critical distinction
54+
55+
This is the most important concept in this step.
56+
57+
| | Hashing | Encryption |
58+
|---|---|---|
59+
| Direction | One-way — cannot be reversed | Two-way — can be decrypted |
60+
| Algorithm | bcrypt, SHA256 | AES, Fernet |
61+
| Use case | Login passwords | Secrets you need to read back |
62+
| Example | Your Vault login password | Your Gmail password stored in Vault |
63+
64+
Your Vault login password is **hashed** — the server never needs to know the original, it just checks if what you typed matches the hash.
65+
66+
Your Gmail password stored inside the Vault is **encrypted** — you need to read it back in full when you open your vault. Hashing it would make it permanently unreadable.
67+
68+
Same word "password", completely different treatment.
69+
70+
### Fernet symmetric encryption
71+
Fernet is a symmetric encryption scheme from the `cryptography` library. One key encrypts and decrypts. The key must be:
72+
- Exactly 32 bytes
73+
- URL-safe base64 encoded
74+
- Generated using `Fernet.generate_key()` — not made up by hand
75+
76+
Generate one for your `.env`:
77+
```bash
78+
python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
79+
```
80+
81+
### Why the key must live in `.env`
82+
The deliberate mistake in this step shows exactly what happens when it doesn't — a new random key is generated every server restart. Any previously encrypted values become permanently unreadable. In production, losing the key = losing all stored passwords.
83+
84+
### Two response schemas for passwords
85+
- `PasswordListResponse` — used in `GET /passwords?user_id=1`. No `value` field. You don't return the password on every list call — only when specifically requested.
86+
- `PasswordDetailResponse` — used in `GET /passwords/{id}`. Includes the decrypted `value`.
87+
88+
This is a real-world API design principle: return the minimum data needed for each context.

0 commit comments

Comments
 (0)