/**
* Auth Routes — Challenge-response, profile, username
*
* Endpoints:
* - POST /api/auth/challenge — Get challenge for signing
* - GET /api/user/profile — Get current user profile
* - PATCH /api/user/profile — Update display name
* - POST /api/user/username — Set username
* - GET /api/user/username/:username/available — Check username availability
*
* NOTE: Keypair generation is done CLIENT-SIDE in the browser using @noble/ed25519.
* The secret key never touches the server.
*/
import { json, readJson } from '../response.js'
import { UnauthorizedError, ValidationError, ConflictError } from '../errors.js'
/**
* Auth helper — authenticate request, throw on failure
*/
async function requireAuth (req, server) {
const auth = await server.authManager.authenticate(req)
if (!auth) throw new UnauthorizedError('Authentication required')
return auth
}
/**
* Register all auth-related routes
*/
export function registerAuthRoutes (router, server) {
// POST /api/auth/challenge — Get challenge for signing
// No auth required. Rate limited (auth bucket).
router.post('/api/auth/challenge', async (req, res) => {
const clientIp = server.authManager.getClientIp(req)
const rateLimit = await server.authManager.rateLimiter.check(clientIp, 'auth')
if (!rateLimit.allowed) {
json(res, {
error: 'Rate limit exceeded',
retryAfter: rateLimit.retryAfter
}, 429)
return
}
const challenge = await server.authManager.generateChallenge()
json(res, {
challengeId: challenge.challengeId,
challenge: challenge.challenge,
expiresAt: challenge.expiresAt
})
})
// GET /api/user/profile — Get current user profile
// Auth required.
router.get('/api/user/profile', async (req, res) => {
const auth = await requireAuth(req, server)
// For bearer token auth, return token info
if (auth.method === 'bearer-token') {
json(res, {
userId: null,
username: auth.username || null,
method: auth.method,
permissions: auth.permissions
})
return
}
// For identity-based auth, return full user profile
const user = auth.user
if (!user) {
json(res, {
userId: auth.userId,
username: null,
method: auth.method,
permissions: auth.permissions
})
return
}
const identities = await server.authManager.identityRegistry.listUserIdentities(user.userId)
json(res, {
userId: user.userId,
username: user.username || null,
name: user.name,
theme: user.theme || 'dark',
method: auth.method,
permissions: auth.permissions,
identities: identities.map(i => ({
identityId: i.identityId,
method: i.method,
createdAt: i.createdAt
})),
createdAt: user.createdAt
})
})
// PATCH /api/user/profile — Update display name
// Auth required.
router.patch('/api/user/profile', async (req, res) => {
const auth = await requireAuth(req, server)
if (!auth.userId) {
throw new ValidationError('Cannot update profile for token-based auth')
}
const body = await readJson(req)
const updates = {}
if (body.name !== undefined) updates.name = String(body.name).trim()
if (body.theme !== undefined) {
const validThemes = ['light', 'dark', 'dark-blue']
if (!validThemes.includes(body.theme)) {
throw new ValidationError('Invalid theme. Must be one of: ' + validThemes.join(', '), 'theme')
}
updates.theme = body.theme
}
if (Object.keys(updates).length === 0) {
throw new ValidationError('No fields to update')
}
const user = await server.authManager.identityRegistry.updateUserProfile(auth.userId, updates)
json(res, {
userId: user.userId,
username: user.username || null,
name: user.name,
theme: user.theme || 'dark'
})
})
// POST /api/user/username — Set username (one-time or update)
// Auth required.
router.post('/api/user/username', async (req, res) => {
const auth = await requireAuth(req, server)
if (!auth.userId) {
throw new ValidationError('Cannot set username for token-based auth')
}
const body = await readJson(req)
const username = body.username
try {
const user = await server.authManager.identityRegistry.setUsername(auth.userId, username)
// Setting a username promotes user to write — they can create their own notebooks
if (!user.globalPermissions.includes('write')) {
await server.authManager.identityRegistry.upgradePermissions(auth.userId, '*', ['write'])
}
json(res, { username: user.username })
} catch (err) {
if (err.code === 'CONFLICT') {
throw new ConflictError('Username already taken', 'username')
}
if (err.code === 'VALIDATION_ERROR') {
throw new ValidationError(err.message, 'username')
}
throw err
}
})
// POST /api/admin/promote — Promote a user (admin only)
// Auth required. Caller must be admin.
router.post('/api/admin/promote', async (req, res) => {
const auth = await requireAuth(req, server)
if (!auth.permissions.admin) {
json(res, { error: 'Admin permission required' }, 403)
return
}
const body = await readJson(req)
const targetUserId = body.userId
const role = body.role || 'write' // 'read', 'write', or 'admin'
if (!targetUserId) {
throw new ValidationError('userId is required')
}
const validRoles = { read: ['read'], write: ['read', 'write'], admin: ['read', 'write', 'admin'] }
const perms = validRoles[role]
if (!perms) {
throw new ValidationError('role must be read, write, or admin')
}
const registry = server.authManager.identityRegistry
const user = await registry.upgradePermissions(targetUserId, '*', perms)
json(res, {
userId: user.userId,
username: user.username,
globalPermissions: user.globalPermissions,
role
})
})
// GET /api/admin/users — List all users (admin only)
router.get('/api/admin/users', async (req, res) => {
const auth = await requireAuth(req, server)
if (!auth.permissions.admin) {
json(res, { error: 'Admin permission required' }, 403)
return
}
const registry = server.authManager.identityRegistry
const users = []
for await (const entry of registry.db.createReadStream({ gte: 'user:', lt: 'user:\xff' })) {
const u = entry.value
if (u.mergedInto) continue // Skip merged accounts
users.push({
userId: u.userId,
username: u.username,
name: u.name,
globalPermissions: u.globalPermissions,
identityCount: (u.identities || []).length,
createdAt: u.createdAt
})
}
json(res, { users })
})
// POST /api/admin/demote — Demote a user's role (admin only)
router.post('/api/admin/demote', async (req, res) => {
const auth = await requireAuth(req, server)
if (!auth.permissions.admin) {
json(res, { error: 'Admin permission required' }, 403)
return
}
const body = await readJson(req)
const targetUserId = body.userId
const role = body.role || 'read'
if (!targetUserId) throw new ValidationError('userId is required')
if (targetUserId === auth.userId) throw new ValidationError('Cannot demote yourself')
const validRoles = { read: ['read'], write: ['read', 'write'] }
const perms = validRoles[role]
if (!perms) throw new ValidationError('role must be read or write')
const registry = server.authManager.identityRegistry
const user = await registry.getUser(targetUserId)
if (!user) throw new ValidationError('User not found')
user.globalPermissions = perms
user.notebooks = [{ name: '*', permissions: perms }]
user.updatedAt = Date.now()
await registry.db.put(`user:${targetUserId}`, user)
json(res, {
userId: user.userId,
username: user.username,
globalPermissions: user.globalPermissions,
role
})
})
// DELETE /api/admin/users/:userId — Delete a user (admin only)
router.delete('/api/admin/users/:userId', async (req, res) => {
const auth = await requireAuth(req, server)
if (!auth.permissions.admin) {
json(res, { error: 'Admin permission required' }, 403)
return
}
const targetUserId = req.params.userId
if (targetUserId === auth.userId) {
throw new ValidationError('Cannot delete yourself')
}
const registry = server.authManager.identityRegistry
const user = await registry.getUser(targetUserId)
if (!user) throw new ValidationError('User not found')
// Soft-delete only. Hard `db.del` of identity/user rows destroyed 28
// accounts historically — deactivateUser revokes the identities (blocking
// auth) and flags the user while preserving every record for audit/restore.
await registry.deactivateUser(targetUserId, { by: auth.userId, reason: 'admin deactivation' })
json(res, { ok: true, deactivated: targetUserId })
})
// POST /api/admin/users/:userId/reactivate — Reverse a soft-delete (admin only)
router.post('/api/admin/users/:userId/reactivate', async (req, res) => {
const auth = await requireAuth(req, server)
if (!auth.permissions.admin) {
json(res, { error: 'Admin permission required' }, 403)
return
}
const registry = server.authManager.identityRegistry
const user = await registry.getUser(req.params.userId)
if (!user) throw new ValidationError('User not found')
await registry.reactivateUser(req.params.userId)
json(res, { ok: true, reactivated: req.params.userId })
})
// POST /api/admin/notebooks/:notebook/transfer — Transfer notebook ownership (admin only)
router.post('/api/admin/notebooks/:notebook/transfer', async (req, res) => {
const auth = await requireAuth(req, server)
if (!auth.permissions.admin) {
json(res, { error: 'Admin permission required' }, 403)
return
}
const body = await readJson(req)
const newOwnerId = body.ownerId
if (!newOwnerId) throw new ValidationError('ownerId is required')
const registry = server.authManager.identityRegistry
const newOwner = await registry.getUser(newOwnerId)
if (!newOwner) throw new ValidationError('Target user not found')
const nm = server.noteManager
const notebook = await nm.getNotebook(req.params.notebook)
if (!notebook) throw new ValidationError('Notebook not found')
notebook.ownerId = newOwnerId
notebook.updatedAt = Date.now()
const drive = await server.driveManager.getDrive(req.params.notebook)
await drive.put('/metadata.json', Buffer.from(JSON.stringify(notebook)))
json(res, notebook)
})
// GET /api/admin/stats — Server stats (admin only)
router.get('/api/admin/stats', async (req, res) => {
const auth = await requireAuth(req, server)
if (!auth.permissions.admin) {
json(res, { error: 'Admin permission required' }, 403)
return
}
const registry = server.authManager.identityRegistry
const users = await registry.listAllUsers()
const notebooks = await server.noteManager.listNotebooks()
let totalNotes = 0
for (const nb of notebooks) totalNotes += nb.noteCount || 0
json(res, {
userCount: users.length,
notebookCount: notebooks.length,
noteCount: totalNotes,
users: users.filter(u => !u.mergedInto && !u.deactivated).length,
publicNotebooks: notebooks.filter(nb => nb.visibility !== 'private' && !nb.encrypted).length,
privateNotebooks: notebooks.filter(nb => nb.visibility === 'private').length
})
})
// GET /api/user/username/:username/available — Check username availability
// No auth required.
router.get('/api/user/username/:username/available', async (req, res) => {
const username = req.params.username
const validation = server.authManager.identityRegistry.validateUsername(username)
if (!validation.valid) {
json(res, { available: false, reason: validation.reason })
return
}
const available = await server.authManager.identityRegistry.isUsernameAvailable(username)
json(res, { available })
})
// GET /api/users — Public user directory (only users with usernames)
// No auth required.
router.get('/api/users', async (req, res) => {
const registry = server.authManager.identityRegistry
const users = []
for await (const entry of registry.db.createReadStream({ gte: 'user:', lt: 'user:\xff' })) {
const u = entry.value
if (!u.username) continue // Only show users who have set a username
if (u.deactivated || u.mergedInto) continue // Hide soft-deleted/merged accounts
users.push({
userId: u.userId,
username: u.username,
name: u.name || 'Anonymous',
createdAt: u.createdAt
})
}
json(res, { users })
})
}