/**
 * EncryptID Server
 *
 * Handles WebAuthn registration/authentication, session management,
 * and serves the .well-known/webauthn configuration.
 *
 * Storage: PostgreSQL (via db.ts)
 */

import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { logger } from 'hono/logger';
import { serveStatic } from 'hono/bun';
import { sign, verify } from 'hono/jwt';
import { createTransport, type Transporter } from 'nodemailer';
import {
  initDatabase,
  storeCredential,
  getCredential,
  updateCredentialUsage,
  getUserCredentials,
  storeChallenge,
  getChallenge,
  deleteChallenge,
  cleanExpiredChallenges,
  cleanExpiredRecoveryTokens,
  checkDatabaseHealth,
  createUser,
  setUserEmail,
  getUserByEmail,
  getUserById,
  getUserByUsername,
  storeRecoveryToken,
  getRecoveryToken,
  markRecoveryTokenUsed,
  type StoredCredential,
  type StoredChallenge,
  type StoredRecoveryToken,
  addGuardian,
  getGuardians,
  getGuardianByInviteToken,
  acceptGuardianInvite,
  removeGuardian,
  getGuardianById,
  getGuardianships,
  createRecoveryRequest,
  getRecoveryRequest,
  getActiveRecoveryRequest,
  createRecoveryApproval,
  approveRecoveryByToken,
  updateRecoveryRequestStatus,
  getRecoveryApprovals,
  createDeviceLink,
  getDeviceLink,
  markDeviceLinkUsed,
  getSpaceMember,
  listSpaceMembers,
  upsertSpaceMember,
  removeSpaceMember,
  getUserProfile,
  updateUserProfile,
  getUserAddresses,
  getAddressById,
  saveUserAddress,
  deleteUserAddress,
  getEmailForwardStatus,
  setEmailForward,
  listAllUsers,
  deleteUser,
  deleteSpaceMembers,
  createSpaceInvite,
  getSpaceInviteByToken,
  listSpaceInvites,
  acceptSpaceInvite,
  revokeSpaceInvite,
  sql,
} from './db.js';
import {
  isMailcowConfigured,
  createAlias,
  deleteAlias,
  updateAlias,
  aliasExists,
} from './mailcow.js';
import { notify } from '../../server/notification-service';

// ============================================================================
// CONFIGURATION
// ============================================================================

const CONFIG = {
  port: process.env.PORT || 3000,
  rpId: 'rspace.online',
  rpName: 'EncryptID',
  jwtSecret: (() => {
    const secret = process.env.JWT_SECRET;
    if (!secret) throw new Error('JWT_SECRET environment variable is required');
    return secret;
  })(),
  sessionDuration: 30 * 24 * 60 * 60,  // 30 days
  refreshDuration: 7 * 24 * 60 * 60,  // 7 days
  smtp: {
    host: process.env.SMTP_HOST || 'mail.rmail.online',
    port: parseInt(process.env.SMTP_PORT || '587'),
    secure: false, // STARTTLS on 587
    user: process.env.SMTP_USER || 'noreply@rspace.online',
    pass: process.env.SMTP_PASS || '',
    from: process.env.SMTP_FROM || 'EncryptID <noreply@rspace.online>',
  },
  recoveryUrl: process.env.RECOVERY_URL || 'https://auth.rspace.online/recover',
  adminDIDs: (process.env.ADMIN_DIDS || '').split(',').filter(Boolean),
  allowedOrigins: [
    // rspace.online — RP ID domain and all subdomains
    'https://rspace.online',
    'https://auth.rspace.online',
    'https://cca.rspace.online',
    'https://demo.rspace.online',
    'https://app.rspace.online',
    'https://dev.rspace.online',
    // r* ecosystem apps (each *.online is an eTLD+1 — Related Origins limit is 5)
    'https://rwallet.online',
    'https://rvote.online',
    'https://rmaps.online',
    'https://rfiles.online',
    'https://rnotes.online',
    'https://rflows.online',
    'https://rtrips.online',
    'https://rnetwork.online',
    'https://rcart.online',
    'https://rtube.online',
    'https://rchats.online',
    'https://rstack.online',
    'https://rpubs.online',
    'https://rauctions.online',
    'https://ridentity.online',
    'https://auth.ridentity.online',
    'https://rphotos.online',
    'https://rcal.online',
    'https://rinbox.online',
    'https://rmail.online',
    'https://rsocials.online',
    'https://rwork.online',
    'https://rforum.online',
    'https://rchoices.online',
    'https://rswag.online',
    'https://rdata.online',
    // Development
    'http://localhost:3000',
    'http://localhost:5173',
  ],
};

// ============================================================================
// SMTP TRANSPORT
// ============================================================================

let smtpTransport: Transporter | null = null;

if (CONFIG.smtp.pass) {
  smtpTransport = createTransport({
    host: CONFIG.smtp.host,
    port: CONFIG.smtp.port,
    secure: CONFIG.smtp.port === 465,
    auth: {
      user: CONFIG.smtp.user,
      pass: CONFIG.smtp.pass,
    },
    tls: {
      rejectUnauthorized: false,  // Internal Mailcow uses self-signed cert
    },
  });

  // Verify connection on startup
  smtpTransport.verify().then(() => {
    console.log('EncryptID: SMTP connected to', CONFIG.smtp.host);
  }).catch((err) => {
    console.error('EncryptID: SMTP connection failed —', err.message);
    console.error('EncryptID: Email recovery will not work until SMTP is configured');
    smtpTransport = null;
  });
} else {
  console.warn('EncryptID: SMTP_PASS not set — email recovery disabled (tokens logged to console)');
}

async function sendRecoveryEmail(to: string, token: string, username: string): Promise<boolean> {
  const recoveryLink = `${CONFIG.recoveryUrl}?token=${encodeURIComponent(token)}`;

  if (!smtpTransport) {
    console.log(`EncryptID: [NO SMTP] Recovery link for ${to}: ${recoveryLink}`);
    return false;
  }

  await smtpTransport.sendMail({
    from: CONFIG.smtp.from,
    to,
    subject: 'rStack — Account Recovery',
    text: [
      `Hi ${username},`,
      '',
      'A recovery request was made for your rStack account.',
      'Use the link below to add a new passkey:',
      '',
      recoveryLink,
      '',
      'This link expires in 30 minutes.',
      'If you did not request this, you can safely ignore this email.',
      '',
      '— rStack Identity',
    ].join('\n'),
    html: `
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body style="margin:0;padding:0;background:#1a1a2e;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
  <table width="100%" cellpadding="0" cellspacing="0" style="background:#1a1a2e;padding:40px 20px;">
    <tr><td align="center">
      <table width="480" cellpadding="0" cellspacing="0" style="background:#16213e;border-radius:12px;border:1px solid rgba(255,255,255,0.1);">
        <tr><td style="padding:32px 32px 24px;text-align:center;">
          <div style="font-size:36px;margin-bottom:8px;">&#128274;</div>
          <h1 style="margin:0;font-size:24px;background:linear-gradient(90deg,#00d4ff,#7c3aed);-webkit-background-clip:text;-webkit-text-fill-color:transparent;">rStack Identity</h1>
        </td></tr>
        <tr><td style="padding:0 32px 24px;color:#e2e8f0;font-size:15px;line-height:1.6;">
          <p>Hi <strong>${username}</strong>,</p>
          <p>A recovery request was made for your rStack account. Click below to add a new passkey:</p>
        </td></tr>
        <tr><td style="padding:0 32px 32px;text-align:center;">
          <a href="${recoveryLink}" style="display:inline-block;padding:12px 32px;background:linear-gradient(90deg,#00d4ff,#7c3aed);color:#fff;text-decoration:none;border-radius:8px;font-weight:600;font-size:15px;">Recover Account</a>
        </td></tr>
        <tr><td style="padding:0 32px 32px;color:#94a3b8;font-size:13px;line-height:1.5;">
          <p>This link expires in <strong>30 minutes</strong>.</p>
          <p>If you didn't request this, you can safely ignore this email — your account is secure.</p>
          <p style="margin-top:16px;padding-top:16px;border-top:1px solid rgba(255,255,255,0.1);font-size:12px;color:#64748b;">
            Can't click the button? Copy this link:<br>
            <span style="color:#94a3b8;word-break:break-all;">${recoveryLink}</span>
          </p>
        </td></tr>
      </table>
    </td></tr>
  </table>
</body>
</html>`,
  });

  return true;
}

// ============================================================================
// HONO APP
// ============================================================================

const app = new Hono();

// Middleware
app.use('*', logger());
app.use('*', cors({
  origin: (origin) => {
    // Allow all *.rspace.online subdomains dynamically (any canvas slug)
    if (origin === 'https://rspace.online' || origin?.endsWith('.rspace.online')) {
      return origin;
    }
    // Allow explicit r* ecosystem origins
    if (CONFIG.allowedOrigins.includes(origin)) {
      return origin;
    }
    return undefined;
  },
  allowMethods: ['GET', 'POST', 'DELETE', 'OPTIONS'],
  allowHeaders: ['Content-Type', 'Authorization'],
  credentials: true,
}));

// ============================================================================
// STATIC FILES & WELL-KNOWN
// ============================================================================

// Serve .well-known/webauthn for Related Origins
// Only list non-rspace.online origins here — *.rspace.online subdomains are
// automatically valid because rspace.online is the RP ID.
// Keep to max 5 eTLD+1 labels to stay within browser limits.
app.get('/.well-known/webauthn', (c) => {
  const nonRspaceOrigins = CONFIG.allowedOrigins.filter(
    o => o.startsWith('https://') && !o.endsWith('.rspace.online') && o !== 'https://rspace.online'
  );
  return c.json({ origins: nonRspaceOrigins });
});

// Health check — includes database connectivity
app.get('/health', async (c) => {
  const dbHealthy = await checkDatabaseHealth();
  const status = dbHealthy ? 'ok' : 'degraded';
  return c.json({ status, service: 'encryptid', database: dbHealthy, timestamp: Date.now() }, dbHealthy ? 200 : 503);
});

// ============================================================================
// RP ID RESOLUTION
// ============================================================================

/**
 * Resolve RP ID for WebAuthn ceremonies.
 *
 * Always returns 'rspace.online' so that all passkeys are registered with
 * the same RP ID. The .well-known/webauthn endpoint lists Related Origins,
 * allowing browsers on other r*.online domains to use these passkeys.
 */
function resolveRpId(_c: any): string {
  return CONFIG.rpId; // Always 'rspace.online'
}

// ============================================================================
// REGISTRATION ENDPOINTS
// ============================================================================

/**
 * Start registration - returns challenge and options
 */
app.post('/api/register/start', async (c) => {
  const { username, displayName } = await c.req.json();

  if (!username) {
    return c.json({ error: 'Username required' }, 400);
  }

  // Generate challenge
  const challenge = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url');

  // Generate user ID
  const userId = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url');

  // Store challenge in database
  const challengeRecord: StoredChallenge = {
    challenge,
    userId,
    type: 'registration',
    createdAt: Date.now(),
    expiresAt: Date.now() + 5 * 60 * 1000,  // 5 minutes
  };
  await storeChallenge(challengeRecord);

  // Build registration options — use the caller's domain as RP ID
  const rpId = resolveRpId(c);
  const options = {
    challenge,
    rp: {
      id: rpId,
      name: CONFIG.rpName,
    },
    user: {
      id: userId,
      name: username,
      displayName: displayName || username,
    },
    pubKeyCredParams: [
      { alg: -7, type: 'public-key' },   // ES256
      { alg: -257, type: 'public-key' }, // RS256
    ],
    authenticatorSelection: {
      residentKey: 'required',
      requireResidentKey: true,
      userVerification: 'required',
    },
    timeout: 60000,
    attestation: 'none',
    extensions: {
      credProps: true,
    },
  };

  return c.json({ options, userId });
});

/**
 * Complete registration - verify and store credential
 */
app.post('/api/register/complete', async (c) => {
  const { challenge, credential, userId, username, email } = await c.req.json();

  // Verify challenge
  const challengeRecord = await getChallenge(challenge);
  if (!challengeRecord || challengeRecord.type !== 'registration') {
    return c.json({ error: 'Invalid challenge' }, 400);
  }
  if (Date.now() > challengeRecord.expiresAt) {
    await deleteChallenge(challenge);
    return c.json({ error: 'Challenge expired' }, 400);
  }
  await deleteChallenge(challenge);

  // In production, verify the attestation properly
  // For now, we trust the client-side verification

  // Create user and store credential in database
  const did = `did:key:${userId.slice(0, 32)}`;
  await createUser(userId, username, username, did);

  // Set recovery email if provided during registration
  if (email && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
    await setUserEmail(userId, email);
  }

  // Resolve the RP ID from the caller's origin
  const rpId = resolveRpId(c);
  const storedCredential: StoredCredential = {
    credentialId: credential.credentialId,
    publicKey: credential.publicKey,
    userId,
    username,
    counter: 0,
    createdAt: Date.now(),
    transports: credential.transports,
    rpId,
  };
  await storeCredential(storedCredential);

  console.log('EncryptID: Credential registered', {
    credentialId: credential.credentialId.slice(0, 20) + '...',
    userId: userId.slice(0, 20) + '...',
  });

  // Auto-provision user space at <username>.rspace.online
  try {
    const { communityExists, createCommunity } = await import('../../server/community-store');
    const { DEFAULT_USER_NEST_POLICY, DEFAULT_USER_MODULES } = await import('../../server/community-store');
    const { getAllModules } = await import('../../shared/module');

    const spaceSlug = username.toLowerCase().replace(/[^a-z0-9-]/g, '-');
    if (!await communityExists(spaceSlug)) {
      await createCommunity(username, spaceSlug, did, 'private', {
        enabledModules: DEFAULT_USER_MODULES,
        nestPolicy: DEFAULT_USER_NEST_POLICY,
      });

      // Fire module hooks
      for (const mod of getAllModules()) {
        if (mod.onSpaceCreate) {
          try { await mod.onSpaceCreate(spaceSlug); } catch (e) {
            console.error(`[EncryptID] Module ${mod.id} onSpaceCreate for user space:`, e);
          }
        }
      }

      console.log(`EncryptID: Auto-provisioned space ${spaceSlug}.rspace.online for ${username}`);
    }
  } catch (e) {
    // Non-fatal: user is created even if space provisioning fails
    console.error('EncryptID: Failed to auto-provision user space:', e);
  }

  // Generate initial session token
  const token = await generateSessionToken(userId, username);

  return c.json({
    success: true,
    userId,
    token,
    did,
    spaceSlug: username.toLowerCase().replace(/[^a-z0-9-]/g, '-'),
  });
});

// ============================================================================
// AUTHENTICATION ENDPOINTS
// ============================================================================

/**
 * Start authentication - returns challenge
 */
app.post('/api/auth/start', async (c) => {
  const body = await c.req.json().catch(() => ({}));
  const { credentialId } = body;

  // Generate challenge
  const challenge = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url');

  // Store challenge in database
  const challengeRecord: StoredChallenge = {
    challenge,
    type: 'authentication',
    createdAt: Date.now(),
    expiresAt: Date.now() + 5 * 60 * 1000,
  };
  await storeChallenge(challengeRecord);

  // Build allowed credentials if specified
  let allowCredentials;
  if (credentialId) {
    const cred = await getCredential(credentialId);
    if (cred) {
      allowCredentials = [{
        type: 'public-key',
        id: credentialId,
        transports: cred.transports,
      }];
    }
  }

  // Use the caller's domain as RP ID
  const rpId = resolveRpId(c);
  const options = {
    challenge,
    rpId,
    userVerification: 'required',
    timeout: 60000,
    allowCredentials,
  };

  return c.json({ options });
});

/**
 * Complete authentication - verify and issue token
 */
app.post('/api/auth/complete', async (c) => {
  const { challenge, credential } = await c.req.json();

  // Verify challenge from database
  const challengeRecord = await getChallenge(challenge);
  if (!challengeRecord || challengeRecord.type !== 'authentication') {
    return c.json({ error: 'Invalid challenge' }, 400);
  }
  if (Date.now() > challengeRecord.expiresAt) {
    await deleteChallenge(challenge);
    return c.json({ error: 'Challenge expired' }, 400);
  }
  await deleteChallenge(challenge);

  // Look up credential from database
  const storedCredential = await getCredential(credential.credentialId);
  if (!storedCredential) {
    return c.json({ error: 'Unknown credential' }, 400);
  }

  // In production, verify signature against stored public key
  // For now, we trust the client-side verification

  // Update counter and last used in database
  await updateCredentialUsage(credential.credentialId, storedCredential.counter + 1);

  console.log('EncryptID: Authentication successful', {
    credentialId: credential.credentialId.slice(0, 20) + '...',
    userId: storedCredential.userId.slice(0, 20) + '...',
  });

  // Generate session token
  const token = await generateSessionToken(
    storedCredential.userId,
    storedCredential.username
  );

  return c.json({
    success: true,
    userId: storedCredential.userId,
    username: storedCredential.username,
    token,
    did: `did:key:${storedCredential.userId.slice(0, 32)}`,
  });
});

// ============================================================================
// SESSION ENDPOINTS
// ============================================================================

