The JWT already carried a `tier` claim but nothing on the server read it
— AuthGate enforcement was client-only, so a valid JWT could hit paid
LLM/research endpoints regardless of the user's access tier.
- shared-hono authMiddleware now extracts `tier` into `c.userTier`,
defaulting unknown/missing claims to `public` (never silently grants
higher access).
- New `requireTier(minTier)` middleware + `hasTier`/`getTierLevel`
helpers. Tier hierarchy (guest < public < beta < alpha < founder) is
mirrored locally to avoid pulling the Svelte-facing shared-branding
package into Bun services.
- Applied `requireTier('beta')` as defense-in-depth on resource-heavy
apps/api modules (chat, context, food, guides, news-research, picture,
plants, research, traces, who) and the MCP endpoint. Pure CRUD modules
stay auth-only — access there is gated by ownership, not tier.
- DEV_BYPASS_AUTH now injects `userTier` (defaults to founder, override
via DEV_USER_TIER).
- Authentication guideline documents the pattern + test suite covers
hierarchy, passes-at-minimum, and rejection paths.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
26 KiB
Authentication Guidelines
Overview
All authentication is handled by Mana Core Auth, a centralized authentication service using Better Auth with EdDSA JWT tokens.
Architecture
┌─────────────────┐ ┌─────────────────┐ ┌──────────────────┐
│ Web/Mobile │────>│ Compute Server │────>│ mana-auth │
│ Client │ │ (Hono/Bun) │ │ (port 3001) │
└─────────────────┘ └─────────────────┘ └──────────────────┘
│ │ │
│ 1. Login │ │
│─────────────────────────────────────────────>│
│ │ │
│ 2. JWT Token │ │
│<─────────────────────────────────────────────│
│ │ │
│ 3. API Request │ │
│ + Bearer Token │ │
│──────────────────────>│ │
│ │ │
│ │ 4. Validate Token │
│ │──────────────────────>│
│ │ │
│ │ 5. {valid, payload} │
│ │<──────────────────────│
│ │ │
│ 6. Response │ │
│<──────────────────────│ │
User ID Format
CRITICAL: Mana Core Auth uses Better Auth, which generates non-UUID user IDs.
Example user ID: otUe1YrfENPdHnrF3g1vSBfpkQfambCZ
Format details:
- 32 characters
- Base62 alphabet (a-z, A-Z, 0-9)
- ~190 bits of entropy (more than UUID's 122 bits)
- NOT a valid UUID format
Database schema implications:
// CORRECT - use text for user_id
userId: text('user_id').notNull(),
// WRONG - will cause "invalid input syntax for type uuid" errors
userId: uuid('user_id').notNull(),
Always use text type for user_id columns in all database schemas.
Token Structure (EdDSA JWT)
{
"sub": "otUe1YrfENPdHnrF3g1vSBfpkQfambCZ",
"email": "user@example.com",
"role": "user",
"sid": "session-id-456",
"iat": 1701234567,
"exp": 1701238167,
"iss": "manacore",
"aud": "manacore"
}
Note: The sub claim contains the Better Auth user ID (not a UUID).
Important: Keep claims minimal. Do NOT include:
- Credit balance (changes frequently)
- Organization data (use API instead)
- Feature flags
- Other dynamic data
Shared Packages
| Package | Purpose | Use Case |
|---|---|---|
@manacore/shared-hono |
Hono auth middleware + helpers | All compute servers (Hono/Bun) |
@manacore/shared-auth |
Client auth service | Web/Mobile apps |
Server Integration (Hono/Bun)
All compute servers use @manacore/shared-hono:
import { Hono } from 'hono';
import { authMiddleware, healthRoute, errorHandler, notFoundHandler } from '@manacore/shared-hono';
const app = new Hono();
app.onError(errorHandler);
app.notFound(notFoundHandler);
app.route('/health', healthRoute('my-server'));
// Protect all /api/* routes
app.use('/api/*', authMiddleware());
// Access user in route handlers
app.get('/api/v1/data', (c) => {
const userId = c.get('userId'); // Better Auth user ID (not UUID)
const email = c.get('userEmail');
return c.json({ userId });
});
Note: Arcade's server (
@arcade/server) does not require auth — game generation and community submission are public endpoints.
Environment Variables
# Required for all servers
MANA_CORE_AUTH_URL=http://localhost:3001
# Development bypass (optional)
NODE_ENV=development
DEV_BYPASS_AUTH=true
DEV_USER_ID=dev-user-123
# For credit operations (when using nestjs-integration)
MANA_CORE_SERVICE_KEY=your-service-key
APP_ID=your-app-id
Client Integration (Web)
Runtime URL Injection (Recommended for Docker)
For Docker deployments, use runtime URL injection instead of build-time environment variables:
Step 1: Server Hook (hooks.server.ts)
// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
export const handle: Handle = async ({ event, resolve }) => {
return resolve(event, {
transformPageChunk: ({ html }) =>
html.replace(
'%RUNTIME_ENV%',
`<script>
window.__PUBLIC_AUTH_URL__ = "${env.PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001'}";
window.__PUBLIC_BACKEND_URL__ = "${env.PUBLIC_BACKEND_URL || 'http://localhost:3000'}";
</script>`
),
});
};
Step 2: Update app.html
<!DOCTYPE html>
<html lang="en">
<head>
%sveltekit.head%
%RUNTIME_ENV%
</head>
<body>
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
Step 3: URL Helper (url.ts)
// src/lib/config/url.ts
import { browser } from '$app/environment';
import { PUBLIC_MANA_CORE_AUTH_URL, PUBLIC_BACKEND_URL } from '$env/static/public';
declare global {
interface Window {
__PUBLIC_AUTH_URL__?: string;
__PUBLIC_BACKEND_URL__?: string;
}
}
export function getAuthUrl(): string {
if (browser && window.__PUBLIC_AUTH_URL__) {
return window.__PUBLIC_AUTH_URL__;
}
return PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
}
export function getBackendUrl(): string {
if (browser && window.__PUBLIC_BACKEND_URL__) {
return window.__PUBLIC_BACKEND_URL__;
}
return PUBLIC_BACKEND_URL || 'http://localhost:3000';
}
Standard Auth Store Pattern (Svelte 5)
// src/lib/stores/auth.svelte.ts
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { initializeWebAuth } from '@manacore/shared-auth';
import { getAuthUrl } from '$lib/config/url';
// Lazy initialization - only create when needed (browser only)
let _authService: ReturnType<typeof initializeWebAuth>['authService'] | null = null;
function getAuthService() {
if (!browser) return null;
if (!_authService) {
const auth = initializeWebAuth({ baseUrl: getAuthUrl() });
_authService = auth.authService;
}
return _authService;
}
// Svelte 5 reactive state
let user = $state<User | null>(null);
let accessToken = $state<string | null>(null);
let loading = $state(true);
let initialized = $state(false);
// Initialize auth on app start
async function initialize() {
if (!browser || initialized) return;
const authService = getAuthService();
if (!authService) {
loading = false;
return;
}
try {
const currentUser = await authService.getCurrentUser();
if (currentUser) {
user = currentUser;
// Use getValidToken() for auto-refresh, NOT getAccessToken()
accessToken = await authService.getValidToken();
}
} catch (error) {
console.error('Auth initialization failed:', error);
user = null;
accessToken = null;
} finally {
loading = false;
initialized = true;
}
}
// Get a valid token (with auto-refresh if expired)
async function getValidToken(): Promise<string | null> {
const authService = getAuthService();
if (!authService) return null;
try {
return await authService.getValidToken();
} catch {
// Token refresh failed, user needs to re-login
await logout();
return null;
}
}
// DEPRECATED: Use getValidToken() instead
function getAccessToken(): string | null {
console.warn('getAccessToken() is deprecated. Use getValidToken() for auto-refresh.');
return accessToken;
}
async function login(email: string, password: string): Promise<boolean> {
const authService = getAuthService();
if (!authService) return false;
try {
const result = await authService.signIn({ email, password });
user = result.user;
accessToken = result.accessToken;
return true;
} catch {
return false;
}
}
async function logout() {
const authService = getAuthService();
if (authService) {
try {
await authService.signOut();
} catch {
// Ignore logout errors
}
}
user = null;
accessToken = null;
goto('/login');
}
export const authStore = {
// Getters (reactive)
get user() { return user; },
get loading() { return loading; },
get isAuthenticated() { return !!accessToken && !!user; },
get initialized() { return initialized; },
// Methods
initialize,
login,
logout,
getValidToken, // RECOMMENDED
getAccessToken, // DEPRECATED
};
Key Best Practices
| Practice | Description |
|---|---|
| Lazy Initialization | Only create auth service when needed (browser check) |
Use getValidToken() |
Auto-refreshes expired tokens, unlike getAccessToken() |
| Runtime URL Injection | Enables Docker deployments without rebuild |
| SSR Guard | Always check browser before accessing auth service |
| Initialized Flag | Prevents double initialization |
Basic Setup (Static URLs)
For simpler deployments without Docker, use static environment variables:
// src/lib/stores/auth.svelte.ts
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { initializeWebAuth } from '@manacore/shared-auth';
import { PUBLIC_MANA_CORE_AUTH_URL } from '$env/static/public';
const AUTH_URL = PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
// Lazy initialize to avoid SSR issues
let _authService: ReturnType<typeof initializeWebAuth>['authService'] | null = null;
function getAuthService() {
if (!browser) return null;
if (!_authService) {
const auth = initializeWebAuth({ baseUrl: AUTH_URL });
_authService = auth.authService;
}
return _authService;
}
// State
let user = $state<User | null>(null);
let token = $state<string | null>(null);
let loading = $state(true);
// Initialize on app start
async function initialize() {
if (!browser) return;
const authService = getAuthService();
if (!authService) return;
const currentUser = await authService.getCurrentUser();
if (currentUser) {
user = currentUser;
token = await authService.getAccessToken();
}
loading = false;
}
// Actions
async function login(email: string, password: string): Promise<boolean> {
const authService = getAuthService();
if (!authService) return false;
try {
const result = await authService.signIn({ email, password });
user = result.user;
token = result.accessToken;
return true;
} catch {
return false;
}
}
async function logout() {
const authService = getAuthService();
if (authService) {
await authService.signOut();
}
user = null;
token = null;
goto('/login');
}
export const authStore = {
get user() { return user; },
get token() { return token; },
get loading() { return loading; },
get isAuthenticated() { return !!token; },
initialize,
login,
logout,
};
Protected Routes
<!-- src/routes/(protected)/+layout.svelte -->
<script lang="ts">
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
import { onMount } from 'svelte';
let { children } = $props();
onMount(() => {
authStore.initialize();
});
$effect(() => {
if (browser && !authStore.loading && !authStore.isAuthenticated) {
goto('/login');
}
});
</script>
{#if authStore.loading}
<LoadingScreen />
{:else if authStore.isAuthenticated}
{@render children()}
{/if}
API Requests with Token
// src/lib/api/client.ts
import { authStore } from '$lib/stores/auth.svelte';
import { goto } from '$app/navigation';
import { PUBLIC_BACKEND_URL } from '$env/static/public';
async function request<T>(endpoint: string, options: RequestInit = {}): Promise<Result<T>> {
const token = authStore.token;
const response = await fetch(`${PUBLIC_BACKEND_URL}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.headers,
},
});
// Handle 401 - session expired
if (response.status === 401) {
authStore.logout();
goto('/login');
return { ok: false, error: { code: 'ERR_2000', message: 'Session expired' } };
}
const json = await response.json();
return json.ok ? { ok: true, data: json.data } : { ok: false, error: json.error };
}
Client Integration (Mobile)
Auth Provider
// context/AuthProvider.tsx
import { createContext, useContext, useState, useEffect } from 'react';
import * as SecureStore from 'expo-secure-store';
import { initializeMobileAuth } from '@manacore/shared-auth';
import Constants from 'expo-constants';
const AUTH_URL = Constants.expoConfig?.extra?.authUrl ?? 'http://localhost:3001';
const TOKEN_KEY = 'mana_auth_token';
const USER_KEY = 'mana_auth_user';
interface AuthContextType {
user: User | null;
token: string | null;
loading: boolean;
login: (email: string, password: string) => Promise<boolean>;
logout: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadStoredAuth();
}, []);
async function loadStoredAuth() {
try {
const storedToken = await SecureStore.getItemAsync(TOKEN_KEY);
const storedUser = await SecureStore.getItemAsync(USER_KEY);
if (storedToken && storedUser) {
// Validate token is still valid
const isValid = await validateToken(storedToken);
if (isValid) {
setToken(storedToken);
setUser(JSON.parse(storedUser));
} else {
// Token expired, clear storage
await SecureStore.deleteItemAsync(TOKEN_KEY);
await SecureStore.deleteItemAsync(USER_KEY);
}
}
} finally {
setLoading(false);
}
}
async function validateToken(token: string): Promise<boolean> {
try {
const response = await fetch(`${AUTH_URL}/api/v1/auth/validate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
});
const result = await response.json();
return result.valid === true;
} catch {
return false;
}
}
async function login(email: string, password: string): Promise<boolean> {
try {
const response = await fetch(`${AUTH_URL}/api/v1/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const result = await response.json();
if (result.accessToken && result.user) {
await SecureStore.setItemAsync(TOKEN_KEY, result.accessToken);
await SecureStore.setItemAsync(USER_KEY, JSON.stringify(result.user));
setToken(result.accessToken);
setUser(result.user);
return true;
}
return false;
} catch {
return false;
}
}
async function logout() {
await SecureStore.deleteItemAsync(TOKEN_KEY);
await SecureStore.deleteItemAsync(USER_KEY);
setToken(null);
setUser(null);
}
return (
<AuthContext.Provider value={{ user, token, loading, login, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) throw new Error('useAuth must be within AuthProvider');
return context;
}
Auth Endpoints
Mana Core Auth API
| Endpoint | Method | Description |
|---|---|---|
/api/v1/auth/register |
POST | Register new user |
/api/v1/auth/login |
POST | Login, returns JWT |
/api/v1/auth/logout |
POST | Logout, invalidates session |
/api/v1/auth/validate |
POST | Validate JWT token |
/api/v1/auth/refresh |
POST | Refresh access token |
/api/v1/auth/me |
GET | Get current user |
/api/v1/auth/jwks |
GET | Get JWKS for token verification |
Request/Response Examples
Register
POST /api/v1/auth/register
{
"email": "user@example.com",
"password": "securepassword123",
"name": "John Doe"
}
Response:
{
"user": { "id": "...", "email": "...", "name": "..." },
"accessToken": "eyJ...",
"refreshToken": "..."
}
Login
POST /api/v1/auth/login
{
"email": "user@example.com",
"password": "securepassword123"
}
Response:
{
"user": { "id": "...", "email": "...", "name": "..." },
"accessToken": "eyJ...",
"refreshToken": "..."
}
Validate Token
POST /api/v1/auth/validate
{
"token": "eyJ..."
}
Response:
{
"valid": true,
"payload": {
"sub": "user-id",
"email": "user@example.com",
"role": "user",
"sid": "session-id"
}
}
Server-Side Tier Gating
The JWT carries a tier claim (guest | public | beta | alpha | founder) sourced from auth.users.access_tier. Client-side AuthGate enforcement is not enough — a user can still call the API directly with their token. For any endpoint that consumes shared infrastructure (LLM calls, external search, image/video generation), add a server-side requireTier gate on top of authMiddleware.
import { authMiddleware, requireTier } from '@mana/shared-hono';
app.use('/api/*', authMiddleware());
app.use('/api/v1/research/*', requireTier('beta'));
app.use('/api/v1/picture/*', requireTier('beta'));
Rules:
- Apply at the module-group level in
index.ts, not inside handlers — easy to audit in one place. requireTieralways runs afterauthMiddleware; it relies on theuserTiercontext variable the middleware sets.- Missing / unknown
tierclaims default topublic, so a malformed JWT cannot accidentally grantalpha. - Pure CRUD modules that only expose a user's own records don't need a tier gate — the access check is ownership, not tier.
DEV_BYPASS_AUTH=truesetsuserTier=founderby default; override withDEV_USER_TIER=<tier>when testing rejection paths locally.
Development Bypass
For local development, you can bypass auth:
DEV_BYPASS_AUTH=true
DEV_USER_ID=dev-user-123
DEV_USER_TIER=founder # optional — defaults to founder
The guard will inject a mock user:
// From JwtAuthGuard when bypass is enabled
request.user = {
userId: process.env.DEV_USER_ID || 'dev-user',
email: 'dev@example.com',
role: 'user',
tier: process.env.DEV_USER_TIER || 'founder',
};
Testing with Auth
Unit Tests
// Mock the guard
const module = await Test.createTestingModule({
controllers: [FileController],
providers: [FileService],
})
.overrideGuard(JwtAuthGuard)
.useValue({ canActivate: () => true })
.compile();
// Mock user in controller tests
const mockUser = { userId: 'test-user', email: 'test@example.com', role: 'user' };
await controller.listFiles(mockUser);
E2E Tests
// Get a real token
const loginResponse = await request(app.getHttpServer())
.post('/api/v1/auth/login')
.send({ email: 'test@example.com', password: 'password' });
const token = loginResponse.body.accessToken;
// Use token in requests
await request(app.getHttpServer())
.get('/api/v1/files')
.set('Authorization', `Bearer ${token}`)
.expect(200);
SvelteKit Auth Routes Checklist
When creating a new SvelteKit web app, ALL of the following auth routes MUST be implemented:
Required Routes (in src/routes/(auth)/)
| Route | Page | Component | Store Method |
|---|---|---|---|
/login |
login/+page.svelte |
LoginPage from @manacore/shared-auth-ui |
authStore.signIn() |
/register |
register/+page.svelte |
RegisterPage from @manacore/shared-auth-ui |
authStore.signUp() |
/forgot-password |
forgot-password/+page.svelte |
ForgotPasswordPage from @manacore/shared-auth-ui |
authStore.resetPassword() |
/reset-password |
reset-password/+page.svelte |
Custom form (token from URL) | authStore.resetPasswordWithToken() |
Required Auth Store Methods
The auth.svelte.ts store MUST implement these methods using @manacore/shared-auth:
| Method | Shared Auth Method | Purpose |
|---|---|---|
signIn(email, password) |
authService.signIn() |
Login |
signUp(email, password) |
authService.signUp() |
Register |
signOut() |
authService.signOut() |
Logout |
resetPassword(email) |
authService.forgotPassword() |
Send reset email |
resetPasswordWithToken(token, pw) |
authService.resetPassword() |
Reset with token |
resendVerificationEmail(email) |
authService.resendVerificationEmail() |
Resend verification |
getValidToken() |
tokenManager.getValidToken() |
Get valid JWT |
getAuthHeaders() |
Direct localStorage read | Get Authorization header |
Login Page Template
<script lang="ts">
import { goto } from '$app/navigation';
import { LoginPage } from '@manacore/shared-auth-ui';
import { getLoginTranslations } from '@manacore/shared-i18n';
import { YourAppLogo } from '@manacore/shared-branding';
import { authStore } from '$lib/stores/auth.svelte';
const translations = getLoginTranslations('en');
async function handleSignIn(email: string, password: string) {
return authStore.signIn(email, password);
}
</script>
<LoginPage
appName="YourApp"
logo={YourAppLogo}
primaryColor="#your-color"
onSignIn={handleSignIn}
{goto}
forgotPasswordPath="/forgot-password"
registerPath="/register"
{translations}
/>
Forgot Password Page Template
<script lang="ts">
import { goto } from '$app/navigation';
import { ForgotPasswordPage } from '@manacore/shared-auth-ui';
import { getForgotPasswordTranslations } from '@manacore/shared-i18n';
import { YourAppLogo } from '@manacore/shared-branding';
import { authStore } from '$lib/stores/auth.svelte';
const translations = getForgotPasswordTranslations('en');
async function handleForgotPassword(email: string) {
return authStore.resetPassword(email);
}
</script>
<ForgotPasswordPage
appName="YourApp"
logo={YourAppLogo}
primaryColor="#your-color"
onForgotPassword={handleForgotPassword}
{goto}
loginPath="/login"
{translations}
/>
Reset Password Page
The reset password page is a custom implementation (not a shared component) because it handles token validation from URL params. See apps/calendar/apps/web/src/routes/(auth)/reset-password/+page.svelte as reference.
Key requirements:
- Read
tokenfrom$page.url.searchParams - Validate password length (min 8 chars) and match
- Call
authStore.resetPasswordWithToken(token, password) - Show success state and redirect to
/loginafter 3 seconds - Show invalid token state with link to
/forgot-password
Security Considerations
-
Store tokens securely
- Web: HttpOnly cookies or memory (not localStorage)
- Mobile: SecureStore (not AsyncStorage)
-
Token refresh
- Access tokens expire in 1 hour
- Use refresh tokens to get new access tokens
- Handle 401 responses gracefully
-
CORS configuration
- Only allow known origins
- Include credentials for cookie-based auth
-
Never trust client data
- Always validate token server-side
- Use
@CurrentUser()decorator, not request body
Debugging
Token not validating?
# 1. Check algorithm (should be EdDSA)
echo $TOKEN | cut -d'.' -f1 | base64 -d
# 2. Check JWKS endpoint
curl http://localhost:3001/api/v1/auth/jwks
# 3. Check issuer/audience
# Should match between signing and validation
401 errors?
- Check token exists in Authorization header
- Check token format:
Bearer <token> - Check token hasn't expired
- Check MANA_CORE_AUTH_URL is correct
Auth UX Patterns
Email Verification Flow
Rule: Distinguish all email-related error states, never show a generic error.
| User Action | State | Expected UX |
|---|---|---|
| Register with new email | needsVerification: true |
Green "Check your email" panel + Resend button |
| Register with existing unverified email | EMAIL_ALREADY_REGISTERED |
Amber warning panel + Resend button + "Sign in instead" link |
| Register with existing verified email | EMAIL_ALREADY_REGISTERED |
Same amber panel — user should click "Sign in instead" |
| Login with unverified email | EMAIL_NOT_VERIFIED |
Inline error + Resend button (not generic "invalid credentials") |
| Login with wrong password | INVALID_CREDENTIALS |
Inline error — no resend button |
Implementation:
- Backend (
auth.ts): catchUSER_ALREADY_EXISTSfrom Better Auth → return{ code: 'EMAIL_ALREADY_REGISTERED' }(409) - Backend (
auth.ts): catchEMAIL_NOT_VERIFIED/status: 'FORBIDDEN'on login → return{ code: 'EMAIL_NOT_VERIFIED' }(403) authService.ts: map both codes to typed error strings, never swallow as generic errorRegisterPage:emailAlreadyRegisteredstate triggers amber panel with dual CTAsLoginPage:showEmailNotVerifiedstate triggers resend button below error message
Key principle: A user who registered but never verified should always be able to request a new verification email, from both the login and register pages, without knowing which page to go to.
Verification Email Redirect
The verification email must redirect back to the source app, not to auth.mana.how/.
sourceAppStore.set(email, sourceAppUrl)is called beforesignUpEmailand beforesendVerificationEmail- The
sendVerificationEmailcallback inbetter-auth.config.tssetscallbackURL(notredirectTo) on the verification URL - Better Auth uses
callbackURLfor the post-verification redirect;redirectTois ignored sourceAppUrlcomes fromwindow.location.originin the browser (set bycreateManaAuthStore)