multi-tenant relay und card-push to user

This commit is contained in:
th committed 2026-04-10 18:23:07 +02:00
1 parent 7219bae655
commit 5b18c46ac2
22 files changed
+7453 -519

No files matched your search

+102 -14
View File
@@ -821,33 +821,61 @@ if (str_starts_with($route, '/api/')) {
if ($route === '/api/relay/settings' && $method === 'GET') {
$url = $db->query("SELECT value FROM settings WHERE key = 'relay_url'")->fetchColumn() ?: '';
$hasKey = !empty($db->query("SELECT value FROM settings WHERE key = 'relay_key'")->fetchColumn());
echo json_encode(['url' => $url, 'has_key' => $hasKey]); exit;
$username = $db->query("SELECT value FROM settings WHERE key = 'relay_username'")->fetchColumn() ?: '';
$key = $db->query("SELECT value FROM settings WHERE key = 'relay_key'")->fetchColumn() ?: '';
// Single-user local app — echo the stored secret back so the user can
// see and edit it directly without guessing if it's still saved.
echo json_encode(['url' => $url, 'username' => $username, 'key' => $key]); exit;
}
if ($route === '/api/relay/settings' && $method === 'POST') {
$input = json_decode(file_get_contents('php://input'), true);
if (isset($input['url'])) $db->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('relay_url', :v)")->execute(['v' => $input['url']]);
if (!empty($input['key'])) $db->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('relay_key', :v)")->execute(['v' => $input['key']]);
if (isset($input['username'])) $db->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('relay_username', :v)")->execute(['v' => $input['username']]);
// Always overwrite key when present (even empty) — single-user local
// app, the input is now pre-populated so an empty submit is intentional
if (array_key_exists('key', $input)) $db->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('relay_key', :v)")->execute(['v' => $input['key']]);
echo json_encode(['ok' => true]); exit;
}
if ($route === '/api/relay/poll' && $method === 'POST') {
$input = json_decode(file_get_contents('php://input'), true) ?: [];
$strict = !empty($input['strict']); // test button sets this; background poll doesn't
$relayUrl = $db->query("SELECT value FROM settings WHERE key = 'relay_url'")->fetchColumn();
$relayKey = $db->query("SELECT value FROM settings WHERE key = 'relay_key'")->fetchColumn();
if (!$relayUrl || !$relayKey) { echo json_encode(['ok' => true, 'pulled' => 0]); exit; }
$relayUser = $db->query("SELECT value FROM settings WHERE key = 'relay_username'")->fetchColumn();
if (!$relayUrl || !$relayKey || !$relayUser) {
if ($strict) {
$missing = [];
if (!$relayUrl) $missing[] = 'url';
if (!$relayUser) $missing[] = 'username';
if (!$relayKey) $missing[] = 'secret';
echo json_encode(['ok' => false, 'error' => 'relay not configured: missing ' . implode(', ', $missing)]); exit;
}
echo json_encode(['ok' => true, 'pulled' => 0]); exit;
}
$ch = curl_init(rtrim($relayUrl, '/') . '/api/messages');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['X-Relay-Key: ' . $relayKey],
CURLOPT_HTTPHEADER => ['X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
CURLOPT_TIMEOUT => 10,
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status !== 200) { echo json_encode(['ok' => false, 'error' => "HTTP $status"]); exit; }
if ($status !== 200) {
$relayMsg = '';
$decoded = json_decode($response, true);
if (is_array($decoded) && !empty($decoded['error'])) {
$relayMsg = ' — ' . $decoded['error'];
} elseif (is_string($response) && $response !== '') {
$relayMsg = ' — ' . substr($response, 0, 200);
}
echo json_encode(['ok' => false, 'error' => "HTTP $status$relayMsg", 'sent_username' => $relayUser]);
exit;
}
$messages = json_decode($response, true);
if (!is_array($messages)) { echo json_encode(['ok' => false, 'error' => 'invalid response']); exit; }
@@ -909,7 +937,7 @@ if (str_starts_with($route, '/api/')) {
$ach = curl_init(rtrim($relayUrl, '/') . '/api/attachments?relay_uid=' . urlencode($relayUid));
curl_setopt_array($ach, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['X-Relay-Key: ' . $relayKey],
CURLOPT_HTTPHEADER => ['X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
CURLOPT_TIMEOUT => 30,
]);
$attResponse = curl_exec($ach);
@@ -938,14 +966,45 @@ if (str_starts_with($route, '/api/')) {
echo json_encode(['ok' => true, 'pulled' => $pulled]); exit;
}
// List relay tenants (for the recipient picker)
if ($route === '/api/relay/tenants' && $method === 'GET') {
$relayUrl = $db->query("SELECT value FROM settings WHERE key = 'relay_url'")->fetchColumn();
$relayKey = $db->query("SELECT value FROM settings WHERE key = 'relay_key'")->fetchColumn();
$relayUser = $db->query("SELECT value FROM settings WHERE key = 'relay_username'")->fetchColumn();
if (!$relayUrl || !$relayKey || !$relayUser) {
echo json_encode(['ok' => false, 'error' => 'relay not configured']); exit;
}
$ch = curl_init(rtrim($relayUrl, '/') . '/api/tenants');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
CURLOPT_TIMEOUT => 10,
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status !== 200) {
echo json_encode(['ok' => false, 'error' => "relay returned HTTP $status"]); exit;
}
$data = json_decode($response, true);
echo json_encode([
'ok' => true,
'tenants' => $data['tenants'] ?? [],
'self' => $data['self'] ?? $relayUser,
]);
exit;
}
// Push a card to relay
if ($route === '/api/relay/push' && $method === 'POST') {
$relayUrl = $db->query("SELECT value FROM settings WHERE key = 'relay_url'")->fetchColumn();
$relayKey = $db->query("SELECT value FROM settings WHERE key = 'relay_key'")->fetchColumn();
if (!$relayUrl || !$relayKey) { echo json_encode(['ok' => false, 'error' => 'relay not configured']); exit; }
$relayUser = $db->query("SELECT value FROM settings WHERE key = 'relay_username'")->fetchColumn();
if (!$relayUrl || !$relayKey || !$relayUser) { echo json_encode(['ok' => false, 'error' => 'relay not configured (need url, username, and secret)']); exit; }
$input = json_decode(file_get_contents('php://input'), true);
$cardId = $input['card_id'] ?? null;
$recipient = isset($input['recipient']) ? trim((string)$input['recipient']) : null;
if (!$cardId) { http_response_code(400); echo json_encode(['error' => 'card_id required']); exit; }
$card = $db->prepare('SELECT * FROM cards WHERE id = ?');
@@ -967,24 +1026,32 @@ if (str_starts_with($route, '/api/')) {
return "HYPRCRD-RELAY-V1\n" . base64_encode($iv . $cipher);
};
$payload = json_encode([
$payloadData = [
'title' => $encryptField($card['title'] ?? ''),
'body' => $encryptField($card['body'] ?? ''),
'color' => $card['color'] ?? '#38bdf8',
'tags' => $card['tags'] ?? '',
'relay_uid' => $relayUid,
]);
];
if ($recipient) $payloadData['recipient'] = $recipient;
$payload = json_encode($payloadData);
$ch = curl_init(rtrim($relayUrl, '/') . '/api/messages');
$debugStream = fopen('php://temp', 'w+');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-Relay-Key: ' . $relayKey],
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
CURLOPT_TIMEOUT => 10,
CURLOPT_VERBOSE => true,
CURLOPT_STDERR => $debugStream,
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
rewind($debugStream);
$curlDebug = stream_get_contents($debugStream);
fclose($debugStream);
curl_close($ch);
if ($status === 200 || $status === 201) {
@@ -1013,7 +1080,7 @@ if (str_starts_with($route, '/api/')) {
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $attPayload,
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-Relay-Key: ' . $relayKey],
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-Relay-User: ' . $relayUser, 'X-Relay-Key: ' . $relayKey],
CURLOPT_TIMEOUT => 30,
]);
curl_exec($ach);
@@ -1022,7 +1089,25 @@ if (str_starts_with($route, '/api/')) {
echo json_encode(['ok' => true]); exit;
}
echo json_encode(['ok' => false, 'error' => "HTTP $status"]); exit;
// Surface the relay's actual error message so the user can see why
$relayMsg = '';
$decoded = json_decode($response, true);
if (is_array($decoded) && !empty($decoded['error'])) {
$relayMsg = ' — ' . $decoded['error'];
} elseif (is_string($response) && $response !== '') {
$relayMsg = ' — ' . substr($response, 0, 200);
}
// Strip the X-Relay-Key value from the debug log so we don't leak the secret
$safeDebug = preg_replace('/(X-Relay-Key:\s*)[^\r\n]+/i', '$1<redacted>', $curlDebug);
echo json_encode([
'ok' => false,
'error' => "relay returned HTTP $status$relayMsg",
'sent_username' => $relayUser,
'relay_url' => rtrim($relayUrl, '/') . '/api/messages',
'response_body' => is_string($response) ? substr($response, 0, 500) : null,
'curl_debug' => $safeDebug,
]);
exit;
}
http_response_code(404); echo json_encode(['error' => 'not found']); exit;
@@ -1438,13 +1523,15 @@ if (str_starts_with($route, '/api/')) {
</div>
<div id="settings-panel-relay" class="hidden space-y-3">
<div class="font-pixel text-[9px] uppercase tracking-wider text-t-dim mb-2"><i class="ph ph-broadcast text-xs"></i> relay</div>
<p class="text-[10px] text-t-dim leading-relaxed">The relay is multi-tenant: each user has their own account on the relay server. Ask the relay admin to create a user for you, then enter the credentials below.</p>
<div><label class="font-pixel text-[9px] text-t-dim uppercase block mb-1">relay url</label><input id="relay-url" type="text" placeholder="https://relay.example.com" autocomplete="off" class="w-full bg-t-bg border border-t-border text-t-bright px-3 py-2 text-xs focus:border-t-accent focus:outline-none placeholder:text-t-dim font-mono"></div>
<div><label class="font-pixel text-[9px] text-t-dim uppercase block mb-1">username</label><input id="relay-username" type="text" placeholder="your relay username" autocomplete="off" class="w-full bg-t-bg border border-t-border text-t-bright px-3 py-2 text-xs focus:border-t-accent focus:outline-none placeholder:text-t-dim font-mono"></div>
<div><label class="font-pixel text-[9px] text-t-dim uppercase block mb-1">shared secret</label><input id="relay-key" type="text" placeholder="shared secret" autocomplete="off" style="-webkit-text-security:disc;" class="w-full bg-t-bg border border-t-border text-t-bright px-3 py-2 text-xs focus:border-t-accent focus:outline-none font-mono"></div>
<div class="flex gap-2">
<button id="relay-save" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-accent text-t-accent hover:bg-t-accent/10 transition-colors"><i class="ph ph-floppy-disk text-sm"></i><span class="font-pixel text-[10px]">save</span></button>
<button id="relay-test" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors"><i class="ph ph-plugs-connected text-sm"></i><span class="font-pixel text-[10px]">test</span></button>
<span id="relay-status" class="font-pixel text-[9px] text-t-dim self-center"></span>
</div>
<div id="relay-status" class="hidden mt-1 px-3 py-2 text-[10px] font-mono break-words border border-t-border bg-t-bg"></div>
</div>
<div id="settings-panel-behavior" class="hidden space-y-5">
<div>
@@ -1819,6 +1906,7 @@ if (str_starts_with($route, '/api/')) {
</div>
<div id="ctx-buckets" class="hidden border-t border-t-border py-1"></div>
<div id="ctx-members" class="hidden border-t border-t-border py-1 max-h-48 overflow-y-auto"></div>
<div id="ctx-relay-tenants" class="hidden border-t border-t-border py-1 max-h-48 overflow-y-auto"></div>
<div id="ctx-tags" class="hidden border-t border-t-border py-1">
<div class="px-3 py-1.5"><input id="ctx-tag-input" type="text" placeholder="#tag..." class="w-full bg-t-bg border border-t-border text-t-text text-[10px] font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none"></div>
<div id="ctx-tag-list" class="max-h-36 overflow-y-auto"></div>
+40 -11
View File
@@ -222,24 +222,48 @@ function showConnectionLog(lines) {
// ── Relay settings ──
function setRelayStatus(text, kind = 'info') {
const el = $('relay-status');
if (!el) return;
if (!text) {
el.classList.add('hidden');
el.textContent = '';
el.style.borderColor = '';
el.style.color = '';
return;
}
el.classList.remove('hidden');
el.textContent = text;
if (kind === 'error') { el.style.borderColor = 'var(--t-danger)'; el.style.color = 'var(--t-danger)'; }
else if (kind === 'success') { el.style.borderColor = 'var(--t-accent)'; el.style.color = 'var(--t-accent)'; }
else { el.style.borderColor = 'var(--t-border)'; el.style.color = 'var(--t-muted)'; }
}
$('relay-save')?.addEventListener('click', async () => {
const relayStatus = $('relay-status');
const body = { url: $('relay-url')?.value || '' };
const key = $('relay-key')?.value;
if (key) body.key = key;
const body = {
url: $('relay-url')?.value || '',
username: $('relay-username')?.value || '',
key: $('relay-key')?.value || '',
};
setRelayStatus('saving...');
await api('api/relay/settings', { method: 'POST', body });
if (relayStatus) { relayStatus.textContent = 'saved'; relayStatus.style.color = 'var(--t-accent)'; }
setRelayStatus('saved', 'success');
setTimeout(() => setRelayStatus(''), 1500);
});
$('relay-test')?.addEventListener('click', async () => {
const relayStatus = $('relay-status');
if (relayStatus) { relayStatus.textContent = 'testing...'; relayStatus.style.color = 'var(--t-dim)'; }
const res = await fetch('api/relay/poll', { method: 'POST', headers: { 'X-CSRF-Token': getCsrf() } }).then(r => r.json()).catch(() => null);
setRelayStatus('testing...');
const res = await fetch('api/relay/poll', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf() },
body: JSON.stringify({ strict: true }),
}).then(r => r.json()).catch(() => null);
if (res?.ok) {
if (relayStatus) { relayStatus.textContent = `ok — ${res.pulled} pulled`; relayStatus.style.color = 'var(--t-accent)'; }
setRelayStatus(`ok — ${res.pulled} pulled`, 'success');
if (res.pulled) await window._reloadAll?.();
} else {
if (relayStatus) { relayStatus.textContent = res?.error || 'failed'; relayStatus.style.color = 'var(--t-danger)'; }
const detail = res?.sent_username ? ` (sent username: ${res.sent_username})` : '';
setRelayStatus((res?.error || 'failed') + detail, 'error');
}
});
@@ -247,9 +271,14 @@ export async function loadRelaySettings() {
const s = await api('api/relay/settings');
if (!s) return;
const urlInput = $('relay-url');
const userInput = $('relay-username');
const keyInput = $('relay-key');
if (urlInput) urlInput.value = s.url || '';
if (keyInput) keyInput.placeholder = s.has_key ? '(saved)' : 'shared secret';
if (userInput) userInput.value = s.username || '';
if (keyInput) {
keyInput.value = s.key || '';
keyInput.placeholder = 'shared secret';
}
}
$('cal-caldav-settings')?.addEventListener('click', openCaldavSettings);
+93 -11
View File
@@ -11,6 +11,12 @@ const colors = $('ctx-colors');
const buckets = $('ctx-buckets');
const members = $('ctx-members');
const tags = $('ctx-tags');
const relayTenants = $('ctx-relay-tenants');
// Cached relay tenant list. Loaded on first push-relay submenu open
// for the session, then reused. Refreshed if the user closes/reopens
// the menu without success.
let cachedRelayTenants = null;
export function hideContextMenu() { menu.classList.add('hidden'); }
@@ -38,7 +44,7 @@ export function showContextMenu(x, y, card) {
<button class="ctx-item" data-action="due-date"><i class="ph ph-clock-countdown text-sm ${card.due_date ? 'text-t-accent' : ''}"></i><span class="font-pixel text-[10px]">${card.due_date ? 'due: ' + card.due_date : 'set due date'}</span></button>
${card.due_date ? `<button class="ctx-item" data-action="due-done"><i class="ph ph-check-circle text-sm text-t-accent"></i><span class="font-pixel text-[10px]">mark done</span></button>` : ''}
<button class="ctx-item" data-action="save-md"><i class="ph ph-file-md text-sm"></i><span class="font-pixel text-[10px]">save .md</span></button>
<button class="ctx-item" data-action="push-relay"><i class="ph ph-broadcast text-sm"></i><span class="font-pixel text-[10px]">push to relay</span></button>
<button class="ctx-item" data-action="push-relay"><i class="ph ph-broadcast text-sm"></i><span class="font-pixel text-[10px]">push to relay</span><i class="ph ph-caret-right text-xs ml-auto"></i></button>
<button class="ctx-item" data-action="favorite"><i class="ph${isFavorite(card.id) ? '-fill' : ''} ph-star text-sm ${isFavorite(card.id) ? 'text-amber-400' : ''}"></i><span class="font-pixel text-[10px]">${isFavorite(card.id) ? 'unfavorite' : 'favorite'}</span></button>
<div class="ctx-sep"></div>
<button class="ctx-item" data-action="color"><i class="ph ph-palette text-sm"></i><span class="font-pixel text-[10px]">color</span><i class="ph ph-caret-right text-xs ml-auto"></i></button>
@@ -162,7 +168,7 @@ export function showContextMenu(x, y, card) {
menu.style.left = Math.min(x, innerWidth - menuRect.width - 8) + 'px';
menu.style.top = (y + menuRect.height > innerHeight - 8 ? Math.max(8, y - menuRect.height) : y) + 'px';
const toggle = (panel) => { [colors, buckets, members, tags].forEach(p => { if (p !== panel) p.classList.add('hidden'); }); panel.classList.toggle('hidden'); if (panel === tags && !tags.classList.contains('hidden')) setTimeout(() => tagInput.focus(), 0); };
const toggle = (panel) => { [colors, buckets, members, tags, relayTenants].forEach(p => { if (p !== panel) p.classList.add('hidden'); }); panel.classList.toggle('hidden'); if (panel === tags && !tags.classList.contains('hidden')) setTimeout(() => tagInput.focus(), 0); };
// Actions
items.querySelectorAll('[data-action]').forEach(btn => btn.addEventListener('click', async e => {
@@ -210,15 +216,7 @@ export function showContextMenu(x, y, card) {
}
else if (a === 'save-md') { hideContextMenu(); window.location.href = `api/export-md?ids=${card.id}`; }
else if (a === 'push-relay') {
hideContextMenu();
const res = await api(`api/relay/push`, { method: 'POST', body: { card_id: card.id } });
const msg = res?.ok ? 'pushed to relay' : (res?.error || 'push failed');
const color = res?.ok ? 'var(--t-accent)' : 'var(--t-danger)';
const n = document.createElement('div');
n.style.cssText = `position:fixed;bottom:60px;right:16px;z-index:200;background:var(--t-surface);border:1px solid ${color};border-radius:4px;padding:10px 14px;box-shadow:0 4px 20px rgba(0,0,0,0.4);`;
n.innerHTML = `<div style="font-family:var(--t-font-pixel);font-size:10px;color:${color};display:flex;align-items:center;gap:6px;"><i class="ph ph-broadcast"></i>${msg}</div>`;
document.body.appendChild(n);
setTimeout(() => { n.style.transition='opacity .3s'; n.style.opacity='0'; setTimeout(() => n.remove(), 350); }, 3000);
await openRelayTenantSubmenu(card);
}
else if (a === 'favorite') { hideContextMenu(); toggleFavorite(card.id); window._renderBoard(); }
else if (a === 'color') toggle(colors);
@@ -252,3 +250,87 @@ export function showContextMenu(x, y, card) {
members.querySelectorAll('[data-ctx-member]').forEach(btn => btn.addEventListener('click', e => { e.stopPropagation(); if (e.target.closest('[data-ctx-kick]')) return; hideContextMenu(); window._openDetail(btn.dataset.ctxMember); }));
members.querySelectorAll('[data-ctx-kick]').forEach(btn => btn.addEventListener('click', async e => { e.stopPropagation(); hideContextMenu(); await api(`api/cards/${btn.dataset.ctxKick}`, { method: 'PATCH', body: { parent_id: null } }); await window._reloadAll(); }));
}
// ── Push to relay submenu (cross-tenant recipient picker) ──
async function openRelayTenantSubmenu(card) {
// Show submenu immediately with a loading state, then fetch the
// tenant list (cached after first successful load).
const toggle = (panel) => {
[colors, buckets, members, tags, relayTenants].forEach(p => { if (p !== panel) p.classList.add('hidden'); });
panel.classList.remove('hidden');
};
toggle(relayTenants);
if (!cachedRelayTenants) {
relayTenants.innerHTML = '<div class="px-3 py-2 text-[10px] text-t-dim font-pixel">loading tenants...</div>';
try {
const res = await fetch('api/relay/tenants').then(r => r.json());
if (!res.ok) {
relayTenants.innerHTML = `<div class="px-3 py-2 text-[10px] text-t-danger font-pixel break-words">${res.error || 'failed'}</div>`;
return;
}
cachedRelayTenants = res;
} catch (err) {
relayTenants.innerHTML = `<div class="px-3 py-2 text-[10px] text-t-danger font-pixel break-words">${err.message}</div>`;
return;
}
}
renderRelayTenants(card);
}
function renderRelayTenants(card) {
const list = cachedRelayTenants?.tenants || [];
const self = cachedRelayTenants?.self || '';
if (!list.length) {
relayTenants.innerHTML = '<div class="px-3 py-2 text-[10px] text-t-dim font-pixel italic">no tenants</div>';
return;
}
// Self first, then others alphabetical
const ordered = [
...list.filter(t => t.username === self),
...list.filter(t => t.username !== self),
];
relayTenants.innerHTML = ordered.map(t => {
const isSelf = t.username === self;
const icon = isSelf ? 'ph-user' : 'ph-user-circle';
const label = isSelf ? `${t.username} (you)` : t.username;
return `<button class="ctx-item" data-relay-recipient="${t.username}"><i class="ph ${icon} text-sm"></i><span class="font-pixel text-[10px]">${label}</span></button>`;
}).join('');
relayTenants.querySelectorAll('[data-relay-recipient]').forEach(btn => {
btn.addEventListener('click', async e => {
e.stopPropagation();
const recipient = btn.dataset.relayRecipient;
hideContextMenu();
const res = await api('api/relay/push', { method: 'POST', body: { card_id: card.id, recipient } });
relayPushToast(res, recipient);
});
});
}
function relayPushToast(res, recipient) {
const ok = res?.ok;
const msg = ok
? (recipient && recipient !== cachedRelayTenants?.self ? `pushed to ${recipient}` : 'pushed to relay')
: (res?.error || 'push failed');
const color = ok ? 'var(--t-accent)' : 'var(--t-danger)';
if (!ok) {
// Dump every diagnostic the backend gave us so we can see the wire-level
// truth instead of guessing why the relay said no
console.group('%c[relay push failed]', 'color:#fb7185;font-weight:bold');
console.log('error:', res?.error);
if (res?.relay_url) console.log('url:', res.relay_url);
if (res?.sent_username) console.log('sent username:', res.sent_username);
if (res?.response_body) console.log('response body:', res.response_body);
if (res?.curl_debug) console.log('curl debug:\n' + res.curl_debug);
console.groupEnd();
}
const n = document.createElement('div');
n.style.cssText = `position:fixed;bottom:60px;right:16px;z-index:200;background:var(--t-surface);border:1px solid ${color};border-radius:4px;padding:10px 14px;box-shadow:0 4px 20px rgba(0,0,0,0.4);max-width:340px;`;
const hint = ok ? '' : '<div style="font-family:var(--t-font-pixel);font-size:9px;color:var(--t-dim);margin-top:4px;">see browser console for wire-level debug</div>';
n.innerHTML = `<div style="font-family:var(--t-font-pixel);font-size:10px;color:${color};display:flex;align-items:center;gap:6px;"><i class="ph ph-broadcast"></i>${esc(msg)}</div>${hint}`;
document.body.appendChild(n);
setTimeout(() => { n.style.transition = 'opacity .3s'; n.style.opacity = '0'; setTimeout(() => n.remove(), 350); }, ok ? 3000 : 6000);
}