/**
 * 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,
  listSpacesForUser,
  upsertSpaceMember,
  removeSpaceMember,
  getUserProfile,
  updateUserProfile,
  getUserAddresses,
  getAddressById,
  saveUserAddress,
  deleteUserAddress,
  getEmailForwardStatus,
  setEmailForward,
  listAllUsers,
  deleteUser,
  deleteSpaceMembers,
  createSpaceInvite,
  getSpaceInviteByToken,
  listSpaceInvites,
  acceptSpaceInvite,
  revokeSpaceInvite,
  createFundClaim,
  getFundClaimByToken,
  getFundClaimsByEmailHash,
  acceptFundClaim,
  accumulateFundClaim,
  cleanExpiredFundClaims,
  getOidcClient,
  createOidcAuthCode,
  consumeOidcAuthCode,
  cleanExpiredOidcCodes,
  seedOidcClients,
  createIdentityInvite,
  getIdentityInviteByToken,
  getIdentityInvitesByInviter,
  getIdentityInvitesByClient,
  claimIdentityInvite,
  revokeIdentityInvite,
  cleanExpiredIdentityInvites,
  listOidcClients,
  updateOidcClient,
  createOidcClient,
  deleteOidcClient,
  createLinkedWallet,
  getLinkedWallets,
  deleteLinkedWallet,
  linkedWalletExists,
  updateCredentialLabel,
  deleteCredential,
  createLegacyIdentity,
  getLegacyIdentityByPublicKeyHash,
  getLegacyIdentitiesByUser,
  getUserByLegacyPublicKeyHash,
  consumeChallenge,
  createDelegation,
  getDelegation,
  listDelegationsFrom,
  listDelegationsTo,
  listActiveDelegations,
  updateDelegation,
  revokeDelegation,
  getTotalDelegatedWeight,
  cleanExpiredDelegations,
  logTrustEvent,
  getTrustEvents,
  getTrustEventsSince,
  getAggregatedTrustScores,
  getTrustScoresByAuthority,
  listAllUsersWithTrust,
  getPowerIndices,
  getPowerIndex,
  upsertPowerIndex,
  sql,
  getUserNotifications,
  getUnreadCount,
  markNotificationRead,
  markAllNotificationsRead,
  dismissNotification,
  getNotificationPreferences,
  upsertNotificationPreferences,
  savePushSubscription,
  deletePushSubscriptionByEndpoint,
  getUserUPAddress,
  setUserUPAddress,
  getUserByUPAddress,
  setPassphraseSalt,
  getPassphraseSalt,
  updateUserDid,
  migrateSpaceMemberDid,
  setUserLoggedOutAt,
  getUserLoggedOutAt,
  upsertSpaceEmailForwarding,
  removeSpaceEmailForwarding,
  getSpaceEmailForwarding,
  getOptedInDids,
  getProfileEmailsByDids,
  getAgentMailbox,
  listAllAgentMailboxes,
  createDrillRequest,
  setUserLastDrillAt,
  getUserLastDrillAt,
} from './db.js';
import {
  isMailcowConfigured,
  createAlias,
  deleteAlias,
  updateAlias,
  aliasExists,
} from './mailcow.js';
import { notify } from '../../server/notification-service';
import { sendWelcomeEmail } from '../../server/welcome-email';
import { startTrustEngine } from './trust-engine.js';
import { computePowerIndices, resolveVotingWeights, simulateCoalition } from './power-indices.js';
import { provisionSpaceAlias, syncSpaceAlias, deprovisionSpaceAlias, provisionAgentMailbox, deprovisionAgentMailbox } from './space-alias-service.js';

// ============================================================================
// 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@rmail.online',
    pass: process.env.SMTP_PASS || '',
    from: process.env.SMTP_FROM || 'EncryptID <noreply@rmail.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 subdomains (all r*.online 301 → rspace.online now)
    'https://rspace.online',
    'https://auth.rspace.online',
    'https://cca.rspace.online',
    'https://demo.rspace.online',
    'https://app.rspace.online',
    'https://dev.rspace.online',
    // ridentity.online — EncryptID's own domain
    'https://ridentity.online',
    'https://auth.ridentity.online',
    // Separate deployments (not on rspace.online)
    'https://rsocials.online',
    'https://demo.rsocials.online',
    'https://socials.crypto-commons.org',
    'https://socials.p2pfoundation.net',
    // canvas-website (CryptID migration)
    'https://jeffemmett-canvas.pages.dev',
    // Development
    'http://localhost:3000',
    'http://localhost:5173',
  ],
};

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

let smtpTransport: Transporter | null = null;

const isInternalSmtp = CONFIG.smtp.host.includes('mailcow') || CONFIG.smtp.host.includes('postfix');
if (CONFIG.smtp.pass || isInternalSmtp) {
  smtpTransport = createTransport({
    host: CONFIG.smtp.host,
    port: isInternalSmtp ? 25 : CONFIG.smtp.port,
    secure: !isInternalSmtp && CONFIG.smtp.port === 465,
    ...(isInternalSmtp ? {} : {
      auth: { user: CONFIG.smtp.user, pass: CONFIG.smtp.pass },
    }),
    tls: {
      rejectUnauthorized: false,
    },
  });

  // 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 delivery will not work until SMTP is configured');
    smtpTransport = null;
  });
} else {
  console.warn('EncryptID: SMTP not configured — email delivery disabled');
}

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;
}

async function sendVerificationEmail(to: string, token: string, username: string, appName: string): Promise<boolean> {
  const verifyLink = `https://auth.ridentity.online/oidc/verify-email-callback?token=${encodeURIComponent(token)}`;

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

  await smtpTransport.sendMail({
    from: CONFIG.smtp.from,
    to,
    subject: 'rIdentity — Verify your email address',
    text: [
      `Hi ${username},`,
      '',
      `Please verify your email address to sign in to ${appName}.`,
      'Click the link below to verify:',
      '',
      verifyLink,
      '',
      'This link expires in 30 minutes.',
      'If you did not request this, you can safely ignore this email.',
      '',
      '— rIdentity',
      '',
      'rIdentity: https://ridentity.online',
      'rStack: https://rstack.online',
      'rSpace: https://rspace.online',
    ].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;">rIdentity</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>Verify your email address to sign in to <strong>${appName}</strong>:</p>
        </td></tr>
        <tr><td style="padding:0 32px 32px;text-align:center;">
          <a href="${verifyLink}" 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;">Verify Email</a>
        </td></tr>
        <tr><td style="padding:0 32px 24px;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.</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;">${verifyLink}</span>
          </p>
        </td></tr>
        <tr><td style="padding:16px 32px 24px;text-align:center;border-top:1px solid rgba(255,255,255,0.06);">
          <p style="margin:0;font-size:12px;color:#64748b;">
            <a href="https://ridentity.online" style="color:#7c3aed;text-decoration:none;">rIdentity</a>
            &nbsp;&middot;&nbsp;
            <a href="https://rstack.online" style="color:#7c3aed;text-decoration:none;">rStack</a>
            &nbsp;&middot;&nbsp;
            <a href="https://rspace.online" style="color:#7c3aed;text-decoration:none;">rSpace</a>
          </p>
        </td></tr>
      </table>
    </td></tr>
  </table>
</body>
</html>`,
  });

  return true;
}

async function sendClaimEmail(to: string, token: string, amount?: string, currency?: string): Promise<boolean> {
  const claimLink = `https://auth.rspace.online/claim?token=${encodeURIComponent(token)}`;
  const amountStr = amount && currency ? `$${amount} ${currency}` : 'Your funds';

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

  await smtpTransport.sendMail({
    from: CONFIG.smtp.from,
    to,
    subject: 'rStack — Funds ready to claim',
    text: [
      `Hi,`,
      '',
      `${amountStr} are ready to claim via EncryptID.`,
      'Click the link below to sign in and link your funded wallet to your account:',
      '',
      claimLink,
      '',
      'This link 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;">&#128176;</div>
          <h1 style="margin:0;font-size:24px;background:linear-gradient(90deg,#00d4ff,#7c3aed);-webkit-background-clip:text;-webkit-text-fill-color:transparent;">Funds Ready to Claim</h1>
        </td></tr>
        <tr><td style="padding:0 32px 24px;color:#e2e8f0;font-size:15px;line-height:1.6;">
          <p><strong>${amountStr}</strong> have been deposited into a wallet for you.</p>
          <p>Sign in with EncryptID to link this wallet to your account — no keys or seed phrases needed.</p>
        </td></tr>
        <tr><td style="padding:0 32px 32px;text-align:center;">
          <a href="${claimLink}" 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;">Claim Your Funds</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>7 days</strong>.</p>
          <p>If you didn't expect this, you can safely ignore this email.</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;">${claimLink}</span>
          </p>
        </td></tr>
      </table>
    </td></tr>
  </table>
</body>
</html>`,
  });

  return true;
}

// PII hashing (keyed HMAC-SHA256 via server-crypto)
import { hashForLookup } from './server-crypto';

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

const app = new Hono();

// Middleware
app.use('*', logger());
// Security headers
app.use('*', async (c, next) => {
  await next();
  c.header('X-Content-Type-Options', 'nosniff');
  c.header('X-Frame-Options', 'DENY');
  c.header('Referrer-Policy', 'strict-origin-when-cross-origin');
});
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', 'PUT', 'DELETE', 'OPTIONS'],
  allowHeaders: ['Content-Type', 'Authorization'],
  credentials: true,
}));

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

// Serve .well-known/webauthn for Related Origins
// Browsers enforce a 5 eTLD+1 limit. Only list domains where passkey
// ceremonies happen directly (OIDC authorize, admin pages).
// *.rspace.online subdomains work automatically (RP ID = rspace.online).
app.get('/.well-known/webauthn', (c) => {
  // Priority origins — these domains actually trigger passkey auth in-browser.
  // Each unique eTLD+1 counts toward the 5-origin limit.
  const origins = [
    'https://ridentity.online',           // EncryptID domain (eTLD+1 #1)
    'https://auth.ridentity.online',
    'https://rsocials.online',            // Postiz ecosystem (eTLD+1 #2)
    'https://demo.rsocials.online',
    'https://socials.crypto-commons.org', // (eTLD+1 #3)
    'https://socials.p2pfoundation.net',  // (eTLD+1 #4)
  ];
  return c.json({ origins });
});

// 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',
      // Don't force 'platform' — let the browser offer cross-platform
      // authenticators (security keys, phone) on devices without platform auth
    },
    timeout: 60000,
    attestation: 'none',
    extensions: {
      credProps: true,
    },
  };

  // Compute PRF salt: SHA-256("encryptid-prf-salt-master-key-v1")
  // This matches the client-side generatePRFSalt('master-key') in webauthn.ts
  const prfSaltData = new TextEncoder().encode('encryptid-prf-salt-master-key-v1');
  const prfSaltHash = await crypto.subtle.digest('SHA-256', prfSaltData);
  const prfSalt = Buffer.from(prfSaltHash).toString('base64url');

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

/**
 * Complete registration - verify and store credential
 */
app.post('/api/register/complete', async (c) => {
  const body = await c.req.json().catch(() => null);
  if (!body) {
    return c.json({ error: 'Invalid request body' }, 400);
  }
  const { challenge, credential, userId, username, email, clientDid, eoaAddress, deviceName } = body;

  if (!userId || !credential || !username) {
    return c.json({ error: 'Missing required fields: userId, credential, username' }, 400);
  }

  // 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

  // Use client-derived DID if available (proper did:key:z6Mk... from PRF),
  // otherwise generate a provisional one from userId
  const did = (clientDid && typeof clientDid === 'string' && clientDid.startsWith('did:key:z'))
    ? clientDid
    : `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.trim().toLowerCase());
  }

  // 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,
  };
  const credLabel = (typeof deviceName === 'string' && deviceName.trim()) ? deviceName.trim().slice(0, 100) : undefined;
  await storeCredential(storedCredential, did, credLabel);

  // Store wallet address if provided (from PRF-derived EOA)
  if (eoaAddress && typeof eoaAddress === 'string' && eoaAddress.startsWith('0x')) {
    await updateUserProfile(userId, { walletAddress: eoaAddress });
  }

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

  // 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 with proper lifecycle context
      const { syncServer } = await import('../../server/sync-instance');
      const lifecycleCtx = {
        spaceSlug,
        ownerDID: did,
        enabledModules: DEFAULT_USER_MODULES,
        syncServer,
      };
      for (const mod of getAllModules()) {
        if (mod.onSpaceCreate) {
          try { await mod.onSpaceCreate(lifecycleCtx); } 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);
  }

  // Mint welcome balance (non-fatal)
  try {
    const { mintWelcomeBalance } = await import('../../server/token-service');
    mintWelcomeBalance(did, username);
  } catch (e) {
    console.warn('EncryptID: Failed to mint welcome balance:', 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, email, username: loginUsername } = 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 — scope to user if email/username provided
  let allowCredentials;
  let userFound = false;
  if (credentialId) {
    const cred = await getCredential(credentialId);
    if (cred) {
      allowCredentials = [{
        type: 'public-key',
        id: credentialId,
        transports: cred.transports,
      }];
      userFound = true;
    }
  } else if (email || loginUsername) {
    // Look up user by email or username, then get their credentials
    const user = email
      ? await getUserByEmail(email.trim().toLowerCase())
      : await getUserByUsername(loginUsername.trim());
    if (user) {
      userFound = true;
      const creds = await getUserCredentials(user.id);
      if (creds.length > 0) {
        allowCredentials = creds.map(c => ({
          type: 'public-key' as const,
          id: c.credentialId,
          transports: c.transports,
        }));
      }
    }
  }

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

  // Include PRF salt for key derivation during sign-in
  const authPrfSaltData = new TextEncoder().encode('encryptid-prf-salt-master-key-v1');
  const authPrfSaltHash = await crypto.subtle.digest('SHA-256', authPrfSaltData);
  const prfSalt = Buffer.from(authPrfSaltHash).toString('base64url');

  return c.json({ options, prfSalt, userFound });
});

/**
 * Complete authentication - verify and issue token
 */
app.post('/api/auth/complete', async (c) => {
  try {
    const body = await c.req.json().catch(() => null);
    if (!body?.challenge || !body?.credential?.credentialId) {
      return c.json({ error: 'Missing required fields: challenge, credential' }, 400);
    }
    const { challenge, credential } = body;

    // 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' }, 401);
    }

    // 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
    );

    // Read stored DID from database
    const authUser = await getUserById(storedCredential.userId);
    const authDid = authUser?.did || `did:key:${storedCredential.userId.slice(0, 32)}`;

    return c.json({
      success: true,
      userId: storedCredential.userId,
      username: storedCredential.username,
      token,
      did: authDid,
    });
  } catch (error: any) {
    console.error('EncryptID: auth/complete error:', error);
    return c.json({ error: 'Authentication failed' }, 500);
  }
});

// ============================================================================
// PASSPHRASE SALT ENDPOINT
// ============================================================================

/**
 * Get passphrase salt for the authenticated user.
 * Used by non-PRF browsers (Firefox) to derive encryption keys from a passphrase.
 */
app.get('/api/account/passphrase-salt', 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 userId = payload.sub as string;
    const salt = await getPassphraseSalt(userId);
    if (!salt) return c.json({ error: 'No passphrase salt — user has PRF support' }, 404);
    return c.json({ salt });
  } catch {
    return c.json({ error: 'Invalid token' }, 401);
  }
});

/**
 * Store passphrase salt for the authenticated user (called once during registration
 * when the browser doesn't support PRF).
 */
app.post('/api/account/passphrase-salt', 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 userId = payload.sub as string;
    const { salt } = await c.req.json();
    if (!salt || typeof salt !== 'string') {
      return c.json({ error: 'Missing salt' }, 400);
    }
    await setPassphraseSalt(userId, salt);
    return c.json({ success: true });
  } catch {
    return c.json({ error: 'Invalid token' }, 401);
  }
});

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

/** Check if a token was issued before the user's last global logout */
async function isTokenRevokedByLogout(userId: string, iat: number): Promise<boolean> {
  const loggedOutAt = await getUserLoggedOutAt(userId);
  if (!loggedOutAt) return false;
  // Token issued before the logout timestamp → revoked
  return iat <= Math.floor(loggedOutAt.getTime() / 1000);
}

/**
 * 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');

    // Check if session was revoked by a logout from another session
    if (await isTokenRevokedByLogout(payload.sub as string, payload.iat as number)) {
      return c.json({ valid: false, error: 'Session revoked' }, 401);
    }

    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');

    if (await isTokenRevokedByLogout(payload.sub as string, payload.iat as number)) {
      return c.json({ valid: false, error: 'Session revoked' });
    }

    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

    // Reject refresh if user logged out globally after this token was issued
    if (await isTokenRevokedByLogout(payload.sub as string, payload.iat as number)) {
      return c.json({ error: 'Session revoked' }, 401);
    }

    // Ensure username is present — look up from DB if missing from old token
    let username = payload.username as string | undefined;
    if (!username) {
      const user = await getUserById(payload.sub as string);
      username = user?.username || '';
    }

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

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

/**
 * Logout — revoke all sessions for this user (cross-session propagation).
 * Other browser sessions will detect this on their next verify/refresh.
 */
app.post('/api/session/logout', 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 {
    // Accept even expired tokens for logout (user should always be able to log out)
    const payload = await verify(token, CONFIG.jwtSecret, { alg: 'HS256', exp: false });
    await setUserLoggedOutAt(payload.sub as string);
    return c.json({ ok: true });
  } 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,
      label: cred.label ?? null,
    }));

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

/**
 * Rename a credential (passkey label)
 */
app.patch('/api/user/credentials/:credentialId', async (c) => {
  const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
  if (!claims) return c.json({ error: 'Unauthorized' }, 401);

  const credentialId = c.req.param('credentialId');
  const { label } = await c.req.json();

  if (!label || typeof label !== 'string' || label.trim().length === 0 || label.trim().length > 100) {
    return c.json({ error: 'Label must be a non-empty string (max 100 chars)' }, 400);
  }

  const updated = await updateCredentialLabel(credentialId, claims.sub as string, label.trim());
  if (!updated) return c.json({ error: 'Credential not found' }, 404);

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

/**
 * Remove a credential (passkey) — refuses to delete the last one
 */
app.delete('/api/user/credentials/:credentialId', 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 credentialId = c.req.param('credentialId');

  const creds = await getUserCredentials(userId);
  if (creds.length <= 1) {
    return c.json({ error: 'Cannot delete your only passkey' }, 400);
  }
  if (!creds.some(cr => cr.credentialId === credentialId)) {
    return c.json({ error: 'Credential not found' }, 404);
  }

  await deleteCredential(credentialId, userId);
  return c.json({ success: true });
});

// ============================================================================
// DID MIGRATION ENDPOINT
// ============================================================================

/**
 * Upgrade a user's DID from truncated format to proper did:key:z6Mk...
 * Called automatically during sign-in when client detects a DID mismatch.
 */
app.post('/api/account/upgrade-did', async (c) => {
  const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
  if (!claims) return c.json({ error: 'Unauthorized' }, 401);

  const { clientDid, eoaAddress } = await c.req.json();
  if (!clientDid || typeof clientDid !== 'string' || !clientDid.startsWith('did:key:z')) {
    return c.json({ error: 'Invalid DID format — must start with did:key:z' }, 400);
  }

  const userId = claims.sub as string;
  const oldDid = claims.did as string;

  // Don't upgrade if already proper format
  if (oldDid === clientDid) {
    return c.json({ success: true, did: oldDid, message: 'DID already up to date' });
  }

  // Update user's DID in database
  await updateUserDid(userId, clientDid);

  // Migrate space memberships from old DID to new DID
  let migratedSpaces = 0;
  if (oldDid) {
    migratedSpaces = await migrateSpaceMemberDid(oldDid, clientDid);
  }

  // Store wallet address if provided
  if (eoaAddress && typeof eoaAddress === 'string' && eoaAddress.startsWith('0x')) {
    await updateUserProfile(userId, { walletAddress: eoaAddress });
  }

  console.log(`EncryptID: DID upgraded for user ${userId}`, {
    oldDid: oldDid?.slice(0, 30) + '...',
    newDid: clientDid.slice(0, 30) + '...',
    migratedSpaces,
    hasWallet: !!eoaAddress,
  });

  // Generate new JWT with updated DID
  const username = claims.username as string;
  const token = await generateSessionToken(userId, username);

  return c.json({
    success: true,
    did: clientDid,
    token,
    migratedSpaces,
  });
});

// ============================================================================
// 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 });
});

// GET /api/user/spaces — list spaces the user is a member of
app.get('/api/user/spaces', async (c) => {
  const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
  if (!claims) return c.json({ error: 'Unauthorized' }, 401);

  const spaces = await listSpacesForUser(claims.sub);
  return c.json({ spaces });
});

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

  const profile = await getUserProfile(claims.sub);
  if (!profile?.profileEmail) return c.json({ claims: [] });

  const emailHashed = await hashForLookup(profile.profileEmail);
  const pendingClaims = await getFundClaimsByEmailHash(emailHashed);
  return c.json({
    claims: pendingClaims.map(cl => ({
      id: cl.id,
      token: cl.token,
      fiatAmount: cl.fiatAmount,
      fiatCurrency: cl.fiatCurrency,
      walletAddress: cl.walletAddress,
      expiresAt: cl.expiresAt,
      status: cl.status,
    })),
  });
});

// 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 oldProfile = await getUserProfile(claims.sub as string);
  const profile = await updateUserProfile(claims.sub as string, updates);
  if (!profile) return c.json({ error: 'User not found' }, 404);

  // Send welcome email on first email connect
  if (updates.profileEmail && !oldProfile?.profileEmail) {
    sendWelcomeEmail(updates.profileEmail, profile.username).catch(() => {});
  }

  // 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 });
});

// ============================================================================
// USER TAB STATE (per-user tab persistence across devices)
// ============================================================================

/** GET /api/user/tabs/:spaceSlug — return saved tabs for this user+space */
app.get('/api/user/tabs/:spaceSlug', 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 spaceSlug = c.req.param('spaceSlug');

  const rows = await sql`
    SELECT tabs, updated_at FROM user_tab_state
    WHERE user_id = ${userId} AND space_slug = ${spaceSlug}
  `;

  if (rows.length === 0) {
    return c.json({ tabs: null, updatedAt: null });
  }

  return c.json({ tabs: rows[0].tabs, updatedAt: rows[0].updated_at });
});

/** PUT /api/user/tabs/:spaceSlug — upsert tab list for this user+space */
app.put('/api/user/tabs/:spaceSlug', 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 spaceSlug = c.req.param('spaceSlug');
  const body = await c.req.json();

  if (!Array.isArray(body.tabs)) {
    return c.json({ error: 'tabs must be an array' }, 400);
  }

  await sql`
    INSERT INTO user_tab_state (user_id, space_slug, tabs, updated_at)
    VALUES (${userId}, ${spaceSlug}, ${JSON.stringify(body.tabs)}::jsonb, NOW())
    ON CONFLICT (user_id, space_slug)
    DO UPDATE SET tabs = ${JSON.stringify(body.tabs)}::jsonb, updated_at = NOW()
  `;

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

// ============================================================================
// NOTIFICATION API (read/manage — used by rspace proxy)
// ============================================================================

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

  try {
    const url = new URL(c.req.url);
    const notifications = await getUserNotifications(claims.sub, {
      unreadOnly: url.searchParams.get('unread') === 'true',
      category: url.searchParams.get('category') || undefined,
      limit: Math.min(Number(url.searchParams.get('limit')) || 50, 100),
      offset: Number(url.searchParams.get('offset')) || 0,
    });
    return c.json({ notifications });
  } catch (err) {
    console.error('[notifications] GET / failed:', err instanceof Error ? err.message : err);
    return c.json({ notifications: [] });
  }
});

/** GET /api/notifications/count — lightweight unread count */
app.get('/api/notifications/count', async (c) => {
  const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
  if (!claims) return c.json({ error: 'Authentication required' }, 401);

  try {
    const count = await getUnreadCount(claims.sub);
    return c.json({ unreadCount: count });
  } catch (err) {
    console.error('[notifications] GET /count failed:', err instanceof Error ? err.message : err);
    return c.json({ unreadCount: 0 });
  }
});

/** PATCH /api/notifications/:id/read — mark one notification as read */
app.patch('/api/notifications/:id/read', async (c) => {
  const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
  if (!claims) return c.json({ error: 'Authentication required' }, 401);

  const ok = await markNotificationRead(c.req.param('id'), claims.sub);
  if (!ok) return c.json({ error: 'Notification not found' }, 404);
  return c.json({ ok: true });
});

/** POST /api/notifications/read-all — mark all read */
app.post('/api/notifications/read-all', async (c) => {
  const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
  if (!claims) return c.json({ error: 'Authentication required' }, 401);

  let spaceSlug: string | undefined;
  let category: string | undefined;
  try {
    const body = await c.req.json();
    spaceSlug = body.spaceSlug;
    category = body.category;
  } catch { /* no body is fine */ }

  const count = await markAllNotificationsRead(claims.sub, { spaceSlug, category });
  return c.json({ ok: true, markedRead: count });
});

/** DELETE /api/notifications/:id — dismiss/archive */
app.delete('/api/notifications/:id', async (c) => {
  const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
  if (!claims) return c.json({ error: 'Authentication required' }, 401);

  const ok = await dismissNotification(c.req.param('id'), claims.sub);
  if (!ok) return c.json({ error: 'Notification not found' }, 404);
  return c.json({ ok: true });
});

/** GET /api/notifications/preferences */
app.get('/api/notifications/preferences', async (c) => {
  const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
  if (!claims) return c.json({ error: 'Authentication required' }, 401);

  const prefs = await getNotificationPreferences(claims.sub);
  return c.json({ preferences: prefs || {
    emailEnabled: true, pushEnabled: true,
    quietHoursStart: null, quietHoursEnd: null,
    mutedSpaces: [], mutedCategories: [],
    digestFrequency: 'none',
  }});
});

/** PATCH /api/notifications/preferences */
app.patch('/api/notifications/preferences', async (c) => {
  const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
  if (!claims) return c.json({ error: 'Authentication required' }, 401);

  const body = await c.req.json();
  const prefs = await upsertNotificationPreferences(claims.sub, body);
  return c.json({ preferences: prefs });
});

/** GET /api/notifications/push/vapid-public-key */
app.get('/api/notifications/push/vapid-public-key', (c) => {
  const key = process.env.VAPID_PUBLIC_KEY;
  if (!key) return c.json({ error: 'Push not configured' }, 503);
  return c.json({ publicKey: key });
});

/** POST /api/notifications/push/subscribe */
app.post('/api/notifications/push/subscribe', async (c) => {
  const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
  if (!claims) return c.json({ error: 'Authentication required' }, 401);

  const body = await c.req.json();
  const { endpoint, keys } = body;
  if (!endpoint || !keys?.p256dh || !keys?.auth) {
    return c.json({ error: 'Invalid subscription object' }, 400);
  }

  await savePushSubscription({
    id: crypto.randomUUID(),
    userDid: claims.sub,
    endpoint,
    keyP256dh: keys.p256dh,
    keyAuth: keys.auth,
    userAgent: c.req.header('user-agent'),
  });
  return c.json({ ok: true });
});

/** POST /api/notifications/push/unsubscribe */
app.post('/api/notifications/push/unsubscribe', async (c) => {
  const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
  if (!claims) return c.json({ error: 'Authentication required' }, 401);

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

  await deletePushSubscriptionByEndpoint(body.endpoint);
  return c.json({ ok: 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 (count accepted ones for recovery readiness)
  let guardianCount = 0;
  let acceptedGuardianCount = 0;
  try {
    const rows = await sql`SELECT status, COUNT(*) as count FROM guardians WHERE user_id = ${userId} AND status != 'revoked' GROUP BY status`;
    for (const r of rows) {
      const cnt = parseInt(r.count || '0');
      guardianCount += cnt;
      if (r.status === 'accepted') acceptedGuardianCount = cnt;
    }
  } catch { /* ignore */ }
  const hasRecovery = acceptedGuardianCount >= 2;

  // Last drill timestamp
  const lastDrillAt = await getUserLastDrillAt(userId);

  return c.json({
    email: hasEmail,
    emailAddress: profile?.profileEmail || null,
    multiDevice: hasMultiDevice,
    socialRecovery: hasRecovery,
    credentialCount: creds.length,
    guardianCount,
    acceptedGuardianCount,
    lastDrillAt,
  });
});

/**
 * 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.trim().toLowerCase());

  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, deviceName } = 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);
  const devLabel = (typeof deviceName === 'string' && deviceName.trim()) ? deviceName.trim().slice(0, 100) : undefined;
  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,
  }, undefined, devLabel);

  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);
    }

    const normalizedEmail = email.trim().toLowerCase();
    await setUserEmail(payload.sub as string, normalizedEmail);
    return c.json({ success: true, email: normalizedEmail });
  } 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.',
  });
});

// ============================================================================
// MAGIC LINK LOGIN
// ============================================================================

/**
 * Request a magic login link — sends an email with a one-time login URL.
 * For users signing in from a device that doesn't have their passkey.
 * Session issued has wallet capabilities disabled (no PRF keys available).
 */
app.post('/api/auth/magic-link', async (c) => {
  const { email } = await c.req.json();

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

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

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

  // Send magic login email
  const loginLink = `https://auth.rspace.online/magic-login?token=${encodeURIComponent(token)}`;
  if (smtpTransport) {
    try {
      await smtpTransport.sendMail({
        from: CONFIG.smtp.from,
        to: email.trim().toLowerCase(),
        subject: 'rStack — Sign In Link',
        text: [
          `Hi ${user.username},`,
          '',
          'Use the link below to sign in to your rStack account:',
          '',
          loginLink,
          '',
          'This link expires in 15 minutes and can only be used once.',
          'Note: Wallet features are unavailable when signing in via email link. Use a device with your passkey for full functionality.',
          '',
          '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;">&#9993;</div>
          <h1 style="margin:0;font-size:24px;background:linear-gradient(90deg,#00d4ff,#7c3aed);-webkit-background-clip:text;-webkit-text-fill-color:transparent;">rStack Sign In</h1>
        </td></tr>
        <tr><td style="padding:0 32px 24px;color:#e2e8f0;font-size:15px;line-height:1.6;">
          <p>Hi <strong>${user.username}</strong>,</p>
          <p>Click below to sign in to your rStack account:</p>
        </td></tr>
        <tr><td style="padding:0 32px 24px;text-align:center;">
          <a href="${loginLink}" 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;">Sign In</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>15 minutes</strong> and can only be used once.</p>
          <p style="background:rgba(234,179,8,0.1);border:1px solid rgba(234,179,8,0.2);border-radius:8px;padding:8px 12px;margin-top:8px;color:#eab308;font-size:12px;">
            Wallet and signing features are unavailable via email login. Use a device with your passkey for full functionality.
          </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;">${loginLink}</span>
          </p>
        </td></tr>
      </table>
    </td></tr>
  </table>
</body>
</html>`,
      });
    } catch (err) {
      console.error('EncryptID: Failed to send magic login email:', err);
    }
  } else {
    console.log(`EncryptID: [NO SMTP] Magic login link for ${email}: ${loginLink}`);
  }

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

/**
 * Magic login page — verifies token and issues session
 */
app.get('/magic-login', async (c) => {
  const token = c.req.query('token');
  if (!token) {
    return c.redirect('/');
  }

  const rt = await getRecoveryToken(token);
  const valid = rt && !rt.used && rt.type === 'magic_login' && Date.now() <= rt.expiresAt;

  if (valid) {
    await markRecoveryTokenUsed(token);
  }

  const user = valid ? await getUserById(rt.userId) : null;
  const sessionToken = user ? await generateSessionToken(rt.userId, user.username) : null;

  // Return a page that stores the token and redirects
  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 — Sign In</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; }
    .icon { font-size: 3rem; margin-bottom: 0.75rem; }
    h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
    p { color: #94a3b8; font-size: 0.9rem; line-height: 1.5; margin-bottom: 1rem; }
    .warning { background: rgba(234,179,8,0.1); border: 1px solid rgba(234,179,8,0.2); border-radius: 8px; padding: 0.75rem; color: #eab308; font-size: 0.8rem; margin-bottom: 1rem; }
    .error { color: #fca5a5; }
    .btn { display: inline-block; padding: 0.75rem 2rem; background: linear-gradient(90deg, #00d4ff, #7c3aed); color: #fff; text-decoration: none; border-radius: 8px; font-weight: 600; font-size: 0.9rem; border: none; cursor: pointer; }
  </style>
</head>
<body>
  <div class="card">
${valid && sessionToken ? `
    <div class="icon">&#10003;</div>
    <h1>Signed In</h1>
    <p>Welcome back, <strong>${user!.username}</strong>!</p>
    <div class="warning">You're signed in via email link. Wallet and signing features require your passkey device.</div>
    <p>Redirecting...</p>
    <script>
      localStorage.setItem('encryptid_token', ${JSON.stringify(sessionToken)});
      setTimeout(() => { window.location.href = '/'; }, 1500);
    </script>
` : `
    <div class="icon">&#10060;</div>
    <h1>Link Expired</h1>
    <p class="error">This login link is invalid or has already been used.</p>
    <a href="/" class="btn">Back to Sign In</a>
`}
  </div>
</body>
</html>`);
});

// ============================================================================
// 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 upInfo = await getUserUPAddress(userId);

  // Read stored DID from database instead of deriving from userId
  const user = await getUserById(userId);
  const did = user?.did || `did:key:${userId.slice(0, 32)}`;

  const payload = {
    iss: 'https://auth.rspace.online',
    sub: userId,
    aud: 'rspace.online',
    iat: now,
    exp: now + CONFIG.sessionDuration,
    jti: Buffer.from(crypto.getRandomValues(new Uint8Array(16))).toString('base64url'),
    username,
    did,
    eid: {
      authLevel: 3,  // ELEVATED (fresh WebAuthn)
      authTime: now,
      walletAddress: profile?.walletAddress || null,
      capabilities: {
        encrypt: true,
        sign: true,
        wallet: hasWallet,
      },
      recoveryConfigured: false,
      ...(upInfo ? {
        up: {
          address: upInfo.upAddress,
          keyManagerAddress: upInfo.keyManagerAddress,
          chainId: upInfo.chainId,
        },
      } : {}),
    },
  };

  return sign(payload, CONFIG.jwtSecret);
}

// ============================================================================
// UNIVERSAL PROFILE ENDPOINTS
// ============================================================================

app.get('/api/profile/:id/up', async (c) => {
  const userId = c.req.param('id');
  const authHeader = c.req.header('Authorization');
  if (!authHeader?.startsWith('Bearer ')) return c.json({ error: 'Unauthorized' }, 401);
  try {
    const claims = await verify(authHeader.slice(7), CONFIG.jwtSecret);
    if (claims.sub !== userId) return c.json({ error: 'Forbidden' }, 403);
  } catch { return c.json({ error: 'Invalid token' }, 401); }

  const up = await getUserUPAddress(userId);
  if (!up) return c.json({ deployed: false });
  return c.json({ deployed: true, ...up });
});

app.post('/api/profile/:id/up', async (c) => {
  const userId = c.req.param('id');
  const authHeader = c.req.header('Authorization');
  if (!authHeader?.startsWith('Bearer ')) return c.json({ error: 'Unauthorized' }, 401);
  try {
    const claims = await verify(authHeader.slice(7), CONFIG.jwtSecret);
    if (claims.sub !== userId) return c.json({ error: 'Forbidden' }, 403);
    const eid = claims.eid as Record<string, unknown> | undefined;
    if (!eid || (eid.authLevel as number) < 3) return c.json({ error: 'ELEVATED auth required' }, 403);
  } catch { return c.json({ error: 'Invalid token' }, 401); }

  const body = await c.req.json<{ upAddress: string; keyManagerAddress: string; chainId: number }>();
  if (!body.upAddress || !body.keyManagerAddress || !body.chainId) {
    return c.json({ error: 'Missing required fields' }, 400);
  }
  await setUserUPAddress(userId, body.upAddress, body.keyManagerAddress, body.chainId);
  return c.json({ ok: true });
});

// ============================================================================
// 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>
    function detectDeviceName() {
      const ua = navigator.userAgent;
      let browser = 'Unknown browser', os = 'Unknown OS';
      if (/CriOS/i.test(ua)) browser = 'Chrome';
      else if (/FxiOS/i.test(ua)) browser = 'Firefox';
      else if (/Safari/i.test(ua) && !/Chrome/i.test(ua)) browser = 'Safari';
      else if (/Edg/i.test(ua)) browser = 'Edge';
      else if (/Firefox/i.test(ua)) browser = 'Firefox';
      else if (/Chrome/i.test(ua)) browser = 'Chrome';
      if (/iPhone/i.test(ua)) os = 'iPhone';
      else if (/iPad/i.test(ua)) os = 'iPad';
      else if (/Android/i.test(ua)) os = 'Android';
      else if (/Mac OS/i.test(ua)) os = 'macOS';
      else if (/Windows/i.test(ua)) os = 'Windows';
      else if (/Linux/i.test(ua)) os = 'Linux';
      else if (/CrOS/i.test(ua)) os = 'ChromeOS';
      return browser + ' on ' + os;
    }

    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, deviceName: detectDeviceName() }),
        });
        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>
  `);
});

// ============================================================================
// SOCIAL RECOVERY COMPLETION PAGE
// ============================================================================

/**
 * GET /recover/social — page where user finalizes social recovery
 * After guardians approve, user opens this to register a new passkey
 */
app.get('/recover/social', (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>Social 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: 480px; 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; }
    .progress-bar { width: 100%; height: 8px; background: rgba(255,255,255,0.1); border-radius: 4px; margin: 1rem 0; overflow: hidden; }
    .progress-fill { height: 100%; background: linear-gradient(90deg, #00d4ff, #7c3aed); border-radius: 4px; transition: width 0.5s; }
    .approval-list { text-align: left; margin: 1rem 0; }
    .approval-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0; border-bottom: 1px solid rgba(255,255,255,0.05); font-size: 0.85rem; color: #94a3b8; }
    .approval-item .dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
    .dot.approved { background: #22c55e; }
    .dot.pending { background: #64748b; }
    .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; }
    .link { color: #00d4ff; text-decoration: none; }
    .link:hover { text-decoration: underline; }
  </style>
</head>
<body>
  <div class="card">
    <div class="icon">&#128101;</div>
    <h1>Social Recovery</h1>
    <p class="sub">Your guardians are helping you recover your account</p>

    <div id="status" class="status loading">Checking recovery status...</div>
    <div id="progress-section" class="hidden">
      <div class="progress-bar"><div id="progress-fill" class="progress-fill" style="width:0%"></div></div>
      <div id="approval-list" class="approval-list"></div>
    </div>

    <button id="complete-btn" class="btn hidden" onclick="completeRecovery()">Complete Recovery</button>

    <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 <a class="link" href="/">sign in with your new passkey</a> on any r*.online app.</p>
    </div>
  </div>

  <script>
    function detectDeviceName() {
      const ua = navigator.userAgent;
      let browser = 'Unknown browser', os = 'Unknown OS';
      if (/CriOS/i.test(ua)) browser = 'Chrome';
      else if (/FxiOS/i.test(ua)) browser = 'Firefox';
      else if (/Safari/i.test(ua) && !/Chrome/i.test(ua)) browser = 'Safari';
      else if (/Edg/i.test(ua)) browser = 'Edge';
      else if (/Firefox/i.test(ua)) browser = 'Firefox';
      else if (/Chrome/i.test(ua)) browser = 'Chrome';
      if (/iPhone/i.test(ua)) os = 'iPhone';
      else if (/iPad/i.test(ua)) os = 'iPad';
      else if (/Android/i.test(ua)) os = 'Android';
      else if (/Mac OS/i.test(ua)) os = 'macOS';
      else if (/Windows/i.test(ua)) os = 'Windows';
      else if (/Linux/i.test(ua)) os = 'Linux';
      else if (/CrOS/i.test(ua)) os = 'ChromeOS';
      return browser + ' on ' + os;
    }

    const params = new URLSearchParams(window.location.search);
    const requestId = params.get('id');
    const statusEl = document.getElementById('status');
    const progressSection = document.getElementById('progress-section');
    const progressFill = document.getElementById('progress-fill');
    const approvalList = document.getElementById('approval-list');
    const completeBtn = document.getElementById('complete-btn');
    const registerSection = document.getElementById('register-section');
    const successSection = document.getElementById('success-section');

    let sessionToken = null;
    let userId = null;
    let username = null;
    let pollTimer = null;

    async function checkStatus() {
      if (!requestId) {
        statusEl.className = 'status error';
        statusEl.textContent = 'No recovery request ID. Use the link from your recovery email.';
        return;
      }
      try {
        const res = await fetch('/api/recovery/social/' + encodeURIComponent(requestId) + '/status');
        if (!res.ok) {
          const err = await res.json().catch(() => ({}));
          statusEl.className = 'status error';
          statusEl.textContent = err.error || 'Recovery request not found.';
          return;
        }
        const data = await res.json();
        const pct = Math.round((data.approvalCount / data.threshold) * 100);
        progressFill.style.width = Math.min(pct, 100) + '%';

        // Show approval dots
        approvalList.innerHTML = data.approvals.map(a =>
          '<div class="approval-item"><span class="dot ' + (a.approved ? 'approved' : 'pending') + '"></span>' +
          'Guardian ' + a.guardianId.slice(0, 8) + '... — ' + (a.approved ? 'Approved' : 'Waiting') + '</div>'
        ).join('');
        progressSection.classList.remove('hidden');

        if (data.status === 'approved') {
          clearInterval(pollTimer);
          statusEl.className = 'status success';
          statusEl.textContent = 'Recovery approved! ' + data.approvalCount + '/' + data.threshold + ' guardians confirmed. Complete your recovery below.';
          completeBtn.classList.remove('hidden');
        } else if (data.status === 'completed') {
          clearInterval(pollTimer);
          statusEl.className = 'status success';
          statusEl.textContent = 'This recovery has already been completed.';
          progressSection.classList.add('hidden');
        } else if (data.status === 'expired') {
          clearInterval(pollTimer);
          statusEl.className = 'status error';
          statusEl.textContent = 'This recovery request has expired. Please start a new one.';
        } else {
          // pending
          statusEl.className = 'status loading';
          statusEl.textContent = 'Waiting for guardian approvals... ' + data.approvalCount + '/' + data.threshold + ' so far. This page auto-refreshes.';
        }
      } catch (e) {
        statusEl.className = 'status error';
        statusEl.textContent = 'Failed to check status: ' + e.message;
      }
    }

    async function completeRecovery() {
      completeBtn.disabled = true;
      completeBtn.textContent = 'Completing...';
      try {
        const res = await fetch('/api/recovery/social/' + encodeURIComponent(requestId) + '/complete', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
        });
        const data = await res.json();
        if (!data.success) {
          throw new Error(data.error || 'Failed to complete recovery');
        }
        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.';
        completeBtn.classList.add('hidden');
        registerSection.classList.remove('hidden');
      } catch (e) {
        statusEl.className = 'status error';
        statusEl.textContent = 'Failed: ' + e.message;
        completeBtn.disabled = false;
        completeBtn.textContent = 'Complete Recovery';
      }
    }

    async function registerNewPasskey() {
      const btn = document.getElementById('register-btn');
      btn.disabled = true;
      btn.textContent = 'Registering...';
      try {
        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();

        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: 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?.() || [],
        };

        const completeRes = await fetch('/api/register/complete', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ challenge: options.challenge, credential: credentialData, userId, username, deviceName: detectDeviceName() }),
        });
        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');
          progressSection.classList.add('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';
      }
    }

    // Initial check + poll every 10s while pending
    checkStatus();
    pollTimer = setInterval(checkStatus, 10000);
  </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';

    function detectDeviceName() {
      const ua = navigator.userAgent;
      let browser = 'Unknown browser', os = 'Unknown OS';
      if (/CriOS/i.test(ua)) browser = 'Chrome';
      else if (/FxiOS/i.test(ua)) browser = 'Firefox';
      else if (/Safari/i.test(ua) && !/Chrome/i.test(ua)) browser = 'Safari';
      else if (/Edg/i.test(ua)) browser = 'Edge';
      else if (/Firefox/i.test(ua)) browser = 'Firefox';
      else if (/Chrome/i.test(ua)) browser = 'Chrome';
      if (/iPhone/i.test(ua)) os = 'iPhone';
      else if (/iPad/i.test(ua)) os = 'iPad';
      else if (/Android/i.test(ua)) os = 'Android';
      else if (/Mac OS/i.test(ua)) os = 'macOS';
      else if (/Windows/i.test(ua)) os = 'Windows';
      else if (/Linux/i.test(ua)) os = 'Linux';
      else if (/CrOS/i.test(ua)) os = 'ChromeOS';
      return browser + ' on ' + os;
    }

    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, deviceName: detectDeviceName() }),
      });
      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, requestId: existing.id, 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 },
    actionUrl: `/recover/social?id=${requestId}`,
  }).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, requestId, 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; }
    .status.drill { background: rgba(251,191,36,0.1); color: #fbbf24; }
    .drill-badge { display: inline-block; padding: 3px 10px; border-radius: 6px; background: rgba(251,191,36,0.15); color: #fbbf24; font-size: 0.7rem; font-weight: 700; letter-spacing: 0.05em; margin-bottom: 0.75rem; }
    .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.drill { background: linear-gradient(90deg, #fbbf24, #f59e0b); color: #1a1a2e; }
    .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 id="title">Recovery Approval</h1>
    <div id="drill-badge" class="drill-badge hidden">DRILL — TEST ONLY</div>
    <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');
    const titleEl = document.getElementById('title');
    const drillBadge = document.getElementById('drill-badge');

    // Pre-check if this is a drill by looking at query params or checking the token info
    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;
        }
        // Show drill badge if this was a drill
        if (data.isDrill) {
          drillBadge.classList.remove('hidden');
          titleEl.textContent = 'Recovery Drill';
          statusEl.className = 'status success';
          statusEl.innerHTML = 'Drill approved! <strong>' + data.approvalCount + ' of ' + data.threshold + '</strong> approvals received.' +
            (data.approvalCount >= data.threshold ? '<br><br>The drill is complete. The account owner\\'s recovery setup is working!' : '<br><br>Waiting for more guardians to approve the drill.');
        } else {
          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');

    if (request.isDrill) {
      // Auto-complete drills when threshold met + record timestamp
      await updateRecoveryRequestStatus(request.id, 'completed');
      await setUserLastDrillAt(request.userId);
    }

    // Notify account owner
    const eventType = request.isDrill ? 'recovery_drill_complete' : 'recovery_approved';
    const title = request.isDrill ? 'Recovery drill complete!' : 'Account recovery approved';
    const body = request.isDrill
      ? `${request.approvalCount}/${request.threshold} guardians approved your drill. Your recovery setup is working!`
      : `${request.approvalCount}/${request.threshold} guardians approved. You can now recover your account.`;
    notify({
      userDid: request.userId,
      category: 'system',
      eventType,
      title,
      body,
      metadata: { recoveryRequestId: request.id, isDrill: request.isDrill },
      actionUrl: request.isDrill ? undefined : `https://auth.rspace.online/recover/social?id=${request.id}`,
    }).catch(() => {});
  }

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

