$isSetup, 'has_admin' => adminCount($db) > 0, 'has_super_secret' => hasInterTenantKey($db), ]; $sessionTenant = getSessionTenant($db); if ($sessionTenant) { $cnt = $db->prepare('SELECT COUNT(*) FROM messages WHERE recipient_id = :r AND fetched = 0'); $cnt->execute(['r' => $sessionTenant['id']]); $data['logged_in'] = true; $data['username'] = $sessionTenant['username']; $data['is_admin'] = (bool)$sessionTenant['is_admin']; $data['inbox_count'] = (int)$cnt->fetchColumn(); $data['has_secret'] = !empty($sessionTenant['secret_hash']); $data['password_changed'] = (bool)$sessionTenant['password_changed']; } else { $data['logged_in'] = false; } jsonResponse($data); } // ── Public: setup (only when no tenants exist) ──────────────────── if ($route === '/api/setup' && $method === 'POST') { if (isRateLimited($db, 'setup', 5)) jsonResponse(['error' => 'too many attempts'], 429); if (!$isSetup) jsonResponse(['error' => 'already configured'], 403); $input = json_decode(file_get_contents('php://input'), true) ?: []; $username = trim((string)($input['username'] ?? '')); $password = (string)($input['password'] ?? ''); $superSecret = (string)($input['super_secret'] ?? ''); if (!preg_match('/^[a-zA-Z0-9_-]{2,32}$/', $username)) { jsonResponse(['error' => 'username must be 2-32 chars: letters, digits, _ or -'], 400); } if (strlen($password) < 10) jsonResponse(['error' => 'password must be at least 10 chars'], 400); if (strlen($superSecret) < 12) jsonResponse(['error' => 'super-shared secret must be at least 12 chars'], 400); // Create the admin tenant. NO client secret yet — the admin (and every // user) sets that themselves later via the account view. $passHash = password_hash($password, PASSWORD_BCRYPT); $db->prepare('INSERT INTO tenants (username, password_hash, is_admin) VALUES (:u, :p, 1)') ->execute([ 'u' => $username, 'p' => $passHash, ]); // Set the relay-wide super-shared key. This is the cross-tenant // routing key — used by the relay to re-encrypt messages at rest. setInterTenantKey($db, $MASTER_KEY, $superSecret); $tenantId = (int)$db->lastInsertId(); $token = makeSession($db, $tenantId); setSessionCookie($token); jsonResponse(['ok' => true, 'username' => $username, 'is_admin' => true]); } // ── Public: login (UI session) ──────────────────────────────────── if ($route === '/api/login' && $method === 'POST') { if (isRateLimited($db, 'login', 5)) jsonResponse(['error' => 'too many attempts'], 429); $input = json_decode(file_get_contents('php://input'), true) ?: []; $username = trim((string)($input['username'] ?? '')); $password = (string)($input['password'] ?? ''); $tenant = getTenantByUsername($db, $username); if (!$tenant || !password_verify($password, $tenant['password_hash'])) { jsonResponse(['error' => 'wrong username or password'], 401); } $token = makeSession($db, (int)$tenant['id']); setSessionCookie($token); jsonResponse([ 'ok' => true, 'username' => $tenant['username'], 'is_admin' => (bool)$tenant['is_admin'], ]); } if ($route === '/api/logout' && $method === 'POST') { destroySession($db); jsonResponse(['ok' => true]); } // ── Session-authenticated: /api/me ──────────────────────────────── $sessionTenant = getSessionTenant($db); if ($route === '/api/me' && $method === 'GET') { if (!$sessionTenant) jsonResponse(['error' => 'not logged in'], 401); jsonResponse([ 'username' => $sessionTenant['username'], 'is_admin' => (bool)$sessionTenant['is_admin'], 'created_at' => $sessionTenant['created_at'], ]); }