diff --git a/core/archipelago/src/api/handler/mod.rs b/core/archipelago/src/api/handler/mod.rs index e36fa4bf..3d0600ca 100644 --- a/core/archipelago/src/api/handler/mod.rs +++ b/core/archipelago/src/api/handler/mod.rs @@ -46,6 +46,43 @@ pub(super) fn build_response( .unwrap_or_else(|_| Response::new(hyper::Body::from("Internal error"))) } +fn zero_sats(value: Option<&serde_json::Value>) -> bool { + match value { + Some(serde_json::Value::Number(number)) => number.as_u64() == Some(0), + Some(serde_json::Value::String(number)) => number.parse::() == Ok(0), + _ => false, + } +} + +/// Public playback must be explicitly free in both the mutable project row and +/// any signed rental offer. A stale project price must never override signed +/// paid terms, and malformed offers fail closed. +fn indeehub_listing_is_publicly_free(listing: &serde_json::Value) -> bool { + if !zero_sats(listing.get("rentalPrice")) { + return false; + } + let Some(offer) = listing.get("offerEvent") else { + return true; + }; + if offer.is_null() { + return true; + } + let Some(tags) = offer.get("tags").and_then(serde_json::Value::as_array) else { + return false; + }; + let Some(terms) = tags.iter().find_map(|tag| { + let tag = tag.as_array()?; + (tag.first()?.as_str()? == "rental") + .then(|| tag.get(1)?.as_str()) + .flatten() + }) else { + return false; + }; + serde_json::from_str::(terms) + .ok() + .is_some_and(|terms| zero_sats(terms.get("priceSats"))) +} + pub struct ApiHandler { config: Config, rpc_handler: Arc, @@ -282,6 +319,38 @@ impl ApiHandler { hyper::Body::from(r#"{"error":"invalid content id"}"#), )); } + let catalog = reqwest::Client::new() + .get("http://127.0.0.1:7778/api/projects/public-catalog") + .header(reqwest::header::ACCEPT, "application/json") + .send() + .await?; + if !catalog.status().is_success() { + return Ok(build_response( + StatusCode::BAD_GATEWAY, + "application/json", + hyper::Body::from(r#"{"error":"IndeeHub catalog unavailable"}"#), + )); + } + let listings: serde_json::Value = catalog.json().await?; + let listing = listings.as_array().and_then(|items| { + items + .iter() + .find(|item| item.get("id").and_then(|v| v.as_str()) == Some(id)) + }); + let Some(listing) = listing else { + return Ok(build_response( + StatusCode::NOT_FOUND, + "application/json", + hyper::Body::from(r#"{"error":"film is not publicly listed"}"#), + )); + }; + if !indeehub_listing_is_publicly_free(listing) { + return Ok(build_response( + StatusCode::PAYMENT_REQUIRED, + "application/json", + hyper::Body::from(r#"{"error":"rental required"}"#), + )); + } let upstream = reqwest::Client::new() .get(format!("http://127.0.0.1:7778/api/contents/{id}/stream")) .header(reqwest::header::ACCEPT, "application/json") @@ -1346,3 +1415,33 @@ mod terminal_origin_tests { )); } } + +#[cfg(test)] +mod indeehub_public_stream_tests { + use super::indeehub_listing_is_publicly_free; + use serde_json::json; + + #[test] + fn public_stream_requires_explicitly_free_project_and_signed_terms() { + assert!(indeehub_listing_is_publicly_free(&json!({ + "rentalPrice": 0, + "offerEvent": null + }))); + assert!(indeehub_listing_is_publicly_free(&json!({ + "rentalPrice": "0", + "offerEvent": {"tags": [["rental", "{\"priceSats\":0}"]]} + }))); + assert!(!indeehub_listing_is_publicly_free(&json!({ + "rentalPrice": 15, + "offerEvent": null + }))); + assert!(!indeehub_listing_is_publicly_free(&json!({ + "rentalPrice": 0, + "offerEvent": {"tags": [["rental", "{\"priceSats\":15}"]]} + }))); + assert!(!indeehub_listing_is_publicly_free(&json!({ + "rentalPrice": 0, + "offerEvent": {"tags": [["rental", "invalid"]]} + }))); + } +} diff --git a/core/archipelago/src/api/handler/terminal.rs b/core/archipelago/src/api/handler/terminal.rs index 1eb31cab..89aaac84 100644 --- a/core/archipelago/src/api/handler/terminal.rs +++ b/core/archipelago/src/api/handler/terminal.rs @@ -178,10 +178,21 @@ pub(crate) async fn create(body: &[u8]) -> Result> { } let id = format!("s-{}", Uuid::new_v4().simple()); let tmux_name = format!("archy-{id}"); - let output = Command::new("tmux") + let output = match Command::new("tmux") .args(["new-session", "-d", "-s", &tmux_name, "-c", &workspace]) .output() - .await?; + .await + { + Ok(output) => output, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(build_response( + StatusCode::SERVICE_UNAVAILABLE, + "application/json", + hyper::Body::from(r#"{"error":"Terminal runtime is not installed on this node"}"#), + )); + } + Err(error) => return Err(error.into()), + }; if !output.status.success() { return Ok(build_response( StatusCode::SERVICE_UNAVAILABLE, diff --git a/core/archipelago/src/mesh/protocol.rs b/core/archipelago/src/mesh/protocol.rs index 4aa8d43b..fdf6b221 100644 --- a/core/archipelago/src/mesh/protocol.rs +++ b/core/archipelago/src/mesh/protocol.rs @@ -551,17 +551,19 @@ pub fn parse_contact_msg_v3(data: &[u8]) -> Result<(String, String, i8)> { } /// Parse RESP_CHANNEL_MSG_V3 (0x11) - channel message. -/// Format: channel_idx(1B) + path_len(1B) + txt_type(1B) + timestamp(4B) + text +/// Format after the response code has been removed: +/// SNR(1B) + reserved(2B) + channel_idx(1B) + path_len(1B) + +/// txt_type(1B) + timestamp(4B) + text. /// Returns (channel_idx, text). pub fn parse_channel_msg_v3(data: &[u8]) -> Result<(u8, String)> { - if data.len() < 7 { + if data.len() < 10 { anyhow::bail!("Channel message too short: {} bytes", data.len()); } - let channel_idx = data[0]; - // data[1] = path_len, data[2] = txt_type - // data[3..7] = timestamp - let text = if data.len() > 7 { - String::from_utf8_lossy(&data[7..]) + let channel_idx = data[3]; + // data[0] = SNR, data[1..3] = reserved, data[4] = path_len, + // data[5] = txt_type, data[6..10] = timestamp. + let text = if data.len() > 10 { + String::from_utf8_lossy(&data[10..]) .trim_end_matches('\0') .to_string() } else { @@ -649,12 +651,12 @@ pub fn parse_contact_msg_v1_raw(data: &[u8]) -> Result<(String, Vec)> { /// Parse RESP_CHANNEL_MSG_V3 returning raw payload bytes. /// Returns (channel_idx, raw_payload_bytes). pub fn parse_channel_msg_v3_raw(data: &[u8]) -> Result<(u8, Vec)> { - if data.len() < 7 { + if data.len() < 10 { anyhow::bail!("Channel message too short: {} bytes", data.len()); } - let channel_idx = data[0]; - let payload = if data.len() > 7 { - let mut p = data[7..].to_vec(); + let channel_idx = data[3]; + let payload = if data.len() > 10 { + let mut p = data[10..].to_vec(); // Strip trailing NUL bytes while p.last() == Some(&0) { p.pop(); @@ -779,6 +781,23 @@ pub fn parse_identity_broadcast(msg: &str) -> Option<(String, String, String)> { mod tests { use super::*; + #[test] + fn parses_v3_channel_after_snr_and_reserved_prefix() -> Result<()> { + // decode_frame removes the 0x11 response code before this parser sees + // the payload. A real V3 frame then begins with SNR + two reserved + // bytes; treating SNR as the channel index filed public messages under + // random channels such as 47 or 208. + let data = [ + 0xd0, 0x00, 0x00, // SNR and reserved + 0x00, 0xff, 0x00, // public channel, path, text type + 0x78, 0x56, 0x34, 0x12, // timestamp + b'h', b'e', b'l', b'l', b'o', 0x00, + ]; + assert_eq!(parse_channel_msg_v3(&data)?, (0, "hello".to_string())); + assert_eq!(parse_channel_msg_v3_raw(&data)?, (0, b"hello".to_vec())); + Ok(()) + } + #[test] fn test_encode_frame() { let frame = encode_frame(&[CMD_DEVICE_QUERY, PROTOCOL_VERSION]); diff --git a/docs/post-1.8.22-regressions-20261001.md b/docs/post-1.8.22-regressions-20261001.md index fd9bf8a6..31749f8a 100644 --- a/docs/post-1.8.22-regressions-20261001.md +++ b/docs/post-1.8.22-regressions-20261001.md @@ -42,6 +42,56 @@ acceptance; this is a new paid-file incident. ## Current tasks +- 2026-10-10 three-node IndeeHub/terminal UAT checkpoint: Yaya, Framework and + Shorty serve byte-identical IndeeHub entry pages + (`0a2ef3393aacd96d834ad4666d4aa4c64c5fe0cc28f973351cfbc73a78156e7a`), + dashboard entry pages + (`b41a8d3c87ba923c9a4e5f7d0a61dafac593a3438510482d9e6b85a53bb63c9f`) + and management binaries + (`daf39c71b7029c0b51f8b74cc146fb3dd9af287744856e0e6a63bb63b40829ab`). + All manager health endpoints report `ok` with crash recovery complete. + Bitcoin/LND container IDs remained exact across the manager restarts. The + canonical runtime Nginx source is identical on all three nodes at + `524fd418ce71c6399f2772f7a105a968254e9a6414c96e45376422c414c18658`; + this matters because manager bootstrap otherwise restored an older runtime + template and removed terminal/playback routes on restart. + + Terminal creation and authenticated listing pass on Yaya and Framework. + The live failure was missing `tmux`, after routing and origin failures had + separately been corrected. `tmux` is installed on all three UAT nodes and is + now required by both ISO installation paths. Shorty's proxy/runtime is + installed and Nginx-valid, but authenticated creation was not accepted because + its dashboard password differs; keep that as unverified rather than a pass. + Test-created Yaya/Framework session records were moved recoverably under the + support directory after acceptance and no longer appear in the session list. + The UI now includes actual response details for create failures instead of the + generic message, and its empty state occupies and centers within the full + terminal viewport. That dashboard build is byte-identical on all three nodes. + + Cross-node public film discovery now works through FIPS in both directions: + Yaya receives Framework's *Arch x Indie* and Framework receives Yaya's + *Web5 Explained*. Framework's explicitly free film returned a JSON stream, + HLS manifest, 16-byte AES key and 1,736,192-byte MPEG-TS segment through the + Yaya same-origin bridge. A negative test found Yaya's project row said zero + while its signed offer required 15 sats; the first bridge build would have + treated that mismatch as free. The deployed guard now requires both project + data and signed terms to be explicitly zero. Framework receives HTTP 402 + `rental required` and no stream URL for that title. Never weaken this guard to + make a paid playback test pass. Paid Cashu/Lightning rental, metered ecash, + entitlement recovery without another payment, and the intended native-identity + replacement for the old `0494` offer remain open end-to-end acceptance gates. + + MeshCore V3 parsing now reads the channel after SNR/reserved bytes. The UI + recovers already-persisted impossible MeshCore channel IDs into Public while + preserving valid channels and other transports. Connected Nodes uses `Map`, + with full-width My connections/Find Nodes controls on mobile. Focused frontend + tests (8), TypeScript, production build, terminal packaging regression and + formatting pass. Three targeted backend regressions pass through the isolated + runner: guarded public streaming, MeshCore V3 channel parsing, and the peer + content path filter. Source review/commit, ngit proposal disposition, exact + main mirroring and a complete mirror audit remain required before OTA/ISO. No + OTA or ISO was published by this UAT deployment. + - 2026-10-09 dual-node UAT correction: Archy `main` at `5b0b35401` was already serving identical backend and dashboard artifacts on Yaya and Framework; browser cache hid the new device-model screens. Yaya's terminal failed diff --git a/image-recipe/_archived/build-auto-installer-iso.sh b/image-recipe/_archived/build-auto-installer-iso.sh index 53b2f6ad..105a8371 100755 --- a/image-recipe/_archived/build-auto-installer-iso.sh +++ b/image-recipe/_archived/build-auto-installer-iso.sh @@ -386,6 +386,7 @@ DOCKERFILE_HEAD python3 \ curl \ git \ + tmux \ vim-tiny \ nano \ ca-certificates \ diff --git a/image-recipe/archipelago-scripts/install-to-disk.sh b/image-recipe/archipelago-scripts/install-to-disk.sh index 073f5eb4..8f20ec08 100755 --- a/image-recipe/archipelago-scripts/install-to-disk.sh +++ b/image-recipe/archipelago-scripts/install-to-disk.sh @@ -178,6 +178,7 @@ chroot /mnt/archipelago apt-get install -y \ curl \ wget \ htop \ + tmux \ vim-tiny \ nano \ ca-certificates \ diff --git a/image-recipe/configs/nginx-archipelago.conf b/image-recipe/configs/nginx-archipelago.conf index 4e380525..8dff0c61 100644 --- a/image-recipe/configs/nginx-archipelago.conf +++ b/image-recipe/configs/nginx-archipelago.conf @@ -43,9 +43,13 @@ map "$host|$http_origin" $archy_cloud_picker_origin { default ""; "~^([^|]+)\|http://\1:7778$" $http_origin; } -map "$request_method|$http_sec_fetch_site" $archy_terminal_origin { +map "$request_method|$http_sec_fetch_site|$http_origin" $archy_terminal_origin { default $http_origin; - "GET|same-origin" "$scheme://$http_host"; + ~^GET\|same-origin\| "$scheme://$http_host"; + # Older WebViews can omit both Origin and Fetch Metadata on same-origin + # GETs. Only synthesize in that header-free case; a cross-origin request + # carrying Origin still reaches the backend unchanged and is rejected. + "GET||" "$scheme://$http_host"; } # Rate limit zones @@ -295,13 +299,25 @@ server { location ^~ /api/terminal/ { proxy_pass http://127.0.0.1:5678; proxy_http_version 1.1; - proxy_set_header Host $host; + proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header Cookie $http_cookie; proxy_set_header Origin $archy_terminal_origin; proxy_buffering off; } + # Browser-facing bridge for IndeeHub streams hosted by connected nodes. + # The backend enforces the owner session on peer routes and free-content + # policy on public routes; keep these ahead of the dashboard SPA fallback. + location ~ ^/api/(peer|public)-indeehub-(stream|media|key)/ { + proxy_pass http://127.0.0.1:5678; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header Cookie $http_cookie; + proxy_buffering off; + } + location / { try_files $uri $uri/ /index.html; add_header Cache-Control "no-cache, must-revalidate"; @@ -1309,13 +1325,22 @@ server { location ^~ /api/terminal/ { proxy_pass http://127.0.0.1:5678; proxy_http_version 1.1; - proxy_set_header Host $host; + proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header Cookie $http_cookie; proxy_set_header Origin $archy_terminal_origin; proxy_buffering off; } + location ~ ^/api/(peer|public)-indeehub-(stream|media|key)/ { + proxy_pass http://127.0.0.1:5678; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header Cookie $http_cookie; + proxy_buffering off; + } + location / { try_files $uri $uri/ /index.html; } diff --git a/neode-ui/src/components/CLIPopup.vue b/neode-ui/src/components/CLIPopup.vue index eecd66ac..42988780 100644 --- a/neode-ui/src/components/CLIPopup.vue +++ b/neode-ui/src/components/CLIPopup.vue @@ -88,7 +88,10 @@ async function createSession() { error.value = '' try { const response = await fetch('/api/terminal/sessions', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Archipelago work' }) }) - if (!response.ok) throw new Error('Could not create a developer session.') + if (!response.ok) { + const payload = await response.json().catch(() => null) as { error?: string } | null + throw new Error(payload?.error || `Could not create a developer session (${response.status}).`) + } const session = await response.json() sessions.value = [session, ...sessions.value] selectedId.value = session.id @@ -215,6 +218,6 @@ onBeforeUnmount(() => { .terminal-select, .terminal-icon-button, .terminal-button { min-height: 36px; border-radius: 9px; border: 1px solid rgba(255,255,255,.12); background: rgba(255,255,255,.06); color: white; padding: 0 9px; font-size: 12px; } .terminal-icon-button:hover, .terminal-button:hover, .terminal-select:hover { background: rgba(255,255,255,.13); } .terminal-primary { background: #f7931a; border-color: #f7931a; color: #17100a; font-weight: 700; } -.terminal-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; height: 100%; min-height: 300px; padding: 32px; text-align: center; color: rgba(255,255,255,.55); } .terminal-empty strong { color: white; font-size: 16px; } .terminal-error strong { color: #ffaaa6; } +.terminal-empty { display: flex; flex: 1 1 100%; width: 100%; flex-direction: column; align-items: center; justify-content: center; gap: 10px; height: 100%; min-height: 300px; padding: 32px; text-align: center; color: rgba(255,255,255,.55); } .terminal-empty strong { color: white; font-size: 16px; } .terminal-error strong { color: #ffaaa6; } @media (max-width: 640px) { .terminal-panel { width: calc(100vw - 1rem); height: calc(100vh - 1rem); max-width: calc(100vw - 1rem); max-height: calc(100vh - 1rem); } .terminal-header { align-items: flex-start; flex-direction: column; } .terminal-actions { width: 100%; flex-wrap: wrap; } .terminal-select { flex: 1; min-width: 0; } .terminal-status { display: none; } } diff --git a/neode-ui/src/views/Mesh.vue b/neode-ui/src/views/Mesh.vue index 7aa89529..a2ec5922 100644 --- a/neode-ui/src/views/Mesh.vue +++ b/neode-ui/src/views/Mesh.vue @@ -20,6 +20,7 @@ import MediaLightbox from '@/components/cloud/MediaLightbox.vue' import type { FileBrowserItem } from '@/api/filebrowser-client' import { useCachedResource } from '@/composables/useCachedResource' import RefreshIndicator from '@/components/RefreshIndicator.vue' +import { channelContactId, isChannelMessage, isPublicChannelMessage } from '@/views/mesh/channelMessages' import '@/views/mesh/mesh-styles.css' const mesh = useMeshStore() @@ -78,18 +79,18 @@ let pollInterval: ReturnType | null = null let wsUnsub: (() => void) | null = null // The Public channel (always available on Meshcore) -// "Public" maps to meshcore slot 1 — the configured "archipelago" channel -// in mesh-config.json. Slot 0 is the firmware default Public, which uses -// the universal meshcore key and only works between devices sharing keys -// + RF region. Slot 1 is set by archipelago first-boot to a hash derived -// from the channel_name, so all archipelago nodes are guaranteed on the -// same channel regardless of region. +// "Public" is the stock MeshCore/Meshtastic public channel in slot 0. const publicChannel = { index: 0, name: 'Public' } -// Channel contact_id convention: matches backend u32::MAX - channel_index -function channelContactId(channelIndex: number): number { - return 4294967295 - channelIndex // u32::MAX - index -} +const publicChannelUnread = computed(() => { + const contactIds = new Set([ + channelContactId(publicChannel.index), + ...mesh.messages.filter(isPublicChannelMessage).map(message => message.peer_contact_id), + ]) + return Object.entries(mesh.unreadCounts) + .filter(([contactId]) => contactIds.has(Number(contactId))) + .reduce((total, [, count]) => total + count, 0) +}) // Archipelago Channel — Tor-based messaging to all federated/peered nodes const archChannelActive = ref(false) @@ -714,8 +715,10 @@ const chatMessages = computed(() => { const HIDDEN_TYPES = new Set(['reaction', 'read_receipt', 'edit', 'presence', 'channel_invite', 'contact_card']) const hideReactions = (m: MeshMessage) => !m.message_type || !HIDDEN_TYPES.has(m.message_type) if (activeChatChannel.value) { - const chanId = channelContactId(activeChatChannel.value.index) - return mesh.messages.filter(m => m.peer_contact_id === chanId && hideReactions(m)) + return mesh.messages.filter(m => { + const belongsToChannel = isChannelMessage(m, activeChatChannel.value!.index) + return belongsToChannel && hideReactions(m) + }) } if (activeChatPeer.value) { // Pull from every underlying contact_id in the merged group so radio @@ -1134,7 +1137,10 @@ function openChannelChat(channel: { index: number; name: string }) { messageText.value = '' activeTab.value = 'chat' mobileShowChat.value = true - mesh.markChatRead(channelContactId(channel.index)) + const contactIds = channel.index === publicChannel.index + ? mesh.messages.filter(isPublicChannelMessage).map(message => message.peer_contact_id) + : [channelContactId(channel.index)] + mesh.markChatRead([...new Set([channelContactId(channel.index), ...contactIds])]) nextTick(() => scrollChatToBottom()) } @@ -2275,7 +2281,7 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
Public
Mesh radio
- {{ mesh.unreadCounts[channelContactId(0)] }} + {{ publicChannelUnread }}
No contacts match “{{ peerSearch.trim() }}”. diff --git a/neode-ui/src/views/mesh/__tests__/channelMessages.test.ts b/neode-ui/src/views/mesh/__tests__/channelMessages.test.ts new file mode 100644 index 00000000..dfed6c7c --- /dev/null +++ b/neode-ui/src/views/mesh/__tests__/channelMessages.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import type { MeshMessage } from '@/stores/mesh' +import { channelContactId, isChannelMessage, isPublicChannelMessage } from '../channelMessages' + +function message(peerContactId: number, transport = 'meshcore'): MeshMessage { + return { + id: 1, + direction: 'received', + peer_contact_id: peerContactId, + peer_name: null, + plaintext: 'hello', + timestamp: '2026-10-10T00:00:00Z', + delivered: true, + encrypted: false, + transport, + message_type: 'text', + typed_payload: null, + sender_pubkey: null, + sender_seq: null, + } +} + +describe('Mesh public-channel compatibility', () => { + it('shows a correctly decoded slot-zero message in Public', () => { + expect(isPublicChannelMessage(message(channelContactId(0)))).toBe(true) + }) + + it('recovers persisted V3 messages whose SNR was mistaken for a channel index', () => { + expect(isPublicChannelMessage(message(channelContactId(47)))).toBe(true) + expect(isPublicChannelMessage(message(channelContactId(208)))).toBe(true) + }) + + it('does not merge valid non-public slots or another transport into Public', () => { + expect(isPublicChannelMessage(message(channelContactId(1)))).toBe(false) + expect(isPublicChannelMessage(message(channelContactId(47), 'meshtastic'))).toBe(false) + expect(isChannelMessage(message(channelContactId(1)), 1)).toBe(true) + }) +}) + diff --git a/neode-ui/src/views/mesh/channelMessages.ts b/neode-ui/src/views/mesh/channelMessages.ts new file mode 100644 index 00000000..65a8a138 --- /dev/null +++ b/neode-ui/src/views/mesh/channelMessages.ts @@ -0,0 +1,27 @@ +import type { MeshMessage } from '@/stores/mesh' + +const MAX_U32 = 4294967295 +const MAX_MESHCORE_CHANNEL_INDEX = 7 + +export function channelContactId(channelIndex: number): number { + return MAX_U32 - channelIndex +} + +export function isPublicChannelMessage(message: MeshMessage): boolean { + if (message.peer_contact_id === channelContactId(0)) return true + + // Compatibility for messages persisted by the old V3 decoder, which used + // SNR as the channel index. MeshCore has only slots 0..7, making 8..255 an + // unambiguous legacy decoder artifact. + const derivedIndex = MAX_U32 - message.peer_contact_id + return message.transport === 'meshcore' + && derivedIndex > MAX_MESHCORE_CHANNEL_INDEX + && derivedIndex <= 255 +} + +export function isChannelMessage(message: MeshMessage, channelIndex: number): boolean { + return channelIndex === 0 + ? isPublicChannelMessage(message) + : message.peer_contact_id === channelContactId(channelIndex) +} + diff --git a/neode-ui/src/views/web5/Web5ConnectedNodes.vue b/neode-ui/src/views/web5/Web5ConnectedNodes.vue index a609d71f..af8a8fcc 100644 --- a/neode-ui/src/views/web5/Web5ConnectedNodes.vue +++ b/neode-ui/src/views/web5/Web5ConnectedNodes.vue @@ -167,16 +167,19 @@
-
diff --git a/neode-ui/src/views/web5/__tests__/Web5ConnectedNodes.test.ts b/neode-ui/src/views/web5/__tests__/Web5ConnectedNodes.test.ts index 01f5f0cf..75800a61 100644 --- a/neode-ui/src/views/web5/__tests__/Web5ConnectedNodes.test.ts +++ b/neode-ui/src/views/web5/__tests__/Web5ConnectedNodes.test.ts @@ -54,6 +54,10 @@ describe('Web5ConnectedNodes', () => { expect(nav.text()).toContain('My connections') expect(nav.text()).toContain('Find Nodes') expect(nav.text()).toContain('Map') + expect(nav.text()).not.toContain('Network Map') + const mobilePrimaryRow = nav.get('div.grid.grid-cols-2') + expect(mobilePrimaryRow.findAll('button')).toHaveLength(2) + expect(mobilePrimaryRow.findAll('button').every(button => button.classes().includes('w-full'))).toBe(true) await nav.findAll('button')[0]!.trigger('click') await nav.findAll('button')[1]!.trigger('click') await nav.findAll('button')[2]!.trigger('click') diff --git a/tests/regression/terminal-runtime-packaging.py b/tests/regression/terminal-runtime-packaging.py new file mode 100644 index 00000000..e3876548 --- /dev/null +++ b/tests/regression/terminal-runtime-packaging.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +"""Keep the browser terminal's runtime and exact-authority proxy in releases.""" + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +builder = (ROOT / "image-recipe/_archived/build-auto-installer-iso.sh").read_text() +installer = (ROOT / "image-recipe/archipelago-scripts/install-to-disk.sh").read_text() +nginx = (ROOT / "image-recipe/configs/nginx-archipelago.conf").read_text() + +rootfs_packages = builder.split("cat >> \"$WORK_DIR/Dockerfile.rootfs\"", 1)[1].split( + "# Create archipelago systemd service", 1 +)[0] +assert " tmux \\\n" in rootfs_packages, "fresh ISO rootfs omits tmux" +assert " tmux \\\n" in installer, "install-to-disk path omits tmux" + +assert 'map "$request_method|$http_sec_fetch_site|$http_origin" $archy_terminal_origin' in nginx +terminal_locations = nginx.split("location ^~ /api/terminal/ {")[1:] +assert len(terminal_locations) == 2, "expected HTTP and HTTPS terminal proxies" +for location in terminal_locations: + block = location.split("}", 1)[0] + assert "proxy_set_header Host $http_host;" in block, "terminal proxy drops authority port" + assert "proxy_set_header Origin $archy_terminal_origin;" in block + +assert nginx.count( + "location ~ ^/api/(peer|public)-indeehub-(stream|media|key)/ {" +) == 2, "HTTP and HTTPS must proxy IndeeHub playback before the SPA fallback" + +print("PASS terminal runtime packaging and proxy authority") diff --git a/tests/release/run.sh b/tests/release/run.sh index f429cc8a..ad8b8fbb 100755 --- a/tests/release/run.sh +++ b/tests/release/run.sh @@ -73,6 +73,7 @@ stage "git-diff-check" git diff --check stage "mirror-gate-regression" python3 scripts/tests/test_git_mirrors.py stage "iso-boot-runner-regression" python3 scripts/tests/test_iso_qemu_runner.py stage "iso-qualified-web-ui" python3 tests/regression/iso-qualified-web-ui.py +stage "terminal-runtime-packaging" python3 tests/regression/terminal-runtime-packaging.py stage "first-boot-retry" python3 tests/regression/first-boot-retry.py stage "demo-rpc-parity" timeout 120 node neode-ui/scripts/mock-rpc-parity.mjs stage "demo-resumable-uploads" timeout 120 node --test neode-ui/scripts/demo-upload-test.mjs