/**
 * 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.',
  });
});

// ============================================================================
// RECOVERY DRILL (test recovery flow)
// ============================================================================

/**
 * POST /api/recovery/drill/initiate — start a recovery drill (auth required)
 * Sends drill-flagged notifications to guardians
 */
app.post('/api/recovery/drill/initiate', 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 user = await getUserById(userId);
  if (!user) return c.json({ error: 'User not found' }, 404);

  const guardians = await getGuardians(userId);
  const accepted = guardians.filter(g => g.status === 'accepted');
  if (accepted.length < 2) {
    return c.json({ error: 'Need at least 2 accepted guardians to run a drill' }, 400);
  }

  // Check no active non-drill recovery request
  const existing = await getActiveRecoveryRequest(userId);
  if (existing && !existing.isDrill) {
    return c.json({ error: 'An active recovery request already exists. Cannot start a drill.' }, 400);
  }

  // Create drill request (24h expiry, same 2-of-3 threshold)
  const drillId = generateToken();
  const expiresAt = Date.now() + 24 * 60 * 60 * 1000;
  await createDrillRequest(drillId, userId, 2, expiresAt);

  // Create approval tokens and notify guardians
  const drillGuardians: Array<{ id: string; name: string; status: string }> = [];
  for (const guardian of accepted) {
    const approvalToken = generateToken();
    await createRecoveryApproval(drillId, guardian.id, approvalToken);

    const approveUrl = `https://auth.rspace.online/approve?token=${approvalToken}`;
    drillGuardians.push({ id: guardian.id, name: guardian.name, status: 'pending' });

    // In-app notification
    if (guardian.guardianUserId) {
      notify({
        userDid: guardian.guardianUserId,
        category: 'system',
        eventType: 'recovery_drill',
        title: `Recovery drill from ${user.username}`,
        body: `This is a test! ${user.username} is testing their recovery setup. Please verify and approve.`,
        actionUrl: approveUrl,
        actorUsername: user.username,
        metadata: { recoveryRequestId: drillId, guardianId: guardian.id, isDrill: true },
      }).catch(() => {});
    }

    // Email notification
    if (guardian.email && smtpTransport) {
      try {
        await smtpTransport.sendMail({
          from: CONFIG.smtp.from,
          to: guardian.email,
          subject: `Recovery DRILL from ${user.username} — rStack`,
          text: [
            `Hi ${guardian.name},`,
            '',
            `This is a TEST! ${user.username} is running a recovery drill to test their guardian setup.`,
            '',
            'Please verify their identity and approve:',
            approveUrl,
            '',
            'This is NOT a real recovery — just a test. The drill expires in 24 hours.',
            '',
            '— 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,#fbbf24,#f59e0b);-webkit-background-clip:text;-webkit-text-fill-color:transparent;">Recovery DRILL</h1>
          <div style="display:inline-block;margin-top:8px;padding:4px 12px;border-radius:6px;background:rgba(251,191,36,0.15);color:#fbbf24;font-size:0.75rem;font-weight:700;letter-spacing:0.05em;">TEST ONLY</div>
        </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 running a <strong>recovery drill</strong> to test their guardian setup.</p>
          <p style="color:#94a3b8;font-size:13px;">This is NOT a real recovery. Please verify their identity and approve the drill.</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,#fbbf24,#f59e0b);color:#1a1a2e;text-decoration:none;border-radius:8px;font-weight:600;font-size:15px;">Approve Drill</a>
        </td></tr>
        <tr><td style="padding:0 32px 32px;color:#94a3b8;font-size:13px;line-height:1.5;">
          <p>This drill expires in <strong>24 hours</strong>.</p>
        </td></tr>
      </table>
    </td></tr>
  </table>
</body></html>`,
        });
      } catch (err) {
        console.error(`EncryptID: Failed to send drill email to ${guardian.email}:`, err);
      }
    }
  }

  return c.json({
    success: true,
    drillId,
    guardians: drillGuardians,
  });
});

/**
 * GET /api/recovery/drill/:id/status — check drill status (auth required)
 */
app.get('/api/recovery/drill/:id/status', async (c) => {
  const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
  if (!claims) return c.json({ error: 'Unauthorized' }, 401);

  const { id } = c.req.param();
  const request = await getRecoveryRequest(id);
  if (!request) return c.json({ error: 'Not found' }, 404);
  if (!request.isDrill) return c.json({ error: 'Not a drill' }, 400);
  if (request.userId !== claims.sub) return c.json({ error: 'Not your drill' }, 403);

  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/drill/:id/complete — mark drill as completed (no recovery token issued)
 */
app.post('/api/recovery/drill/:id/complete', async (c) => {
  const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
  if (!claims) return c.json({ error: 'Unauthorized' }, 401);

  const { id } = c.req.param();
  const request = await getRecoveryRequest(id);
  if (!request) return c.json({ error: 'Not found' }, 404);
  if (!request.isDrill) return c.json({ error: 'Not a drill' }, 400);
  if (request.userId !== claims.sub) return c.json({ error: 'Not your drill' }, 403);

  await updateRecoveryRequestStatus(id, 'completed');
  await setUserLastDrillAt(claims.sub as string);

  return c.json({ success: true, message: 'Drill completed successfully!' });
});

// ============================================================================
// 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, deviceName } = 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);
  const linkLabel = (typeof deviceName === 'string' && deviceName.trim()) ? deviceName.trim().slice(0, 100) : undefined;
  await storeCredential({
    credentialId: credential.credentialId,
    publicKey: credential.publicKey,
    userId: link.userId,
    username: user.username,
    counter: 0,
    createdAt: Date.now(),
    transports: credential.transports,
    rpId,
  }, undefined, linkLabel);

  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; }
    .btn-copy { display: inline-block; padding: 0.6rem 1.5rem; background: rgba(255,255,255,0.1); color: #fff; border: 1px solid rgba(255,255,255,0.2); border-radius: 8px; font-size: 0.85rem; cursor: pointer; margin-top: 0.75rem; }
    .btn-copy:active { background: rgba(255,255,255,0.2); }
    .browser-hint { color: #94a3b8; font-size: 0.8rem; margin-top: 0.75rem; line-height: 1.5; }
    .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="no-webauthn" class="hidden">
      <p class="browser-hint">Passkeys require a full browser.<br>Open this link in <strong>Safari</strong> (iOS) or <strong>Chrome</strong> (Android):</p>
      <button class="btn-copy" onclick="copyLink()">Copy Link</button>
      <p id="copy-confirm" class="browser-hint hidden" style="color:#22c55e;">Copied!</p>
    </div>
    <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>
    function detectDeviceName() {
      const ua = navigator.userAgent;
      let browser = 'Unknown browser', os = 'Unknown OS';
      if (/CriOS/i.test(ua)) browser = 'Chrome';
      else if (/FxiOS/i.test(ua)) browser = 'Firefox';
      else if (/Safari/i.test(ua) && !/Chrome/i.test(ua)) browser = 'Safari';
      else if (/Edg/i.test(ua)) browser = 'Edge';
      else if (/Firefox/i.test(ua)) browser = 'Firefox';
      else if (/Chrome/i.test(ua)) browser = 'Chrome';
      if (/iPhone/i.test(ua)) os = 'iPhone';
      else if (/iPad/i.test(ua)) os = 'iPad';
      else if (/Android/i.test(ua)) os = 'Android';
      else if (/Mac OS/i.test(ua)) os = 'macOS';
      else if (/Windows/i.test(ua)) os = 'Windows';
      else if (/Linux/i.test(ua)) os = 'Linux';
      else if (/CrOS/i.test(ua)) os = 'ChromeOS';
      return browser + ' on ' + os;
    }

    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;

    function copyLink() {
      const url = window.location.href;
      if (navigator.clipboard) {
        navigator.clipboard.writeText(url).then(() => {
          document.getElementById('copy-confirm').classList.remove('hidden');
        });
      } else {
        // Fallback for WebViews without clipboard API
        const ta = document.createElement('textarea');
        ta.value = url;
        ta.style.position = 'fixed';
        ta.style.opacity = '0';
        document.body.appendChild(ta);
        ta.select();
        document.execCommand('copy');
        document.body.removeChild(ta);
        document.getElementById('copy-confirm').classList.remove('hidden');
      }
    }

    async function init() {
      if (!linkToken) {
        statusEl.className = 'status error';
        statusEl.textContent = 'No link token found.';
        return;
      }

      // Check WebAuthn support early — QR scanners often use WebViews that lack it
      const webauthnSupported = window.PublicKeyCredential &&
        typeof navigator.credentials?.create === 'function';

      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;

        if (!webauthnSupported) {
          statusEl.className = 'status error';
          statusEl.textContent = 'This browser does not support passkeys.';
          document.getElementById('no-webauthn').classList.remove('hidden');
          return;
        }

        statusEl.className = 'status success';
        statusEl.textContent = 'Link this device to ' + data.username + String.fromCharCode(39) + '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, deviceName: detectDeviceName() }),
        });
        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: 'eth', 10: 'oeth', 100: 'gno', 137: 'pol',
    8453: 'base', 42161: 'arb1', 42220: 'celo', 43114: 'avax',
    56: 'bnb', 324: 'zksync', 59144: 'linea', 11155111: 'sep', 84532: 'basesep',
  };
  const prefix = CHAIN_PREFIXES[chain];
  if (!prefix) {
    return c.json({ error: 'Unsupported chain' }, 400);
  }

  try {
    const safeRes = await fetch(
      `https://api.safe.global/tx-service/${prefix}/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);
  }
});

// ============================================================================
// SERVER-SIDE ENCRYPTION FOR LINKED WALLETS
// ============================================================================

// Derive a dedicated AES-256-GCM key from JWT_SECRET via HKDF.
// This encrypts linked wallet data at rest in the DB.
// (Linked wallets are NOT zero-knowledge — the server sees the address during
// SIWE verification — but we encrypt at rest to prevent casual DB snooping.)
let _walletLinkKey: CryptoKey | null = null;
async function getWalletLinkKey(): Promise<CryptoKey> {
  if (_walletLinkKey) return _walletLinkKey;
  const raw = new TextEncoder().encode(CONFIG.jwtSecret);
  const base = await crypto.subtle.importKey('raw', raw, { name: 'HKDF' }, false, ['deriveKey']);
  _walletLinkKey = await crypto.subtle.deriveKey(
    { name: 'HKDF', hash: 'SHA-256', salt: new TextEncoder().encode('linked-wallets-v1'), info: new Uint8Array(0) },
    base,
    { name: 'AES-GCM', length: 256 },
    false,
    ['encrypt', 'decrypt'],
  );
  return _walletLinkKey;
}

async function encryptWalletEntry(plaintext: string): Promise<{ ciphertext: string; iv: string }> {
  const key = await getWalletLinkKey();
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const ct = new Uint8Array(await crypto.subtle.encrypt(
    { name: 'AES-GCM', iv },
    key,
    new TextEncoder().encode(plaintext),
  ));
  return {
    ciphertext: Buffer.from(ct).toString('base64url'),
    iv: Buffer.from(iv).toString('base64url'),
  };
}

async function decryptWalletEntry(ciphertext: string, iv: string): Promise<string> {
  const key = await getWalletLinkKey();
  const ctBuf = Buffer.from(ciphertext, 'base64url');
  const ivBuf = Buffer.from(iv, 'base64url');
  const plain = await crypto.subtle.decrypt(
    { name: 'AES-GCM', iv: ivBuf },
    key,
    ctBuf,
  );
  return new TextDecoder().decode(plain);
}

// ============================================================================
// LINKED WALLET ROUTES (SIWE-verified external wallet associations)
// ============================================================================

// Rate limiter for wallet-link nonce: max 5 requests per user per 5 minutes
const _nonceLimiter = new Map<string, number[]>();
function checkNonceRateLimit(userId: string): boolean {
  const now = Date.now();
  const windowMs = 5 * 60 * 1000;
  const timestamps = (_nonceLimiter.get(userId) || []).filter(t => now - t < windowMs);
  if (timestamps.length === 0) {
    _nonceLimiter.delete(userId); // evict stale keys
  }
  if (timestamps.length >= 5) return false;
  timestamps.push(now);
  _nonceLimiter.set(userId, timestamps);
  return true;
}

// POST /encryptid/api/wallet-link/nonce — Generate a nonce for SIWE signing.
// Reuses the challenges table with type 'wallet_link'.
app.post('/encryptid/api/wallet-link/nonce', async (c) => {
  const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
  if (!claims) return c.json({ error: 'Authentication required' }, 401);

  if (!checkNonceRateLimit(claims.sub)) {
    return c.json({ error: 'Too many nonce requests. Try again in a few minutes.' }, 429);
  }

  const nonce = crypto.randomUUID().replace(/-/g, '');
  await storeChallenge({
    challenge: nonce,
    userId: claims.sub,
    type: 'wallet_link',
    createdAt: Date.now(),
    expiresAt: Date.now() + 5 * 60 * 1000, // 5 minutes
  });

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

// POST /encryptid/api/wallet-link/verify — Verify SIWE signature and store encrypted link.
// Requires ELEVATED auth (fresh passkey assertion).
app.post('/encryptid/api/wallet-link/verify', async (c) => {
  const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
  if (!claims) return c.json({ error: 'Authentication required' }, 401);

  // Require elevated auth
  if (!claims.eid || claims.eid.authLevel < 3) {
    return c.json({ error: 'Elevated authentication required to link wallets' }, 403);
  }

  const body = await c.req.json();
  const { message, signature, addressHash, walletType, entry } = body;

  if (!message || !signature || !addressHash) {
    return c.json({ error: 'Missing required fields: message, signature, addressHash' }, 400);
  }
  // Input length and type guards
  if (typeof message !== 'string' || message.length > 4096) return c.json({ error: 'Invalid message' }, 400);
  if (typeof signature !== 'string' || signature.length > 520) return c.json({ error: 'Invalid signature' }, 400);
  if (typeof addressHash !== 'string' || addressHash.length > 128) return c.json({ error: 'Invalid addressHash' }, 400);
  if (entry && typeof entry === 'object') {
    const json = JSON.stringify(entry);
    if (json.length > 2048) return c.json({ error: 'Entry data too large' }, 400);
  }

  // Parse and verify SIWE message using viem/siwe
  try {
    const { parseSiweMessage, validateSiweMessage } = await import('viem/siwe');
    const { verifyMessage } = await import('viem');

    const parsed = parseSiweMessage(message);
    if (!parsed.address || !parsed.nonce) {
      return c.json({ error: 'Invalid SIWE message format' }, 400);
    }

    // Atomically consume the nonce (DELETE...RETURNING prevents TOCTOU races)
    const challenge = await consumeChallenge(parsed.nonce, claims.sub, 'wallet_link');
    if (!challenge) {
      return c.json({ error: 'Invalid, expired, or already-used nonce' }, 400);
    }

    // Validate SIWE message fields against server-known domains
    const allowedDomains = [CONFIG.rpId || 'rspace.online'];
    const messageDomain = parsed.domain || '';
    if (!allowedDomains.some(d => messageDomain === d || messageDomain.endsWith(`.${d}`))) {
      return c.json({ error: 'SIWE domain not recognized' }, 400);
    }
    const isValid = validateSiweMessage({
      message: parsed,
      domain: messageDomain,
      nonce: parsed.nonce,
    });
    if (!isValid) {
      return c.json({ error: 'SIWE message validation failed' }, 400);
    }

    // Verify the signature cryptographically
    const recovered = await verifyMessage({
      address: parsed.address as `0x${string}`,
      message,
      signature: signature as `0x${string}`,
    });
    if (!recovered) {
      return c.json({ error: 'Signature verification failed' }, 400);
    }

    // Recompute addressHash server-side (don't trust client-supplied hash)
    const addrNormalized = claims.sub + ':' + (parsed.address as string).toLowerCase();
    const addrHashBuf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(addrNormalized));
    const serverAddressHash = Buffer.from(addrHashBuf).toString('base64url');

    // Check for duplicate
    const exists = await linkedWalletExists(claims.sub, serverAddressHash);
    if (exists) {
      return c.json({ error: 'This wallet is already linked to your account' }, 409);
    }

    // Encrypt wallet entry data server-side (AES-256-GCM at rest)
    const id = crypto.randomUUID();
    const source = walletType === 'safe' ? 'external-safe' : 'external-eoa';
    const entryData = JSON.stringify(entry || {
      address: parsed.address,
      type: walletType || 'eoa',
      label: 'External Wallet',
    });
    const encrypted = await encryptWalletEntry(entryData);
    const stored = await createLinkedWallet(claims.sub, {
      id,
      ciphertext: encrypted.ciphertext,
      iv: encrypted.iv,
      addressHash: serverAddressHash,
      source: source as 'external-eoa' | 'external-safe',
    });

    return c.json({ id: stored.id, linked: true }, 201);
  } catch (err: any) {
    console.error('EncryptID: SIWE verification error', err);
    return c.json({ error: 'SIWE verification failed' }, 400);
  }
});

// GET /encryptid/api/wallet-link/list — Decrypt and return linked wallet entries.
app.get('/encryptid/api/wallet-link/list', async (c) => {
  const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
  if (!claims) return c.json({ error: 'Authentication required' }, 401);

  const wallets = await getLinkedWallets(claims.sub);
  const entries = [];
  for (const w of wallets) {
    try {
      const decrypted = await decryptWalletEntry(w.ciphertext, w.iv);
      const entry = JSON.parse(decrypted);
      entries.push({
        id: w.id,
        source: w.source,
        verified: w.verified,
        linkedAt: w.linkedAt,
        ...entry,
      });
    } catch {
      // Skip entries that can't be decrypted (corrupted or from old Base64 format)
      console.warn(`EncryptID: Failed to decrypt linked wallet ${w.id} for user ${claims.sub}`);
    }
  }
  return c.json({ wallets: entries });
});

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

  // Require elevated auth
  if (!claims.eid || claims.eid.authLevel < 3) {
    return c.json({ error: 'Elevated authentication required to unlink wallets' }, 403);
  }

  const id = c.req.param('id');
  const removed = await deleteLinkedWallet(claims.sub, id);
  if (!removed) {
    return c.json({ error: 'Linked wallet not found' }, 404);
  }

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

// ============================================================================
// CRYPTID LEGACY MIGRATION HELPERS
// ============================================================================

/**
 * Verify a CryptID P-256 ECDSA signature.
 * CryptID uses raw ECDSA P-256 keypairs — the public key is a base64-encoded
 * raw key (65 bytes uncompressed), signature is base64-encoded DER/raw.
 */
async function verifyCryptIDSignature(
  publicKeyBase64: string,
  signatureBase64: string,
  challenge: string,
): Promise<boolean> {
  try {
    const pubKeyBytes = Buffer.from(publicKeyBase64, 'base64');
    const sigBytes = Buffer.from(signatureBase64, 'base64');
    const challengeBytes = new TextEncoder().encode(challenge);

    const key = await crypto.subtle.importKey(
      'raw',
      pubKeyBytes,
      { name: 'ECDSA', namedCurve: 'P-256' },
      false,
      ['verify'],
    );

    return await crypto.subtle.verify(
      { name: 'ECDSA', hash: 'SHA-256' },
      key,
      sigBytes,
      challengeBytes,
    );
  } catch (err) {
    console.error('EncryptID: CryptID signature verification failed', err);
    return false;
  }
}

/** SHA-256 hash of a public key base64 string → hex for dedup */
async function hashPublicKey(publicKeyBase64: string): Promise<string> {
  const buf = await crypto.subtle.digest(
    'SHA-256',
    new TextEncoder().encode(publicKeyBase64),
  );
  return Buffer.from(buf).toString('hex');
}

// ============================================================================
// CRYPTID → ENCRYPTID MIGRATION ROUTES
// ============================================================================

// POST /encryptid/api/migration/challenge — Generate a migration challenge.
// For clients that still have private key access (fresh challenge-response).
app.post('/encryptid/api/migration/challenge', async (c) => {
  const body = await c.req.json();
  const { legacyUsername, legacyPublicKey } = body;

  if (!legacyUsername || !legacyPublicKey) {
    return c.json({ error: 'Missing required fields: legacyUsername, legacyPublicKey' }, 400);
  }
  if (typeof legacyUsername !== 'string' || legacyUsername.length > 64) {
    return c.json({ error: 'Invalid legacyUsername' }, 400);
  }
  if (typeof legacyPublicKey !== 'string' || legacyPublicKey.length > 256) {
    return c.json({ error: 'Invalid legacyPublicKey' }, 400);
  }

  const challenge = crypto.randomUUID();
  await storeChallenge({
    challenge,
    type: 'legacy_migration',
    createdAt: Date.now(),
    expiresAt: Date.now() + 10 * 60 * 1000, // 10 minutes
  });

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

// POST /encryptid/api/migration/verify — Verify CryptID ownership and link to EncryptID.
// Two proof modes:
//   Fresh: { migrationChallenge, freshSignature, legacyUsername, legacyPublicKey }
//   Historical: { originalChallenge, originalSignature, legacyUsername, legacyPublicKey }
// If Authorization header present → links to existing EncryptID user (Mode A).
// If no auth → returns pending registration URL (Mode B).
app.post('/encryptid/api/migration/verify', async (c) => {
  const body = await c.req.json();
  const { legacyUsername, legacyPublicKey } = body;

  if (!legacyUsername || !legacyPublicKey) {
    return c.json({ error: 'Missing required fields: legacyUsername, legacyPublicKey' }, 400);
  }
  if (typeof legacyUsername !== 'string' || legacyUsername.length > 64) {
    return c.json({ error: 'Invalid legacyUsername' }, 400);
  }
  if (typeof legacyPublicKey !== 'string' || legacyPublicKey.length > 256) {
    return c.json({ error: 'Invalid legacyPublicKey' }, 400);
  }

  // Determine proof mode and verify signature
  let verified = false;

  if (body.migrationChallenge && body.freshSignature) {
    // Fresh mode: verify against a challenge we issued
    const challenge = await getChallenge(body.migrationChallenge);
    if (!challenge || challenge.type !== 'legacy_migration') {
      return c.json({ error: 'Invalid or expired migration challenge' }, 400);
    }
    await deleteChallenge(body.migrationChallenge);

    verified = await verifyCryptIDSignature(legacyPublicKey, body.freshSignature, body.migrationChallenge);
  } else if (body.originalChallenge && body.originalSignature) {
    // Historical mode: replay the CryptID registration authData
    verified = await verifyCryptIDSignature(legacyPublicKey, body.originalSignature, body.originalChallenge);
  } else {
    return c.json({ error: 'Must provide either {migrationChallenge, freshSignature} or {originalChallenge, originalSignature}' }, 400);
  }

  if (!verified) {
    return c.json({ error: 'Signature verification failed' }, 400);
  }

  const pubKeyHash = await hashPublicKey(legacyPublicKey);

  // Check if this legacy identity is already linked
  const existing = await getLegacyIdentityByPublicKeyHash('cryptid', pubKeyHash);

  // Mode A: Authenticated user → link to their EncryptID account
  const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
  if (claims) {
    if (existing && existing.userId !== claims.sub) {
      return c.json({ error: 'This CryptID identity is already linked to another account' }, 409);
    }

    const identity = await createLegacyIdentity(claims.sub, {
      id: existing?.id || crypto.randomUUID(),
      provider: 'cryptid',
      legacyUsername,
      legacyPublicKey,
      legacyPublicKeyHash: pubKeyHash,
    });

    return c.json({
      success: true,
      mode: 'linked',
      legacyIdentityId: identity.id,
      legacyUsername: identity.legacyUsername,
      linkedToUser: claims.sub,
    });
  }

  // Mode B: No auth → create a pending migration token for registration
  if (existing?.verified) {
    return c.json({
      success: true,
      mode: 'already_linked',
      message: 'This CryptID identity is already linked to an EncryptID account. Log in to your EncryptID account.',
    });
  }

  // Store a short-lived migration token (reuse challenges table)
  const migrationToken = crypto.randomUUID();
  await storeChallenge({
    challenge: migrationToken,
    type: 'legacy_migration',
    createdAt: Date.now(),
    expiresAt: Date.now() + 30 * 60 * 1000, // 30 minutes to complete registration
  });

  // Stash the verified legacy info in the challenge metadata area
  // (We'll re-verify on the registration side, so this is just a convenience token)
  return c.json({
    success: true,
    mode: 'pending_registration',
    migrationToken,
    legacyUsername,
    registrationUrl: `https://auth.rspace.online/register?migration=${migrationToken}&legacyUsername=${encodeURIComponent(legacyUsername)}&legacyPublicKey=${encodeURIComponent(legacyPublicKey)}`,
  });
});

// POST /encryptid/api/migration/auth — Fallback auth for transition period.
// Allows CryptID users with a verified linked identity to get a JWT.
app.post('/encryptid/api/migration/auth', async (c) => {
  const body = await c.req.json();
  const { legacyPublicKey, signature, challenge } = body;

  if (!legacyPublicKey || !signature || !challenge) {
    return c.json({ error: 'Missing required fields: legacyPublicKey, signature, challenge' }, 400);
  }
  if (typeof legacyPublicKey !== 'string' || legacyPublicKey.length > 256) {
    return c.json({ error: 'Invalid legacyPublicKey' }, 400);
  }
  if (typeof signature !== 'string' || signature.length > 256) {
    return c.json({ error: 'Invalid signature' }, 400);
  }
  if (typeof challenge !== 'string' || challenge.length > 256) {
    return c.json({ error: 'Invalid challenge' }, 400);
  }

  // Verify the challenge was issued by us (from /api/auth/start)
  const storedChallenge = await getChallenge(challenge);
  if (!storedChallenge) {
    return c.json({ error: 'Invalid or expired challenge' }, 400);
  }
  await deleteChallenge(challenge);

  // Verify the P-256 signature
  const verified = await verifyCryptIDSignature(legacyPublicKey, signature, challenge);
  if (!verified) {
    return c.json({ error: 'Signature verification failed' }, 400);
  }

  // Look up the linked EncryptID user
  const pubKeyHash = await hashPublicKey(legacyPublicKey);
  const linkedUser = await getUserByLegacyPublicKeyHash('cryptid', pubKeyHash);
  if (!linkedUser) {
    return c.json({ error: 'No linked EncryptID account found. Complete migration first.' }, 404);
  }

  // Issue a standard session token
  const token = await generateSessionToken(linkedUser.userId, linkedUser.username);

  return c.json({
    token,
    userId: linkedUser.userId,
    username: linkedUser.username,
    did: linkedUser.did,
    authMethod: 'legacy_cryptid',
  });
});

// GET /encryptid/api/migration/status — Check linked legacy identities.
app.get('/encryptid/api/migration/status', async (c) => {
  const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
  if (!claims) return c.json({ error: 'Authentication required' }, 401);

  const identities = await getLegacyIdentitiesByUser(claims.sub);
  return c.json({
    legacyIdentities: identities.map((li) => ({
      id: li.id,
      provider: li.provider,
      legacyUsername: li.legacyUsername,
      verified: li.verified,
      migratedData: li.migratedData,
      linkedAt: li.linkedAt,
      verifiedAt: li.verifiedAt,
    })),
  });
});

// ============================================================================
// 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 grantedByDid = claims.did as string;
  const member = await upsertSpaceMember(slug, body.userDID, body.role, grantedByDid);
  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 });
});

// ── Internal: email lookup by userId (no auth, internal network only) ──
app.get('/api/internal/user-email/:userId', async (c) => {
  const userId = c.req.param('userId');
  if (!userId) return c.json({ error: 'userId required' }, 400);
  const [user, profile] = await Promise.all([getUserById(userId), getUserProfile(userId)]);
  if (!user && !profile) return c.json({ error: 'User not found' }, 404);
  return c.json({
    recoveryEmail: user?.email || null,
    profileEmail: profile?.profileEmail || null,
    username: profile?.username || user?.username || null,
    displayName: profile?.displayName || user?.display_name || null,
  });
});

// ── Internal: resolve email → user (no auth, internal network only) ──
app.get('/api/internal/user-by-email', async (c) => {
  const email = c.req.query('email');
  if (!email) return c.json({ error: 'email query parameter required' }, 400);
  const user = await getUserByEmail(email.toLowerCase().trim());
  if (!user) return c.json({ error: 'No user with that email' }, 404);
  return c.json({
    id: user.id,
    did: user.did,
    username: user.username,
    displayName: user.display_name || user.username,
  });
});

// GET /api/internal/user-by-wallet — look up user by wallet address
app.get('/api/internal/user-by-wallet', async (c) => {
  const serviceKey = c.req.header('X-Service-Key');
  if (!INTERNAL_SERVICE_KEY || serviceKey !== INTERNAL_SERVICE_KEY) {
    return c.json({ error: 'Unauthorized' }, 401);
  }

  const wallet = c.req.query('wallet');
  if (!wallet) return c.json({ found: false }, 200);

  const normalizedWallet = wallet.toLowerCase().trim();
  // Look up user by wallet_address field in users table
  const rows = await sql`SELECT * FROM users WHERE LOWER(wallet_address) = ${normalizedWallet} LIMIT 1`;
  if (rows.length === 0) return c.json({ found: false }, 200);

  const profile = await getUserProfile(rows[0].id);
  return c.json({
    found: true,
    email: profile?.profileEmail || rows[0].email || undefined,
    username: profile?.username || rows[0].username || undefined,
    userId: rows[0].id,
    did: rows[0].did || undefined,
  });
});

// POST /api/internal/notify — trigger in-app notification for a user by DID or wallet
app.post('/api/internal/notify', async (c) => {
  const serviceKey = c.req.header('X-Service-Key');
  if (!INTERNAL_SERVICE_KEY || serviceKey !== INTERNAL_SERVICE_KEY) {
    return c.json({ error: 'Unauthorized' }, 401);
  }

  const { userDid, wallet, category, eventType, title, body, actionUrl, actorUsername, metadata } = await c.req.json();

  // Resolve DID from wallet if not provided
  let resolvedDid = userDid;
  if (!resolvedDid && wallet) {
    const normalizedWallet = wallet.toLowerCase().trim();
    const rows = await sql`SELECT did FROM users WHERE LOWER(wallet_address) = ${normalizedWallet} LIMIT 1`;
    if (rows.length > 0) resolvedDid = rows[0].did;
  }

  if (!resolvedDid) {
    return c.json({ error: 'Could not resolve user — provide userDid or a registered wallet' }, 400);
  }

  const notification = await notify({
    userDid: resolvedDid,
    category: category || 'payment',
    eventType: eventType || 'payment_received',
    title: title || 'Payment received',
    body,
    actionUrl,
    actorUsername,
    metadata,
  });

  return c.json({ success: true, notificationId: notification.id });
});

// ============================================================================
// 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({
    id: user.id,
    did: user.did,
    username: user.username,
    displayName: user.display_name || user.username,
  });
});