/**
 * Verify session token (supports both GET with Authorization header and POST with body)
 */
app.get('/api/session/verify', async (c) => {
  const authHeader = c.req.header('Authorization');
  if (!authHeader?.startsWith('Bearer ')) {
    return c.json({ valid: false, error: 'No token' }, 401);
  }

  const token = authHeader.slice(7);

  try {
    const payload = await verify(token, CONFIG.jwtSecret, 'HS256');
    return c.json({
      valid: true,
      userId: payload.sub,
      username: payload.username,
      did: payload.did,
      exp: payload.exp,
    });
  } catch {
    return c.json({ valid: false, error: 'Invalid token' }, 401);
  }
});

app.post('/api/session/verify', async (c) => {
  const { token } = await c.req.json();
  if (!token) {
    return c.json({ valid: false, error: 'No token' }, 400);
  }

  try {
    const payload = await verify(token, CONFIG.jwtSecret, 'HS256');
    return c.json({
      valid: true,
      claims: payload,
      userId: payload.sub,
      username: payload.username,
      did: payload.did,
      exp: payload.exp,
    });
  } catch {
    return c.json({ valid: false, error: 'Invalid token' });
  }
});

/**
 * Refresh session token
 */
app.post('/api/session/refresh', async (c) => {
  const authHeader = c.req.header('Authorization');
  if (!authHeader?.startsWith('Bearer ')) {
    return c.json({ error: 'No token' }, 401);
  }

  const token = authHeader.slice(7);

  try {
    const payload = await verify(token, CONFIG.jwtSecret, { alg: 'HS256', exp: false });  // Allow expired tokens for refresh

    // Issue new token
    const newToken = await generateSessionToken(
      payload.sub as string,
      payload.username as string
    );

    return c.json({ token: newToken });
  } catch {
    return c.json({ error: 'Invalid token' }, 401);
  }
});

// ============================================================================
// USER INFO ENDPOINTS
// ============================================================================

/**
 * Get user credentials (for listing passkeys)
 */
app.get('/api/user/credentials', async (c) => {
  const authHeader = c.req.header('Authorization');
  if (!authHeader?.startsWith('Bearer ')) {
    return c.json({ error: 'Unauthorized' }, 401);
  }

  const token = authHeader.slice(7);

  try {
    const payload = await verify(token, CONFIG.jwtSecret, 'HS256');
    const userId = payload.sub as string;

    const creds = await getUserCredentials(userId);
    const credentialList = creds.map(cred => ({
      credentialId: cred.credentialId,
      createdAt: cred.createdAt,
      lastUsed: cred.lastUsed,
      transports: cred.transports,
    }));

    return c.json({ credentials: credentialList });
  } catch {
    return c.json({ error: 'Unauthorized' }, 401);
  }
});

// ============================================================================
// USER PROFILE ENDPOINTS
// ============================================================================

// GET /api/user/profile — get the authenticated user's profile
app.get('/api/user/profile', async (c) => {
  const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
  if (!claims) return c.json({ error: 'Unauthorized' }, 401);

  const profile = await getUserProfile(claims.sub as string);
  if (!profile) return c.json({ error: 'User not found' }, 404);

  return c.json({ success: true, profile });
});

// PUT /api/user/profile — update the authenticated user's profile
app.put('/api/user/profile', async (c) => {
  const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
  if (!claims) return c.json({ error: 'Unauthorized' }, 401);

  const body = await c.req.json();
  const updates: Record<string, any> = {};

  if (body.displayName !== undefined) updates.displayName = body.displayName;
  if (body.bio !== undefined) {
    if (typeof body.bio === 'string' && body.bio.length > 500) {
      return c.json({ error: 'Bio must be 500 characters or fewer' }, 400);
    }
    updates.bio = body.bio;
  }
  if (body.avatarUrl !== undefined) updates.avatarUrl = body.avatarUrl;
  if (body.profileEmail !== undefined) {
    if (body.profileEmail && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(body.profileEmail)) {
      return c.json({ error: 'Invalid email format' }, 400);
    }
    updates.profileEmail = body.profileEmail;
  }
  if (body.profileEmailIsRecovery !== undefined) updates.profileEmailIsRecovery = body.profileEmailIsRecovery;
  if (body.walletAddress !== undefined) updates.walletAddress = body.walletAddress;

  const profile = await updateUserProfile(claims.sub as string, updates);
  if (!profile) return c.json({ error: 'User not found' }, 404);

  // If profile email changed and forwarding is active, update/disable the alias
  if (updates.profileEmail !== undefined && isMailcowConfigured()) {
    try {
      const fwdStatus = await getEmailForwardStatus(claims.sub as string);
      if (fwdStatus?.enabled && fwdStatus.mailcowId) {
        if (updates.profileEmail) {
          // Email changed — update alias destination
          await updateAlias(fwdStatus.mailcowId, updates.profileEmail);
        } else {
          // Email cleared — disable forwarding
          await deleteAlias(fwdStatus.mailcowId);
          await setEmailForward(claims.sub as string, false, null);
        }
      }
    } catch (err) {
      console.error('EncryptID: Failed to update Mailcow alias after profile email change:', err);
    }
  }

  return c.json({ success: true, profile });
});

// ============================================================================
// ENCRYPTED ADDRESS ENDPOINTS
// ============================================================================

const MAX_ADDRESSES = 10;
const VALID_LABELS = ['home', 'work', 'shipping', 'billing', 'other'];

// GET /api/user/addresses — get all encrypted addresses for the user
app.get('/api/user/addresses', async (c) => {
  const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
  if (!claims) return c.json({ error: 'Unauthorized' }, 401);

  const addresses = await getUserAddresses(claims.sub as string);
  return c.json({
    success: true,
    addresses: addresses.map(a => ({
      id: a.id,
      ciphertext: a.ciphertext,
      iv: a.iv,
      label: a.label,
      labelCustom: a.labelCustom,
      isDefault: a.isDefault,
    })),
  });
});

// POST /api/user/addresses — save a new encrypted address
app.post('/api/user/addresses', async (c) => {
  const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
  if (!claims) return c.json({ error: 'Unauthorized' }, 401);

  const userId = claims.sub as string;
  const body = await c.req.json();

  if (!body.id || !body.ciphertext || !body.iv || !body.label) {
    return c.json({ error: 'Missing required fields: id, ciphertext, iv, label' }, 400);
  }
  if (!VALID_LABELS.includes(body.label)) {
    return c.json({ error: `Invalid label. Must be one of: ${VALID_LABELS.join(', ')}` }, 400);
  }

  // Check address limit
  const existing = await getUserAddresses(userId);
  if (existing.length >= MAX_ADDRESSES) {
    return c.json({ error: `Maximum ${MAX_ADDRESSES} addresses allowed` }, 400);
  }

  const address = await saveUserAddress(userId, {
    id: body.id,
    ciphertext: body.ciphertext,
    iv: body.iv,
    label: body.label,
    labelCustom: body.labelCustom,
    isDefault: body.isDefault || false,
  });

  return c.json({
    success: true,
    address: {
      id: address.id,
      ciphertext: address.ciphertext,
      iv: address.iv,
      label: address.label,
      labelCustom: address.labelCustom,
      isDefault: address.isDefault,
    },
  }, 201);
});

// PUT /api/user/addresses/:id — update an existing encrypted address
app.put('/api/user/addresses/:id', async (c) => {
  const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
  if (!claims) return c.json({ error: 'Unauthorized' }, 401);

  const userId = claims.sub as string;
  const id = c.req.param('id');
  const body = await c.req.json();

  const existing = await getAddressById(id, userId);
  if (!existing) return c.json({ error: 'Address not found' }, 404);

  if (body.label && !VALID_LABELS.includes(body.label)) {
    return c.json({ error: `Invalid label. Must be one of: ${VALID_LABELS.join(', ')}` }, 400);
  }

  const address = await saveUserAddress(userId, {
    id,
    ciphertext: body.ciphertext || existing.ciphertext,
    iv: body.iv || existing.iv,
    label: body.label || existing.label,
    labelCustom: body.labelCustom !== undefined ? body.labelCustom : existing.labelCustom,
    isDefault: body.isDefault !== undefined ? body.isDefault : existing.isDefault,
  });

  return c.json({
    success: true,
    address: {
      id: address.id,
      ciphertext: address.ciphertext,
      iv: address.iv,
      label: address.label,
      labelCustom: address.labelCustom,
      isDefault: address.isDefault,
    },
  });
});

// DELETE /api/user/addresses/:id — delete an encrypted address
app.delete('/api/user/addresses/:id', async (c) => {
  const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
  if (!claims) return c.json({ error: 'Unauthorized' }, 401);

  const userId = claims.sub as string;
  const id = c.req.param('id');

  const deleted = await deleteUserAddress(id, userId);
  if (!deleted) return c.json({ error: 'Address not found' }, 404);

  return c.json({ success: true });
});

// ============================================================================
// ACCOUNT SETTINGS ENDPOINTS
// ============================================================================

/**
 * GET /api/account/status — account setup completion status
 * Returns which security/setup steps are done.
 */
app.get('/api/account/status', async (c) => {
  const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
  if (!claims) return c.json({ error: 'Unauthorized' }, 401);

  const userId = claims.sub as string;

  // Check email
  const profile = await getUserProfile(userId);
  const hasEmail = !!(profile?.profileEmail);

  // Check credentials count (>1 means multi-device)
  const creds = await getUserCredentials(userId);
  const hasMultiDevice = creds.length > 1;

  // Check guardians
  let guardianCount = 0;
  try {
    const rows = await sql`SELECT COUNT(*) as count FROM guardians WHERE user_id = ${userId} AND status != 'removed'`;
    guardianCount = parseInt(rows[0]?.count || '0');
  } catch { /* ignore */ }
  const hasRecovery = guardianCount >= 2;

  // Check encrypted backup
  // (This is client-side localStorage, but we can infer from whether they have any synced docs)
  // For now, we just return the server-side info and let the client check localStorage

  return c.json({
    email: hasEmail,
    multiDevice: hasMultiDevice,
    socialRecovery: hasRecovery,
    credentialCount: creds.length,
    guardianCount,
  });
});

/**
 * POST /api/account/email/start — send verification code to email
 * Body: { email }
 * Auth required
 */
app.post('/api/account/email/start', async (c) => {
  const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
  if (!claims) return c.json({ error: 'Unauthorized' }, 401);

  const { email } = await c.req.json();
  if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
    return c.json({ error: 'Valid email required' }, 400);
  }

  const code = String(Math.floor(100000 + Math.random() * 900000));
  const tokenKey = `emailverify_${claims.sub}_${code}`;

  await storeRecoveryToken({
    token: tokenKey,
    userId: claims.sub as string,
    type: 'email_verify',
    createdAt: Date.now(),
    expiresAt: Date.now() + 10 * 60 * 1000, // 10 minutes
    used: false,
  });

  if (smtpTransport) {
    try {
      await smtpTransport.sendMail({
        from: CONFIG.smtp.from,
        to: email,
        subject: `${code} — rStack Email Verification`,
        text: `Your verification code is: ${code}\n\nThis code expires in 10 minutes.\n\n— rStack Identity`,
        html: `
<!DOCTYPE html><html><head><meta charset="utf-8"></head>
<body style="margin:0;padding:0;background:#1a1a2e;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
  <table width="100%" cellpadding="0" cellspacing="0" style="background:#1a1a2e;padding:40px 20px;">
    <tr><td align="center">
      <table width="480" cellpadding="0" cellspacing="0" style="background:#16213e;border-radius:12px;border:1px solid rgba(255,255,255,0.1);">
        <tr><td style="padding:32px 32px 24px;text-align:center;">
          <div style="font-size:36px;margin-bottom:8px;">&#9993;</div>
          <h1 style="margin:0;font-size:24px;background:linear-gradient(90deg,#00d4ff,#7c3aed);-webkit-background-clip:text;-webkit-text-fill-color:transparent;">Email Verification</h1>
        </td></tr>
        <tr><td style="padding:0 32px 24px;color:#e2e8f0;font-size:15px;line-height:1.6;text-align:center;">
          <p>Your verification code is:</p>
          <div style="font-size:36px;font-weight:800;letter-spacing:8px;color:#06b6d4;padding:16px 0;">${code}</div>
          <p style="color:#94a3b8;font-size:13px;">This code expires in 10 minutes.</p>
        </td></tr>
      </table>
    </td></tr>
  </table>
</body></html>`,
      });
    } catch (err) {
      console.error('EncryptID: Failed to send verification email:', err);
      return c.json({ error: 'Failed to send verification email' }, 500);
    }
  } else {
    console.log(`EncryptID: [NO SMTP] Email verification code for ${email}: ${code}`);
  }

  return c.json({ success: true });
});

/**
 * POST /api/account/email/verify — verify code and set email
 * Body: { email, code }
 * Auth required
 */
app.post('/api/account/email/verify', async (c) => {
  const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
  if (!claims) return c.json({ error: 'Unauthorized' }, 401);

  const { email, code } = await c.req.json();
  if (!email || !code) return c.json({ error: 'Email and code required' }, 400);

  const tokenKey = `emailverify_${claims.sub}_${code}`;
  const rt = await getRecoveryToken(tokenKey);

  if (!rt || rt.used || Date.now() > rt.expiresAt || rt.userId !== (claims.sub as string)) {
    return c.json({ error: 'Invalid or expired verification code' }, 400);
  }

  await markRecoveryTokenUsed(tokenKey);
  await setUserEmail(claims.sub as string, email);

  return c.json({ success: true, email });
});

// ============================================================================
// EMAIL FORWARDING (Mailcow alias management)
// ============================================================================

/**
 * GET /api/account/email-forward — check forwarding status
 * Auth required
 */
app.get('/api/account/email-forward', async (c) => {
  const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
  if (!claims) return c.json({ error: 'Unauthorized' }, 401);

  const status = await getEmailForwardStatus(claims.sub as string);
  if (!status) return c.json({ error: 'User not found' }, 404);

  const available = isMailcowConfigured();
  const address = `${status.username}@rspace.online`;

  return c.json({
    enabled: status.enabled,
    address: status.enabled ? address : null,
    forwardsTo: status.enabled ? status.profileEmail : null,
    available,
  });
});

/**
 * POST /api/account/email-forward/enable — create forwarding alias
 * Auth required. Requires a verified profile_email.
 */
app.post('/api/account/email-forward/enable', async (c) => {
  const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
  if (!claims) return c.json({ error: 'Unauthorized' }, 401);

  if (!isMailcowConfigured()) {
    return c.json({ error: 'Email forwarding is not configured' }, 503);
  }

  const userId = claims.sub as string;
  const status = await getEmailForwardStatus(userId);
  if (!status) return c.json({ error: 'User not found' }, 404);

  if (status.enabled) {
    return c.json({ error: 'Email forwarding is already enabled' }, 409);
  }

  if (!status.profileEmail) {
    return c.json({ error: 'A verified profile email is required to enable forwarding' }, 400);
  }

  const address = `${status.username}@rspace.online`;

  // Check for existing alias conflict
  try {
    if (await aliasExists(address)) {
      return c.json({ error: 'An alias already exists for this address' }, 409);
    }
  } catch (err) {
    console.error('EncryptID: Mailcow aliasExists check failed:', err);
    return c.json({ error: 'Email forwarding service unavailable' }, 503);
  }

  try {
    const mailcowId = await createAlias(status.username, status.profileEmail);
    await setEmailForward(userId, true, mailcowId);

    return c.json({
      success: true,
      enabled: true,
      address,
      forwardsTo: status.profileEmail,
    });
  } catch (err) {
    console.error('EncryptID: Failed to create Mailcow alias:', err);
    return c.json({ error: 'Email forwarding service unavailable' }, 503);
  }
});

/**
 * POST /api/account/email-forward/disable — remove forwarding alias
 * Auth required.
 */
app.post('/api/account/email-forward/disable', async (c) => {
  const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
  if (!claims) return c.json({ error: 'Unauthorized' }, 401);

  if (!isMailcowConfigured()) {
    return c.json({ error: 'Email forwarding is not configured' }, 503);
  }

  const userId = claims.sub as string;
  const status = await getEmailForwardStatus(userId);
  if (!status) return c.json({ error: 'User not found' }, 404);

  if (!status.enabled) {
    return c.json({ error: 'Email forwarding is not enabled' }, 400);
  }

  // Best-effort: clear local state even if Mailcow delete fails
  if (status.mailcowId) {
    try {
      await deleteAlias(status.mailcowId);
    } catch (err) {
      console.error('EncryptID: Failed to delete Mailcow alias (clearing local state anyway):', err);
    }
  }

  await setEmailForward(userId, false, null);

  return c.json({ success: true, enabled: false });
});

/**
 * POST /api/account/device/start — get WebAuthn options for registering another passkey
 * Auth required
 */
