feat: session mode parsing, memory-only cookies, single-user mode

- COOKIE_SECURE env now accepts: 'true' (secure), 'false' (memory-only), 'single_user' (no cookie)
- config.parse_cookie_mode() returns (COOKIE_SECURE, SINGLE_USER) tuple for routing
- single_user_owner() returns oldest Owner for test/local deployments
- session cookie respects mode: Max-Age only in secure mode, dropped for memory-only
- base.html renders ephemeral-session and single-user banners per mode
- Tests: comprehensive coverage for all three modes with monkeypatch configs
- CLAUDE.md + README docs updated

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 09:21:18 +02:00
co-authored by Claude Haiku 4.5
parent a8bcf7cdeb
commit f70ba93e7a
9 changed files with 231 additions and 12 deletions
+16 -1
View File
@@ -3,10 +3,11 @@ from __future__ import annotations
from pathlib import Path
from fastapi import APIRouter, Form, Request
from fastapi import APIRouter, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
import imptune.config as cfg
from imptune.db.models import Owner
from imptune.services.session import COOKIE_NAME, cookie_kwargs, is_same_origin
@@ -17,9 +18,21 @@ templates = Jinja2Templates(
)
def _require_cookie_sessions() -> None:
"""404 these routes in single-user mode — keys have no meaning without a cookie.
Restoring one could not re-point anything, and downloading one would hand
out the shared owner's bearer key, which turns into a live credential the
moment the deployment is switched back to a cookie-scoped mode.
"""
if cfg.SINGLE_USER:
raise HTTPException(status_code=404, detail="Session keys are disabled in single-user mode")
@router.get("/key/download")
def download_key(request: Request) -> PlainTextResponse:
"""Mark the current owner permanent and hand back its key as a backup file."""
_require_cookie_sessions()
owner: Owner = request.state.owner
if not owner.is_permanent:
owner.is_permanent = True
@@ -33,6 +46,7 @@ def download_key(request: Request) -> PlainTextResponse:
@router.get("/restore", response_class=HTMLResponse)
def restore_page(request: Request, error: str = "") -> HTMLResponse:
_require_cookie_sessions()
return templates.TemplateResponse(
request=request,
name="session_restore.html",
@@ -43,6 +57,7 @@ def restore_page(request: Request, error: str = "") -> HTMLResponse:
@router.post("/restore")
def restore_session(request: Request, key: str = Form(...)):
"""Re-associate this browser with a previously downloaded backup key."""
_require_cookie_sessions()
if not is_same_origin(request):
return templates.TemplateResponse(
request=request,