feat: Implement Phase 4 Web Interface with bugfixes (v0.5.2)

## Phase 4: Web Interface Implementation

Implemented complete web interface with public and admin routes,
templates, CSS, and development authentication.

### Core Features

**Public Routes**:
- Homepage with recent published notes
- Note permalinks with microformats2
- Server-side rendering (Jinja2)

**Admin Routes**:
- Login via IndieLogin
- Dashboard with note management
- Create, edit, delete notes
- Protected with @require_auth decorator

**Development Authentication**:
- Dev login bypass for local testing (DEV_MODE only)
- Security safeguards per ADR-011
- Returns 404 when disabled

**Templates & Frontend**:
- Base layouts (public + admin)
- 8 HTML templates with microformats2
- Custom responsive CSS (114 lines)
- Error pages (404, 500)

### Bugfixes (v0.5.1 → v0.5.2)

1. **Cookie collision fix (v0.5.1)**:
   - Renamed auth cookie from "session" to "starpunk_session"
   - Fixed redirect loop between dev login and admin dashboard
   - Flask's session cookie no longer conflicts with auth

2. **HTTP 404 error handling (v0.5.1)**:
   - Update route now returns 404 for nonexistent notes
   - Delete route now returns 404 for nonexistent notes
   - Follows ADR-012 HTTP Error Handling Policy
   - Pattern consistency across all admin routes

3. **Note model enhancement (v0.5.2)**:
   - Exposed deleted_at field from database schema
   - Enables soft deletion verification in tests
   - Follows ADR-013 transparency principle

### Architecture

**New ADRs**:
- ADR-011: Development Authentication Mechanism
- ADR-012: HTTP Error Handling Policy
- ADR-013: Expose deleted_at Field in Note Model

**Standards Compliance**:
- Uses uv for Python environment
- Black formatted, Flake8 clean
- Follows git branching strategy
- Version incremented per versioning strategy

### Test Results

- 405/406 tests passing (99.75%)
- 87% code coverage
- All security tests passing
- Manual testing confirmed working

### Documentation

- Complete implementation reports in docs/reports/
- Architecture reviews in docs/reviews/
- Design documents in docs/design/
- CHANGELOG updated for v0.5.2

### Files Changed

**New Modules**:
- starpunk/dev_auth.py
- starpunk/routes/ (public, admin, auth, dev_auth)

**Templates**: 10 files (base, pages, admin, errors)
**Static**: CSS and optional JavaScript
**Tests**: 4 test files for routes and templates
**Docs**: 20+ architectural and implementation documents

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-18 23:01:53 -07:00
parent 575a02186b
commit 0cca8169ce
56 changed files with 13151 additions and 304 deletions

View File