app.post('/api/account/device/start', async (c) => {
  const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
  if (!claims) return c.json({ error: 'Unauthorized' }, 401);

  const user = await getUserById(claims.sub as string);
  if (!user) return c.json({ error: 'User not found' }, 404);

  const challenge = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url');

  await storeChallenge({
    challenge,
    userId: claims.sub as string,
    type: 'device_registration',
    createdAt: Date.now(),
    expiresAt: Date.now() + 5 * 60 * 1000,
  });

  const rpId = resolveRpId(c);
  const options = {
    challenge,
    rp: { id: rpId, name: CONFIG.rpName },
    user: {
      id: claims.sub as string,
      name: user.username,
      displayName: user.username,
    },
    pubKeyCredParams: [
      { alg: -7, type: 'public-key' },
      { alg: -257, type: 'public-key' },
    ],
    authenticatorSelection: {
      residentKey: 'required',
      requireResidentKey: true,
      userVerification: 'required',
    },
    timeout: 60000,
    attestation: 'none',
  };

  return c.json({ options, userId: claims.sub });
});

/**
 * POST /api/account/device/complete — register additional passkey for existing account
 * Body: { challenge, credential: { credentialId, publicKey, transports } }
 * Auth required
 */
app.post('/api/account/device/complete', async (c) => {
  const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
  if (!claims) return c.json({ error: 'Unauthorized' }, 401);

  const { challenge, credential } = await c.req.json();
  if (!challenge || !credential?.credentialId) {
    return c.json({ error: 'Challenge and credential required' }, 400);
  }

  const challengeRecord = await getChallenge(challenge);
  if (!challengeRecord || challengeRecord.type !== 'device_registration') {
    return c.json({ error: 'Invalid challenge' }, 400);
  }
  if (challengeRecord.userId !== (claims.sub as string)) {
    return c.json({ error: 'Challenge mismatch' }, 400);
  }
  if (Date.now() > challengeRecord.expiresAt) {
    await deleteChallenge(challenge);
    return c.json({ error: 'Challenge expired' }, 400);
  }
  await deleteChallenge(challenge);

  const user = await getUserById(claims.sub as string);
  if (!user) return c.json({ error: 'User not found' }, 404);

  const rpId = resolveRpId(c);
  await storeCredential({
    credentialId: credential.credentialId,
    publicKey: credential.publicKey || '',
    userId: claims.sub as string,
    username: user.username,
    counter: 0,
    createdAt: Date.now(),
    transports: credential.transports || [],
    rpId,
  });

  console.log('EncryptID: Additional device registered for', user.username);
  return c.json({ success: true });
});

// ============================================================================
// RECOVERY ENDPOINTS
// ============================================================================

/**
 * Set recovery email for authenticated user
 */
app.post('/api/recovery/email/set', async (c) => {
  const authHeader = c.req.header('Authorization');
  if (!authHeader?.startsWith('Bearer ')) {
    return c.json({ error: 'Unauthorized' }, 401);
  }

  try {
    const payload = await verify(authHeader.slice(7), CONFIG.jwtSecret, 'HS256');
    const { email } = await c.req.json();

    if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
      return c.json({ error: 'Valid email required' }, 400);
    }

    await setUserEmail(payload.sub as string, email);
    return c.json({ success: true, email });
  } catch {
    return c.json({ error: 'Unauthorized' }, 401);
  }
});

/**
 * Request account recovery via email — sends a recovery token
 */
app.post('/api/recovery/email/request', async (c) => {
  const { email } = await c.req.json();

  if (!email) {
    return c.json({ error: 'Email required' }, 400);
  }

  const user = await getUserByEmail(email);
  // Always return success to avoid email enumeration
  if (!user) {
    return c.json({ success: true, message: 'If an account exists with this email, a recovery link has been sent.' });
  }

  // Generate recovery token
  const token = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url');
  const rt: StoredRecoveryToken = {
    token,
    userId: user.id,
    type: 'account_recovery',
    createdAt: Date.now(),
    expiresAt: Date.now() + 30 * 60 * 1000, // 30 minutes
    used: false,
  };
  await storeRecoveryToken(rt);

  // Send recovery email via Mailcow SMTP
  try {
    await sendRecoveryEmail(email, token, user.username);
  } catch (err) {
    console.error('EncryptID: Failed to send recovery email:', err);
  }

  return c.json({ success: true, message: 'If an account exists with this email, a recovery link has been sent.' });
});

/**
 * Verify recovery token — returns temporary auth to register a new passkey
 */
app.post('/api/recovery/email/verify', async (c) => {
  const { token: recoveryToken } = await c.req.json();

  if (!recoveryToken) {
    return c.json({ error: 'Recovery token required' }, 400);
  }

  const rt = await getRecoveryToken(recoveryToken);
  if (!rt || rt.used || Date.now() > rt.expiresAt) {
    return c.json({ error: 'Invalid or expired recovery token' }, 400);
  }

  await markRecoveryTokenUsed(recoveryToken);

  // Get user info
  const user = await getUserById(rt.userId);
  if (!user) {
    return c.json({ error: 'Account not found' }, 404);
  }

  // Issue a short-lived recovery session token
  const sessionToken = await generateSessionToken(rt.userId, user.username);

  return c.json({
    success: true,
    token: sessionToken,
    userId: rt.userId,
    username: user.username,
    did: user.did,
    message: 'Recovery successful. Use this token to register a new passkey.',
  });
});

// ============================================================================
// HELPER FUNCTIONS
// ============================================================================

async function generateSessionToken(userId: string, username: string): Promise<string> {
  const now = Math.floor(Date.now() / 1000);

  // Check if user has set a wallet address → enable wallet capability
  // Safe associations are stored client-side (privacy); server only knows
  // the user has wallet capability via the wallet_address profile field.
  const profile = await getUserProfile(userId);
  const hasWallet = !!profile?.walletAddress;

  const payload = {
    iss: 'https://auth.rspace.online',
    sub: userId,
    aud: CONFIG.allowedOrigins,
    iat: now,
    exp: now + CONFIG.sessionDuration,
    username,
    did: `did:key:${userId.slice(0, 32)}`,
    eid: {
      authLevel: 3,  // ELEVATED (fresh WebAuthn)
      capabilities: {
        encrypt: true,
        sign: true,
        wallet: hasWallet,
      },
    },
  };

  return sign(payload, CONFIG.jwtSecret);
}

// ============================================================================
// RECOVERY PAGE
// ============================================================================

app.get('/recover', (c) => {
  return c.html(`
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>rStack Identity — Account Recovery</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
      background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
      min-height: 100vh;
      display: flex;
      align-items: center;
      justify-content: center;
      color: #fff;
    }
    .card {
      background: rgba(255,255,255,0.05);
      border: 1px solid rgba(255,255,255,0.1);
      border-radius: 16px;
      padding: 2.5rem;
      max-width: 440px;
      width: 100%;
      text-align: center;
    }
    .logo { font-size: 3rem; margin-bottom: 0.5rem; }
    h1 {
      font-size: 1.5rem;
      margin-bottom: 0.5rem;
      background: linear-gradient(90deg, #00d4ff, #7c3aed);
      -webkit-background-clip: text;
      -webkit-text-fill-color: transparent;
    }
    .subtitle { color: #94a3b8; margin-bottom: 1.5rem; font-size: 0.9rem; }
    .status { padding: 1rem; border-radius: 8px; margin-bottom: 1rem; font-size: 0.9rem; }
    .status.loading { background: rgba(6,182,212,0.1); color: #06b6d4; }
    .status.success { background: rgba(34,197,94,0.1); color: #22c55e; }
    .status.error { background: rgba(239,68,68,0.1); color: #ef4444; }
    .btn {
      display: inline-block; padding: 0.75rem 2rem;
      background: linear-gradient(90deg, #00d4ff, #7c3aed);
      color: #fff; border: none; border-radius: 8px;
      font-weight: 600; font-size: 1rem; cursor: pointer;
      transition: transform 0.2s;
    }
    .btn:hover { transform: translateY(-2px); }
    .btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
    .hidden { display: none; }
  </style>
</head>
<body>
  <div class="card">
    <div class="logo">&#128274;</div>
    <h1>Account Recovery</h1>
    <p class="subtitle">Verify your recovery link and add a new passkey to your rStack account</p>

    <div id="status" class="status loading">Verifying recovery token...</div>

    <div id="register-section" class="hidden">
      <button id="register-btn" class="btn" onclick="registerNewPasskey()">
        Add New Passkey
      </button>
    </div>

    <div id="success-section" class="hidden">
      <p style="color:#94a3b8;margin-top:1rem;">You can now sign in with your new passkey on any r*.online app.</p>
    </div>
  </div>

  <script>
    const params = new URLSearchParams(window.location.search);
    const recoveryToken = params.get('token');
    let sessionToken = null;
    let userId = null;
    let username = null;

    const statusEl = document.getElementById('status');
    const registerSection = document.getElementById('register-section');
    const successSection = document.getElementById('success-section');

    async function verifyToken() {
      if (!recoveryToken) {
        statusEl.className = 'status error';
        statusEl.textContent = 'No recovery token found in link.';
        return;
      }
      try {
        const res = await fetch('/api/recovery/email/verify', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ token: recoveryToken }),
        });
        const data = await res.json();
        if (!data.success) {
          statusEl.className = 'status error';
          statusEl.textContent = data.error || 'Invalid or expired recovery link.';
          return;
        }
        sessionToken = data.token;
        userId = data.userId;
        username = data.username;
        statusEl.className = 'status success';
        statusEl.textContent = 'Recovery verified! Now register a new passkey for your account.';
        registerSection.classList.remove('hidden');
      } catch (e) {
        statusEl.className = 'status error';
        statusEl.textContent = 'Failed to verify recovery token.';
      }
    }

    async function registerNewPasskey() {
      const btn = document.getElementById('register-btn');
      btn.disabled = true;
      btn.textContent = 'Registering...';
      try {
        // Start registration
        const startRes = await fetch('/api/register/start', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + sessionToken },
          body: JSON.stringify({ username, displayName: username }),
        });
        const { options } = await startRes.json();

        // Decode challenge for WebAuthn
        options.challenge = Uint8Array.from(atob(options.challenge.replace(/-/g,'+').replace(/_/g,'/')), c => c.charCodeAt(0));
        options.user.id = Uint8Array.from(atob(options.user.id.replace(/-/g,'+').replace(/_/g,'/')), c => c.charCodeAt(0));

        // WebAuthn ceremony
        const credential = await navigator.credentials.create({ publicKey: options });
        const credentialData = {
          credentialId: btoa(String.fromCharCode(...new Uint8Array(credential.rawId))).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=+$/,''),
          publicKey: btoa(String.fromCharCode(...new Uint8Array(credential.response.getPublicKey?.() || new ArrayBuffer(0)))).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=+$/,''),
          transports: credential.response.getTransports?.() || [],
        };

        // Complete registration
        const completeRes = await fetch('/api/register/complete', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ challenge: options.challenge, credential: credentialData, userId, username }),
        });
        const result = await completeRes.json();
        if (result.success) {
          statusEl.className = 'status success';
          statusEl.textContent = 'New passkey registered successfully!';
          registerSection.classList.add('hidden');
          successSection.classList.remove('hidden');
        } else {
          throw new Error(result.error || 'Registration failed');
        }
      } catch (e) {
        statusEl.className = 'status error';
        statusEl.textContent = 'Failed to register passkey: ' + e.message;
        btn.disabled = false;
        btn.textContent = 'Add New Passkey';
      }
    }

    verifyToken();
  </script>
</body>
</html>
  `);
});

// ============================================================================
// GUARDIAN MANAGEMENT ROUTES
// ============================================================================

function generateToken(): string {
  return Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url');
}

/**
 * GET /api/guardians — list my guardians
 */
app.get('/api/guardians', async (c) => {
  const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
  if (!claims) return c.json({ error: 'Unauthorized' }, 401);

  const guardians = await getGuardians(claims.sub as string);
  return c.json({
    guardians: guardians.map(g => ({
      id: g.id,
      name: g.name,
      email: g.email,
      status: g.status,
      acceptedAt: g.acceptedAt,
      createdAt: g.createdAt,
    })),
    count: guardians.length,
    threshold: 2,
  });
});

/**
 * POST /api/guardians — add a guardian (max 3 active)
 * Body: { name, email? }
 * Returns guardian with invite link/token
 */
app.post('/api/guardians', async (c) => {
  const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
  if (!claims) return c.json({ error: 'Unauthorized' }, 401);

  const userId = claims.sub as string;
  const { name, email } = await c.req.json();

  if (!name) return c.json({ error: 'Guardian name is required' }, 400);

  // Check max 3 active guardians
  const existing = await getGuardians(userId);
  if (existing.length >= 3) {
    return c.json({ error: 'Maximum 3 guardians allowed. Remove one first.' }, 400);
  }

  const id = generateToken();
  const inviteToken = generateToken();
  const inviteExpiresAt = Date.now() + 7 * 24 * 60 * 60 * 1000; // 7 days

  const guardian = await addGuardian(id, userId, name, email || null, inviteToken, inviteExpiresAt);

  const inviteUrl = `https://auth.rspace.online/guardian?token=${inviteToken}`;

  // Send invite email if email provided
  if (email && smtpTransport) {
    const user = await getUserById(userId);
    const username = user?.username || 'Someone';
    try {
      await smtpTransport.sendMail({
        from: CONFIG.smtp.from,
        to: email,
        subject: `${username} wants you as a recovery guardian — rStack`,
        text: [
          `Hi ${name},`,
          '',
          `${username} has asked you to be a recovery guardian for their rStack Identity account.`,
          '',
          'As a guardian, you can help them recover their account if they lose access to their devices.',
          'You will never have access to their account or data — only the ability to approve recovery.',
          '',
          'Accept the invitation:',
          inviteUrl,
          '',
          'This invitation expires in 7 days.',
          '',
          '— rStack Identity',
        ].join('\n'),
        html: `
<!DOCTYPE html><html><head><meta charset="utf-8"></head>
<body style="margin:0;padding:0;background:#1a1a2e;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
  <table width="100%" cellpadding="0" cellspacing="0" style="background:#1a1a2e;padding:40px 20px;">
    <tr><td align="center">
      <table width="480" cellpadding="0" cellspacing="0" style="background:#16213e;border-radius:12px;border:1px solid rgba(255,255,255,0.1);">
        <tr><td style="padding:32px 32px 24px;text-align:center;">
          <div style="font-size:36px;margin-bottom:8px;">&#129309;</div>
          <h1 style="margin:0;font-size:22px;background:linear-gradient(90deg,#00d4ff,#7c3aed);-webkit-background-clip:text;-webkit-text-fill-color:transparent;">Guardian Invitation</h1>
        </td></tr>
        <tr><td style="padding:0 32px 24px;color:#e2e8f0;font-size:15px;line-height:1.6;">
          <p>Hi <strong>${name}</strong>,</p>
          <p><strong>${username}</strong> has asked you to be a <strong>recovery guardian</strong> for their rStack Identity account.</p>
          <p style="color:#94a3b8;font-size:13px;">As a guardian, you can help them recover their account if they lose access. You will never have access to their account or data.</p>
        </td></tr>
        <tr><td style="padding:0 32px 32px;text-align:center;">
          <a href="${inviteUrl}" style="display:inline-block;padding:12px 32px;background:linear-gradient(90deg,#00d4ff,#7c3aed);color:#fff;text-decoration:none;border-radius:8px;font-weight:600;font-size:15px;">Accept Invitation</a>
        </td></tr>
        <tr><td style="padding:0 32px 32px;color:#94a3b8;font-size:13px;line-height:1.5;">
          <p>This invitation expires in <strong>7 days</strong>.</p>
          <p style="margin-top:16px;padding-top:16px;border-top:1px solid rgba(255,255,255,0.1);font-size:12px;color:#64748b;">
            Can't click the button? Copy this link:<br>
            <span style="color:#94a3b8;word-break:break-all;">${inviteUrl}</span>
          </p>
        </td></tr>
      </table>
    </td></tr>
  </table>
</body></html>`,
      });
    } catch (err) {
      console.error('EncryptID: Failed to send guardian invite email:', err);
    }
  }

  return c.json({
    guardian: {
      id: guardian.id,
      name: guardian.name,
      email: guardian.email,
      status: guardian.status,
    },
    inviteUrl,
    inviteToken,
  }, 201);
});

/**
 * DELETE /api/guardians/:id — remove a guardian
 */
app.delete('/api/guardians/:id', async (c) => {
  const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
  if (!claims) return c.json({ error: 'Unauthorized' }, 401);

  const { id } = c.req.param();
  const removed = await removeGuardian(id, claims.sub as string);
  if (!removed) return c.json({ error: 'Guardian not found' }, 404);
  return c.json({ success: true });
});

/**
 * GET /api/guardian/invites — list accounts I'm a guardian for
 */
app.get('/api/guardian/invites', async (c) => {
  const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
  if (!claims) return c.json({ error: 'Unauthorized' }, 401);

  const guardianships = await getGuardianships(claims.sub as string);
  const result = [];
  for (const g of guardianships) {
    const owner = await getUserById(g.userId);
    result.push({
      id: g.id,
      ownerUsername: owner?.username || 'Unknown',
      name: g.name,
      acceptedAt: g.acceptedAt,
    });
  }
  return c.json({ guardianships: result });
});

/**
 * GET /api/guardian/requests — pending recovery requests where I'm a guardian
 */
