Add design doc: public Navi frontend with selective backend auth

Comprehensive endpoint inventory and auth classification for opening
navi.echo6.co frontend to public while protecting:
- Paid API calls (Google Places, TomTom)
- Per-user data (contacts)
- Admin functions (key management, service control)

Implementation deferred to Phase 3 session.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Matt 2026-04-26 01:11:02 +00:00
commit 3777a5ba22

684
AUTH-PUBLIC-FRONTEND.md Normal file
View file

@ -0,0 +1,684 @@
# AUTH-PUBLIC-FRONTEND.md — Public Navi Frontend with Selective Backend Auth
**Status:** DRAFT
**Created:** 2026-04-25
**Author:** Matt + Claude
**Target:** Phase 3 dedicated session
**Location:** recon_refactor repo on cortex
---
## 1. Principle Statement
**Single rule:** Auth-lock anything that (a) costs real money to serve or (b) is tied to a specific user account. Everything else is public.
This enables a public-facing navigation frontend at navi.echo6.co while protecting:
- Paid API calls (Google Places, TomTom commercial endpoints)
- Per-user data (contacts, saved locations)
- Administrative functions (key management, service restart)
---
## 2. Endpoint Inventory
### 2.1 Main API Routes (api.py)
| Route | Method | Classification | Rationale |
|-------|--------|----------------|-----------|
| `/` | GET | PUBLIC | Dashboard landing page |
| `/search` | GET | PUBLIC | Vector search UI (uses local embedding) |
| `/catalogue` | GET | PUBLIC | Document catalogue browser |
| `/upload` | GET | PUBLIC | Upload page (form only) |
| `/web-ingest` | GET | PUBLIC | Web ingest form |
| `/failures` | GET | PUBLIC | Failure browser |
| `/deleted-contacts` | GET | AUTHED-USER | Per-user deleted contacts page |
| `/nav-i` | GET | AUTHED-USER | Nav-I landing (shows user contact count) |
| `/nav-i/api-keys` | GET | AUTHED-USER | API key management UI |
| `/peertube` | GET | PUBLIC | PeerTube dashboard |
| `/peertube/channels` | GET | PUBLIC | Channel listing |
| `/settings/keys` | GET | AUTHED-USER | Gemini key management |
| `/settings/cookies` | GET | AUTHED-USER | YouTube cookie management |
| `/settings/vpn` | GET | AUTHED-USER | VPN control panel |
| `/settings/health` | GET | PUBLIC | Service health dashboard |
| `/kiwix` | GET | PUBLIC | Kiwix library browser |
| `/kiwix/scraper` | GET | PUBLIC | Scraper status |
| `/api/upload` | POST | AUTHED-USER | Document upload (disk usage concern) |
| `/api/upload/<hash>/status` | GET | PUBLIC | Upload status check |
| `/api/upload/categories` | GET | PUBLIC | Category listing |
| `/api/quick-stats` | GET | PUBLIC | Cached stats |
| `/api/retry-all` | POST | AUTHED-USER | Bulk retry (admin action) |
| `/api/ingest-url` | POST | AUTHED-USER | URL ingestion (disk usage) |
| `/api/ingest-urls` | POST | AUTHED-USER | Batch URL ingestion |
| `/api/crawl` | POST | AUTHED-USER | Site crawl (resource intensive) |
| `/api/crawl/<id>/status` | GET | PUBLIC | Crawl status |
| `/api/ingest-peertube` | POST | AUTHED-USER | PeerTube ingestion |
| `/api/ingest-peertube/<id>/status` | GET | PUBLIC | Ingestion status |
| `/api/peertube/stats` | GET | PUBLIC | Stats endpoint |
| `/api/search` | POST | PUBLIC | Vector search (local embedding) |
| `/api/status` | GET | PUBLIC | Pipeline status |
| `/api/retry/<hash>` | POST | AUTHED-USER | Single retry |
| `/api/ingest` | POST | AUTHED-USER | Raw intel ingestion |
| `/api/knowledge-stats` | GET | PUBLIC | Cached knowledge stats |
| `/api/traffic/flow/<z>/<x>/<y>.png` | GET | **AUTHED-COST** | TomTom traffic tiles (paid API) |
| `/api/place/<type>/<id>` | GET | **MIXED** | See Section 8 |
| `/api/landclass` | GET | PUBLIC | PAD-US lookup (local DuckDB) |
| `/api/config` | GET | PUBLIC | Feature flags |
| `/api/health` | GET | PUBLIC | Health check |
| `/api/service/restart` | POST | AUTHED-USER | Service restart (admin) |
| `/api/keys` | GET | AUTHED-USER | List Gemini keys |
| `/api/keys` | POST | AUTHED-USER | Add Gemini key |
| `/api/keys/<idx>` | PUT | AUTHED-USER | Replace key |
| `/api/keys/<idx>` | DELETE | AUTHED-USER | Remove key |
| `/api/keys/validate` | POST | AUTHED-USER | Validate all keys |
| `/api/keys/<idx>/validate` | POST | AUTHED-USER | Validate one key |
| `/api/keys/reload` | POST | AUTHED-USER | Reload from env |
| `/api/nav-i/api-keys/list` | GET | AUTHED-USER | Nav-I key listing |
| `/api/nav-i/api-keys/update` | POST | AUTHED-USER | Update key |
| `/api/nav-i/api-keys/test` | POST | AUTHED-USER | Test key |
| `/api/nav-i/api-keys/restart-recon` | POST | AUTHED-USER | Restart service |
| `/api/cookies/status` | GET | AUTHED-USER | Cookie freshness |
| `/api/cookies/upload` | POST | AUTHED-USER | Upload cookies |
| `/api/vpn/*` | * | AUTHED-USER | VPN control (all 5 routes) |
| `/api/peertube/channels/stats` | GET | PUBLIC | Channel stats |
| `/api/peertube/channels` | GET | PUBLIC | Channel listing |
| `/api/peertube/channels/add` | POST | AUTHED-USER | Add channel |
| `/api/peertube/channels/<name>` | DELETE | AUTHED-USER | Remove channel |
| `/api/peertube/dashboard` | GET | PUBLIC | Cached dashboard |
| `/api/kiwix/sources` | GET | PUBLIC | ZIM source listing |
| `/api/kiwix/toggle-ingest/<id>` | POST | AUTHED-USER | Toggle ingest |
| `/api/kiwix/trigger-ingest/<id>` | POST | AUTHED-USER | Trigger ingest |
| `/api/kiwix/upload` | POST | AUTHED-USER | ZIM upload (disk usage) |
| `/api/kiwix/remove/<id>` | POST | AUTHED-USER | Remove ZIM |
| `/api/scraper/submit` | POST | AUTHED-USER | Submit scrape job |
| `/api/scraper/jobs` | GET | PUBLIC | Job listing |
| `/api/scraper/cancel/<id>` | POST | AUTHED-USER | Cancel job |
| `/api/scraper/retry/<id>` | POST | AUTHED-USER | Retry job |
| `/api/scraper/delete/<id>` | POST | AUTHED-USER | Delete job |
| `/api/scraper/clear-failed` | POST | AUTHED-USER | Clear failed jobs |
| `/api/metrics/history` | GET | PUBLIC | Metrics history |
### 2.2 Contacts API (contacts_api.py) — ALL AUTHED-USER
All routes use `@require_auth` decorator. Per-user phone book.
| Route | Method | Classification |
|-------|--------|----------------|
| `/api/contacts` | GET | AUTHED-USER |
| `/api/contacts` | POST | AUTHED-USER |
| `/api/contacts/nearby` | GET | AUTHED-USER |
| `/api/contacts/deleted` | GET | AUTHED-USER |
| `/api/contacts/<id>` | GET | AUTHED-USER |
| `/api/contacts/<id>` | PATCH | AUTHED-USER |
| `/api/contacts/<id>` | DELETE | AUTHED-USER |
| `/api/contacts/<id>/restore` | POST | AUTHED-USER |
| `/api/contacts/<id>/restore-as` | POST | AUTHED-USER |
| `/api/contacts/<id>/purge` | DELETE | AUTHED-USER |
### 2.3 Address Book API (address_book_api.py) — ALL PUBLIC
Static address lookups from YAML. No auth required.
| Route | Method | Classification |
|-------|--------|----------------|
| `/api/address_book/lookup` | GET | PUBLIC |
| `/api/address_book/list` | GET | PUBLIC |
### 2.4 Geocode API (netsyms_api.py) — ALL PUBLIC
Local Photon geocoding and Netsyms address lookup.
| Route | Method | Classification |
|-------|--------|----------------|
| `/api/netsyms/lookup` | GET | PUBLIC |
| `/api/netsyms/health` | GET | PUBLIC |
| `/api/geocode` | GET | PUBLIC |
| `/api/reverse` | GET | PUBLIC |
### 2.5 Routes NOT on RECON Backend
The following routes mentioned in the original spec do not exist on RECON:
- **`/api/photon/*`** — Photon is accessed via `/api/geocode` and `/api/reverse`
- **`/api/nominatim/*`** — Nominatim is accessed via `/api/place` internally
- **`/api/valhalla/*`** — Valhalla is called directly by Aurora (cortex), not proxied through RECON
- **`/api/overture/*`** — Overture is an enrichment layer inside `place_detail.py`, not a direct route
- **`/api/padus/*`** — PAD-US is accessed via `/api/landclass`
- **`/api/aurora/*`** — Aurora runs on Open WebUI (cortex), not on RECON
---
## 3. Current Auth Architecture
### 3.1 Authentik Forward Auth (CT 101 Caddy)
**Current state:** Unable to read Caddy config from CT 101 (SSH access issue during doc creation).
**Expected configuration:**
- `navi.echo6.co` domain likely uses `forward_auth` directive to Authentik
- All paths currently gated at Caddy level
- Authentik application name: likely "navi" or "recon"
### 3.2 X-Authentik-Username Header
The backend checks this header in two places:
**auth.py:**
```python
def get_user_id():
"""Return X-Authentik-Username or None."""
return request.headers.get('X-Authentik-Username')
def require_auth(f):
"""Decorator: 401 if no Authentik auth header."""
@wraps(f)
def wrapper(*args, **kwargs):
user_id = get_user_id()
if not user_id:
return jsonify({'error': 'Authentication required'}), 401
request.user_id = user_id
return f(*args, **kwargs)
return wrapper
```
**Usage:**
- `contacts_api.py` — ALL routes use `@require_auth`
- `api.py``get_user_id()` called in `/deleted-contacts` and `/nav-i` page handlers, but not enforced (returns "anonymous" if missing)
### 3.3 Public Exception: recon-watchdog
The monitoring system uses `localhost:8420` to bypass Caddy/Authentik entirely when polling health endpoints.
---
## 4. Proposed Caddy Changes
### 4.1 Path-Based Forward Auth Split
```caddyfile
navi.echo6.co {
# Static frontend assets — PUBLIC
handle /assets/* {
reverse_proxy 192.168.1.130:8420
}
# Public API routes — no auth required
handle_path /api/geocode* {
reverse_proxy 192.168.1.130:8420
}
handle_path /api/reverse* {
reverse_proxy 192.168.1.130:8420
}
handle_path /api/address_book/* {
reverse_proxy 192.168.1.130:8420
}
handle_path /api/netsyms/* {
reverse_proxy 192.168.1.130:8420
}
handle_path /api/place/* {
reverse_proxy 192.168.1.130:8420
}
handle_path /api/landclass* {
reverse_proxy 192.168.1.130:8420
}
handle_path /api/config* {
reverse_proxy 192.168.1.130:8420
}
handle_path /api/health* {
reverse_proxy 192.168.1.130:8420
}
handle_path /api/kiwix/sources* {
reverse_proxy 192.168.1.130:8420
}
# Auth-required routes — forward_auth first
handle /api/contacts/* {
forward_auth {
uri /outpost.goauthentik.io/auth/caddy
copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Email
}
reverse_proxy 192.168.1.130:8420
}
handle /api/keys/* {
forward_auth {
uri /outpost.goauthentik.io/auth/caddy
copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Email
}
reverse_proxy 192.168.1.130:8420
}
handle /api/nav-i/* {
forward_auth {
uri /outpost.goauthentik.io/auth/caddy
copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Email
}
reverse_proxy 192.168.1.130:8420
}
handle /api/traffic/* {
forward_auth {
uri /outpost.goauthentik.io/auth/caddy
copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Email
}
reverse_proxy 192.168.1.130:8420
}
# ... additional auth-required paths ...
# Default: public (frontend pages)
handle {
reverse_proxy 192.168.1.130:8420
}
}
```
### 4.2 Alternative: Path Matchers
For cleaner config, use path matchers:
```caddyfile
@public_api path /api/geocode* /api/reverse* /api/address_book/* /api/netsyms/* /api/place/* /api/landclass* /api/config* /api/health* /api/kiwix/sources*
@authed_api path /api/contacts/* /api/keys/* /api/nav-i/* /api/traffic/* /api/upload /api/ingest* /api/crawl /api/service/* /api/cookies/* /api/vpn/*
```
---
## 5. Proposed RECON Backend Changes
### 5.1 Routes Needing Explicit Auth Checks
Currently these routes do not enforce auth but should:
| Route | Current State | Required Change |
|-------|---------------|-----------------|
| `/api/upload` POST | No check | Add `@require_auth` |
| `/api/ingest-url` POST | No check | Add `@require_auth` |
| `/api/ingest-urls` POST | No check | Add `@require_auth` |
| `/api/crawl` POST | No check | Add `@require_auth` |
| `/api/retry-all` POST | No check | Add `@require_auth` |
| `/api/retry/<hash>` POST | No check | Add `@require_auth` |
| `/api/ingest` POST | No check | Add `@require_auth` |
| `/api/service/restart` POST | No check | Add `@require_auth` |
| `/api/keys/*` | No check | Add `@require_auth` |
| `/api/nav-i/api-keys/*` | No check | Add `@require_auth` |
| `/api/cookies/*` | No check | Add `@require_auth` |
| `/api/vpn/*` | No check | Add `@require_auth` |
| `/api/peertube/channels/add` POST | No check | Add `@require_auth` |
| `/api/peertube/channels/<name>` DELETE | No check | Add `@require_auth` |
| `/api/kiwix/toggle-ingest/*` POST | No check | Add `@require_auth` |
| `/api/kiwix/trigger-ingest/*` POST | No check | Add `@require_auth` |
| `/api/kiwix/upload` POST | No check | Add `@require_auth` |
| `/api/kiwix/remove/*` POST | No check | Add `@require_auth` |
| `/api/scraper/submit` POST | No check | Add `@require_auth` |
| `/api/scraper/cancel/*` POST | No check | Add `@require_auth` |
| `/api/scraper/retry/*` POST | No check | Add `@require_auth` |
| `/api/scraper/delete/*` POST | No check | Add `@require_auth` |
| `/api/scraper/clear-failed` POST | No check | Add `@require_auth` |
| `/api/traffic/flow/*` | No check | Add `@require_auth` |
### 5.2 Implementation Pattern
For routes that currently assume auth, make explicit:
```python
from .auth import require_auth
@app.route('/api/upload', methods=['POST'])
@require_auth
def api_upload():
# request.user_id now guaranteed to be set
...
```
### 5.3 Routes That Can Remain Public
These need no changes — no user state, no cost:
- `/api/geocode`, `/api/reverse` (Photon — local)
- `/api/address_book/*` (YAML lookup — local)
- `/api/netsyms/*` (SQLite — local)
- `/api/place/*` (Nominatim/Overpass — local/free, but see Section 8)
- `/api/landclass` (DuckDB — local)
- `/api/config` (static config)
- `/api/health` (health check)
- `/api/kiwix/sources` (read-only listing)
- `/api/search` (local embedding + Qdrant)
- `/api/knowledge-stats`, `/api/quick-stats` (cached stats)
- `/api/peertube/dashboard`, `/api/peertube/stats`, `/api/peertube/channels` GET (read-only)
- `/api/scraper/jobs` GET (read-only listing)
- `/api/metrics/history` (read-only)
---
## 6. Frontend Auth-Aware UX
### 6.1 Options Considered
| Option | Description | Pros | Cons |
|--------|-------------|------|------|
| **A. Inline auth modal** | Intercept 401, show login modal, retry | Smooth UX, no context loss | Complex state management |
| **B. Redirect to Authentik** | On 401, redirect to `/outpost.goauthentik.io/start` | Simple, Authentik handles flow | Loses frontend context/state |
| **C. Visually gate features** | Hide/disable authed features until logged in | Prevents 401 entirely | Requires knowing auth state upfront |
### 6.2 Recommendation: Option C (Visually Gate) + B (Fallback)
**Primary:** Check auth state on page load via presence of `X-Authentik-Username` cookie or a `/api/whoami` endpoint. Show authed features only when logged in.
**Fallback:** If a 401 occurs unexpectedly, redirect to Authentik with return URL.
**Implementation sketch:**
```javascript
// On app init
async function checkAuth() {
const resp = await fetch('/api/whoami'); // new endpoint
if (resp.ok) {
const { username, groups } = await resp.json();
window.authState = { authenticated: true, username, groups };
} else {
window.authState = { authenticated: false };
}
renderAuthAwareUI();
}
```
**New backend endpoint:**
```python
@app.route('/api/whoami')
def api_whoami():
user_id = get_user_id()
if not user_id:
return jsonify({'authenticated': False}), 200
return jsonify({
'authenticated': True,
'username': user_id,
'groups': request.headers.get('X-Authentik-Groups', '').split(','),
})
```
### 6.3 UI Patterns
- **Contacts panel:** Hidden entirely when unauthenticated
- **API keys button:** Hidden when unauthenticated
- **Traffic layer toggle:** Hidden or shows "Login for traffic data"
- **Upload button:** Shows "Login to upload" when clicked unauthenticated
---
## 7. Rate Limiting Plan
### 7.1 Caddy Rate Limit Configuration
Use `rate_limit` directive for public endpoints:
```caddyfile
@public_api path /api/geocode* /api/reverse* /api/place/* ...
handle @public_api {
rate_limit {
zone geocode {
key {remote_host}
events 60
window 60s
}
}
reverse_proxy 192.168.1.130:8420
}
```
### 7.2 Proposed Thresholds
| Endpoint Group | Requests/min | Burst | Rationale |
|----------------|--------------|-------|-----------|
| `/api/geocode`, `/api/reverse` | 60/min | 10 | Typical map interaction |
| `/api/place/*` | 30/min | 5 | Heavy backend processing |
| `/api/landclass` | 30/min | 5 | DuckDB query |
| `/api/search` | 20/min | 3 | Embedding + vector search |
| `/api/kiwix/sources` | 10/min | 2 | Cached, rarely changes |
| `/api/address_book/*` | 120/min | 20 | Very cheap lookup |
### 7.3 429 Response Shape
```json
{
"error": "Rate limit exceeded",
"retry_after": 45,
"limit": "60/min"
}
```
Include `Retry-After` header.
---
## 8. Mixed-Route Handling: /api/place
### 8.1 Current Behavior
`/api/place/<osm_type>/<osm_id>` performs:
1. Local Nominatim lookup (free)
2. Local Overture enrichment (free)
3. **Google Places enrichment** (paid) — fills phone, website, hours for business POIs
### 8.2 Options Considered
| Option | Description | Pros | Cons |
|--------|-------------|------|------|
| **A. Return partial, flag missing** | Return OSM+Overture data, add `enrichment_available: "google"` flag | Clean public API | Frontend complexity |
| **B. Split routes** | `/api/place/basic/...` and `/api/place/enriched/...` | Clear API contract | Breaking change |
| **C. Auth-conditional enrichment** | Check `X-Authentik-Username`, skip Google if missing | Backward compatible | Inconsistent response shape |
### 8.3 Recommendation: Option C (Auth-Conditional)
**Implementation:**
```python
# In place_detail.py
def _enrich_with_google(result, osm_type, osm_id):
# Check auth header
from flask import request
user_id = request.headers.get('X-Authentik-Username')
if not user_id:
# Add metadata indicating what is missing
result.setdefault('sources', {})['google_places'] = {
'status': 'auth_required',
'reason': 'Google Places enrichment requires authentication'
}
return result
# ... existing enrichment logic ...
```
**Response shape (unauthenticated):**
```json
{
"osm_type": "N",
"osm_id": 12345,
"name": "Boise Coffee House",
"sources": {
"primary": "nominatim_local",
"enrichment": "overture",
"google_places": {
"status": "auth_required",
"reason": "Google Places enrichment requires authentication"
}
}
}
```
**Response shape (authenticated):**
```json
{
"osm_type": "N",
"osm_id": 12345,
"name": "Boise Coffee House",
"extratags": {
"phone": "+1-208-555-0123",
"opening_hours": "Mo-Fr 06:00-18:00"
},
"sources": {
"primary": "nominatim_local",
"enrichment": "overture",
"google_places": {
"place_id": "ChIJ...",
"source": "api"
}
}
}
```
---
## 9. Aurora Handling
### 9.1 Current Architecture
Aurora runs on Open WebUI (cortex), NOT on the RECON backend. It is accessed via:
- `aurora.echo6.co` (web interface)
- Mesh bridge integration (future)
Aurora calls RECON APIs (geocode, search) but is not exposed through navi.echo6.co.
### 9.2 Classification
**AUTHED** — Aurora is compute-expensive (LLM inference on cortex GPU).
### 9.3 Future Consideration: Aurora-Lite
Out of scope for this design, but noted for future:
> Rate-limited "Aurora-lite" public endpoint with tight token budget per IP.
> Could expose `/api/aurora/ask` with:
> - 5 requests/hour/IP
> - Max 100 input tokens
> - Predefined safe prompts only (directions, place info)
---
## 10. Migration Plan
### Phase 1: Backend Auth Enforcement
**Risk:** Low
**Rollback:** Revert `@require_auth` decorators
1. Add `@require_auth` to all identified routes (Section 5.1)
2. Add `/api/whoami` endpoint
3. Test with existing Caddy config (all routes still gated)
4. Deploy to VM 130
5. Verify Authentik headers flow correctly
### Phase 2: Caddy Public Exceptions
**Risk:** Medium
**Rollback:** Remove path exceptions from Caddy config
1. Update CT 101 Caddyfile with public path exceptions
2. Keep sensitive paths behind forward_auth
3. Test unauthenticated access to public endpoints
4. Verify authenticated endpoints still require login
### Phase 3: Frontend Auth-Aware UI
**Risk:** Low
**Rollback:** N/A (additive change)
1. Implement `checkAuth()` on frontend
2. Conditionally render authed features
3. Add 401 handling with Authentik redirect
4. Deploy frontend changes
### Phase 4: Rate Limiting
**Risk:** Low
**Rollback:** Remove rate_limit directives
1. Add rate_limit zones to Caddy config
2. Start with high limits, monitor
3. Tune thresholds based on traffic patterns
4. Add monitoring/alerting for 429s
### Phase 5: DNS/CDN Consideration
**Risk:** Low
**Rollback:** DNS change
1. Evaluate Cloudflare for static asset caching
2. Consider geographic distribution for tile proxying
3. Out of scope for this design — separate decision
---
## 11. Open Questions
### Q1: Caddy Configuration Access
**Issue:** Unable to SSH to CT 101 to read current Caddyfile during doc creation.
**Action needed:** User to provide current navi.echo6.co Caddy block or SSH access path.
### Q2: TomTom Traffic Tile Cost Model
**Issue:** Unknown per-tile cost for traffic API.
**Decision needed:** Is auth + rate limiting sufficient, or should traffic be entirely disabled for public users?
### Q3: Knowledge Dashboard Admin Actions
**Issue:** Several routes like `/api/retry-all` are admin-only but do not have role checks.
**Decision needed:** Is `@require_auth` sufficient, or should we add `@require_admin` that checks `X-Authentik-Groups`?
### Q4: Upload Size Limits
**Issue:** `/api/upload` and `/api/kiwix/upload` accept large files.
**Decision needed:** Should public users (if upload ever becomes public) have stricter size limits than authenticated users?
### Q5: Existing Session State
**Issue:** What happens to in-progress operations (crawls, scraper jobs) when auth is enforced?
**Decision needed:** Grandfather existing jobs or require re-auth?
### Q6: Rate Limit Per-User vs Per-IP
**Issue:** Authenticated users could get higher limits.
**Decision needed:** Implement tiered rate limiting based on auth state?
---
## Appendix A: Route Summary by Classification
### PUBLIC (28 routes)
- `/api/geocode`, `/api/reverse`, `/api/address_book/*`, `/api/netsyms/*`
- `/api/place/*` (basic), `/api/landclass`, `/api/config`, `/api/health`
- `/api/kiwix/sources`, `/api/search`, `/api/knowledge-stats`, `/api/quick-stats`
- `/api/peertube/dashboard`, `/api/peertube/stats`, `/api/peertube/channels` GET
- `/api/scraper/jobs`, `/api/metrics/history`, `/api/status`
- `/api/upload/<hash>/status`, `/api/upload/categories`
- `/api/crawl/<id>/status`, `/api/ingest-peertube/<id>/status`
- All page routes except settings/nav-i
### AUTHED-USER (35 routes)
- `/api/contacts/*` (10 routes)
- `/api/keys/*` (7 routes)
- `/api/nav-i/*` (4 routes)
- `/api/cookies/*` (2 routes)
- `/api/vpn/*` (5 routes)
- `/api/upload`, `/api/ingest-*`, `/api/crawl` POST
- `/api/peertube/channels/add`, `/api/peertube/channels/<name>` DELETE
- `/api/kiwix/toggle-ingest/*`, `/api/kiwix/trigger-ingest/*`, `/api/kiwix/upload`, `/api/kiwix/remove/*`
- `/api/scraper/submit`, `/api/scraper/cancel/*`, `/api/scraper/retry/*`, `/api/scraper/delete/*`, `/api/scraper/clear-failed`
- `/api/service/restart`, `/api/retry-all`, `/api/retry/<hash>`, `/api/ingest`
- Settings pages, Nav-I pages
### AUTHED-COST (1 route)
- `/api/traffic/flow/*` — TomTom paid API
### MIXED (1 route)
- `/api/place/*` — Returns OSM+Overture freely, Google enrichment requires auth
---
## Appendix B: Files to Modify
### Backend (VM 130)
- `/opt/recon/lib/api.py` — Add `@require_auth` to 25+ routes
- `/opt/recon/lib/place_detail.py` — Auth-conditional Google enrichment
- `/opt/recon/lib/auth.py` — No changes needed (already has `require_auth`)
### Caddy (CT 101)
- `/etc/caddy/Caddyfile` — Path-based forward_auth split + rate limiting
### Frontend (TBD)
- Auth state detection
- Conditional feature rendering
- 401 handling