personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add soft-delete and key viewing for remote app

Remotes are now soft-deleted (revoked) instead of permanently removed,
appearing greyed out in the UI. Added endpoint to retrieve full key for
existing remotes, enabling users to view the observer command after
initial creation.

- Convert _delete_remote() to _revoke_remote() with revoked/revoked_at fields
- Add GET /api/<prefix>/key endpoint for full key retrieval
- Reject ingest requests from revoked remotes with 403
- Show revoked remotes greyed out in UI (no actions available)
- Add View Key button to active remotes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+166 -29
+99 -6
apps/remote/tests/test_routes.py
··· 6 6 from __future__ import annotations 7 7 8 8 import io 9 - import json 10 9 11 10 12 11 def test_api_list_empty(remote_env): ··· 86 85 87 86 88 87 def test_api_delete_remote(remote_env): 89 - """Test deleting a remote.""" 88 + """Test revoking a remote (soft-delete).""" 90 89 env = remote_env() 91 90 92 91 # Create a remote 93 92 resp = env.client.post( 94 93 "/app/remote/api/create", 95 - json={"name": "to-delete"}, 94 + json={"name": "to-revoke"}, 96 95 content_type="application/json", 97 96 ) 98 97 key_prefix = resp.get_json()["key_prefix"] 99 98 100 - # Delete it 99 + # Revoke it 101 100 resp = env.client.delete(f"/app/remote/api/{key_prefix}") 102 101 assert resp.status_code == 200 103 102 assert resp.get_json()["status"] == "ok" 104 103 105 - # List should be empty 104 + # List should still show it, but marked as revoked 106 105 resp = env.client.get("/app/remote/api/list") 107 - assert resp.get_json() == [] 106 + remotes = resp.get_json() 107 + assert len(remotes) == 1 108 + assert remotes[0]["key_prefix"] == key_prefix 109 + assert remotes[0]["revoked"] is True 110 + assert remotes[0]["revoked_at"] is not None 108 111 109 112 110 113 def test_api_delete_nonexistent(remote_env): ··· 342 345 ) 343 346 assert resp.status_code == 400 344 347 assert "Missing tract or event" in resp.get_json()["error"] 348 + 349 + 350 + def test_ingest_revoked_key(remote_env): 351 + """Test that ingest rejects revoked keys.""" 352 + env = remote_env() 353 + 354 + # Create and revoke a remote 355 + resp = env.client.post( 356 + "/app/remote/api/create", 357 + json={"name": "revoked-test"}, 358 + content_type="application/json", 359 + ) 360 + data = resp.get_json() 361 + key = data["key"] 362 + key_prefix = data["key_prefix"] 363 + 364 + resp = env.client.delete(f"/app/remote/api/{key_prefix}") 365 + assert resp.status_code == 200 366 + 367 + # Try to upload - should fail 368 + test_data = b"test content" 369 + resp = env.client.post( 370 + f"/app/remote/ingest/{key}", 371 + data={ 372 + "day": "20250103", 373 + "segment": "120000_300", 374 + "files": (io.BytesIO(test_data), "audio.flac"), 375 + }, 376 + ) 377 + assert resp.status_code == 403 378 + assert "Remote revoked" in resp.get_json()["error"] 379 + 380 + 381 + def test_ingest_event_revoked_key(remote_env): 382 + """Test that event relay rejects revoked keys.""" 383 + env = remote_env() 384 + 385 + # Create and revoke a remote 386 + resp = env.client.post( 387 + "/app/remote/api/create", 388 + json={"name": "revoked-event-test"}, 389 + content_type="application/json", 390 + ) 391 + data = resp.get_json() 392 + key = data["key"] 393 + key_prefix = data["key_prefix"] 394 + 395 + resp = env.client.delete(f"/app/remote/api/{key_prefix}") 396 + assert resp.status_code == 200 397 + 398 + # Try to send event - should fail 399 + resp = env.client.post( 400 + f"/app/remote/ingest/{key}/event", 401 + json={"tract": "observe", "event": "status"}, 402 + content_type="application/json", 403 + ) 404 + assert resp.status_code == 403 405 + assert "Remote revoked" in resp.get_json()["error"] 406 + 407 + 408 + def test_api_get_key(remote_env): 409 + """Test retrieving full key for a remote.""" 410 + env = remote_env() 411 + 412 + # Create a remote 413 + resp = env.client.post( 414 + "/app/remote/api/create", 415 + json={"name": "key-test"}, 416 + content_type="application/json", 417 + ) 418 + create_data = resp.get_json() 419 + key = create_data["key"] 420 + key_prefix = create_data["key_prefix"] 421 + 422 + # Get the key 423 + resp = env.client.get(f"/app/remote/api/{key_prefix}/key") 424 + assert resp.status_code == 200 425 + 426 + data = resp.get_json() 427 + assert data["key"] == key 428 + assert data["name"] == "key-test" 429 + assert data["ingest_url"] == f"/app/remote/ingest/{key}" 430 + 431 + 432 + def test_api_get_key_nonexistent(remote_env): 433 + """Test getting key for nonexistent remote returns 404.""" 434 + env = remote_env() 435 + 436 + resp = env.client.get("/app/remote/api/nonexistent/key") 437 + assert resp.status_code == 404
+67 -23
apps/remote/workspace.html
··· 9 9 .remote-card.disconnected { 10 10 opacity: 0.7; 11 11 } 12 + .remote-card.revoked { 13 + opacity: 0.5; 14 + background: #e9ecef; 15 + } 12 16 .remote-header { 13 17 display: flex; 14 18 justify-content: space-between; ··· 49 53 border-radius: 50%; 50 54 background: #dc3545; 51 55 } 56 + .remote-status.revoked { 57 + background: #e9ecef; 58 + color: #6c757d; 59 + } 60 + .remote-status.revoked::before { 61 + content: ''; 62 + width: 8px; 63 + height: 8px; 64 + border-radius: 50%; 65 + background: #6c757d; 66 + } 52 67 .remote-stats { 53 68 font-size: 0.9em; 54 69 color: #666; ··· 110 125 cursor: not-allowed; 111 126 } 112 127 113 - /* New remote modal */ 128 + /* Remote key modal */ 114 129 .modal { 115 130 display: none; 116 131 position: fixed; ··· 214 229 </div> 215 230 </div> 216 231 217 - <!-- New Remote Modal --> 218 - <div id="newRemoteModal" class="modal"> 232 + <!-- Remote Key Modal (for new remotes and viewing existing keys) --> 233 + <div id="keyModal" class="modal"> 219 234 <div class="modal-content"> 220 - <span class="modal-close">&times;</span> 221 - <h3>Remote Created: <span id="modalRemoteName"></span></h3> 235 + <span class="modal-close" id="keyModalClose">&times;</span> 236 + <h3 id="keyModalTitle">Remote: <span id="modalRemoteName"></span></h3> 222 237 <p>Run this command on your observer machine:</p> 223 238 <div class="command-box"> 224 239 <code id="commandText"></code> ··· 238 253 const remotesList = document.getElementById('remotesList'); 239 254 const addRemoteForm = document.getElementById('addRemoteForm'); 240 255 const remoteNameInput = document.getElementById('remoteName'); 241 - const newRemoteModal = document.getElementById('newRemoteModal'); 256 + const keyModal = document.getElementById('keyModal'); 242 257 const modalRemoteName = document.getElementById('modalRemoteName'); 243 258 const commandText = document.getElementById('commandText'); 244 259 const copyBtn = document.getElementById('copyBtn'); 245 260 const doneBtn = document.getElementById('doneBtn'); 261 + const keyModalClose = document.getElementById('keyModalClose'); 246 262 247 263 function formatBytes(bytes) { 248 264 if (bytes === 0) return '0 B'; ··· 279 295 280 296 let html = ''; 281 297 for (const remote of remotes) { 282 - const connected = isConnected(remote.last_seen); 283 - const statusClass = connected ? 'connected' : 'disconnected'; 284 - const statusText = connected ? 'Connected' : 'Disconnected'; 298 + const isRevoked = remote.revoked; 299 + let statusClass, statusText, cardClass; 300 + 301 + if (isRevoked) { 302 + statusClass = 'revoked'; 303 + statusText = 'Revoked'; 304 + cardClass = 'revoked'; 305 + } else { 306 + const connected = isConnected(remote.last_seen); 307 + statusClass = connected ? 'connected' : 'disconnected'; 308 + statusText = connected ? 'Connected' : 'Disconnected'; 309 + cardClass = statusClass; 310 + } 285 311 286 312 html += ` 287 - <div class="remote-card ${statusClass}" data-key="${remote.key_prefix}"> 313 + <div class="remote-card ${cardClass}" data-key="${remote.key_prefix}"> 288 314 <div class="remote-header"> 289 315 <span class="remote-name">${escapeHtml(remote.name)}</span> 290 316 <span class="remote-status ${statusClass}">${statusText}</span> ··· 295 321 <span>Data: ${formatBytes(remote.stats?.bytes_received || 0)}</span> 296 322 </div> 297 323 <div class="remote-actions"> 298 - <button class="danger" onclick="revokeRemote('${remote.key_prefix}', '${escapeHtml(remote.name)}')">Revoke</button> 324 + ${isRevoked ? '' : `<button onclick="viewRemoteKey('${remote.key_prefix}', '${escapeHtml(remote.name)}')">View Key</button>`} 325 + ${isRevoked ? '' : `<button class="danger" onclick="revokeRemote('${remote.key_prefix}', '${escapeHtml(remote.name)}')">Revoke</button>`} 299 326 </div> 300 327 </div> 301 328 `; ··· 334 361 } 335 362 } 336 363 364 + async function viewRemoteKey(keyPrefix, name) { 365 + try { 366 + const response = await fetch(`/app/remote/api/${keyPrefix}/key`); 367 + const data = await response.json(); 368 + 369 + if (!response.ok) { 370 + throw new Error(data.error || 'Failed to get key'); 371 + } 372 + 373 + showKeyModal(name, data.ingest_url); 374 + } catch (err) { 375 + if (window.showError) showError(err.message); 376 + } 377 + } 378 + 379 + function showKeyModal(name, ingestUrl) { 380 + const baseUrl = window.location.origin; 381 + const fullUrl = `${baseUrl}${ingestUrl}`; 382 + modalRemoteName.textContent = name; 383 + commandText.textContent = `observer --remote ${fullUrl}`; 384 + keyModal.style.display = 'block'; 385 + } 386 + 337 387 addRemoteForm.onsubmit = async (e) => { 338 388 e.preventDefault(); 339 389 const name = remoteNameInput.value.trim(); ··· 355 405 throw new Error(data.error || 'Failed to create remote'); 356 406 } 357 407 358 - // Build full URL 359 - const baseUrl = window.location.origin; 360 - const fullUrl = `${baseUrl}${data.ingest_url}`; 361 - 362 408 // Show modal with command 363 - modalRemoteName.textContent = name; 364 - commandText.textContent = `observer --remote ${fullUrl}`; 365 - newRemoteModal.style.display = 'block'; 409 + showKeyModal(name, data.ingest_url); 366 410 367 411 // Clear input and reload list 368 412 remoteNameInput.value = ''; ··· 375 419 }; 376 420 377 421 // Modal controls 378 - document.querySelector('.modal-close').onclick = () => { 379 - newRemoteModal.style.display = 'none'; 422 + keyModalClose.onclick = () => { 423 + keyModal.style.display = 'none'; 380 424 }; 381 425 382 426 doneBtn.onclick = () => { 383 - newRemoteModal.style.display = 'none'; 427 + keyModal.style.display = 'none'; 384 428 }; 385 429 386 430 window.onclick = (e) => { 387 - if (e.target === newRemoteModal) { 388 - newRemoteModal.style.display = 'none'; 431 + if (e.target === keyModal) { 432 + keyModal.style.display = 'none'; 389 433 } 390 434 }; 391 435