Files
StarPunk/docs/design/v1.1.1/auth-redirect-loop-diagram.md
Phil Skentelbery f10d0679da 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>
2025-12-10 11:24:23 -07:00

18 KiB

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! ✅                            │
└──────────────────────────────────────────────────────────────────┘

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

# 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",
)
# 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:

# 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