app.get('/api/guardian/requests', async (c) => {
  const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
  if (!claims) return c.json({ error: 'Unauthorized' }, 401);

  const guardianships = await getGuardianships(claims.sub as string);
  const pending = [];
  for (const g of guardianships) {
    const req = await getActiveRecoveryRequest(g.userId);
    if (req) {
      const owner = await getUserById(g.userId);
      const approvals = await getRecoveryApprovals(req.id);
      const myApproval = approvals.find(a => a.guardianId === g.id);
      pending.push({
        requestId: req.id,
        ownerUsername: owner?.username || 'Unknown',
        status: req.status,
        threshold: req.threshold,
        approvalCount: req.approvalCount,
        initiatedAt: req.initiatedAt,
        expiresAt: req.expiresAt,
        alreadyApproved: !!myApproval?.approvedAt,
        guardianId: g.id,
      });
    }
  }
  return c.json({ requests: pending });
});

// ============================================================================
// GUARDIAN INVITE ACCEPTANCE PAGE
// ============================================================================

app.get('/guardian', (c) => {
  return c.html(`
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Guardian Invitation — rStack Identity</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); min-height: 100vh; display: flex; align-items: center; justify-content: center; color: #fff; padding: 1rem; }
    .card { background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); border-radius: 16px; padding: 2.5rem; max-width: 440px; width: 100%; text-align: center; }
    .icon { font-size: 3rem; margin-bottom: 0.5rem; }
    h1 { font-size: 1.5rem; margin-bottom: 0.5rem; background: linear-gradient(90deg, #00d4ff, #7c3aed); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
    .sub { color: #94a3b8; margin-bottom: 1.5rem; font-size: 0.9rem; line-height: 1.5; }
    .info-box { background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08); border-radius: 8px; padding: 1rem; margin-bottom: 1.5rem; text-align: left; font-size: 0.85rem; color: #e2e8f0; line-height: 1.6; }
    .info-box strong { color: #00d4ff; }
    .status { padding: 1rem; border-radius: 8px; margin-bottom: 1rem; font-size: 0.9rem; }
    .status.loading { background: rgba(6,182,212,0.1); color: #06b6d4; }
    .status.success { background: rgba(34,197,94,0.1); color: #22c55e; }
    .status.error { background: rgba(239,68,68,0.1); color: #ef4444; }
    .btn { display: inline-block; padding: 0.75rem 2rem; background: linear-gradient(90deg, #00d4ff, #7c3aed); color: #fff; border: none; border-radius: 8px; font-weight: 600; font-size: 1rem; cursor: pointer; transition: transform 0.2s; }
    .btn:hover { transform: translateY(-2px); }
    .btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
    .btn-outline { background: transparent; border: 1px solid rgba(255,255,255,0.2); margin-left: 0.5rem; }
    .hidden { display: none; }
  </style>
</head>
<body>
  <div class="card">
    <div class="icon">&#129309;</div>
    <h1>Guardian Invitation</h1>
    <div id="status" class="status loading">Loading invitation...</div>
    <div id="info" class="hidden">
      <div class="info-box">
        <p><strong id="owner-name">Someone</strong> wants you as a recovery guardian.</p>
        <p style="margin-top:0.5rem;color:#94a3b8;">If they ever lose access to their devices, you'll be asked to approve their account recovery. You won't have access to their account or data.</p>
      </div>
      <div id="accept-section">
        <p style="font-size:0.85rem;color:#94a3b8;margin-bottom:1rem;">To accept, sign in with your passkey (or create an account).</p>
        <button class="btn" id="accept-btn" onclick="acceptInvite()">Accept & Sign In</button>
      </div>
    </div>
    <div id="done" class="hidden">
      <p style="color:#94a3b8;margin-top:1rem;font-size:0.9rem;">You're now a guardian. If <strong id="done-owner">they</strong> ever need recovery, you'll receive a notification.</p>
    </div>
  </div>

  <script type="module">
    import { bufferToBase64url, base64urlToBuffer } from '/dist/index.js';

    const params = new URLSearchParams(window.location.search);
    const inviteToken = params.get('token');
    const statusEl = document.getElementById('status');

    let ownerUsername = '';

    async function loadInvite() {
      if (!inviteToken) {
        statusEl.className = 'status error';
        statusEl.textContent = 'No invitation token found.';
        return;
      }
      try {
        const res = await fetch('/api/guardian/accept?token=' + encodeURIComponent(inviteToken));
        const data = await res.json();
        if (data.error) {
          statusEl.className = 'status error';
          statusEl.textContent = data.error;
          return;
        }
        ownerUsername = data.ownerUsername;
        document.getElementById('owner-name').textContent = data.ownerUsername;
        statusEl.className = 'status success';
        statusEl.textContent = 'Invitation from ' + data.ownerUsername + ' — ' + data.guardianName;
        document.getElementById('info').classList.remove('hidden');
      } catch {
        statusEl.className = 'status error';
        statusEl.textContent = 'Failed to load invitation.';
      }
    }

    // Server-initiated auth: /api/auth/start → WebAuthn get → /api/auth/complete
    async function serverAuth() {
      const startRes = await fetch('/api/auth/start', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: '{}',
      });
      if (!startRes.ok) throw new Error('Failed to start authentication');
      const { options: serverOptions } = await startRes.json();

      const credential = await navigator.credentials.get({
        publicKey: {
          challenge: new Uint8Array(base64urlToBuffer(serverOptions.challenge)),
          rpId: serverOptions.rpId || 'rspace.online',
          userVerification: 'required',
          timeout: 60000,
        },
      });
      if (!credential) throw new Error('Authentication failed');

      const completeRes = await fetch('/api/auth/complete', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          challenge: serverOptions.challenge,
          credential: { credentialId: bufferToBase64url(credential.rawId) },
        }),
      });
      const data = await completeRes.json();
      if (!completeRes.ok || !data.success) throw new Error(data.error || 'Authentication failed');
      return data;
    }

    // Server-initiated registration: /api/register/start → WebAuthn create → /api/register/complete
    async function serverRegister(username) {
      const startRes = await fetch('/api/register/start', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ username, displayName: username }),
      });
      if (!startRes.ok) throw new Error('Failed to start registration');
      const { options: serverOptions, userId } = await startRes.json();

      const credential = await navigator.credentials.create({
        publicKey: {
          challenge: new Uint8Array(base64urlToBuffer(serverOptions.challenge)),
          rp: { id: serverOptions.rp?.id || 'rspace.online', name: serverOptions.rp?.name || 'EncryptID' },
          user: { id: new Uint8Array(base64urlToBuffer(serverOptions.user.id)), name: username, displayName: username },
          pubKeyCredParams: serverOptions.pubKeyCredParams || [
            { alg: -7, type: 'public-key' },
            { alg: -257, type: 'public-key' },
          ],
          authenticatorSelection: { residentKey: 'required', requireResidentKey: true, userVerification: 'required' },
          attestation: 'none',
          timeout: 60000,
        },
      });
      if (!credential) throw new Error('Failed to create credential');

      const response = credential.response;
      const publicKey = response.getPublicKey?.();
      const credentialData = {
        credentialId: bufferToBase64url(credential.rawId),
        publicKey: publicKey ? bufferToBase64url(publicKey) : '',
        transports: response.getTransports?.() || [],
      };

      const completeRes = await fetch('/api/register/complete', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ challenge: serverOptions.challenge, credential: credentialData, userId, username }),
      });
      const data = await completeRes.json();
      if (!completeRes.ok || !data.success) throw new Error(data.error || 'Registration failed');
      return data;
    }

    async function acceptWithToken(token) {
      const acceptRes = await fetch('/api/guardian/accept', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
        body: JSON.stringify({ inviteToken }),
      });
      const acceptData = await acceptRes.json();
      if (acceptData.error) throw new Error(acceptData.error);
      statusEl.className = 'status success';
      statusEl.textContent = 'You are now a guardian!';
      document.getElementById('info').classList.add('hidden');
      document.getElementById('done').classList.remove('hidden');
      document.getElementById('done-owner').textContent = ownerUsername;
    }

    window.acceptInvite = async () => {
      const btn = document.getElementById('accept-btn');
      btn.disabled = true;
      btn.textContent = 'Signing in...';
      try {
        let token;
        try {
          // Try to authenticate with existing passkey
          const authData = await serverAuth();
          token = authData.token;
        } catch {
          // If no passkey, prompt to register
          const username = prompt('No passkey found. Create an account?\\nChoose a username:');
          if (!username) { btn.disabled = false; btn.textContent = 'Accept & Sign In'; return; }
          const regData = await serverRegister(username);
          token = regData.token;
        }
        localStorage.setItem('encryptid_token', token);
        await acceptWithToken(token);
      } catch (err) {
        statusEl.className = 'status error';
        statusEl.textContent = 'Failed: ' + err.message;
        btn.disabled = false;
        btn.textContent = 'Accept & Sign In';
      }
    };

    loadInvite();
  </script>
</body>
</html>
  `);
});

/**
 * GET /api/guardian/accept?token=... — get invite info (unauthenticated)
 */
app.get('/api/guardian/accept', async (c) => {
  const token = c.req.query('token');
  if (!token) return c.json({ error: 'Token required' }, 400);

  const guardian = await getGuardianByInviteToken(token);
  if (!guardian) return c.json({ error: 'Invalid or expired invitation' }, 404);
  if (guardian.inviteExpiresAt && Date.now() > guardian.inviteExpiresAt) {
    return c.json({ error: 'Invitation has expired' }, 400);
  }
  if (guardian.status === 'accepted') {
    return c.json({ error: 'Invitation already accepted' }, 400);
  }

  const owner = await getUserById(guardian.userId);
  return c.json({
    guardianName: guardian.name,
    ownerUsername: owner?.username || 'Unknown',
  });
});

/**
 * POST /api/guardian/accept — accept an invite (authenticated)
 * Body: { inviteToken }
 */
app.post('/api/guardian/accept', async (c) => {
  const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
  if (!claims) return c.json({ error: 'Sign in to accept this invitation' }, 401);

  const { inviteToken } = await c.req.json();
  if (!inviteToken) return c.json({ error: 'Invite token required' }, 400);

  const guardian = await getGuardianByInviteToken(inviteToken);
  if (!guardian) return c.json({ error: 'Invalid or expired invitation' }, 404);
  if (guardian.inviteExpiresAt && Date.now() > guardian.inviteExpiresAt) {
    return c.json({ error: 'Invitation has expired' }, 400);
  }
  if (guardian.status === 'accepted') {
    return c.json({ error: 'Already accepted' }, 400);
  }

  // Can't be your own guardian
  if (guardian.userId === claims.sub) {
    return c.json({ error: 'You cannot be your own guardian' }, 400);
  }

  await acceptGuardianInvite(guardian.id, claims.sub as string);

  // Notify the account owner that their guardian accepted
  const acceptorUser = await getUserById(claims.sub as string);
  notify({
    userDid: guardian.userId,
    category: 'system',
    eventType: 'guardian_accepted',
    title: `${acceptorUser?.username || guardian.name} accepted your guardian invite`,
    body: `${guardian.name} is now a recovery guardian for your account.`,
    actorDid: claims.sub as string,
    actorUsername: acceptorUser?.username || guardian.name,
    metadata: { guardianId: guardian.id },
  }).catch(() => {});

  return c.json({ success: true, message: 'You are now a guardian!' });
});

// ============================================================================
// SOCIAL RECOVERY ROUTES
// ============================================================================

/**
 * POST /api/recovery/social/initiate — start social recovery
 * Body: { email or username }
 * Sends approval links to all guardians
 */
app.post('/api/recovery/social/initiate', async (c) => {
  const { email, username: reqUsername } = await c.req.json();

  // Find the user
  let user;
  if (email) user = await getUserByEmail(email);
  if (!user && reqUsername) user = await getUserByUsername(reqUsername);
  // Always return success to prevent enumeration
  if (!user) return c.json({ success: true, message: 'If the account exists and has guardians, recovery emails have been sent.' });

  const guardians = await getGuardians(user.id);
  const accepted = guardians.filter(g => g.status === 'accepted');
  if (accepted.length < 2) {
    return c.json({ success: true, message: 'If the account exists and has guardians, recovery emails have been sent.' });
  }

  // Check for existing active request
  const existing = await getActiveRecoveryRequest(user.id);
  if (existing) {
    return c.json({ success: true, message: 'A recovery request is already active. Recovery emails have been re-sent.' });
  }

  // Create recovery request (7 day expiry, 2-of-3 threshold)
  const requestId = generateToken();
  const expiresAt = Date.now() + 7 * 24 * 60 * 60 * 1000;
  await createRecoveryRequest(requestId, user.id, 2, expiresAt);

  // Notify account owner about the recovery initiation
  notify({
    userDid: user.id,
    category: 'system',
    eventType: 'recovery_initiated',
    title: 'Account recovery initiated',
    body: `A recovery request was initiated for your account. ${accepted.length} guardians have been contacted.`,
    metadata: { recoveryRequestId: requestId, threshold: 2, totalGuardians: accepted.length },
  }).catch(() => {});

  // Create approval tokens and notify guardians
  for (const guardian of accepted) {
    const approvalToken = generateToken();
    await createRecoveryApproval(requestId, guardian.id, approvalToken);

    const approveUrl = `https://auth.rspace.online/approve?token=${approvalToken}`;

    // In-app notification for guardians with rSpace accounts
    if (guardian.guardianUserId) {
      notify({
        userDid: guardian.guardianUserId,
        category: 'system',
        eventType: 'recovery_initiated',
        title: `${user.username} needs your help to recover their account`,
        body: 'Review and approve the recovery request if legitimate.',
        actionUrl: approveUrl,
        actorUsername: user.username,
        metadata: { recoveryRequestId: requestId, guardianId: guardian.id },
      }).catch(() => {});
    }

    // Send email if available
    if (guardian.email && smtpTransport) {
      try {
        await smtpTransport.sendMail({
          from: CONFIG.smtp.from,
          to: guardian.email,
          subject: `Recovery request from ${user.username} — rStack`,
          text: [
            `Hi ${guardian.name},`,
            '',
            `${user.username} is trying to recover their rStack Identity account and needs your help.`,
            '',
            'To approve this recovery, click the link below:',
            approveUrl,
            '',
            'This request expires in 7 days. 2 of 3 guardians must approve.',
            '',
            'If you did not expect this, do NOT click the link.',
            '',
            '— rStack Identity',
          ].join('\n'),
          html: `
<!DOCTYPE html><html><head><meta charset="utf-8"></head>
<body style="margin:0;padding:0;background:#1a1a2e;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
  <table width="100%" cellpadding="0" cellspacing="0" style="background:#1a1a2e;padding:40px 20px;">
    <tr><td align="center">
      <table width="480" cellpadding="0" cellspacing="0" style="background:#16213e;border-radius:12px;border:1px solid rgba(255,255,255,0.1);">
        <tr><td style="padding:32px 32px 24px;text-align:center;">
          <div style="font-size:36px;margin-bottom:8px;">&#128272;</div>
          <h1 style="margin:0;font-size:22px;background:linear-gradient(90deg,#00d4ff,#7c3aed);-webkit-background-clip:text;-webkit-text-fill-color:transparent;">Recovery Request</h1>
        </td></tr>
        <tr><td style="padding:0 32px 24px;color:#e2e8f0;font-size:15px;line-height:1.6;">
          <p>Hi <strong>${guardian.name}</strong>,</p>
          <p><strong>${user.username}</strong> is trying to recover their rStack Identity account and needs your approval.</p>
          <p style="color:#94a3b8;font-size:13px;">2 of 3 guardians must approve for recovery to proceed.</p>
        </td></tr>
        <tr><td style="padding:0 32px 32px;text-align:center;">
          <a href="${approveUrl}" style="display:inline-block;padding:12px 32px;background:linear-gradient(90deg,#22c55e,#16a34a);color:#fff;text-decoration:none;border-radius:8px;font-weight:600;font-size:15px;">Approve Recovery</a>
        </td></tr>
        <tr><td style="padding:0 32px 32px;color:#94a3b8;font-size:13px;line-height:1.5;">
          <p>This request expires in <strong>7 days</strong>.</p>
          <p style="color:#ef4444;margin-top:8px;">If you did not expect this request, do NOT click the button.</p>
          <p style="margin-top:16px;padding-top:16px;border-top:1px solid rgba(255,255,255,0.1);font-size:12px;color:#64748b;">
            Can't click the button? Copy this link:<br>
            <span style="color:#94a3b8;word-break:break-all;">${approveUrl}</span>
          </p>
        </td></tr>
      </table>
    </td></tr>
  </table>
</body></html>`,
        });
      } catch (err) {
        console.error(`EncryptID: Failed to send recovery approval email to ${guardian.email}:`, err);
      }
    }
  }

  return c.json({ success: true, message: 'If the account exists and has guardians, recovery emails have been sent.' });
});

/**
 * GET /approve — one-click guardian approval page
 */