@@ -0,0 +1,307 @@
# Authentication Redirect Loop Diagnosis - Phase 4
**Date**: 2025-11-18
**Status**: ROOT CAUSE IDENTIFIED
**Severity**: Critical - Blocking manual testing
## Executive Summary
The Phase 4 development authentication is experiencing a redirect loop between `/dev/login` and `/admin/`. The session cookie is being set correctly, but Flask's server-side session storage is failing, preventing the `@require_auth` decorator from storing the redirect URL properly.
**Root Cause**: Misuse of Flask's `session` object in the `require_auth` decorator without proper initialization.
## Problem Description
### User Experience
1. User clicks dev login at `/dev/login`
2. Browser redirects to `/admin/` (302)
3. Browser redirects back to `/admin/login` (302)
4. User lands on login page, unauthenticated
### Server Logs
```
[2025-11-18 21:55:03] WARNING in dev_auth: DEV MODE: Creating session for https://dev.example.com WITHOUT authentication.
[2025-11-18 21:55:03] INFO in auth: Session created for https://dev.example.com
127.0.0.1 - - [18/Nov/2025 21:55:03] "GET /dev/login HTTP/1.1" 302 -
127.0.0.1 - - [18/Nov/2025 21:55:03] "GET /admin/ HTTP/1.1" 302 -
127.0.0.1 - - [18/Nov/2025 21:55:03] "GET /admin/login HTTP/1.1" 200 -
```
## Root Cause Analysis
### The Critical Issue
In `starpunk/auth.py`, line 397, the `require_auth` decorator attempts to use Flask's server-side session:
```python
@wraps(f)
def decorated_function(*args, **kwargs):
# Get session token from cookie
session_token = request.cookies.get("session")
# Verify session
session_info = verify_session(session_token)
if not session_info:
# Store intended destination
session["next"] = request.url # ← THIS IS THE PROBLEM
return redirect(url_for("auth.login_form"))
```
### Why This Causes the Redirect Loop
1. **Session Cookie Name Collision**:
- Flask's server-side session uses a cookie named `session` by default
- StarPunk's authentication uses a cookie named `session` for the session token
- These are TWO DIFFERENT things being stored under the same name
2. **What Actually Happens**:
- `/dev/login` sets `session` cookie with the authentication token (e.g., `"xyz123abc456..."`)
- Browser sends this cookie to `/admin/`
- `@require_auth` reads `request.cookies.get("session")` → Gets the auth token (correct)
- `verify_session()` validates the token → Returns valid session info (correct)
- BUT: If there's ANY code path that triggers Flask session access elsewhere, Flask tries to deserialize the auth token as a Flask session object
- When `require_auth` tries to write `session["next"] = request.url`, Flask overwrites the `session` cookie with its own signed session data
- On the next request, the auth token is gone, replaced by Flask session data
- `verify_session()` fails because the cookie now contains Flask session JSON, not an auth token
- User is redirected back to login
3. **The Timing Issue**:
- The redirect happens so fast that the browser sees:
1. Cookie set to auth token
2. Redirect to `/admin/`
3. Flask session middleware processes the request
4. Cookie gets overwritten with Flask session data
5. Auth check fails
6. Redirect to `/admin/login`
### Secondary Issue: Flash Messages
The dev login route also uses `flash()` which relies on Flask's session:
```python
flash("DEV MODE: Logged in without authentication", "warning")
```
When `flash()` is called, Flask writes to the server-side session, which triggers the cookie overwrite.
## Why This Wasn't Caught Earlier
1. **Production IndieAuth Flow**: The production flow doesn't use `flash()` or `session["next"]` in the same request cycle as setting the auth cookie
2. **Test Coverage Gap**: Tests likely mock the session or don't test the full HTTP request/response cycle
3. **Cookie Name Collision**: Using `session` for both Flask's session and StarPunk's auth token is architecturally unsound
## The Fix
### Option 1: Rename StarPunk Session Cookie (RECOMMENDED)
**Rationale**: Flask owns the `session` cookie name. We should not conflict with framework conventions.
**Changes Required**:
#### 1. Update `starpunk/routes/dev_auth.py` (Line 74-81)
**Old Code**:
```python
response.set_cookie(
"session",
session_token,
httponly=True,
secure=False,
samesite="Lax",
max_age=30 * 24 * 60 * 60,
)
```
**New Code**:
```python
response.set_cookie(
"starpunk_session", # ← Changed from "session"
session_token,
httponly=True,
secure=False,
samesite="Lax",
max_age=30 * 24 * 60 * 60,
)
```
#### 2. Update `starpunk/auth.py` (Line 390)
**Old Code**:
```python
session_token = request.cookies.get("session")
```
**New Code**:
```python
session_token = request.cookies.get("starpunk_session") # ← Changed from "session"
```
#### 3. Update `starpunk/routes/auth.py` (IndieAuth callback)
Find where the session cookie is set after IndieAuth callback (likely similar to dev_auth) and change the cookie name there as well.
**Search for**: `response.set_cookie("session"`
**Replace with**: `response.set_cookie("starpunk_session"`
#### 4. Update logout route to clear correct cookie
Find the logout route and ensure it clears `starpunk_session` instead of `session`.
### Option 2: Disable Flask Session (NOT RECOMMENDED)
We could disable Flask's session entirely by not setting `SECRET_KEY`, but this would:
- Break `flash()` messages
- Break `session["next"]` redirect tracking
- Require rewriting all flash message functionality
This adds complexity without benefit.
### Option 3: Use Query Parameter for Redirect (PARTIAL FIX)
Instead of `session["next"]`, use a query parameter:
```python
return redirect(url_for("auth.login_form", next=request.url))
```
This fixes the immediate issue but doesn't resolve the cookie name collision, which will cause problems elsewhere.
## Recommended Solution: Option 1
**Why**:
- Minimal code changes (4 locations)
- Follows Flask conventions (Flask owns `session`)
- Preserves all existing functionality
- Clear separation of concerns
- No security implications
**Implementation Steps**:
1. Search codebase for all instances of `"session"` cookie usage
2. Replace with `"starpunk_session"`
3. Update any logout functionality
4. Update any session validation code
5. Test full auth flow (dev and production)
## Files Requiring Changes
1. `/home/phil/Projects/starpunk/starpunk/routes/dev_auth.py` - Line 75
2. `/home/phil/Projects/starpunk/starpunk/auth.py` - Line 390
3. `/home/phil/Projects/starpunk/starpunk/routes/auth.py` - Find callback route cookie setting
4. `/home/phil/Projects/starpunk/starpunk/routes/auth.py` - Find logout route cookie clearing
## Testing Approach
### Manual Test Plan
1. **Dev Login Flow**:
```
1. Visit http://localhost:5000/admin/
2. Verify redirect to /admin/login
3. Click dev login link
4. Verify redirect to /admin/
5. Verify dashboard loads (no redirect loop)
6. Verify flash message appears
7. Check browser DevTools → Application → Cookies
8. Verify "starpunk_session" cookie exists with token value
9. Verify "session" cookie exists with Flask session data (if flash used)
```
2. **Session Persistence**:
```
1. After successful login, visit /admin/new
2. Verify authentication persists
3. Refresh page
4. Verify still authenticated
```
3. **Logout**:
```
1. While authenticated, click logout
2. Verify redirect to login
3. Verify "starpunk_session" cookie is cleared
4. Try to visit /admin/
5. Verify redirect to /admin/login
```
### Automated Test Requirements
Add tests for:
- Cookie name verification
- Session persistence across requests
- Flash message functionality with auth
- Redirect loop prevention
## Security Implications
**None**: This change is purely architectural cleanup. Both cookie names are:
- HttpOnly (prevents JavaScript access)
- SameSite=Lax (CSRF protection)
- Same security properties
The separation actually improves security by:
- Clear separation of concerns
- Easier to audit (two distinct cookies)
- Follows framework conventions
## Architecture Decision
This issue reveals a broader architectural concern: **Cookie Naming Strategy**.
### New Standard: Cookie Naming Convention
**Rule**: Never use generic names that conflict with framework conventions.
**StarPunk Cookie Names**:
- `starpunk_session` - Authentication session token
- `session` - Reserved for Flask framework use
- Future cookies should use `starpunk_*` prefix
**Document in**: `/docs/standards/api-design.md` under "Cookie Standards"
## Prevention
### Code Review Checklist Addition
Add to code review standards:
- [ ] No custom cookies named `session`, `csrf_token`, or other framework-reserved names
- [ ] All StarPunk cookies use `starpunk_` prefix
- [ ] Cookie security attributes verified (HttpOnly, Secure, SameSite)
### Configuration Validation
Consider adding startup validation:
```python
# In config.py validate_config()
if app.config.get("SESSION_COOKIE_NAME") == "session":
app.logger.warning(
"Using default Flask session cookie name. "
"StarPunk auth uses 'starpunk_session' to avoid conflicts."
)
```
## Timeline
**Estimated Fix Time**: 30 minutes
- 10 min: Search and replace cookie names
- 10 min: Manual testing
- 10 min: Update changelog and version
**Priority**: CRITICAL - Blocking Phase 4 manual testing
## Next Steps for Developer
1. Read this document completely
2. Search codebase for all `"session"` cookie references
3. Implement Option 1 changes systematically
4. Run manual test plan
5. Update `/docs/standards/api-design.md` with cookie naming convention
6. Update changelog
7. Increment version to 0.5.1 (bugfix)
8. Create git commit with proper message
## References
- Flask Documentation: https://flask.palletsprojects.com/en/3.0.x/api/#flask.session
- Cookie Security: https://owasp.org/www-community/controls/SecureFlag
- IndieWeb Session Spec: https://indieweb.org/session

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

View File

