diff --git a/android/.gitignore b/android/.gitignore deleted file mode 100644 index c35e177a..00000000 --- a/android/.gitignore +++ /dev/null @@ -1,12 +0,0 @@ -.gradle/ -build/ -local.properties -*.iml -.idea/ -captures/ -.cxx/ - -# The gomobile binding is a build artifact (~24 MB). Regenerate it from ../mobile -# with `gomobile bind` (see README.md); it is not versioned. -app/libs/*.aar -app/libs/*.jar diff --git a/android/README.md b/android/README.md deleted file mode 100644 index 6f4341e2..00000000 --- a/android/README.md +++ /dev/null @@ -1,83 +0,0 @@ -# unibus · app Android - -Cliente móvil nativo de unibus. La app no habla con un gateway: embebe un **peer -real** del bus a través del binding gomobile `mobile/unibus.go`, de modo que el -cifrado extremo a extremo corre **en el dispositivo**. Cada teléfono es un peer -de primera clase del bus, igual que cualquier peer Go. - -## Arquitectura - -``` -Kotlin/Compose UI ──> BusViewModel ──> com.unibus.core.mobile.Session (.aar) - │ (NATS data plane + E2E crypto, en Go) - ▼ - membershipd (control plane HTTP :8470) - NATS (data plane :4250) -``` - -- `BusViewModel` traduce intents de UI en llamadas al binding. Las llamadas de red - (`newSession`, `createRoom`, `join`, `publish`) corren en `Dispatchers.IO`. -- Los frames entrantes llegan por `FrameListener.onFrame` en una goroutine NATS - (hilo JNI); se publican en un `StateFlow` (thread-safe) que Compose recolecta en - el hilo principal. - -## Requisitos - -- Android SDK (compileSdk 34), NDK (para regenerar el `.aar`), JDK 17. -- El binding `app/libs/unibus.aar` (no versionado: es un artefacto de ~24 MB). - -## 1. Generar el binding (.aar) - -Desde la raíz del repo de la app (`projects/message_bus/apps/unibus`): - -```bash -export ANDROID_HOME=$HOME/android-sdk -export ANDROID_NDK_HOME=$HOME/android-sdk/ndk/26.3.11579264 -mkdir -p android/app/libs -gomobile bind -target=android -androidapi 21 -javapkg com.unibus.core \ - -o android/app/libs/unibus.aar ./mobile -``` - -Esto produce `unibus.aar` con la clase estática `com.unibus.core.mobile.Mobile` -(`generateIdentity`, `newSession`) y los tipos `Session` y `FrameListener`. - -## 2. Compilar el APK - -```bash -cd android -export JAVA_HOME=$HOME/android-sdk/jdk-17/jdk-17.0.19+10 -export ANDROID_HOME=$HOME/android-sdk -./gradlew assembleDebug -# APK: app/build/outputs/apk/debug/app-debug.apk -``` - -`local.properties` apunta a `sdk.dir`; ajústalo si tu SDK está en otra ruta. - -## 3. Arrancar el bus y probar en el emulador - -```bash -# 1. En el PC: control plane + NATS embebido (HTTP :8470, NATS :4250) -cd projects/message_bus/apps/unibus && go run ./cmd/membershipd - -# 2. Emulador Pixel_API34 -$ANDROID_HOME/emulator/emulator -avd Pixel_API34 & - -# 3. Instalar + lanzar -adb install -r app/build/outputs/apk/debug/app-debug.apk -adb shell am start -n com.unibus.app/.MainActivity -``` - -En la pantalla de conexión, desde el emulador el host del PC es `10.0.2.2`: - -- **Host (control plane):** `http://10.0.2.2:8470` -- **NATS (data plane):** `nats://10.0.2.2:4250` - -Para un teléfono físico en la misma LAN, usa la IP LAN del PC en lugar de -`10.0.2.2`. - -## Notas - -- La identidad del peer se guarda en `filesDir/peer.id` (claves privadas - Ed25519 + X25519). No se sincroniza ni se respalda. -- Una room creada en modo "cifrar (E2E)" usa la política Matrix (cifrada, - persistida, firmada); en modo normal usa NATS cleartext. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts deleted file mode 100644 index 78c52a3a..00000000 --- a/android/app/build.gradle.kts +++ /dev/null @@ -1,66 +0,0 @@ -plugins { - id("com.android.application") - id("org.jetbrains.kotlin.android") - id("org.jetbrains.kotlin.plugin.compose") -} - -android { - namespace = "com.unibus.app" - compileSdk = 34 - - defaultConfig { - applicationId = "com.unibus.app" - minSdk = 21 - targetSdk = 34 - versionCode = 1 - versionName = "0.1.0" - } - - buildFeatures { - compose = true - } - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - kotlinOptions { - jvmTarget = "17" - } - - buildTypes { - getByName("release") { - isMinifyEnabled = false - proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro", - ) - } - } - - packaging { - resources { - excludes += "/META-INF/{AL2.0,LGPL2.1}" - } - } -} - -dependencies { - // The unibus gomobile binding: a real bus peer that does NATS + E2E crypto - // on the device. All protocol logic lives here, shared with every other peer. - implementation(files("libs/unibus.aar")) - - val composeBom = platform("androidx.compose:compose-bom:2024.09.03") - implementation(composeBom) - implementation("androidx.compose.ui:ui") - implementation("androidx.compose.ui:ui-graphics") - implementation("androidx.compose.ui:ui-tooling-preview") - implementation("androidx.compose.material3:material3") - implementation("androidx.compose.material:material-icons-extended") - implementation("androidx.activity:activity-compose:1.9.2") - implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.6") - implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.6") - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") - - debugImplementation("androidx.compose.ui:ui-tooling") -} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro deleted file mode 100644 index e3de6b13..00000000 --- a/android/app/proguard-rules.pro +++ /dev/null @@ -1,4 +0,0 @@ -# gomobile generates JNI-bound classes under com.unibus.core.mobile and go.*. -# They are reached from native code, so keep them intact even when minifying. --keep class com.unibus.core.mobile.** { *; } --keep class go.** { *; } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml deleted file mode 100644 index 55345ef5..00000000 --- a/android/app/src/main/AndroidManifest.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/android/app/src/main/java/com/unibus/app/BusViewModel.kt b/android/app/src/main/java/com/unibus/app/BusViewModel.kt deleted file mode 100644 index 28deafb5..00000000 --- a/android/app/src/main/java/com/unibus/app/BusViewModel.kt +++ /dev/null @@ -1,162 +0,0 @@ -package com.unibus.app - -import android.app.Application -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.viewModelScope -import com.unibus.core.mobile.FrameListener -import com.unibus.core.mobile.Mobile -import com.unibus.core.mobile.Session -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import java.io.File - -/** One chat message shown in the UI. */ -data class ChatMessage( - val sender: String, - val text: String, - val mine: Boolean, - val ts: Long, -) - -/** The whole observable UI state of the app. */ -data class BusState( - val connecting: Boolean = false, - val connected: Boolean = false, - val endpointId: String = "", - val roomId: String = "", - val roomSubject: String = "", - val status: String = "", - val error: String? = null, - val messages: List = emptyList(), -) - -/** - * BusViewModel drives a real unibus peer on the device through the gomobile - * binding. The binding performs NATS transport and end-to-end crypto natively; - * this class only translates UI intents into binding calls and exposes the - * incoming frames as observable state. - * - * Threading: every binding call that touches the network (newSession, createRoom, - * join, publish) runs off the main thread on Dispatchers.IO to avoid - * NetworkOnMainThreadException. Incoming frames arrive on a JNI-attached NATS - * goroutine via [onFrame]; we only append to a thread-safe StateFlow there, and - * Compose collects that flow on the main thread. - */ -class BusViewModel(app: Application) : AndroidViewModel(app), FrameListener { - private val _state = MutableStateFlow(BusState()) - val state: StateFlow = _state.asStateFlow() - - private var session: Session? = null - private var myEndpoint: String = "" - - private val idPath: String - get() = File(getApplication().filesDir, "peer.id").absolutePath - - override fun onFrame(roomID: String, sender: String, msgID: String, text: String) { - _state.update { - it.copy( - messages = it.messages + ChatMessage( - sender = sender, - text = text, - mine = sender == myEndpoint, - ts = System.currentTimeMillis(), - ), - ) - } - } - - fun connect(host: String, nats: String, peerName: String) { - if (_state.value.connecting) return - _state.update { it.copy(connecting = true, error = null, status = "Conectando…") } - viewModelScope.launch(Dispatchers.IO) { - try { - val s = Mobile.newSession(idPath, nats.trim(), host.trim()) - session = s - myEndpoint = s.endpointID() - _state.update { - it.copy( - connecting = false, - connected = true, - endpointId = myEndpoint, - status = "Conectado como $peerName", - ) - } - } catch (e: Exception) { - _state.update { - it.copy(connecting = false, connected = false, error = e.message ?: "error desconocido") - } - } - } - } - - fun createRoom(subject: String, encrypted: Boolean) { - val s = session ?: return - viewModelScope.launch(Dispatchers.IO) { - try { - val mode = if (encrypted) "matrix" else "nats" - val roomId = s.createRoom(subject.trim(), mode) - s.subscribe(roomId, this@BusViewModel) - _state.update { - it.copy( - roomId = roomId, - roomSubject = subject.trim(), - messages = emptyList(), - status = "Room creada", - ) - } - } catch (e: Exception) { - _state.update { it.copy(error = e.message ?: "error al crear room") } - } - } - } - - fun joinRoom(roomId: String) { - val s = session ?: return - viewModelScope.launch(Dispatchers.IO) { - try { - val rid = roomId.trim() - s.join(rid) - s.subscribe(rid, this@BusViewModel) - _state.update { - it.copy(roomId = rid, roomSubject = "(unida)", messages = emptyList(), status = "Unido a la room") - } - } catch (e: Exception) { - _state.update { it.copy(error = e.message ?: "error al unirse") } - } - } - } - - fun publish(text: String) { - val s = session ?: return - val room = _state.value.roomId - if (room.isEmpty() || text.isBlank()) return - viewModelScope.launch(Dispatchers.IO) { - try { - s.publish(room, text) - } catch (e: Exception) { - _state.update { it.copy(error = e.message ?: "error al publicar") } - } - } - } - - /** card returns this peer's shareable public identity (no secret). */ - fun card(): String = try { - session?.card() ?: "" - } catch (_: Exception) { - "" - } - - fun clearError() = _state.update { it.copy(error = null) } - - override fun onCleared() { - try { - session?.close() - } catch (_: Exception) { - } - session = null - } -} diff --git a/android/app/src/main/java/com/unibus/app/MainActivity.kt b/android/app/src/main/java/com/unibus/app/MainActivity.kt deleted file mode 100644 index ba45af98..00000000 --- a/android/app/src/main/java/com/unibus/app/MainActivity.kt +++ /dev/null @@ -1,307 +0,0 @@ -package com.unibus.app - -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.activity.viewModels -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Send -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Lock -import androidx.compose.material3.Button -import androidx.compose.material3.Card -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.darkColorScheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale - -class MainActivity : ComponentActivity() { - private val vm: BusViewModel by viewModels() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContent { - MaterialTheme(colorScheme = darkColorScheme()) { - Surface(modifier = Modifier.fillMaxSize()) { - UnibusApp(vm) - } - } - } - } -} - -@Composable -fun UnibusApp(vm: BusViewModel) { - val state by vm.state.collectAsState() - if (!state.connected) { - ConnectScreen( - connecting = state.connecting, - error = state.error, - onConnect = { host, nats, name -> vm.connect(host, nats, name) }, - ) - } else { - ChatScreen(state = state, vm = vm) - } -} - -@Composable -fun ConnectScreen( - connecting: Boolean, - error: String?, - onConnect: (String, String, String) -> Unit, -) { - var host by rememberSaveable { mutableStateOf("http://10.0.2.2:8470") } - var nats by rememberSaveable { mutableStateOf("nats://10.0.2.2:4250") } - var name by rememberSaveable { mutableStateOf("android") } - - Column( - modifier = Modifier - .fillMaxSize() - .padding(24.dp), - verticalArrangement = Arrangement.Center, - ) { - Text("unibus", style = MaterialTheme.typography.headlineMedium) - Text( - "chat cifrado extremo a extremo sobre NATS", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.height(24.dp)) - OutlinedTextField( - value = host, - onValueChange = { host = it }, - label = { Text("Host (control plane)") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - Spacer(Modifier.height(12.dp)) - OutlinedTextField( - value = nats, - onValueChange = { nats = it }, - label = { Text("NATS (data plane)") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - Spacer(Modifier.height(12.dp)) - OutlinedTextField( - value = name, - onValueChange = { name = it }, - label = { Text("Identidad") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - if (error != null) { - Spacer(Modifier.height(12.dp)) - Text(error, color = MaterialTheme.colorScheme.error) - } - Spacer(Modifier.height(24.dp)) - Button( - onClick = { onConnect(host, nats, name) }, - enabled = !connecting, - modifier = Modifier.fillMaxWidth(), - ) { - if (connecting) { - CircularProgressIndicator(modifier = Modifier.height(18.dp).width(18.dp), strokeWidth = 2.dp) - Spacer(Modifier.width(8.dp)) - } - Text(if (connecting) "Conectando…" else "Conectar") - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun ChatScreen(state: BusState, vm: BusViewModel) { - var subject by rememberSaveable { mutableStateOf("room.general") } - var encrypt by rememberSaveable { mutableStateOf(false) } - var joinId by rememberSaveable { mutableStateOf("") } - var draft by rememberSaveable { mutableStateOf("") } - val listState = rememberLazyListState() - - LaunchedEffect(state.messages.size) { - if (state.messages.isNotEmpty()) listState.animateScrollToItem(state.messages.size - 1) - } - - Scaffold( - topBar = { - TopAppBar( - title = { - Column { - Text("unibus", style = MaterialTheme.typography.titleMedium) - Text( - state.status.ifEmpty { state.endpointId.take(12) + "…" }, - style = MaterialTheme.typography.bodySmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - }, - ) - }, - ) { inner -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(inner) - .padding(horizontal = 12.dp), - ) { - // Room controls. - Card(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) { - Column(Modifier.padding(12.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - OutlinedTextField( - value = subject, - onValueChange = { subject = it }, - label = { Text("subject") }, - singleLine = true, - modifier = Modifier.weight(1f), - ) - Spacer(Modifier.width(8.dp)) - Button(onClick = { vm.createRoom(subject, encrypt) }) { - Icon(Icons.Filled.Add, contentDescription = "crear") - } - } - Row(verticalAlignment = Alignment.CenterVertically) { - Switch(checked = encrypt, onCheckedChange = { encrypt = it }) - Spacer(Modifier.width(8.dp)) - Icon(Icons.Filled.Lock, contentDescription = null, modifier = Modifier.height(16.dp)) - Text("cifrar (E2E)", style = MaterialTheme.typography.bodySmall) - } - Spacer(Modifier.height(4.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { - OutlinedTextField( - value = joinId, - onValueChange = { joinId = it }, - label = { Text("unirse por room id") }, - singleLine = true, - modifier = Modifier.weight(1f), - ) - Spacer(Modifier.width(8.dp)) - OutlinedButton(onClick = { if (joinId.isNotBlank()) vm.joinRoom(joinId) }) { - Text("Unir") - } - } - if (state.roomId.isNotEmpty()) { - Spacer(Modifier.height(4.dp)) - Text( - "room: ${state.roomSubject} · ${state.roomId}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } - } - - if (state.error != null) { - Text( - state.error, - color = MaterialTheme.colorScheme.error, - modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), - ) - } - - // Messages. - LazyColumn( - state = listState, - modifier = Modifier.weight(1f).fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { - itemsIndexed(state.messages, key = { i, m -> "${m.ts}-$i" }) { _, m -> - MessageBubble(m) - } - } - - // Composer. - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedTextField( - value = draft, - onValueChange = { draft = it }, - placeholder = { Text("Mensaje…") }, - singleLine = true, - enabled = state.roomId.isNotEmpty(), - modifier = Modifier.weight(1f), - ) - Spacer(Modifier.width(8.dp)) - IconButton( - onClick = { - vm.publish(draft) - draft = "" - }, - enabled = state.roomId.isNotEmpty() && draft.isNotBlank(), - ) { - Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "enviar") - } - } - } - } -} - -private val timeFmt = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) - -@Composable -fun MessageBubble(m: ChatMessage) { - val align = if (m.mine) Alignment.End else Alignment.Start - Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = align) { - Card( - modifier = Modifier.fillMaxWidth(0.8f), - ) { - Column(Modifier.padding(8.dp)) { - if (!m.mine) { - Text( - m.sender.take(12) + "…", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary, - ) - } - Text(m.text, style = MaterialTheme.typography.bodyMedium) - Text( - timeFmt.format(Date(m.ts)), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } -} diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml deleted file mode 100644 index 968ab786..00000000 --- a/android/app/src/main/res/values/strings.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - unibus - diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml deleted file mode 100644 index 3f5162b4..00000000 --- a/android/app/src/main/res/values/themes.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - -
-