app.get('/approve', (c) => {
  return c.html(`
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Approve Recovery — rStack Identity</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); min-height: 100vh; display: flex; align-items: center; justify-content: center; color: #fff; padding: 1rem; }
    .card { background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); border-radius: 16px; padding: 2.5rem; max-width: 440px; width: 100%; text-align: center; }
    .icon { font-size: 3rem; margin-bottom: 0.5rem; }
    h1 { font-size: 1.5rem; margin-bottom: 0.5rem; background: linear-gradient(90deg, #00d4ff, #7c3aed); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
    .sub { color: #94a3b8; margin-bottom: 1.5rem; font-size: 0.9rem; line-height: 1.5; }
    .status { padding: 1rem; border-radius: 8px; margin-bottom: 1rem; font-size: 0.9rem; line-height: 1.5; }
    .status.loading { background: rgba(6,182,212,0.1); color: #06b6d4; }
    .status.success { background: rgba(34,197,94,0.1); color: #22c55e; }
    .status.error { background: rgba(239,68,68,0.1); color: #ef4444; }
    .status.warning { background: rgba(234,179,8,0.1); color: #eab308; }
    .btn { display: inline-block; padding: 0.75rem 2rem; background: linear-gradient(90deg, #22c55e, #16a34a); color: #fff; border: none; border-radius: 8px; font-weight: 600; font-size: 1rem; cursor: pointer; transition: transform 0.2s; margin-top: 1rem; }
    .btn:hover { transform: translateY(-2px); }
    .btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
    .hidden { display: none; }
  </style>
</head>
<body>
  <div class="card">
    <div class="icon">&#128272;</div>
    <h1>Recovery Approval</h1>
    <div id="status" class="status loading">Verifying approval link...</div>
    <button id="approve-btn" class="btn hidden" onclick="doApprove()">Confirm Approval</button>
  </div>

  <script>
    const params = new URLSearchParams(window.location.search);
    const approvalToken = params.get('token');
    const statusEl = document.getElementById('status');
    const btn = document.getElementById('approve-btn');

    async function init() {
      if (!approvalToken) {
        statusEl.className = 'status error';
        statusEl.textContent = 'No approval token found in link.';
        return;
      }
      // Show confirmation step
      statusEl.className = 'status warning';
      statusEl.textContent = 'You are about to approve a recovery request. Only do this if the account owner contacted you directly and asked for help recovering their account.';
      btn.classList.remove('hidden');
    }

    async function doApprove() {
      btn.disabled = true;
      btn.textContent = 'Approving...';
      try {
        const res = await fetch('/api/recovery/social/approve', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ approvalToken }),
        });
        const data = await res.json();
        if (data.error) {
          statusEl.className = 'status error';
          statusEl.textContent = data.error;
          btn.classList.add('hidden');
          return;
        }
        statusEl.className = 'status success';
        statusEl.innerHTML = 'Recovery approved! <strong>' + data.approvalCount + ' of ' + data.threshold + '</strong> approvals received.' +
          (data.approvalCount >= data.threshold ? '<br><br>The account owner can now recover their account.' : '<br><br>Waiting for more guardians to approve.');
        btn.classList.add('hidden');
      } catch {
        statusEl.className = 'status error';
        statusEl.textContent = 'Failed to process approval.';
        btn.disabled = false;
        btn.textContent = 'Confirm Approval';
      }
    }

    init();
  </script>
</body>
</html>
  `);
});

/**
 * POST /api/recovery/social/approve — approve via token (no auth needed, token is proof)
 */
app.post('/api/recovery/social/approve', async (c) => {
  const { approvalToken } = await c.req.json();
  if (!approvalToken) return c.json({ error: 'Approval token required' }, 400);

  const result = await approveRecoveryByToken(approvalToken);
  if (!result) return c.json({ error: 'Invalid or already used approval link' }, 400);

  // Get updated request
  const request = await getRecoveryRequest(result.requestId);
  if (!request) return c.json({ error: 'Recovery request not found' }, 404);

  // Check if threshold met
  if (request.approvalCount >= request.threshold && request.status === 'pending') {
    await updateRecoveryRequestStatus(request.id, 'approved');

    // Notify account owner that recovery is approved
    notify({
      userDid: request.userId,
      category: 'system',
      eventType: 'recovery_approved',
      title: 'Account recovery approved',
      body: `${request.approvalCount}/${request.threshold} guardians approved. You can now recover your account.`,
      metadata: { recoveryRequestId: request.id },
    }).catch(() => {});
  }

  return c.json({
    success: true,
    approvalCount: request.approvalCount,
    threshold: request.threshold,
    status: request.approvalCount >= request.threshold ? 'approved' : 'pending',
  });
});

/**
 * GET /api/recovery/social/:id/status — check recovery request status
 */
app.get('/api/recovery/social/:id/status', async (c) => {
  const { id } = c.req.param();
  const request = await getRecoveryRequest(id);
  if (!request) return c.json({ error: 'Not found' }, 404);

  const approvals = await getRecoveryApprovals(request.id);
  return c.json({
    id: request.id,
    status: request.status,
    threshold: request.threshold,
    approvalCount: request.approvalCount,
    approvals: approvals.map(a => ({
      guardianId: a.guardianId,
      approved: !!a.approvedAt,
    })),
    expiresAt: request.expiresAt,
  });
});

/**
 * POST /api/recovery/social/:id/complete — finalize recovery (register new passkey)
 * Only works when status is 'approved'
 */
app.post('/api/recovery/social/:id/complete', async (c) => {
  const { id } = c.req.param();
  const request = await getRecoveryRequest(id);
  if (!request) return c.json({ error: 'Not found' }, 404);
  if (request.status !== 'approved') return c.json({ error: 'Recovery not yet approved. ' + request.approvalCount + '/' + request.threshold + ' approvals.' }, 400);
  if (Date.now() > request.expiresAt) {
    await updateRecoveryRequestStatus(id, 'expired');
    return c.json({ error: 'Recovery request expired' }, 400);
  }

  // Mark completed and issue a recovery session token
  await updateRecoveryRequestStatus(id, 'completed');

  const user = await getUserById(request.userId);
  if (!user) return c.json({ error: 'User not found' }, 404);

  const token = await generateSessionToken(request.userId, user.username);

  return c.json({
    success: true,
    token,
    userId: request.userId,
    username: user.username,
    message: 'Recovery complete. Use this token to register a new passkey.',
  });
});

// ============================================================================
// DEVICE LINKING ROUTES
// ============================================================================

/**
 * POST /api/device-link/start — generate a device link token (authenticated)
 */
app.post('/api/device-link/start', async (c) => {
  const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
  if (!claims) return c.json({ error: 'Unauthorized' }, 401);

  const token = generateToken();
  const expiresAt = Date.now() + 10 * 60 * 1000; // 10 minutes
  await createDeviceLink(token, claims.sub as string, expiresAt);

  const linkUrl = `https://auth.rspace.online/link?token=${token}`;
  return c.json({ token, linkUrl, expiresAt });
});

/**
 * GET /api/device-link/:token/info — get link info (unauthenticated)
 */
app.get('/api/device-link/:token/info', async (c) => {
  const { token } = c.req.param();
  const link = await getDeviceLink(token);
  if (!link) return c.json({ error: 'Invalid link' }, 404);
  if (link.used) return c.json({ error: 'Link already used' }, 400);
  if (Date.now() > link.expiresAt) return c.json({ error: 'Link expired' }, 400);

  const user = await getUserById(link.userId);
  return c.json({
    username: user?.username || 'Unknown',
    expiresAt: link.expiresAt,
  });
});

/**
 * POST /api/device-link/:token/complete — register new credential on linked device
 */
app.post('/api/device-link/:token/complete', async (c) => {
  const { token } = c.req.param();
  const link = await getDeviceLink(token);
  if (!link) return c.json({ error: 'Invalid link' }, 404);
  if (link.used) return c.json({ error: 'Link already used' }, 400);
  if (Date.now() > link.expiresAt) return c.json({ error: 'Link expired' }, 400);

  const { credential } = await c.req.json();
  if (!credential?.credentialId || !credential?.publicKey) {
    return c.json({ error: 'Credential data required' }, 400);
  }

  const user = await getUserById(link.userId);
  if (!user) return c.json({ error: 'User not found' }, 404);

  // Store the new credential under the same user
  const rpId = resolveRpId(c);
  await storeCredential({
    credentialId: credential.credentialId,
    publicKey: credential.publicKey,
    userId: link.userId,
    username: user.username,
    counter: 0,
    createdAt: Date.now(),
    transports: credential.transports,
    rpId,
  });

  await markDeviceLinkUsed(token);

  console.log('EncryptID: Second device linked for', user.username);
  return c.json({ success: true, message: 'Device linked successfully' });
});

/**
 * GET /link — device linking page (scanned from QR or opened from email)
 */
app.get('/link', (c) => {
  return c.html(`
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Link Device — rStack Identity</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); min-height: 100vh; display: flex; align-items: center; justify-content: center; color: #fff; padding: 1rem; }
    .card { background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); border-radius: 16px; padding: 2.5rem; max-width: 440px; width: 100%; text-align: center; }
    .icon { font-size: 3rem; margin-bottom: 0.5rem; }
    h1 { font-size: 1.5rem; margin-bottom: 0.5rem; background: linear-gradient(90deg, #00d4ff, #7c3aed); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
    .sub { color: #94a3b8; margin-bottom: 1.5rem; font-size: 0.9rem; }
    .status { padding: 1rem; border-radius: 8px; margin-bottom: 1rem; font-size: 0.9rem; }
    .status.loading { background: rgba(6,182,212,0.1); color: #06b6d4; }
    .status.success { background: rgba(34,197,94,0.1); color: #22c55e; }
    .status.error { background: rgba(239,68,68,0.1); color: #ef4444; }
    .btn { display: inline-block; padding: 0.75rem 2rem; background: linear-gradient(90deg, #00d4ff, #7c3aed); color: #fff; border: none; border-radius: 8px; font-weight: 600; font-size: 1rem; cursor: pointer; transition: transform 0.2s; }
    .btn:hover { transform: translateY(-2px); }
    .btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
    .hidden { display: none; }
  </style>
</head>
<body>
  <div class="card">
    <div class="icon">&#128241;</div>
    <h1>Link This Device</h1>
    <div id="status" class="status loading">Loading...</div>
    <button id="link-btn" class="btn hidden" onclick="linkDevice()">Create Passkey on This Device</button>
    <div id="done" class="hidden">
      <p style="color:#94a3b8;margin-top:1rem;font-size:0.9rem;">You can now sign in from this device using your passkey.</p>
    </div>
  </div>

  <script>
    const params = new URLSearchParams(window.location.search);
    const linkToken = params.get('token');
    const statusEl = document.getElementById('status');
    const btn = document.getElementById('link-btn');

    let linkInfo = null;

    async function init() {
      if (!linkToken) {
        statusEl.className = 'status error';
        statusEl.textContent = 'No link token found.';
        return;
      }
      try {
        const res = await fetch('/api/device-link/' + encodeURIComponent(linkToken) + '/info');
        const data = await res.json();
        if (data.error) {
          statusEl.className = 'status error';
          statusEl.textContent = data.error;
          return;
        }
        linkInfo = data;
        statusEl.className = 'status success';
        statusEl.textContent = 'Link this device to ' + data.username + '\\'s account';
        btn.classList.remove('hidden');
      } catch {
        statusEl.className = 'status error';
        statusEl.textContent = 'Failed to load link info.';
      }
    }

    function b64url(buffer) {
      return btoa(String.fromCharCode(...new Uint8Array(buffer)))
        .replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');
    }

    async function linkDevice() {
      btn.disabled = true;
      btn.textContent = 'Creating passkey...';
      try {
        // Get registration options from the server
        const startRes = await fetch('/api/register/start', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ username: linkInfo.username }),
        });
        const { options, userId } = await startRes.json();

        // Decode for WebAuthn
        options.challenge = Uint8Array.from(atob(options.challenge.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0));
        options.user.id = Uint8Array.from(atob(options.user.id.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0));

        const credential = await navigator.credentials.create({ publicKey: options });

        const credentialData = {
          credentialId: b64url(credential.rawId),
          publicKey: b64url(credential.response.getPublicKey?.() || new ArrayBuffer(0)),
          transports: credential.response.getTransports?.() || [],
        };

        // Complete via device-link endpoint (not register/complete)
        const completeRes = await fetch('/api/device-link/' + encodeURIComponent(linkToken) + '/complete', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ credential: credentialData }),
        });
        const data = await completeRes.json();
        if (data.error) throw new Error(data.error);

        statusEl.className = 'status success';
        statusEl.textContent = 'Device linked successfully!';
        btn.classList.add('hidden');
        document.getElementById('done').classList.remove('hidden');
      } catch (err) {
        statusEl.className = 'status error';
        statusEl.textContent = 'Failed: ' + err.message;
        btn.disabled = false;
        btn.textContent = 'Create Passkey on This Device';
      }
    }

    init();
  </script>
</body>
</html>
  `);
});

// ============================================================================
// SHARED AUTH HELPER
// ============================================================================

// Helper: verify JWT and return claims, or null
async function verifyTokenFromRequest(authorization: string | undefined): Promise<{
  sub: string; did?: string; username?: string; eid?: any;
} | null> {
  if (!authorization?.startsWith('Bearer ')) return null;
  const token = authorization.slice(7);
  try {
    const payload = await verify(token, CONFIG.jwtSecret, 'HS256');
    return payload as { sub: string; did?: string; username?: string; eid?: any };
  } catch {
    return null;
  }
}

// ============================================================================
// WALLET CAPABILITY ROUTES
// ============================================================================

// POST /encryptid/api/wallet-capability — Enable wallet capability for this user.
// Sets wallet_address on the user profile. Safe associations are stored
// client-side only (encrypted localStorage) to avoid server-side correlation
// between identity and on-chain addresses.
app.post('/encryptid/api/wallet-capability', async (c) => {
  const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
  if (!claims) return c.json({ error: 'Authentication required' }, 401);

  const { walletAddress } = await c.req.json();
  if (!walletAddress || !/^0x[0-9a-fA-F]{40}$/.test(walletAddress)) {
    return c.json({ error: 'Valid Ethereum address required' }, 400);
  }

  await updateUserProfile(claims.sub, { walletAddress });
  return c.json({ enabled: true, walletAddress });
});

// DELETE /encryptid/api/wallet-capability — Disable wallet capability.
app.delete('/encryptid/api/wallet-capability', async (c) => {
  const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
  if (!claims) return c.json({ error: 'Authentication required' }, 401);

  await updateUserProfile(claims.sub, { walletAddress: null });
  return c.json({ enabled: false });
});

// POST /encryptid/api/safe/verify — Check if EOA is owner on a Safe (stateless proxy).
// No server-side state — just proxies Safe Transaction Service API.
app.post('/encryptid/api/safe/verify', async (c) => {
  const { safeAddress, chainId, eoaAddress } = await c.req.json();
  if (!safeAddress || !eoaAddress) {
    return c.json({ error: 'safeAddress and eoaAddress are required' }, 400);
  }

  const chain = chainId || 84532;
  const CHAIN_PREFIXES: Record<number, string> = {
    1: 'mainnet', 10: 'optimism', 100: 'gnosis-chain', 137: 'polygon',
    8453: 'base', 42161: 'arbitrum', 42220: 'celo', 43114: 'avalanche',
    56: 'bsc', 324: 'zksync', 11155111: 'sepolia', 84532: 'base-sepolia',
  };
  const prefix = CHAIN_PREFIXES[chain];
  if (!prefix) {
    return c.json({ error: 'Unsupported chain' }, 400);
  }

  try {
    const safeRes = await fetch(
      `https://safe-transaction-${prefix}.safe.global/api/v1/safes/${safeAddress}/`,
    );
    if (!safeRes.ok) {
      return c.json({ isOwner: false, error: 'Safe not found' });
    }

    const safeData = await safeRes.json() as { owners?: string[]; threshold?: number; nonce?: number };
    const owners = (safeData.owners || []).map((o: string) => o.toLowerCase());
    const isOwner = owners.includes(eoaAddress.toLowerCase());

    return c.json({
      isOwner,
      safeAddress,
      chainId: chain,
      threshold: safeData.threshold,
      ownerCount: owners.length,
      nonce: safeData.nonce,
    });
  } catch {
    return c.json({ isOwner: false, error: 'Failed to query Safe' }, 502);
  }
});

// ============================================================================
// SPACE MEMBERSHIP ROUTES
// ============================================================================

const VALID_ROLES = ['viewer', 'member', 'moderator', 'admin'];

// GET /api/spaces/:slug/members — list all members
app.get('/api/spaces/:slug/members', async (c) => {
  const { slug } = c.req.param();
  const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
  if (!claims) {
    return c.json({ error: 'Authentication required' }, 401);
  }

  const members = await listSpaceMembers(slug);
  return c.json({
    members: members.map((m) => ({
      userDID: m.userDID,
      spaceSlug: m.spaceSlug,
      role: m.role,
      joinedAt: m.joinedAt,
      grantedBy: m.grantedBy,
    })),
    total: members.length,
  });
});