// GET /api/users/search?q=<partial>&limit=5 — fuzzy username/display_name search
app.get('/api/users/search', async (c) => {
  const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
  if (!claims) return c.json({ error: 'Authentication required' }, 401);

  const q = (c.req.query('q') || '').trim();
  if (q.length < 2) return c.json({ error: 'Query must be at least 2 characters' }, 400);

  const limit = Math.min(Math.max(parseInt(c.req.query('limit') || '5'), 1), 10);
  const pattern = `${q}%`;

  const rows = await sql`
    SELECT id, did, username, display_name
    FROM users
    WHERE username ILIKE ${pattern} OR display_name ILIKE ${pattern}
    ORDER BY
      CASE WHEN username ILIKE ${q} THEN 0 ELSE 1 END,
      username
    LIMIT ${limit}
  `;

  return c.json(rows.map((r: any) => ({
    id: r.id,
    did: r.did,
    username: r.username,
    displayName: r.display_name || r.username,
  })));
});

// POST /api/users/resolve-dids — batch-resolve DIDs/userIds to usernames (public profile data)
app.post('/api/users/resolve-dids', async (c) => {
  const body = await c.req.json();
  const dids: string[] = Array.isArray(body.dids) ? body.dids.slice(0, 100) : [];
  if (!dids.length) return c.json({});

  // Query all matching users in one go
  const rows = await sql`SELECT id, did, username, display_name FROM users WHERE did = ANY(${dids}) OR id = ANY(${dids})`;
  const result: Record<string, { username: string; displayName: string }> = {};
  for (const row of rows) {
    const entry = { username: row.username, displayName: row.display_name || row.username };
    if (row.did) result[row.did] = entry;
    result[row.id] = entry;
  }
  return c.json(result);
});

// ============================================================================
// 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 (use DID, not raw userId)
  const userDid = claims.did as string;
  await upsertSpaceMember(accepted.spaceSlug, userDid, 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 });
});

// ============================================================================
// ADMIN: OIDC CLIENT MANAGEMENT
// ============================================================================

// List all OIDC clients
app.get('/api/admin/oidc/clients', 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 clients = await listOidcClients();
  // Mask secrets in list view
  return c.json({ clients: clients.map(cl => ({ ...cl, clientSecret: cl.clientSecret.slice(0, 4) + '***' })) });
});

// Get single OIDC client (full details)
app.get('/api/admin/oidc/clients/:clientId', 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 client = await getOidcClient(c.req.param('clientId'));
  if (!client) return c.json({ error: 'Client not found' }, 404);
  return c.json({ client });
});

// Create new OIDC client
app.post('/api/admin/oidc/clients', 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 body = await c.req.json();
  const { clientId, name, redirectUris, allowedEmails } = body;

  if (!clientId || !name || !redirectUris?.length) {
    return c.json({ error: 'clientId, name, and redirectUris are required' }, 400);
  }

  const clientSecret = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64');

  const client = await createOidcClient({
    clientId,
    clientSecret,
    name,
    redirectUris,
    allowedEmails: allowedEmails || [],
  });

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

// Update OIDC client
app.put('/api/admin/oidc/clients/:clientId', 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 body = await c.req.json();
  const updated = await updateOidcClient(c.req.param('clientId'), {
    name: body.name,
    redirectUris: body.redirectUris,
    allowedEmails: body.allowedEmails,
  });

  if (!updated) return c.json({ error: 'Client not found' }, 404);
  return c.json({ client: updated });
});

// Regenerate client secret
app.post('/api/admin/oidc/clients/:clientId/rotate-secret', 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 newSecret = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64');
  const updated = await updateOidcClient(c.req.param('clientId'), { clientSecret: newSecret });
  if (!updated) return c.json({ error: 'Client not found' }, 404);
  return c.json({ client: updated });
});

// Delete OIDC client
app.delete('/api/admin/oidc/clients/:clientId', 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 deleted = await deleteOidcClient(c.req.param('clientId'));
  if (!deleted) return c.json({ error: 'Client not found' }, 404);
  return c.json({ ok: true });
});

// Invite a user to an OIDC client app
app.post('/api/admin/oidc/clients/:clientId/invite', 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 clientId = c.req.param('clientId');
  const client = await getOidcClient(clientId);
  if (!client) return c.json({ error: 'Client not found' }, 404);

  const body = await c.req.json();
  const { email, message } = body;
  if (!email || typeof email !== 'string' || !email.includes('@')) {
    return c.json({ error: 'Valid email is required' }, 400);
  }

  const normalizedEmail = email.toLowerCase().trim();

  // Check if already on allowlist
  if (client.allowedEmails.includes(normalizedEmail)) {
    return c.json({ error: 'This email is already on the allowlist' }, 409);
  }

  const inviter = await getUserById(claims.sub);
  const id = crypto.randomUUID();
  const token = Buffer.from(crypto.getRandomValues(new Uint8Array(24))).toString('base64url');

  const invite = await createIdentityInvite({
    id,
    token,
    email: normalizedEmail,
    invitedByUserId: claims.sub,
    invitedByUsername: inviter?.username || 'admin',
    message: message || undefined,
    clientId,
    expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000,
  });

  // Send branded email
  const acceptLink = `https://auth.ridentity.online/oidc/accept?token=${encodeURIComponent(token)}`;
  if (smtpTransport) {
    try {
      await smtpTransport.sendMail({
        from: CONFIG.smtp.from,
        to: normalizedEmail,
        subject: `You've been invited to ${client.name}`,
        html: `
          <div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 520px; margin: 0 auto; padding: 2rem;">
            <h2 style="color: #1a1a2e; margin-bottom: 0.5rem;">You've been invited to ${escapeHtml(client.name)}</h2>
            <p style="color: #475569;">You've been invited to join <strong>${escapeHtml(client.name)}</strong> as a team member.</p>
            ${message ? `<blockquote style="border-left: 3px solid #7c3aed; padding: 0.5rem 1rem; margin: 1rem 0; color: #475569; background: #f8fafc; border-radius: 0 0.5rem 0.5rem 0;">"${escapeHtml(message)}"</blockquote>` : ''}
            <p style="text-align: center; margin: 2rem 0;">
              <a href="${acceptLink}" style="display: inline-block; padding: 0.85rem 2rem; background: linear-gradient(90deg, #00d4ff, #7c3aed); color: #fff; text-decoration: none; border-radius: 0.5rem; font-weight: 600; font-size: 1rem;">Accept Invitation</a>
            </p>
            <p style="color: #94a3b8; font-size: 0.85rem;">You'll create a secure passkey — no passwords needed.<br>This invite expires in 7 days.</p>
          </div>
        `,
      });
    } catch (err) {
      console.error('EncryptID: Failed to send OIDC invite email:', (err as Error).message);
    }
  } else {
    console.log(`EncryptID: [NO SMTP] OIDC invite link for ${normalizedEmail}: ${acceptLink}`);
  }

  return c.json({ id: invite.id, token: invite.token, email: invite.email });
});

// List pending invites for an OIDC client
app.get('/api/admin/oidc/clients/:clientId/invites', 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 invites = await getIdentityInvitesByClient(c.req.param('clientId'));
  return c.json({ invites });
});

// Admin OIDC management page
app.get('/admin/oidc', (c) => {
  return c.html(oidcAdminPage());
});