unibus playground

- embedded NATS + JetStream · E2E rooms · forward secrecy · SSE -
- -
- -
-
-

1 · Identity

- -
- - -
-
-
-
- -
-

2 · Rooms

- - -
- - -
-
- - -
-
- persistente = quien se une despues ve el historial; sin persistir = solo mensajes nuevos (NATS simple). -
- -
- - - -
-
- -
-

3 · Action

- - - -
- - -
-
- -
- - -
- - -
-
-
- - -
-
-

Live messages disconnected

-
-
-
- ⓘ How to try it
- Open 2 tabs. Connect as alice in one and bob in the other. - In alice: create a 🔒 encrypted room, copy the room_id, - then pick bob as target and Invite to this room. - In bob: paste that room_id and Join. - Type in both → messages appear live on each side. - In alice: Kick bob → bob stops seeing new messages (forward secrecy: the room - key rotates and bob no longer holds it). -
-
-
- - -
-
-

Benchmark de rendimiento · 1 publisher → N subscribers

-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
- -
-
- JetStream y Encriptación son ejes independientes: NATS core (ambos off) · JetStream durable · E2E (AEAD + firma Ed25519 por mensaje) · E2E + JetStream. Los modos con cripto o persistencia se limitan a 30 000 mensajes (cada mensaje paga cifrado/firma/ack). -
-
-
Enviados
0
-
Recibidos (Σ subs)
0
-
Throughput recv
0
-
Tiempo
0.00 s
-
- -
- enviados (publisher) - recibidos (suma de subscribers) -
-
-
-
- - - - diff --git a/playground/server.go b/playground/server.go deleted file mode 100644 index b93a409e..00000000 --- a/playground/server.go +++ /dev/null @@ -1,933 +0,0 @@ -// Command playground is an all-in-one, web-based sandbox for the unibus message -// bus. A single `go run ./playground` launches the entire stack embedded: -// -// - an embedded NATS server with JetStream (the data plane), -// - the membership control plane (rooms, members, sealed keys, rekey) over an -// internal HTTP server, -// - the media blob store, and -// - a browser-facing web UI on :7700. -// -// The browser never speaks NATS. The Go server is the actual bus peer: it holds -// one unibus client per named peer, subscribes to rooms on the peer's behalf, -// and streams received messages to the browser over Server-Sent Events. The -// browser drives everything with plain fetch() + EventSource() — no build step, -// no JS framework, no external libraries. -// -// This is a playground (see .claude/rules/playgrounds.md): it lives inside the -// unibus app, reuses the parent module (no new go.mod), is not indexed, and -// stores ephemeral state under playground/local_files/. -package main - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "log" - "net/http" - "os" - "os/signal" - "path/filepath" - "strconv" - "sync" - "sync/atomic" - "syscall" - "time" - - _ "embed" - - cs "fn-registry/functions/cybersecurity" - "github.com/enmanuel/unibus/pkg/blobstore" - "github.com/enmanuel/unibus/pkg/client" - "github.com/enmanuel/unibus/pkg/embeddednats" - "github.com/enmanuel/unibus/pkg/frame" - "github.com/enmanuel/unibus/pkg/membership" - "github.com/enmanuel/unibus/pkg/room" -) - -// Fixed ports (verified free before assignment — do not change without reason). -const ( - webAddr = "127.0.0.1:7700" // browser-facing web UI - ctrlAddr = "127.0.0.1:8480" // internal membership control plane - ctrlURL = "http://" + ctrlAddr - natsPort = 4260 // internal embedded NATS - natsURL = "nats://127.0.0.1:4260" - localFiles = "playground/local_files" -) - -//go:embed index.html -var indexHTML []byte - -// --------------------------------------------------------------------------- -// Event: a message received by a peer on one of its subscribed rooms. Fanned -// out to every SSE listener attached to that peer. -// --------------------------------------------------------------------------- - -type Event struct { - RoomID string `json:"room_id"` - Subject string `json:"subject"` - Sender string `json:"sender"` - Text string `json:"text"` - Encrypted bool `json:"encrypted"` - TS int64 `json:"ts"` // unix millis -} - -// roomInfo caches the per-room metadata a peer needs to label incoming frames. -type roomInfo struct { - subject string - encrypt bool -} - -// peerState holds everything about one named peer: its bus client, its public -// endpoint, its live subscriptions, the rooms it knows, and the set of SSE -// listener channels currently attached to it. -type peerState struct { - name string - client *client.Client - endpoint client.Endpoint - - mu sync.Mutex - subs map[string]*client.Sub // roomID -> subscription - rooms map[string]roomInfo // roomID -> subject/encrypt - listeners map[chan Event]struct{} // attached SSE channels -} - -// emit fans an event out to all attached listeners without blocking on a slow -// or disconnected consumer. -func (p *peerState) emit(ev Event) { - p.mu.Lock() - defer p.mu.Unlock() - for ch := range p.listeners { - select { - case ch <- ev: - default: // listener buffer full: drop rather than block the NATS callback - } - } -} - -func (p *peerState) addListener(ch chan Event) { - p.mu.Lock() - p.listeners[ch] = struct{}{} - p.mu.Unlock() -} - -func (p *peerState) removeListener(ch chan Event) { - p.mu.Lock() - delete(p.listeners, ch) - p.mu.Unlock() -} - -func (p *peerState) setRoom(roomID string, info roomInfo) { - p.mu.Lock() - p.rooms[roomID] = info - p.mu.Unlock() -} - -// roomList returns a snapshot of the rooms this peer knows (created or joined), -// so the SPA can render the peer's room list without re-deriving it client-side. -func (p *peerState) roomList() []map[string]any { - p.mu.Lock() - defer p.mu.Unlock() - out := make([]map[string]any, 0, len(p.rooms)) - for id, info := range p.rooms { - out = append(out, map[string]any{ - "room_id": id, - "subject": info.subject, - "encrypt": info.encrypt, - }) - } - return out -} - -// --------------------------------------------------------------------------- -// Hub: the registry of peers, protected by a single mutex. -// --------------------------------------------------------------------------- - -type Hub struct { - mu sync.Mutex - peers map[string]*peerState -} - -func newHub() *Hub { return &Hub{peers: map[string]*peerState{}} } - -// getOrCreate returns the peer for name, creating its identity + bus client on -// first use. Identities persist to playground/local_files/.id so a peer -// keeps the same endpoint across restarts. -func (h *Hub) getOrCreate(name string) (*peerState, error) { - h.mu.Lock() - defer h.mu.Unlock() - if p, ok := h.peers[name]; ok { - return p, nil - } - idPath := filepath.Join(localFiles, name+".id") - id, err := client.LoadOrCreateIdentity(idPath) - if err != nil { - return nil, fmt.Errorf("identity for %q: %w", name, err) - } - c, err := client.New(natsURL, ctrlURL, id) - if err != nil { - return nil, fmt.Errorf("client for %q: %w", name, err) - } - p := &peerState{ - name: name, - client: c, - endpoint: c.Endpoint(), - subs: map[string]*client.Sub{}, - rooms: map[string]roomInfo{}, - listeners: map[chan Event]struct{}{}, - } - h.peers[name] = p - return p, nil -} - -// lookup returns an already-created peer or false. -func (h *Hub) lookup(name string) (*peerState, bool) { - h.mu.Lock() - defer h.mu.Unlock() - p, ok := h.peers[name] - return p, ok -} - -// list returns a snapshot of all peers (name + endpoint id). -func (h *Hub) list() []map[string]string { - h.mu.Lock() - defer h.mu.Unlock() - out := make([]map[string]string, 0, len(h.peers)) - for name, p := range h.peers { - out = append(out, map[string]string{"name": name, "endpoint_id": p.endpoint.ID}) - } - return out -} - -func (h *Hub) closeAll() { - h.mu.Lock() - defer h.mu.Unlock() - for _, p := range h.peers { - p.mu.Lock() - for _, sub := range p.subs { - _ = sub.Unsubscribe() - } - p.mu.Unlock() - _ = p.client.Close() - } -} - -// subscribeRoom subscribes the peer to a room (idempotent) and wires the frame -// handler to fan incoming messages out as Events. info labels each event with -// the room's subject and encryption flag. -func (p *peerState) subscribeRoom(roomID string, info roomInfo) error { - p.mu.Lock() - if _, already := p.subs[roomID]; already { - p.mu.Unlock() - return nil - } - p.mu.Unlock() - - sub, err := p.client.Subscribe(roomID, func(f frame.Frame, plaintext []byte) { - p.emit(Event{ - RoomID: roomID, - Subject: info.subject, - Sender: f.Sender, - Text: string(plaintext), - Encrypted: info.encrypt, - TS: time.Now().UnixMilli(), - }) - }) - if err != nil { - return fmt.Errorf("subscribe room %s: %w", roomID, err) - } - p.mu.Lock() - p.subs[roomID] = sub - p.mu.Unlock() - p.setRoom(roomID, info) - return nil -} - -// --------------------------------------------------------------------------- -// Control-plane helper: fetch a room's subject + policy from membershipd. The -// client package keeps fetchRoom private, so the playground talks to the -// control plane directly (read endpoints are unauthenticated by design). -// --------------------------------------------------------------------------- - -type ctrlRoomResp struct { - Subject string `json:"subject"` - Epoch int `json:"epoch"` - Policy struct { - Encrypt bool `json:"encrypt"` - Persist bool `json:"persist"` - SignMsgs bool `json:"sign_msgs"` - } `json:"policy"` -} - -func fetchRoomInfo(roomID string) (roomInfo, error) { - resp, err := http.Get(ctrlURL + "/rooms/" + roomID) - if err != nil { - return roomInfo{}, fmt.Errorf("fetch room %s: %w", roomID, err) - } - defer resp.Body.Close() - if resp.StatusCode >= 300 { - return roomInfo{}, fmt.Errorf("room %s not found (status %d)", roomID, resp.StatusCode) - } - var r ctrlRoomResp - if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { - return roomInfo{}, fmt.Errorf("decode room %s: %w", roomID, err) - } - return roomInfo{subject: r.Subject, encrypt: r.Policy.Encrypt}, nil -} - -// --------------------------------------------------------------------------- -// HTTP handlers (web UI on :7700). -// --------------------------------------------------------------------------- - -func writeJSON(w http.ResponseWriter, code int, v any) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(code) - _ = json.NewEncoder(w).Encode(v) -} - -func writeErr(w http.ResponseWriter, code int, msg string) { - writeJSON(w, code, map[string]string{"error": msg}) -} - -func decodeBody(r *http.Request, out any) error { - defer r.Body.Close() - return json.NewDecoder(r.Body).Decode(out) -} - -func (h *Hub) handleIndex(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/" { - http.NotFound(w, r) - return - } - w.Header().Set("Content-Type", "text/html; charset=utf-8") - _, _ = w.Write(indexHTML) -} - -func (h *Hub) handlePeer(w http.ResponseWriter, r *http.Request) { - var req struct { - Name string `json:"name"` - } - if err := decodeBody(r, &req); err != nil || req.Name == "" { - writeErr(w, http.StatusBadRequest, "name required") - return - } - p, err := h.getOrCreate(req.Name) - if err != nil { - writeErr(w, http.StatusInternalServerError, err.Error()) - return - } - writeJSON(w, http.StatusOK, map[string]string{"name": p.name, "endpoint_id": p.endpoint.ID}) -} - -func (h *Hub) handlePeers(w http.ResponseWriter, r *http.Request) { - writeJSON(w, http.StatusOK, h.list()) -} - -func (h *Hub) handleRoom(w http.ResponseWriter, r *http.Request) { - var req struct { - Peer string `json:"peer"` - Subject string `json:"subject"` - Encrypt bool `json:"encrypt"` - Persist bool `json:"persist"` - } - if err := decodeBody(r, &req); err != nil || req.Peer == "" || req.Subject == "" { - writeErr(w, http.StatusBadRequest, "peer and subject required") - return - } - p, ok := h.lookup(req.Peer) - if !ok { - writeErr(w, http.StatusBadRequest, "unknown peer "+req.Peer) - return - } - // The two checkboxes map to an explicit per-room policy. encrypt drives both - // encryption and per-message signing; persist (default false) independently - // toggles durable JetStream history. persist=false keeps plain ephemeral NATS. - policy := room.Policy{Encrypt: req.Encrypt, Persist: req.Persist, SignMsgs: req.Encrypt} - roomID, err := p.client.CreateRoom(req.Subject, policy) - if err != nil { - writeErr(w, http.StatusInternalServerError, err.Error()) - return - } - info := roomInfo{subject: req.Subject, encrypt: req.Encrypt} - if err := p.subscribeRoom(roomID, info); err != nil { - writeErr(w, http.StatusInternalServerError, err.Error()) - return - } - writeJSON(w, http.StatusOK, map[string]any{ - "room_id": roomID, "subject": req.Subject, "encrypt": req.Encrypt, "persist": req.Persist, - }) -} - -func (h *Hub) handleJoin(w http.ResponseWriter, r *http.Request) { - var req struct { - Peer string `json:"peer"` - RoomID string `json:"room_id"` - } - if err := decodeBody(r, &req); err != nil || req.Peer == "" || req.RoomID == "" { - writeErr(w, http.StatusBadRequest, "peer and room_id required") - return - } - p, ok := h.lookup(req.Peer) - if !ok { - writeErr(w, http.StatusBadRequest, "unknown peer "+req.Peer) - return - } - if err := p.client.Join(req.RoomID); err != nil { - writeErr(w, http.StatusBadRequest, "join failed: "+err.Error()) - return - } - info, err := fetchRoomInfo(req.RoomID) - if err != nil { - writeErr(w, http.StatusInternalServerError, err.Error()) - return - } - if err := p.subscribeRoom(req.RoomID, info); err != nil { - writeErr(w, http.StatusInternalServerError, err.Error()) - return - } - writeJSON(w, http.StatusOK, map[string]any{"subject": info.subject, "encrypt": info.encrypt}) -} - -func (h *Hub) handleInvite(w http.ResponseWriter, r *http.Request) { - var req struct { - Peer string `json:"peer"` - RoomID string `json:"room_id"` - Target string `json:"target"` - } - if err := decodeBody(r, &req); err != nil || req.Peer == "" || req.RoomID == "" || req.Target == "" { - writeErr(w, http.StatusBadRequest, "peer, room_id and target required") - return - } - p, ok := h.lookup(req.Peer) - if !ok { - writeErr(w, http.StatusBadRequest, "unknown peer "+req.Peer) - return - } - target, ok := h.lookup(req.Target) - if !ok { - writeErr(w, http.StatusBadRequest, "target peer "+req.Target+" does not exist; connect it first") - return - } - if err := p.client.Invite(req.RoomID, target.endpoint); err != nil { - writeErr(w, http.StatusInternalServerError, err.Error()) - return - } - writeJSON(w, http.StatusOK, map[string]string{"status": "invited", "target": req.Target}) -} - -func (h *Hub) handlePublish(w http.ResponseWriter, r *http.Request) { - var req struct { - Peer string `json:"peer"` - RoomID string `json:"room_id"` - Text string `json:"text"` - } - if err := decodeBody(r, &req); err != nil || req.Peer == "" || req.RoomID == "" { - writeErr(w, http.StatusBadRequest, "peer and room_id required") - return - } - p, ok := h.lookup(req.Peer) - if !ok { - writeErr(w, http.StatusBadRequest, "unknown peer "+req.Peer) - return - } - if err := p.client.Publish(req.RoomID, []byte(req.Text)); err != nil { - writeErr(w, http.StatusInternalServerError, err.Error()) - return - } - writeJSON(w, http.StatusOK, map[string]string{"status": "published"}) -} - -func (h *Hub) handleKick(w http.ResponseWriter, r *http.Request) { - var req struct { - Peer string `json:"peer"` - RoomID string `json:"room_id"` - Target string `json:"target"` - } - if err := decodeBody(r, &req); err != nil || req.Peer == "" || req.RoomID == "" || req.Target == "" { - writeErr(w, http.StatusBadRequest, "peer, room_id and target required") - return - } - p, ok := h.lookup(req.Peer) - if !ok { - writeErr(w, http.StatusBadRequest, "unknown peer "+req.Peer) - return - } - target, ok := h.lookup(req.Target) - if !ok { - writeErr(w, http.StatusBadRequest, "target peer "+req.Target+" does not exist") - return - } - if err := p.client.Kick(req.RoomID, target.endpoint.ID); err != nil { - writeErr(w, http.StatusInternalServerError, err.Error()) - return - } - writeJSON(w, http.StatusOK, map[string]string{"status": "kicked", "target": req.Target}) -} - -// handleRooms returns the rooms a peer knows (created or joined). The SPA polls -// or calls this after create/join to refresh its room list. -// -// GET /api/rooms?peer=ana -func (h *Hub) handleRooms(w http.ResponseWriter, r *http.Request) { - name := r.URL.Query().Get("peer") - if name == "" { - writeErr(w, http.StatusBadRequest, "peer query param required") - return - } - p, ok := h.lookup(name) - if !ok { - writeErr(w, http.StatusBadRequest, "unknown peer "+name) - return - } - writeJSON(w, http.StatusOK, p.roomList()) -} - -// handleMembers lists the members of a room (endpoint id + role) so the SPA can -// render a members panel and drive invite/kick. It proxies the control plane's -// unauthenticated read endpoint; the public keys it returns are not secret. -// -// GET /api/members?room_id= -func (h *Hub) handleMembers(w http.ResponseWriter, r *http.Request) { - roomID := r.URL.Query().Get("room_id") - if roomID == "" { - writeErr(w, http.StatusBadRequest, "room_id query param required") - return - } - resp, err := http.Get(ctrlURL + "/rooms/" + roomID + "/members") - if err != nil { - writeErr(w, http.StatusInternalServerError, "fetch members: "+err.Error()) - return - } - defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(resp.StatusCode) - _, _ = w.Write(body) -} - -// withCORS allows the SPA running under the Vite dev server (a different origin) -// to call the gateway. It answers preflight OPTIONS and tags every response with -// permissive CORS headers. v1 trusts the local network, mirroring the control -// plane's auth model. -func withCORS(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Access-Control-Allow-Origin", "*") - w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type") - if r.Method == http.MethodOptions { - w.WriteHeader(http.StatusNoContent) - return - } - next.ServeHTTP(w, r) - }) -} - -// handleStream is the SSE endpoint. The browser opens one EventSource per peer; -// each received Event is emitted as a `data: \n\n` block. The listener is -// cleaned up when the HTTP request context is cancelled (tab closed / reload). -func (h *Hub) handleStream(w http.ResponseWriter, r *http.Request) { - name := r.URL.Query().Get("peer") - if name == "" { - writeErr(w, http.StatusBadRequest, "peer query param required") - return - } - p, ok := h.lookup(name) - if !ok { - writeErr(w, http.StatusBadRequest, "unknown peer "+name) - return - } - flusher, ok := w.(http.Flusher) - if !ok { - writeErr(w, http.StatusInternalServerError, "streaming unsupported") - return - } - - w.Header().Set("Content-Type", "text/event-stream") - w.Header().Set("Cache-Control", "no-cache") - w.Header().Set("Connection", "keep-alive") - - ch := make(chan Event, 64) - p.addListener(ch) - defer p.removeListener(ch) - - // Initial comment so the browser marks the stream open immediately. - fmt.Fprintf(w, ": connected to %s\n\n", name) - flusher.Flush() - - ctx := r.Context() - ping := time.NewTicker(20 * time.Second) - defer ping.Stop() - for { - select { - case <-ctx.Done(): - return - case <-ping.C: - fmt.Fprintf(w, ": ping\n\n") - flusher.Flush() - case ev := <-ch: - b, err := json.Marshal(ev) - if err != nil { - continue - } - fmt.Fprintf(w, "data: %s\n\n", b) - flusher.Flush() - } - } -} - -// --------------------------------------------------------------------------- -// Benchmark: one publisher floods a room with thousands of messages that N -// subscribers receive. The two policy axes are exposed as independent flags: -// encrypt (AEAD payload + Ed25519 per-message signature) and persist (durable -// JetStream history vs ephemeral core NATS). Payload size is configurable. The -// benchmark uses its own ephemeral peers (not the hub's named peers) so it never -// interferes with the manual sandbox, and streams progress samples over SSE so -// the browser can animate a live throughput chart. -// --------------------------------------------------------------------------- - -// benchSample is one Server-Sent Event of a running benchmark. -type benchSample struct { - Type string `json:"type"` // "start" | "sample" | "done" | "error" - T float64 `json:"t"` - Sent int64 `json:"sent"` - Recv int64 `json:"recv"` - NMsgs int `json:"n_msgs,omitempty"` - NSubs int `json:"n_subs,omitempty"` - Payload int `json:"payload,omitempty"` - Encrypt bool `json:"encrypt,omitempty"` - Persist bool `json:"persist,omitempty"` - Capped bool `json:"capped,omitempty"` - PubTps int64 `json:"pub_tps,omitempty"` - RecvTps int64 `json:"recv_tps,omitempty"` - PerSub []int64 `json:"per_sub,omitempty"` - Msg string `json:"msg,omitempty"` -} - -// runBench wires up one publisher + nSubs subscribers, publishes nMsgs payloads, -// and calls emit periodically with the running totals. emit is only ever called -// from the calling goroutine (the SSE handler), so it needs no locking. -func runBench(ctx context.Context, emit func(benchSample), nMsgs, nSubs, payloadBytes int, encrypt, persist bool) { - policy := room.Policy{Encrypt: encrypt, Persist: persist, SignMsgs: encrypt} - subject := fmt.Sprintf("bench.%d", time.Now().UnixNano()) - - newPeer := func() (*client.Client, error) { - id, err := cs.GenerateIdentity() - if err != nil { - return nil, err - } - return client.New(natsURL, ctrlURL, id) - } - - pub, err := newPeer() - if err != nil { - emit(benchSample{Type: "error", Msg: "publisher: " + err.Error()}) - return - } - defer pub.Close() - - roomID, err := pub.CreateRoom(subject, policy) - if err != nil { - emit(benchSample{Type: "error", Msg: "create room: " + err.Error()}) - return - } - - counters := make([]int64, nSubs) - subClients := make([]*client.Client, 0, nSubs) - defer func() { - for _, c := range subClients { - _ = c.Close() - } - }() - - // One room, N subscribers. For encrypted rooms each subscriber must be invited - // (sealed key) and join before subscribing; for cleartext rooms Subscribe on - // the shared roomID is enough. - for i := 0; i < nSubs; i++ { - c, err := newPeer() - if err != nil { - emit(benchSample{Type: "error", Msg: fmt.Sprintf("subscriber %d: %v", i, err)}) - return - } - subClients = append(subClients, c) - if encrypt { - if err := pub.Invite(roomID, c.Endpoint()); err != nil { - emit(benchSample{Type: "error", Msg: fmt.Sprintf("invite %d: %v", i, err)}) - return - } - if err := c.Join(roomID); err != nil { - emit(benchSample{Type: "error", Msg: fmt.Sprintf("join %d: %v", i, err)}) - return - } - } - idx := i - if _, err := c.Subscribe(roomID, func(_ frame.Frame, _ []byte) { - atomic.AddInt64(&counters[idx], 1) - }); err != nil { - emit(benchSample{Type: "error", Msg: fmt.Sprintf("subscribe %d: %v", i, err)}) - return - } - } - - sumRecv := func() int64 { - var s int64 - for i := range counters { - s += atomic.LoadInt64(&counters[i]) - } - return s - } - - payload := bytes.Repeat([]byte{'x'}, payloadBytes) - var sent int64 - - emit(benchSample{Type: "start", NMsgs: nMsgs, NSubs: nSubs, Payload: payloadBytes, Encrypt: encrypt, Persist: persist}) - - t0 := time.Now() - done := make(chan struct{}) - var pubErr atomic.Value - go func() { - defer close(done) - for k := 0; k < nMsgs; k++ { - if err := pub.Publish(roomID, payload); err != nil { - pubErr.Store(err) - return - } - atomic.AddInt64(&sent, 1) - if k%256 == 0 { - select { - case <-ctx.Done(): - return - default: - } - } - } - }() - - ticker := time.NewTicker(60 * time.Millisecond) - defer ticker.Stop() - deadline := time.After(120 * time.Second) - target := int64(nMsgs) * int64(nSubs) - -sampleLoop: - for { - select { - case <-ctx.Done(): - return - case <-deadline: - break sampleLoop - case <-done: - break sampleLoop - case <-ticker.C: - emit(benchSample{Type: "sample", T: time.Since(t0).Seconds(), Sent: atomic.LoadInt64(&sent), Recv: sumRecv()}) - } - } - if v := pubErr.Load(); v != nil { - emit(benchSample{Type: "error", Msg: "publish: " + v.(error).Error()}) - return - } - - // Final drain: keep sampling until every subscriber has caught up (or we give up). - for i := 0; i < 240; i++ { - if sumRecv() >= target { - break - } - select { - case <-ctx.Done(): - return - case <-time.After(25 * time.Millisecond): - } - emit(benchSample{Type: "sample", T: time.Since(t0).Seconds(), Sent: atomic.LoadInt64(&sent), Recv: sumRecv()}) - } - - dur := time.Since(t0).Seconds() - finalSent := atomic.LoadInt64(&sent) - finalRecv := sumRecv() - per := make([]int64, nSubs) - for i := range counters { - per[i] = atomic.LoadInt64(&counters[i]) - } - var pubTps, recvTps int64 - if dur > 0 { - pubTps = int64(float64(finalSent) / dur) - recvTps = int64(float64(finalRecv) / dur) - } - emit(benchSample{Type: "done", T: dur, Sent: finalSent, Recv: finalRecv, PerSub: per, PubTps: pubTps, RecvTps: recvTps, NSubs: nSubs}) -} - -// handleBench is the SSE endpoint that drives a benchmark from query params: -// -// GET /api/bench?n_msgs=20000&n_subs=3&payload=128&encrypt=0&persist=0 -// -// Encrypted/persistent runs are capped to a lower message count (the per-message -// crypto + JetStream ack make them far slower); the cap is reported in the start -// sample so the UI can show it. -func (h *Hub) handleBench(w http.ResponseWriter, r *http.Request) { - q := r.URL.Query() - atoiDef := func(k string, def int) int { - if v, err := strconv.Atoi(q.Get(k)); err == nil { - return v - } - return def - } - truthy := func(k string) bool { v := q.Get(k); return v == "1" || v == "true" } - - nMsgs := atoiDef("n_msgs", 20000) - nSubs := atoiDef("n_subs", 3) - payload := atoiDef("payload", 128) - encrypt := truthy("encrypt") - persist := truthy("persist") - - if nSubs < 1 { - nSubs = 1 - } else if nSubs > 16 { - nSubs = 16 - } - if payload < 1 { - payload = 1 - } else if payload > 8192 { - payload = 8192 - } - if nMsgs < 100 { - nMsgs = 100 - } - maxMsgs := 200000 - if encrypt || persist { - maxMsgs = 30000 // crypto + JetStream ack are much slower; keep the run bounded - } - capped := false - if nMsgs > maxMsgs { - nMsgs, capped = maxMsgs, true - } - - flusher, ok := w.(http.Flusher) - if !ok { - writeErr(w, http.StatusInternalServerError, "streaming unsupported") - return - } - w.Header().Set("Content-Type", "text/event-stream") - w.Header().Set("Cache-Control", "no-cache") - w.Header().Set("Connection", "keep-alive") - fmt.Fprintf(w, ": bench start\n\n") - flusher.Flush() - - emit := func(s benchSample) { - if s.Type == "start" { - s.Capped = capped - } - b, err := json.Marshal(s) - if err != nil { - return - } - fmt.Fprintf(w, "data: %s\n\n", b) - flusher.Flush() - } - - runBench(r.Context(), emit, nMsgs, nSubs, payload, encrypt, persist) - fmt.Fprintf(w, "event: end\ndata: {}\n\n") - flusher.Flush() -} - -// --------------------------------------------------------------------------- -// main: bring up NATS, control plane, and the web server; tear them all down -// cleanly on signal. -// --------------------------------------------------------------------------- - -func main() { - log.SetFlags(log.LstdFlags | log.Lmsgprefix) - log.SetPrefix("[playground] ") - - if err := os.MkdirAll(localFiles, 0o755); err != nil { - log.Fatalf("mkdir %s: %v", localFiles, err) - } - - // 1. Data plane: embedded NATS + JetStream on the fixed internal port. - ns, err := embeddednats.Start(filepath.Join(localFiles, "js"), natsPort) - if err != nil { - log.Fatalf("start embedded nats: %v", err) - } - log.Printf("embedded NATS (JetStream) ready: %s", embeddednats.ClientURL(ns)) - - // 2. Control plane: membership store + blob store + internal HTTP server. - store, err := membership.Open(filepath.Join(localFiles, "play.db")) - if err != nil { - ns.Shutdown() - log.Fatalf("open membership store: %v", err) - } - blobs, err := blobstore.New(filepath.Join(localFiles, "blobs")) - if err != nil { - store.Close() - ns.Shutdown() - log.Fatalf("open blob store: %v", err) - } - // AuthOff: the playground is a local dev gateway that has not migrated to - // signed control-plane requests or a secured upstream bus yet. What it would - // need is written up in dev/0001e-remaining-clients.md (issue 0001, phase 0001e). - ctrlSrv := &http.Server{Addr: ctrlAddr, Handler: membership.NewServer(store, blobs, membership.AuthOff)} - go func() { - if err := ctrlSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { - log.Fatalf("control plane: %v", err) - } - }() - if err := waitHealthy(ctrlURL+"/healthz", 5*time.Second); err != nil { - log.Fatalf("control plane not healthy: %v", err) - } - log.Printf("control plane ready: %s", ctrlURL) - - // 3. Web UI on :7700. - hub := newHub() - mux := http.NewServeMux() - mux.HandleFunc("/", hub.handleIndex) - mux.HandleFunc("POST /api/peer", hub.handlePeer) - mux.HandleFunc("GET /api/peers", hub.handlePeers) - mux.HandleFunc("POST /api/room", hub.handleRoom) - mux.HandleFunc("POST /api/join", hub.handleJoin) - mux.HandleFunc("POST /api/invite", hub.handleInvite) - mux.HandleFunc("POST /api/publish", hub.handlePublish) - mux.HandleFunc("POST /api/kick", hub.handleKick) - mux.HandleFunc("GET /api/rooms", hub.handleRooms) - mux.HandleFunc("GET /api/members", hub.handleMembers) - mux.HandleFunc("GET /api/stream", hub.handleStream) - mux.HandleFunc("GET /api/bench", hub.handleBench) - webSrv := &http.Server{Addr: webAddr, Handler: withCORS(mux)} - go func() { - if err := webSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { - log.Fatalf("web server: %v", err) - } - }() - log.Printf("web UI ready: http://%s", webAddr) - log.Printf("open http://localhost:7700 in two browser tabs to try the bus") - - // 4. Graceful shutdown. - stop := make(chan os.Signal, 1) - signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) - <-stop - log.Printf("shutting down...") - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - _ = webSrv.Shutdown(ctx) - hub.closeAll() - _ = ctrlSrv.Shutdown(ctx) - store.Close() - ns.Shutdown() - ns.WaitForShutdown() - log.Printf("bye") -} - -// waitHealthy polls url until it returns a 2xx/3xx or the deadline elapses. -func waitHealthy(url string, timeout time.Duration) error { - deadline := time.Now().Add(timeout) - c := &http.Client{Timeout: 500 * time.Millisecond} - for time.Now().Before(deadline) { - resp, err := c.Get(url) - if err == nil { - resp.Body.Close() - if resp.StatusCode < 400 { - return nil - } - } - time.Sleep(100 * time.Millisecond) - } - return fmt.Errorf("timeout waiting for %s", url) -} diff --git a/web/.gitignore b/web/.gitignore deleted file mode 100644 index de9ca1dd..00000000 --- a/web/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -node_modules/ -dist/ -*.local -.vite/ -*.tsbuildinfo diff --git a/web/index.html b/web/index.html deleted file mode 100644 index 500d5dd2..00000000 --- a/web/index.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - unibus · chat - - -
- - - diff --git a/web/package.json b/web/package.json deleted file mode 100644 index bc952710..00000000 --- a/web/package.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "name": "unibus-web", - "private": true, - "version": "0.1.0", - "type": "module", - "description": "SPA de chat para el bus unibus (rooms cifradas E2E, mensajes en vivo por SSE).", - "scripts": { - "dev": "vite", - "build": "tsc -b && vite build", - "preview": "vite preview" - }, - "dependencies": { - "@mantine/core": "^9.3.0", - "@mantine/hooks": "^9.3.0", - "@tabler/icons-react": "^3.36.0", - "react": "^19.2.0", - "react-dom": "^19.2.0" - }, - "devDependencies": { - "@types/react": "^19.2.0", - "@types/react-dom": "^19.2.0", - "@vitejs/plugin-react": "^4.3.4", - "postcss": "^8.4.49", - "postcss-preset-mantine": "^1.17.0", - "postcss-simple-vars": "^7.0.1", - "typescript": "^5.6.3", - "vite": "^6.0.3" - } -} diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml deleted file mode 100644 index 6b89b200..00000000 --- a/web/pnpm-lock.yaml +++ /dev/null @@ -1,1481 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - '@mantine/core': - specifier: ^9.3.0 - version: 9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@mantine/hooks': - specifier: ^9.3.0 - version: 9.3.0(react@19.2.7) - '@tabler/icons-react': - specifier: ^3.36.0 - version: 3.44.0(react@19.2.7) - react: - specifier: ^19.2.0 - version: 19.2.7 - react-dom: - specifier: ^19.2.0 - version: 19.2.7(react@19.2.7) - devDependencies: - '@types/react': - specifier: ^19.2.0 - version: 19.2.16 - '@types/react-dom': - specifier: ^19.2.0 - version: 19.2.3(@types/react@19.2.16) - '@vitejs/plugin-react': - specifier: ^4.3.4 - version: 4.7.0(vite@6.4.3(sugarss@5.0.1(postcss@8.5.15))) - postcss: - specifier: ^8.4.49 - version: 8.5.15 - postcss-preset-mantine: - specifier: ^1.17.0 - version: 1.18.0(postcss@8.5.15) - postcss-simple-vars: - specifier: ^7.0.1 - version: 7.0.1(postcss@8.5.15) - typescript: - specifier: ^5.6.3 - version: 5.9.3 - vite: - specifier: ^6.0.3 - version: 6.4.3(sugarss@5.0.1(postcss@8.5.15)) - -packages: - - '@babel/code-frame@7.29.7': - resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} - engines: {node: '>=6.9.0'} - - '@babel/compat-data@7.29.7': - resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} - engines: {node: '>=6.9.0'} - - '@babel/core@7.29.7': - resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} - engines: {node: '>=6.9.0'} - - '@babel/generator@7.29.7': - resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} - engines: {node: '>=6.9.0'} - - '@babel/helper-compilation-targets@7.29.7': - resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} - engines: {node: '>=6.9.0'} - - '@babel/helper-globals@7.29.7': - resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} - engines: {node: '>=6.9.0'} - - '@babel/helper-module-imports@7.29.7': - resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} - engines: {node: '>=6.9.0'} - - '@babel/helper-module-transforms@7.29.7': - resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/helper-plugin-utils@7.29.7': - resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-string-parser@7.29.7': - resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-validator-identifier@7.29.7': - resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} - engines: {node: '>=6.9.0'} - - '@babel/helper-validator-option@7.29.7': - resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} - engines: {node: '>=6.9.0'} - - '@babel/helpers@7.29.7': - resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} - engines: {node: '>=6.9.0'} - - '@babel/parser@7.29.7': - resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} - engines: {node: '>=6.0.0'} - hasBin: true - - '@babel/plugin-transform-react-jsx-self@7.29.7': - resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-react-jsx-source@7.29.7': - resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/template@7.29.7': - resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} - engines: {node: '>=6.9.0'} - - '@babel/traverse@7.29.7': - resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} - engines: {node: '>=6.9.0'} - - '@babel/types@7.29.7': - resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} - engines: {node: '>=6.9.0'} - - '@esbuild/aix-ppc64@0.25.12': - resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - - '@esbuild/android-arm64@0.25.12': - resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm@0.25.12': - resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - - '@esbuild/android-x64@0.25.12': - resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - - '@esbuild/darwin-arm64@0.25.12': - resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-x64@0.25.12': - resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - - '@esbuild/freebsd-arm64@0.25.12': - resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.25.12': - resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - - '@esbuild/linux-arm64@0.25.12': - resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm@0.25.12': - resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-ia32@0.25.12': - resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-loong64@0.25.12': - resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-mips64el@0.25.12': - resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-ppc64@0.25.12': - resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-riscv64@0.25.12': - resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-s390x@0.25.12': - resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-x64@0.25.12': - resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - - '@esbuild/netbsd-arm64@0.25.12': - resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.25.12': - resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - - '@esbuild/openbsd-arm64@0.25.12': - resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.25.12': - resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openharmony-arm64@0.25.12': - resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - - '@esbuild/sunos-x64@0.25.12': - resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - - '@esbuild/win32-arm64@0.25.12': - resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-ia32@0.25.12': - resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-x64@0.25.12': - resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - - '@floating-ui/core@1.7.5': - resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} - - '@floating-ui/dom@1.7.6': - resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} - - '@floating-ui/react-dom@2.1.8': - resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==} - peerDependencies: - react: '>=16.8.0' - react-dom: '>=16.8.0' - - '@floating-ui/react@0.27.19': - resolution: {integrity: sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog==} - peerDependencies: - react: '>=17.0.0' - react-dom: '>=17.0.0' - - '@floating-ui/utils@0.2.11': - resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} - - '@jridgewell/gen-mapping@0.3.13': - resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} - - '@jridgewell/remapping@2.3.5': - resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} - - '@jridgewell/resolve-uri@3.1.2': - resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} - engines: {node: '>=6.0.0'} - - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - - '@jridgewell/trace-mapping@0.3.31': - resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - - '@mantine/core@9.3.0': - resolution: {integrity: sha512-mHVCm61YVW9ipy9eHiKMqsRUm3TkOErbdw7zHs0HRw5g403nf7tSTqNGvaYE+aX1Py874qMkrUzeQfj4bjiiBA==} - peerDependencies: - '@mantine/hooks': 9.3.0 - react: ^19.2.0 - react-dom: ^19.2.0 - - '@mantine/hooks@9.3.0': - resolution: {integrity: sha512-QoSr9WI4WsKWrM3qFYYizHUn3+n+CVcFMYe4sdlnmFPStvs6BacPODKJSbFlYl73Z20t82JIy0eKqt4noHQI2g==} - peerDependencies: - react: ^19.2.0 - - '@rolldown/pluginutils@1.0.0-beta.27': - resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} - - '@rollup/rollup-android-arm-eabi@4.61.1': - resolution: {integrity: sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==} - cpu: [arm] - os: [android] - - '@rollup/rollup-android-arm64@4.61.1': - resolution: {integrity: sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==} - cpu: [arm64] - os: [android] - - '@rollup/rollup-darwin-arm64@4.61.1': - resolution: {integrity: sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==} - cpu: [arm64] - os: [darwin] - - '@rollup/rollup-darwin-x64@4.61.1': - resolution: {integrity: sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==} - cpu: [x64] - os: [darwin] - - '@rollup/rollup-freebsd-arm64@4.61.1': - resolution: {integrity: sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==} - cpu: [arm64] - os: [freebsd] - - '@rollup/rollup-freebsd-x64@4.61.1': - resolution: {integrity: sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==} - cpu: [x64] - os: [freebsd] - - '@rollup/rollup-linux-arm-gnueabihf@4.61.1': - resolution: {integrity: sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==} - cpu: [arm] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-arm-musleabihf@4.61.1': - resolution: {integrity: sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==} - cpu: [arm] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-arm64-gnu@4.61.1': - resolution: {integrity: sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-arm64-musl@4.61.1': - resolution: {integrity: sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-loong64-gnu@4.61.1': - resolution: {integrity: sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==} - cpu: [loong64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-loong64-musl@4.61.1': - resolution: {integrity: sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==} - cpu: [loong64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-ppc64-gnu@4.61.1': - resolution: {integrity: sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-ppc64-musl@4.61.1': - resolution: {integrity: sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==} - cpu: [ppc64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-riscv64-gnu@4.61.1': - resolution: {integrity: sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-riscv64-musl@4.61.1': - resolution: {integrity: sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==} - cpu: [riscv64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-s390x-gnu@4.61.1': - resolution: {integrity: sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-x64-gnu@4.61.1': - resolution: {integrity: sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-x64-musl@4.61.1': - resolution: {integrity: sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==} - cpu: [x64] - os: [linux] - libc: [musl] - - '@rollup/rollup-openbsd-x64@4.61.1': - resolution: {integrity: sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==} - cpu: [x64] - os: [openbsd] - - '@rollup/rollup-openharmony-arm64@4.61.1': - resolution: {integrity: sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==} - cpu: [arm64] - os: [openharmony] - - '@rollup/rollup-win32-arm64-msvc@4.61.1': - resolution: {integrity: sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==} - cpu: [arm64] - os: [win32] - - '@rollup/rollup-win32-ia32-msvc@4.61.1': - resolution: {integrity: sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==} - cpu: [ia32] - os: [win32] - - '@rollup/rollup-win32-x64-gnu@4.61.1': - resolution: {integrity: sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==} - cpu: [x64] - os: [win32] - - '@rollup/rollup-win32-x64-msvc@4.61.1': - resolution: {integrity: sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==} - cpu: [x64] - os: [win32] - - '@tabler/icons-react@3.44.0': - resolution: {integrity: sha512-8+rvzBbVm/1Z3sG3x7GUNAaxIKxwgz8xaMhRs23nrCnMTKRFAhEC+82zAIFeAA0seXdrAGX5HFCkaLpGK2rVHg==} - peerDependencies: - react: '>= 16' - - '@tabler/icons@3.44.0': - resolution: {integrity: sha512-Wn0AOZG9sg0L+bjfMqq4eNhC6pQjIrk94LvvWYNYkY8KH8wC3YILRzQlrnVJc4FUeMxH/AK97QsYCX35H3LndA==} - - '@types/babel__core@7.20.5': - resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} - - '@types/babel__generator@7.27.0': - resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} - - '@types/babel__template@7.4.4': - resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} - - '@types/babel__traverse@7.28.0': - resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} - - '@types/estree@1.0.9': - resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} - - '@types/react-dom@19.2.3': - resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} - peerDependencies: - '@types/react': ^19.2.0 - - '@types/react@19.2.16': - resolution: {integrity: sha512-esJiCAnl0kfpNdE69f3So4WJUXy95dLZydX0KwK46riIHDzHM7O9Vtf9xCHW0PXIqvgqNrswl522kA/5yx+F4w==} - - '@vitejs/plugin-react@4.7.0': - resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} - engines: {node: ^14.18.0 || >=16.0.0} - peerDependencies: - vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 - - baseline-browser-mapping@2.10.33: - resolution: {integrity: sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==} - engines: {node: '>=6.0.0'} - hasBin: true - - browserslist@4.28.2: - resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} - engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} - hasBin: true - - camelcase-css@2.0.1: - resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} - engines: {node: '>= 6'} - - caniuse-lite@1.0.30001793: - resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==} - - clsx@2.1.1: - resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} - engines: {node: '>=6'} - - convert-source-map@2.0.0: - resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - - cssesc@3.0.0: - resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} - engines: {node: '>=4'} - hasBin: true - - csstype@3.2.3: - resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} - - debug@4.4.3: - resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - detect-node-es@1.1.0: - resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} - - electron-to-chromium@1.5.368: - resolution: {integrity: sha512-7RckJJK4uESJF9PxvfMWd3TGqIiieUTG4HxnKaKuIpGbcr+r2ZEB3g2gAhCP3Fqm42vJSzLfgab9eva/C4/XVw==} - - esbuild@0.25.12: - resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} - engines: {node: '>=18'} - hasBin: true - - escalade@3.2.0: - resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} - engines: {node: '>=6'} - - fdir@6.5.0: - resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} - engines: {node: '>=12.0.0'} - peerDependencies: - picomatch: ^3 || ^4 - peerDependenciesMeta: - picomatch: - optional: true - - fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - - gensync@1.0.0-beta.2: - resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} - engines: {node: '>=6.9.0'} - - get-nonce@1.0.1: - resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} - engines: {node: '>=6'} - - js-tokens@4.0.0: - resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - - jsesc@3.1.0: - resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} - engines: {node: '>=6'} - hasBin: true - - json5@2.2.3: - resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} - engines: {node: '>=6'} - hasBin: true - - lru-cache@5.1.1: - resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - - nanoid@3.3.12: - resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - - node-releases@2.0.47: - resolution: {integrity: sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==} - engines: {node: '>=18'} - - picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - - picomatch@4.0.4: - resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} - engines: {node: '>=12'} - - postcss-js@4.1.0: - resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==} - engines: {node: ^12 || ^14 || >= 16} - peerDependencies: - postcss: ^8.4.21 - - postcss-mixins@12.1.2: - resolution: {integrity: sha512-90pSxmZVfbX9e5xCv7tI5RV1mnjdf16y89CJKbf/hD7GyOz1FCxcYMl8ZYA8Hc56dbApTKKmU9HfvgfWdCxlwg==} - engines: {node: ^20.0 || ^22.0 || >=24.0} - peerDependencies: - postcss: ^8.2.14 - - postcss-nested@7.0.2: - resolution: {integrity: sha512-5osppouFc0VR9/VYzYxO03VaDa3e8F23Kfd6/9qcZTUI8P58GIYlArOET2Wq0ywSl2o2PjELhYOFI4W7l5QHKw==} - engines: {node: '>=18.0'} - peerDependencies: - postcss: ^8.2.14 - - postcss-preset-mantine@1.18.0: - resolution: {integrity: sha512-sP6/s1oC7cOtBdl4mw/IRKmKvYTuzpRrH/vT6v9enMU/EQEQ31eQnHcWtFghOXLH87AAthjL/Q75rLmin1oZoA==} - peerDependencies: - postcss: '>=8.0.0' - - postcss-selector-parser@7.1.1: - resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} - engines: {node: '>=4'} - - postcss-simple-vars@7.0.1: - resolution: {integrity: sha512-5GLLXaS8qmzHMOjVxqkk1TZPf1jMqesiI7qLhnlyERalG0sMbHIbJqrcnrpmZdKCLglHnRHoEBB61RtGTsj++A==} - engines: {node: '>=14.0'} - peerDependencies: - postcss: ^8.2.1 - - postcss@8.5.15: - resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} - engines: {node: ^10 || ^12 || >=14} - - react-dom@19.2.7: - resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} - peerDependencies: - react: ^19.2.7 - - react-number-format@5.4.5: - resolution: {integrity: sha512-y8O2yHHj3w0aE9XO8d2BCcUOOdQTRSVq+WIuMlLVucAm5XNjJAy+BoOJiuQMldVYVOKTMyvVNfnbl2Oqp+YxGw==} - peerDependencies: - react: ^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - - react-refresh@0.17.0: - resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} - engines: {node: '>=0.10.0'} - - react-remove-scroll-bar@2.3.8: - resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} - engines: {node: '>=10'} - peerDependencies: - '@types/react': '*' - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - peerDependenciesMeta: - '@types/react': - optional: true - - react-remove-scroll@2.7.2: - resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} - engines: {node: '>=10'} - peerDependencies: - '@types/react': '*' - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - react-style-singleton@2.2.3: - resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} - engines: {node: '>=10'} - peerDependencies: - '@types/react': '*' - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - react@19.2.7: - resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} - engines: {node: '>=0.10.0'} - - rollup@4.61.1: - resolution: {integrity: sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==} - engines: {node: '>=18.0.0', npm: '>=8.0.0'} - hasBin: true - - scheduler@0.27.0: - resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} - - semver@6.3.1: - resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} - hasBin: true - - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} - engines: {node: '>=0.10.0'} - - sugarss@5.0.1: - resolution: {integrity: sha512-ctS5RYCBVvPoZAnzIaX5QSShK8ZiZxD5HUqSxlusvEMC+QZQIPCPOIJg6aceFX+K2rf4+SH89eu++h1Zmsr2nw==} - engines: {node: '>=18.0'} - peerDependencies: - postcss: ^8.3.3 - - tabbable@6.4.0: - resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} - - tagged-tag@1.0.0: - resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} - engines: {node: '>=20'} - - tinyglobby@0.2.17: - resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} - engines: {node: '>=12.0.0'} - - tslib@2.8.1: - resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - - type-fest@5.7.0: - resolution: {integrity: sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==} - engines: {node: '>=20'} - - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} - engines: {node: '>=14.17'} - hasBin: true - - update-browserslist-db@1.2.3: - resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} - hasBin: true - peerDependencies: - browserslist: '>= 4.21.0' - - use-callback-ref@1.3.3: - resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} - engines: {node: '>=10'} - peerDependencies: - '@types/react': '*' - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - use-sidecar@1.1.3: - resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} - engines: {node: '>=10'} - peerDependencies: - '@types/react': '*' - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - util-deprecate@1.0.2: - resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - - vite@6.4.3: - resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} - engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} - hasBin: true - peerDependencies: - '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 - jiti: '>=1.21.0' - less: '*' - lightningcss: ^1.21.0 - sass: '*' - sass-embedded: '*' - stylus: '*' - sugarss: '*' - terser: ^5.16.0 - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - '@types/node': - optional: true - jiti: - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - tsx: - optional: true - yaml: - optional: true - - yallist@3.1.1: - resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} - -snapshots: - - '@babel/code-frame@7.29.7': - dependencies: - '@babel/helper-validator-identifier': 7.29.7 - js-tokens: 4.0.0 - picocolors: 1.1.1 - - '@babel/compat-data@7.29.7': {} - - '@babel/core@7.29.7': - dependencies: - '@babel/code-frame': 7.29.7 - '@babel/generator': 7.29.7 - '@babel/helper-compilation-targets': 7.29.7 - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) - '@babel/helpers': 7.29.7 - '@babel/parser': 7.29.7 - '@babel/template': 7.29.7 - '@babel/traverse': 7.29.7 - '@babel/types': 7.29.7 - '@jridgewell/remapping': 2.3.5 - convert-source-map: 2.0.0 - debug: 4.4.3 - gensync: 1.0.0-beta.2 - json5: 2.2.3 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - - '@babel/generator@7.29.7': - dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - jsesc: 3.1.0 - - '@babel/helper-compilation-targets@7.29.7': - dependencies: - '@babel/compat-data': 7.29.7 - '@babel/helper-validator-option': 7.29.7 - browserslist: 4.28.2 - lru-cache: 5.1.1 - semver: 6.3.1 - - '@babel/helper-globals@7.29.7': {} - - '@babel/helper-module-imports@7.29.7': - dependencies: - '@babel/traverse': 7.29.7 - '@babel/types': 7.29.7 - transitivePeerDependencies: - - supports-color - - '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': - dependencies: - '@babel/core': 7.29.7 - '@babel/helper-module-imports': 7.29.7 - '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.7 - transitivePeerDependencies: - - supports-color - - '@babel/helper-plugin-utils@7.29.7': {} - - '@babel/helper-string-parser@7.29.7': {} - - '@babel/helper-validator-identifier@7.29.7': {} - - '@babel/helper-validator-option@7.29.7': {} - - '@babel/helpers@7.29.7': - dependencies: - '@babel/template': 7.29.7 - '@babel/types': 7.29.7 - - '@babel/parser@7.29.7': - dependencies: - '@babel/types': 7.29.7 - - '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)': - dependencies: - '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.29.7 - - '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)': - dependencies: - '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.29.7 - - '@babel/template@7.29.7': - dependencies: - '@babel/code-frame': 7.29.7 - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 - - '@babel/traverse@7.29.7': - dependencies: - '@babel/code-frame': 7.29.7 - '@babel/generator': 7.29.7 - '@babel/helper-globals': 7.29.7 - '@babel/parser': 7.29.7 - '@babel/template': 7.29.7 - '@babel/types': 7.29.7 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - - '@babel/types@7.29.7': - dependencies: - '@babel/helper-string-parser': 7.29.7 - '@babel/helper-validator-identifier': 7.29.7 - - '@esbuild/aix-ppc64@0.25.12': - optional: true - - '@esbuild/android-arm64@0.25.12': - optional: true - - '@esbuild/android-arm@0.25.12': - optional: true - - '@esbuild/android-x64@0.25.12': - optional: true - - '@esbuild/darwin-arm64@0.25.12': - optional: true - - '@esbuild/darwin-x64@0.25.12': - optional: true - - '@esbuild/freebsd-arm64@0.25.12': - optional: true - - '@esbuild/freebsd-x64@0.25.12': - optional: true - - '@esbuild/linux-arm64@0.25.12': - optional: true - - '@esbuild/linux-arm@0.25.12': - optional: true - - '@esbuild/linux-ia32@0.25.12': - optional: true - - '@esbuild/linux-loong64@0.25.12': - optional: true - - '@esbuild/linux-mips64el@0.25.12': - optional: true - - '@esbuild/linux-ppc64@0.25.12': - optional: true - - '@esbuild/linux-riscv64@0.25.12': - optional: true - - '@esbuild/linux-s390x@0.25.12': - optional: true - - '@esbuild/linux-x64@0.25.12': - optional: true - - '@esbuild/netbsd-arm64@0.25.12': - optional: true - - '@esbuild/netbsd-x64@0.25.12': - optional: true - - '@esbuild/openbsd-arm64@0.25.12': - optional: true - - '@esbuild/openbsd-x64@0.25.12': - optional: true - - '@esbuild/openharmony-arm64@0.25.12': - optional: true - - '@esbuild/sunos-x64@0.25.12': - optional: true - - '@esbuild/win32-arm64@0.25.12': - optional: true - - '@esbuild/win32-ia32@0.25.12': - optional: true - - '@esbuild/win32-x64@0.25.12': - optional: true - - '@floating-ui/core@1.7.5': - dependencies: - '@floating-ui/utils': 0.2.11 - - '@floating-ui/dom@1.7.6': - dependencies: - '@floating-ui/core': 1.7.5 - '@floating-ui/utils': 0.2.11 - - '@floating-ui/react-dom@2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': - dependencies: - '@floating-ui/dom': 1.7.6 - react: 19.2.7 - react-dom: 19.2.7(react@19.2.7) - - '@floating-ui/react@0.27.19(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': - dependencies: - '@floating-ui/react-dom': 2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@floating-ui/utils': 0.2.11 - react: 19.2.7 - react-dom: 19.2.7(react@19.2.7) - tabbable: 6.4.0 - - '@floating-ui/utils@0.2.11': {} - - '@jridgewell/gen-mapping@0.3.13': - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/remapping@2.3.5': - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/resolve-uri@3.1.2': {} - - '@jridgewell/sourcemap-codec@1.5.5': {} - - '@jridgewell/trace-mapping@0.3.31': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 - - '@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': - dependencies: - '@floating-ui/react': 0.27.19(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@mantine/hooks': 9.3.0(react@19.2.7) - clsx: 2.1.1 - react: 19.2.7 - react-dom: 19.2.7(react@19.2.7) - react-number-format: 5.4.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - react-remove-scroll: 2.7.2(@types/react@19.2.16)(react@19.2.7) - type-fest: 5.7.0 - transitivePeerDependencies: - - '@types/react' - - '@mantine/hooks@9.3.0(react@19.2.7)': - dependencies: - react: 19.2.7 - - '@rolldown/pluginutils@1.0.0-beta.27': {} - - '@rollup/rollup-android-arm-eabi@4.61.1': - optional: true - - '@rollup/rollup-android-arm64@4.61.1': - optional: true - - '@rollup/rollup-darwin-arm64@4.61.1': - optional: true - - '@rollup/rollup-darwin-x64@4.61.1': - optional: true - - '@rollup/rollup-freebsd-arm64@4.61.1': - optional: true - - '@rollup/rollup-freebsd-x64@4.61.1': - optional: true - - '@rollup/rollup-linux-arm-gnueabihf@4.61.1': - optional: true - - '@rollup/rollup-linux-arm-musleabihf@4.61.1': - optional: true - - '@rollup/rollup-linux-arm64-gnu@4.61.1': - optional: true - - '@rollup/rollup-linux-arm64-musl@4.61.1': - optional: true - - '@rollup/rollup-linux-loong64-gnu@4.61.1': - optional: true - - '@rollup/rollup-linux-loong64-musl@4.61.1': - optional: true - - '@rollup/rollup-linux-ppc64-gnu@4.61.1': - optional: true - - '@rollup/rollup-linux-ppc64-musl@4.61.1': - optional: true - - '@rollup/rollup-linux-riscv64-gnu@4.61.1': - optional: true - - '@rollup/rollup-linux-riscv64-musl@4.61.1': - optional: true - - '@rollup/rollup-linux-s390x-gnu@4.61.1': - optional: true - - '@rollup/rollup-linux-x64-gnu@4.61.1': - optional: true - - '@rollup/rollup-linux-x64-musl@4.61.1': - optional: true - - '@rollup/rollup-openbsd-x64@4.61.1': - optional: true - - '@rollup/rollup-openharmony-arm64@4.61.1': - optional: true - - '@rollup/rollup-win32-arm64-msvc@4.61.1': - optional: true - - '@rollup/rollup-win32-ia32-msvc@4.61.1': - optional: true - - '@rollup/rollup-win32-x64-gnu@4.61.1': - optional: true - - '@rollup/rollup-win32-x64-msvc@4.61.1': - optional: true - - '@tabler/icons-react@3.44.0(react@19.2.7)': - dependencies: - '@tabler/icons': 3.44.0 - react: 19.2.7 - - '@tabler/icons@3.44.0': {} - - '@types/babel__core@7.20.5': - dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 - '@types/babel__generator': 7.27.0 - '@types/babel__template': 7.4.4 - '@types/babel__traverse': 7.28.0 - - '@types/babel__generator@7.27.0': - dependencies: - '@babel/types': 7.29.7 - - '@types/babel__template@7.4.4': - dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 - - '@types/babel__traverse@7.28.0': - dependencies: - '@babel/types': 7.29.7 - - '@types/estree@1.0.9': {} - - '@types/react-dom@19.2.3(@types/react@19.2.16)': - dependencies: - '@types/react': 19.2.16 - - '@types/react@19.2.16': - dependencies: - csstype: 3.2.3 - - '@vitejs/plugin-react@4.7.0(vite@6.4.3(sugarss@5.0.1(postcss@8.5.15)))': - dependencies: - '@babel/core': 7.29.7 - '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) - '@rolldown/pluginutils': 1.0.0-beta.27 - '@types/babel__core': 7.20.5 - react-refresh: 0.17.0 - vite: 6.4.3(sugarss@5.0.1(postcss@8.5.15)) - transitivePeerDependencies: - - supports-color - - baseline-browser-mapping@2.10.33: {} - - browserslist@4.28.2: - dependencies: - baseline-browser-mapping: 2.10.33 - caniuse-lite: 1.0.30001793 - electron-to-chromium: 1.5.368 - node-releases: 2.0.47 - update-browserslist-db: 1.2.3(browserslist@4.28.2) - - camelcase-css@2.0.1: {} - - caniuse-lite@1.0.30001793: {} - - clsx@2.1.1: {} - - convert-source-map@2.0.0: {} - - cssesc@3.0.0: {} - - csstype@3.2.3: {} - - debug@4.4.3: - dependencies: - ms: 2.1.3 - - detect-node-es@1.1.0: {} - - electron-to-chromium@1.5.368: {} - - esbuild@0.25.12: - optionalDependencies: - '@esbuild/aix-ppc64': 0.25.12 - '@esbuild/android-arm': 0.25.12 - '@esbuild/android-arm64': 0.25.12 - '@esbuild/android-x64': 0.25.12 - '@esbuild/darwin-arm64': 0.25.12 - '@esbuild/darwin-x64': 0.25.12 - '@esbuild/freebsd-arm64': 0.25.12 - '@esbuild/freebsd-x64': 0.25.12 - '@esbuild/linux-arm': 0.25.12 - '@esbuild/linux-arm64': 0.25.12 - '@esbuild/linux-ia32': 0.25.12 - '@esbuild/linux-loong64': 0.25.12 - '@esbuild/linux-mips64el': 0.25.12 - '@esbuild/linux-ppc64': 0.25.12 - '@esbuild/linux-riscv64': 0.25.12 - '@esbuild/linux-s390x': 0.25.12 - '@esbuild/linux-x64': 0.25.12 - '@esbuild/netbsd-arm64': 0.25.12 - '@esbuild/netbsd-x64': 0.25.12 - '@esbuild/openbsd-arm64': 0.25.12 - '@esbuild/openbsd-x64': 0.25.12 - '@esbuild/openharmony-arm64': 0.25.12 - '@esbuild/sunos-x64': 0.25.12 - '@esbuild/win32-arm64': 0.25.12 - '@esbuild/win32-ia32': 0.25.12 - '@esbuild/win32-x64': 0.25.12 - - escalade@3.2.0: {} - - fdir@6.5.0(picomatch@4.0.4): - optionalDependencies: - picomatch: 4.0.4 - - fsevents@2.3.3: - optional: true - - gensync@1.0.0-beta.2: {} - - get-nonce@1.0.1: {} - - js-tokens@4.0.0: {} - - jsesc@3.1.0: {} - - json5@2.2.3: {} - - lru-cache@5.1.1: - dependencies: - yallist: 3.1.1 - - ms@2.1.3: {} - - nanoid@3.3.12: {} - - node-releases@2.0.47: {} - - picocolors@1.1.1: {} - - picomatch@4.0.4: {} - - postcss-js@4.1.0(postcss@8.5.15): - dependencies: - camelcase-css: 2.0.1 - postcss: 8.5.15 - - postcss-mixins@12.1.2(postcss@8.5.15): - dependencies: - postcss: 8.5.15 - postcss-js: 4.1.0(postcss@8.5.15) - postcss-simple-vars: 7.0.1(postcss@8.5.15) - sugarss: 5.0.1(postcss@8.5.15) - tinyglobby: 0.2.17 - - postcss-nested@7.0.2(postcss@8.5.15): - dependencies: - postcss: 8.5.15 - postcss-selector-parser: 7.1.1 - - postcss-preset-mantine@1.18.0(postcss@8.5.15): - dependencies: - postcss: 8.5.15 - postcss-mixins: 12.1.2(postcss@8.5.15) - postcss-nested: 7.0.2(postcss@8.5.15) - - postcss-selector-parser@7.1.1: - dependencies: - cssesc: 3.0.0 - util-deprecate: 1.0.2 - - postcss-simple-vars@7.0.1(postcss@8.5.15): - dependencies: - postcss: 8.5.15 - - postcss@8.5.15: - dependencies: - nanoid: 3.3.12 - picocolors: 1.1.1 - source-map-js: 1.2.1 - - react-dom@19.2.7(react@19.2.7): - dependencies: - react: 19.2.7 - scheduler: 0.27.0 - - react-number-format@5.4.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7): - dependencies: - react: 19.2.7 - react-dom: 19.2.7(react@19.2.7) - - react-refresh@0.17.0: {} - - react-remove-scroll-bar@2.3.8(@types/react@19.2.16)(react@19.2.7): - dependencies: - react: 19.2.7 - react-style-singleton: 2.2.3(@types/react@19.2.16)(react@19.2.7) - tslib: 2.8.1 - optionalDependencies: - '@types/react': 19.2.16 - - react-remove-scroll@2.7.2(@types/react@19.2.16)(react@19.2.7): - dependencies: - react: 19.2.7 - react-remove-scroll-bar: 2.3.8(@types/react@19.2.16)(react@19.2.7) - react-style-singleton: 2.2.3(@types/react@19.2.16)(react@19.2.7) - tslib: 2.8.1 - use-callback-ref: 1.3.3(@types/react@19.2.16)(react@19.2.7) - use-sidecar: 1.1.3(@types/react@19.2.16)(react@19.2.7) - optionalDependencies: - '@types/react': 19.2.16 - - react-style-singleton@2.2.3(@types/react@19.2.16)(react@19.2.7): - dependencies: - get-nonce: 1.0.1 - react: 19.2.7 - tslib: 2.8.1 - optionalDependencies: - '@types/react': 19.2.16 - - react@19.2.7: {} - - rollup@4.61.1: - dependencies: - '@types/estree': 1.0.9 - optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.61.1 - '@rollup/rollup-android-arm64': 4.61.1 - '@rollup/rollup-darwin-arm64': 4.61.1 - '@rollup/rollup-darwin-x64': 4.61.1 - '@rollup/rollup-freebsd-arm64': 4.61.1 - '@rollup/rollup-freebsd-x64': 4.61.1 - '@rollup/rollup-linux-arm-gnueabihf': 4.61.1 - '@rollup/rollup-linux-arm-musleabihf': 4.61.1 - '@rollup/rollup-linux-arm64-gnu': 4.61.1 - '@rollup/rollup-linux-arm64-musl': 4.61.1 - '@rollup/rollup-linux-loong64-gnu': 4.61.1 - '@rollup/rollup-linux-loong64-musl': 4.61.1 - '@rollup/rollup-linux-ppc64-gnu': 4.61.1 - '@rollup/rollup-linux-ppc64-musl': 4.61.1 - '@rollup/rollup-linux-riscv64-gnu': 4.61.1 - '@rollup/rollup-linux-riscv64-musl': 4.61.1 - '@rollup/rollup-linux-s390x-gnu': 4.61.1 - '@rollup/rollup-linux-x64-gnu': 4.61.1 - '@rollup/rollup-linux-x64-musl': 4.61.1 - '@rollup/rollup-openbsd-x64': 4.61.1 - '@rollup/rollup-openharmony-arm64': 4.61.1 - '@rollup/rollup-win32-arm64-msvc': 4.61.1 - '@rollup/rollup-win32-ia32-msvc': 4.61.1 - '@rollup/rollup-win32-x64-gnu': 4.61.1 - '@rollup/rollup-win32-x64-msvc': 4.61.1 - fsevents: 2.3.3 - - scheduler@0.27.0: {} - - semver@6.3.1: {} - - source-map-js@1.2.1: {} - - sugarss@5.0.1(postcss@8.5.15): - dependencies: - postcss: 8.5.15 - - tabbable@6.4.0: {} - - tagged-tag@1.0.0: {} - - tinyglobby@0.2.17: - dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - - tslib@2.8.1: {} - - type-fest@5.7.0: - dependencies: - tagged-tag: 1.0.0 - - typescript@5.9.3: {} - - update-browserslist-db@1.2.3(browserslist@4.28.2): - dependencies: - browserslist: 4.28.2 - escalade: 3.2.0 - picocolors: 1.1.1 - - use-callback-ref@1.3.3(@types/react@19.2.16)(react@19.2.7): - dependencies: - react: 19.2.7 - tslib: 2.8.1 - optionalDependencies: - '@types/react': 19.2.16 - - use-sidecar@1.1.3(@types/react@19.2.16)(react@19.2.7): - dependencies: - detect-node-es: 1.1.0 - react: 19.2.7 - tslib: 2.8.1 - optionalDependencies: - '@types/react': 19.2.16 - - util-deprecate@1.0.2: {} - - vite@6.4.3(sugarss@5.0.1(postcss@8.5.15)): - dependencies: - esbuild: 0.25.12 - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - postcss: 8.5.15 - rollup: 4.61.1 - tinyglobby: 0.2.17 - optionalDependencies: - fsevents: 2.3.3 - sugarss: 5.0.1(postcss@8.5.15) - - yallist@3.1.1: {} diff --git a/web/pnpm-workspace.yaml b/web/pnpm-workspace.yaml deleted file mode 100644 index 5ed0b5af..00000000 --- a/web/pnpm-workspace.yaml +++ /dev/null @@ -1,2 +0,0 @@ -allowBuilds: - esbuild: true diff --git a/web/postcss.config.cjs b/web/postcss.config.cjs deleted file mode 100644 index e817f567..00000000 --- a/web/postcss.config.cjs +++ /dev/null @@ -1,14 +0,0 @@ -module.exports = { - plugins: { - "postcss-preset-mantine": {}, - "postcss-simple-vars": { - variables: { - "mantine-breakpoint-xs": "36em", - "mantine-breakpoint-sm": "48em", - "mantine-breakpoint-md": "62em", - "mantine-breakpoint-lg": "75em", - "mantine-breakpoint-xl": "88em", - }, - }, - }, -}; diff --git a/web/src/App.tsx b/web/src/App.tsx deleted file mode 100644 index c268f66b..00000000 --- a/web/src/App.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { useState } from "react"; -import { GatewayClient } from "./api"; -import type { Peer } from "./types"; -import { ConnectScreen } from "./components/ConnectScreen"; -import { ChatLayout } from "./components/ChatLayout"; - -// Connection holds the live gateway client plus the identity it connected as. -interface Connection { - client: GatewayClient; - peer: Peer; -} - -// App is the root: it shows the connect screen until the user picks a gateway -// URL and a peer name, then swaps to the full chat layout. Disconnecting drops -// back to the connect screen. -export function App() { - const [conn, setConn] = useState(null); - - if (!conn) { - return setConn({ client, peer })} />; - } - return ( - setConn(null)} - /> - ); -} diff --git a/web/src/api.ts b/web/src/api.ts deleted file mode 100644 index bc3c40e6..00000000 --- a/web/src/api.ts +++ /dev/null @@ -1,99 +0,0 @@ -// GatewayClient is the SPA's typed wrapper over the unibus gateway HTTP API. -// Every method is a thin fetch against the gateway, which hosts one real Go bus -// peer per name and performs all NATS + end-to-end crypto on the browser's -// behalf. The base URL is chosen at runtime on the connect screen. -import type { BusEvent, Member, Peer, Room } from "./types"; - -export class GatewayClient { - constructor(public readonly baseURL: string) { - // Normalize: drop a trailing slash so `${base}/api/...` never doubles up. - this.baseURL = baseURL.replace(/\/+$/, ""); - } - - private async req(method: string, path: string, body?: unknown): Promise { - const res = await fetch(this.baseURL + path, { - method, - headers: body !== undefined ? { "Content-Type": "application/json" } : undefined, - body: body !== undefined ? JSON.stringify(body) : undefined, - }); - const text = await res.text(); - if (!res.ok) { - let msg = text; - try { - const j = JSON.parse(text); - if (j && typeof j.error === "string") msg = j.error; - } catch { - // not JSON: keep the raw text - } - throw new Error(msg || `HTTP ${res.status}`); - } - return (text ? JSON.parse(text) : {}) as T; - } - - // connect creates (or recovers) the named peer on the gateway and returns its - // public identity. The identity persists across gateway restarts. - connect(name: string): Promise { - return this.req("POST", "/api/peer", { name }); - } - - // peers lists every peer currently hosted by the gateway (for the invite picker - // and to label senders by name). - peers(): Promise { - return this.req("GET", "/api/peers"); - } - - // rooms lists the rooms the named peer knows (created or joined). - rooms(peer: string): Promise { - return this.req("GET", `/api/rooms?peer=${encodeURIComponent(peer)}`); - } - - // members lists the participants of a room. - members(roomID: string): Promise { - return this.req("GET", `/api/members?room_id=${encodeURIComponent(roomID)}`); - } - - // createRoom opens a room on the given subject. encrypt drives both E2E - // encryption and per-message signing; the peer is auto-subscribed. - createRoom(peer: string, subject: string, encrypt: boolean): Promise { - return this.req("POST", "/api/room", { peer, subject, encrypt, persist: false }); - } - - // join subscribes the peer to an existing room (must have been invited first - // when the room is encrypted). - join(peer: string, roomID: string): Promise<{ subject: string; encrypt: boolean }> { - return this.req("POST", "/api/join", { peer, room_id: roomID }); - } - - // invite adds another connected peer (by name) to a room, sealing the room key - // to it. Caller must be the room owner. - invite(peer: string, roomID: string, target: string): Promise<{ status: string }> { - return this.req("POST", "/api/invite", { peer, room_id: roomID, target }); - } - - // publish sends a text message to a room. - publish(peer: string, roomID: string, text: string): Promise<{ status: string }> { - return this.req("POST", "/api/publish", { peer, room_id: roomID, text }); - } - - // kick removes a peer (by name) from a room and rotates the key (forward - // secrecy). Caller must be the room owner. - kick(peer: string, roomID: string, target: string): Promise<{ status: string }> { - return this.req("POST", "/api/kick", { peer, room_id: roomID, target }); - } - - // stream opens the SSE channel for a peer. onEvent fires for each received bus - // message; onError fires if the stream drops. Returns the EventSource so the - // caller can close it. - stream(peer: string, onEvent: (ev: BusEvent) => void, onError?: () => void): EventSource { - const es = new EventSource(`${this.baseURL}/api/stream?peer=${encodeURIComponent(peer)}`); - es.onmessage = (e) => { - try { - onEvent(JSON.parse(e.data) as BusEvent); - } catch { - // ignore malformed frames (keepalive comments never reach onmessage) - } - }; - if (onError) es.onerror = onError; - return es; - } -} diff --git a/web/src/components/ChatLayout.tsx b/web/src/components/ChatLayout.tsx deleted file mode 100644 index 2c544e37..00000000 --- a/web/src/components/ChatLayout.tsx +++ /dev/null @@ -1,285 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { - AppShell, - Group, - Title, - Badge, - Button, - CopyButton, - Tooltip, - ActionIcon, - ThemeIcon, - Alert, - Transition, -} from "@mantine/core"; -import { - IconBolt, - IconLogout, - IconCopy, - IconCheck, - IconAlertTriangle, -} from "@tabler/icons-react"; -import { GatewayClient } from "../api"; -import type { Member, Message, Peer, Room } from "../types"; -import { RoomList } from "./RoomList"; -import { MessagePane } from "./MessagePane"; -import { MembersPane } from "./MembersPane"; - -interface Props { - client: GatewayClient; - peer: Peer; - onDisconnect: () => void; -} - -// short renders the first 10 chars of an endpoint id, enough to disambiguate. -export function short(endpoint: string): string { - return endpoint.length > 12 ? endpoint.slice(0, 10) + "…" : endpoint; -} - -// ChatLayout owns all chat state: the peer's rooms, the active room, the -// per-room message log fed by the SSE stream, the directory of connected peers -// (to label senders and pick invitees), and the active room's member list. Every -// bus action goes through the gateway client. -export function ChatLayout({ client, peer, onDisconnect }: Props) { - const [rooms, setRooms] = useState([]); - const [activeRoom, setActiveRoom] = useState(null); - const [messages, setMessages] = useState>({}); - const [peers, setPeers] = useState([]); - const [members, setMembers] = useState([]); - const [error, setError] = useState(null); - const seq = useRef(0); - - const fail = useCallback((e: unknown) => { - setError(e instanceof Error ? e.message : String(e)); - }, []); - - // ---- data refreshers ---------------------------------------------------- - - const refreshRooms = useCallback(async () => { - try { - setRooms(await client.rooms(peer.name)); - } catch (e) { - fail(e); - } - }, [client, peer.name, fail]); - - const refreshPeers = useCallback(async () => { - try { - setPeers(await client.peers()); - } catch (e) { - fail(e); - } - }, [client, fail]); - - const refreshMembers = useCallback( - async (roomID: string) => { - try { - setMembers(await client.members(roomID)); - } catch (e) { - fail(e); - } - }, - [client, fail], - ); - - // ---- live stream (SSE) -------------------------------------------------- - - useEffect(() => { - const es = client.stream( - peer.name, - (ev) => { - seq.current += 1; - const msg: Message = { ...ev, id: `${ev.ts}-${seq.current}` }; - setMessages((prev) => { - const list = prev[ev.room_id] ?? []; - return { ...prev, [ev.room_id]: [...list, msg] }; - }); - }, - () => setError("Se perdió la conexión con el gateway (stream SSE)"), - ); - return () => es.close(); - }, [client, peer.name]); - - // Initial load. - useEffect(() => { - refreshRooms(); - refreshPeers(); - }, [refreshRooms, refreshPeers]); - - // Refresh members whenever the active room changes. - useEffect(() => { - if (activeRoom) refreshMembers(activeRoom); - else setMembers([]); - }, [activeRoom, refreshMembers]); - - // ---- actions ------------------------------------------------------------ - - const onCreateRoom = useCallback( - async (subject: string, encrypt: boolean) => { - try { - const r = await client.createRoom(peer.name, subject, encrypt); - await refreshRooms(); - setActiveRoom(r.room_id); - } catch (e) { - fail(e); - } - }, - [client, peer.name, refreshRooms, fail], - ); - - const onJoinRoom = useCallback( - async (roomID: string) => { - try { - await client.join(peer.name, roomID); - await refreshRooms(); - setActiveRoom(roomID); - } catch (e) { - fail(e); - } - }, - [client, peer.name, refreshRooms, fail], - ); - - const onInvite = useCallback( - async (target: string) => { - if (!activeRoom) return; - try { - await client.invite(peer.name, activeRoom, target); - await refreshMembers(activeRoom); - } catch (e) { - fail(e); - } - }, - [client, peer.name, activeRoom, refreshMembers, fail], - ); - - const onKick = useCallback( - async (target: string) => { - if (!activeRoom) return; - try { - await client.kick(peer.name, activeRoom, target); - await refreshMembers(activeRoom); - } catch (e) { - fail(e); - } - }, - [client, peer.name, activeRoom, refreshMembers, fail], - ); - - const onPublish = useCallback( - async (text: string) => { - if (!activeRoom) return; - try { - await client.publish(peer.name, activeRoom, text); - } catch (e) { - fail(e); - } - }, - [client, peer.name, activeRoom, fail], - ); - - // endpoint -> display name, using the peer directory; falls back to a short id. - const nameFor = useMemo(() => { - const byEndpoint = new Map(peers.map((p) => [p.endpoint_id, p.name])); - return (endpoint: string) => - endpoint === peer.endpoint_id ? peer.name : byEndpoint.get(endpoint) ?? short(endpoint); - }, [peers, peer]); - - const activeRoomObj = rooms.find((r) => r.room_id === activeRoom) ?? null; - const iAmOwner = members.some((m) => m.endpoint === peer.endpoint_id && m.role === "owner"); - - return ( - - - - - - - - unibus - - - - {peer.name} - - - {({ copied, copy }) => ( - - - {copied ? : } - - - )} - - - - - - - - - - - - {error && ( - - {(styles) => ( - } - withCloseButton - onClose={() => setError(null)} - title="Error" - > - {error} - - )} - - )} - - - - - {activeRoomObj && ( - activeRoom && refreshMembers(activeRoom)} - /> - )} - - - ); -} diff --git a/web/src/components/ConnectScreen.tsx b/web/src/components/ConnectScreen.tsx deleted file mode 100644 index 901582a0..00000000 --- a/web/src/components/ConnectScreen.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { useState } from "react"; -import { - Button, - Card, - Center, - Group, - Stack, - Text, - TextInput, - Title, - Alert, - ThemeIcon, -} from "@mantine/core"; -import { IconBolt, IconPlugConnected, IconAlertTriangle } from "@tabler/icons-react"; -import { GatewayClient } from "../api"; -import type { Peer } from "../types"; - -const LS_GATEWAY = "unibus.gateway"; -const LS_PEER = "unibus.peer"; - -interface Props { - onConnect: (client: GatewayClient, peer: Peer) => void; -} - -// ConnectScreen asks for the gateway URL and the identity (peer name) to connect -// as. Both persist in localStorage so a reload reconnects with one click. The -// gateway hosts the real Go bus peer; the browser only drives it. -export function ConnectScreen({ onConnect }: Props) { - const [gateway, setGateway] = useState( - () => localStorage.getItem(LS_GATEWAY) ?? "http://localhost:7700", - ); - const [name, setName] = useState(() => localStorage.getItem(LS_PEER) ?? ""); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); - - const connect = async () => { - const trimmed = name.trim(); - if (!trimmed) { - setError("Elige un nombre de identidad"); - return; - } - setBusy(true); - setError(null); - try { - const client = new GatewayClient(gateway.trim()); - const peer = await client.connect(trimmed); - localStorage.setItem(LS_GATEWAY, client.baseURL); - localStorage.setItem(LS_PEER, trimmed); - onConnect(client, peer); - } catch (e) { - setError(e instanceof Error ? e.message : String(e)); - } finally { - setBusy(false); - } - }; - - return ( -
- - - - - - -
- unibus - - chat cifrado extremo a extremo sobre NATS - -
-
- - setGateway(e.currentTarget.value)} - disabled={busy} - /> - setName(e.currentTarget.value)} - onKeyDown={(e) => e.key === "Enter" && connect()} - disabled={busy} - data-autofocus - /> - - {error && ( - } - title="No se pudo conectar" - > - {error} - - )} - - -
-
-
- ); -} diff --git a/web/src/components/MembersPane.tsx b/web/src/components/MembersPane.tsx deleted file mode 100644 index 891247cd..00000000 --- a/web/src/components/MembersPane.tsx +++ /dev/null @@ -1,153 +0,0 @@ -import { useState } from "react"; -import { - Stack, - Group, - Text, - Badge, - Select, - Button, - ActionIcon, - Divider, - Box, - Avatar, - Tooltip, - ScrollArea, -} from "@mantine/core"; -import { IconUserPlus, IconUserMinus, IconRefresh, IconUsers } from "@tabler/icons-react"; -import type { Member, Peer, Room } from "../types"; - -interface Props { - room: Room; - members: Member[]; - peers: Peer[]; - myEndpoint: string; - iAmOwner: boolean; - nameFor: (endpoint: string) => string; - onInvite: (target: string) => void; - onKick: (target: string) => void; - onRefresh: () => void; -} - -// MembersPane is the right column: who is in the active room, plus invite (pick a -// connected peer) and kick (owner only). Invite/kick address peers by name; the -// gateway resolves the name to its bus endpoint. -export function MembersPane({ - room, - members, - peers, - myEndpoint, - iAmOwner, - nameFor, - onInvite, - onKick, - onRefresh, -}: Props) { - const [target, setTarget] = useState(null); - - const memberEndpoints = new Set(members.map((m) => m.endpoint)); - // Candidates to invite: connected peers not already in the room. - const candidates = peers - .filter((p) => !memberEndpoints.has(p.endpoint_id)) - .map((p) => ({ value: p.name, label: p.name })); - - const invite = () => { - if (target) { - onInvite(target); - setTarget(null); - } - }; - - return ( - - - - - Miembros - - {members.length} - - - - - - - - - - - - Invitar {room.encrypt && "(reparte la clave)"} - - -