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:
2025-12-10 11:24:23 -07:00
parent 927db4aea0
commit f10d0679da
188 changed files with 601 additions and 945 deletions

View 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