feat: guest passage — slip through as a wanderer

POST /auth/guest mints a real user row (wanderer-<4 hex>, collision
retry, unusable random password) and issues the normal session cookie,
per-IP rate limited at 5/hour. EnterPage gains the guest action;
the séance shows a dismissible claim-a-name note for wanderer- users.

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

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> {
return request<void>('/auth/logout', { method: 'POST' })
}

View File

@@ -79,7 +79,8 @@
"switchToRegister": "no name in the registry? first contact →",
"switchToLogin": "already carved your name? reconnect →",
"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": {
"modes": {
@@ -272,6 +273,11 @@
"entity": "entity",
"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": {

View File

@@ -79,7 +79,8 @@
"switchToRegister": "¿sin nombre en el registro? primer contacto →",
"switchToLogin": "¿ya grabaste tu nombre? reconectar →",
"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": {
"modes": {
@@ -272,6 +273,11 @@
"entity": "entidad",
"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": {

View File

@@ -257,6 +257,38 @@
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 {
margin: 1.6rem 0 0;
text-align: center;

View File

@@ -13,7 +13,7 @@ type EnterMode = 'login' | 'register'
export function EnterPage() {
const { t } = useTranslation()
const { user, login, register } = useAuth()
const { user, login, register, guest } = useAuth()
const navigate = useNavigate()
const [mode, setMode] = useState<EnterMode>('login')
@@ -22,8 +22,10 @@ export function EnterPage() {
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
// Already seated at the table — no need to knock again.
if (user) {
// Already seated at the table — no need to knock again. Wanderers are the
// 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 />
}
@@ -118,6 +120,25 @@ export function EnterPage() {
</button>
</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">
{t('enter.smallPrint', {
defaultValue: 'your medium handle is only ever stored on this server',

View File

@@ -329,6 +329,50 @@
/* ---------- 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 {
position: absolute;
top: 62px;

View File

@@ -108,6 +108,10 @@ function SeanceSession({ username }: { username: string }) {
const { t, i18n } = useTranslation()
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 [dossierOpen, setDossierOpen] = useState(true)
const [view, setView] = useState<BoardView>('board')
@@ -306,6 +310,22 @@ function SeanceSession({ username }: { username: string }) {
</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 className="seance-main">
<section className="seance-board-wrap">

View File

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