N) // GET /api/chat/sent?since=N — pull state updates (delivered/seen) for // *my* sent messages with id > N. The // sender uses this to learn read receipts // without needing a backchannel. // POST /api/chat/seen {peer} — mark every message FROM peer TO me as // seen. The sender's next /api/chat/sent // poll picks the new state up. // // Same decrypt-reencrypt hop as messages.php: arrives encrypted with the // senders client secret, stored at-rest under the inter-tenant key, returned // re-encrypted with the recipients client secret. Relay sees plaintext only // during the routing hop. if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; } // ── Session-based chat (relay UI) ── if ($route === '/api/chat/threads' && $method === 'GET' && $sessionTenant) { $interKey = getInterTenantKey($db, $MASTER_KEY); if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500); $myId = $sessionTenant['id']; $stmt = $db->prepare(' SELECT sender_username AS peer, MAX(created_at) AS last_at, COUNT(*) AS count FROM chat_messages WHERE recipient_id = :r GROUP BY sender_username UNION SELECT t.username AS peer, MAX(cm.created_at) AS last_at, COUNT(*) AS count FROM chat_messages cm JOIN tenants t ON t.id = cm.recipient_id WHERE cm.sender_id = :s AND cm.recipient_id != :s2 GROUP BY t.username '); $stmt->execute(['r' => $myId, 's' => $myId, 's2' => $myId]); $rows = $stmt->fetchAll(); $threads = []; foreach ($rows as $row) { $peer = $row['peer']; if (!isset($threads[$peer])) $threads[$peer] = ['peer' => $peer, 'last_at' => $row['last_at'], 'count' => 0]; $threads[$peer]['count'] += (int)$row['count']; if ($row['last_at'] > $threads[$peer]['last_at']) $threads[$peer]['last_at'] = $row['last_at']; } usort($threads, fn($a, $b) => strcmp($b['last_at'], $a['last_at'])); // Add unread count per peer foreach ($threads as &$t) { $stmt2 = $db->prepare('SELECT COUNT(*) FROM chat_messages WHERE recipient_id = :r AND sender_username = :p AND seen = 0'); $stmt2->execute(['r' => $myId, 'p' => $t['peer']]); $t['unread'] = (int)$stmt2->fetchColumn(); } jsonResponse(array_values($threads)); } if ($route === '/api/chat/history' && $method === 'GET' && $sessionTenant) { $peer = trim($_GET['peer'] ?? ''); if (!$peer) jsonResponse(['error' => 'peer required'], 400); $interKey = getInterTenantKey($db, $MASTER_KEY); if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500); $myId = $sessionTenant['id']; $peerTenant = getTenantByUsername($db, $peer); if (!$peerTenant) jsonResponse(['error' => 'unknown peer'], 404); $peerId = $peerTenant['id']; $stmt = $db->prepare(' SELECT id, sender_id, sender_username, body, created_at, seen, bot FROM chat_messages WHERE (recipient_id = :r AND sender_id = :p) OR (recipient_id = :p2 AND sender_id = :s) ORDER BY created_at ASC LIMIT 500 '); $stmt->execute(['r' => $myId, 'p' => $peerId, 'p2' => $peerId, 's' => $myId]); $rows = $stmt->fetchAll(); $out = []; foreach ($rows as $row) { $out[] = [ 'id' => (int)$row['id'], 'from' => $row['sender_username'], 'mine' => (int)$row['sender_id'] === $myId, 'body' => clientDecrypt($row['body'] ?? '', $interKey), 'created_at' => $row['created_at'], 'seen' => (int)$row['seen'], 'bot' => (int)($row['bot'] ?? 0), ]; } // Mark incoming as seen $db->prepare('UPDATE chat_messages SET seen = 1 WHERE recipient_id = :r AND sender_id = :p AND seen = 0') ->execute(['r' => $myId, 'p' => $peerId]); jsonResponse($out); } if ($route === '/api/chat/send' && $method === 'POST' && $sessionTenant) { $input = json_decode(file_get_contents('php://input'), true) ?: []; $body = trim((string)($input['body'] ?? '')); $recipientName = trim((string)($input['recipient'] ?? '')); if ($body === '') jsonResponse(['error' => 'body required'], 400); if ($recipientName === '') jsonResponse(['error' => 'recipient required'], 400); $recipient = getTenantByUsername($db, $recipientName); if (!$recipient) jsonResponse(['error' => "no such user: $recipientName"], 404); $interKey = getInterTenantKey($db, $MASTER_KEY); if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500); $stored = clientEncrypt($body, $interKey); $db->prepare('INSERT INTO chat_messages (recipient_id, sender_id, sender_username, body) VALUES (:r, :s, :sn, :b)') ->execute(['r' => $recipient['id'], 's' => $sessionTenant['id'], 'sn' => $sessionTenant['username'], 'b' => $stored]); jsonResponse(['ok' => true, 'id' => (int)$db->lastInsertId()]); } // ── Client API ── $clientTenant = $clientTenant ?? authClient($db); // Bot notification: client announces "I synced a card" to the sender. // No encryption — body is a short system-generated string like // "alice synced: Card Title". bot=1 flags it so the UI renders in // the orange accent color. if ($route === '/api/chat/bot' && $method === 'POST') { if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401); $input = json_decode(file_get_contents('php://input'), true) ?: []; $body = trim((string)($input['body'] ?? '')); $recipientName = trim((string)($input['recipient'] ?? '')); if ($body === '') jsonResponse(['error' => 'body required'], 400); if ($recipientName === '') jsonResponse(['error' => 'recipient required'], 400); if ($recipientName === $clientTenant['username']) jsonResponse(['ok' => true, 'id' => 0]); // no self-notifications $recipient = getTenantByUsername($db, $recipientName); if (!$recipient) jsonResponse(['error' => "no such user: $recipientName"], 404); $interKey = getInterTenantKey($db, $MASTER_KEY); if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500); $stored = clientEncrypt($body, $interKey); $db->prepare('INSERT INTO chat_messages (recipient_id, sender_id, sender_username, body, bot) VALUES (:r, :s, :sn, :b, 1)') ->execute(['r' => $recipient['id'], 's' => $clientTenant['id'], 'sn' => $clientTenant['username'], 'b' => $stored]); jsonResponse(['ok' => true, 'id' => (int)$db->lastInsertId()]); } if ($route === '/api/chat' && $method === 'POST') { if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401); $input = json_decode(file_get_contents('php://input'), true) ?: []; $encryptedBody = (string)($input['body'] ?? ''); $recipientName = trim((string)($input['recipient'] ?? '')); if ($encryptedBody === '') jsonResponse(['error' => 'body required'], 400); if ($recipientName === '') jsonResponse(['error' => 'recipient required'], 400); $recipient = getTenantByUsername($db, $recipientName); if (!$recipient) jsonResponse(['error' => "no such user: $recipientName"], 404); if (empty($clientTenant['secret_encrypted'])) { jsonResponse(['error' => 'sender has no client secret set — log into the relay UI and configure one in your account'], 400); } $senderSecret = masterDecrypt($clientTenant['secret_encrypted'], $MASTER_KEY); if (!$senderSecret) jsonResponse(['error' => 'sender secret unavailable'], 500); $interKey = getInterTenantKey($db, $MASTER_KEY); if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set — admin must complete setup'], 500); $plain = clientDecrypt($encryptedBody, $senderSecret); if ($plain === '') jsonResponse(['error' => 'body decrypt failed (sender secret mismatch?)'], 400); $stored = clientEncrypt($plain, $interKey); $db->prepare('INSERT INTO chat_messages (recipient_id, sender_id, sender_username, body) VALUES (:r, :s, :sn, :b)') ->execute([ 'r' => $recipient['id'], 's' => $clientTenant['id'], 'sn' => $clientTenant['username'], 'b' => $stored, ]); jsonResponse(['ok' => true, 'id' => (int)$db->lastInsertId()]); } if ($route === '/api/chat' && $method === 'GET') { if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401); if (empty($clientTenant['secret_encrypted'])) { jsonResponse(['error' => 'no client secret set — log into the relay UI and configure one in your account'], 400); } $since = (int)($_GET['since'] ?? 0); $stmt = $db->prepare('SELECT id, sender_username, body, created_at FROM chat_messages WHERE recipient_id = :r AND id > :s ORDER BY id ASC LIMIT 500'); $stmt->execute(['r' => $clientTenant['id'], 's' => $since]); $rows = $stmt->fetchAll(); $interKey = getInterTenantKey($db, $MASTER_KEY); if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500); $recipientSecret = masterDecrypt($clientTenant['secret_encrypted'], $MASTER_KEY); if (!$recipientSecret) jsonResponse(['error' => 'recipient secret unavailable'], 500); $out = []; $maxId = $since; foreach ($rows as $row) { $plain = clientDecrypt($row['body'] ?? '', $interKey); $row['body'] = clientEncrypt($plain, $recipientSecret); $out[] = $row; if ((int)$row['id'] > $maxId) $maxId = (int)$row['id']; } // Mark fetched so cleanup can prune them sooner if desired. We still // keep them around for the 7-day window so a re-poll with since=0 // backfills history if a client wipes its local cache. if ($out) { $db->prepare('UPDATE chat_messages SET fetched = 1 WHERE recipient_id = :r AND id <= :m AND fetched = 0') ->execute(['r' => $clientTenant['id'], 'm' => $maxId]); } jsonResponse(['messages' => $out, 'max_id' => $maxId]); } // ── GET /api/chat/sent ───────────────────────────────────────── // Returns the receipt state (fetched/seen) for messages the caller has // sent. Body is intentionally NOT included — the sender already has the // plaintext locally; the relay only echoes the state flags so the sender // can render check / double-check on the right messages. if ($route === '/api/chat/sent' && $method === 'GET') { if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401); $since = (int)($_GET['since'] ?? 0); $stmt = $db->prepare(' SELECT id, recipient_id, fetched, seen, created_at FROM chat_messages WHERE sender_id = :s AND id > :since ORDER BY id ASC LIMIT 1000 '); $stmt->execute(['s' => $clientTenant['id'], 'since' => $since]); $rows = $stmt->fetchAll(); $maxId = $since; foreach ($rows as $r) { if ((int)$r['id'] > $maxId) $maxId = (int)$r['id']; } jsonResponse(['receipts' => $rows, 'max_id' => $maxId]); } // ── POST /api/chat/seen ──────────────────────────────────────── // Body: { peer: "" }. Marks every message that the // peer sent to the caller as seen=1. Idempotent — safe to call on // every thread open. if ($route === '/api/chat/seen' && $method === 'POST') { if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401); $input = json_decode(file_get_contents('php://input'), true) ?: []; $peer = trim((string)($input['peer'] ?? '')); if ($peer === '') jsonResponse(['error' => 'peer required'], 400); $db->prepare(' UPDATE chat_messages SET seen = 1 WHERE recipient_id = :r AND sender_username = :p AND seen = 0 ')->execute(['r' => $clientTenant['id'], 'p' => $peer]); jsonResponse(['ok' => true]); }