function oidcAdminPage(): string {
  return `<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>OIDC Clients — EncryptID Admin</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
      background: #0f0f1a;
      min-height: 100vh;
      color: #e2e8f0;
    }
    .page { max-width: 900px; margin: 0 auto; padding: 2rem 1.5rem; }
    h1 { font-size: 1.6rem; margin-bottom: 0.25rem; }
    .sub { color: #94a3b8; font-size: 0.9rem; margin-bottom: 2rem; }
    .login-prompt { text-align: center; padding: 4rem 2rem; }

    /* Client cards */
    .client-list { display: flex; flex-direction: column; gap: 1rem; }
    .client-card {
      background: rgba(255,255,255,0.04);
      border: 1px solid rgba(255,255,255,0.1);
      border-radius: 0.75rem;
      padding: 1.25rem;
    }
    .client-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem; }
    .client-name { font-weight: 600; font-size: 1.05rem; }
    .client-id { font-family: monospace; font-size: 0.8rem; color: #7c3aed; }
    .client-field { margin-bottom: 0.75rem; }
    .client-field label { display: block; font-size: 0.75rem; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.25rem; }
    .client-field .value { font-family: monospace; font-size: 0.85rem; word-break: break-all; color: #cbd5e1; }

    /* Email tags */
    .email-list { display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center; min-height: 32px; }
    .email-tag {
      display: inline-flex; align-items: center; gap: 0.3rem;
      background: rgba(124,58,237,0.15); border: 1px solid rgba(124,58,237,0.3);
      border-radius: 1rem; padding: 0.2rem 0.6rem; font-size: 0.8rem; color: #c4b5fd;
    }
    .email-tag .remove {
      cursor: pointer; font-size: 1rem; line-height: 1; color: #94a3b8;
      background: none; border: none; padding: 0;
    }
    .email-tag .remove:hover { color: #ef4444; }
    .unrestricted { color: #94a3b8; font-size: 0.8rem; font-style: italic; }

    /* Add email input */
    .add-email-row { display: flex; gap: 0.4rem; margin-top: 0.4rem; }
    .add-email-row input {
      flex: 1; padding: 0.35rem 0.6rem; border-radius: 0.4rem;
      border: 1px solid rgba(255,255,255,0.15); background: rgba(255,255,255,0.05);
      color: #fff; font-size: 0.85rem; outline: none;
    }
    .add-email-row input:focus { border-color: #7c3aed; }
    .add-email-row input::placeholder { color: #475569; }

    /* Redirect URIs */
    .uri-list { display: flex; flex-direction: column; gap: 0.25rem; }
    .uri-item { font-family: monospace; font-size: 0.8rem; color: #94a3b8; }

    /* Buttons */
    .btn {
      padding: 0.35rem 0.8rem; border-radius: 0.4rem; border: none;
      font-size: 0.8rem; font-weight: 500; cursor: pointer; transition: opacity 0.15s;
    }
    .btn:hover { opacity: 0.85; }
    .btn-sm { padding: 0.2rem 0.5rem; font-size: 0.75rem; }
    .btn-primary { background: #7c3aed; color: #fff; }
    .btn-secondary { background: rgba(255,255,255,0.1); color: #e2e8f0; }
    .btn-danger { background: rgba(239,68,68,0.2); color: #fca5a5; }
    .btn-group { display: flex; gap: 0.4rem; margin-top: 0.75rem; }

    /* New client form */
    .new-client-form {
      background: rgba(255,255,255,0.04);
      border: 1px dashed rgba(255,255,255,0.15);
      border-radius: 0.75rem;
      padding: 1.25rem;
      display: none;
    }
    .new-client-form.show { display: block; }
    .form-row { margin-bottom: 0.75rem; }
    .form-row label { display: block; font-size: 0.8rem; color: #94a3b8; margin-bottom: 0.25rem; }
    .form-row input {
      width: 100%; padding: 0.5rem 0.75rem; border-radius: 0.4rem;
      border: 1px solid rgba(255,255,255,0.15); background: rgba(255,255,255,0.05);
      color: #fff; font-size: 0.9rem; outline: none;
    }
    .form-row input:focus { border-color: #7c3aed; }

    /* Toast */
    .toast {
      position: fixed; bottom: 1.5rem; right: 1.5rem;
      padding: 0.6rem 1.2rem; border-radius: 0.5rem;
      font-size: 0.85rem; font-weight: 500; z-index: 100;
      transition: opacity 0.3s; opacity: 0;
    }
    .toast.show { opacity: 1; }
    .toast-success { background: rgba(34,197,94,0.9); color: #fff; }
    .toast-error { background: rgba(239,68,68,0.9); color: #fff; }

    .secret-reveal { cursor: pointer; }
    .secret-reveal:hover { color: #fff; }

    /* Invite list */
    .invite-list { display: flex; flex-direction: column; gap: 0.3rem; }
    .invite-item {
      display: flex; justify-content: space-between; align-items: center;
      font-size: 0.8rem; padding: 0.3rem 0.5rem;
      background: rgba(255,255,255,0.03); border-radius: 0.3rem;
    }
    .invite-email { color: #cbd5e1; }
    .invite-status { font-size: 0.7rem; padding: 0.15rem 0.4rem; border-radius: 0.25rem; }
    .invite-status.pending { background: rgba(251,191,36,0.15); color: #fbbf24; }
    .invite-status.claimed { background: rgba(34,197,94,0.15); color: #86efac; }
    .invite-status.expired { background: rgba(239,68,68,0.15); color: #fca5a5; }
  </style>
</head>
<body>
  <div class="page">
    <h1>OIDC Client Management</h1>
    <p class="sub">Manage which apps can authenticate via EncryptID and who has access</p>

    <div id="loginPrompt" class="login-prompt" style="display:none;">
      <p style="color:#94a3b8; margin-bottom:1rem;">Admin access required. Sign in with your passkey.</p>
      <button class="btn btn-primary" onclick="login()" id="loginBtn">Sign in with Passkey</button>
    </div>

    <div id="content" style="display:none;">
      <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:1.5rem;">
        <span style="color:#94a3b8; font-size:0.85rem;" id="clientCount"></span>
        <button class="btn btn-primary" onclick="toggleNewForm()">+ New Client</button>
      </div>

      <div id="newClientForm" class="new-client-form">
        <div class="form-row">
          <label>Client ID (slug, e.g. "my-app")</label>
          <input type="text" id="newClientId" placeholder="my-app" />
        </div>
        <div class="form-row">
          <label>Display Name</label>
          <input type="text" id="newName" placeholder="My Application" />
        </div>
        <div class="form-row">
          <label>Redirect URI</label>
          <input type="text" id="newRedirectUri" placeholder="https://myapp.example.com/callback" />
        </div>
        <div class="btn-group">
          <button class="btn btn-primary" onclick="createClient()">Create</button>
          <button class="btn btn-secondary" onclick="toggleNewForm()">Cancel</button>
        </div>
      </div>

      <div id="clientList" class="client-list"></div>
    </div>
  </div>

  <div id="toast" class="toast"></div>

  <script>
    const API = '';
    let token = localStorage.getItem('eid_admin_token');
    let clients = [];

    function toast(msg, type = 'success') {
      const el = document.getElementById('toast');
      el.textContent = msg;
      el.className = 'toast toast-' + type + ' show';
      setTimeout(() => el.className = 'toast', 2500);
    }

    async function api(path, opts = {}) {
      const res = await fetch(API + path, {
        ...opts,
        headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json', ...opts.headers },
      });
      const data = await res.json();
      if (!res.ok) {
        if (res.status === 401 || res.status === 403) {
          token = null;
          localStorage.removeItem('eid_admin_token');
          showLogin();
        }
        throw new Error(data.error || 'Request failed');
      }
      return data;
    }

    function showLogin() {
      document.getElementById('loginPrompt').style.display = 'block';
      document.getElementById('content').style.display = 'none';
    }

    function showContent() {
      document.getElementById('loginPrompt').style.display = 'none';
      document.getElementById('content').style.display = 'block';
    }

    async function login() {
      const btn = document.getElementById('loginBtn');
      btn.disabled = true;
      try {
        const startRes = await fetch('/api/auth/start', {
          method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}',
        });
        const { options } = await startRes.json();
        const challengeBytes = Uint8Array.from(atob(options.challenge.replace(/-/g,'+').replace(/_/g,'/')), c => c.charCodeAt(0));
        const pubKeyOpts = { challenge: challengeBytes, rpId: options.rpId, userVerification: options.userVerification, timeout: options.timeout };
        const assertion = await navigator.credentials.get({ publicKey: pubKeyOpts });
        const credentialId = btoa(String.fromCharCode(...new Uint8Array(assertion.rawId))).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=/g,'');
        const completeRes = await fetch('/api/auth/complete', {
          method: 'POST', headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            challenge: options.challenge,
            credential: {
              credentialId,
              authenticatorData: btoa(String.fromCharCode(...new Uint8Array(assertion.response.authenticatorData))).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=/g,''),
              clientDataJSON: btoa(String.fromCharCode(...new Uint8Array(assertion.response.clientDataJSON))).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=/g,''),
              signature: btoa(String.fromCharCode(...new Uint8Array(assertion.response.signature))).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=/g,''),
            },
          }),
        });
        const result = await completeRes.json();
        if (!result.success) throw new Error(result.error || 'Auth failed');
        token = result.token;
        localStorage.setItem('eid_admin_token', token);
        await loadClients();
      } catch (err) {
        toast(err.message, 'error');
      } finally {
        btn.disabled = false;
      }
    }

    async function loadClients() {
      try {
        const data = await api('/api/admin/oidc/clients');
        clients = data.clients;
        renderClients();
        showContent();
      } catch (err) {
        if (!token) return showLogin();
        toast(err.message, 'error');
      }
    }

    function renderClients() {
      document.getElementById('clientCount').textContent = clients.length + ' client' + (clients.length !== 1 ? 's' : '');
      const list = document.getElementById('clientList');
      list.innerHTML = clients.map(cl => \`
        <div class="client-card" id="card-\${cl.clientId}">
          <div class="client-header">
            <div>
              <div class="client-name">\${esc(cl.name)}</div>
              <div class="client-id">\${esc(cl.clientId)}</div>
            </div>
          </div>
          <div class="client-field">
            <label>Client Secret</label>
            <div class="value secret-reveal" onclick="revealSecret('\${esc(cl.clientId)}', this)" title="Click to reveal">\${esc(cl.clientSecret)}</div>
          </div>
          <div class="client-field">
            <label>Redirect URIs</label>
            <div class="uri-list">\${cl.redirectUris.map(u => '<div class="uri-item">' + esc(u) + '</div>').join('')}</div>
          </div>
          <div class="client-field">
            <label>Allowed Emails</label>
            <div class="email-list" id="emails-\${cl.clientId}">
              \${cl.allowedEmails.length === 0
                ? '<span class="unrestricted">Unrestricted (all emails allowed)</span>'
                : cl.allowedEmails.map(e => \`<span class="email-tag">\${esc(e)} <button class="remove" onclick="removeEmail('\${esc(cl.clientId)}', '\${esc(e)}')">&times;</button></span>\`).join('')}
            </div>
            <div class="add-email-row">
              <input type="email" placeholder="Add email..." id="email-input-\${cl.clientId}" onkeydown="if(event.key==='Enter')addEmail('\${esc(cl.clientId)}')" />
              <button class="btn btn-sm btn-primary" onclick="addEmail('\${esc(cl.clientId)}')">Add</button>
            </div>
          </div>
          <div class="client-field">
            <label>Invite User</label>
            <div class="add-email-row">
              <input type="email" placeholder="Email to invite..." id="invite-input-\${cl.clientId}" onkeydown="if(event.key==='Enter')sendInvite('\${esc(cl.clientId)}')" />
              <input type="text" placeholder="Message (optional)" id="invite-msg-\${cl.clientId}" style="flex:0.7;" />
              <button class="btn btn-sm btn-primary" onclick="sendInvite('\${esc(cl.clientId)}')">Invite</button>
            </div>
            <div id="invites-\${cl.clientId}" class="invite-list" style="margin-top:0.5rem;"></div>
          </div>
          <div class="btn-group">
            <button class="btn btn-sm btn-secondary" onclick="rotateSecret('\${esc(cl.clientId)}')">Rotate Secret</button>
            <button class="btn btn-sm btn-secondary" onclick="loadInvites('\${esc(cl.clientId)}')">Show Invites</button>
            <button class="btn btn-sm btn-danger" onclick="deleteClient('\${esc(cl.clientId)}')">Delete</button>
          </div>
        </div>
      \`).join('');
    }

    function esc(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }

    async function revealSecret(clientId, el) {
      try {
        const data = await api('/api/admin/oidc/clients/' + encodeURIComponent(clientId));
        el.textContent = data.client.clientSecret;
        el.style.color = '#fbbf24';
        el.onclick = null;
      } catch (err) { toast(err.message, 'error'); }
    }

    async function addEmail(clientId) {
      const input = document.getElementById('email-input-' + clientId);
      const email = input.value.trim().toLowerCase();
      if (!email || !email.includes('@')) return;
      const cl = clients.find(c => c.clientId === clientId);
      const emails = [...(cl.allowedEmails || [])];
      if (emails.includes(email)) return;
      emails.push(email);
      try {
        await api('/api/admin/oidc/clients/' + encodeURIComponent(clientId), {
          method: 'PUT', body: JSON.stringify({ allowedEmails: emails }),
        });
        cl.allowedEmails = emails;
        input.value = '';
        renderClients();
        toast('Added ' + email);
      } catch (err) { toast(err.message, 'error'); }
    }

    async function removeEmail(clientId, email) {
      const cl = clients.find(c => c.clientId === clientId);
      const emails = cl.allowedEmails.filter(e => e !== email);
      try {
        await api('/api/admin/oidc/clients/' + encodeURIComponent(clientId), {
          method: 'PUT', body: JSON.stringify({ allowedEmails: emails }),
        });
        cl.allowedEmails = emails;
        renderClients();
        toast('Removed ' + email);
      } catch (err) { toast(err.message, 'error'); }
    }

    async function rotateSecret(clientId) {
      if (!confirm('Rotate secret for ' + clientId + '? The app will need updating.')) return;
      try {
        const data = await api('/api/admin/oidc/clients/' + encodeURIComponent(clientId) + '/rotate-secret', { method: 'POST' });
        toast('Secret rotated. New: ' + data.client.clientSecret.slice(0, 8) + '...');
        await loadClients();
      } catch (err) { toast(err.message, 'error'); }
    }

    async function deleteClient(clientId) {
      if (!confirm('Delete OIDC client ' + clientId + '? This cannot be undone.')) return;
      try {
        await api('/api/admin/oidc/clients/' + encodeURIComponent(clientId), { method: 'DELETE' });
        toast('Deleted ' + clientId);
        await loadClients();
      } catch (err) { toast(err.message, 'error'); }
    }

    async function sendInvite(clientId) {
      const emailInput = document.getElementById('invite-input-' + clientId);
      const msgInput = document.getElementById('invite-msg-' + clientId);
      const email = emailInput.value.trim().toLowerCase();
      if (!email || !email.includes('@')) { toast('Enter a valid email', 'error'); return; }
      try {
        await api('/api/admin/oidc/clients/' + encodeURIComponent(clientId) + '/invite', {
          method: 'POST',
          body: JSON.stringify({ email, message: msgInput.value.trim() || undefined }),
        });
        emailInput.value = '';
        msgInput.value = '';
        toast('Invite sent to ' + email);
        loadInvites(clientId);
      } catch (err) { toast(err.message, 'error'); }
    }

    async function loadInvites(clientId) {
      try {
        const data = await api('/api/admin/oidc/clients/' + encodeURIComponent(clientId) + '/invites');
        const container = document.getElementById('invites-' + clientId);
        if (!data.invites.length) {
          container.innerHTML = '<span style="color:#64748b;font-size:0.8rem;">No invites yet</span>';
          return;
        }
        container.innerHTML = data.invites.map(inv =>
          '<div class="invite-item">' +
            '<span class="invite-email">' + esc(inv.email) + '</span>' +
            '<span class="invite-status ' + inv.status + '">' + inv.status + '</span>' +
          '</div>'
        ).join('');
      } catch (err) { toast(err.message, 'error'); }
    }

    function toggleNewForm() {
      document.getElementById('newClientForm').classList.toggle('show');
    }

    async function createClient() {
      const clientId = document.getElementById('newClientId').value.trim();
      const name = document.getElementById('newName').value.trim();
      const redirectUri = document.getElementById('newRedirectUri').value.trim();
      if (!clientId || !name || !redirectUri) return toast('All fields required', 'error');
      try {
        const data = await api('/api/admin/oidc/clients', {
          method: 'POST',
          body: JSON.stringify({ clientId, name, redirectUris: [redirectUri] }),
        });
        toast('Created ' + clientId + '. Secret: ' + data.client.clientSecret.slice(0, 12) + '...');
        document.getElementById('newClientId').value = '';
        document.getElementById('newName').value = '';
        document.getElementById('newRedirectUri').value = '';
        toggleNewForm();
        await loadClients();
      } catch (err) { toast(err.message, 'error'); }
    }

    // Init
    if (token) { loadClients(); } else { showLogin(); }
  </script>
</body>
</html>`;
}

// ============================================================================
// FUND CLAIM ROUTES
// ============================================================================

const INTERNAL_SERVICE_KEY = process.env.INTERNAL_SERVICE_KEY || '';

// POST /api/internal/fund-claims — called by rflows to create a claim
app.post('/api/internal/fund-claims', async (c) => {
  const serviceKey = c.req.header('X-Service-Key');
  if (!INTERNAL_SERVICE_KEY || serviceKey !== INTERNAL_SERVICE_KEY) {
    return c.json({ error: 'Unauthorized' }, 401);
  }

  const { email, walletAddress, openfortPlayerId, fiatAmount, fiatCurrency, sessionId, provider } = await c.req.json();
  if (!email || !walletAddress) {
    return c.json({ error: 'email and walletAddress are required' }, 400);
  }

  const emailHashed = await hashForLookup(email);
  const expiresAt = Date.now() + 7 * 24 * 60 * 60 * 1000; // 7 days

  // Check for existing pending claim — accumulate deposits
  const existingClaims = await getFundClaimsByEmailHash(emailHashed);
  if (existingClaims.length > 0 && fiatAmount) {
    const existing = existingClaims[0];
    const updated = await accumulateFundClaim(existing.id, fiatAmount, expiresAt);

    let sent = false;
    try {
      const totalAmount = updated?.fiatAmount || fiatAmount;
      sent = await sendClaimEmail(email, existing.token, totalAmount, fiatCurrency || 'USD');
    } catch (err) {
      console.error('EncryptID: Failed to send claim email:', err);
    }

    return c.json({ claimId: existing.id, accumulated: true, sent });
  }

  // No existing claim — create new
  const id = crypto.randomUUID();
  const token = generateToken();

  const claim = await createFundClaim({
    id,
    token,
    emailHash: emailHashed,
    email,
    walletAddress,
    openfortPlayerId,
    fiatAmount,
    fiatCurrency: fiatCurrency || 'USD',
    sessionId,
    provider,
    expiresAt,
  });

  let sent = false;
  try {
    sent = await sendClaimEmail(email, token, fiatAmount, fiatCurrency || 'USD');
  } catch (err) {
    console.error('EncryptID: Failed to send claim email:', err);
  }

  return c.json({ claimId: claim.id, sent });
});

// GET /api/claims/:token — public claim info (no auth)
app.get('/api/claims/:token', async (c) => {
  const token = c.req.param('token');
  const claim = await getFundClaimByToken(token);
  if (!claim) return c.json({ error: 'Claim not found' }, 404);

  const truncatedWallet = `${claim.walletAddress.slice(0, 6)}...${claim.walletAddress.slice(-4)}`;
  return c.json({
    status: claim.status,
    amount: claim.fiatAmount,
    currency: claim.fiatCurrency,
    truncatedWallet,
    expiresAt: claim.expiresAt,
    expired: claim.expiresAt < Date.now(),
  });
});

// POST /api/claims/:token/accept — authenticated claim acceptance
app.post('/api/claims/:token/accept', async (c) => {
  const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
  if (!claims) return c.json({ error: 'Authentication required' }, 401);

  const token = c.req.param('token');
  const fundClaim = await getFundClaimByToken(token);
  if (!fundClaim) return c.json({ error: 'Claim not found' }, 404);
  if (fundClaim.status !== 'pending' && fundClaim.status !== 'resent') return c.json({ error: 'Claim already processed' }, 400);
  if (fundClaim.expiresAt < Date.now()) return c.json({ error: 'Claim expired' }, 400);

  // Link wallet to user profile
  await updateUserProfile(claims.sub, { walletAddress: fundClaim.walletAddress });

  // Mark claim as claimed
  const accepted = await acceptFundClaim(token, claims.sub);
  if (!accepted) return c.json({ error: 'Failed to accept claim' }, 500);

  // Get username for redirect URL
  const user = await getUserById(claims.sub);

  return c.json({
    success: true,
    walletAddress: fundClaim.walletAddress,
    amount: fundClaim.fiatAmount,
    currency: fundClaim.fiatCurrency,
    username: user?.username || '',
  });
});

// POST /api/claims/resend — resend claim email (anti-enumeration: always 200)
app.post('/api/claims/resend', async (c) => {
  const { email } = await c.req.json();
  if (!email) return c.json({ ok: true });

  const emailHashed = await hashForLookup(email);
  const pendingClaims = await getFundClaimsByEmailHash(emailHashed);

  if (pendingClaims.length > 0) {
    const claim = pendingClaims[0];
    // Generate new token for the resend
    const newToken = generateToken();
    await sql`UPDATE fund_claims SET token = ${newToken}, status = 'resent' WHERE id = ${claim.id}`;
    try {
      await sendClaimEmail(email, newToken, claim.fiatAmount || undefined, claim.fiatCurrency);
    } catch (err) {
      console.error('EncryptID: Failed to resend claim email:', err);
    }
  }

  // Always return ok (anti-enumeration)
  return c.json({ ok: true });
});