// GET /api/spaces/:slug/members/:did — get one member's role
app.get('/api/spaces/:slug/members/:did', async (c) => {
  const { slug, did } = c.req.param();
  const member = await getSpaceMember(slug, decodeURIComponent(did));
  if (!member) {
    return c.json({ error: 'Member not found' }, 404);
  }
  return c.json({
    userDID: member.userDID,
    spaceSlug: member.spaceSlug,
    role: member.role,
    joinedAt: member.joinedAt,
    grantedBy: member.grantedBy,
  });
});

// POST /api/spaces/:slug/members — add or update a member
app.post('/api/spaces/:slug/members', async (c) => {
  const { slug } = c.req.param();
  const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
  if (!claims) {
    return c.json({ error: 'Authentication required' }, 401);
  }

  // Verify caller is admin in this space (or first member → becomes admin)
  const callerMember = await getSpaceMember(slug, claims.sub);
  const members = await listSpaceMembers(slug);

  // If space has no members, the first person to add becomes admin
  const isFirstMember = members.length === 0;
  if (!isFirstMember && (!callerMember || callerMember.role !== 'admin')) {
    return c.json({ error: 'Admin role required' }, 403);
  }

  const body = await c.req.json<{ userDID: string; role: string }>();
  if (!body.userDID || !body.role) {
    return c.json({ error: 'userDID and role are required' }, 400);
  }
  if (!VALID_ROLES.includes(body.role)) {
    return c.json({ error: `Invalid role. Must be one of: ${VALID_ROLES.join(', ')}` }, 400);
  }

  const member = await upsertSpaceMember(slug, body.userDID, body.role, claims.sub);
  return c.json({
    userDID: member.userDID,
    spaceSlug: member.spaceSlug,
    role: member.role,
    joinedAt: member.joinedAt,
    grantedBy: member.grantedBy,
  }, 201);
});

// DELETE /api/spaces/:slug/members/:did — remove a member
app.delete('/api/spaces/:slug/members/:did', async (c) => {
  const { slug, did } = c.req.param();
  const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
  if (!claims) {
    return c.json({ error: 'Authentication required' }, 401);
  }

  const callerMember = await getSpaceMember(slug, claims.sub);
  if (!callerMember || callerMember.role !== 'admin') {
    return c.json({ error: 'Admin role required' }, 403);
  }

  const removed = await removeSpaceMember(slug, decodeURIComponent(did));
  if (!removed) {
    return c.json({ error: 'Member not found' }, 404);
  }
  return c.json({ success: true });
});

// ============================================================================
// USER LOOKUP
// ============================================================================

// GET /api/users/lookup?username=<username> — look up user by username
app.get('/api/users/lookup', async (c) => {
  const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
  if (!claims) return c.json({ error: 'Authentication required' }, 401);

  const username = c.req.query('username');
  if (!username) return c.json({ error: 'username query parameter required' }, 400);

  const user = await getUserByUsername(username);
  if (!user) return c.json({ error: 'User not found' }, 404);

  return c.json({
    did: user.did,
    username: user.username,
    displayName: user.display_name || user.username,
  });
});

// ============================================================================
// SPACE INVITE ROUTES
// ============================================================================

// POST /api/spaces/:slug/invites — create an invite
app.post('/api/spaces/:slug/invites', async (c) => {
  const { slug } = c.req.param();
  const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
  if (!claims) return c.json({ error: 'Authentication required' }, 401);

  // Require admin role
  const callerMember = await getSpaceMember(slug, claims.sub);
  if (!callerMember || callerMember.role !== 'admin') {
    return c.json({ error: 'Admin role required' }, 403);
  }

  const body = await c.req.json<{ email?: string; role?: string }>();
  const role = body.role || 'member';
  if (!VALID_ROLES.includes(role)) {
    return c.json({ error: `Invalid role. Must be one of: ${VALID_ROLES.join(', ')}` }, 400);
  }

  const id = crypto.randomUUID();
  const token = crypto.randomUUID();
  const expiresAt = Date.now() + 7 * 24 * 60 * 60 * 1000; // 7 days

  const invite = await createSpaceInvite(id, slug, token, claims.sub, role, expiresAt, body.email || undefined);
  return c.json(invite, 201);
});

// GET /api/spaces/:slug/invites — list invites for a space
app.get('/api/spaces/:slug/invites', async (c) => {
  const { slug } = c.req.param();
  const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
  if (!claims) return c.json({ error: 'Authentication required' }, 401);

  const callerMember = await getSpaceMember(slug, claims.sub);
  if (!callerMember || callerMember.role !== 'admin') {
    return c.json({ error: 'Admin role required' }, 403);
  }

  const invites = await listSpaceInvites(slug);
  return c.json({ invites, total: invites.length });
});

// POST /api/spaces/:slug/invites/:id/revoke — revoke an invite
app.post('/api/spaces/:slug/invites/:id/revoke', async (c) => {
  const { slug, id } = c.req.param();
  const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
  if (!claims) return c.json({ error: 'Authentication required' }, 401);

  const callerMember = await getSpaceMember(slug, claims.sub);
  if (!callerMember || callerMember.role !== 'admin') {
    return c.json({ error: 'Admin role required' }, 403);
  }

  const revoked = await revokeSpaceInvite(id, slug);
  if (!revoked) return c.json({ error: 'Invite not found or already used' }, 404);
  return c.json({ ok: true });
});

// GET /api/invites/:token — get invite details by token (public, no auth needed)
app.get('/api/invites/:token', async (c) => {
  const { token } = c.req.param();
  const invite = await getSpaceInviteByToken(token);
  if (!invite) return c.json({ error: 'Invite not found' }, 404);
  if (invite.status !== 'pending') return c.json({ error: `Invite ${invite.status}` }, 410);
  if (invite.expiresAt < Date.now()) return c.json({ error: 'Invite expired' }, 410);
  return c.json({
    spaceSlug: invite.spaceSlug,
    role: invite.role,
    expiresAt: invite.expiresAt,
  });
});

// POST /api/invites/:token/accept — accept an invite
app.post('/api/invites/:token/accept', async (c) => {
  const { token } = c.req.param();
  const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
  if (!claims) return c.json({ error: 'Authentication required — sign in to accept' }, 401);

  const invite = await getSpaceInviteByToken(token);
  if (!invite) return c.json({ error: 'Invite not found' }, 404);
  if (invite.status !== 'pending') return c.json({ error: `Invite ${invite.status}` }, 410);
  if (invite.expiresAt < Date.now()) return c.json({ error: 'Invite expired' }, 410);

  // Accept the invite
  const accepted = await acceptSpaceInvite(token, claims.sub);
  if (!accepted) return c.json({ error: 'Failed to accept invite' }, 500);

  // Add to space_members with the invite's role
  await upsertSpaceMember(accepted.spaceSlug, claims.sub, accepted.role, accepted.invitedBy);

  return c.json({
    ok: true,
    spaceSlug: accepted.spaceSlug,
    role: accepted.role,
  });
});

// ============================================================================
// ADMIN ROUTES
// ============================================================================

function isAdmin(did: string | undefined): boolean {
  if (!did || CONFIG.adminDIDs.length === 0) return false;
  return CONFIG.adminDIDs.includes(did);
}

// GET /api/admin/users — list all users (admin only)
app.get('/api/admin/users', async (c) => {
  const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
  if (!claims) return c.json({ error: 'Authentication required' }, 401);
  if (!isAdmin(claims.sub)) return c.json({ error: 'Admin access required' }, 403);

  const users = await listAllUsers();
  return c.json({ users, total: users.length });
});

// DELETE /api/admin/users/:userId — delete a user (admin only)
app.delete('/api/admin/users/:userId', async (c) => {
  const userId = c.req.param('userId');
  const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
  if (!claims) return c.json({ error: 'Authentication required' }, 401);
  if (!isAdmin(claims.sub)) return c.json({ error: 'Admin access required' }, 403);

  if (userId === claims.sub) {
    return c.json({ error: 'Cannot delete your own account' }, 400);
  }

  const deleted = await deleteUser(userId);
  if (!deleted) return c.json({ error: 'User not found' }, 404);
  return c.json({ ok: true, message: `User ${userId} deleted` });
});

// DELETE /api/admin/spaces/:slug/members — clean up space members (admin only)
app.delete('/api/admin/spaces/:slug/members', async (c) => {
  const slug = c.req.param('slug');
  const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
  if (!claims) return c.json({ error: 'Authentication required' }, 401);
  if (!isAdmin(claims.sub)) return c.json({ error: 'Admin access required' }, 403);

  const count = await deleteSpaceMembers(slug);
  return c.json({ ok: true, removed: count });
});

// ============================================================================
// SERVE STATIC FILES
// ============================================================================

// Serve demo page and static assets
app.get('/demo.html', serveStatic({ path: './src/encryptid/demo.html' }));
// Serve bundled JavaScript modules
app.use('/dist/*', serveStatic({ root: './src/encryptid/' }));
app.use('/demo/*', serveStatic({ root: './src/encryptid/' }));
app.use('/static/*', serveStatic({ root: './public/' }));

