feat: initial KosmoConnect platform v0.1
Some checks failed
CI / lint-docs (push) Has been cancelled
CI / build-firmware (push) Has been cancelled
CI / test-backend (push) Has been cancelled
CI / test-web (push) Has been cancelled

Includes:
- Backend services: ingestion (:8001), weather API (:8002),
  gateway (:8003), billing (:8004) with BTCPay integration
- Shared asyncpg pool, TimescaleDB hypertable, Redis, Mosquitto MQTT
- React frontend: Dashboard (MapLibre) and Messaging (chat UI)
- Bridge daemon for Pi + Meshtastic (Serial/TCP T-Deck support)
- Production Docker Compose, Nginx reverse proxy, ops scripts
- DEPLOY.md with step-by-step deployment guide
This commit is contained in:
2026-04-12 17:30:15 +02:00
commit 0a4fb7b55e
95 changed files with 9903 additions and 0 deletions

35
web/messaging/README.md Normal file
View File

@@ -0,0 +1,35 @@
# KosmoConnect Messaging Client
A subscriber-only web application for sending and receiving messages with the Meshtastic mesh network.
## Tech Stack
- **React 18** + **Vite**
- Plain CSS (no heavy UI framework)
## Running Locally
Make sure the **Gateway Service** is running on `http://localhost:8003` (see `backend/gateway/README.md`).
```bash
cd web/messaging
npm install
npm run dev
```
Open http://localhost:3001 in your browser.
## Features (v0.1)
- **User switcher** (dev mode): select between test subscription tiers (Wanderer / Guardian)
- **Conversation list**: auto-refreshing sidebar with latest message preview and unread badges
- **Message thread**: chat-style bubbles with timestamps and delivery status indicators
- `⏳` pending / `✓` queued / `✓✓` transmitted or delivered
- **Auto-refresh**: polls for new replies every 5 seconds
- **Subscription enforcement**: errors surfaced as browser alerts (e.g., quota exceeded, node not allowed)
## Architecture Notes
- The Vite dev server proxies `/api` requests to `localhost:8003` to avoid CORS issues during development.
- In production, the messaging client is served as static files and talks directly to the API gateway host.
- Authentication is currently mocked with a simple `X-User-ID` header selector. Production will use JWT.

12
web/messaging/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>KosmoConnect Messaging</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

1753
web/messaging/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,21 @@
{
"name": "kosmoconnect-messaging",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"vite": "^5.4.1"
}
}

92
web/messaging/src/App.jsx Normal file
View File

@@ -0,0 +1,92 @@
import { useState } from 'react'
import ConversationList from './components/ConversationList'
import MessageThread from './components/MessageThread'
import UserSwitcher from './components/UserSwitcher'
const styles = {
container: {
display: 'flex',
flexDirection: 'column',
width: '100%',
height: '100vh',
background: '#0f172a',
},
header: {
padding: '0.75rem 1rem',
background: '#0f172a',
borderBottom: '1px solid #1e293b',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '1rem',
},
title: {
margin: 0,
fontSize: '1.25rem',
fontWeight: 600,
color: '#38bdf8',
},
main: {
display: 'flex',
flex: 1,
overflow: 'hidden',
},
sidebar: {
width: '320px',
minWidth: '260px',
borderRight: '1px solid #1e293b',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
},
thread: {
flex: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
},
empty: {
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#64748b',
fontSize: '0.95rem',
},
}
export default function App() {
const [userId, setUserId] = useState('11111111-1111-1111-1111-111111111111')
const [selectedNodeId, setSelectedNodeId] = useState(null)
return (
<div style={styles.container}>
<header style={styles.header}>
<h1 style={styles.title}>KosmoConnect Messaging</h1>
<UserSwitcher userId={userId} onChange={setUserId} />
</header>
<main style={styles.main}>
<aside style={styles.sidebar}>
<ConversationList
userId={userId}
selectedNodeId={selectedNodeId}
onSelect={setSelectedNodeId}
/>
</aside>
<section style={styles.thread}>
{selectedNodeId ? (
<MessageThread
userId={userId}
nodeId={selectedNodeId}
key={selectedNodeId + userId}
/>
) : (
<div style={styles.empty}>Select a conversation to start messaging</div>
)}
</section>
</main>
</div>
)
}

View File