@@ -0,0 +1,125 @@
# Auth Redirect Loop - Executive Summary
**Date**: 2025-11-18
**Status**: ROOT CAUSE IDENTIFIED - FIX READY
**Priority**: CRITICAL
## The Problem (30 Second Version)
When you click dev login, you get redirected back to the login page instead of to the admin dashboard. This is a redirect loop.
## Root Cause (One Sentence)
Flask's `session` object (used by `flash()` messages) and StarPunk's authentication both use a cookie named `session`, causing Flask to overwrite the auth token.
## The Fix (One Sentence)
Rename StarPunk's authentication cookie from `"session"` to `"starpunk_session"`.
## What the Developer Needs to Do
1. Read `/home/phil/Projects/starpunk/docs/design/auth-redirect-loop-fix-implementation.md`
2. Change 6 lines in production code (3 files)
3. Change 5 lines in test code (2 files)
4. Run tests
5. Test manually (dev login → should work without loop)
6. Update changelog
7. Commit
**Time Estimate**: 30 minutes
## Why This Happened
StarPunk uses a cookie named `session` to store the authentication token (e.g., `"abc123xyz"`).
Flask uses a cookie named `session` to store server-side session data (for flash messages and `session["next"]`).
These are two different things trying to use the same cookie name.
### The Sequence of Events
```
1. /dev/login sets cookie: session = "auth_token_abc123"
2. /dev/login calls flash() → Flask writes session = {flash: "message"}
3. Browser redirects to /admin/
4. @require_auth reads cookie: session = {flash: "message"} ← WRONG!
5. verify_session("flash: message") → FAIL (not a valid token)
6. Redirect to /admin/login
7. Loop!
```
## The Fix Explained
By renaming StarPunk's cookie to `starpunk_session`, we avoid the collision:
```
1. /dev/login sets: starpunk_session = "auth_token_abc123"
2. /dev/login calls flash() → Flask sets: session = {flash: "message"}
3. Browser has TWO cookies now (no conflict)
4. @require_auth reads: starpunk_session = "auth_token_abc123" ← CORRECT!
5. verify_session("auth_token_abc123") → SUCCESS
6. Dashboard loads ✓
```
## Files to Change
### Production Code (3 files, 6 changes)
1. `starpunk/routes/dev_auth.py` - Line 75 (set_cookie)
2. `starpunk/routes/auth.py` - Lines 47, 121, 167, 178 (get/set/delete cookie)
3. `starpunk/auth.py` - Line 390 (get cookie)
### Tests (2 files, 5 changes)
1. `tests/test_routes_admin.py` - Line 54
2. `tests/test_templates.py` - Lines 234, 247, 259, 272
## Breaking Change
**Yes** - Existing logged-in users will be logged out and need to re-authenticate.
This is because we're changing the cookie name, so their existing `session` cookies won't be read as `starpunk_session`.
## Detailed Documentation
- **Diagnosis**: `/home/phil/Projects/starpunk/docs/design/auth-redirect-loop-diagnosis.md`
- **Implementation Guide**: `/home/phil/Projects/starpunk/docs/design/auth-redirect-loop-fix-implementation.md`
## Architecture Impact
This establishes a new architectural standard:
**Cookie Naming Convention**: All StarPunk cookies MUST use the `starpunk_` prefix to avoid conflicts with framework-reserved names.
This prevents this class of bugs in the future.
## Testing
### Must Pass
1. Dev login flow (no redirect loop)
2. Session persistence across page loads
3. Logout clears cookie properly
4. Flash messages still work
5. All automated tests pass
### Browser Check
After fix, cookies should be:
- `starpunk_session` = {long-auth-token}
- `session` = {flask-session-with-flash-messages}
## Version Impact
This is a bugfix release: **0.5.0 → 0.5.1**
## Questions?
Read the full implementation guide: `/home/phil/Projects/starpunk/docs/design/auth-redirect-loop-fix-implementation.md`
It contains:
- Exact code changes (old vs new)
- Line-by-line change locations
- Complete testing plan
- Rollback instructions
- Git commit template

View File

