feat: guest passage — slip through as a wanderer (Workstream A)

POST /auth/guest creates a real User row (wanderer-<hex>, unusable random
password) and issues the normal session cookie, so every downstream system
— essence, codex, devices — works for guests unmodified. Per-IP limited
(5/hour, Cloudflare-aware) with username-collision retry. EnterPage gains
the wanderer button; the séance shows a dismissible claim-a-name nudge
keyed off the username prefix. The auth gate on /seance is untouched —
the guest button is the path through, not around.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Indiana
2026-07-28 17:18:46 +00:00
10 changed files with 275 additions and 7 deletions

View File

@@ -1,3 +1,4 @@
import secrets
from datetime import datetime, timezone from datetime import datetime, timezone
from fastapi import APIRouter, Cookie, Depends, HTTPException, Request, Response, status from fastapi import APIRouter, Cookie, Depends, HTTPException, Request, Response, status
@@ -9,6 +10,7 @@ from app.deps import SESSION_COOKIE_NAME, get_current_user
from app.models.auth_session import AuthSession, SESSION_TTL, generate_session_token, hash_token from app.models.auth_session import AuthSession, SESSION_TTL, generate_session_token, hash_token
from app.models.unlock import UnlockRecord from app.models.unlock import UnlockRecord
from app.models.user import User from app.models.user import User
from app.rate_limit import RateLimiter, resolve_client_ip
from app.schemas import LoginRequest, RegisterRequest, UserOut from app.schemas import LoginRequest, RegisterRequest, UserOut
from app.security import hash_password, verify_password from app.security import hash_password, verify_password
@@ -16,6 +18,15 @@ router = APIRouter(prefix="/auth", tags=["auth"])
_DUMMY_PASSWORD_HASH = hash_password("dummy-password-for-timing-safety") _DUMMY_PASSWORD_HASH = hash_password("dummy-password-for-timing-safety")
# Guest creation writes a real user row per call — without a per-IP cap a
# single client could fill the users table. resolve_client_ip (not the raw
# socket peer) because internet traffic arrives via the Cloudflare Tunnel.
guest_limiter = RateLimiter(max_requests=5, window_seconds=3600)
# 4 hex chars = 65k names; a full retry budget failing means the wanderer
# namespace is effectively exhausted, not that we got unlucky.
_GUEST_NAME_ATTEMPTS = 8
@router.post("/register", response_model=UserOut, status_code=status.HTTP_201_CREATED) @router.post("/register", response_model=UserOut, status_code=status.HTTP_201_CREATED)
async def register(payload: RegisterRequest, db: AsyncSession = Depends(get_db)): async def register(payload: RegisterRequest, db: AsyncSession = Depends(get_db)):
@@ -34,6 +45,63 @@ async def register(payload: RegisterRequest, db: AsyncSession = Depends(get_db))
return user return user
@router.post("/guest", response_model=UserOut, status_code=status.HTTP_201_CREATED)
async def guest(
request: Request,
response: Response,
db: AsyncSession = Depends(get_db),
):
client_ip = resolve_client_ip(
request.headers, request.client.host if request.client else None
)
if not guest_limiter.allow(client_ip):
raise HTTPException(
status.HTTP_429_TOO_MANY_REQUESTS,
"the veil admits only so many wanderers — return later",
)
for _ in range(_GUEST_NAME_ATTEMPTS):
username = f"wanderer-{secrets.token_hex(2)}"
existing = await db.scalar(select(User).where(User.username == username))
if existing is None:
break
else:
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
"the mist is too crowded — try again",
)
# A guest is a real user: the password is random and never disclosed, so
# the row is unreachable via /auth/login but works everywhere else.
user = User(
username=username,
password_hash=hash_password(secrets.token_urlsafe(32)),
)
db.add(user)
await db.commit()
await db.refresh(user)
raw_token, token_hash = generate_session_token()
session = AuthSession(
user_id=user.id,
token_hash=token_hash,
expires_at=datetime.now(timezone.utc) + SESSION_TTL,
)
db.add(session)
await db.commit()
# Same dual-scheme cookie rule as /auth/login (https tunnel vs LAN http).
response.set_cookie(
SESSION_COOKIE_NAME,
raw_token,
httponly=True,
samesite="lax",
secure=request.url.scheme == "https",
max_age=int(SESSION_TTL.total_seconds()),
)
return user
@router.post("/login", response_model=UserOut) @router.post("/login", response_model=UserOut)
async def login( async def login(
payload: LoginRequest, payload: LoginRequest,

View File

@@ -0,0 +1,60 @@
import pytest
import app.routes.auth as auth_module
@pytest.fixture(autouse=True)
def _reset_guest_limiter():
# The limiter is module-level state; a previous test's hits would bleed
# into the next one's per-IP budget.
auth_module.guest_limiter._hits.clear()
yield
@pytest.mark.asyncio
async def test_guest_creates_wanderer_and_cookie_works_on_me(client):
response = await client.post("/auth/guest")
assert response.status_code == 201
body = response.json()
assert body["username"].startswith("wanderer-")
assert "password" not in body
assert "qm_session" in response.cookies
me_resp = await client.get("/auth/me")
assert me_resp.status_code == 200
assert me_resp.json()["username"] == body["username"]
@pytest.mark.asyncio
async def test_guest_cannot_login_with_any_password(client):
response = await client.post("/auth/guest")
username = response.json()["username"]
login_resp = await client.post(
"/auth/login", json={"username": username, "password": "anythingatall"}
)
assert login_resp.status_code == 401
@pytest.mark.asyncio
async def test_guest_rate_limit_fires_per_ip(client):
for _ in range(5):
response = await client.post("/auth/guest")
assert response.status_code == 201
response = await client.post("/auth/guest")
assert response.status_code == 429
@pytest.mark.asyncio
async def test_guest_username_collision_retries(client, monkeypatch):
taken_resp = await client.post("/auth/guest")
taken = taken_resp.json()["username"].removeprefix("wanderer-")
# First attempt collides with the existing wanderer; the retry must land
# on the fresh suffix instead of erroring out.
suffixes = iter([taken, "f4ee"])
monkeypatch.setattr(
auth_module.secrets, "token_hex", lambda n: next(suffixes)
)
response = await client.post("/auth/guest")
assert response.status_code == 201
assert response.json()["username"] == "wanderer-f4ee"

View File

@@ -40,6 +40,10 @@ export function login(username: string, password: string): Promise<User> {
}) })
} }
export function guest(): Promise<User> {
return request<User>('/auth/guest', { method: 'POST' })
}
export function logout(): Promise<void> { export function logout(): Promise<void> {
return request<void>('/auth/logout', { method: 'POST' }) return request<void>('/auth/logout', { method: 'POST' })
} }