// GET /claim — self-contained claim page
app.get('/claim', (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 — Claim Your Funds</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;
      padding: 20px;
    }
    .card {
      background: #16213e;
      border: 1px solid rgba(255,255,255,0.1);
      border-radius: 16px;
      padding: 40px;
      max-width: 440px;
      width: 100%;
      text-align: center;
    }
    .icon { font-size: 48px; margin-bottom: 16px; }
    h1 {
      font-size: 24px;
      margin-bottom: 8px;
      background: linear-gradient(90deg, #00d4ff, #7c3aed);
      -webkit-background-clip: text;
      -webkit-text-fill-color: transparent;
    }
    .subtitle { color: #94a3b8; font-size: 14px; margin-bottom: 24px; }
    .info-row {
      display: flex;
      justify-content: space-between;
      padding: 12px 16px;
      background: rgba(255,255,255,0.05);
      border-radius: 8px;
      margin-bottom: 8px;
      color: #e2e8f0;
      font-size: 14px;
    }
    .info-label { color: #94a3b8; }
    .info-value { font-weight: 600; font-family: monospace; }
    .btn {
      display: inline-block;
      padding: 14px 32px;
      background: linear-gradient(90deg, #00d4ff, #7c3aed);
      color: #fff;
      border: none;
      border-radius: 8px;
      font-weight: 600;
      font-size: 15px;
      cursor: pointer;
      text-decoration: none;
      margin-top: 24px;
      width: 100%;
    }
    .btn:hover { opacity: 0.9; }
    .btn:disabled { opacity: 0.5; cursor: not-allowed; }
    .btn-secondary {
      background: rgba(255,255,255,0.1);
      margin-top: 12px;
    }
    .error { color: #f87171; font-size: 14px; margin-top: 16px; }
    .success { color: #4ade80; }
    .success-card { background: rgba(74, 222, 128, 0.1); border: 1px solid rgba(74, 222, 128, 0.3); border-radius: 12px; padding: 24px; margin-top: 20px; }
    #status { margin-top: 16px; }
    .spinner { display: inline-block; width: 20px; height: 20px; border: 2px solid rgba(255,255,255,0.3); border-top-color: #fff; border-radius: 50%; animation: spin 0.6s linear infinite; }
    @keyframes spin { to { transform: rotate(360deg); } }
    .hidden { display: none; }
  </style>
</head>
<body>
  <div class="card">
    <div class="icon">&#128176;</div>
    <h1>Claim Your Funds</h1>
    <div class="subtitle">Sign in with EncryptID to link your funded wallet</div>
    <div id="loading"><div class="spinner"></div><p style="color:#94a3b8;margin-top:12px">Loading claim...</p></div>
    <div id="claim-info" class="hidden"></div>
    <div id="status"></div>
  </div>
  <script>
    const params = new URLSearchParams(location.search);
    const token = params.get('token');
    const authBase = location.origin;

    function getSession() {
      // Check both storage keys (landing page uses encryptid_token, SDK uses encryptid_session)
      const token = localStorage.getItem('encryptid_token');
      if (token) return { token };
      try { return JSON.parse(localStorage.getItem('encryptid_session') || 'null'); } catch { return null; }
    }

    async function init() {
      if (!token) {
        document.getElementById('loading').classList.add('hidden');
        document.getElementById('status').innerHTML = '<p class="error">No claim token provided.</p>';
        return;
      }

      try {
        const res = await fetch(authBase + '/api/claims/' + encodeURIComponent(token));
        const data = await res.json();
        document.getElementById('loading').classList.add('hidden');

        if (!res.ok) {
          document.getElementById('status').innerHTML = '<p class="error">' + (data.error || 'Claim not found') + '</p>';
          return;
        }

        if (data.status === 'claimed') {
          document.getElementById('claim-info').classList.remove('hidden');
          document.getElementById('claim-info').innerHTML =
            '<div class="success-card"><p class="success" style="font-size:18px;font-weight:600">&#x2705; Already Claimed</p>' +
            '<p style="color:#94a3b8;margin-top:8px">These funds have already been linked to an account.</p></div>';
          return;
        }

        if (data.expired || data.status === 'expired') {
          document.getElementById('claim-info').classList.remove('hidden');
          document.getElementById('claim-info').innerHTML =
            '<p class="error">This claim has expired.</p>';
          return;
        }

        const infoHtml =
          (data.amount ? '<div class="info-row"><span class="info-label">Amount</span><span class="info-value">$' + data.amount + ' ' + (data.currency || 'USD') + '</span></div>' : '') +
          '<div class="info-row"><span class="info-label">Wallet</span><span class="info-value">' + data.truncatedWallet + '</span></div>' +
          '<div class="info-row"><span class="info-label">Expires</span><span class="info-value">' + new Date(data.expiresAt).toLocaleDateString() + '</span></div>';

        const session = getSession();
        let actionHtml = '';
        if (session && session.token) {
          actionHtml = '<button class="btn" id="claim-btn">Claim Your Funds</button>';
        } else {
          const redirect = encodeURIComponent('/claim?token=' + encodeURIComponent(token));
          actionHtml =
            '<a class="btn" href="/?redirect=' + redirect + '">Sign In to Claim</a>' +
            '<a class="btn btn-secondary" href="/?tab=register&redirect=' + redirect + '">Create Account</a>' +
            '<p style="color:#64748b;font-size:12px;margin-top:16px">You will be redirected back here after signing in.</p>';
        }

        document.getElementById('claim-info').classList.remove('hidden');
        document.getElementById('claim-info').innerHTML = infoHtml + actionHtml;

        const claimBtn = document.getElementById('claim-btn');
        if (claimBtn) {
          claimBtn.addEventListener('click', async () => {
            claimBtn.disabled = true;
            claimBtn.textContent = 'Claiming...';
            try {
              const acceptRes = await fetch(authBase + '/api/claims/' + encodeURIComponent(token) + '/accept', {
                method: 'POST',
                headers: { 'Authorization': 'Bearer ' + session.token },
              });
              const result = await acceptRes.json();
              if (acceptRes.ok && result.success) {
                var walletUrl = 'https://' + (result.username || 'demo') + '.rspace.online/rwallet?address=' + encodeURIComponent(result.walletAddress);
                document.getElementById('claim-info').innerHTML =
                  '<div class="success-card">' +
                  '<p class="success" style="font-size:18px;font-weight:600">&#x2705; Funds Claimed!</p>' +
                  '<p style="color:#e2e8f0;margin-top:12px">Wallet <code style="background:rgba(255,255,255,0.1);padding:2px 6px;border-radius:4px">' + result.walletAddress.slice(0, 6) + '...' + result.walletAddress.slice(-4) + '</code> has been linked to your EncryptID account.</p>' +
                  (result.amount ? '<p style="color:#94a3b8;margin-top:8px">$' + result.amount + ' ' + (result.currency || 'USD') + ' are now accessible.</p>' : '') +
                  '<a class="btn" href="' + walletUrl + '" style="margin-top:20px">View in rWallet</a>' +
                  '</div>';
              } else {
                document.getElementById('status').innerHTML = '<p class="error">' + (result.error || 'Claim failed') + '</p>';
                claimBtn.disabled = false;
                claimBtn.textContent = 'Claim Your Funds';
              }
            } catch (err) {
              document.getElementById('status').innerHTML = '<p class="error">Network error. Please try again.</p>';
              claimBtn.disabled = false;
              claimBtn.textContent = 'Claim Your Funds';
            }
          });
        }
      } catch (err) {
        document.getElementById('loading').classList.add('hidden');
        document.getElementById('status').innerHTML = '<p class="error">Failed to load claim info.</p>';
      }
    }

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

// ============================================================================
// IDENTITY INVITES ("Claim your rSpace" flow)
// ============================================================================

// Send an invite (authenticated user invites a friend by email)
app.post('/api/invites/identity', async (c) => {
  const authHeader = c.req.header('Authorization');
  if (!authHeader?.startsWith('Bearer ')) {
    return c.json({ error: 'Unauthorized' }, 401);
  }
  let payload: any;
  try {
    payload = await verify(authHeader.slice(7), CONFIG.jwtSecret, 'HS256');
  } catch {
    return c.json({ error: 'Invalid token' }, 401);
  }

  const body = await c.req.json();
  const { email, message, spaceSlug, spaceRole } = body;

  if (!email || typeof email !== 'string' || !email.includes('@')) {
    return c.json({ error: 'Valid email is required' }, 400);
  }

  // Check if this email already has an account
  const existingUser = await getUserByEmail(email.toLowerCase().trim());
  if (existingUser) {
    return c.json({ error: 'This person already has an EncryptID account' }, 409);
  }

  const id = crypto.randomUUID();
  const token = Buffer.from(crypto.getRandomValues(new Uint8Array(24))).toString('base64url');

  const invite = await createIdentityInvite({
    id,
    token,
    email: email.toLowerCase().trim(),
    invitedByUserId: payload.sub,
    invitedByUsername: payload.username,
    message: message || undefined,
    spaceSlug: spaceSlug || undefined,
    spaceRole: spaceRole || 'member',
    expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000, // 7 days
  });

  // Send invite email
  const joinLink = `https://auth.rspace.online/join?token=${encodeURIComponent(token)}`;
  if (smtpTransport) {
    try {
      const spaceInfo = spaceSlug
        ? `<p style="color:#475569;line-height:1.6;">You'll automatically join the <strong>${escapeHtml(spaceSlug)}</strong> space as a <strong>${escapeHtml(spaceRole || 'member')}</strong>, with access to collaborative notes, maps, voting, calendar, and more.</p>`
        : `<p style="color:#475569;line-height:1.6;">rSpace is a suite of privacy-first collaborative tools — notes, maps, voting, calendar, wallet, and more — powered by passkey authentication (no passwords).</p>`;
      const subjectLine = spaceSlug
        ? `${payload.username} invited you to join "${spaceSlug}" on rSpace`
        : `${payload.username} invited you to join rSpace`;
      const agentAddr = spaceSlug ? `${spaceSlug}-agent@rspace.online` : null;
      await smtpTransport.sendMail({
        from: agentAddr ? `${spaceSlug} <${agentAddr}>` : CONFIG.smtp.from,
        ...(agentAddr ? { replyTo: agentAddr } : {}),
        ...(agentAddr ? { envelope: { from: CONFIG.smtp.user, to: email } } : {}),
        to: email,
        subject: subjectLine,
        html: `
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:520px;margin:0 auto;padding:2rem;">
  <h2 style="color:#1a1a2e;margin-bottom:0.5rem;">${spaceSlug ? `You've been invited to ${escapeHtml(spaceSlug)}` : `You've been invited to rSpace`}</h2>
  <p style="color:#475569;line-height:1.6;"><strong>${escapeHtml(payload.username)}</strong> wants you to join${spaceSlug ? ` the <strong>${escapeHtml(spaceSlug)}</strong> space on` : ''} rSpace.</p>
  ${message ? `<blockquote style="border-left:3px solid #7c3aed;padding:0.5rem 1rem;margin:1rem 0;color:#475569;background:#f8fafc;border-radius:0 0.5rem 0.5rem 0;">"${escapeHtml(message)}"</blockquote>` : ''}
  ${spaceInfo}
  <p style="color:#475569;line-height:1.6;">Click below to create your account and set up your passkey:</p>
  <p style="text-align:center;margin:2rem 0;">
    <a href="${joinLink}" style="display:inline-block;padding:0.85rem 2rem;background:linear-gradient(135deg,#14b8a6,#0d9488);color:#fff;text-decoration:none;border-radius:0.5rem;font-weight:600;font-size:1rem;">Accept Invitation</a>
  </p>
  <p style="color:#94a3b8;font-size:0.85rem;">This invite expires in 7 days. No passwords needed — you'll use a passkey to sign in securely.</p>
</div>`,
      });
    } catch (err) {
      console.error('EncryptID: Failed to send invite email:', (err as Error).message);
    }
  } else {
    console.log(`EncryptID: [NO SMTP] Invite link for ${email}: ${joinLink}`);
  }

  return c.json({ id: invite.id, token: invite.token, email: invite.email });
});

// List invites sent by the authenticated user
app.get('/api/invites/identity', async (c) => {
  const authHeader = c.req.header('Authorization');
  if (!authHeader?.startsWith('Bearer ')) {
    return c.json({ error: 'Unauthorized' }, 401);
  }
  let payload: any;
  try {
    payload = await verify(authHeader.slice(7), CONFIG.jwtSecret, 'HS256');
  } catch {
    return c.json({ error: 'Invalid token' }, 401);
  }

  const invites = await getIdentityInvitesByInviter(payload.sub);
  return c.json({ invites });
});

// Revoke a pending invite
app.delete('/api/invites/identity/:id', async (c) => {
  const authHeader = c.req.header('Authorization');
  if (!authHeader?.startsWith('Bearer ')) {
    return c.json({ error: 'Unauthorized' }, 401);
  }
  let payload: any;
  try {
    payload = await verify(authHeader.slice(7), CONFIG.jwtSecret, 'HS256');
  } catch {
    return c.json({ error: 'Invalid token' }, 401);
  }

  const revoked = await revokeIdentityInvite(c.req.param('id'), payload.sub);
  return revoked ? c.json({ success: true }) : c.json({ error: 'Invite not found or already claimed' }, 404);
});

// Get invite info (public — for the claim page)
app.get('/api/invites/identity/:token/info', async (c) => {
  const invite = await getIdentityInviteByToken(c.req.param('token'));
  if (!invite || invite.status !== 'pending') {
    return c.json({ error: 'Invite not found or expired' }, 404);
  }
  if (Date.now() > invite.expiresAt) {
    return c.json({ error: 'Invite expired' }, 410);
  }
  // Look up OIDC client info if this is a client invite
  let clientName: string | null = null;
  let clientAppUrl: string | null = null;
  if (invite.clientId) {
    const client = await getOidcClient(invite.clientId);
    clientName = client?.name || null;
    // Derive app URL from first redirect URI (strip the callback path)
    if (client?.redirectUris?.[0]) {
      try { clientAppUrl = new URL(client.redirectUris[0]).origin; } catch {}
    }
  }
  return c.json({
    invitedBy: invite.invitedByUsername,
    message: invite.message,
    email: invite.email,
    spaceSlug: invite.spaceSlug,
    clientId: invite.clientId,
    clientName,
    clientAppUrl,
  });
});

// Claim an invite (after passkey registration)
app.post('/api/invites/identity/:token/claim', async (c) => {
  const token = c.req.param('token');
  const body = await c.req.json();
  const { sessionToken } = body;

  // Verify the new user's session token (from registration)
  let payload: any;
  try {
    payload = await verify(sessionToken, CONFIG.jwtSecret, 'HS256');
  } catch {
    return c.json({ error: 'Invalid session' }, 401);
  }

  const invite = await getIdentityInviteByToken(token);
  if (!invite || invite.status !== 'pending') {
    return c.json({ error: 'Invite not found or already claimed' }, 404);
  }
  if (Date.now() > invite.expiresAt) {
    return c.json({ error: 'Invite expired' }, 410);
  }

  // Link the email to the new user's account
  await setUserEmail(payload.sub, invite.email);

  // Claim the invite
  const claimed = await claimIdentityInvite(token, payload.sub);
  if (!claimed) {
    return c.json({ error: 'Failed to claim invite' }, 500);
  }

  // Auto-join space if specified
  if (invite.spaceSlug) {
    const inviteeUser = await getUserById(payload.sub);
    const did = inviteeUser?.did || `did:key:${payload.sub.slice(0, 32)}`;
    const inviterUser = await getUserById(invite.invitedByUserId);
    const inviterDid = inviterUser?.did || `did:key:${invite.invitedByUserId.slice(0, 32)}`;
    await upsertSpaceMember(invite.spaceSlug, did, invite.spaceRole, inviterDid);
    // Sync to rSpace's Automerge doc so the member has immediate access
    const rspaceUrl = process.env.RSPACE_INTERNAL_URL || 'http://rspace-online:3000';
    fetch(`${rspaceUrl}/api/internal/sync-space-member`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        spaceSlug: invite.spaceSlug,
        userDid: did,
        role: invite.spaceRole,
        username: inviteeUser?.username,
      }),
    }).catch((err) => console.error('EncryptID: Failed to sync space member to Automerge:', err.message));
  }

  // Auto-add email to OIDC client allowlist if this is a client invite
  if (invite.clientId) {
    const client = await getOidcClient(invite.clientId);
    if (client && !client.allowedEmails.includes(invite.email)) {
      await updateOidcClient(invite.clientId, {
        allowedEmails: [...client.allowedEmails, invite.email],
      });
    }
  }

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

// Join page — the invite claim UI
app.get('/join', async (c) => {
  const token = c.req.query('token');
  return c.html(joinPage(token || ''));
});

function joinPage(token: string): string {
  return `<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Claim your rSpace — EncryptID</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.06);
      border: 1px solid rgba(255,255,255,0.12);
      border-radius: 1rem;
      padding: 2.5rem;
      max-width: 440px;
      width: 90%;
      text-align: center;
    }
    .logo { font-size: 2.5rem; margin-bottom: 0.5rem; }
    h1 { font-size: 1.4rem; margin-bottom: 0.25rem; }
    .sub { color: #94a3b8; font-size: 0.9rem; margin-bottom: 1.5rem; }
    .inviter { color: #7c3aed; font-weight: 600; }
    .message-box {
      border-left: 3px solid #7c3aed;
      padding: 0.6rem 1rem;
      margin: 1rem 0 1.5rem;
      text-align: left;
      color: #cbd5e1;
      background: rgba(124,58,237,0.08);
      border-radius: 0 0.5rem 0.5rem 0;
      font-size: 0.9rem;
      font-style: italic;
      display: none;
    }
    .form-group { margin-bottom: 1rem; text-align: left; }
    .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;
    }
    .form-group input:focus { border-color: #7c3aed; }
    .form-group input::placeholder { color: #475569; }
    .form-group input:read-only { opacity: 0.6; cursor: not-allowed; }
    .btn-primary {
      width: 100%; padding: 0.85rem; 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;
      margin-top: 0.5rem;
    }
    .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; display: none;
    }
    .status { color: #94a3b8; font-size: 0.85rem; margin-top: 1rem; display: none; }
    .step-indicator {
      display: flex; justify-content: center; gap: 0.5rem; margin-bottom: 1.5rem;
    }
    .step {
      width: 8px; height: 8px; border-radius: 50%;
      background: rgba(255,255,255,0.15); transition: background 0.3s;
    }
    .step.active { background: #7c3aed; }
    .step.done { background: #22c55e; }
    .features {
      margin-top: 1.5rem; text-align: left; font-size: 0.8rem; color: #94a3b8;
    }
    .features li { margin: 0.4rem 0; list-style: none; }
    .features li::before { content: '✦ '; color: #00d4ff; }
  </style>
</head>
<body>
  <div class="card">
    <div class="logo">🔐</div>
    <h1>Claim your rSpace</h1>
    <p id="subtitle" class="sub">Create your passkey-protected identity</p>

    <div class="step-indicator">
      <div id="step1" class="step active"></div>
      <div id="step2" class="step"></div>
      <div id="step3" class="step"></div>
    </div>

    <div id="messageBox" class="message-box"></div>
    <div id="error" class="error"></div>
    <div id="success" class="success"></div>

    <div id="registerForm">
      <div class="form-group">
        <label>Username</label>
        <input type="text" id="username" placeholder="Choose a username" autocomplete="username" />
      </div>
      <div class="form-group">
        <label>Email</label>
        <input type="email" id="email" placeholder="your@email.com" readonly />
      </div>
      <button id="registerBtn" class="btn-primary" onclick="register()" disabled>
        Create Passkey &amp; Claim
      </button>
    </div>

    <p id="status" class="status"></p>

    <ul class="features">
      <li>No passwords — your device is the key</li>
      <li>Works across the entire r* ecosystem</li>
      <li>Privacy-first, encrypted by default</li>
    </ul>
  </div>

  <script>
    const TOKEN = ${JSON.stringify(token)};
    let inviteData = null;

    function detectDeviceName() {
      const ua = navigator.userAgent;
      let browser = 'Unknown browser', os = 'Unknown OS';
      if (/CriOS/i.test(ua)) browser = 'Chrome';
      else if (/FxiOS/i.test(ua)) browser = 'Firefox';
      else if (/Safari/i.test(ua) && !/Chrome/i.test(ua)) browser = 'Safari';
      else if (/Edg/i.test(ua)) browser = 'Edge';
      else if (/Firefox/i.test(ua)) browser = 'Firefox';
      else if (/Chrome/i.test(ua)) browser = 'Chrome';
      if (/iPhone/i.test(ua)) os = 'iPhone';
      else if (/iPad/i.test(ua)) os = 'iPad';
      else if (/Android/i.test(ua)) os = 'Android';
      else if (/Mac OS/i.test(ua)) os = 'macOS';
      else if (/Windows/i.test(ua)) os = 'Windows';
      else if (/Linux/i.test(ua)) os = 'Linux';
      else if (/CrOS/i.test(ua)) os = 'ChromeOS';
      return browser + ' on ' + os;
    }

    const errorEl = document.getElementById('error');
    const successEl = document.getElementById('success');
    const statusEl = document.getElementById('status');
    const registerBtn = document.getElementById('registerBtn');
    const usernameInput = document.getElementById('username');
    const emailInput = document.getElementById('email');
    const messageBox = document.getElementById('messageBox');

    function showError(msg) {
      errorEl.textContent = msg;
      errorEl.style.display = 'block';
      successEl.style.display = 'none';
      statusEl.style.display = 'none';
    }
    function showStatus(msg) {
      statusEl.textContent = msg;
      statusEl.style.display = 'block';
      errorEl.style.display = 'none';
    }
    function setStep(n) {
      for (let i = 1; i <= 3; i++) {
        const el = document.getElementById('step' + i);
        el.className = 'step' + (i < n ? ' done' : i === n ? ' active' : '');
      }
    }

    // Load invite info
    (async () => {
      if (!TOKEN) {
        showError('No invite token provided. You need an invitation link to join.');
        return;
      }
      try {
        const res = await fetch('/api/invites/identity/' + encodeURIComponent(TOKEN) + '/info');
        if (!res.ok) {
          const data = await res.json();
          showError(data.error || 'This invite is no longer valid.');
          return;
        }
        inviteData = await res.json();
        document.getElementById('subtitle').innerHTML =
          '<span class="inviter">' + inviteData.invitedBy + '</span> invited you to join rSpace';
        emailInput.value = inviteData.email;
        if (inviteData.message) {
          messageBox.textContent = '"' + inviteData.message + '"';
          messageBox.style.display = 'block';
        }
        registerBtn.disabled = false;
      } catch (err) {
        showError('Failed to load invite. Please try again.');
      }
    })();

    async function register() {
      const username = usernameInput.value.trim();
      if (!username || username.length < 2) {
        showError('Username must be at least 2 characters');
        return;
      }
      if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
        showError('Username can only contain letters, numbers, hyphens, and underscores');
        return;
      }

      registerBtn.disabled = true;
      errorEl.style.display = 'none';
      setStep(2);
      showStatus('Starting passkey registration...');

      try {
        // Step 1: Start registration
        const startRes = await fetch('/api/register/start', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ username }),
        });
        const startData = await startRes.json();
        if (startData.error) {
          showError(startData.error);
          registerBtn.disabled = false;
          setStep(1);
          return;
        }

        // Step 2: Create credential
        showStatus('Follow your browser prompt to create a passkey...');
        const { options } = startData;
        const challengeBytes = Uint8Array.from(
          atob(options.challenge.replace(/-/g, '+').replace(/_/g, '/')),
          c => c.charCodeAt(0)
        );
        const userIdBytes = Uint8Array.from(
          atob(options.user.id.replace(/-/g, '+').replace(/_/g, '/')),
          c => c.charCodeAt(0)
        );

        const publicKeyOptions = {
          challenge: challengeBytes,
          rp: options.rp,
          user: { ...options.user, id: userIdBytes },
          pubKeyCredParams: options.pubKeyCredParams,
          timeout: options.timeout,
          authenticatorSelection: options.authenticatorSelection,
          attestation: options.attestation,
        };

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

        // Step 3: Complete registration
        setStep(3);
        showStatus('Completing registration...');

        const credentialId = btoa(String.fromCharCode(...new Uint8Array(credential.rawId)))
          .replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=/g, '');
        const attestationObject = btoa(String.fromCharCode(...new Uint8Array(credential.response.attestationObject)))
          .replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=/g, '');
        const clientDataJSON = btoa(String.fromCharCode(...new Uint8Array(credential.response.clientDataJSON)))
          .replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=/g, '');

        const completeRes = await fetch('/api/register/complete', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            username,
            challenge: options.challenge,
            deviceName: detectDeviceName(),
            credential: {
              credentialId,
              attestationObject,
              clientDataJSON,
              transports: credential.response.getTransports ? credential.response.getTransports() : [],
            },
          }),
        });
        const completeData = await completeRes.json();

        if (!completeData.success) {
          showError(completeData.error || 'Registration failed');
          registerBtn.disabled = false;
          setStep(1);
          return;
        }

        // Step 4: Claim the invite
        showStatus('Claiming your invite...');
        const claimRes = await fetch('/api/invites/identity/' + encodeURIComponent(TOKEN) + '/claim', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ sessionToken: completeData.token }),
        });
        const claimData = await claimRes.json();

        if (claimData.error) {
          // Registration succeeded but claim failed — still show partial success
          showError('Account created but invite claim failed: ' + claimData.error);
          return;
        }

        // Success!
        document.getElementById('registerForm').style.display = 'none';
        statusEl.style.display = 'none';
        const destUrl = claimData.spaceSlug
          ? 'https://' + claimData.spaceSlug + '.rspace.online'
          : 'https://rspace.online';
        successEl.innerHTML = '<strong>Welcome to rSpace!</strong><br>Your identity has been created and your passkey is set up.' +
          (claimData.spaceSlug ? '<br>You\\'ve been added to <strong>' + claimData.spaceSlug + '</strong>.' : '') +
          '<br><br><a href="' + destUrl + '" style="color: #7c3aed;">Go to ' + (claimData.spaceSlug || 'rSpace') + ' →</a>';
        successEl.style.display = 'block';

      } catch (err) {
        if (err.name === 'NotAllowedError') {
          showError('Passkey creation was cancelled. Please try again.');
        } else {
          showError(err.message || 'Registration failed');
        }
        registerBtn.disabled = false;
        setStep(1);
      }
    }
  </script>
</body>
</html>`;
}

// ============================================================================
// OIDC CLIENT INVITE — ACCEPT PAGE
// ============================================================================

app.get('/oidc/accept', async (c) => {
  const token = c.req.query('token');
  return c.html(oidcAcceptPage(token || ''));
});

function oidcAcceptPage(token: string): string {
  return `<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Accept Invitation — EncryptID</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.06);
      border: 1px solid rgba(255,255,255,0.12);
      border-radius: 1rem;
      padding: 2.5rem;
      max-width: 440px;
      width: 90%;
      text-align: center;
    }
    .logo { font-size: 2.5rem; margin-bottom: 0.5rem; }
    h1 { font-size: 1.4rem; margin-bottom: 0.25rem; }
    .sub { color: #94a3b8; font-size: 0.9rem; margin-bottom: 1.5rem; }
    .app-name { color: #7c3aed; font-weight: 600; }
    .message-box {
      border-left: 3px solid #7c3aed;
      padding: 0.6rem 1rem;
      margin: 1rem 0 1.5rem;
      text-align: left;
      color: #cbd5e1;
      background: rgba(124,58,237,0.08);
      border-radius: 0 0.5rem 0.5rem 0;
      font-size: 0.9rem;
      font-style: italic;
      display: none;
    }

    /* Tabs */
    .tabs { display: flex; gap: 0; margin-bottom: 1.5rem; border-radius: 0.5rem; overflow: hidden; border: 1px solid rgba(255,255,255,0.15); }
    .tab {
      flex: 1; padding: 0.6rem; background: transparent; border: none;
      color: #94a3b8; font-size: 0.85rem; font-weight: 500; cursor: pointer;
      transition: all 0.2s;
    }
    .tab.active { background: rgba(124,58,237,0.2); color: #fff; }
    .tab:hover:not(.active) { background: rgba(255,255,255,0.05); }

    /* Panels */
    .panel { display: none; }
    .panel.active { display: block; }

    .form-group { margin-bottom: 1rem; text-align: left; }
    .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;
    }
    .form-group input:focus { border-color: #7c3aed; }
    .form-group input::placeholder { color: #475569; }
    .form-group input:read-only { opacity: 0.6; cursor: not-allowed; }
    .btn-primary {
      width: 100%; padding: 0.85rem; 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;
      margin-top: 0.5rem;
    }
    .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; display: none;
    }
    .status { color: #94a3b8; font-size: 0.85rem; margin-top: 1rem; display: none; }
  </style>
</head>
<body>
  <div class="card">
    <div class="logo">🔐</div>
    <h1 id="title">Accept Invitation</h1>
    <p id="subtitle" class="sub">Loading invite details...</p>

    <div id="messageBox" class="message-box"></div>
    <div id="error" class="error"></div>
    <div id="success" class="success"></div>

    <div id="authSection" style="display:none;">
      <div class="tabs">
        <button class="tab active" onclick="switchTab('new')">I'm new</button>
        <button class="tab" onclick="switchTab('existing')">I have an account</button>
      </div>

      <div id="panel-new" class="panel active">
        <div class="form-group">
          <label>Choose a username</label>
          <input type="text" id="username" placeholder="username" autocomplete="username" />
        </div>
        <div class="form-group">
          <label>Email</label>
          <input type="email" id="email" readonly />
        </div>
        <button id="registerBtn" class="btn-primary" onclick="doRegister()">
          Create Passkey &amp; Accept
        </button>
      </div>

      <div id="panel-existing" class="panel">
        <p style="color: #cbd5e1; font-size: 0.9rem; margin-bottom: 1rem;">
          Sign in with your existing passkey to accept this invitation.
        </p>
        <button id="loginBtn" class="btn-primary" onclick="doLogin()">
          Sign in with Passkey
        </button>
      </div>
    </div>

    <p id="status" class="status"></p>
  </div>

  <script>
    const TOKEN = ${JSON.stringify(token)};
    let inviteData = null;

    const errorEl = document.getElementById('error');
    const successEl = document.getElementById('success');
    const statusEl = document.getElementById('status');

    function showError(msg) {
      errorEl.textContent = msg;
      errorEl.style.display = 'block';
      successEl.style.display = 'none';
      statusEl.style.display = 'none';
    }
    function showStatus(msg) {
      statusEl.textContent = msg;
      statusEl.style.display = 'block';
      errorEl.style.display = 'none';
    }

    function switchTab(tab) {
      document.querySelectorAll('.tab').forEach((t, i) => {
        t.classList.toggle('active', (tab === 'new' && i === 0) || (tab === 'existing' && i === 1));
      });
      document.getElementById('panel-new').classList.toggle('active', tab === 'new');
      document.getElementById('panel-existing').classList.toggle('active', tab === 'existing');
      errorEl.style.display = 'none';
    }

    // Load invite info
    (async () => {
      if (!TOKEN) { showError('No invite token provided.'); return; }
      try {
        const res = await fetch('/api/invites/identity/' + encodeURIComponent(TOKEN) + '/info');
        if (!res.ok) {
          const data = await res.json();
          showError(data.error || 'This invite is no longer valid.');
          return;
        }
        inviteData = await res.json();
        const appName = inviteData.clientName || 'rSpace';
        document.getElementById('title').textContent = 'Join ' + appName;
        document.getElementById('subtitle').innerHTML =
          'You\\u2019ve been invited to <span class="app-name">' + esc(appName) + '</span>';
        document.getElementById('email').value = inviteData.email;
        if (inviteData.message) {
          const mb = document.getElementById('messageBox');
          mb.textContent = '\\u201c' + inviteData.message + '\\u201d';
          mb.style.display = 'block';
        }
        document.getElementById('authSection').style.display = 'block';
      } catch (err) {
        showError('Failed to load invite. Please try again.');
      }
    })();

    function esc(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }

    // base64url helpers
    function toB64url(buf) {
      return btoa(String.fromCharCode(...new Uint8Array(buf)))
        .replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=/g, '');
    }
    function fromB64url(s) {
      return Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0));
    }

    async function claimAndRedirect(sessionToken) {
      showStatus('Claiming invitation...');
      const claimRes = await fetch('/api/invites/identity/' + encodeURIComponent(TOKEN) + '/claim', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ sessionToken }),
      });
      const claimData = await claimRes.json();
      if (claimData.error) {
        showError('Invite claim failed: ' + claimData.error);
        return;
      }

      // Show success with link to the app
      document.getElementById('authSection').style.display = 'none';
      statusEl.style.display = 'none';

      const appName = inviteData.clientName || 'rSpace';
      const appUrl = inviteData.clientAppUrl || 'https://rspace.online';

      if (inviteData.clientId) {
        successEl.innerHTML = '<strong>You\\u2019re in!</strong><br>' +
          'Your account is set up and you\\u2019ve been granted access to <strong>' + esc(appName) + '</strong>.' +
          '<br><br><a href="' + esc(appUrl) + '" style="display:inline-block;padding:0.7rem 1.5rem;background:linear-gradient(90deg,#00d4ff,#7c3aed);color:#fff;text-decoration:none;border-radius:0.5rem;font-weight:600;">Go to ' + esc(appName) + ' \\u2192</a>' +
          '<p style="color:#94a3b8;font-size:0.8rem;margin-top:1rem;">You\\u2019ll sign in with your passkey when you get there.</p>';
      } else {
        successEl.innerHTML = '<strong>Welcome!</strong><br>Your invitation has been accepted.' +
          (claimData.spaceSlug ? '<br>You\\u2019ve been added to <strong>' + esc(claimData.spaceSlug) + '</strong>.' : '') +
          '<br><br><a href="https://rspace.online" style="color: #7c3aed;">Go to rSpace \\u2192</a>';
      }
      successEl.style.display = 'block';
    }

    async function doRegister() {
      const username = document.getElementById('username').value.trim();
      if (!username || username.length < 2) { showError('Username must be at least 2 characters'); return; }
      if (!/^[a-zA-Z0-9_-]+$/.test(username)) { showError('Username can only contain letters, numbers, hyphens, and underscores'); return; }

      const btn = document.getElementById('registerBtn');
      btn.disabled = true;
      errorEl.style.display = 'none';
      showStatus('Starting passkey registration...');

      try {
        const startRes = await fetch('/api/register/start', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ username }),
        });
        const startData = await startRes.json();
        if (startData.error) { showError(startData.error); btn.disabled = false; return; }

        showStatus('Follow your browser prompt to create a passkey...');
        const { options } = startData;

        const credential = await navigator.credentials.create({
          publicKey: {
            challenge: fromB64url(options.challenge),
            rp: options.rp,
            user: { ...options.user, id: fromB64url(options.user.id) },
            pubKeyCredParams: options.pubKeyCredParams,
            timeout: options.timeout,
            authenticatorSelection: options.authenticatorSelection,
            attestation: options.attestation,
          },
        });

        showStatus('Completing registration...');
        const completeRes = await fetch('/api/register/complete', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            username,
            challenge: options.challenge,
            deviceName: detectDeviceName(),
            credential: {
              credentialId: toB64url(credential.rawId),
              attestationObject: toB64url(credential.response.attestationObject),
              clientDataJSON: toB64url(credential.response.clientDataJSON),
              transports: credential.response.getTransports ? credential.response.getTransports() : [],
            },
          }),
        });
        const completeData = await completeRes.json();
        if (!completeData.success) { showError(completeData.error || 'Registration failed'); btn.disabled = false; return; }

        await claimAndRedirect(completeData.token);
      } catch (err) {
        if (err.name === 'NotAllowedError') {
          showError('Passkey creation was cancelled. Please try again.');
        } else {
          showError(err.message || 'Registration failed');
        }
        btn.disabled = false;
      }
    }

    async function doLogin() {
      const btn = document.getElementById('loginBtn');
      btn.disabled = true;
      errorEl.style.display = 'none';
      showStatus('Starting passkey authentication...');

      try {
        const startRes = await fetch('/api/auth/start', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({}),
        });
        const { options } = await startRes.json();

        showStatus('Touch your passkey...');
        const pubKeyOpts = {
          challenge: fromB64url(options.challenge),
          rpId: options.rpId,
          userVerification: options.userVerification,
          timeout: options.timeout,
        };
        if (options.allowCredentials) {
          pubKeyOpts.allowCredentials = options.allowCredentials.map(c => ({
            type: c.type,
            id: fromB64url(c.id),
            transports: c.transports,
          }));
        }

        const assertion = await navigator.credentials.get({ publicKey: pubKeyOpts });

        const completeRes = await fetch('/api/auth/complete', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            challenge: options.challenge,
            credential: {
              credentialId: toB64url(assertion.rawId),
              authenticatorData: toB64url(assertion.response.authenticatorData),
              clientDataJSON: toB64url(assertion.response.clientDataJSON),
              signature: toB64url(assertion.response.signature),
            },
          }),
        });
        const authResult = await completeRes.json();
        if (!authResult.success) { showError(authResult.error || 'Authentication failed'); btn.disabled = false; return; }

        await claimAndRedirect(authResult.token);
      } catch (err) {
        if (err.name === 'NotAllowedError') {
          showError('Passkey authentication was cancelled.');
        } else {
          showError(err.message || 'Authentication failed');
        }
        btn.disabled = false;
      }
    }
  </script>
</body>
</html>`;
}

// ============================================================================
// OIDC PROVIDER (Authorization Code Flow)
// ============================================================================

const OIDC_ISSUER = process.env.OIDC_ISSUER || 'https://auth.rspace.online';

// --- RS256 Key Management ---
// Load RSA private key from env (PEM), or auto-generate an ephemeral keypair.
// For production, set OIDC_RSA_PRIVATE_KEY with a PEM-encoded PKCS8 private key.
let oidcPrivateKey: CryptoKey;
let oidcPublicKey: CryptoKey;
let oidcJwk: JsonWebKey & { kid: string; use: string; alg: string; kty: string };

async function initOidcKeys() {
  const pemEnv = process.env.OIDC_RSA_PRIVATE_KEY;
  if (pemEnv) {
    // Import PEM private key
    const pemBody = pemEnv
      .replace(/-----BEGIN PRIVATE KEY-----/, '')
      .replace(/-----END PRIVATE KEY-----/, '')
      .replace(/\\n/g, '')   // literal \n from .env files
      .replace(/\s/g, '');
    const binaryDer = Uint8Array.from(atob(pemBody), c => c.charCodeAt(0));
    oidcPrivateKey = await crypto.subtle.importKey(
      'pkcs8', binaryDer,
      { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
      true, ['sign']
    );
    // Derive public key by exporting/importing
    const jwk = await crypto.subtle.exportKey('jwk', oidcPrivateKey);
    // Remove private components for public key
    const pubJwk = { kty: jwk.kty!, n: jwk.n!, e: jwk.e! };
    oidcPublicKey = await crypto.subtle.importKey(
      'jwk', pubJwk,
      { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
      true, ['verify']
    );
    oidcJwk = { ...pubJwk, kid: 'oidc-1', use: 'sig', alg: 'RS256', kty: 'RSA' };
    console.log('[oidc] Loaded RSA private key from OIDC_RSA_PRIVATE_KEY');
  } else {
    // Auto-generate ephemeral keypair (not persistent across restarts)
    console.warn('[oidc] WARNING: No OIDC_RSA_PRIVATE_KEY set, generating ephemeral RSA keypair');
    const keyPair = await crypto.subtle.generateKey(
      { name: 'RSASSA-PKCS1-v1_5', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' },
      true, ['sign', 'verify']
    );
    oidcPrivateKey = keyPair.privateKey;
    oidcPublicKey = keyPair.publicKey;
    const pubJwk = await crypto.subtle.exportKey('jwk', oidcPublicKey);
    oidcJwk = { kty: pubJwk.kty!, n: pubJwk.n!, e: pubJwk.e!, kid: 'oidc-1', use: 'sig', alg: 'RS256' };
  }
}

// Sign a JWT payload with RS256
async function signRS256(payload: Record<string, unknown>): Promise<string> {
  const header = { alg: 'RS256', typ: 'JWT', kid: 'oidc-1' };
  const encode = (obj: unknown) => btoa(JSON.stringify(obj)).replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
  const headerB64 = encode(header);
  const payloadB64 = encode(payload);
  const signingInput = `${headerB64}.${payloadB64}`;
  const signature = await crypto.subtle.sign(
    'RSASSA-PKCS1-v1_5',
    oidcPrivateKey,
    new TextEncoder().encode(signingInput)
  );
  const sigB64 = btoa(String.fromCharCode(...new Uint8Array(signature)))
    .replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
  return `${signingInput}.${sigB64}`;
}

// Verify a JWT with RS256, returns payload or throws
async function verifyRS256(token: string): Promise<Record<string, unknown>> {
  const parts = token.split('.');
  if (parts.length !== 3) throw new Error('Invalid JWT format');
  const signingInput = `${parts[0]}.${parts[1]}`;
  // Decode signature
  const sigB64 = parts[2].replace(/-/g, '+').replace(/_/g, '/');
  const padded = sigB64 + '='.repeat((4 - sigB64.length % 4) % 4);
  const signature = Uint8Array.from(atob(padded), c => c.charCodeAt(0));
  const valid = await crypto.subtle.verify(
    'RSASSA-PKCS1-v1_5',
    oidcPublicKey,
    signature,
    new TextEncoder().encode(signingInput)
  );
  if (!valid) throw new Error('Invalid signature');
  // Decode payload
  const payloadB64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
  const payloadPadded = payloadB64 + '='.repeat((4 - payloadB64.length % 4) % 4);
  const payload = JSON.parse(atob(payloadPadded));
  // Check expiry
  if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) {
    throw new Error('Token expired');
  }
  return payload;
}

// JWKS endpoint — public key for RS256 token verification
app.get('/oidc/jwks', (c) => {
  return c.json({ keys: [oidcJwk] });
});

// Discovery document
app.get('/.well-known/openid-configuration', (c) => {
  return c.json({
    issuer: OIDC_ISSUER,
    authorization_endpoint: `${OIDC_ISSUER}/oidc/authorize`,
    token_endpoint: `${OIDC_ISSUER}/oidc/token`,
    userinfo_endpoint: `${OIDC_ISSUER}/oidc/userinfo`,
    jwks_uri: `${OIDC_ISSUER}/oidc/jwks`,
    response_types_supported: ['code'],
    subject_types_supported: ['public'],
    id_token_signing_alg_values_supported: ['RS256'],
    scopes_supported: ['openid', 'profile', 'email'],
    claims_supported: ['sub', 'email', 'name', 'preferred_username'],
    grant_types_supported: ['authorization_code'],
    token_endpoint_auth_methods_supported: ['client_secret_post', 'client_secret_basic'],
  });
});

// Authorization endpoint
app.get('/oidc/authorize', async (c) => {
  const clientId = c.req.query('client_id');
  const redirectUri = c.req.query('redirect_uri');
  const responseType = c.req.query('response_type');
  const scope = c.req.query('scope') || 'openid profile email';
  const state = c.req.query('state') || '';

  if (responseType !== 'code') {
    return c.text('Unsupported response_type', 400);
  }
  if (!clientId || !redirectUri) {
    return c.text('Missing client_id or redirect_uri', 400);
  }

  const client = await getOidcClient(clientId);
  if (!client) {
    return c.text('Unknown client_id', 400);
  }

  if (!client.redirectUris.includes(redirectUri)) {
    return c.text('Invalid redirect_uri', 400);
  }

  // Serve the authorize page — it handles login + consent in one flow
  return c.html(oidcAuthorizePage(client.name, clientId, redirectUri, scope, state));
});

// Authorization callback (POST from the authorize page after passkey login)
app.post('/oidc/authorize', async (c) => {
  const body = await c.req.json();
  const { clientId, redirectUri, scope, state, token } = body;

  // Verify the session token from passkey login
  let payload: any;
  try {
    payload = await verify(token, CONFIG.jwtSecret, 'HS256');
  } catch {
    return c.json({ error: 'Invalid session' }, 401);
  }

  const client = await getOidcClient(clientId);
  if (!client) {
    return c.json({ error: 'Unknown client' }, 400);
  }
  if (!client.redirectUris.includes(redirectUri)) {
    return c.json({ error: 'Invalid redirect_uri' }, 400);
  }

  // Check email allowlist
  const user = await getUserById(payload.sub);
  if (!user) {
    return c.json({ error: 'User not found' }, 404);
  }
  const userEmail = (user.email || user.profile_email || '').toLowerCase();
  if (client.allowedEmails.length > 0) {
    const allowedLower = client.allowedEmails.map((e: string) => e.toLowerCase());
    if (!userEmail) {
      return c.json({ error: 'email_required', message: 'Email verification is required for this application.' }, 403);
    }
    if (!allowedLower.includes(userEmail)) {
      return c.json({ error: 'access_denied', message: 'Your email is not authorized for this application.' }, 403);
    }
  }

  // Generate auth code
  const code = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url');
  await createOidcAuthCode(code, clientId, payload.sub, redirectUri, scope || 'openid profile email');

  const url = new URL(redirectUri);
  url.searchParams.set('code', code);
  if (state) url.searchParams.set('state', state);

  return c.json({ redirectUrl: url.toString() });
});

// Email verification for OIDC access — send verification email
app.post('/oidc/verify-email', async (c) => {
  const body = await c.req.json();
  const { token, email, clientId, redirectUri, scope, state } = body;

  if (!token || !email || !clientId) {
    return c.json({ error: 'Missing required fields' }, 400);
  }

  // Verify session token
  let payload: any;
  try {
    payload = await verify(token, CONFIG.jwtSecret, 'HS256');
  } catch {
    return c.json({ error: 'Invalid session' }, 401);
  }

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

  const client = await getOidcClient(clientId);
  if (!client) {
    return c.json({ error: 'Unknown client' }, 400);
  }

  // Security: only allow emails that are in the client's allowedEmails list
  const normalizedEmail = email.trim().toLowerCase();
  if (client.allowedEmails.length === 0 || !client.allowedEmails.includes(normalizedEmail)) {
    return c.json({ error: 'This email is not authorized for this application.' }, 403);
  }

  // Encode OIDC context into the token key so the callback can reconstruct the flow
  const oidcContext = Buffer.from(JSON.stringify({
    email: normalizedEmail,
    userId: payload.sub,
    clientId,
    redirectUri: redirectUri || '',
    scope: scope || 'openid profile email',
    state: state || '',
  })).toString('base64url');

  const random = Buffer.from(crypto.getRandomValues(new Uint8Array(16))).toString('base64url');
  const verifyToken = `oidcverify_${oidcContext}_${random}`;

  const now = Date.now();
  await storeRecoveryToken({
    token: verifyToken,
    userId: payload.sub,
    type: 'email_verify',
    createdAt: now,
    expiresAt: now + 30 * 60 * 1000,
    used: false,
  });

  const sent = await sendVerificationEmail(normalizedEmail, verifyToken, user.username, client.name);

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

// Email verification callback — clicked from the verification email
app.get('/oidc/verify-email-callback', async (c) => {
  const token = c.req.query('token');
  if (!token) {
    return c.html(verifyEmailResultPage(false, 'Missing verification token.'));
  }

  // Validate token
  const record = await getRecoveryToken(token);
  if (!record) {
    return c.html(verifyEmailResultPage(false, 'Invalid or expired verification link.'));
  }
  if (record.used) {
    return c.html(verifyEmailResultPage(false, 'This verification link has already been used.'));
  }
  if (new Date(record.expiresAt) < new Date()) {
    return c.html(verifyEmailResultPage(false, 'This verification link has expired.'));
  }

  // Parse OIDC context from token
  const parts = token.split('_');
  if (parts.length < 3 || parts[0] !== 'oidcverify') {
    return c.html(verifyEmailResultPage(false, 'Invalid verification token format.'));
  }

  let context: { email: string; userId: string; clientId: string; redirectUri: string; scope: string; state: string };
  try {
    context = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
  } catch {
    return c.html(verifyEmailResultPage(false, 'Could not parse verification token.'));
  }

  // Update user email and mark token used
  await setUserEmail(context.userId, context.email);
  await markRecoveryTokenUsed(token);

  // Build retry URL
  const retryUrl = `/oidc/authorize?response_type=code&client_id=${encodeURIComponent(context.clientId)}&redirect_uri=${encodeURIComponent(context.redirectUri)}&scope=${encodeURIComponent(context.scope)}&state=${encodeURIComponent(context.state)}`;

  return c.html(verifyEmailResultPage(true, '', retryUrl));
});

// Token endpoint
app.post('/oidc/token', async (c) => {
  const contentType = c.req.header('content-type') || '';
  let grantType: string, code: string, redirectUri: string, clientId: string, clientSecret: string;

  if (contentType.includes('application/json')) {
    const body = await c.req.json();
    grantType = body.grant_type;
    code = body.code;
    redirectUri = body.redirect_uri;
    clientId = body.client_id;
    clientSecret = body.client_secret;
  } else {
    // application/x-www-form-urlencoded (standard OIDC)
    const body = await c.req.parseBody();
    grantType = body.grant_type as string;
    code = body.code as string;
    redirectUri = body.redirect_uri as string;
    clientId = body.client_id as string;
    clientSecret = body.client_secret as string;
  }

  // Support Basic auth for client credentials
  if (!clientId || !clientSecret) {
    const authHeader = c.req.header('Authorization');
    if (authHeader?.startsWith('Basic ')) {
      const decoded = Buffer.from(authHeader.slice(6), 'base64').toString();
      const [id, secret] = decoded.split(':');
      clientId = clientId || id;
      clientSecret = clientSecret || secret;
    }
  }

  if (grantType !== 'authorization_code') {
    return c.json({ error: 'unsupported_grant_type' }, 400);
  }

  const client = await getOidcClient(clientId);
  if (!client) {
    return c.json({ error: 'invalid_client' }, 401);
  }

  // Constant-time comparison for client secret
  const expected = new TextEncoder().encode(client.clientSecret);
  const received = new TextEncoder().encode(clientSecret || '');
  if (expected.length !== received.length) {
    return c.json({ error: 'invalid_client' }, 401);
  }
  let mismatch = 0;
  for (let i = 0; i < expected.length; i++) {
    mismatch |= expected[i] ^ received[i];
  }
  if (mismatch !== 0) {
    return c.json({ error: 'invalid_client' }, 401);
  }

  // Consume auth code (atomic — marks used)
  const authCode = await consumeOidcAuthCode(code);
  if (!authCode) {
    return c.json({ error: 'invalid_grant' }, 400);
  }
  if (authCode.clientId !== clientId) {
    return c.json({ error: 'invalid_grant' }, 400);
  }
  if (authCode.redirectUri !== redirectUri) {
    return c.json({ error: 'invalid_grant' }, 400);
  }

  // Look up user for claims
  const user = await getUserById(authCode.userId);
  if (!user) {
    return c.json({ error: 'invalid_grant' }, 400);
  }

  const now = Math.floor(Date.now() / 1000);
  const idTokenPayload = {
    iss: OIDC_ISSUER,
    sub: authCode.userId,
    aud: clientId,
    iat: now,
    exp: now + 3600,
    email: user.email || user.profile_email || '',
    name: user.display_name || user.username,
    preferred_username: user.username,
  };

  const idToken = await signRS256(idTokenPayload);

  // Access token is also a JWT with same claims (used by userinfo)
  const accessTokenPayload = {
    iss: OIDC_ISSUER,
    sub: authCode.userId,
    aud: clientId,
    iat: now,
    exp: now + 3600,
    scope: authCode.scope,
  };
  const accessToken = await signRS256(accessTokenPayload);

  return c.json({
    access_token: accessToken,
    token_type: 'Bearer',
    expires_in: 3600,
    id_token: idToken,
  });
});

// UserInfo endpoint (GET and POST per RFC 7662)
async function handleUserInfo(c: any) {
  const authHeader = c.req.header('Authorization');
  if (!authHeader?.startsWith('Bearer ')) {
    console.error('[oidc] userinfo: missing or invalid Authorization header');
    return c.json({ error: 'invalid_token' }, 401);
  }

  const token = authHeader.slice(7);
  let payload: any;
  try {
    payload = await verifyRS256(token);
  } catch (err) {
    console.error('[oidc] userinfo: token verification failed:', (err as Error).message);
    return c.json({ error: 'invalid_token' }, 401);
  }

  const user = await getUserById(payload.sub);
  if (!user) {
    console.error('[oidc] userinfo: user not found for sub:', payload.sub);
    return c.json({ error: 'invalid_token' }, 401);
  }

  return c.json({
    sub: payload.sub,
    email: user.email || user.profile_email || '',
    name: user.display_name || user.username,
    preferred_username: user.username,
  });
}
app.get('/oidc/userinfo', handleUserInfo);
app.post('/oidc/userinfo', handleUserInfo);

// Authorize page HTML — embeds passkey login, then posts back to complete authorization
function oidcAuthorizePage(appName: string, clientId: string, redirectUri: string, scope: string, state: string): string {
  return `<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Sign in — EncryptID</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.06);
      border: 1px solid rgba(255,255,255,0.12);
      border-radius: 1rem;
      padding: 2.5rem;
      max-width: 400px;
      width: 90%;
      text-align: center;
    }
    .logo { font-size: 2.5rem; margin-bottom: 0.5rem; }
    h1 { font-size: 1.3rem; margin-bottom: 0.25rem; }
    .sub { color: #94a3b8; font-size: 0.9rem; margin-bottom: 2rem; }
    .app-name { color: #7c3aed; font-weight: 600; }
    .btn-primary {
      width: 100%; padding: 0.85rem; 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;
    }
    .status { color: #94a3b8; font-size: 0.85rem; margin-top: 1rem; display: none; }
    .scope-list {
      text-align: left; margin: 1rem 0 1.5rem; padding: 0.75rem 1rem;
      background: rgba(255,255,255,0.04); border-radius: 0.5rem;
      font-size: 0.85rem; color: #cbd5e1;
    }
    .scope-list li { margin: 0.3rem 0; list-style: none; }
    .scope-list li::before { content: '✓ '; color: #22c55e; }
    .verify-section { display: none; text-align: left; margin-top: 1rem; }
    .verify-section label { display: block; color: #cbd5e1; font-size: 0.85rem; margin-bottom: 0.4rem; }
    .verify-section input {
      width: 100%; padding: 0.7rem 0.75rem; border-radius: 0.5rem; border: 1px solid rgba(255,255,255,0.15);
      background: rgba(255,255,255,0.06); color: #fff; font-size: 0.95rem; margin-bottom: 0.75rem;
      outline: none;
    }
    .verify-section input:focus { border-color: #7c3aed; }
    .verify-section p { color: #94a3b8; font-size: 0.82rem; line-height: 1.5; margin-bottom: 1rem; text-align: center; }
    .verify-sent { display: none; }
    .verify-sent .sent-icon { font-size: 2rem; margin-bottom: 0.5rem; }
    .verify-sent p { color: #94a3b8; font-size: 0.85rem; line-height: 1.5; }
  </style>
</head>
<body>
  <div class="card">
    <div class="logo">🔐</div>
    <h1>Sign in with EncryptID</h1>
    <p class="sub"><span class="app-name">${escapeHtml(appName)}</span> wants to access your account</p>

    <ul class="scope-list">
      <li>Your name and username</li>
      <li>Your email address</li>
    </ul>

    <div id="error" class="error"></div>

    <div id="verify-section" class="verify-section">
      <p>Your passkey is valid, but your account doesn't have a verified email for this app. Enter your email to verify access.</p>
      <label for="verify-email">Email address</label>
      <input type="email" id="verify-email" placeholder="you@example.com" autocomplete="email">
      <button class="btn-primary" onclick="sendVerifyEmail()">Send Verification Email</button>
    </div>

    <div id="verify-sent" class="verify-sent">
      <div class="sent-icon">&#9993;</div>
      <p><strong>Check your inbox!</strong><br>A verification link has been sent to <strong id="sent-email"></strong>. Click the link to verify your email and continue signing in.</p>
    </div>

    <button id="loginBtn" class="btn-primary" onclick="startLogin()">
      Sign in with Passkey
    </button>

    <p id="status" class="status"></p>
  </div>

  <script>
    const CLIENT_ID = ${JSON.stringify(clientId)};
    const REDIRECT_URI = ${JSON.stringify(redirectUri)};
    const SCOPE = ${JSON.stringify(scope)};
    const STATE = ${JSON.stringify(state)};

    const errorEl = document.getElementById('error');
    const statusEl = document.getElementById('status');
    const loginBtn = document.getElementById('loginBtn');
    const verifySection = document.getElementById('verify-section');
    const verifySent = document.getElementById('verify-sent');
    let sessionToken = null;

    function showError(msg) {
      errorEl.textContent = msg;
      errorEl.style.display = 'block';
      statusEl.style.display = 'none';
    }

    function showStatus(msg) {
      statusEl.textContent = msg;
      statusEl.style.display = 'block';
      errorEl.style.display = 'none';
    }

    async function startLogin() {
      loginBtn.disabled = true;
      errorEl.style.display = 'none';
      showStatus('Starting passkey authentication...');

      try {
        // Step 1: Get authentication challenge
        const startRes = await fetch('/api/auth/start', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({}),
        });
        const { options } = await startRes.json();

        // Step 2: WebAuthn get assertion
        const challengeBytes = Uint8Array.from(
          atob(options.challenge.replace(/-/g, '+').replace(/_/g, '/')),
          c => c.charCodeAt(0)
        );

        const publicKeyOptions = {
          challenge: challengeBytes,
          rpId: options.rpId,
          userVerification: options.userVerification,
          timeout: options.timeout,
        };

        if (options.allowCredentials) {
          publicKeyOptions.allowCredentials = options.allowCredentials.map(c => ({
            type: c.type,
            id: Uint8Array.from(atob(c.id.replace(/-/g, '+').replace(/_/g, '/')), ch => ch.charCodeAt(0)),
            transports: c.transports,
          }));
        }

        showStatus('Touch your passkey...');
        const assertion = await navigator.credentials.get({ publicKey: publicKeyOptions });

        // Step 3: Complete authentication
        const credentialId = btoa(String.fromCharCode(...new Uint8Array(assertion.rawId)))
          .replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=/g, '');

        const completeRes = await fetch('/api/auth/complete', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            challenge: options.challenge,
            credential: {
              credentialId,
              authenticatorData: btoa(String.fromCharCode(...new Uint8Array(assertion.response.authenticatorData)))
                .replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=/g, ''),
              clientDataJSON: btoa(String.fromCharCode(...new Uint8Array(assertion.response.clientDataJSON)))
                .replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=/g, ''),
              signature: btoa(String.fromCharCode(...new Uint8Array(assertion.response.signature)))
                .replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=/g, ''),
            },
          }),
        });
        const authResult = await completeRes.json();

        if (!authResult.success) {
          showError(authResult.error || 'Authentication failed');
          loginBtn.disabled = false;
          return;
        }

        // Save session token for email verification flow
        sessionToken = authResult.token;

        // Step 4: Exchange session token for OIDC auth code
        showStatus('Authorizing...');
        const authorizeRes = await fetch('/oidc/authorize', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            clientId: CLIENT_ID,
            redirectUri: REDIRECT_URI,
            scope: SCOPE,
            state: STATE,
            token: authResult.token,
          }),
        });
        const authorizeResult = await authorizeRes.json();

        if (authorizeResult.error) {
          if (authorizeResult.error === 'email_required') {
            // User has no verified email — show email verification form
            loginBtn.style.display = 'none';
            statusEl.style.display = 'none';
            verifySection.style.display = 'block';
          } else {
            showError(authorizeResult.message || authorizeResult.error);
            loginBtn.disabled = false;
          }
          return;
        }

        // Step 5: Redirect back to the app with the auth code
        showStatus('Redirecting...');
        window.location.href = authorizeResult.redirectUrl;

      } catch (err) {
        if (err.name === 'NotAllowedError') {
          showError('Passkey authentication was cancelled.');
        } else {
          showError(err.message || 'Authentication failed');
        }
        loginBtn.disabled = false;
      }
    }

    async function sendVerifyEmail() {
      const emailInput = document.getElementById('verify-email');
      const email = emailInput.value.trim().toLowerCase();
      if (!email || !email.includes('@')) {
        showError('Please enter a valid email address.');
        return;
      }
      if (!sessionToken) {
        showError('Session expired. Please refresh and try again.');
        return;
      }

      errorEl.style.display = 'none';
      verifySection.querySelector('.btn-primary').disabled = true;

      try {
        const res = await fetch('/oidc/verify-email', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            token: sessionToken,
            email,
            clientId: CLIENT_ID,
            redirectUri: REDIRECT_URI,
            scope: SCOPE,
            state: STATE,
          }),
        });
        const result = await res.json();

        if (result.error) {
          showError(result.error);
          verifySection.querySelector('.btn-primary').disabled = false;
          return;
        }

        // Show success message
        verifySection.style.display = 'none';
        verifySent.style.display = 'block';
        document.getElementById('sent-email').textContent = email;
      } catch (err) {
        showError(err.message || 'Failed to send verification email.');
        verifySection.querySelector('.btn-primary').disabled = false;
      }
    }

  </script>
</body>
</html>`;
}

function verifyEmailResultPage(success: boolean, errorMessage: string = '', retryUrl: string = ''): string {
  const content = success
    ? `<div style="font-size:3rem;margin-bottom:1rem;">&#10003;</div>
       <h1 style="font-size:1.3rem;margin-bottom:0.5rem;">Email Verified</h1>
       <p style="color:#94a3b8;font-size:0.9rem;margin-bottom:2rem;">Your email address has been verified. You can now sign in.</p>
       <a href="${escapeHtml(retryUrl)}" style="display:inline-block;width:100%;padding:0.85rem;border-radius:0.5rem;background:linear-gradient(90deg,#00d4ff,#7c3aed);color:#fff;text-decoration:none;font-size:1rem;font-weight:600;text-align:center;box-sizing:border-box;">Continue to Sign In</a>`
    : `<div style="font-size:3rem;margin-bottom:1rem;">&#10007;</div>
       <h1 style="font-size:1.3rem;margin-bottom:0.5rem;">Verification Failed</h1>
       <p style="color:#fca5a5;font-size:0.9rem;margin-bottom:2rem;">${escapeHtml(errorMessage)}</p>`;

  return `<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>${success ? 'Email Verified' : 'Verification Failed'} — EncryptID</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.06);
      border: 1px solid rgba(255,255,255,0.12);
      border-radius: 1rem;
      padding: 2.5rem;
      max-width: 400px;
      width: 90%;
      text-align: center;
    }
  </style>
</head>
<body>
  <div class="card">
    ${content}
  </div>
</body>
</html>`;
}

function escapeHtml(s: string): string {
  return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}

// ============================================================================
// 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); }

    /* Device list */
    .device-item { padding: 0.75rem; background: rgba(255,255,255,0.04); border-radius: 0.5rem; margin-bottom: 0.5rem; font-size: 0.8rem; }
    .device-item-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.25rem; }
    .device-name { color: #e2e8f0; font-weight: 500; }
    .device-status { font-size: 0.7rem; padding: 0.15rem 0.5rem; border-radius: 9999px; }
    .device-status.active { background: rgba(34,197,94,0.15); color: #22c55e; }
    .device-status.pending { background: rgba(251,191,36,0.15); color: #fbbf24; }
    .device-meta { color: #64748b; font-size: 0.75rem; }
    .device-actions { display: flex; gap: 0.5rem; margin-top: 0.5rem; }
    .device-actions button { background: none; border: none; color: #94a3b8; cursor: pointer; font-size: 0.75rem; padding: 0.2rem 0.4rem; border-radius: 0.25rem; }
    .device-actions button:hover { background: rgba(255,255,255,0.08); color: #e2e8f0; }
    .device-actions button.danger:hover { color: #ef4444; }
    .device-rename-input { background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.15); color: #fff; padding: 0.3rem 0.5rem; border-radius: 0.25rem; font-size: 0.8rem; width: 100%; }

    /* Recovery email */
    .recovery-section, .guardians-section, .devices-section, .vault-section, .wallet-section, .spaces-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, .wallet-section h4, .spaces-section h4 { font-size: 0.9rem; margin-bottom: 0.75rem; color: #94a3b8; }
    .wallet-card { padding: 0.75rem; border-radius: 0.5rem; background: rgba(255,255,255,0.04); margin-bottom: 0.5rem; }
    .wallet-card .wallet-label { font-size: 0.75rem; color: #64748b; text-transform: uppercase; margin-bottom: 0.25rem; }
    .wallet-card .wallet-addr { font-family: monospace; font-size: 0.85rem; color: #e2e8f0; word-break: break-all; }
    .wallet-card .wallet-addr a { color: #60a5fa; text-decoration: none; }
    .wallet-card .wallet-addr a:hover { text-decoration: underline; }
    .wallet-card .wallet-balance { font-size: 0.8rem; color: #4ade80; margin-top: 0.25rem; }
    .space-card { padding: 0.75rem; border-radius: 0.5rem; background: rgba(255,255,255,0.04); margin-bottom: 0.5rem; display: flex; justify-content: space-between; align-items: center; }
    .space-card .space-name { font-weight: 600; color: #e2e8f0; }
    .space-card .space-name a { color: #e2e8f0; text-decoration: none; }
    .space-card .space-name a:hover { color: #60a5fa; }
    .space-card .space-role { font-size: 0.75rem; color: #64748b; text-transform: uppercase; }
    .space-wallet { font-size: 0.8rem; color: #94a3b8; margin-top: 0.25rem; font-family: monospace; }
    .space-wallet a { color: #60a5fa; text-decoration: none; }
    .space-wallet a:hover { text-decoration: underline; }
    .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; }

    /* Registration stepper */
    .stepper { display: none; }
    .stepper.active { display: block; }
    .stepper-progress { display: flex; align-items: center; justify-content: center; gap: 0; margin-bottom: 1.5rem; padding: 0 1rem; }
    .stepper-dot { width: 10px; height: 10px; border-radius: 50%; background: rgba(255,255,255,0.15); transition: all 0.3s; flex-shrink: 0; }
    .stepper-dot.active { background: #7c3aed; box-shadow: 0 0 8px rgba(124,58,237,0.4); }
    .stepper-dot.done { background: #22c55e; }
    .stepper-line { height: 2px; flex: 1; background: rgba(255,255,255,0.1); transition: background 0.3s; }
    .stepper-line.done { background: #22c55e; }

    .step-card { animation: stepIn 0.3s ease; }
    @keyframes stepIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }

    .step-icon { font-size: 2rem; text-align: center; margin-bottom: 0.75rem; }
    .step-title { font-size: 1.1rem; font-weight: 600; margin-bottom: 0.5rem; }
    .step-desc { font-size: 0.85rem; color: #94a3b8; line-height: 1.5; margin-bottom: 1rem; }

    .key-status { background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08); border-radius: 0.5rem; padding: 0.75rem; margin-bottom: 1rem; }
    .key-row { display: flex; justify-content: space-between; align-items: center; padding: 0.3rem 0; font-size: 0.8rem; }
    .key-label { color: #94a3b8; }
    .key-value { color: #22c55e; font-family: monospace; font-size: 0.75rem; }
    .key-value.pending { color: #64748b; }

    .did-display { background: rgba(124,58,237,0.1); border: 1px solid rgba(124,58,237,0.2); border-radius: 0.5rem; padding: 0.6rem 0.75rem; font-family: monospace; font-size: 0.7rem; color: #c4b5fd; word-break: break-all; cursor: pointer; position: relative; }
    .did-display:hover { border-color: #7c3aed; }
    .did-display .copy-toast { position: absolute; right: 0.5rem; top: 50%; transform: translateY(-50%); font-size: 0.7rem; color: #7c3aed; font-family: sans-serif; }

    .eoa-display { background: rgba(34,197,94,0.08); border: 1px solid rgba(34,197,94,0.2); border-radius: 0.5rem; padding: 0.6rem 0.75rem; font-family: monospace; font-size: 0.75rem; color: #86efac; word-break: break-all; margin-top: 0.5rem; }

    .step-note { font-size: 0.8rem; color: #64748b; font-style: italic; padding: 0.5rem 0.75rem; background: rgba(255,255,255,0.03); border-radius: 0.5rem; margin-bottom: 1rem; }
    .btn-secondary { display: block; width: 100%; padding: 0.75rem; border: 1px solid rgba(255,255,255,0.15); background: transparent; color: #e2e8f0; border-radius: 0.5rem; font-size: 0.9rem; cursor: pointer; transition: all 0.2s; margin-top: 0.5rem; }
    .btn-secondary:hover { border-color: #7c3aed; color: #fff; }

    @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>

        <!-- Sign-in mode (default) — dynamically rendered by renderSigninAccounts() -->
        <div id="signin-fields"></div>

        <!-- Registration stepper (hidden until register tab) -->
        <div id="register-stepper" style="display:none">
          <div class="stepper-progress" id="stepper-progress">
            <div class="stepper-dot active" data-step="1"></div>
            <div class="stepper-line" data-line="1"></div>
            <div class="stepper-dot" data-step="2"></div>
            <div class="stepper-line" data-line="2"></div>
            <div class="stepper-dot" data-step="3"></div>
            <div class="stepper-line" data-line="3"></div>
            <div class="stepper-dot" data-step="4"></div>
            <div class="stepper-line" data-line="4"></div>
            <div class="stepper-dot" data-step="5"></div>
          </div>

          <!-- Step 1: Choose Your Identity -->
          <div class="stepper step-card active" id="reg-step-1">
            <div class="step-icon">&#128100;</div>
            <div class="step-title">Choose Your Identity</div>
            <div class="step-desc">Your username is your identity across rStack. No passwords — your device IS your key.</div>
            <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>
            <button class="btn-primary" id="step1-btn" onclick="handleStep1()">Continue</button>
          </div>

          <!-- Step 2: Create Your Passkey -->
          <div class="stepper step-card" id="reg-step-2">
            <div class="step-icon">&#128274;</div>
            <div class="step-title">Create Your Passkey</div>
            <div class="step-desc">A passkey is a cryptographic credential stored on your device. It replaces passwords with biometrics (fingerprint, face) or your device PIN. Passkeys are phishing-resistant and never leave your device.</div>
            <button class="btn-primary" id="step2-btn" onclick="handleStep2()">Create Passkey</button>
          </div>

          <!-- Step 3: Your Universal Identity (DID) -->
          <div class="stepper step-card" id="reg-step-3">
            <div class="step-icon">&#128273;</div>
            <div class="step-title">Your Universal Identity</div>
            <div id="step3-prf-content">
              <div class="step-desc">Your passkey derived a unique Decentralized Identifier (DID) — a cryptographic identity that works across every app. Encryption and signing keys were also derived. All keys stay on your device.</div>
              <div class="did-display" id="step3-did" onclick="copyDid(this)">
                <span id="step3-did-text">Loading...</span>
                <span class="copy-toast">click to copy</span>
              </div>
              <div class="key-status" style="margin-top:0.75rem">
                <div class="key-row"><span class="key-label">Encryption</span><span class="key-value" id="key-encrypt">&#10003; Derived</span></div>
                <div class="key-row"><span class="key-label">Signing</span><span class="key-value" id="key-sign">&#10003; Derived</span></div>
                <div class="key-row"><span class="key-label">DID</span><span class="key-value" id="key-did">&#10003; Derived</span></div>
                <div class="key-row"><span class="key-label">EOA Wallet</span><span class="key-value" id="key-eoa">&#10003; Derived</span></div>
              </div>
            </div>
            <div id="step3-no-prf" style="display:none">
              <div class="step-note">Your browser doesn't support hardware-backed encryption. Set a passphrase to encrypt your documents.</div>
              <div class="form-group" style="margin-top:1rem">
                <label for="encryption-passphrase">Encryption Passphrase</label>
                <input id="encryption-passphrase" type="password" placeholder="Choose a strong passphrase" autocomplete="off" />
              </div>
              <div class="form-group">
                <label for="encryption-passphrase-confirm">Confirm Passphrase</label>
                <input id="encryption-passphrase-confirm" type="password" placeholder="Confirm your passphrase" autocomplete="off" />
              </div>
              <div style="font-size:0.75rem;color:#94a3b8;margin-top:0.5rem">This passphrase encrypts your documents locally. It is never sent to any server.</div>
            </div>
            <button class="btn-primary" onclick="handleStep3Continue()">Continue</button>
          </div>

          <!-- Step 4: Your Wallet -->
          <div class="stepper step-card" id="reg-step-4">
            <div class="step-icon">&#128176;</div>
            <div class="step-title">Your Wallet</div>
            <div id="step4-prf-content">
              <div class="step-desc">Your passkey derived an Ethereum wallet address. This wallet can hold tokens and sign transactions — no seed phrases or browser extensions needed.</div>
              <div class="eoa-display" id="step4-eoa">Loading...</div>
              <div class="step-desc" style="margin-top:0.75rem;font-size:0.8rem">A CRDT-based local wallet is also active for offline-first token management.</div>
            </div>
            <div id="step4-no-prf" style="display:none">
              <div class="step-note">Wallet derivation requires PRF-capable authenticator. You can add a wallet later from settings.</div>
            </div>
            <button class="btn-primary" onclick="goToStep(5)">Continue</button>
          </div>

          <!-- Step 5: Secure Your Account -->
          <div class="stepper step-card" id="reg-step-5">
            <div class="step-icon">&#128737;</div>
            <div class="step-title">Secure Your Account</div>
            <div class="step-desc">Social recovery replaces seed phrases. Choose trusted people who can help you recover your account if you lose all devices.</div>
            <div class="key-status">
              <div class="key-row"><span class="key-label">Passkey</span><span class="key-value">&#10003; Created</span></div>
              <div class="key-row"><span class="key-label">Recovery email</span><span class="key-value" id="step5-email">&#9675; Not set</span></div>
              <div class="key-row"><span class="key-label">Second device</span><span class="key-value pending">&#9675; Not added</span></div>
              <div class="key-row"><span class="key-label">Guardians</span><span class="key-value pending">&#9675; 0 of 3</span></div>
              <div class="key-row"><span class="key-label">Vault backup</span><span class="key-value pending">&#9675; Not configured</span></div>
            </div>
            <button class="btn-primary" id="step5-btn" onclick="finishRegistration()">Go to Dashboard</button>
            <button class="btn-secondary" onclick="finishRegistration()">Set up later</button>
          </div>
        </div>

        <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="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="wallet-section">
          <h4>My Wallet</h4>
          <p style="font-size:0.8rem;color:#64748b;margin-bottom:0.75rem">Your EncryptID-linked wallet for receiving and managing funds.</p>
          <div id="wallet-info" style="font-size:0.85rem;padding:0.75rem;border-radius:0.5rem;background:rgba(255,255,255,0.04);color:#94a3b8">No wallet linked</div>
          <div id="wallet-claims" style="margin-top:0.75rem"></div>
        </div>

        <div class="spaces-section">
          <h4>My Spaces</h4>
          <p style="font-size:0.8rem;color:#64748b;margin-bottom:0.75rem">Communities you belong to and their treasury wallets.</p>
          <div id="spaces-list" style="font-size:0.85rem;color:#94a3b8">Loading...</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://rspace.online/rnotes">rNotes</a>
        <a href="https://rspace.online/rfiles">rFiles</a>
        <a href="https://rspace.online/rcart">rCart</a>
        <a href="https://rspace.online/rflows">rFlows</a>
        <a href="https://rspace.online/rwallet">rWallet</a>
        <a href="https://rspace.online/rauctions">rAuctions</a>
        <a href="https://rspace.online/rpubs">rPubs</a>
        <a href="https://rspace.online/rvote">rVote</a>
        <a href="https://rspace.online/rmaps">rMaps</a>
        <a href="https://rspace.online/rtrips">rTrips</a>
        <a href="https://rspace.online/rtube">rTube</a>
        <a href="https://rspace.online/rinbox">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';

    function detectDeviceName() {
      const ua = navigator.userAgent;
      let browser = 'Unknown browser', os = 'Unknown OS';
      if (/CriOS/i.test(ua)) browser = 'Chrome';
      else if (/FxiOS/i.test(ua)) browser = 'Firefox';
      else if (/Safari/i.test(ua) && !/Chrome/i.test(ua)) browser = 'Safari';
      else if (/Edg/i.test(ua)) browser = 'Edge';
      else if (/Firefox/i.test(ua)) browser = 'Firefox';
      else if (/Chrome/i.test(ua)) browser = 'Chrome';
      if (/iPhone/i.test(ua)) os = 'iPhone';
      else if (/iPad/i.test(ua)) os = 'iPad';
      else if (/Android/i.test(ua)) os = 'Android';
      else if (/Mac OS/i.test(ua)) os = 'macOS';
      else if (/Windows/i.test(ua)) os = 'Windows';
      else if (/Linux/i.test(ua)) os = 'Linux';
      else if (/CrOS/i.test(ua)) os = 'ChromeOS';
      return browser + ' on ' + os;
    }

    const TOKEN_KEY = 'encryptid_token';
    const KNOWN_ACCOUNTS_KEY = 'encryptid-known-accounts';

    function getKnownAccounts() {
      try { return JSON.parse(localStorage.getItem(KNOWN_ACCOUNTS_KEY) || '[]'); }
      catch { return []; }
    }
    function addKnownAccount(username, displayName) {
      const accounts = getKnownAccounts().filter(a => a.username !== username);
      accounts.unshift({ username, displayName: displayName || username });
      localStorage.setItem(KNOWN_ACCOUNTS_KEY, JSON.stringify(accounts));
    }

    // Render known accounts into sign-in fields on load
    function renderSigninAccounts() {
      const container = document.getElementById('signin-fields');
      const accounts = getKnownAccounts();
      if (accounts.length === 0) {
        // No known accounts — passkey-first button
        container.innerHTML = \`
          <button class="btn-primary" id="auth-btn" onclick="handleAuth()" style="font-size:1.1rem;padding:0.9rem">&#128273; Sign in with Passkey</button>
          <div style="text-align:center;margin-top:0.75rem">
            <a href="#" id="show-email-fallback" onclick="toggleEmailFallback(); return false;" style="color:#64748b;font-size:0.8rem;text-decoration:none">No passkey on this device? Sign in with email</a>
          </div>
          <div id="email-fallback-section" style="display:none;margin-top:0.75rem;border-top:1px solid rgba(255,255,255,0.08);padding-top:0.75rem">
            <div class="form-group">
              <label for="signin-email">Email address</label>
              <input id="signin-email" type="email" placeholder="you@example.com" autocomplete="email" />
            </div>
            <button class="btn-secondary" id="magic-link-btn" onclick="sendMagicLink()">Send magic link</button>
            <div id="magic-link-msg" style="font-size:0.8rem;margin-top:0.5rem;display:none"></div>
          </div>\`;
      } else if (accounts.length === 1) {
        const esc = s => s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
        const name = esc(accounts[0].displayName || accounts[0].username);
        container.innerHTML = \`
          <button class="btn-primary" id="auth-btn" onclick="handleAuth('\${esc(accounts[0].username)}')" style="font-size:1.1rem;padding:0.9rem">&#128273; Sign in as \${name}</button>
          <div style="text-align:center;margin-top:0.5rem">
            <a href="#" onclick="handleAuth(); return false;" style="color:#64748b;font-size:0.8rem;text-decoration:none">Use a different account</a>
          </div>
          <div style="text-align:center;margin-top:0.25rem">
            <a href="#" id="show-email-fallback" onclick="toggleEmailFallback(); return false;" style="color:#64748b;font-size:0.8rem;text-decoration:none">Sign in with email instead</a>
          </div>
          <div id="email-fallback-section" style="display:none;margin-top:0.75rem;border-top:1px solid rgba(255,255,255,0.08);padding-top:0.75rem">
            <div class="form-group">
              <label for="signin-email">Email address</label>
              <input id="signin-email" type="email" placeholder="you@example.com" autocomplete="email" />
            </div>
            <button class="btn-secondary" id="magic-link-btn" onclick="sendMagicLink()">Send magic link</button>
            <div id="magic-link-msg" style="font-size:0.8rem;margin-top:0.5rem;display:none"></div>
          </div>\`;
      } else {
        const esc = s => s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
        const items = accounts.map(a => {
          const name = esc(a.displayName || a.username);
          const initial = esc((a.displayName || a.username).slice(0, 2).toUpperCase());
          return \`<button class="btn-secondary" onclick="handleAuth('\${esc(a.username)}')" style="display:flex;align-items:center;gap:0.75rem;padding:0.75rem">
            <span style="width:28px;height:28px;border-radius:50%;background:#7c3aed;display:flex;align-items:center;justify-content:center;font-weight:600;font-size:0.75rem;color:#fff;flex-shrink:0">\${initial}</span>
            <span>\${name}</span>
          </button>\`;
        }).join('');
        container.innerHTML = \`
          <div style="display:flex;flex-direction:column;gap:0.5rem">\${items}</div>
          <div style="text-align:center;margin-top:0.5rem">
            <a href="#" onclick="handleAuth(); return false;" style="color:#64748b;font-size:0.8rem;text-decoration:none">Use a different account</a>
          </div>
          <div style="text-align:center;margin-top:0.25rem">
            <a href="#" id="show-email-fallback" onclick="toggleEmailFallback(); return false;" style="color:#64748b;font-size:0.8rem;text-decoration:none">Sign in with email instead</a>
          </div>
          <div id="email-fallback-section" style="display:none;margin-top:0.75rem;border-top:1px solid rgba(255,255,255,0.08);padding-top:0.75rem">
            <div class="form-group">
              <label for="signin-email">Email address</label>
              <input id="signin-email" type="email" placeholder="you@example.com" autocomplete="email" />
            </div>
            <button class="btn-secondary" id="magic-link-btn" onclick="sendMagicLink()">Send magic link</button>
            <div id="magic-link-msg" style="font-size:0.8rem;margin-top:0.5rem;display:none"></div>
          </div>\`;
      }
    }

    let currentMode = 'signin';

    // Registration stepper state
    let regStep = 1;
    let regData = {}; // stores username, email, userId, serverOptions, prfSalt, clientDid, eoaAddress, token, did

    // 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-stepper').style.display = mode === 'register' ? 'block' : 'none';
      document.getElementById('signin-fields').style.display = mode === 'signin' ? 'block' : 'none';
      document.getElementById('recovery-link-row').style.display = mode === 'signin' ? 'block' : 'none';
      hideMessages();
      if (mode === 'register') {
        regStep = 1;
        updateStepper();
      }
    };

    function updateStepper() {
      for (let i = 1; i <= 5; i++) {
        const stepEl = document.getElementById('reg-step-' + i);
        const dotEl = document.querySelector('.stepper-dot[data-step="' + i + '"]');
        if (stepEl) stepEl.classList.toggle('active', i === regStep);
        if (dotEl) {
          dotEl.classList.toggle('active', i === regStep);
          dotEl.classList.toggle('done', i < regStep);
        }
      }
      for (let i = 1; i <= 4; i++) {
        const lineEl = document.querySelector('.stepper-line[data-line="' + i + '"]');
        if (lineEl) lineEl.classList.toggle('done', i < regStep);
      }
    }

    window.goToStep = (step) => {
      regStep = step;
      updateStepper();
    };

    window.copyDid = (el) => {
      const text = el.querySelector('span')?.textContent || el.textContent;
      navigator.clipboard.writeText(text).then(() => {
        const toast = el.querySelector('.copy-toast');
        if (toast) { toast.textContent = 'copied!'; setTimeout(() => toast.textContent = 'click to copy', 1500); }
      });
    };

    // Step 1: Validate username
    window.handleStep1 = async () => {
      const username = document.getElementById('username').value.trim();
      if (!username) { showError('Username is required'); return; }
      if (username.length < 2) { showError('Username must be at least 2 characters'); return; }
      const email = document.getElementById('reg-email').value.trim();
      const btn = document.getElementById('step1-btn');
      btn.disabled = true;
      btn.textContent = 'Checking...';
      hideMessages();
      try {
        // Check username availability
        const checkRes = await fetch('/api/register/start', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ username, displayName: username }),
        });
        if (!checkRes.ok) {
          const err = await checkRes.json().catch(() => ({}));
          throw new Error(err.error || 'Username may be taken');
        }
        const { options, userId, prfSalt } = await checkRes.json();
        regData = { username, email, userId, serverOptions: options, prfSalt };
        goToStep(2);
      } catch (err) {
        showError(err.message);
      } finally {
        btn.disabled = false;
        btn.textContent = 'Continue';
      }
    };

    // Step 2: Create passkey with PRF
    window.handleStep2 = async () => {
      const btn = document.getElementById('step2-btn');
      btn.disabled = true;
      btn.textContent = 'Waiting for authenticator...';
      hideMessages();
      try {
        const { serverOptions, prfSalt, username, email, userId } = regData;

        // Build PRF extension
        const prfExtension = prfSalt ? {
          prf: { eval: { first: new Uint8Array(base64urlToBuffer(prfSalt)) } },
        } : {};

        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,
            extensions: { credProps: true, ...prfExtension },
          },
        });
        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?.() || [],
        };

        // Check PRF results
        let clientDid = null, eoaAddress = null, prfSupported = false;
        const extResults = credential.getClientExtensionResults?.();
        const prfResults = extResults?.prf?.results;
        if (prfResults?.first) {
          prfSupported = true;
          try {
            const km = getKeyManager();
            await km.initFromPRF(prfResults.first);
            const keys = await km.getKeys();
            clientDid = keys.did;
            eoaAddress = keys.eoaAddress;
          } catch (e) {
            console.warn('EncryptID: PRF key derivation failed', e);
          }
        }

        // Complete registration with server
        const regBody = {
          challenge: serverOptions.challenge,
          credential: credentialData,
          userId, username,
        };
        regBody.deviceName = detectDeviceName();
        if (email) regBody.email = email;
        if (clientDid) regBody.clientDid = clientDid;
        if (eoaAddress) regBody.eoaAddress = eoaAddress;

        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');

        // Save to regData for subsequent steps
        regData.token = data.token;
        regData.did = data.did;
        regData.clientDid = clientDid;
        regData.eoaAddress = eoaAddress;
        regData.prfSupported = prfSupported;
        localStorage.setItem(TOKEN_KEY, data.token);

        // Populate step 3
        if (prfSupported && clientDid) {
          document.getElementById('step3-did-text').textContent = clientDid;
          document.getElementById('step3-prf-content').style.display = 'block';
          document.getElementById('step3-no-prf').style.display = 'none';
        } else {
          document.getElementById('step3-prf-content').style.display = 'none';
          document.getElementById('step3-no-prf').style.display = 'block';
        }

        // Populate step 4
        if (prfSupported && eoaAddress) {
          document.getElementById('step4-eoa').textContent = eoaAddress;
          document.getElementById('step4-prf-content').style.display = 'block';
          document.getElementById('step4-no-prf').style.display = 'none';
        } else {
          document.getElementById('step4-prf-content').style.display = 'none';
          document.getElementById('step4-no-prf').style.display = 'block';
        }

        // Populate step 5
        if (email) {
          document.getElementById('step5-email').textContent = '&#10003; ' + email;
          document.getElementById('step5-email').classList.remove('pending');
        }

        // PRF: show key derivation step; non-PRF: show passphrase setup step (3) then skip wallet (4)
        goToStep(prfSupported ? 3 : 3);
      } catch (err) {
        showError(err.message);
        btn.textContent = 'Create Passkey';
        btn.disabled = false;
      }
    };

    window.handleStep3Continue = async () => {
      if (regData.prfSupported) {
        goToStep(4);
        return;
      }
      // Non-PRF: validate and save passphrase
      const pp = document.getElementById('encryption-passphrase')?.value || '';
      const ppConfirm = document.getElementById('encryption-passphrase-confirm')?.value || '';
      if (!pp || pp.length < 8) {
        showError('Passphrase must be at least 8 characters.');
        return;
      }
      if (pp !== ppConfirm) {
        showError('Passphrases do not match.');
        return;
      }
      hideMessages();
      try {
        // Generate salt, store server-side
        const salt = crypto.getRandomValues(new Uint8Array(32));
        const saltB64 = bufferToBase64url(salt);
        await fetch('/api/account/passphrase-salt', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + regData.token },
          body: JSON.stringify({ salt: saltB64 }),
        });
        // Init encryption from passphrase
        const km = getKeyManager();
        await km.initFromPassphrase(pp, salt);
        showSuccess('Encryption passphrase set successfully.');
      } catch (e) {
        console.warn('Passphrase setup failed:', e);
      }
      // Skip wallet step (no PRF = no EOA wallet)
      goToStep(5);
    };

    window.finishRegistration = () => {
      const { token, username, did, prfSupported, clientDid, eoaAddress } = regData;
      showProfile(token, username, did, { prfSupported, clientDid, eoaAddress });
    };

    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.innerHTML = data.message || 'Recovery request sent. Check with your guardians.';
        if (data.requestId) {
          msgEl.innerHTML += '<br><br><a href="/recover/social?id=' + encodeURIComponent(data.requestId) + '" style="color:#00d4ff;">Track your recovery progress &rarr;</a>';
        }
        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';
      }
    };

    // Toggle email fallback visibility
    window.toggleEmailFallback = () => {
      const section = document.getElementById('email-fallback-section');
      const link = document.getElementById('show-email-fallback');
      if (section.style.display === 'none') {
        section.style.display = 'block';
        link.style.display = 'none';
      } else {
        section.style.display = 'none';
        link.style.display = '';
      }
    };

    // handleAuth — optionally scoped to a known username, unscoped if omitted
    window.handleAuth = async (username) => {
      const btn = document.getElementById('auth-btn');
      if (btn) { btn.disabled = true; btn.textContent = 'Waiting for passkey...'; }
      hideMessages();

      try {
        // If username provided, scope credentials to that user; otherwise unscoped
        const authBody = username ? { username } : {};
        const startRes = await fetch('/api/auth/start', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(authBody),
        });
        if (!startRes.ok) throw new Error('Failed to start authentication');
        const { options: serverOptions, prfSalt } = await startRes.json();

        // Build PRF extension for sign-in
        const prfExtension = prfSalt ? {
          prf: { eval: { first: new Uint8Array(base64urlToBuffer(prfSalt)) } },
        } : {};

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

        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);

        // Check for PRF output and auto-upgrade DID if needed
        const extResults = credential.getClientExtensionResults?.();
        const prfResults = extResults?.prf?.results;
        if (prfResults?.first) {
          try {
            const km = getKeyManager();
            await km.initFromPRF(prfResults.first);
            const keys = await km.getKeys();
            // Check if server DID is truncated (doesn't start with z6Mk)
            const serverDid = data.did || '';
            const didPart = serverDid.replace('did:key:', '');
            if (keys.did && !didPart.startsWith('z6Mk') && !didPart.startsWith('z')) {
              // Auto-upgrade DID
              try {
                const upgradeRes = await fetch('/api/account/upgrade-did', {
                  method: 'POST',
                  headers: {
                    'Content-Type': 'application/json',
                    'Authorization': 'Bearer ' + data.token,
                  },
                  body: JSON.stringify({
                    clientDid: keys.did,
                    eoaAddress: keys.eoaAddress,
                  }),
                });
                if (upgradeRes.ok) {
                  const upgradeData = await upgradeRes.json();
                  if (upgradeData.token) {
                    localStorage.setItem(TOKEN_KEY, upgradeData.token);
                    data.token = upgradeData.token;
                    data.did = keys.did;
                    console.log('EncryptID: DID auto-upgraded to', keys.did);
                  }
                }
              } catch (e) {
                console.warn('EncryptID: DID upgrade failed', e);
              }
            }
          } catch (e) {
            console.warn('EncryptID: PRF key derivation during sign-in failed', e);
          }
        }

        // Non-PRF: check for passphrase salt and prompt for passphrase
        if (!prfResults?.first) {
          try {
            const saltRes = await fetch('/api/account/passphrase-salt', {
              headers: { 'Authorization': 'Bearer ' + data.token },
            });
            if (saltRes.ok) {
              const { salt } = await saltRes.json();
              const pp = prompt('Enter your encryption passphrase:');
              if (pp) {
                const km = getKeyManager();
                await km.initFromPassphrase(pp, new Uint8Array(base64urlToBuffer(salt)));
              }
            }
          } catch (e) {
            console.warn('EncryptID: Passphrase salt fetch failed', e);
          }
        }

        // Remember this account for next login
        if (data.username) addKnownAccount(data.username, data.username);

        showProfile(data.token, data.username, data.did);
      } catch (err) {
        if (err.name === 'NotAllowedError') {
          showError('Passkey dismissed. Try again, or sign in with email.');
          // Auto-show email fallback
          const fb = document.getElementById('email-fallback-section');
          const link = document.getElementById('show-email-fallback');
          if (fb) fb.style.display = 'block';
          if (link) link.style.display = 'none';
        } else if (err.name === 'NotSupportedError') {
          showError('Passkeys are not supported on this browser. Use email to sign in.');
          const fb = document.getElementById('email-fallback-section');
          const link = document.getElementById('show-email-fallback');
          if (fb) fb.style.display = 'block';
          if (link) link.style.display = 'none';
        } else if (err.name === 'AbortError') {
          // Silent — conditional UI abort
        } else {
          showError(err.message || 'Authentication failed');
        }
        if (btn) { btn.innerHTML = '&#128273; Sign in with Passkey'; btn.disabled = false; }
      }
    };

    // Magic link: send login email
    window.sendMagicLink = async () => {
      const email = document.getElementById('signin-email').value.trim();
      const msgEl = document.getElementById('magic-link-msg');
      const btn = document.getElementById('magic-link-btn');
      if (!email || !email.includes('@')) {
        msgEl.textContent = 'Enter your email address above first';
        msgEl.style.color = '#fca5a5';
        msgEl.style.display = 'block';
        return;
      }
      btn.disabled = true;
      btn.textContent = 'Sending...';
      try {
        const res = await fetch('/api/auth/magic-link', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ email }),
        });
        const data = await res.json();
        msgEl.textContent = data.message || 'Check your email for a login link.';
        msgEl.style.color = '#86efac';
        msgEl.style.display = 'block';
        btn.textContent = 'Link sent!';
      } catch (err) {
        msgEl.textContent = 'Failed to send: ' + err.message;
        msgEl.style.color = '#fca5a5';
        msgEl.style.display = 'block';
        btn.textContent = 'Email me a login link';
        btn.disabled = false;
      }
    };

    async function showProfile(token, username, did, extra = {}) {
      // If there's a redirect param, navigate there after login
      const redirectUrl = new URLSearchParams(location.search).get('redirect');
      if (redirectUrl && redirectUrl.startsWith('/')) {
        location.href = redirectUrl;
        return;
      }

      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 and render unified device list
      try {
        const res = await fetch('/api/user/credentials', {
          headers: { 'Authorization': 'Bearer ' + token },
        });
        const data = await res.json();
        window._credentialsData = data.credentials || [];
        renderDeviceList(data.credentials || []);
      } catch { /* ignore */ }

      // Load guardians, vault status, wallet, and spaces
      loadGuardians();
      checkVaultStatus();
      loadWalletInfo(token, username);
      loadUserSpaces(token);
    }

    async function loadWalletInfo(token, username) {
      var walletEl = document.getElementById('wallet-info');
      var claimsEl = document.getElementById('wallet-claims');
      try {
        var res = await fetch('/api/user/profile', {
          headers: { 'Authorization': 'Bearer ' + token },
        });
        var data = await res.json();
        if (data.profile && data.profile.walletAddress) {
          var addr = data.profile.walletAddress;
          var short = addr.slice(0, 6) + '...' + addr.slice(-4);
          var rwalletUrl = 'https://' + (username || 'demo') + '.rspace.online/rwallet?address=' + encodeURIComponent(addr);
          walletEl.innerHTML =
            '<div class="wallet-card">' +
            '<div class="wallet-label">Linked Wallet</div>' +
            '<div class="wallet-addr"><a href="' + rwalletUrl + '" target="_blank">' + short + '</a></div>' +
            '</div>';
        } else {
          walletEl.innerHTML = '<div style="color:#64748b;font-size:0.85rem">No wallet linked yet. Complete a fund claim or add one manually.</div>';
        }

        // Check for pending fund claims
        var claimRes = await fetch('/api/user/claims', {
          headers: { 'Authorization': 'Bearer ' + token },
        });
        if (claimRes.ok) {
          var claimData = await claimRes.json();
          if (claimData.claims && claimData.claims.length > 0) {
            claimsEl.innerHTML = claimData.claims.map(function(cl) {
              return '<div class="wallet-card" style="border-left:3px solid #f59e0b">' +
                '<div class="wallet-label">Pending Claim</div>' +
                '<div class="wallet-balance">$' + (cl.fiatAmount || '0') + ' ' + (cl.fiatCurrency || 'USD') + '</div>' +
                '<div style="font-size:0.75rem;color:#64748b;margin-top:0.25rem">Expires ' + new Date(cl.expiresAt).toLocaleDateString() + '</div>' +
                '<a href="/claim?token=' + cl.token + '" class="btn-secondary" style="margin-top:0.5rem;display:inline-block;font-size:0.8rem;padding:0.35rem 0.75rem">Claim Now</a>' +
                '</div>';
            }).join('');
          }
        }
      } catch (err) {
        console.error('Failed to load wallet info:', err);
      }
    }

    async function loadUserSpaces(token) {
      var spacesEl = document.getElementById('spaces-list');
      try {
        var res = await fetch('/api/user/spaces', {
          headers: { 'Authorization': 'Bearer ' + token },
        });
        var data = await res.json();
        if (!data.spaces || data.spaces.length === 0) {
          spacesEl.innerHTML = '<div style="color:#64748b;font-size:0.85rem">You are not a member of any spaces yet.</div>';
          return;
        }

        spacesEl.innerHTML = data.spaces.map(function(s) {
          var spaceUrl = 'https://' + s.spaceSlug + '.rspace.online';
          var walletUrl = spaceUrl + '/rwallet';
          return '<div class="space-card">' +
            '<div>' +
            '<div class="space-name"><a href="' + spaceUrl + '" target="_blank">' + s.spaceSlug + '</a></div>' +
            '<div class="space-wallet"><a href="' + walletUrl + '" target="_blank">View Treasury</a></div>' +
            '</div>' +
            '<div class="space-role">' + s.role + '</div>' +
            '</div>';
        }).join('');
      } catch (err) {
        spacesEl.innerHTML = '<div style="color:#64748b;font-size:0.85rem">Could not load spaces.</div>';
        console.error('Failed to load spaces:', err);
      }
    }

    window.handleLogout = () => {
      // Notify server so other browser sessions are revoked
      const token = localStorage.getItem(TOKEN_KEY);
      if (token) {
        fetch('/api/session/logout', {
          method: 'POST',
          headers: { Authorization: 'Bearer ' + token },
        }).catch(() => {});
      }
      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 data-guardian-id="' + g.id + '" style="background:none;border:none;color:#64748b;cursor:pointer;font-size:0.8rem;" title="Remove">&times;</button>' +
            '</span></div>'
          ).join('');
          // Attach click handlers for remove buttons via data attributes
          list.querySelectorAll('[data-guardian-id]').forEach(btn => {
            btn.addEventListener('click', () => removeGuardian(btn.dataset.guardianId));
          });
          // 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';
      }
    }

    // ── Device list rendering ──

    function timeAgo(ts) {
      if (!ts) return 'Never';
      const diff = Date.now() - ts;
      const mins = Math.floor(diff / 60000);
      if (mins < 1) return 'Just now';
      if (mins < 60) return mins + 'm ago';
      const hrs = Math.floor(mins / 60);
      if (hrs < 24) return hrs + 'h ago';
      const days = Math.floor(hrs / 24);
      if (days < 30) return days + 'd ago';
      return new Date(ts).toLocaleDateString();
    }

    function renderDeviceList(credentials) {
      const list = document.getElementById('device-list');
      if (!credentials || credentials.length === 0) {
        list.innerHTML = '<p style="color:#64748b;font-size:0.8rem;">No devices found</p>';
        return;
      }
      const token = localStorage.getItem(TOKEN_KEY);
      const canDelete = credentials.length > 1;
      list.innerHTML = credentials.map((c, i) => {
        const name = c.label || 'Unknown device';
        const isActive = !!c.lastUsed;
        const statusClass = isActive ? 'active' : 'pending';
        const statusText = isActive ? 'Active' : 'Not yet confirmed';
        const created = c.createdAt ? new Date(c.createdAt).toLocaleDateString() : '';
        const lastUsed = timeAgo(c.lastUsed);
        return '<div class="device-item" id="device-' + i + '">' +
          '<div class="device-item-header">' +
            '<span class="device-name" id="device-name-' + i + '">' + escapeText(name) + '</span>' +
            '<span class="device-status ' + statusClass + '">' + statusText + '</span>' +
          '</div>' +
          '<div class="device-meta">Created ' + created + ' &middot; Last used: ' + lastUsed + '</div>' +
          '<div class="device-actions">' +
            '<button onclick="renameDevice(' + i + ')" title="Rename">&#9998; Rename</button>' +
            (canDelete ? '<button class="danger" onclick="deleteDevice(' + i + ')" title="Delete">&#128465; Delete</button>' : '') +
          '</div>' +
        '</div>';
      }).join('');
    }

    function escapeText(s) {
      const d = document.createElement('div');
      d.textContent = s;
      return d.innerHTML;
    }

    async function renameDevice(idx) {
      const creds = window._credentialsData;
      if (!creds || !creds[idx]) return;
      const cred = creds[idx];
      const nameEl = document.getElementById('device-name-' + idx);
      const currentName = cred.label || '';
      nameEl.innerHTML = '<input class="device-rename-input" id="rename-input-' + idx + '" value="' + escapeText(currentName) + '" maxlength="100" />';
      const input = document.getElementById('rename-input-' + idx);
      input.focus();
      input.select();
      input.addEventListener('keydown', async (e) => {
        if (e.key === 'Enter') await saveRename(idx);
        if (e.key === 'Escape') renderDeviceList(creds);
      });
      input.addEventListener('blur', () => saveRename(idx));
    }

    async function saveRename(idx) {
      const input = document.getElementById('rename-input-' + idx);
      if (!input) return;
      const creds = window._credentialsData;
      const cred = creds[idx];
      const newLabel = input.value.trim();
      if (!newLabel || newLabel === (cred.label || '')) {
        renderDeviceList(creds);
        return;
      }
      const token = localStorage.getItem(TOKEN_KEY);
      try {
        const res = await fetch('/api/user/credentials/' + encodeURIComponent(cred.credentialId), {
          method: 'PATCH',
          headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
          body: JSON.stringify({ label: newLabel }),
        });
        if (res.ok) {
          cred.label = newLabel;
        }
      } catch { /* ignore */ }
      renderDeviceList(creds);
    }

    async function deleteDevice(idx) {
      const creds = window._credentialsData;
      if (!creds || !creds[idx]) return;
      if (creds.length <= 1) { alert('Cannot delete your only device'); return; }
      const cred = creds[idx];
      const name = cred.label || 'this device';
      if (!confirm('Delete ' + name + '? This cannot be undone.')) return;
      const token = localStorage.getItem(TOKEN_KEY);
      try {
        const res = await fetch('/api/user/credentials/' + encodeURIComponent(cred.credentialId), {
          method: 'DELETE',
          headers: { 'Authorization': 'Bearer ' + token },
        });
        if (res.ok) {
          creds.splice(idx, 1);
          renderDeviceList(creds);
        } else {
          const data = await res.json().catch(() => ({}));
          alert(data.error || 'Failed to delete device');
        }
      } catch { alert('Failed to delete device'); }
    }

    // ── 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 — only count confirmed devices (those with lastUsed set)
      const creds = window._credentialsData || [];
      const confirmedCount = creds.filter(c => !!c.lastUsed).length;
      const deviceIcon = document.getElementById('check-device');
      const deviceText = document.getElementById('check-device-text');
      if (confirmedCount > 1) {
        deviceIcon.className = 'check-icon done';
        deviceIcon.innerHTML = '&#10003;';
        deviceText.textContent = confirmedCount + ' devices confirmed';
      } else if (creds.length > 1) {
        deviceIcon.className = 'check-icon todo';
        deviceIcon.innerHTML = creds.length.toString();
        deviceText.textContent = creds.length + ' devices linked (sign in on new device to confirm)';
      }
      // 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: render known accounts + handle query params + check token
    (async () => {
      renderSigninAccounts();

      const params = new URLSearchParams(location.search);
      if (params.get('tab') === 'register') switchTab('register');

      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>
  `);
});

// ============================================================================
// DELEGATION ROUTES (person-to-person liquid democracy)
// ============================================================================

const VALID_AUTHORITIES = ['gov-ops', 'fin-ops', 'dev-ops', 'custom'];

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

  const { delegateDid, authority, weight, spaceSlug, maxDepth, retainAuthority, customScope, expiresAt } = await c.req.json();

  if (!delegateDid || !authority || !spaceSlug || weight == null) {
    return c.json({ error: 'delegateDid, authority, weight, and spaceSlug are required' }, 400);
  }

  if (!VALID_AUTHORITIES.includes(authority)) {
    return c.json({ error: `authority must be one of: ${VALID_AUTHORITIES.join(', ')}` }, 400);
  }

  const w = parseFloat(weight);
  if (isNaN(w) || w <= 0 || w > 1) {
    return c.json({ error: 'weight must be between 0 (exclusive) and 1 (inclusive)' }, 400);
  }

  const delegatorDid = claims.did as string;

  if (delegateDid === delegatorDid) {
    return c.json({ error: 'Cannot delegate to yourself' }, 400);
  }

  // Check weight sum doesn't exceed 1.0
  const currentTotal = await getTotalDelegatedWeight(delegatorDid, authority, spaceSlug);
  if (currentTotal + w > 1.0 + 0.001) { // small epsilon for float
    return c.json({ error: `Total delegation weight would exceed 100% (current: ${Math.round(currentTotal * 100)}%, requested: ${Math.round(w * 100)}%)` }, 400);
  }

  try {
    const id = crypto.randomUUID();
    const delegation = await createDelegation({
      id,
      delegatorDid,
      delegateDid,
      authority,
      weight: w,
      maxDepth: maxDepth ?? 3,
      retainAuthority: retainAuthority ?? true,
      spaceSlug,
      customScope,
      expiresAt,
    });

    // Log trust event
    await logTrustEvent({
      id: crypto.randomUUID(),
      sourceDid: delegatorDid,
      targetDid: delegateDid,
      eventType: 'delegation_created',
      authority,
      weightDelta: w,
      spaceSlug,
    });

    // Notify delegate
    try {
      await notify({
        userDid: delegateDid,
        category: 'social',
        eventType: 'delegation_received',
        title: 'New delegation received',
        body: `${claims.username || 'Someone'} delegated ${Math.round(w * 100)}% ${authority} authority to you`,
        spaceSlug,
        actorDid: delegatorDid,
        actorUsername: claims.username,
      });
    } catch { /* notification delivery is best-effort */ }

    return c.json({ success: true, delegation });
  } catch (err: any) {
    if (err.message?.includes('unique') || err.code === '23505') {
      return c.json({ error: 'Delegation already exists for this authority in this space' }, 409);
    }
    console.error('[delegations] Create error:', err.message);
    return c.json({ error: 'Failed to create delegation' }, 500);
  }
});

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

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

  const delegatorDid = claims.did as string;
  const delegations = await listDelegationsFrom(delegatorDid, spaceSlug);
  return c.json({ delegations });
});

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

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

  const delegateDid = claims.did as string;
  const delegations = await listDelegationsTo(delegateDid, spaceSlug);
  return c.json({ delegations });
});

// PATCH /api/delegations/:id — update weight/state
app.patch('/api/delegations/:id', async (c) => {
  const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
  if (!claims) return c.json({ error: 'Unauthorized' }, 401);

  const id = c.req.param('id');
  const existing = await getDelegation(id);
  if (!existing) return c.json({ error: 'Delegation not found' }, 404);

  const delegatorDid = claims.did as string;
  if (existing.delegatorDid !== delegatorDid) {
    return c.json({ error: 'Only the delegator can modify a delegation' }, 403);
  }

  const { weight, state, maxDepth, retainAuthority, expiresAt } = await c.req.json();

  // Validate weight sum if weight is being changed
  if (weight != null) {
    const w = parseFloat(weight);
    if (isNaN(w) || w <= 0 || w > 1) {
      return c.json({ error: 'weight must be between 0 (exclusive) and 1 (inclusive)' }, 400);
    }
    const currentTotal = await getTotalDelegatedWeight(delegatorDid, existing.authority, existing.spaceSlug);
    const adjustedTotal = currentTotal - existing.weight + w;
    if (adjustedTotal > 1.0 + 0.001) {
      return c.json({ error: `Total delegation weight would exceed 100%` }, 400);
    }
  }

  const updated = await updateDelegation(id, {
    weight: weight != null ? parseFloat(weight) : undefined,
    state,
    maxDepth,
    retainAuthority,
    expiresAt,
  });

  if (!updated) return c.json({ error: 'Update failed' }, 500);

  // Log trust event
  const eventType = state === 'paused' ? 'delegation_paused'
    : state === 'active' ? 'delegation_resumed'
    : weight != null && weight > existing.weight ? 'delegation_increased'
    : weight != null && weight < existing.weight ? 'delegation_decreased'
    : null;

  if (eventType) {
    await logTrustEvent({
      id: crypto.randomUUID(),
      sourceDid: delegatorDid,
      targetDid: existing.delegateDid,
      eventType,
      authority: existing.authority,
      weightDelta: weight != null ? parseFloat(weight) - existing.weight : null,
      spaceSlug: existing.spaceSlug,
    });
  }

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

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

  const id = c.req.param('id');
  const existing = await getDelegation(id);
  if (!existing) return c.json({ error: 'Delegation not found' }, 404);

  const delegatorDid = claims.did as string;
  if (existing.delegatorDid !== delegatorDid) {
    return c.json({ error: 'Only the delegator can revoke a delegation' }, 403);
  }

  const revoked = await revokeDelegation(id);
  if (!revoked) return c.json({ error: 'Revoke failed' }, 500);

  // Log trust event
  await logTrustEvent({
    id: crypto.randomUUID(),
    sourceDid: delegatorDid,
    targetDid: existing.delegateDid,
    eventType: 'delegation_revoked',
    authority: existing.authority,
    weightDelta: -existing.weight,
    spaceSlug: existing.spaceSlug,
  });

  // Notify delegate
  try {
    await notify({
      userDid: existing.delegateDid,
      category: 'social',
      eventType: 'delegation_revoked',
      title: 'Delegation revoked',
      body: `${claims.username || 'Someone'} revoked their ${existing.authority} delegation to you`,
      spaceSlug: existing.spaceSlug,
      actorDid: delegatorDid,
      actorUsername: claims.username,
    });
  } catch { /* best-effort */ }

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

// GET /api/delegations/space — all active (or all incl. revoked) delegations for a space (internal use, no auth)
app.get('/api/delegations/space', async (c) => {
  const spaceSlug = c.req.query('space');
  if (!spaceSlug) return c.json({ error: 'space query param required' }, 400);
  const authority = c.req.query('authority');
  const includeRevoked = c.req.query('include_revoked') === 'true';

  let delegations;
  if (includeRevoked) {
    // Query all delegations regardless of state
    const rows = authority
      ? await sql`
          SELECT * FROM delegations
          WHERE space_slug = ${spaceSlug} AND authority = ${authority}
          ORDER BY created_at DESC
        `
      : await sql`
          SELECT * FROM delegations
          WHERE space_slug = ${spaceSlug}
          ORDER BY created_at DESC
        `;
    delegations = rows.map((r: any) => ({
      id: r.id,
      from: r.delegator_did,
      to: r.delegate_did,
      authority: r.authority,
      weight: parseFloat(r.weight),
      state: r.state,
      revokedAt: r.state === 'revoked' ? new Date(r.updated_at).getTime() : null,
    }));
  } else {
    const active = await listActiveDelegations(spaceSlug, authority || undefined);
    delegations = active.map(d => ({
      id: d.id,
      from: d.delegatorDid,
      to: d.delegateDid,
      authority: d.authority,
      weight: d.weight,
      state: d.state,
      revokedAt: null,
    }));
  }

  return c.json({
    delegations,
    space: spaceSlug,
    authority: authority || 'all',
  });
});

// POST /api/trust/endorse — log an endorsement trust event
app.post('/api/trust/endorse', async (c) => {
  const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
  if (!claims) return c.json({ error: 'Unauthorized' }, 401);

  const body = await c.req.json().catch(() => null);
  if (!body) return c.json({ error: 'Invalid JSON body' }, 400);

  const { targetDid, authority, weight, space } = body;
  if (!targetDid || typeof targetDid !== 'string')
    return c.json({ error: 'targetDid required (string)' }, 400);
  if (!space || typeof space !== 'string')
    return c.json({ error: 'space required (string)' }, 400);
  const w = typeof weight === 'number' ? Math.max(0, Math.min(1, weight)) : 0.5;
  const auth = typeof authority === 'string' ? authority : 'gov-ops';

  const event = {
    id: crypto.randomUUID(),
    sourceDid: claims.did || `did:key:${claims.sub.slice(0, 32)}`,
    targetDid,
    eventType: 'endorsement' as const,
    authority: auth,
    weightDelta: w,
    spaceSlug: space,
  };

  await logTrustEvent(event);
  return c.json({ success: true, event });
});

// GET /api/trust/scores — aggregated trust scores for visualization
app.get('/api/trust/scores', async (c) => {
  const authority = c.req.query('authority') || 'gov-ops';
  const spaceSlug = c.req.query('space');
  if (!spaceSlug) return c.json({ error: 'space query parameter required' }, 400);

  const scores = await getAggregatedTrustScores(spaceSlug, authority);
  return c.json({ scores, authority, space: spaceSlug });
});

// GET /api/trust/scores/:did — trust profile for one user
app.get('/api/trust/scores/:did', async (c) => {
  const did = c.req.param('did');
  const spaceSlug = c.req.query('space');
  if (!spaceSlug) return c.json({ error: 'space query parameter required' }, 400);

  const scores = await getTrustScoresByAuthority(did, spaceSlug);
  return c.json({ did, scores, space: spaceSlug });
});

// GET /api/trust/events — space-level trust event history
app.get('/api/trust/events', async (c) => {
  const spaceSlug = c.req.query('space');
  if (!spaceSlug) return c.json({ error: 'space query parameter required' }, 400);
  const limit = parseInt(c.req.query('limit') || '200');
  const events = await getTrustEventsSince(spaceSlug, Date.now() - 90 * 24 * 60 * 60 * 1000);
  // Sort by created_at desc and limit
  const limited = events.sort((a, b) => b.createdAt - a.createdAt).slice(0, Math.min(limit, 500));
  return c.json({ events: limited, space: spaceSlug });
});

// GET /api/trust/events/:did — event history for one user
app.get('/api/trust/events/:did', async (c) => {
  const did = c.req.param('did');
  const spaceSlug = c.req.query('space');
  if (!spaceSlug) return c.json({ error: 'space query parameter required' }, 400);

  const events = await getTrustEvents(did, spaceSlug);
  return c.json({ did, events, space: spaceSlug });
});

// GET /api/users/directory — all users for rNetwork with trust metadata
app.get('/api/users/directory', async (c) => {
  const spaceSlug = c.req.query('space');
  if (!spaceSlug) return c.json({ error: 'space query parameter required' }, 400);

  const users = await listAllUsersWithTrust(spaceSlug);
  return c.json({ users, space: spaceSlug });
});

// ============================================================================
// POWER INDICES (Banzhaf, Shapley-Shubik coalitional power)
// ============================================================================

// GET /api/power-indices — full power analysis for a space+authority
app.get('/api/power-indices', async (c) => {
  const spaceSlug = c.req.query('space');
  if (!spaceSlug) return c.json({ error: 'space query parameter required' }, 400);
  const authority = c.req.query('authority') || 'gov-ops';

  // Try precomputed results from DB first
  let indices = await getPowerIndices(spaceSlug, authority);

  // On-demand computation if DB is empty (first load, demo space, etc.)
  if (indices.length === 0) {
    try {
      const players = await resolveVotingWeights(spaceSlug, authority);
      if (players.length >= 2) {
        const analysis = computePowerIndices(players, spaceSlug, authority);
        // Persist for next time
        for (const r of analysis.results) {
          await upsertPowerIndex({
            did: r.did, spaceSlug, authority,
            rawWeight: r.weight, banzhaf: r.banzhaf, shapleyShubik: r.shapleyShubik,
            swingCount: r.swingCount, pivotalCount: r.pivotalCount,
            giniCoefficient: analysis.giniCoefficient, herfindahlIndex: analysis.herfindahlIndex,
          });
        }
        indices = await getPowerIndices(spaceSlug, authority);
      }
    } catch (err) {
      console.error(`[power-indices] On-demand computation failed:`, (err as Error).message);
    }
  }

  if (indices.length === 0) {
    return c.json({ results: [], space: spaceSlug, authority, message: 'No delegation data for power analysis' });
  }

  const totalWeight = indices.reduce((a, i) => a + i.rawWeight, 0);

  return c.json({
    space: spaceSlug,
    authority,
    totalWeight,
    playerCount: indices.length,
    giniCoefficient: indices[0]?.giniCoefficient ?? 0,
    herfindahlIndex: indices[0]?.herfindahlIndex ?? 0,
    lastComputed: indices[0]?.lastComputed ?? 0,
    results: indices.map(i => ({
      did: i.did,
      weight: i.rawWeight,
      banzhaf: i.banzhaf,
      shapleyShubik: i.shapleyShubik,
      swingCount: i.swingCount,
      pivotalCount: i.pivotalCount,
    })),
  });
});

// GET /api/power-indices/:did — one user's power profile across all authorities
app.get('/api/power-indices/:did', async (c) => {
  const did = c.req.param('did');
  const spaceSlug = c.req.query('space');
  if (!spaceSlug) return c.json({ error: 'space query parameter required' }, 400);

  const indices = await getPowerIndex(did, spaceSlug);
  return c.json({
    did,
    space: spaceSlug,
    authorities: indices.map(i => ({
      authority: i.authority,
      weight: i.rawWeight,
      banzhaf: i.banzhaf,
      shapleyShubik: i.shapleyShubik,
      swingCount: i.swingCount,
      pivotalCount: i.pivotalCount,
      giniCoefficient: i.giniCoefficient,
      herfindahlIndex: i.herfindahlIndex,
      lastComputed: i.lastComputed,
    })),
  });
});

// POST /api/power-indices/simulate — "what if" coalition simulation
app.post('/api/power-indices/simulate', async (c) => {
  const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
  if (!claims) return c.json({ error: 'Unauthorized' }, 401);

  const body = await c.req.json().catch(() => null);
  if (!body) return c.json({ error: 'Invalid JSON body' }, 400);

  const { space, authority, coalition } = body;
  if (!space || typeof space !== 'string') return c.json({ error: 'space required' }, 400);
  if (!Array.isArray(coalition) || coalition.length === 0) return c.json({ error: 'coalition array required' }, 400);

  const auth = typeof authority === 'string' ? authority : 'gov-ops';
  const players = await resolveVotingWeights(space, auth);
  if (players.length === 0) return c.json({ error: 'No voting weights found for this space' }, 404);

  const result = simulateCoalition(players, coalition, space, auth);
  return c.json(result);
});

// ============================================================================
// 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();
      break;
    } 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));
    }
  }

  // Seed OIDC clients from environment (after DB is ready)
  try {
    const clientsJson = process.env.OIDC_CLIENTS;
    if (clientsJson) {
      const clients = JSON.parse(clientsJson);
      await seedOidcClients(clients);
      console.log(`EncryptID: Seeded ${clients.length} OIDC client(s)`);
    }
  } catch (err) {
    console.error('EncryptID: Failed to seed OIDC clients:', (err as Error).message);
  }

  // Initialize OIDC RS256 keys
  await initOidcKeys();

  // Start trust engine background job (recomputes scores every 5 min)
  startTrustEngine();
})();

// ============================================================================
// SPACE EMAIL ALIAS ROUTES
// ============================================================================

// Internal: provision alias for a space (called on space creation + startup)
app.post('/api/internal/spaces/:slug/alias', async (c) => {
  const slug = c.req.param('slug');
  try {
    await provisionSpaceAlias(slug);
    return c.json({ ok: true });
  } catch (e) {
    console.error(`[SpaceAlias] Provision failed for ${slug}:`, e);
    return c.json({ error: (e as Error).message }, 500);
  }
});

// Internal: sync alias recipients (called on member add/role change)
app.post('/api/internal/spaces/:slug/alias/sync', async (c) => {
  const slug = c.req.param('slug');
  try {
    const body = await c.req.json<{ userDid?: string; role?: string }>().catch(() => ({}));
    // If a member is specified, seed their opt-in preference first
    if (body.userDid && body.role) {
      const optIn = body.role === 'admin' || body.role === 'moderator';
      await upsertSpaceEmailForwarding(slug, body.userDid, optIn);
    }
    await syncSpaceAlias(slug);
    return c.json({ ok: true });
  } catch (e) {
    console.error(`[SpaceAlias] Sync failed for ${slug}:`, e);
    return c.json({ error: (e as Error).message }, 500);
  }
});

// Internal: deprovision alias (called on space deletion)
app.delete('/api/internal/spaces/:slug/alias', async (c) => {
  const slug = c.req.param('slug');
  try {
    await deprovisionSpaceAlias(slug);
    return c.json({ ok: true });
  } catch (e) {
    console.error(`[SpaceAlias] Deprovision failed for ${slug}:`, e);
    return c.json({ error: (e as Error).message }, 500);
  }
});

// Internal: remove member's forwarding preference + resync
app.delete('/api/internal/spaces/:slug/email-forwarding/:did', async (c) => {
  const slug = c.req.param('slug');
  const did = decodeURIComponent(c.req.param('did'));
  try {
    await removeSpaceEmailForwarding(slug, did);
    await syncSpaceAlias(slug);
    return c.json({ ok: true });
  } catch (e) {
    console.error(`[SpaceAlias] Remove forwarding failed for ${slug}/${did}:`, e);
    return c.json({ error: (e as Error).message }, 500);
  }
});

// Internal: provision agent mailbox ({space}-agent@rspace.online)
app.post('/api/internal/spaces/:slug/agent-mailbox', async (c) => {
  const slug = c.req.param('slug');
  try {
    await provisionAgentMailbox(slug);
    return c.json({ ok: true });
  } catch (e) {
    console.error(`[SpaceAlias] Agent mailbox provision failed for ${slug}:`, e);
    return c.json({ error: (e as Error).message }, 500);
  }
});

// Internal: deprovision agent mailbox
app.delete('/api/internal/spaces/:slug/agent-mailbox', async (c) => {
  const slug = c.req.param('slug');
  try {
    await deprovisionAgentMailbox(slug);
    return c.json({ ok: true });
  } catch (e) {
    console.error(`[SpaceAlias] Agent mailbox deprovision failed for ${slug}:`, e);
    return c.json({ error: (e as Error).message }, 500);
  }
});

// Internal: get IMAP credentials for a space's agent mailbox
app.get('/api/internal/spaces/:slug/agent-mailbox/creds', async (c) => {
  const slug = c.req.param('slug');
  try {
    const creds = await getAgentMailbox(slug);
    if (!creds) return c.json({ error: 'No agent mailbox for this space' }, 404);
    return c.json(creds);
  } catch (e) {
    console.error(`[SpaceAlias] Agent creds fetch failed for ${slug}:`, e);
    return c.json({ error: (e as Error).message }, 500);
  }
});

// Internal: list all agent mailbox credentials (for IMAP sync bootstrap)
app.get('/api/internal/agent-mailboxes', async (c) => {
  try {
    const all = await listAllAgentMailboxes();
    return c.json({ mailboxes: all });
  } catch (e) {
    console.error(`[SpaceAlias] List agent mailboxes failed:`, e);
    return c.json({ error: (e as Error).message }, 500);
  }
});

// Internal: get opted-in member emails for a space (used by agent-notify)
app.get('/api/internal/spaces/:slug/member-emails', async (c) => {
  const slug = c.req.param('slug');
  try {
    const dids = await getOptedInDids(slug);
    if (dids.length === 0) return c.json({ emails: [] });
    const emailMap = await getProfileEmailsByDids(dids);
    const emails = [...emailMap.values()].filter(Boolean);
    return c.json({ emails });
  } catch (e) {
    console.error(`[SpaceAlias] member-emails failed for ${slug}:`, e);
    return c.json({ error: (e as Error).message }, 500);
  }
});

// Authenticated: get my opt-in status for a space
app.get('/api/spaces/:slug/email-forwarding/me', 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);

  const pref = await getSpaceEmailForwarding(slug, claims.sub);
  return c.json({ optIn: pref?.optIn ?? false });
});

// Authenticated: toggle my opt-in for a space
app.put('/api/spaces/:slug/email-forwarding/me', 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);

  const body = await c.req.json<{ optIn: boolean }>();
  if (typeof body.optIn !== 'boolean') {
    return c.json({ error: 'optIn (boolean) is required' }, 400);
  }

  await upsertSpaceEmailForwarding(slug, claims.sub, body.optIn);
  await syncSpaceAlias(slug).catch((e) => {
    console.error(`[SpaceAlias] Sync after opt-in toggle failed for ${slug}:`, e);
  });
  return c.json({ ok: true, optIn: body.optIn });
});

// Clean expired challenges, recovery tokens, fund claims, and OIDC codes every 10 minutes
setInterval(() => {
  cleanExpiredChallenges().catch(() => {});
  cleanExpiredRecoveryTokens().catch(() => {});
  cleanExpiredFundClaims().catch(() => {});
  cleanExpiredOidcCodes().catch(() => {});
  cleanExpiredIdentityInvites().catch(() => {});
  cleanExpiredDelegations().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,
};