@@ -0,0 +1,512 @@
# Implementation Guide: Auth Redirect Loop Fix
**Date**: 2025-11-18
**Related**: auth-redirect-loop-diagnosis.md
**Assignee**: Developer Agent
**Priority**: CRITICAL
## Quick Summary
Change all authentication cookie references from `"session"` to `"starpunk_session"` to avoid collision with Flask's server-side session mechanism.
**Estimated Time**: 30 minutes
**Files to Change**: 5 production files + test files
## Root Cause (Brief)
Flask's `session` object (used by `flash()` and `session["next"]`) writes to a cookie named `session`. StarPunk's auth also uses a cookie named `session`. This creates a collision where Flask overwrites the auth token, causing the redirect loop.
**Solution**: Rename StarPunk's auth cookie to `starpunk_session`.
## Implementation Checklist
### Phase 1: Production Code Changes
#### 1. `/home/phil/Projects/starpunk/starpunk/routes/dev_auth.py`
**Line 75** - Change cookie name when setting:
```python
# OLD (Line 74-81):
response.set_cookie(
"session",
session_token,
httponly=True,
secure=False,
samesite="Lax",
max_age=30 * 24 * 60 * 60,
)
# NEW:
response.set_cookie(
"starpunk_session", # ← CHANGED
session_token,
httponly=True,
secure=False,
samesite="Lax",
max_age=30 * 24 * 60 * 60,
)
```
#### 2. `/home/phil/Projects/starpunk/starpunk/routes/auth.py`
**Line 47** - Change cookie read in login form check:
```python
# OLD:
session_token = request.cookies.get("session")
# NEW:
session_token = request.cookies.get("starpunk_session")
```
**Line 121** - Change cookie name when setting after IndieAuth callback:
```python
# OLD (Lines 120-127):
response.set_cookie(
"session",
session_token,
httponly=True,
secure=secure,
samesite="Lax",
max_age=30 * 24 * 60 * 60,
)
# NEW:
response.set_cookie(
"starpunk_session", # ← CHANGED
session_token,
httponly=True,
secure=secure,
samesite="Lax",
max_age=30 * 24 * 60 * 60,
)
```
**Line 167** - Change cookie read in logout:
```python
# OLD:
session_token = request.cookies.get("session")
# NEW:
session_token = request.cookies.get("starpunk_session")
```
**Line 178** - Change cookie delete in logout:
```python
# OLD:
response.delete_cookie("session")
# NEW:
response.delete_cookie("starpunk_session")
```
#### 3. `/home/phil/Projects/starpunk/starpunk/auth.py`
**Line 390** - Change cookie read in `@require_auth` decorator:
```python
# OLD:
session_token = request.cookies.get("session")
# NEW:
session_token = request.cookies.get("starpunk_session")
```
### Phase 2: Test Code Changes
#### 4. `/home/phil/Projects/starpunk/tests/test_routes_admin.py`
**Line 54** - Change test cookie name:
```python
# OLD:
client.set_cookie("session", session_token)
# NEW:
client.set_cookie("starpunk_session", session_token)
```
#### 5. `/home/phil/Projects/starpunk/tests/test_templates.py`
**Lines 234, 247, 259, 272** - Change all test cookie names:
```python
# OLD (appears 4 times):
client.set_cookie("session", token)
# NEW (all 4 instances):
client.set_cookie("starpunk_session", token)
```
### Phase 3: Documentation Updates
Update the following documentation files to reflect the new cookie name:
1. `/home/phil/Projects/starpunk/docs/decisions/ADR-011-development-authentication-mechanism.md` (Line 112)
2. `/home/phil/Projects/starpunk/docs/decisions/ADR-005-indielogin-authentication.md` (Line 204)
3. `/home/phil/Projects/starpunk/docs/design/phase-4-quick-reference.md` (Line 460)
4. `/home/phil/Projects/starpunk/docs/design/phase-4-web-interface.md` (Lines 298, 522)
5. `/home/phil/Projects/starpunk/docs/design/phase-3-authentication-implementation.md` (Line 313)
**Note**: These are documentation files, so changes are for accuracy but not critical for functionality.
## Complete File Change Summary
### Production Code (5 changes across 3 files)
| File | Line | Change Type | Old Value | New Value |
|------|------|-------------|-----------|-----------|
| `starpunk/routes/dev_auth.py` | 75 | set_cookie name | `"session"` | `"starpunk_session"` |
| `starpunk/routes/auth.py` | 47 | cookies.get | `"session"` | `"starpunk_session"` |
| `starpunk/routes/auth.py` | 121 | set_cookie name | `"session"` | `"starpunk_session"` |
| `starpunk/routes/auth.py` | 167 | cookies.get | `"session"` | `"starpunk_session"` |
| `starpunk/routes/auth.py` | 178 | delete_cookie | `"session"` | `"starpunk_session"` |
| `starpunk/auth.py` | 390 | cookies.get | `"session"` | `"starpunk_session"` |
### Test Code (5 changes across 2 files)
| File | Line(s) | Change Type |
|------|---------|-------------|
| `tests/test_routes_admin.py` | 54 | client.set_cookie |
| `tests/test_templates.py` | 234, 247, 259, 272 | client.set_cookie (4 instances) |
## Search and Replace Strategy
**IMPORTANT**: Do NOT use global search and replace. Many documentation files reference the word "session" legitimately.
### Recommended Approach
Use targeted search patterns:
```bash
# Find all set_cookie calls with "session"
grep -n 'set_cookie.*"session"' starpunk/**/*.py tests/**/*.py
# Find all cookies.get calls with "session"
grep -n 'cookies\.get.*"session"' starpunk/**/*.py tests/**/*.py
# Find all delete_cookie calls with "session"
grep -n 'delete_cookie.*"session"' starpunk/**/*.py tests/**/*.py
```
Then manually review and update each instance.
## Testing Plan
### Automated Tests
After making changes, run the test suite:
```bash
uv run pytest tests/ -v
```
**Expected**: All existing tests should pass with the new cookie name.
### Manual Testing (CRITICAL)
#### Test 1: Dev Login Flow
```
1. Start server: uv run flask run
2. Open browser: http://localhost:5000/admin/
3. Expected: Redirect to /admin/login
4. Click "Dev Login" link (or visit http://localhost:5000/dev/login)
5. Expected: Redirect to /admin/ dashboard
6. Expected: See flash message "DEV MODE: Logged in without authentication"
7. Expected: Dashboard loads successfully (NO redirect loop)
```
**Success Criteria**:
- No redirect loop
- Flash message appears
- Dashboard displays
**Browser DevTools Check**:
```
Application → Cookies → http://localhost:5000
Should see:
- starpunk_session: {long-token-string}
- session: {flask-session-data} (for flash messages)
```
#### Test 2: Session Persistence
```
1. After successful login from Test 1
2. Click "New Note" in navigation
3. Expected: Form loads (no redirect to login)
4. Refresh page (F5)
5. Expected: Still authenticated, form still loads
```
**Success Criteria**:
- No authentication loss on navigation
- No authentication loss on refresh
#### Test 3: Logout
```
1. While authenticated, click "Logout" button
2. Expected: Redirect to homepage
3. Expected: Flash message "Logged out successfully"
4. Try to visit http://localhost:5000/admin/
5. Expected: Redirect to /admin/login
```
**Browser DevTools Check**:
```
Application → Cookies → http://localhost:5000
Should see:
- starpunk_session: (should be deleted)
- session: {may still exist for flash message}
```
**Success Criteria**:
- Cookie properly cleared
- Cannot access admin routes after logout
- Must login again to access admin
#### Test 4: IndieAuth Flow (if configured)
```
1. Logout if logged in
2. Visit /admin/login
3. Enter valid ADMIN_ME URL
4. Complete IndieAuth flow on indielogin.com
5. Expected: Redirect back to dashboard
6. Expected: starpunk_session cookie set
7. Expected: No redirect loop
```
**Success Criteria**:
- Full IndieAuth flow works
- Session persists after callback
- Flash message shows
## Post-Implementation
### 1. Version Bump
Update version to `0.5.1` (bugfix release):
```python
# In starpunk/config.py or wherever VERSION is defined
app.config["VERSION"] = "0.5.1"
```
Also update in:
- `pyproject.toml` (if version is defined there)
- `docs/CHANGELOG.md`
### 2. Changelog Entry
Add to `/home/phil/Projects/starpunk/docs/CHANGELOG.md`:
```markdown
## [0.5.1] - 2025-11-18
### Fixed
- **CRITICAL**: Fixed authentication redirect loop caused by cookie name collision between Flask's session and StarPunk's auth token
- Renamed authentication cookie from `session` to `starpunk_session` to avoid conflict with Flask's server-side session mechanism
- All authentication flows (dev login, IndieAuth, logout) now work correctly without redirect loops
### Changed
- Authentication cookie name changed from `session` to `starpunk_session` (breaking change for existing sessions - users will need to re-login)
```
### 3. Update Standards Document
Create or update `/home/phil/Projects/starpunk/docs/standards/cookie-naming-convention.md`:
```markdown
# Cookie Naming Convention
**Status**: ACTIVE
**Date**: 2025-11-18
## Standard
All StarPunk application cookies MUST use the `starpunk_` prefix to avoid conflicts with framework-reserved names.
## Reserved Names (DO NOT USE)
- `session` - Reserved for Flask server-side session
- `csrf_token` - Reserved for CSRF protection frameworks
- `remember_token` - Common auth framework name
- Any single-word generic names
## StarPunk Cookie Names
| Cookie Name | Purpose | Security Attributes |
|-------------|---------|---------------------|
| `starpunk_session` | Authentication session token | HttpOnly, Secure (prod), SameSite=Lax |
## Future Cookies
All future cookies must:
1. Use `starpunk_` prefix
2. Be documented in this table
3. Have explicit security attributes defined
4. Be reviewed for conflicts with framework conventions
```
### 4. Create Report
Create `/home/phil/Projects/starpunk/docs/reports/2025-11-18-auth-redirect-loop-fix.md`:
```markdown
# Auth Redirect Loop Fix - Implementation Report
**Date**: 2025-11-18
**Version**: 0.5.1
**Severity**: Critical Bug Fix
## Summary
Fixed authentication redirect loop in Phase 4 by renaming authentication cookie from `session` to `starpunk_session`.
## Root Cause
Cookie name collision between Flask's server-side session (used by flash messages) and StarPunk's authentication token.
## Implementation
- Changed 6 instances in production code
- Changed 5 instances in test code
- Updated 6 documentation files
- All tests passing
- Manual testing confirmed fix
## Testing
- Dev login flow: PASS
- Session persistence: PASS
- Logout flow: PASS
- IndieAuth flow: PASS (if applicable)
## Breaking Change
Existing authenticated users will be logged out and need to re-authenticate due to cookie name change.
## Prevention
Established cookie naming convention (starpunk_* prefix) to prevent future conflicts.
## Files Changed
[List all files modified]
## Commit
[Reference commit hash after git commit]
```
### 5. Git Commit
After all changes and testing:
```bash
# Stage all changes
git add starpunk/routes/dev_auth.py \
starpunk/routes/auth.py \
starpunk/auth.py \
tests/test_routes_admin.py \
tests/test_templates.py \
docs/
# Commit with proper message
git commit -m "$(cat <<'EOF'
Fix critical auth redirect loop by renaming session cookie
BREAKING CHANGE: Authentication cookie renamed from 'session' to 'starpunk_session'
Root cause: Cookie name collision between Flask's server-side session
(used by flash messages) and StarPunk's authentication token caused
redirect loop between /dev/login and /admin/ routes.
Changes:
- Rename auth cookie to 'starpunk_session' in all routes
- Update all cookie read/write operations
- Update test suite with new cookie name
- Establish cookie naming convention (starpunk_* prefix)
- Update documentation to reflect changes
Impact:
- Existing authenticated users will be logged out
- Users must re-authenticate after upgrade
Testing:
- All automated tests passing
- Manual testing confirms fix:
- Dev login flow works without redirect loop
- Session persistence across requests
- Logout properly clears cookie
- Flash messages work correctly
Fixes: Phase 4 authentication redirect loop
Version: 0.5.1
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
EOF
)"
```
## Verification Checklist
Before marking this as complete:
- [ ] All 6 production code changes made
- [ ] All 5 test code changes made
- [ ] Test suite passes: `uv run pytest tests/ -v`
- [ ] Manual Test 1 (Dev Login) passes
- [ ] Manual Test 2 (Session Persistence) passes
- [ ] Manual Test 3 (Logout) passes
- [ ] Manual Test 4 (IndieAuth) passes or N/A
- [ ] Version bumped to 0.5.1
- [ ] CHANGELOG.md updated
- [ ] Cookie naming convention documented
- [ ] Implementation report created
- [ ] Git commit created with proper message
- [ ] No redirect loop observed in any test
- [ ] Flash messages still work
## Rollback Plan
If issues are discovered:
```bash
# Revert the commit
git revert HEAD
# Or reset if not pushed
git reset --hard HEAD~1
```
The old behavior will be restored, but the redirect loop will return.
## Support
If you encounter issues during implementation:
1. Check browser DevTools → Application → Cookies
2. Verify both `starpunk_session` and `session` cookies exist
3. Check Flask logs for session-related errors
4. Verify SECRET_KEY is set in config
5. Ensure all 6 production file changes were made correctly
## Architecture Notes
This fix establishes an important principle:
**Never use generic cookie names that conflict with framework conventions.**
Flask owns the `session` cookie namespace. We must respect framework boundaries and use our own namespace (`starpunk_*`).
This is now codified in `/docs/standards/cookie-naming-convention.md` for future reference.

