OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-note / lib / routes / auth.js

383 lines · javascript ref:
/**
 * 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 })
  })
}