// Serve index — landing page with real auth
app.get('/', (c) => {
  return c.html(`
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>rStack Identity — One Passkey for the r* Ecosystem</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
      background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
      min-height: 100vh;
      color: #fff;
    }
    .page { max-width: 720px; margin: 0 auto; padding: 3rem 1.5rem; }

    /* Header */
    .header { text-align: center; margin-bottom: 2.5rem; }
    .header h1 {
      font-size: 2.5rem;
      background: linear-gradient(90deg, #00d4ff, #7c3aed);
      -webkit-background-clip: text;
      -webkit-text-fill-color: transparent;
    }
    .header .tagline { font-size: 1.1rem; color: #94a3b8; margin-top: 0.5rem; }

    /* Auth card */
    .auth-card {
      background: rgba(255,255,255,0.06);
      border: 1px solid rgba(255,255,255,0.12);
      border-radius: 1rem;
      padding: 2rem;
      margin-bottom: 2.5rem;
    }
    .auth-card h2 { font-size: 1.25rem; margin-bottom: 0.25rem; }
    .auth-card .sub { color: #94a3b8; font-size: 0.875rem; margin-bottom: 1.5rem; }

    /* Tabs */
    .tabs { display: flex; border-radius: 0.5rem; overflow: hidden; border: 1px solid rgba(255,255,255,0.15); margin-bottom: 1.5rem; }
    .tab { flex: 1; padding: 0.6rem; text-align: center; font-size: 0.875rem; font-weight: 600; cursor: pointer; transition: background 0.2s; background: transparent; color: #94a3b8; border: none; }
    .tab.active { background: linear-gradient(90deg, #00d4ff, #7c3aed); color: #fff; }

    /* Form */
    .form-group { margin-bottom: 1rem; }
    .form-group label { display: block; font-size: 0.8rem; color: #94a3b8; margin-bottom: 0.4rem; font-weight: 500; }
    .form-group input {
      width: 100%; padding: 0.7rem 1rem; border-radius: 0.5rem; border: 1px solid rgba(255,255,255,0.15);
      background: rgba(255,255,255,0.05); color: #fff; font-size: 0.95rem; outline: none; transition: border 0.2s;
    }
    .form-group input:focus { border-color: #7c3aed; }
    .form-group input::placeholder { color: #475569; }

    .btn-primary {
      width: 100%; padding: 0.75rem; border-radius: 0.5rem; border: none;
      background: linear-gradient(90deg, #00d4ff, #7c3aed); color: #fff;
      font-size: 1rem; font-weight: 600; cursor: pointer; transition: transform 0.15s, opacity 0.15s;
    }
    .btn-primary:hover { transform: translateY(-1px); }
    .btn-primary:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }

    .error { background: rgba(239,68,68,0.15); border: 1px solid rgba(239,68,68,0.3); border-radius: 0.5rem; padding: 0.6rem 1rem; font-size: 0.85rem; color: #fca5a5; margin-bottom: 1rem; display: none; }
    .success { background: rgba(34,197,94,0.15); border: 1px solid rgba(34,197,94,0.3); border-radius: 0.5rem; padding: 0.6rem 1rem; font-size: 0.85rem; color: #86efac; margin-bottom: 1rem; display: none; }

    /* Profile (shown when logged in) */
    .profile { display: none; }
    .profile-header { display: flex; align-items: center; gap: 1rem; margin-bottom: 1.5rem; }
    .profile-avatar { width: 56px; height: 56px; border-radius: 50%; background: linear-gradient(135deg, #00d4ff, #7c3aed); display: flex; align-items: center; justify-content: center; font-size: 1.5rem; font-weight: 700; flex-shrink: 0; }
    .profile-info h3 { font-size: 1.1rem; }
    .profile-info .did { font-family: monospace; font-size: 0.75rem; color: #94a3b8; word-break: break-all; }
    .profile-row { display: flex; justify-content: space-between; padding: 0.6rem 0; border-bottom: 1px solid rgba(255,255,255,0.08); font-size: 0.875rem; }
    .profile-row .label { color: #94a3b8; }
    .profile-actions { display: flex; gap: 0.75rem; margin-top: 1.5rem; flex-wrap: wrap; }
    .btn-secondary {
      padding: 0.6rem 1.25rem; border-radius: 0.5rem; border: 1px solid rgba(255,255,255,0.15);
      background: transparent; color: #fff; font-size: 0.85rem; font-weight: 500; cursor: pointer; transition: background 0.2s;
    }
    .btn-secondary:hover { background: rgba(255,255,255,0.08); }
    .btn-danger { border-color: rgba(239,68,68,0.4); color: #fca5a5; }
    .btn-danger:hover { background: rgba(239,68,68,0.15); }

    /* Passkeys list */
    .passkeys { margin-top: 1.5rem; }
    .passkeys h4 { font-size: 0.9rem; margin-bottom: 0.75rem; color: #94a3b8; }
    .passkey-item { display: flex; justify-content: space-between; align-items: center; padding: 0.6rem 0.75rem; background: rgba(255,255,255,0.04); border-radius: 0.5rem; margin-bottom: 0.5rem; font-size: 0.8rem; }
    .passkey-id { font-family: monospace; color: #94a3b8; }
    .passkey-date { color: #64748b; }

    /* Recovery email */
    .recovery-section, .guardians-section, .devices-section, .vault-section { margin-top: 1.5rem; padding-top: 1.5rem; border-top: 1px solid rgba(255,255,255,0.08); }
    .recovery-section h4, .guardians-section h4, .devices-section h4, .vault-section h4 { font-size: 0.9rem; margin-bottom: 0.75rem; color: #94a3b8; }
    .recovery-row { display: flex; gap: 0.5rem; }
    .recovery-row input { flex: 1; }
    .recovery-row button { white-space: nowrap; }

    /* Guardian items */
    .guardian-item { display: flex; justify-content: space-between; align-items: center; padding: 0.6rem 0.75rem; background: rgba(255,255,255,0.04); border-radius: 0.5rem; margin-bottom: 0.5rem; font-size: 0.85rem; }
    .guardian-name { font-weight: 500; }
    .guardian-status { font-size: 0.75rem; padding: 0.15rem 0.5rem; border-radius: 1rem; }
    .guardian-status.pending { background: rgba(234,179,8,0.15); color: #eab308; }
    .guardian-status.accepted { background: rgba(34,197,94,0.15); color: #22c55e; }
    .guardian-add { display: flex; gap: 0.5rem; margin-top: 0.75rem; }
    .guardian-add input { flex: 1; padding: 0.5rem 0.75rem; border-radius: 0.5rem; border: 1px solid rgba(255,255,255,0.15); background: rgba(255,255,255,0.05); color: #fff; font-size: 0.85rem; outline: none; }
    .guardian-add input:focus { border-color: #7c3aed; }

    /* Setup checklist */
    .setup-checklist { margin-top: 1.5rem; padding-top: 1.5rem; border-top: 1px solid rgba(255,255,255,0.08); }
    .setup-checklist h4 { font-size: 0.9rem; margin-bottom: 0.75rem; color: #94a3b8; }
    .check-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0; font-size: 0.85rem; }
    .check-icon { width: 20px; height: 20px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 0.7rem; flex-shrink: 0; }
    .check-icon.done { background: rgba(34,197,94,0.2); color: #22c55e; }
    .check-icon.todo { background: rgba(255,255,255,0.08); color: #64748b; }

    /* Invite link display */
    .invite-link { background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.1); border-radius: 0.5rem; padding: 0.6rem; font-family: monospace; font-size: 0.75rem; color: #94a3b8; word-break: break-all; margin-top: 0.5rem; cursor: pointer; position: relative; }
    .invite-link:hover { border-color: #7c3aed; }
    .invite-link .copy-hint { position: absolute; right: 0.5rem; top: 50%; transform: translateY(-50%); font-family: sans-serif; font-size: 0.7rem; color: #7c3aed; }

    /* QR placeholder */
    .qr-container { display: flex; flex-direction: column; align-items: center; gap: 0.75rem; margin-top: 0.75rem; }
    .qr-container canvas { border-radius: 8px; background: #fff; padding: 8px; }
    .or-divider { color: #64748b; font-size: 0.8rem; margin: 0.5rem 0; }

    /* Features */
    .features { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1rem; margin-bottom: 2rem; }
    .feature { background: rgba(255,255,255,0.04); padding: 1rem; border-radius: 0.5rem; border: 1px solid rgba(255,255,255,0.08); text-align: center; }
    .feature-title { font-weight: 600; font-size: 0.9rem; margin-bottom: 0.25rem; }
    .feature-desc { font-size: 0.8rem; color: #94a3b8; }

    /* Apps bar */
    .apps { text-align: center; padding-top: 2rem; border-top: 1px solid rgba(255,255,255,0.08); }
    .apps-title { font-size: 0.8rem; color: #64748b; margin-bottom: 0.75rem; }
    .app-links { display: flex; flex-wrap: wrap; justify-content: center; gap: 1rem; }
    .app-links a { color: #64748b; text-decoration: none; font-size: 0.85rem; transition: color 0.2s; }
    .app-links a:hover { color: #00d4ff; }

    .link-row { text-align: center; margin-top: 1rem; font-size: 0.8rem; }
    .link-row a { color: #7c3aed; text-decoration: none; }
    .link-row a:hover { text-decoration: underline; }

    @media (max-width: 480px) {
      .features { grid-template-columns: 1fr; }
      .header h1 { font-size: 2rem; }
    }
  </style>
</head>
<body>
  <div class="page">
    <div class="header">
      <h1>rStack Identity</h1>
      <p class="tagline">One passkey for the <a href="https://rstack.online" style="color:#00d4ff;text-decoration:none">rStack.online</a> ecosystem</p>
    </div>

    <!-- Auth card: login/register when signed out, profile when signed in -->
    <div class="auth-card">
      <!-- Login/Register form -->
      <div id="auth-form">
        <h2>Get started</h2>
        <p class="sub">Sign in with your passkey or create a new account. No passwords, no tracking, no third parties.</p>

        <div class="tabs">
          <button class="tab active" id="tab-signin" onclick="switchTab('signin')">Sign In</button>
          <button class="tab" id="tab-register" onclick="switchTab('register')">Register</button>
        </div>

        <div id="error-msg" class="error"></div>
        <div id="success-msg" class="success"></div>

        <!-- Register fields (hidden in signin mode) -->
        <div id="register-fields" style="display:none">
          <div class="form-group">
            <label for="username">Username</label>
            <input id="username" type="text" placeholder="Choose a username" autocomplete="username" />
          </div>
          <div class="form-group">
            <label for="reg-email">Email <span style="color:#475569;font-weight:400">(optional — for account recovery)</span></label>
            <input id="reg-email" type="email" placeholder="you@example.com" autocomplete="email" />
          </div>
        </div>

        <button class="btn-primary" id="auth-btn" onclick="handleAuth()">Sign In with Passkey</button>

        <div class="link-row" id="recovery-link-row">
          <a href="#" onclick="showRecoveryForm(); return false;">Lost your device? Recover account</a>
        </div>

        <!-- Recovery form (hidden by default) -->
        <div id="recovery-form" style="display:none;margin-top:1rem;">
          <div class="form-group">
            <label for="recover-email">Email or Username</label>
            <input id="recover-email" type="text" placeholder="Enter your email or username" />
          </div>
          <button class="btn-primary" style="background:linear-gradient(90deg,#22c55e,#16a34a);" onclick="initiateRecovery()">Send Recovery Request</button>
          <div id="recover-msg" style="font-size:0.8rem;margin-top:0.5rem;display:none"></div>
        </div>
      </div>

      <!-- Profile (shown when logged in) -->
      <div id="profile" class="profile">
        <div class="profile-header">
          <div class="profile-avatar" id="profile-avatar">?</div>
          <div class="profile-info">
            <h3 id="profile-username">Loading...</h3>
            <div class="did" id="profile-did"></div>
          </div>
        </div>

        <div class="profile-row"><span class="label">Session</span><span id="profile-session">Active</span></div>
        <div class="profile-row"><span class="label">Token expires</span><span id="profile-expires">--</span></div>

        <div class="passkeys">
          <h4>Your Passkeys</h4>
          <div id="passkey-list"><div class="passkey-item"><span class="passkey-id">Loading...</span></div></div>
        </div>

        <div class="setup-checklist" id="setup-checklist">
          <h4>Account Security</h4>
          <div class="check-item"><div class="check-icon done">&#10003;</div> Passkey created</div>
          <div class="check-item"><div class="check-icon" id="check-email">&#9675;</div> <span id="check-email-text">Recovery email</span></div>
          <div class="check-item"><div class="check-icon" id="check-device">&#9675;</div> <span id="check-device-text">Second device</span></div>
          <div class="check-item"><div class="check-icon" id="check-guardians">&#9675;</div> <span id="check-guardians-text">Guardians (0/3)</span></div>
          <div class="check-item"><div class="check-icon" id="check-vault">&#9675;</div> <span id="check-vault-text">Encrypted vault backup</span></div>
        </div>

        <div class="recovery-section">
          <h4>Recovery Email</h4>
          <p style="font-size:0.8rem;color:#64748b;margin-bottom:0.75rem">Recommended for cross-device login and account recovery.</p>
          <div class="recovery-row">
            <input id="recovery-email" type="email" placeholder="you@example.com" style="padding:0.5rem 0.75rem;border-radius:0.5rem;border:1px solid rgba(255,255,255,0.15);background:rgba(255,255,255,0.05);color:#fff;font-size:0.85rem;outline:none" />
            <button class="btn-secondary" onclick="setRecoveryEmail()">Save</button>
          </div>
          <div id="recovery-msg" style="font-size:0.8rem;margin-top:0.5rem;color:#86efac;display:none"></div>
        </div>

        <div class="devices-section">
          <h4>Linked Devices</h4>
          <p style="font-size:0.8rem;color:#64748b;margin-bottom:0.75rem">Add a passkey on another device so you can sign in from anywhere.</p>
          <div id="device-list"></div>
          <button class="btn-secondary" onclick="startDeviceLink()" id="link-device-btn">+ Link Another Device</button>
          <div id="device-link-area" class="hidden">
            <div class="qr-container">
              <canvas id="qr-canvas" width="200" height="200"></canvas>
              <p style="font-size:0.8rem;color:#94a3b8;">Scan this QR code on your other device</p>
            </div>
            <div class="or-divider">— or —</div>
            <div class="invite-link" id="device-link-url" onclick="copyDeviceLink()">
              <span id="device-link-text"></span>
              <span class="copy-hint">click to copy</span>
            </div>
            <p style="font-size:0.75rem;color:#64748b;margin-top:0.5rem;">Link expires in 10 minutes</p>
          </div>
        </div>

        <div class="guardians-section">
          <h4>Recovery Guardians</h4>
          <p style="font-size:0.8rem;color:#64748b;margin-bottom:0.75rem">Choose 3 people you trust. If you lose all your devices, any 2 of them can help you recover your account.</p>
          <div id="guardian-list"></div>
          <div id="guardian-add-form" class="guardian-add">
            <input id="guardian-name" type="text" placeholder="Name (e.g. Mom, Alex)" />
            <input id="guardian-email" type="email" placeholder="Email (optional)" />
            <button class="btn-secondary" onclick="addGuardian()">Add</button>
          </div>
          <div id="guardian-msg" style="font-size:0.8rem;margin-top:0.5rem;display:none"></div>
          <div id="guardian-invite-area" class="hidden" style="margin-top:0.75rem;">
            <p style="font-size:0.8rem;color:#94a3b8;margin-bottom:0.5rem;">Share this invite link with your guardian:</p>
            <div class="invite-link" id="guardian-invite-link" onclick="copyGuardianLink()">
              <span id="guardian-invite-text"></span>
              <span class="copy-hint">click to copy</span>
            </div>
          </div>
        </div>

        <div class="vault-section">
          <h4>Encrypted Account Vault</h4>
          <p style="font-size:0.8rem;color:#64748b;margin-bottom:0.75rem">Your account data is encrypted with your passkey and stored securely. Only you can unlock it.</p>
          <div id="vault-status" style="font-size:0.8rem;padding:0.5rem 0.75rem;border-radius:0.5rem;background:rgba(255,255,255,0.04);color:#94a3b8">Status: Not synced</div>
          <div style="display:flex;gap:0.5rem;margin-top:0.75rem">
            <button class="btn-secondary" onclick="saveVault()">Save to Encrypted Server</button>
            <button class="btn-secondary" onclick="restoreVault()">Restore from Server</button>
          </div>
          <div id="vault-msg" style="font-size:0.8rem;margin-top:0.5rem;display:none"></div>
        </div>

        <div class="profile-actions">
          <a href="/demo.html" class="btn-secondary">SDK Demo</a>
          <button class="btn-secondary btn-danger" onclick="handleLogout()">Sign Out</button>
        </div>
      </div>
    </div>

    <div class="features">
      <div class="feature">
        <div class="feature-title">Passkey Auth</div>
        <div class="feature-desc">Hardware-backed, phishing-resistant login. No passwords ever.</div>
      </div>
      <div class="feature">
        <div class="feature-title">Guardian Recovery</div>
        <div class="feature-desc">3 trusted contacts, 2 to recover. No seed phrases, no single point of failure.</div>
      </div>
      <div class="feature">
        <div class="feature-title">E2E Encryption</div>
        <div class="feature-desc">Derive keys from your passkey. Keys never leave your device.</div>
      </div>
      <div class="feature">
        <div class="feature-title">Cross-App Identity</div>
        <div class="feature-desc">One passkey works across every app in the rStack ecosystem.</div>
      </div>
    </div>

    <div class="apps">
      <div class="apps-title">One identity across the <a href="https://rstack.online" style="color:#64748b;text-decoration:none">rStack.online</a> ecosystem</div>
      <div class="app-links">
        <a href="https://rspace.online">rSpace</a>
        <a href="https://rnotes.online">rNotes</a>
        <a href="https://rfiles.online">rFiles</a>
        <a href="https://rcart.online">rCart</a>
        <a href="https://rflows.online">rFlows</a>
        <a href="https://rwallet.online">rWallet</a>
        <a href="https://rauctions.online">rAuctions</a>
        <a href="https://rpubs.online">rPubs</a>
        <a href="https://rvote.online">rVote</a>
        <a href="https://rmaps.online">rMaps</a>
        <a href="https://rtrips.online">rTrips</a>
        <a href="https://rtube.online">rTube</a>
        <a href="https://rinbox.online">rInbox</a>
        <a href="https://rstack.online">rStack</a>
      </div>
    </div>
  </div>

  <script type="module">
    import {
      bufferToBase64url,
      base64urlToBuffer,
      getKeyManager,
      getSessionManager,
      detectCapabilities,
      authenticatePasskey,
    } from '/dist/index.js';

    const TOKEN_KEY = 'encryptid_token';
    let currentMode = 'signin';

    // Expose to inline onclick handlers
    window.switchTab = (mode) => {
      currentMode = mode;
      document.getElementById('tab-signin').classList.toggle('active', mode === 'signin');
      document.getElementById('tab-register').classList.toggle('active', mode === 'register');
      document.getElementById('register-fields').style.display = mode === 'register' ? 'block' : 'none';
      document.getElementById('auth-btn').textContent = mode === 'signin' ? 'Sign In with Passkey' : 'Register with Passkey';
      hideMessages();
    };

    function showError(msg) {
      const el = document.getElementById('error-msg');
      el.textContent = msg; el.style.display = 'block';
      document.getElementById('success-msg').style.display = 'none';
    }
    function showSuccess(msg) {
      const el = document.getElementById('success-msg');
      el.textContent = msg; el.style.display = 'block';
      document.getElementById('error-msg').style.display = 'none';
    }
    function hideMessages() {
      document.getElementById('error-msg').style.display = 'none';
      document.getElementById('success-msg').style.display = 'none';
    }

    window.showRecoveryForm = () => {
      document.getElementById('recovery-form').style.display = 'block';
      document.getElementById('recovery-link-row').style.display = 'none';
    };

    window.initiateRecovery = async () => {
      const input = document.getElementById('recover-email').value.trim();
      const msgEl = document.getElementById('recover-msg');
      if (!input) { msgEl.textContent = 'Enter your email or username'; msgEl.style.color = '#fca5a5'; msgEl.style.display = 'block'; return; }
      try {
        const body = input.includes('@') ? { email: input } : { username: input };
        const res = await fetch('/api/recovery/social/initiate', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(body),
        });
        const data = await res.json();
        msgEl.textContent = data.message || 'Recovery request sent. Check with your guardians.';
        msgEl.style.color = '#86efac';
        msgEl.style.display = 'block';
        // Also try email recovery
        if (input.includes('@')) {
          fetch('/api/recovery/email/request', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ email: input }),
          }).catch(() => {});
        }
      } catch (err) {
        msgEl.textContent = 'Failed: ' + err.message;
        msgEl.style.color = '#fca5a5';
        msgEl.style.display = 'block';
      }
    };

    window.handleAuth = async () => {
      const btn = document.getElementById('auth-btn');
      btn.disabled = true;
      hideMessages();
      try {
        if (currentMode === 'register') {
          const username = document.getElementById('username').value.trim();
          if (!username) { showError('Username is required'); btn.disabled = false; return; }
          const email = document.getElementById('reg-email').value.trim();

          btn.textContent = 'Creating passkey...';

          // Server-initiated registration flow
          const startRes = await fetch('/api/register/start', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ username, displayName: username }),
          });
          if (!startRes.ok) throw new Error('Failed to start registration');
          const { options: serverOptions, userId } = await startRes.json();

          const credential = await navigator.credentials.create({
            publicKey: {
              challenge: new Uint8Array(base64urlToBuffer(serverOptions.challenge)),
              rp: { id: serverOptions.rp?.id || 'rspace.online', name: serverOptions.rp?.name || 'EncryptID' },
              user: { id: new Uint8Array(base64urlToBuffer(serverOptions.user.id)), name: username, displayName: username },
              pubKeyCredParams: serverOptions.pubKeyCredParams || [
                { alg: -7, type: 'public-key' },
                { alg: -257, type: 'public-key' },
              ],
              authenticatorSelection: { residentKey: 'required', requireResidentKey: true, userVerification: 'required' },
              attestation: 'none',
              timeout: 60000,
            },
          });
          if (!credential) throw new Error('Failed to create credential');

          const response = credential.response;
          const publicKey = response.getPublicKey?.();
          const credentialData = {
            credentialId: bufferToBase64url(credential.rawId),
            publicKey: publicKey ? bufferToBase64url(publicKey) : '',
            transports: response.getTransports?.() || [],
          };

          const regBody = {
            challenge: serverOptions.challenge,
            credential: credentialData,
            userId,
            username,
          };
          if (email) regBody.email = email;
          const res = await fetch('/api/register/complete', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(regBody),
          });
          const data = await res.json();
          if (!res.ok) throw new Error(data.error || 'Registration failed');

          localStorage.setItem(TOKEN_KEY, data.token);
          showProfile(data.token, username, data.did);
        } else {
          btn.textContent = 'Waiting for passkey...';

          // Server-initiated auth flow
          const startRes = await fetch('/api/auth/start', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: '{}',
          });
          if (!startRes.ok) throw new Error('Failed to start authentication');
          const { options: serverOptions } = await startRes.json();

          const credential = await navigator.credentials.get({
            publicKey: {
              challenge: new Uint8Array(base64urlToBuffer(serverOptions.challenge)),
              rpId: serverOptions.rpId || 'rspace.online',
              userVerification: 'required',
              timeout: 60000,
            },
          });
          if (!credential) throw new Error('Authentication failed');

          const res = await fetch('/api/auth/complete', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({
              challenge: serverOptions.challenge,
              credential: { credentialId: bufferToBase64url(credential.rawId) },
            }),
          });
          const data = await res.json();
          if (!res.ok) throw new Error(data.error || 'Authentication failed');

          localStorage.setItem(TOKEN_KEY, data.token);
          showProfile(data.token, data.username, data.did);
        }
      } catch (err) {
        showError(err.message || 'Authentication failed');
        btn.textContent = currentMode === 'signin' ? 'Sign In with Passkey' : 'Register with Passkey';
        btn.disabled = false;
      }
    };

    async function showProfile(token, username, did) {
      document.getElementById('auth-form').style.display = 'none';
      document.getElementById('profile').style.display = 'block';

      document.getElementById('profile-username').textContent = username || 'Anonymous';
      document.getElementById('profile-avatar').textContent = (username || '?')[0].toUpperCase();
      document.getElementById('profile-did').textContent = did || '';

      // Parse token expiry
      try {
        const payload = JSON.parse(atob(token.split('.')[1]));
        const exp = new Date(payload.exp * 1000);
        document.getElementById('profile-expires').textContent = exp.toLocaleString();
      } catch { document.getElementById('profile-expires').textContent = '--'; }

      // Fetch passkeys
      try {
        const res = await fetch('/api/user/credentials', {
          headers: { 'Authorization': 'Bearer ' + token },
        });
        const data = await res.json();
        const list = document.getElementById('passkey-list');
        if (data.credentials && data.credentials.length > 0) {
          list.innerHTML = data.credentials.map(c => {
            const created = c.createdAt ? new Date(c.createdAt).toLocaleDateString() : '';
            return '<div class="passkey-item"><span class="passkey-id">' +
              c.credentialId.slice(0, 24) + '...</span><span class="passkey-date">' + created + '</span></div>';
          }).join('');
        } else {
          list.innerHTML = '<div class="passkey-item"><span class="passkey-id">No passkeys found</span></div>';
        }
      } catch { /* ignore */ }

      // Load guardians and check vault status
      loadGuardians();
      checkVaultStatus();
    }

    window.handleLogout = () => {
      localStorage.removeItem(TOKEN_KEY);
      document.getElementById('auth-form').style.display = 'block';
      document.getElementById('profile').style.display = 'none';
      const btn = document.getElementById('auth-btn');
      btn.disabled = false;
      btn.textContent = 'Sign In with Passkey';
      currentMode = 'signin';
      switchTab('signin');
    };

    window.setRecoveryEmail = async () => {
      const email = document.getElementById('recovery-email').value.trim();
      if (!email) return;
      const token = localStorage.getItem(TOKEN_KEY);
      if (!token) return;
      const msgEl = document.getElementById('recovery-msg');
      try {
        const res = await fetch('/api/recovery/email/set', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
          body: JSON.stringify({ email }),
        });
        const data = await res.json();
        if (!res.ok) throw new Error(data.error || 'Failed');
        msgEl.textContent = 'Recovery email saved.';
        msgEl.style.color = '#86efac';
        msgEl.style.display = 'block';
        updateChecklist();
      } catch (err) {
        msgEl.textContent = err.message;
        msgEl.style.color = '#fca5a5';
        msgEl.style.display = 'block';
      }
    };

    // ── Guardian management ──

    let lastGuardianInviteUrl = '';

    window.addGuardian = async () => {
      const name = document.getElementById('guardian-name').value.trim();
      const email = document.getElementById('guardian-email').value.trim();
      const token = localStorage.getItem(TOKEN_KEY);
      const msgEl = document.getElementById('guardian-msg');
      if (!name) { msgEl.textContent = 'Name is required'; msgEl.style.color = '#fca5a5'; msgEl.style.display = 'block'; return; }
      if (!token) return;
      try {
        const res = await fetch('/api/guardians', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
          body: JSON.stringify({ name, email: email || undefined }),
        });
        const data = await res.json();
        if (!res.ok) throw new Error(data.error || 'Failed');
        // Clear inputs
        document.getElementById('guardian-name').value = '';
        document.getElementById('guardian-email').value = '';
        // Show invite link
        lastGuardianInviteUrl = data.inviteUrl;
        document.getElementById('guardian-invite-text').textContent = data.inviteUrl;
        document.getElementById('guardian-invite-area').classList.remove('hidden');
        if (email) {
          msgEl.textContent = 'Guardian added! Invite email sent to ' + email;
        } else {
          msgEl.textContent = 'Guardian added! Share the invite link below.';
        }
        msgEl.style.color = '#86efac';
        msgEl.style.display = 'block';
        loadGuardians();
      } catch (err) {
        msgEl.textContent = err.message;
        msgEl.style.color = '#fca5a5';
        msgEl.style.display = 'block';
      }
    };

    window.copyGuardianLink = () => {
      navigator.clipboard.writeText(lastGuardianInviteUrl).then(() => {
        const el = document.getElementById('guardian-msg');
        el.textContent = 'Link copied!';
        el.style.color = '#86efac';
        el.style.display = 'block';
      });
    };

    window.removeGuardian = async (id) => {
      const token = localStorage.getItem(TOKEN_KEY);
      if (!token) return;
      if (!confirm('Remove this guardian?')) return;
      await fetch('/api/guardians/' + id, { method: 'DELETE', headers: { 'Authorization': 'Bearer ' + token } });
      loadGuardians();
    };

    async function loadGuardians() {
      const token = localStorage.getItem(TOKEN_KEY);
      if (!token) return;
      try {
        const res = await fetch('/api/guardians', { headers: { 'Authorization': 'Bearer ' + token } });
        const data = await res.json();
        const list = document.getElementById('guardian-list');
        if (data.guardians && data.guardians.length > 0) {
          list.innerHTML = data.guardians.map(g =>
            '<div class="guardian-item">' +
            '<span><span class="guardian-name">' + g.name + '</span>' +
            (g.email ? ' <span style="color:#64748b;font-size:0.75rem">(' + g.email + ')</span>' : '') +
            '</span>' +
            '<span style="display:flex;align-items:center;gap:0.5rem;">' +
            '<span class="guardian-status ' + g.status + '">' + g.status + '</span>' +
            '<button onclick="removeGuardian(\'' + g.id + '\')" style="background:none;border:none;color:#64748b;cursor:pointer;font-size:0.8rem;" title="Remove">&times;</button>' +
            '</span></div>'
          ).join('');
          // Hide add form if 3 guardians
          document.getElementById('guardian-add-form').style.display = data.guardians.length >= 3 ? 'none' : 'flex';
        } else {
          list.innerHTML = '';
        }
        updateChecklist(data.guardians);
      } catch { /* ignore */ }
    }

    // ── Device linking ──

    let deviceLinkUrl = '';

    window.startDeviceLink = async () => {
      const token = localStorage.getItem(TOKEN_KEY);
      if (!token) return;
      const btn = document.getElementById('link-device-btn');
      btn.disabled = true;
      try {
        const res = await fetch('/api/device-link/start', {
          method: 'POST',
          headers: { 'Authorization': 'Bearer ' + token },
        });
        const data = await res.json();
        if (data.error) throw new Error(data.error);
        deviceLinkUrl = data.linkUrl;
        document.getElementById('device-link-text').textContent = data.linkUrl;
        document.getElementById('device-link-area').classList.remove('hidden');
        btn.style.display = 'none';
        // Generate QR code
        generateQR(data.linkUrl, document.getElementById('qr-canvas'));
      } catch (err) {
        alert('Failed: ' + err.message);
        btn.disabled = false;
      }
    };

    window.copyDeviceLink = () => {
      navigator.clipboard.writeText(deviceLinkUrl);
    };

    // Simple QR code generator (alphanumeric mode, version auto)
    function generateQR(text, canvas) {
      // Use a minimal inline QR approach via canvas
      // For simplicity, draw a placeholder with the URL
      const ctx = canvas.getContext('2d');
      const size = canvas.width;
      ctx.fillStyle = '#fff';
      ctx.fillRect(0, 0, size, size);

      // We'll use a simple approach: encode as a data URL and render
      // For production, we'd use a proper QR library, but for now
      // let's create a visual pattern from the URL hash
      const hash = Array.from(text).reduce((h, c) => ((h << 5) - h + c.charCodeAt(0)) | 0, 0);
      const cellSize = 4;
      const modules = Math.floor(size / cellSize);
      ctx.fillStyle = '#000';

      // Generate a deterministic pattern (not a real QR code)
      // We'll load a proper QR via dynamic import if available
      try {
        // Try to use the QR code API endpoint
        const img = new Image();
        img.crossOrigin = 'anonymous';
        img.onload = () => { ctx.drawImage(img, 0, 0, size, size); };
        img.src = 'https://api.qrserver.com/v1/create-qr-code/?size=' + size + 'x' + size + '&data=' + encodeURIComponent(text) + '&format=png&margin=8';
      } catch {
        // Fallback: just show text
        ctx.fillStyle = '#000';
        ctx.font = '10px monospace';
        ctx.fillText('Scan QR on phone', 20, size/2 - 10);
        ctx.fillText('or copy link below', 20, size/2 + 10);
      }
    }

    // ── Vault helpers ──

    const VAULT_SPACE = '__vault';
    const VAULT_DOC = 'account-vault';
    const enc = new TextEncoder();

    async function deriveVaultKey(prfOutput) {
      // Import PRF output as HKDF material
      const master = await crypto.subtle.importKey('raw', prfOutput, { name: 'HKDF' }, false, ['deriveKey', 'deriveBits']);
      // Derive space key: info = "rspace:__vault"
      const spaceBits = await crypto.subtle.deriveBits(
        { name: 'HKDF', hash: 'SHA-256', salt: enc.encode('rspace-space-key-v1'), info: enc.encode('rspace:' + VAULT_SPACE) },
        master, 256
      );
      const spaceKey = await crypto.subtle.importKey('raw', spaceBits, { name: 'HKDF' }, false, ['deriveKey', 'deriveBits']);
      // Derive doc key: info = "doc:account-vault"
      return crypto.subtle.deriveKey(
        { name: 'HKDF', hash: 'SHA-256', salt: enc.encode('rspace-doc-key-v1'), info: enc.encode('doc:' + VAULT_DOC) },
        spaceKey, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']
      );
    }

    function vaultMsg(text, ok) {
      const el = document.getElementById('vault-msg');
      el.textContent = text;
      el.style.color = ok ? '#86efac' : '#fca5a5';
      el.style.display = 'block';
    }

    function gatherVaultData() {
      const username = document.getElementById('profile-username')?.textContent || null;
      const email = document.getElementById('recovery-email')?.value?.trim() || null;
      return {
        version: 1,
        profile: { displayName: username, avatarUrl: null },
        emails: email ? [{ address: email, verified: false, addedAt: Date.now() }] : [],
        devices: [],
        addresses: [],
        wallets: [],
        preferences: {},
        createdAt: Date.now(),
        updatedAt: Date.now(),
      };
    }

    window.saveVault = async () => {
      const token = localStorage.getItem(TOKEN_KEY);
      if (!token) { vaultMsg('Not authenticated', false); return; }
      try {
        vaultMsg('Authenticate with your passkey to encrypt...', true);
        const result = await authenticatePasskey();
        if (!result.prfOutput) { vaultMsg('PRF not available — vault requires a PRF-capable passkey', false); return; }

        const key = await deriveVaultKey(result.prfOutput);
        const vault = gatherVaultData();
        const json = new TextEncoder().encode(JSON.stringify(vault));
        const nonce = crypto.getRandomValues(new Uint8Array(12));
        const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce }, key, json));
        // Pack: [12-byte nonce][ciphertext]
        const packed = new Uint8Array(12 + ct.length);
        packed.set(nonce, 0);
        packed.set(ct, 12);

        const res = await fetch('/api/backup/' + VAULT_SPACE + '/' + VAULT_DOC, {
          method: 'PUT',
          headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/octet-stream' },
          body: packed,
        });
        if (!res.ok) throw new Error('Server returned ' + res.status);
        vaultMsg('Vault saved and encrypted successfully.', true);
        updateVaultChecklist(true);
        document.getElementById('vault-status').textContent = 'Status: Synced — ' + new Date().toLocaleString();
      } catch (err) {
        if (err.name === 'NotAllowedError') { vaultMsg('Passkey authentication cancelled.', false); return; }
        vaultMsg('Save failed: ' + err.message, false);
      }
    };

    window.restoreVault = async () => {
      const token = localStorage.getItem(TOKEN_KEY);
      if (!token) { vaultMsg('Not authenticated', false); return; }
      try {
        vaultMsg('Authenticate with your passkey to decrypt...', true);
        const result = await authenticatePasskey();
        if (!result.prfOutput) { vaultMsg('PRF not available — vault requires a PRF-capable passkey', false); return; }

        const blobRes = await fetch('/api/backup/' + VAULT_SPACE + '/' + VAULT_DOC, {
          headers: { 'Authorization': 'Bearer ' + token },
        });
        if (blobRes.status === 404) { vaultMsg('No vault backup found on server.', false); return; }
        if (!blobRes.ok) throw new Error('Server returned ' + blobRes.status);

        const packed = new Uint8Array(await blobRes.arrayBuffer());
        if (packed.length < 13) throw new Error('Invalid vault data');
        const nonce = packed.slice(0, 12);
        const ct = packed.slice(12);

        const key = await deriveVaultKey(result.prfOutput);
        const plainBuf = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: nonce }, key, ct);
        const vault = JSON.parse(new TextDecoder().decode(plainBuf));

        // Populate DOM
        if (vault.profile?.displayName) {
          const el = document.getElementById('profile-username');
          if (el) el.textContent = vault.profile.displayName;
        }
        if (vault.emails?.length > 0) {
          const el = document.getElementById('recovery-email');
          if (el) el.value = vault.emails[0].address;
        }
        vaultMsg('Vault restored successfully. ' + (vault.emails?.length || 0) + ' email(s), ' + (vault.wallets?.length || 0) + ' wallet(s).', true);
        updateVaultChecklist(true);
        document.getElementById('vault-status').textContent = 'Status: Restored — ' + new Date(vault.updatedAt).toLocaleString();
      } catch (err) {
        if (err.name === 'NotAllowedError') { vaultMsg('Passkey authentication cancelled.', false); return; }
        vaultMsg('Restore failed: ' + err.message, false);
      }
    };

    async function checkVaultStatus() {
      const token = localStorage.getItem(TOKEN_KEY);
      if (!token) return;
      try {
        const res = await fetch('/api/backup/' + VAULT_SPACE + '/' + VAULT_DOC, {
          headers: { 'Authorization': 'Bearer ' + token },
        });
        if (res.ok) {
          updateVaultChecklist(true);
          document.getElementById('vault-status').textContent = 'Status: Backup exists on server';
        } else {
          updateVaultChecklist(false);
        }
      } catch { /* offline or error */ }
    }

    function updateVaultChecklist(exists) {
      const icon = document.getElementById('check-vault');
      const text = document.getElementById('check-vault-text');
      if (exists) {
        icon.className = 'check-icon done';
        icon.innerHTML = '&#10003;';
        text.textContent = 'Encrypted vault backup';
      } else {
        icon.className = 'check-icon todo';
        icon.innerHTML = '&#9675;';
        text.textContent = 'Encrypted vault backup';
      }
    }

    // ── Checklist update ──

    function updateChecklist(guardians) {
      const token = localStorage.getItem(TOKEN_KEY);
      // Email check
      const emailInput = document.getElementById('recovery-email');
      const hasEmail = emailInput && emailInput.value.trim();
      const emailIcon = document.getElementById('check-email');
      if (hasEmail) {
        emailIcon.className = 'check-icon done';
        emailIcon.innerHTML = '&#10003;';
      }
      // Device check (from passkey count)
      const passkeyItems = document.querySelectorAll('.passkey-item');
      const deviceIcon = document.getElementById('check-device');
      const deviceText = document.getElementById('check-device-text');
      if (passkeyItems.length > 1) {
        deviceIcon.className = 'check-icon done';
        deviceIcon.innerHTML = '&#10003;';
        deviceText.textContent = passkeyItems.length + ' devices linked';
      }
      // Guardian check
      const guardianIcon = document.getElementById('check-guardians');
      const guardianText = document.getElementById('check-guardians-text');
      const gList = guardians || [];
      const accepted = gList.filter(g => g.status === 'accepted').length;
      const total = gList.length;
      guardianText.textContent = 'Guardians (' + total + '/3' + (accepted < total ? ', ' + accepted + ' accepted' : '') + ')';
      if (total >= 3 && accepted >= 2) {
        guardianIcon.className = 'check-icon done';
        guardianIcon.innerHTML = '&#10003;';
      } else if (total > 0) {
        guardianIcon.className = 'check-icon todo';
        guardianIcon.innerHTML = total.toString();
      }
    }

    // On page load: check for existing token
    (async () => {
      const token = localStorage.getItem(TOKEN_KEY);
      if (!token) return;
      try {
        const res = await fetch('/api/session/verify', {
          headers: { 'Authorization': 'Bearer ' + token },
        });
        const data = await res.json();
        if (data.valid) {
          showProfile(token, data.username, data.did);
        } else {
          localStorage.removeItem(TOKEN_KEY);
        }
      } catch {
        localStorage.removeItem(TOKEN_KEY);
      }
    })();
  </script>
</body>
</html>
  `);
});