View File

@@ -0,0 +1,251 @@
# Phase 4: Error Handling Fix - Implementation Guide
**Created**: 2025-11-18
**Status**: Ready for Implementation
**Related ADR**: ADR-012 HTTP Error Handling Policy
**Related Review**: `/home/phil/Projects/starpunk/docs/reviews/error-handling-rest-vs-web-patterns.md`
**Test Failure**: `test_update_nonexistent_note_404`
## Problem Summary
The POST route for updating notes (`/admin/edit/<id>`) returns HTTP 302 (redirect) when the note doesn't exist, but the test expects HTTP 404. The GET route for the edit form already returns 404 correctly, so this is an inconsistency in the implementation.
## Solution
Add an existence check at the start of `update_note_submit()` in `/home/phil/Projects/starpunk/starpunk/routes/admin.py`, matching the pattern used in `edit_note_form()`.
## Implementation Steps
### Step 1: Modify `update_note_submit()` Function
**File**: `/home/phil/Projects/starpunk/starpunk/routes/admin.py`
**Lines**: 127-164
**Function**: `update_note_submit(note_id: int)`
**Add the following code after the function definition and decorator, before processing form data:**
```python
@bp.route("/edit/<int:note_id>", methods=["POST"])
@require_auth
def update_note_submit(note_id: int):
"""
Handle note update submission
Updates existing note with submitted form data.
Requires authentication.
Args:
note_id: Database ID of note to update
Form data:
content: Updated markdown content (required)
published: Checkbox for published status (optional)
Returns:
Redirect to dashboard on success, back to form on error
Decorator: @require_auth
"""
# CHECK IF NOTE EXISTS FIRST (ADDED)
existing_note = get_note(id=note_id, load_content=False)
if not existing_note:
flash("Note not found", "error")
return redirect(url_for("admin.dashboard")), 404
# Rest of the function remains the same
content = request.form.get("content", "").strip()
published = "published" in request.form
if not content:
flash("Content cannot be empty", "error")
return redirect(url_for("admin.edit_note_form", note_id=note_id))
try:
note = update_note(id=note_id, content=content, published=published)
flash(f"Note updated: {note.slug}", "success")
return redirect(url_for("admin.dashboard"))
except ValueError as e:
flash(f"Error updating note: {e}", "error")
return redirect(url_for("admin.edit_note_form", note_id=note_id))
except Exception as e:
flash(f"Unexpected error updating note: {e}", "error")
return redirect(url_for("admin.edit_note_form", note_id=note_id))
```
### Step 2: Verify Fix with Tests
Run the failing test to verify it now passes:
```bash
uv run pytest tests/test_routes_admin.py::TestEditNote::test_update_nonexistent_note_404 -v
```
Expected output:
```
tests/test_routes_admin.py::TestEditNote::test_update_nonexistent_note_404 PASSED
```
### Step 3: Run Full Admin Route Test Suite
Verify no regressions:
```bash
uv run pytest tests/test_routes_admin.py -v
```
All tests should pass.
### Step 4: Verify Existing GET Route Still Works
The GET route should still return 404:
```bash
uv run pytest tests/test_routes_admin.py::TestEditNote::test_edit_nonexistent_note_404 -v
```
Should still pass (no changes to this route).
## Code Changes Summary
### File: `/home/phil/Projects/starpunk/starpunk/routes/admin.py`
**Location**: After line 129 (after function docstring, before form processing)
**Add**:
```python
# Check if note exists first
existing_note = get_note(id=note_id, load_content=False)
if not existing_note:
flash("Note not found", "error")
return redirect(url_for("admin.dashboard")), 404
```
**No other changes needed** - the import for `get_note` already exists (line 15).
## Why This Fix Works
### Pattern Consistency
This matches the existing pattern in `edit_note_form()` (lines 118-122):
```python
note = get_note(id=note_id)
if not note:
flash("Note not found", "error")
return redirect(url_for("admin.dashboard")), 404
```
### Prevents Exception Handling
Without this check, the code would:
1. Try to call `update_note(id=note_id, ...)`
2. `update_note()` calls `get_note()` internally (line 603)
3. `get_note()` returns `None` for missing notes (line 368)
4. `update_note()` raises `NoteNotFoundError` (line 607)
5. Exception caught by `except Exception` (line 162)
6. Returns redirect with 302 status
With this check, the code:
1. Calls `get_note(id=note_id)` first
2. Returns 404 immediately if not found
3. Never calls `update_note()` for nonexistent notes
### HTTP Semantic Correctness
- **404 Not Found**: The correct HTTP status for "resource does not exist"
- **302 Found (Redirect)**: Used for successful operations that redirect elsewhere
- The test expects 404, which is semantically correct
### User Experience
While returning 404, we still:
1. Flash an error message ("Note not found")
2. Redirect to the dashboard (safe location)
3. User sees the error in context
Flask allows returning both: `return redirect(...), 404`
## Testing Strategy
### Unit Test Coverage
This test should now pass:
```python
def test_update_nonexistent_note_404(self, authenticated_client):
"""Test that updating a nonexistent note returns 404"""
response = authenticated_client.post(
"/admin/edit/99999",
data={"content": "Updated content", "published": "on"},
follow_redirects=False,
)
assert response.status_code == 404 # ✓ Should pass now
```
### Manual Testing (Optional)
1. Start the development server
2. Log in as admin
3. Try to access `/admin/edit/99999` (GET)
- Should redirect to dashboard with "Note not found" message
- Network tab shows 404 status
4. Try to POST to `/admin/edit/99999` with form data
- Should redirect to dashboard with "Note not found" message
- Network tab shows 404 status
## Additional Considerations
### Performance Impact
**Minimal**: The existence check adds one database query:
- Query: `SELECT * FROM notes WHERE id = ? AND deleted_at IS NULL`
- With `load_content=False`: No file I/O
- SQLite with index: ~0.1ms
- Acceptable for single-user system
### Alternative Approaches Rejected
1. **Catch `NoteNotFoundError` specifically**: Possible, but less explicit than checking first
2. **Let error handler deal with it**: Less flexible for per-route flash messages
3. **Change test to expect 302**: Wrong - test is correct, implementation is buggy
### Future Improvements
Consider adding a similar check to `delete_note_submit()` for consistency:
```python
@bp.route("/delete/<int:note_id>", methods=["POST"])
@require_auth
def delete_note_submit(note_id: int):
if request.form.get("confirm") != "yes":
flash("Deletion cancelled", "info")
return redirect(url_for("admin.dashboard"))
# ADD EXISTENCE CHECK
existing_note = get_note(id=note_id, load_content=False)
if not existing_note:
flash("Note not found", "error")
return redirect(url_for("admin.dashboard")), 404
# Rest of delete logic...
```
However, this requires updating the test `test_delete_nonexistent_note_shows_error` to expect 404 instead of 200.
## Expected Outcome
After implementing this fix:
1.`test_update_nonexistent_note_404` passes
2.`test_edit_nonexistent_note_404` still passes
3. ✓ All other admin route tests pass
4. ✓ GET and POST routes have consistent behavior
5. ✓ HTTP semantics are correct (404 for missing resources)
## References
- Architectural review: `/home/phil/Projects/starpunk/docs/reviews/error-handling-rest-vs-web-patterns.md`
- ADR: `/home/phil/Projects/starpunk/docs/decisions/ADR-012-http-error-handling-policy.md`
- Current implementation: `/home/phil/Projects/starpunk/starpunk/routes/admin.py`
- Test file: `/home/phil/Projects/starpunk/tests/test_routes_admin.py`

