personal memory agent
0
fork

Configure Feed

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

feat: bearer header auth for remote ingest endpoint

Adds Bearer token authentication as primary auth method for /ingest,
keeping URL-embedded key as legacy fallback. Adds keyless /ingest route
for Bearer-only clients. Updates remote CLI output to show 'api key'
instead of embedding key in ingest URL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

+129 -62
+38 -10
apps/remote/routes.py
··· 55 55 KEY_BYTES = 32 56 56 57 57 58 + def _get_key(url_key: str | None = None) -> str | None: 59 + """Extract auth key from Authorization: Bearer header (primary) or URL path (legacy).""" 60 + auth = request.headers.get("Authorization", "") 61 + if auth.startswith("Bearer "): 62 + bearer = auth[7:].strip() 63 + if bearer: 64 + return bearer 65 + return url_key or None 66 + 67 + 58 68 def _generate_key() -> str: 59 69 """Generate a URL-safe key for remote authentication.""" 60 70 return base64.urlsafe_b64encode(secrets.token_bytes(KEY_BYTES)).decode().rstrip("=") ··· 281 291 # === Ingest API (key-protected) === 282 292 283 293 294 + @remote_bp.route("/ingest", methods=["POST"]) 284 295 @remote_bp.route("/ingest/<key>", methods=["POST"]) 285 - def ingest_upload(key: str) -> Any: 296 + def ingest_upload(key: str | None = None) -> Any: 286 297 """Receive file uploads from remote observer. 287 298 288 299 Expects multipart form with: ··· 301 312 - "duplicate": All files already received (no processing triggered) 302 313 - "collision": New segment saved with adjusted key (directory conflict) 303 314 """ 315 + # Extract key from Bearer header (primary) or URL path (legacy) 316 + auth_key = _get_key(key) 317 + if not auth_key: 318 + return jsonify({"error": "Authorization required"}), 401 319 + 304 320 # Validate key 305 - remote = load_remote(key) 321 + remote = load_remote(auth_key) 306 322 if not remote: 307 323 return jsonify({"error": "Invalid key"}), 401 308 324 ··· 336 352 remote_name = remote.get("name", "") 337 353 if effective_host and effective_host != remote_name: 338 354 logger.warning( 339 - f"Remote '{remote_name}' ({key[:8]}) connecting from host " 355 + f"Remote '{remote_name}' ({auth_key[:8]}) connecting from host " 340 356 f"'{effective_host}' — hostname differs from registered name. " 341 357 f"Use `sol remote rename` to update if the host was renamed." 342 358 ) ··· 359 375 if not files: 360 376 return jsonify({"error": "No files uploaded"}), 400 361 377 362 - key_prefix = key[:8] 378 + key_prefix = auth_key[:8] 363 379 364 380 # Read file contents into memory and compute SHA256 before saving 365 381 # This allows duplicate detection without writing to disk ··· 578 594 ) 579 595 580 596 597 + @remote_bp.route("/ingest/event", methods=["POST"]) 581 598 @remote_bp.route("/ingest/<key>/event", methods=["POST"]) 582 - def ingest_event(key: str) -> Any: 599 + def ingest_event(key: str | None = None) -> Any: 583 600 """Receive events from remote observer and relay to local Callosum. 584 601 585 602 Expects JSON body with: ··· 587 604 - event: Event name 588 605 - ...additional fields 589 606 """ 607 + # Extract key from Bearer header (primary) or URL path (legacy) 608 + auth_key = _get_key(key) 609 + if not auth_key: 610 + return jsonify({"error": "Authorization required"}), 401 611 + 590 612 # Validate key 591 - remote = load_remote(key) 613 + remote = load_remote(auth_key) 592 614 if not remote: 593 615 return jsonify({"error": "Invalid key"}), 401 594 616 ··· 621 643 return jsonify({"status": "ok"}) 622 644 623 645 646 + @remote_bp.route("/ingest/segments/<day>") 624 647 @remote_bp.route("/ingest/<key>/segments/<day>") 625 - def ingest_segments(key: str, day: str) -> Any: 648 + def ingest_segments(day: str, key: str | None = None) -> Any: 626 649 """List uploaded segments for a day with file verification. 627 650 628 651 Returns JSON array of segments with file status: ··· 631 654 - missing: File not found 632 655 633 656 Args: 634 - key: Remote authentication key 635 657 day: Day string (YYYYMMDD) 658 + key: Remote authentication key (from URL path, legacy) 636 659 """ 660 + # Extract key from Bearer header (primary) or URL path (legacy) 661 + auth_key = _get_key(key) 662 + if not auth_key: 663 + return jsonify({"error": "Authorization required"}), 401 664 + 637 665 # Validate key 638 - remote = load_remote(key) 666 + remote = load_remote(auth_key) 639 667 if not remote: 640 668 return jsonify({"error": "Invalid key"}), 401 641 669 ··· 650 678 return jsonify({"error": "Invalid day format"}), 400 651 679 652 680 # Load sync history for this remote/day 653 - key_prefix = key[:8] 681 + key_prefix = auth_key[:8] 654 682 records = load_history(key_prefix, day) 655 683 656 684 if not records:
+89 -50
apps/remote/tests/test_routes.py
··· 124 124 env = remote_env() 125 125 126 126 resp = env.client.post( 127 - "/app/remote/ingest/invalid-key-12345", 127 + "/app/remote/ingest", 128 + headers={"Authorization": "Bearer invalid-key-12345"}, 128 129 data={"day": "20250103", "segment": "120000_300"}, 129 130 ) 130 131 assert resp.status_code == 401 ··· 145 146 146 147 # Upload without segment 147 148 resp = env.client.post( 148 - f"/app/remote/ingest/{key}", 149 + "/app/remote/ingest", 150 + headers={"Authorization": f"Bearer {key}"}, 149 151 data={"day": "20250103"}, 150 152 ) 151 153 assert resp.status_code == 400 ··· 166 168 167 169 # Upload without day 168 170 resp = env.client.post( 169 - f"/app/remote/ingest/{key}", 171 + "/app/remote/ingest", 172 + headers={"Authorization": f"Bearer {key}"}, 170 173 data={"segment": "120000_300"}, 171 174 ) 172 175 assert resp.status_code == 400 ··· 187 190 188 191 # Invalid segment format 189 192 resp = env.client.post( 190 - f"/app/remote/ingest/{key}", 193 + "/app/remote/ingest", 194 + headers={"Authorization": f"Bearer {key}"}, 191 195 data={"day": "20250103", "segment": "invalid"}, 192 196 ) 193 197 assert resp.status_code == 400 ··· 208 212 209 213 # Invalid day format 210 214 resp = env.client.post( 211 - f"/app/remote/ingest/{key}", 215 + "/app/remote/ingest", 216 + headers={"Authorization": f"Bearer {key}"}, 212 217 data={"day": "2025-01-03", "segment": "120000_300"}, 213 218 ) 214 219 assert resp.status_code == 400 ··· 229 234 230 235 # Upload without files 231 236 resp = env.client.post( 232 - f"/app/remote/ingest/{key}", 237 + "/app/remote/ingest", 238 + headers={"Authorization": f"Bearer {key}"}, 233 239 data={"day": "20250103", "segment": "120000_300"}, 234 240 ) 235 241 assert resp.status_code == 400 ··· 251 257 # Upload a file 252 258 test_data = b"test audio content" 253 259 resp = env.client.post( 254 - f"/app/remote/ingest/{key}", 260 + "/app/remote/ingest", 261 + headers={"Authorization": f"Bearer {key}"}, 255 262 data={ 256 263 "day": "20250103", 257 264 "segment": "120000_300", ··· 287 294 # Upload a file 288 295 test_data = b"test content" 289 296 resp = env.client.post( 290 - f"/app/remote/ingest/{key}", 297 + "/app/remote/ingest", 298 + headers={"Authorization": f"Bearer {key}"}, 291 299 data={ 292 300 "day": "20250103", 293 301 "segment": "120000_300", ··· 320 328 321 329 # Send an event 322 330 resp = env.client.post( 323 - f"/app/remote/ingest/{key}/event", 331 + "/app/remote/ingest/event", 332 + headers={"Authorization": f"Bearer {key}"}, 324 333 json={"tract": "observe", "event": "status", "mode": "screencast"}, 325 334 content_type="application/json", 326 335 ) ··· 342 351 343 352 # Missing tract 344 353 resp = env.client.post( 345 - f"/app/remote/ingest/{key}/event", 354 + "/app/remote/ingest/event", 355 + headers={"Authorization": f"Bearer {key}"}, 346 356 json={"event": "status"}, 347 357 content_type="application/json", 348 358 ) ··· 370 380 # Try to upload - should fail 371 381 test_data = b"test content" 372 382 resp = env.client.post( 373 - f"/app/remote/ingest/{key}", 383 + "/app/remote/ingest", 384 + headers={"Authorization": f"Bearer {key}"}, 374 385 data={ 375 386 "day": "20250103", 376 387 "segment": "120000_300", ··· 400 411 401 412 # Try to send event - should fail 402 413 resp = env.client.post( 403 - f"/app/remote/ingest/{key}/event", 414 + "/app/remote/ingest/event", 415 + headers={"Authorization": f"Bearer {key}"}, 404 416 json={"tract": "observe", "event": "status"}, 405 417 content_type="application/json", 406 418 ) ··· 545 557 # Upload with same segment key 546 558 test_data = b"new audio content" 547 559 resp = env.client.post( 548 - f"/app/remote/ingest/{key}", 560 + "/app/remote/ingest", 561 + headers={"Authorization": f"Bearer {key}"}, 549 562 data={ 550 563 "day": "20250103", 551 564 "segment": "120000_300", ··· 586 599 # Upload without any conflicting segment directory 587 600 test_data = b"audio content" 588 601 resp = env.client.post( 589 - f"/app/remote/ingest/{key}", 602 + "/app/remote/ingest", 603 + headers={"Authorization": f"Bearer {key}"}, 590 604 data={ 591 605 "day": "20250103", 592 606 "segment": "120000_300", ··· 627 641 # Upload with same segment key 628 642 test_data = b"new audio" 629 643 resp = env.client.post( 630 - f"/app/remote/ingest/{key}", 644 + "/app/remote/ingest", 645 + headers={"Authorization": f"Bearer {key}"}, 631 646 data={ 632 647 "day": "20250103", 633 648 "segment": "120000_300", ··· 669 684 # Upload a file 670 685 test_data = b"test audio content for history" 671 686 resp = env.client.post( 672 - f"/app/remote/ingest/{key}", 687 + "/app/remote/ingest", 688 + headers={"Authorization": f"Bearer {key}"}, 673 689 data={ 674 690 "day": "20250103", 675 691 "segment": "120000_300", ··· 729 745 # Upload with same segment key 730 746 test_data = b"new audio content" 731 747 resp = env.client.post( 732 - f"/app/remote/ingest/{key}", 748 + "/app/remote/ingest", 749 + headers={"Authorization": f"Bearer {key}"}, 733 750 data={ 734 751 "day": "20250103", 735 752 "segment": "120000_300", ··· 774 791 key = resp.get_json()["key"] 775 792 776 793 # Query segments - should be empty 777 - resp = env.client.get(f"/app/remote/ingest/{key}/segments/20250103") 794 + resp = env.client.get("/app/remote/ingest/segments/20250103", headers={"Authorization": f"Bearer {key}"}) 778 795 assert resp.status_code == 200 779 796 assert resp.get_json() == [] 780 797 ··· 783 800 """Test segments endpoint rejects invalid key.""" 784 801 env = remote_env() 785 802 786 - resp = env.client.get("/app/remote/ingest/invalid-key/segments/20250103") 803 + resp = env.client.get("/app/remote/ingest/segments/20250103", headers={"Authorization": "Bearer invalid-key"}) 787 804 assert resp.status_code == 401 788 805 789 806 ··· 799 816 ) 800 817 key = resp.get_json()["key"] 801 818 802 - resp = env.client.get(f"/app/remote/ingest/{key}/segments/2025-01-03") 819 + resp = env.client.get("/app/remote/ingest/segments/2025-01-03", headers={"Authorization": f"Bearer {key}"}) 803 820 assert resp.status_code == 400 804 821 assert "Invalid day format" in resp.get_json()["error"] 805 822 ··· 819 836 # Upload a file 820 837 test_data = b"test audio content" 821 838 resp = env.client.post( 822 - f"/app/remote/ingest/{key}", 839 + "/app/remote/ingest", 840 + headers={"Authorization": f"Bearer {key}"}, 823 841 data={ 824 842 "day": "20250103", 825 843 "segment": "120000_300", ··· 829 847 assert resp.status_code == 200 830 848 831 849 # Query segments 832 - resp = env.client.get(f"/app/remote/ingest/{key}/segments/20250103") 850 + resp = env.client.get("/app/remote/ingest/segments/20250103", headers={"Authorization": f"Bearer {key}"}) 833 851 assert resp.status_code == 200 834 852 data = resp.get_json() 835 853 ··· 871 889 # Upload with collision 872 890 test_data = b"new audio" 873 891 resp = env.client.post( 874 - f"/app/remote/ingest/{key}", 892 + "/app/remote/ingest", 893 + headers={"Authorization": f"Bearer {key}"}, 875 894 data={ 876 895 "day": "20250103", 877 896 "segment": "120000_300", ··· 881 900 assert resp.status_code == 200 882 901 883 902 # Query segments 884 - resp = env.client.get(f"/app/remote/ingest/{key}/segments/20250103") 903 + resp = env.client.get("/app/remote/ingest/segments/20250103", headers={"Authorization": f"Bearer {key}"}) 885 904 data = resp.get_json() 886 905 887 906 assert len(data) == 1 ··· 910 929 # Upload a file 911 930 test_data = b"test audio" 912 931 resp = env.client.post( 913 - f"/app/remote/ingest/{key}", 932 + "/app/remote/ingest", 933 + headers={"Authorization": f"Bearer {key}"}, 914 934 data={ 915 935 "day": "20250103", 916 936 "segment": "120000_300", ··· 925 945 ).unlink() 926 946 927 947 # Query segments 928 - resp = env.client.get(f"/app/remote/ingest/{key}/segments/20250103") 948 + resp = env.client.get("/app/remote/ingest/segments/20250103", headers={"Authorization": f"Bearer {key}"}) 929 949 data = resp.get_json() 930 950 931 951 assert len(data) == 1 ··· 948 968 # Upload a file 949 969 test_data = b"test audio for relocation" 950 970 resp = env.client.post( 951 - f"/app/remote/ingest/{key}", 971 + "/app/remote/ingest", 972 + headers={"Authorization": f"Bearer {key}"}, 952 973 data={ 953 974 "day": "20250103", 954 975 "segment": "120000_300", ··· 965 986 original_path.rename(new_path) 966 987 967 988 # Query segments - should detect relocation by inode 968 - resp = env.client.get(f"/app/remote/ingest/{key}/segments/20250103") 989 + resp = env.client.get("/app/remote/ingest/segments/20250103", headers={"Authorization": f"Bearer {key}"}) 969 990 data = resp.get_json() 970 991 971 992 assert len(data) == 1 ··· 1026 1047 env.client.delete(f"/app/remote/api/{key_prefix}") 1027 1048 1028 1049 # Query segments - should be rejected 1029 - resp = env.client.get(f"/app/remote/ingest/{key}/segments/20250103") 1050 + resp = env.client.get("/app/remote/ingest/segments/20250103", headers={"Authorization": f"Bearer {key}"}) 1030 1051 assert resp.status_code == 403 1031 1052 assert "Remote revoked" in resp.get_json()["error"] 1032 1053 ··· 1050 1071 # Upload a file 1051 1072 test_data = b"test audio content" 1052 1073 resp = env.client.post( 1053 - f"/app/remote/ingest/{key}", 1074 + "/app/remote/ingest", 1075 + headers={"Authorization": f"Bearer {key}"}, 1054 1076 data={ 1055 1077 "day": "20250103", 1056 1078 "segment": "120000_300", ··· 1063 1085 # Upload the same file again (same content = same sha256) 1064 1086 # With duplicate detection, this should be rejected 1065 1087 resp = env.client.post( 1066 - f"/app/remote/ingest/{key}", 1088 + "/app/remote/ingest", 1089 + headers={"Authorization": f"Bearer {key}"}, 1067 1090 data={ 1068 1091 "day": "20250103", 1069 1092 "segment": "120000_300", ··· 1074 1097 assert resp.get_json()["status"] == "duplicate" 1075 1098 1076 1099 # Query segments - should have only one segment (duplicate was rejected) 1077 - resp = env.client.get(f"/app/remote/ingest/{key}/segments/20250103") 1100 + resp = env.client.get("/app/remote/ingest/segments/20250103", headers={"Authorization": f"Bearer {key}"}) 1078 1101 data = resp.get_json() 1079 1102 1080 1103 # Should have 1 segment (duplicate rejected, not 2 segments) ··· 1101 1124 # Upload a file 1102 1125 test_data = b"test audio content" 1103 1126 resp = env.client.post( 1104 - f"/app/remote/ingest/{key}", 1127 + "/app/remote/ingest", 1128 + headers={"Authorization": f"Bearer {key}"}, 1105 1129 data={ 1106 1130 "day": "20250103", 1107 1131 "segment": "120000_300", ··· 1111 1135 assert resp.status_code == 200 1112 1136 1113 1137 # Query segments - should show observed: false 1114 - resp = env.client.get(f"/app/remote/ingest/{key}/segments/20250103") 1138 + resp = env.client.get("/app/remote/ingest/segments/20250103", headers={"Authorization": f"Bearer {key}"}) 1115 1139 data = resp.get_json() 1116 1140 assert len(data) == 1 1117 1141 assert data[0]["observed"] is False ··· 1124 1148 f.write('{"ts": 1704312345000, "type": "observed", "segment": "120000_300"}\n') 1125 1149 1126 1150 # Query again - should now show observed: true 1127 - resp = env.client.get(f"/app/remote/ingest/{key}/segments/20250103") 1151 + resp = env.client.get("/app/remote/ingest/segments/20250103", headers={"Authorization": f"Bearer {key}"}) 1128 1152 data = resp.get_json() 1129 1153 assert len(data) == 1 1130 1154 assert data[0]["observed"] is True ··· 1181 1205 # First upload 1182 1206 test_data = b"test audio content for duplicate test" 1183 1207 resp = env.client.post( 1184 - f"/app/remote/ingest/{key}", 1208 + "/app/remote/ingest", 1209 + headers={"Authorization": f"Bearer {key}"}, 1185 1210 data={ 1186 1211 "day": "20250103", 1187 1212 "segment": "120000_300", ··· 1195 1220 1196 1221 # Second upload with identical content 1197 1222 resp = env.client.post( 1198 - f"/app/remote/ingest/{key}", 1223 + "/app/remote/ingest", 1224 + headers={"Authorization": f"Bearer {key}"}, 1199 1225 data={ 1200 1226 "day": "20250103", 1201 1227 "segment": "120000_300", ··· 1233 1259 1234 1260 # First upload - should emit 1235 1261 resp = env.client.post( 1236 - f"/app/remote/ingest/{key}", 1262 + "/app/remote/ingest", 1263 + headers={"Authorization": f"Bearer {key}"}, 1237 1264 data={ 1238 1265 "day": "20250103", 1239 1266 "segment": "120000_300", ··· 1245 1272 1246 1273 # Second upload - should NOT emit 1247 1274 resp = env.client.post( 1248 - f"/app/remote/ingest/{key}", 1275 + "/app/remote/ingest", 1276 + headers={"Authorization": f"Bearer {key}"}, 1249 1277 data={ 1250 1278 "day": "20250103", 1251 1279 "segment": "120000_300", ··· 1273 1301 1274 1302 # First upload 1275 1303 resp = env.client.post( 1276 - f"/app/remote/ingest/{key}", 1304 + "/app/remote/ingest", 1305 + headers={"Authorization": f"Bearer {key}"}, 1277 1306 data={ 1278 1307 "day": "20250103", 1279 1308 "segment": "120000_300", ··· 1289 1318 1290 1319 # Submit duplicate 1291 1320 resp = env.client.post( 1292 - f"/app/remote/ingest/{key}", 1321 + "/app/remote/ingest", 1322 + headers={"Authorization": f"Bearer {key}"}, 1293 1323 data={ 1294 1324 "day": "20250103", 1295 1325 "segment": "120000_300", ··· 1323 1353 1324 1354 # First upload with audio and screen 1325 1355 resp = env.client.post( 1326 - f"/app/remote/ingest/{key}", 1356 + "/app/remote/ingest", 1357 + headers={"Authorization": f"Bearer {key}"}, 1327 1358 data={ 1328 1359 "day": "20250103", 1329 1360 "segment": "120000_300", ··· 1332 1363 ) 1333 1364 # Add files manually for multipart 1334 1365 resp = env.client.post( 1335 - f"/app/remote/ingest/{key}", 1366 + "/app/remote/ingest", 1367 + headers={"Authorization": f"Bearer {key}"}, 1336 1368 data={ 1337 1369 "day": "20250103", 1338 1370 "segment": "120000_300", ··· 1349 1381 1350 1382 # Second upload with same audio but different screen 1351 1383 resp = env.client.post( 1352 - f"/app/remote/ingest/{key}", 1384 + "/app/remote/ingest", 1385 + headers={"Authorization": f"Bearer {key}"}, 1353 1386 data={ 1354 1387 "day": "20250103", 1355 1388 "segment": "120000_300", ··· 1385 1418 1386 1419 # First upload 1387 1420 resp = env.client.post( 1388 - f"/app/remote/ingest/{key}", 1421 + "/app/remote/ingest", 1422 + headers={"Authorization": f"Bearer {key}"}, 1389 1423 data={ 1390 1424 "day": "20250103", 1391 1425 "segment": "120000_300", ··· 1397 1431 # Second upload with same audio but new additional file 1398 1432 new_data = b"brand new file" 1399 1433 resp = env.client.post( 1400 - f"/app/remote/ingest/{key}", 1434 + "/app/remote/ingest", 1435 + headers={"Authorization": f"Bearer {key}"}, 1401 1436 data={ 1402 1437 "day": "20250103", 1403 1438 "segment": "120000_300", ··· 1453 1488 # Upload - will need collision resolution 1454 1489 test_data = b"new content" 1455 1490 resp = env.client.post( 1456 - f"/app/remote/ingest/{key}", 1491 + "/app/remote/ingest", 1492 + headers={"Authorization": f"Bearer {key}"}, 1457 1493 data={ 1458 1494 "day": "20250103", 1459 1495 "segment": "120000_300", ··· 1480 1516 1481 1517 # Upload a 0-byte file 1482 1518 resp = env.client.post( 1483 - f"/app/remote/ingest/{key}", 1519 + "/app/remote/ingest", 1520 + headers={"Authorization": f"Bearer {key}"}, 1484 1521 data={ 1485 1522 "day": "20250103", 1486 1523 "segment": "120000_300", ··· 1506 1543 # Upload one valid file and one 0-byte file 1507 1544 valid_data = b"real audio content" 1508 1545 resp = env.client.post( 1509 - f"/app/remote/ingest/{key}", 1546 + "/app/remote/ingest", 1547 + headers={"Authorization": f"Bearer {key}"}, 1510 1548 data={ 1511 1549 "day": "20250103", 1512 1550 "segment": "120000_300", ··· 1552 1590 test_data = b"tmux capture content" 1553 1591 meta = json.dumps({"host": "fedora", "platform": "linux", "stream": "fedora.tmux"}) 1554 1592 resp = env.client.post( 1555 - f"/app/remote/ingest/{key}", 1593 + "/app/remote/ingest", 1594 + headers={"Authorization": f"Bearer {key}"}, 1556 1595 data={ 1557 1596 "day": "20250103", 1558 1597 "segment": "120000_300",
+2 -2
observe/remote_cli.py
··· 140 140 print("Remote observer created:") 141 141 print(f" Name: {name}") 142 142 print(f" Prefix: {key[:8]}") 143 - print(f" Key: {key}") 144 - print(f" Ingest URL: /app/remote/ingest/{key}") 143 + print(f" server url: (set during server configuration)") 144 + print(f" api key: {key}") 145 145 return 0 146 146 147 147