// ============================================================================
// DATABASE INITIALIZATION & SERVER START
// ============================================================================

// Initialize database on startup with retries (Docker networking may take a moment)
(async () => {
  const maxRetries = 5;
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      await initDatabase();
      return;
    } catch (err) {
      console.error(`EncryptID: Database init attempt ${attempt}/${maxRetries} failed:`, (err as Error).message);
      if (attempt === maxRetries) {
        console.error('EncryptID: All database init attempts exhausted, exiting');
        process.exit(1);
      }
      await new Promise(r => setTimeout(r, attempt * 2000));
    }
  }
})();

// Clean expired challenges and recovery tokens every 10 minutes
setInterval(() => {
  cleanExpiredChallenges().catch(() => {});
  cleanExpiredRecoveryTokens().catch(() => {});
}, 10 * 60 * 1000);

console.log(`
╔═══════════════════════════════════════════════════════════╗
║                                                           ║
║   🔐 rStack Identity (EncryptID)                          ║
║                                                           ║
║   One passkey for the rStack.online ecosystem             ║
║                                                           ║
║   Port: ${CONFIG.port}                                           ║
║   RP ID: ${CONFIG.rpId}                                   ║
║   Storage: PostgreSQL                                     ║
║   SMTP: ${CONFIG.smtp.pass ? CONFIG.smtp.host : 'disabled (no SMTP_PASS)'}${' '.repeat(Math.max(0, 36 - (CONFIG.smtp.pass ? CONFIG.smtp.host.length : 26)))}║
║                                                           ║
╚═══════════════════════════════════════════════════════════╝
`);

export default {
  port: CONFIG.port,
  fetch: app.fetch,
};