View File

@@ -0,0 +1,564 @@
# Phase 4: Quick Reference
**Phase**: Web Interface
**Version**: 0.5.0
**Status**: Design Complete
**Dependencies**: Phase 3 (Authentication) ✓
## Critical Decision: Development Authentication
**Question**: Should we implement a dev auth mechanism for local testing?
**Answer**: ✓ **YES** - Implement with strict safeguards
**Why**: Enable local testing without deploying to IndieLogin.com
**How**: Separate `/dev/login` route that only works when `DEV_MODE=true`
**Safety**: Returns 404 when disabled, visual warnings, config validation
**Details**: See ADR-011
---
## What Phase 4 Delivers
### Public Interface
- Homepage with recent notes (`/`)
- Note permalinks (`/note/<slug>`)
- Microformats2 markup (h-feed, h-entry)
### Admin Interface
- Login via IndieLogin (`/admin/login`)
- Dashboard with note list (`/admin`)
- Create notes (`/admin/new`)
- Edit notes (`/admin/edit/<id>`)
- Delete notes (`/admin/delete/<id>`)
### Development Tools
- Dev auth for local testing (`/dev/login`)
- Configuration validation
- Dev mode warnings
---
## Routes Summary
### Public (No Auth)
```
GET / Homepage (note list)
GET /note/<slug> Note permalink
```
### Auth Flow
```
GET /admin/login Login form
POST /admin/login Start IndieLogin flow
GET /auth/callback IndieLogin callback
POST /admin/logout Logout
```
### Admin (Auth Required)
```
GET /admin Dashboard
GET /admin/new Create note form
POST /admin/new Save new note
GET /admin/edit/<id> Edit note form
POST /admin/edit/<id> Update note
POST /admin/delete/<id> Delete note
```
### Dev (DEV_MODE Only)
```
GET /dev/login Instant login (bypasses IndieLogin)
```
---
## File Structure
### New Files (~2,770 lines total)
```
starpunk/routes/ # Route handlers
├── public.py # Public routes
├── admin.py # Admin routes
├── auth.py # Auth routes
└── dev_auth.py # Dev routes
starpunk/dev_auth.py # Dev auth module
templates/ # Jinja2 templates
├── base.html
├── index.html
├── note.html
└── admin/
├── base.html
├── login.html
├── dashboard.html
├── new.html
└── edit.html
static/css/style.css # ~350 lines
static/js/preview.js # Optional markdown preview
tests/
├── test_routes_public.py
├── test_routes_admin.py
└── test_dev_auth.py
```
### Modified Files
```
starpunk/config.py # Add DEV_MODE, DEV_ADMIN_ME, VERSION
app.py # Register routes, validate config
CHANGELOG.md # Add v0.5.0 entry
```
---
## Configuration
### New Environment Variables
```bash
# Development Mode (default: false)
DEV_MODE=false # Set to 'true' for local dev
DEV_ADMIN_ME= # Your identity URL for dev mode
# Version (for display)
VERSION=0.5.0
```
### Development Setup
```bash
# For local development
DEV_MODE=true
DEV_ADMIN_ME=https://yoursite.com
# For production (or leave unset)
DEV_MODE=false
ADMIN_ME=https://yoursite.com
```
---
## Security Measures
### Dev Auth Safeguards
1. **Explicit Configuration**: Requires `DEV_MODE=true`
2. **Separate Routes**: `/dev/login` (not `/admin/login`)
3. **Route Protection**: Returns 404 if DEV_MODE=false
4. **Config Validation**: Prevents DEV_MODE + production URL
5. **Visual Warnings**: Red banner when dev mode active
6. **Logging**: All dev auth logged with warnings
### Production Security
- All admin routes use `@require_auth`
- HttpOnly, Secure, SameSite cookies
- CSRF state tokens
- Session expiry (30 days)
- Jinja2 auto-escaping (XSS prevention)
---
## Template Architecture
### Microformats
**Homepage** (h-feed):
```html
<div class="h-feed">
<article class="h-entry">
<div class="e-content">...</div>
<time class="dt-published">...</time>
<a class="u-url" href="...">permalink</a>
</article>
</div>
```
**Note Page** (h-entry):
```html
<article class="h-entry">
<div class="e-content">{{ note.html|safe }}</div>
<a class="u-url" href="{{ url_for('public.note', slug=note.slug) }}">
<time class="dt-published" datetime="{{ note.created_at.isoformat() }}">
{{ note.created_at.strftime('%B %d, %Y') }}
</time>
</a>
</article>
```
### Flash Messages
```python
# In routes
flash('Note created successfully', 'success')
flash('Error: Note not found', 'error')
# In templates
{% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %}
<div class="flash flash-{{ category }}">{{ message }}</div>
{% endfor %}
{% endwith %}
```
---
## CSS Architecture
### Variables
```css
:root {
/* Colors */
--color-text: #333;
--color-bg: #fff;
--color-link: #0066cc;
--color-success: #28a745;
--color-error: #dc3545;
--color-warning: #ffc107;
/* Typography */
--font-body: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-mono: 'SF Mono', Monaco, monospace;
/* Spacing */
--spacing-md: 1rem;
--spacing-lg: 2rem;
/* Layout */
--max-width: 42rem;
}
```
### Mobile-First
```css
/* Base: Mobile */
body { padding: 1rem; }
/* Tablet and up */
@media (min-width: 768px) {
body { padding: 2rem; }
}
```
---
## Testing Strategy
### Coverage Target: >90%
### Unit Tests
- Public routes (homepage, note permalink)
- Admin routes (dashboard, create, edit, delete)
- Dev auth (login, validation, route protection)
### Integration Tests
- Full auth flow (mocked IndieLogin)
- Create note end-to-end
- Edit note end-to-end
- Delete note end-to-end
### Manual Tests
- Browser testing (Chrome, Firefox, Safari)
- Mobile responsive
- Microformats validation (indiewebify.me)
- HTML5 validation (W3C)
- Real IndieLogin authentication
---
## Implementation Checklist
### Phase 4.1: Routes (8 hours)
- [ ] Create routes package
- [ ] Implement public routes
- [ ] Implement auth routes
- [ ] Implement admin routes
### Phase 4.2: Templates (6 hours)
- [ ] Base templates
- [ ] Public templates
- [ ] Admin templates
### Phase 4.3: Dev Auth (4 hours)
- [ ] dev_auth.py module
- [ ] Config validation
- [ ] Visual warnings
### Phase 4.4: CSS (4 hours)
- [ ] style.css
- [ ] Responsive design
### Phase 4.5: JS (Optional, 2 hours)
- [ ] preview.js
- [ ] Progressive enhancement
### Phase 4.6: Testing (8 hours)
- [ ] Route tests
- [ ] Integration tests
- [ ] >90% coverage
### Phase 4.7: Documentation (2 hours)
- [ ] Update CHANGELOG
- [ ] Document routes
- [ ] Version to 0.5.0
**Total: ~34 hours**
---
## Acceptance Criteria
### Must Pass
- [ ] All routes work correctly
- [ ] Authentication enforced on admin routes
- [ ] Dev auth blocked when DEV_MODE=false
- [ ] Templates render with microformats
- [ ] Flash messages work
- [ ] Test coverage >90%
- [ ] No security vulnerabilities
- [ ] Dev mode warnings display
- [ ] Mobile responsive
---
## Performance Targets
- Homepage: < 200ms
- Note page: < 200ms
- Admin pages: < 200ms
- Form submit: < 100ms
---
## Key Integrations
### With Existing Modules
**auth.py** (Phase 3):
```python
from starpunk.auth import require_auth, verify_session, destroy_session
@require_auth
def dashboard():
# User info in g.user_me
pass
```
**notes.py** (Phase 2):
```python
from starpunk.notes import (
get_all_notes,
get_note_by_slug,
create_note,
update_note,
delete_note
)
```
**database.py** (Phase 1):
```python
from starpunk.database import get_db
```
---
## Risk Mitigation
### Dev Auth Accidentally Enabled
**Risk**: Critical
**Mitigation**:
- Config validation
- Startup warnings
- Visual indicators
- Deployment checklist
- Documentation
### XSS Vulnerabilities
**Risk**: High
**Mitigation**:
- Jinja2 auto-escaping
- No user HTML
- Code review
- Security testing
### Session Theft
**Risk**: Medium
**Mitigation**:
- HttpOnly cookies
- Secure flag (production)
- SameSite=Lax
- HTTPS required
---
## Common Patterns
### Protected Route
```python
from starpunk.auth import require_auth
@app.route('/admin/dashboard')
@require_auth
def dashboard():
# g.user_me is set by require_auth
notes = get_all_notes()
return render_template('admin/dashboard.html', notes=notes)
```
### Creating a Note
```python
@app.route('/admin/new', methods=['POST'])
@require_auth
def create_note_submit():
content = request.form.get('content')
published = 'published' in request.form
try:
note = create_note(content, published)
flash(f'Note created: {note.slug}', 'success')
return redirect(url_for('admin.dashboard'))
except ValueError as e:
flash(f'Error: {e}', 'error')
return redirect(url_for('admin.new_note_form'))
```
### Dev Mode Check
```python
# In dev_auth.py
def dev_login():
if not current_app.config.get('DEV_MODE'):
abort(404) # Route doesn't exist
me = current_app.config.get('DEV_ADMIN_ME')
session_token = create_session(me)
current_app.logger.warning(
f"DEV MODE: Session created for {me} without authentication"
)
# Set cookie and redirect
response = redirect(url_for('admin.dashboard'))
response.set_cookie('session', session_token, httponly=True)
return response
```
---
## Troubleshooting
### Dev Auth Not Working
1. Check `DEV_MODE=true` in `.env`
2. Check `DEV_ADMIN_ME` is set
3. Restart Flask server
4. Check logs for warnings
### Templates Not Found
1. Check templates/ directory exists
2. Check template paths in render_template()
3. Restart Flask server
### CSS Not Loading
1. Check static/css/style.css exists
2. Check url_for('static', filename='css/style.css')
3. Clear browser cache
### Authentication Not Working
1. Check ADMIN_ME is set correctly
2. Check SESSION_SECRET is set
3. Check IndieLogin callback URL matches
4. Check browser cookies enabled
---
## Next Steps After Phase 4
### Phase 5: RSS Feed
- Generate `/feed.xml`
- Valid RSS 2.0
- Published notes only
### Phase 6: Micropub
- `/api/micropub` endpoint
- Accept h-entry posts
- IndieAuth token verification
### V1.0.0
- Complete V1 features
- Security audit
- Performance optimization
- Production deployment
---
## Documentation References
- **ADR-011**: Development Auth Decision
- **Phase 4 Design**: Complete specification
- **Assessment Report**: Architectural review
- **Phase 3 Report**: Authentication implementation
- **ADR-003**: Frontend Technology
- **ADR-005**: IndieLogin Authentication
- **ADR-010**: Authentication Module Design
---
## Git Workflow
```bash
# Create feature branch
git checkout -b feature/phase-4-web-interface main
# Implement, test, commit frequently
git commit -m "Add public routes"
git commit -m "Add admin routes"
git commit -m "Add templates"
git commit -m "Add dev auth"
git commit -m "Add CSS"
git commit -m "Add tests"
# Update version
# Edit starpunk/__init__.py: __version__ = "0.5.0"
# Edit CHANGELOG.md
git commit -m "Bump version to 0.5.0"
# Merge to main
git checkout main
git merge feature/phase-4-web-interface
# Tag
git tag -a v0.5.0 -m "Release 0.5.0: Web Interface complete"
# Push
git push origin main v0.5.0
```
---
**Status**: Ready for Implementation
**Estimated Effort**: 34 hours
**Target Version**: 0.5.0
**Developer**: Use with Phase 4 Design Document

File diff suppressed because it is too large Load Diff