@@ -0,0 +1,96 @@
import { useConversations } from '../hooks/useApi'
function timeAgo(iso) {
if (!iso) return ''
const diff = Date.now() - new Date(iso).getTime()
const sec = Math.floor(diff / 1000)
if (sec < 60) return `${sec}s`
const min = Math.floor(sec / 60)
if (min < 60) return `${min}m`
const hr = Math.floor(min / 60)
return `${hr}h`
}
export default function ConversationList({ userId, selectedNodeId, onSelect }) {
const { conversations, error } = useConversations(userId)
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div
style={{
padding: '0.6rem 0.9rem',
fontSize: '0.75rem',
fontWeight: 600,
color: '#64748b',
textTransform: 'uppercase',
letterSpacing: '0.05em',
borderBottom: '1px solid #1e293b',
}}
>
Conversations
</div>
{error && (
<div style={{ padding: '0.5rem 0.75rem', color: '#fecaca', fontSize: '0.85rem' }}>
Error: {error}
</div>
)}
<div style={{ flex: 1, overflowY: 'auto' }}>
{conversations.length === 0 && (
<div style={{ padding: '1rem', color: '#64748b', fontSize: '0.875rem' }}>
No conversations yet.
</div>
)}
{conversations.map((c) => {
const isSelected = c.node_id === selectedNodeId
return (
<button
key={c.node_id}
onClick={() => onSelect(c.node_id)}
style={{
width: '100%',
textAlign: 'left',
padding: '0.75rem 0.9rem',
background: isSelected ? '#1e293b' : 'transparent',
border: 'none',
borderBottom: '1px solid #1e293b',
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
gap: '0.25rem',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontWeight: 600, color: '#f8fafc', fontSize: '0.95rem' }}>
{c.nickname || c.node_id}
</span>
<span style={{ fontSize: '0.75rem', color: '#64748b' }}>{timeAgo(c.latest_at)}</span>
</div>
<div style={{ fontSize: '0.85rem', color: '#94a3b8', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{c.latest_text || 'No messages'}
</div>
{c.unread_count > 0 && (
<span
style={{
alignSelf: 'flex-start',
marginTop: '0.2rem',
background: '#38bdf8',
color: '#0f172a',
fontSize: '0.7rem',
fontWeight: 700,
padding: '0.15rem 0.4rem',
borderRadius: '999px',
}}
>
{c.unread_count} new
</span>
)}
</button>
)
})}
</div>
</div>
)
}

View File

@@ -0,0 +1,133 @@
import { useState, useRef, useEffect } from 'react'
import { useMessages } from '../hooks/useApi'
function statusIcon(status) {
if (status === 'pending') return '⏳'
if (status === 'queued') return '✓'
if (status === 'transmitted') return '✓✓'
if (status === 'delivered') return '✓✓'
return ''
}
export default function MessageThread({ userId, nodeId }) {
const { messages, error, sendMessage } = useMessages(userId, nodeId)
const [text, setText] = useState('')
const [sending, setSending] = useState(false)
const bottomRef = useRef(null)
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
const handleSend = async (e) => {
e.preventDefault()
if (!text.trim() || sending) return
setSending(true)
try {
await sendMessage(text.trim())
setText('')
} catch (err) {
alert(err.message)
} finally {
setSending(false)
}
}
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div
style={{
padding: '0.75rem 1rem',
borderBottom: '1px solid #1e293b',
fontWeight: 600,
color: '#f8fafc',
background: '#0f172a',
}}
>
{nodeId}
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: '1rem', display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
{error && (
<div style={{ color: '#fecaca', fontSize: '0.85rem' }}>Error loading messages: {error}</div>
)}
{messages.map((msg) => {
const isOutbound = msg.direction === 'outbound'
return (
<div
key={msg.id}
style={{
alignSelf: isOutbound ? 'flex-end' : 'flex-start',
maxWidth: '70%',
background: isOutbound ? '#075985' : '#1e293b',
color: '#f8fafc',
padding: '0.6rem 0.9rem',
borderRadius: '0.75rem',
borderBottomRightRadius: isOutbound ? '0.25rem' : '0.75rem',
borderBottomLeftRadius: isOutbound ? '0.75rem' : '0.25rem',
fontSize: '0.9rem',
lineHeight: 1.4,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{msg.text}
<div style={{ marginTop: '0.35rem', fontSize: '0.7rem', color: '#94a3b8', display: 'flex', alignItems: 'center', gap: '0.35rem', justifyContent: isOutbound ? 'flex-end' : 'flex-start' }}>
<span>{new Date(msg.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
{isOutbound && <span title={msg.status}>{statusIcon(msg.status)}</span>}
{msg.hop_count != null && (
<span title={`${msg.hop_count} hops`}>· {msg.hop_count} hops</span>
)}
</div>
</div>
)
})}
<div ref={bottomRef} />
</div>
<form
onSubmit={handleSend}
style={{
display: 'flex',
gap: '0.5rem',
padding: '0.75rem 1rem',
borderTop: '1px solid #1e293b',
background: '#0f172a',
}}
>
<input
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Type a message..."
maxLength={200}
style={{
flex: 1,
background: '#1e293b',
border: '1px solid #334155',
borderRadius: '0.5rem',
padding: '0.5rem 0.75rem',
color: '#f8fafc',
fontSize: '0.9rem',
}}
/>
<button
type="submit"
disabled={sending || !text.trim()}
style={{
background: sending ? '#334155' : '#0284c7',
color: '#fff',
border: 'none',
borderRadius: '0.5rem',
padding: '0.5rem 1rem',
fontWeight: 600,
fontSize: '0.9rem',
}}
>
{sending ? 'Sending…' : 'Send'}
</button>
</form>
</div>
)
}

View File

@@ -0,0 +1,30 @@
const USERS = [
{ id: '11111111-1111-1111-1111-111111111111', label: 'Wanderer (any node)' },
{ id: '22222222-2222-2222-2222-222222222222', label: 'Guardian (allowed nodes)' },
]
export default function UserSwitcher({ userId, onChange }) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span style={{ fontSize: '0.8rem', color: '#94a3b8' }}>User:</span>
<select
value={userId}
onChange={(e) => onChange(e.target.value)}
style={{
background: '#1e293b',
color: '#f8fafc',
border: '1px solid #334155',
borderRadius: '0.375rem',
padding: '0.35rem 0.5rem',
fontSize: '0.85rem',
}}
>
{USERS.map((u) => (
<option key={u.id} value={u.id}>
{u.label}
</option>
))}
</select>
</div>
)
}

View File

@@ -0,0 +1,77 @@
import { useEffect, useState, useCallback } from 'react'
const API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8003'
async function apiFetch(path, userId, options = {}) {
const res = await fetch(`${API_BASE}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
'X-User-ID': userId,
...options.headers,
},
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(err.detail || `HTTP ${res.status}`)
}
return res.json()
}
export function useConversations(userId, refreshMs = 5000) {
const [conversations, setConversations] = useState([])
const [error, setError] = useState(null)
useEffect(() => {
let cancelled = false
async function fetchConversations() {
try {
const json = await apiFetch('/api/v1/messages/conversations', userId)
if (!cancelled) setConversations(json.data || [])
} catch (e) {
if (!cancelled) setError(e.message)
}
}
fetchConversations()
const id = setInterval(fetchConversations, refreshMs)
return () => {
cancelled = true
clearInterval(id)
}
}, [userId, refreshMs])
return { conversations, error }
}
export function useMessages(userId, nodeId, refreshMs = 5000) {
const [messages, setMessages] = useState([])
const [error, setError] = useState(null)
useEffect(() => {
let cancelled = false
async function fetchMessages() {
try {
const json = await apiFetch(`/api/v1/messages/conversations/${encodeURIComponent(nodeId)}`, userId)
if (!cancelled) setMessages(json.data || [])
} catch (e) {
if (!cancelled) setError(e.message)
}
}
fetchMessages()
const id = setInterval(fetchMessages, refreshMs)
return () => {
cancelled = true
clearInterval(id)
}
}, [userId, nodeId, refreshMs])
const sendMessage = useCallback(async (text) => {
const json = await apiFetch('/api/v1/messages', userId, {
method: 'POST',
body: JSON.stringify({ target_node_id: nodeId, text }),
})
return json
}, [userId, nodeId])
return { messages, error, sendMessage }
}

View File

@@ -0,0 +1,31 @@
:root {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.5;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.9);
background-color: #0f172a;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
display: flex;
min-width: 320px;
min-height: 100vh;
}
#root {
width: 100%;
height: 100vh;
}
button {
cursor: pointer;
}

View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.jsx'
import './index.css'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 3001,
proxy: {
'/api': {
target: 'http://localhost:8003',
changeOrigin: true,
}
}
}
})