fix terminal mesh chat and guarded IndeeHub playback

This commit is contained in:
archipelago
2026-10-10 07:52:47 -04:00
parent bbf8dc76c4
commit 71de1a3c27
15 changed files with 363 additions and 43 deletions
+99
View File
@@ -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::<u64>() == 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::<serde_json::Value>(terms)
.ok()
.is_some_and(|terms| zero_sats(terms.get("priceSats")))
}
pub struct ApiHandler {
config: Config,
rpc_handler: Arc<RpcHandler>,
@@ -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"]]}
})));
}
}
+13 -2
View File
@@ -178,10 +178,21 @@ pub(crate) async fn create(body: &[u8]) -> Result<Response<hyper::Body>> {
}
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,
+30 -11
View File
@@ -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<u8>)> {
/// 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<u8>)> {
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]);
+50
View File
@@ -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
@@ -386,6 +386,7 @@ DOCKERFILE_HEAD
python3 \
curl \
git \
tmux \
vim-tiny \
nano \
ca-certificates \
@@ -178,6 +178,7 @@ chroot /mnt/archipelago apt-get install -y \
curl \
wget \
htop \
tmux \
vim-tiny \
nano \
ca-certificates \
+29 -4
View File
@@ -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;
}
+5 -2
View File
@@ -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; } }
</style>
+20 -14
View File
@@ -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<typeof setInterval> | 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) {
<div class="mesh-peer-name">Public</div>
<div class="mesh-peer-sub">Mesh radio</div>
</div>
<span v-if="mesh.unreadCounts[channelContactId(0)]" class="ml-auto text-[10px] px-1.5 py-0.5 rounded-full bg-orange-500/30 text-orange-300">{{ mesh.unreadCounts[channelContactId(0)] }}</span>
<span v-if="publicChannelUnread" class="ml-auto text-[10px] px-1.5 py-0.5 rounded-full bg-orange-500/30 text-orange-300">{{ publicChannelUnread }}</span>
</div>
<div v-if="displayedPeers.length === 0 && peerSearch.trim()" class="mesh-empty">
No contacts match “{{ peerSearch.trim() }}”.
@@ -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)
})
})
@@ -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)
}
+14 -10
View File
@@ -167,16 +167,19 @@
</div>
<div class="mt-auto shrink-0 pt-4 border-t border-white/10">
<nav aria-label="Node connections" class="grid grid-cols-[repeat(2,minmax(0,1fr))_2.75rem] sm:grid-cols-[repeat(3,minmax(0,1fr))_2.75rem] gap-2">
<button @click="router.push({ name: 'federation', query: { view: 'connected' } })" class="mobile-card-action glass-button rounded-lg px-2 text-xs sm:text-sm font-medium text-white/90 hover:text-white text-center min-h-11">
My connections
</button>
<button @click="router.push({ name: 'federation', query: { view: 'discover' } })" class="mobile-card-action glass-button rounded-lg px-2 text-xs sm:text-sm font-medium text-white/90 hover:text-white text-center min-h-11">
Find Nodes
</button>
<button @click="router.push({ name: 'federation', query: { view: 'map' } })" class="mobile-card-action glass-button rounded-lg px-2 text-xs sm:text-sm font-medium text-white/90 hover:text-white text-center min-h-11 col-span-2 sm:col-span-1">
Map
</button>
<nav aria-label="Node connections" class="space-y-2 sm:grid sm:grid-cols-[repeat(3,minmax(0,1fr))_2.75rem] sm:gap-2 sm:space-y-0">
<div class="grid grid-cols-2 gap-2 sm:contents">
<button @click="router.push({ name: 'federation', query: { view: 'connected' } })" class="mobile-card-action glass-button rounded-lg w-full px-2 text-xs sm:text-sm font-medium text-white/90 hover:text-white text-center min-h-11">
My connections
</button>
<button @click="router.push({ name: 'federation', query: { view: 'discover' } })" class="mobile-card-action glass-button rounded-lg w-full px-2 text-xs sm:text-sm font-medium text-white/90 hover:text-white text-center min-h-11">
Find Nodes
</button>
</div>
<div class="flex gap-2 sm:contents">
<button @click="router.push({ name: 'federation', query: { view: 'map' } })" class="mobile-card-action glass-button rounded-lg flex-1 w-full px-2 text-xs sm:text-sm font-medium text-white/90 hover:text-white text-center min-h-11">
Map
</button>
<button
@click="refreshActiveTab"
:disabled="loadingPeers || loadingRequests"
@@ -186,6 +189,7 @@
>
<svg class="w-4 h-4" :class="{ 'animate-spin': loadingPeers || loadingRequests }" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 11a8 8 0 10-2.4 6M20 4v7h-7" /></svg>
</button>
</div>
</nav>
</div>
</div>
@@ -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')
@@ -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")
+1
View File
@@ -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