App Developer Docs
Authentication
Sulci uses a magic-link-first auth model. New users never see a password prompt. Returning users can sign in via either password or a fresh magic link.
New user flow
1. Onboarding page ──► Enter email ──► POST /api/auth/request-magic-link
2. Server creates user record (needs_password_setup = 1)
3. Server inserts magic session token (type = 'magic', expires 24h)
4. Email sent with: GET /api/auth/magic?token=<token>
5. User clicks link ──► server checks token, sees needs_password_setup = 1
6. Redirect to /#/setup-password?token=<token>
7. User sets password ──► POST /api/auth/setup-password
8. Server: saves bcrypt hash, clears needs_password_setup, burns magic token
9. Server: creates real session token (type = 'session', expires 72h)
10. Set-Cookie: session_token=<token>; HttpOnly
11. Redirect to /#/chat
Returning user — magic link
1. Login page, "Email me a link" tab
2. POST /api/auth/request-magic-link ──► user found, magic token created
3. Email sent ──► user clicks link
4. GET /api/auth/magic?token=<token>
5. Server sees needs_password_setup = 0 ──► burn token
6. Create real session, set cookie
7. Redirect to /#/chat
Returning user — password
1. Login page, "Password" tab
2. POST /api/auth/login { email, password }
3. Server: looks up user by email, bcrypt.compare(password, hash)
4. On match: create session token, set cookie, return user payload
5. Frontend: stores user in Pinia, router.push('/chat')
Session validation
Every protected route calls getSessionUser(token) from db.js:
- Look up token in
sessionstable - If missing: return null → 401
- If
expires_atis in the past: delete row, return null → 401 - Return the associated user row
Sessions expire after 72 hours. There is no refresh — the user signs in again when the session expires.
Cookies
The session_token cookie is set with:
HttpOnly: true — not accessible from JavaScript
SameSite: Lax — sent on same-site navigations including magic link redirects
Secure: false (dev) — set COOKIE_SECURE=true in production
MaxAge: 72 hours
Path: /
The /api/auth/me endpoint
Every page load, the frontend calls GET /api/auth/me (via auth.fetchMe() in App.vue’s onMounted). If the response is 200, the user is logged in; if it’s 401, they’re redirected to onboarding.
This is the only auth check — there is no middleware that guards routes. The api/client.js 401 interceptor handles the redirect client-side.
Token storage
Tokens are random 64-character hex strings (randomBytes(32).toString('hex')). They’re stored in the sessions table. There is no JWT — the server always validates against the database.
Frontend auth store
// stores/auth.js
const user = ref(null) // null = not logged in
const loading = ref(true) // true while fetchMe() is in flight
await fetchMe() // called once on app mount
await login(email, password)
await logout() // DELETE cookie server-side, null user client-side
The loading state prevents the app from rendering (or redirecting) before the auth check completes, avoiding flash-of-unauthenticated-content.