View File

@@ -79,7 +79,8 @@
"switchToRegister": "no name in the registry? first contact →", "switchToRegister": "no name in the registry? first contact →",
"switchToLogin": "already carved your name? reconnect →", "switchToLogin": "already carved your name? reconnect →",
"failed": "the veil rejected you: {{message}}", "failed": "the veil rejected you: {{message}}",
"smallPrint": "your medium handle is only ever stored on this server" "smallPrint": "your medium handle is only ever stored on this server",
"guest": "slip through as a wanderer"
}, },
"seance": { "seance": {
"modes": { "modes": {
@@ -272,6 +273,11 @@
"entity": "entity", "entity": "entity",
"jitter": "jitter" "jitter": "jitter"
} }
},
"wanderer": {
"note": "your contacts fade with the mist —",
"claim": "claim a name to keep your codex",
"dismiss": "let the mist take it"
} }
}, },
"codex": { "codex": {

View File

@@ -79,7 +79,8 @@
"switchToRegister": "¿sin nombre en el registro? primer contacto →", "switchToRegister": "¿sin nombre en el registro? primer contacto →",
"switchToLogin": "¿ya grabaste tu nombre? reconectar →", "switchToLogin": "¿ya grabaste tu nombre? reconectar →",
"failed": "el velo te rechazó: {{message}}", "failed": "el velo te rechazó: {{message}}",
"smallPrint": "tu nombre de médium solo se guarda en este servidor" "smallPrint": "tu nombre de médium solo se guarda en este servidor",
"guest": "escabúllete como errante"
}, },
"seance": { "seance": {
"modes": { "modes": {
@@ -272,6 +273,11 @@
"entity": "entidad", "entity": "entidad",
"jitter": "fluctuación" "jitter": "fluctuación"
} }
},
"wanderer": {
"note": "tus contactos se desvanecen con la niebla —",
"claim": "reclama un nombre para conservar tu códice",
"dismiss": "deja que la niebla se lo lleve"
} }
}, },
"codex": { "codex": {

View File

@@ -257,6 +257,38 @@
cursor: wait; cursor: wait;
} }
.enter-guest {
display: block;
width: 100%;
margin-top: 0.9rem;
font-family: 'Cinzel', serif;
font-size: 0.72rem;
letter-spacing: 0.2em;
text-transform: uppercase;
color: rgba(216, 230, 221, 0.6);
background: transparent;
border: 1px dashed rgba(216, 230, 221, 0.25);
border-radius: 0;
padding: 0.65rem 1rem;
cursor: pointer;
transition: color 0.25s ease, border-color 0.25s ease;
}
.enter-guest:hover:not(:disabled) {
color: #8dffc0;
border-color: rgba(124, 255, 178, 0.55);
}
.enter-guest:focus-visible {
outline: 2px solid #b26bff;
outline-offset: 3px;
}
.enter-guest:disabled {
opacity: 0.55;
cursor: wait;
}
.enter-smallprint { .enter-smallprint {
margin: 1.6rem 0 0; margin: 1.6rem 0 0;
text-align: center; text-align: center;

View File

@@ -13,7 +13,7 @@ type EnterMode = 'login' | 'register'
export function EnterPage() { export function EnterPage() {
const { t } = useTranslation() const { t } = useTranslation()
const { user, login, register } = useAuth() const { user, login, register, guest } = useAuth()
const navigate = useNavigate() const navigate = useNavigate()
const [mode, setMode] = useState<EnterMode>('login') const [mode, setMode] = useState<EnterMode>('login')
@@ -22,8 +22,10 @@ export function EnterPage() {
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
// Already seated at the table — no need to knock again. // Already seated at the table — no need to knock again. Wanderers are the
if (user) { // exception: the séance's claim-a-name note sends them here to register,
// so they must be allowed to stand at the threshold while still seated.
if (user && !user.username.startsWith('wanderer-')) {
return <Navigate to="/seance" replace /> return <Navigate to="/seance" replace />
} }
@@ -118,6 +120,25 @@ export function EnterPage() {
</button> </button>
</form> </form>
<button
type="button"
className="enter-guest"
disabled={busy}
onClick={() => {
if (busy) return
setError(null)
setBusy(true)
guest()
.then(() => navigate('/seance'))
.catch((err) => {
setError(err instanceof Error ? err.message : String(err))
setBusy(false)
})
}}
>
{t('enter.guest')}
</button>
<p className="enter-smallprint"> <p className="enter-smallprint">
{t('enter.smallPrint', { {t('enter.smallPrint', {
defaultValue: 'your medium handle is only ever stored on this server', defaultValue: 'your medium handle is only ever stored on this server',

View File

@@ -329,6 +329,50 @@
/* ---------- connection banner ---------- */ /* ---------- connection banner ---------- */
.seance-wanderer-note {
position: absolute;
top: 62px;
left: 50%;
transform: translateX(-50%);
z-index: 29;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.9rem;
background: rgba(16, 16, 26, 0.92);
border: 1px dashed rgba(216, 230, 221, 0.3);
border-radius: 3px;
color: rgba(216, 230, 221, 0.65);
font-size: 0.72rem;
letter-spacing: 0.06em;
white-space: nowrap;
}
.seance-wanderer-note a {
color: #8dffc0;
text-decoration: none;
border-bottom: 1px dotted rgba(141, 255, 192, 0.5);
}
.seance-wanderer-note a:hover {
border-bottom-style: solid;
}
.wanderer-note-dismiss {
margin-left: 0.2rem;
background: transparent;
border: none;
color: rgba(216, 230, 221, 0.45);
font-size: 0.9rem;
line-height: 1;
cursor: pointer;
padding: 0 0.2rem;
}
.wanderer-note-dismiss:hover {
color: #d8e6dd;
}
.seance-conn-banner { .seance-conn-banner {
position: absolute; position: absolute;
top: 62px; top: 62px;

View File

@@ -108,6 +108,10 @@ function SeanceSession({ username }: { username: string }) {
const { t, i18n } = useTranslation() const { t, i18n } = useTranslation()
const [departed, setDeparted] = useState(false) const [departed, setDeparted] = useState(false)
// Guests are recognized by their generated name prefix — no backend flag.
// Dismissal is per-visit on purpose: the nudge should return next séance.
const isWanderer = username.startsWith('wanderer-')
const [wandererNoteDismissed, setWandererNoteDismissed] = useState(false)
const [question, setQuestion] = useState('') const [question, setQuestion] = useState('')
const [dossierOpen, setDossierOpen] = useState(true) const [dossierOpen, setDossierOpen] = useState(true)
const [view, setView] = useState<BoardView>('board') const [view, setView] = useState<BoardView>('board')
@@ -306,6 +310,22 @@ function SeanceSession({ username }: { username: string }) {
</div> </div>
)} )}
{/* ---- wanderer note: guests can claim a permanent name ---- */}
{isWanderer && !wandererNoteDismissed && (
<div className="seance-wanderer-note" role="note">
<span>{t('seance.wanderer.note')}</span>{' '}
<Link to="/enter">{t('seance.wanderer.claim')}</Link>
<button
type="button"
className="wanderer-note-dismiss"
aria-label={t('seance.wanderer.dismiss')}
onClick={() => setWandererNoteDismissed(true)}
>
×
</button>
</div>
)}
{/* ---- main: board + side column ---- */} {/* ---- main: board + side column ---- */}
<main className="seance-main"> <main className="seance-main">
<section className="seance-board-wrap"> <section className="seance-board-wrap">

View File

@@ -11,6 +11,7 @@ export type AuthApi = {
checking: boolean checking: boolean
login: (username: string, password: string) => Promise<User> login: (username: string, password: string) => Promise<User>
register: (username: string, password: string) => Promise<User> register: (username: string, password: string) => Promise<User>
guest: () => Promise<User>
logout: () => Promise<void> logout: () => Promise<void>
} }
@@ -42,14 +43,20 @@ export function AuthProvider({ children }: { children: ReactNode }) {
[login], [login],
) )
const guest = useCallback(async () => {
const u = await api.guest()
setUser(u)
return u
}, [])
const logout = useCallback(async () => { const logout = useCallback(async () => {
await api.logout() await api.logout()
setUser(null) setUser(null)
}, []) }, [])
const value = useMemo<AuthApi>( const value = useMemo<AuthApi>(
() => ({ user, checking, login, register, logout }), () => ({ user, checking, login, register, guest, logout }),
[user, checking, login, register, logout], [user, checking, login, register, guest, logout],
) )
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider> return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
} }