feat(tags): Add database schema and tags module (v1.3.0 Phase 1)
Implements tag/category system backend following microformats2 p-category specification. Database changes: - Migration 008: Add tags and note_tags tables - Normalized tag storage (case-insensitive lookup, display name preserved) - Indexes for performance New module: - starpunk/tags.py: Tag management functions - normalize_tag: Normalize tag strings - get_or_create_tag: Get or create tag records - add_tags_to_note: Associate tags with notes (replaces existing) - get_note_tags: Retrieve note tags (alphabetically ordered) - get_tag_by_name: Lookup tag by normalized name - get_notes_by_tag: Get all notes with specific tag - parse_tag_input: Parse comma-separated tag input Model updates: - Note.tags property (lazy-loaded, prefer pre-loading in routes) - Note.to_dict() add include_tags parameter CRUD updates: - create_note() accepts tags parameter - update_note() accepts tags parameter (None = no change, [] = remove all) Micropub integration: - Pass tags to create_note() (tags already extracted by extract_tags()) - Return tags in q=source response Per design doc: docs/design/v1.3.0/microformats-tags-design.md Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
313
docs/design/v1.1.1/auth-redirect-loop-diagram.md
Normal file
313
docs/design/v1.1.1/auth-redirect-loop-diagram.md
Normal file
@@ -0,0 +1,313 @@
|
||||
# Auth Redirect Loop - Visual Diagram
|
||||
|
||||
## Current Behavior (BROKEN)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ User clicks "Dev Login" │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ GET /dev/login │
|
||||
│ │
|
||||
│ 1. create_dev_session(me) → returns "abc123xyz" │
|
||||
│ 2. response.set_cookie("session", "abc123xyz") │
|
||||
│ 3. flash("DEV MODE: Logged in") ← This triggers Flask session! │
|
||||
│ Flask writes: session = {_flashes: ["message"]} │
|
||||
│ 4. return redirect("/admin/") │
|
||||
│ │
|
||||
│ ⚠️ Cookie "session" is now Flask session data, NOT auth token! │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ Browser → GET /admin/ │
|
||||
│ Cookie: session={_flashes: ["message"]} ← WRONG DATA! │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ @require_auth decorator │
|
||||
│ │
|
||||
│ 1. session_token = request.cookies.get("session") │
|
||||
│ → Gets: {_flashes: ["message"]} ← Not a token! │
|
||||
│ 2. verify_session("{_flashes: ...}") │
|
||||
│ → hash("{_flashes: ...}") doesn't match any DB session │
|
||||
│ → Returns None │
|
||||
│ 3. if not session_info: │
|
||||
│ session["next"] = request.url ← More Flask session! │
|
||||
│ return redirect("/admin/login") │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ Browser → GET /admin/login │
|
||||
│ User sees: Login page (NOT dashboard) │
|
||||
│ Result: REDIRECT LOOP ❌ │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Fixed Behavior (CORRECT)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ User clicks "Dev Login" │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ GET /dev/login │
|
||||
│ │
|
||||
│ 1. create_dev_session(me) → returns "abc123xyz" │
|
||||
│ 2. response.set_cookie("starpunk_session", "abc123xyz") │
|
||||
│ 3. flash("DEV MODE: Logged in") │
|
||||
│ Flask writes: session = {_flashes: ["message"]} │
|
||||
│ 4. return redirect("/admin/") │
|
||||
│ │
|
||||
│ ✅ Two separate cookies: │
|
||||
│ - starpunk_session = "abc123xyz" (auth token) │
|
||||
│ - session = {_flashes: ["message"]} (Flask session) │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ Browser → GET /admin/ │
|
||||
│ Cookie: starpunk_session=abc123xyz │
|
||||
│ Cookie: session={_flashes: ["message"]} │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ @require_auth decorator │
|
||||
│ │
|
||||
│ 1. session_token = request.cookies.get("starpunk_session") │
|
||||
│ → Gets: "abc123xyz" ✅ Correct auth token! │
|
||||
│ 2. verify_session("abc123xyz") │
|
||||
│ → hash("abc123xyz") matches DB session │
|
||||
│ → Returns: {me: "https://dev.example.com", ...} │
|
||||
│ 3. if session_info: ✅ Valid session! │
|
||||
│ g.user = session_info │
|
||||
│ g.me = session_info["me"] │
|
||||
│ return dashboard() ← Continues to dashboard! │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ Browser → Renders /admin/ dashboard │
|
||||
│ User sees: Dashboard with notes ✅ │
|
||||
│ Flash message: "DEV MODE: Logged in" ✅ │
|
||||
│ Result: SUCCESS! No redirect loop! ✅ │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Cookie Collision Visualization
|
||||
|
||||
### BEFORE (Broken)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ BROWSER │
|
||||
│ │
|
||||
│ Cookies for localhost:5000: │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────┐ │
|
||||
│ │ Name: session │ │
|
||||
│ │ Value: {_flashes: ["message"]} │ │
|
||||
│ │ │ │
|
||||
│ │ ❌ CONFLICT: This should be auth token!│ │
|
||||
│ │ Flask overwrote our auth token! │ │
|
||||
│ └─────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### AFTER (Fixed)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ BROWSER │
|
||||
│ │
|
||||
│ Cookies for localhost:5000: │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────┐ │
|
||||
│ │ Name: starpunk_session │ │
|
||||
│ │ Value: abc123xyz... │ │
|
||||
│ │ Purpose: Auth token ✅ │ │
|
||||
│ └─────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────┐ │
|
||||
│ │ Name: session │ │
|
||||
│ │ Value: {_flashes: ["message"]} │ │
|
||||
│ │ Purpose: Flask session ✅ │ │
|
||||
│ └─────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ✅ Two separate cookies, no conflict! │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Timeline of Events
|
||||
|
||||
### Broken Flow
|
||||
|
||||
```
|
||||
Time Request Cookie State Auth State
|
||||
────────────────────────────────────────────────────────────────────────
|
||||
T0 GET /dev/login (none) Not authed
|
||||
T1 ↓ set_cookie session = "abc123xyz" Token set ✅
|
||||
T2 ↓ flash() session = {_flashes: [...]} OVERWRITTEN ❌
|
||||
T3 302 → /admin/ session = {_flashes: [...]} Token LOST ❌
|
||||
T4 GET /admin/ session = {_flashes: [...]} Not authed ❌
|
||||
T5 ↓ @require_auth verify("{_flashes...}") = None FAIL ❌
|
||||
T6 302 → /admin/login session = {_flashes: [...]} Not authed ❌
|
||||
T7 GET /admin/login session = {_flashes: [...]} Not authed ❌
|
||||
→ User sees login page (LOOP!) ❌
|
||||
```
|
||||
|
||||
### Fixed Flow
|
||||
|
||||
```
|
||||
Time Request Cookie State Auth State
|
||||
─────────────────────────────────────────────────────────────────────────────
|
||||
T0 GET /dev/login (none) Not authed
|
||||
T1 ↓ set_cookie starpunk_session = "abc123xyz" Token set ✅
|
||||
T2 ↓ flash() session = {_flashes: [...]} Flask data ✅
|
||||
starpunk_session = "abc123xyz" Token preserved ✅
|
||||
T3 302 → /admin/ starpunk_session = "abc123xyz" Authed ✅
|
||||
session = {_flashes: [...]}
|
||||
T4 GET /admin/ starpunk_session = "abc123xyz" Authed ✅
|
||||
T5 ↓ @require_auth verify("abc123xyz") = {me: ...} SUCCESS ✅
|
||||
T6 Render dashboard starpunk_session = "abc123xyz" Authed ✅
|
||||
→ User sees dashboard ✅
|
||||
```
|
||||
|
||||
## Request/Response Detail
|
||||
|
||||
### Broken Request/Response Cycle
|
||||
|
||||
```
|
||||
REQUEST 1: GET /dev/login
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
RESPONSE 1:
|
||||
HTTP/1.1 302 Found
|
||||
Location: /admin/
|
||||
Set-Cookie: session={_flashes: [...]}; HttpOnly; SameSite=Lax
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
❌ Flask overwrote our auth token!
|
||||
|
||||
───────────────────────────────────────────────────────────────────
|
||||
|
||||
REQUEST 2: GET /admin/
|
||||
Cookie: session={_flashes: [...]}
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
❌ Sending Flask session data, not auth token!
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
RESPONSE 2:
|
||||
HTTP/1.1 302 Found
|
||||
Location: /admin/login
|
||||
❌ @require_auth rejected (no valid token)
|
||||
```
|
||||
|
||||
### Fixed Request/Response Cycle
|
||||
|
||||
```
|
||||
REQUEST 1: GET /dev/login
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
RESPONSE 1:
|
||||
HTTP/1.1 302 Found
|
||||
Location: /admin/
|
||||
Set-Cookie: starpunk_session=abc123xyz; HttpOnly; SameSite=Lax
|
||||
✅ Auth token in separate cookie
|
||||
Set-Cookie: session={_flashes: [...]}; HttpOnly; SameSite=Lax
|
||||
✅ Flask session in separate cookie
|
||||
|
||||
───────────────────────────────────────────────────────────────────
|
||||
|
||||
REQUEST 2: GET /admin/
|
||||
Cookie: starpunk_session=abc123xyz
|
||||
✅ Sending correct auth token!
|
||||
Cookie: session={_flashes: [...]}
|
||||
✅ Flask session data also sent (for flash messages)
|
||||
═══════════════════════════════════════════════════════════════════
|
||||
RESPONSE 2:
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: text/html
|
||||
|
||||
<html>
|
||||
<!-- Dashboard renders successfully! ✅ -->
|
||||
</html>
|
||||
```
|
||||
|
||||
## Code Comparison
|
||||
|
||||
### Setting the Cookie
|
||||
|
||||
```python
|
||||
# BEFORE (Broken)
|
||||
response.set_cookie(
|
||||
"session", # ❌ Conflicts with Flask
|
||||
session_token,
|
||||
httponly=True,
|
||||
secure=False,
|
||||
samesite="Lax",
|
||||
)
|
||||
|
||||
# AFTER (Fixed)
|
||||
response.set_cookie(
|
||||
"starpunk_session", # ✅ No conflict!
|
||||
session_token,
|
||||
httponly=True,
|
||||
secure=False,
|
||||
samesite="Lax",
|
||||
)
|
||||
```
|
||||
|
||||
### Reading the Cookie
|
||||
|
||||
```python
|
||||
# BEFORE (Broken)
|
||||
session_token = request.cookies.get("session")
|
||||
# Gets Flask session data, not our token! ❌
|
||||
|
||||
# AFTER (Fixed)
|
||||
session_token = request.cookies.get("starpunk_session")
|
||||
# Gets our auth token correctly! ✅
|
||||
```
|
||||
|
||||
## Why Flash Triggers the Problem
|
||||
|
||||
Flask's `flash()` function writes to the session:
|
||||
|
||||
```python
|
||||
# When you call:
|
||||
flash("DEV MODE: Logged in", "warning")
|
||||
|
||||
# Flask internally does:
|
||||
session['_flashes'] = [("warning", "DEV MODE: Logged in")]
|
||||
|
||||
# Which triggers:
|
||||
response.set_cookie("session", serialize(session), ...)
|
||||
|
||||
# This OVERWRITES any cookie named "session"!
|
||||
```
|
||||
|
||||
## The Key Insight
|
||||
|
||||
**Flask owns the `session` cookie name. We cannot use it.**
|
||||
|
||||
Flask reserves this cookie for its own session management (flash messages, session["key"] storage, etc.). When we try to use the same cookie name for our auth token, Flask overwrites it whenever session data is modified.
|
||||
|
||||
**Solution**: Use our own namespace → `starpunk_session`
|
||||
|
||||
## Architectural Principle Established
|
||||
|
||||
**Cookie Naming Convention**: All application cookies MUST use an application-specific prefix to avoid conflicts with framework-reserved names.
|
||||
|
||||
- Framework cookies: `session`, `csrf_token`, etc. (owned by Flask)
|
||||
- Application cookies: `starpunk_session`, `starpunk_*` (owned by StarPunk)
|
||||
|
||||
This separation ensures:
|
||||
1. No name collisions
|
||||
2. Clear ownership
|
||||
3. Easier debugging (you know which cookie is which)
|
||||
4. Standards compliance
|
||||
Reference in New Issue
Block a user