commit f0771a1ce4c47b318541ab9a28cd6b768461cd34 Author: Jeff Emmett Date: Mon Apr 6 16:42:22 2026 +0000 Initial commit: rMesh frontend for rSpace Next.js 16 rApp with dual-stack mesh networking dashboard: - MeshCore status, contacts, and LoRa messages - Reticulum status, topology, LXMF messaging - Node registration for hardware tracking - EncryptID auth, Prisma/Postgres, Traefik routing - Serves at rspace.online/rmesh Co-Authored-By: Claude Opus 4.6 (1M context) diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..462d3f3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +node_modules +.next +.git +.env +.env.* +!.env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5ba8a5d --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +NEXTAUTH_SECRET=change-me +DB_PASSWORD=change-me +BRIDGE_API_KEY=change-me +INFISICAL_CLIENT_ID= +INFISICAL_CLIENT_SECRET= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..47dd36d --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +.next/ +.env +.env.* +!.env.example +encryptid-sdk/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b2f4541 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,67 @@ +# Build stage +FROM node:20-alpine AS builder + +WORKDIR /app + +# Copy package files first for layer caching +COPY package*.json ./ +COPY prisma ./prisma/ + +# Copy the encryptid-sdk (symlinked or copied into build context) +COPY encryptid-sdk/ /encryptid-sdk/ + +# Install dependencies +RUN npm ci || npm install + +# Ensure SDK is properly linked in node_modules +RUN rm -rf node_modules/@encryptid/sdk && \ + mkdir -p node_modules/@encryptid && \ + cp -r /encryptid-sdk node_modules/@encryptid/sdk + +# Copy source files +COPY src ./src +COPY public ./public +COPY next.config.ts tsconfig.json postcss.config.mjs components.json ./ + +# Generate Prisma client +RUN npx prisma generate + +# Build the application +RUN npm run build + +# Production stage +FROM node:20-alpine AS runner + +WORKDIR /app + +ENV NODE_ENV=production + +# Create non-root user +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +# Copy necessary files from builder +COPY --from=builder /app/public ./public +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static +COPY --from=builder /app/prisma ./prisma +COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma +COPY --from=builder /app/node_modules/prisma ./node_modules/prisma +COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma + +# Infisical entrypoint for secret injection +COPY --chown=nextjs:nodejs entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +# Set ownership +RUN chown -R nextjs:nodejs /app + +USER nextjs + +EXPOSE 3000 + +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" + +ENTRYPOINT ["/entrypoint.sh"] +CMD ["node", "server.js"] diff --git a/components.json b/components.json new file mode 100644 index 0000000..03909d9 --- /dev/null +++ b/components.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": {} +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c0a5377 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,82 @@ +services: + rmesh-online: + container_name: rmesh-online + restart: unless-stopped + labels: + - traefik.enable=true + - traefik.http.routers.rmesh.rule=(Host(`rspace.online`) || HostRegexp(`{subdomain:[a-z0-9-]+}.rspace.online`)) + && PathPrefix(`/rmesh`) + - traefik.http.routers.rmesh-online.rule=Host(`rmesh.online`) + - traefik.http.routers.rmesh-online.entrypoints=web + - traefik.http.routers.rmesh-online.priority=150 + - traefik.http.routers.rmesh-online.service=rmesh + - traefik.http.routers.rmesh-online.middlewares=rmesh-rootredirect + - traefik.http.middlewares.rmesh-rootredirect.redirectregex.regex=^https?://rmesh\.online/?$ + - traefik.http.middlewares.rmesh-rootredirect.redirectregex.replacement=https://rmesh.online/rmesh + - traefik.http.middlewares.rmesh-rootredirect.redirectregex.permanent=false + - traefik.http.routers.rmesh.entrypoints=web + - traefik.http.routers.rmesh.priority=140 + - traefik.http.services.rmesh.loadbalancer.server.port=3000 + - traefik.docker.network=traefik-public + environment: + - NEXTAUTH_URL=https://rspace.online/rmesh + - NEXTAUTH_SECRET=${NEXTAUTH_SECRET} + - ENCRYPTID_SERVER_URL=https://auth.ridentity.online + - NEXT_PUBLIC_ENCRYPTID_SERVER_URL=https://auth.ridentity.online + - ROOT_DOMAIN=rspace.online + - NEXT_PUBLIC_ROOT_DOMAIN=rspace.online + - DATABASE_URL=postgresql://rmesh:${DB_PASSWORD}@rmesh-postgres:5432/rmesh + - INFISICAL_CLIENT_ID=${INFISICAL_CLIENT_ID} + - INFISICAL_CLIENT_SECRET=${INFISICAL_CLIENT_SECRET} + - INFISICAL_PROJECT_SLUG=rmesh + - RETICULUM_BRIDGE_URL=http://rmesh-reticulum:8000 + - BRIDGE_API_KEY=${BRIDGE_API_KEY} + networks: + - traefik-public + - rmesh-internal + depends_on: + rmesh-postgres: + condition: service_healthy + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + image: localhost:3000/jeffemmett/rmesh-online:${IMAGE_TAG:-latest} + + rmesh-postgres: + image: postgres:16-alpine + container_name: rmesh-postgres + restart: unless-stopped + environment: + - POSTGRES_USER=rmesh + - POSTGRES_PASSWORD=${DB_PASSWORD} + - POSTGRES_DB=rmesh + volumes: + - postgres_data:/var/lib/postgresql/data + networks: + - rmesh-internal + healthcheck: + test: + - CMD-SHELL + - pg_isready -U rmesh -d rmesh + interval: 5s + timeout: 5s + retries: 5 + cap_drop: + - ALL + cap_add: + - DAC_OVERRIDE + - FOWNER + - SETGID + - SETUID + security_opt: + - no-new-privileges:true + +networks: + traefik-public: + external: true + rmesh-internal: + internal: true + +volumes: + postgres_data: diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 0000000..e82770e --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,83 @@ +#!/bin/sh +# Infisical secret injection entrypoint (Node.js) +# Fetches secrets from Infisical API and injects them as env vars before starting the app. +# Required env vars: INFISICAL_CLIENT_ID, INFISICAL_CLIENT_SECRET +# Optional: INFISICAL_PROJECT_SLUG, INFISICAL_ENV (default: prod), +# INFISICAL_URL (default: http://infisical:8080) + +set -e + +export INFISICAL_URL="${INFISICAL_URL:-http://infisical:8080}" +export INFISICAL_ENV="${INFISICAL_ENV:-prod}" +# IMPORTANT: Set INFISICAL_PROJECT_SLUG in your docker-compose.yml +export INFISICAL_PROJECT_SLUG="${INFISICAL_PROJECT_SLUG:?INFISICAL_PROJECT_SLUG must be set}" + +if [ -z "$INFISICAL_CLIENT_ID" ] || [ -z "$INFISICAL_CLIENT_SECRET" ]; then + echo "[infisical] No credentials set, starting without secret injection" + exec "$@" +fi + +echo "[infisical] Fetching secrets from ${INFISICAL_PROJECT_SLUG}/${INFISICAL_ENV}..." + +# Use Node.js (already in the image) for reliable JSON parsing and HTTP calls +EXPORTS=$(node -e " +const http = require('http'); +const https = require('https'); +const url = new URL(process.env.INFISICAL_URL); +const client = url.protocol === 'https:' ? https : http; + +const post = (path, body) => new Promise((resolve, reject) => { + const data = JSON.stringify(body); + const req = client.request({ hostname: url.hostname, port: url.port, path, method: 'POST', + headers: { 'Content-Type': 'application/json', 'Content-Length': data.length } + }, res => { let d = ''; res.on('data', c => d += c); res.on('end', () => resolve(JSON.parse(d))); }); + req.on('error', reject); + req.end(data); +}); + +const get = (path, token) => new Promise((resolve, reject) => { + const req = client.request({ hostname: url.hostname, port: url.port, path, method: 'GET', + headers: { 'Authorization': 'Bearer ' + token } + }, res => { let d = ''; res.on('data', c => d += c); res.on('end', () => resolve(JSON.parse(d))); }); + req.on('error', reject); + req.end(); +}); + +(async () => { + try { + const auth = await post('/api/v1/auth/universal-auth/login', { + clientId: process.env.INFISICAL_CLIENT_ID, + clientSecret: process.env.INFISICAL_CLIENT_SECRET + }); + if (!auth.accessToken) { console.error('[infisical] Auth failed'); process.exit(1); } + + const slug = process.env.INFISICAL_PROJECT_SLUG; + const env = process.env.INFISICAL_ENV; + const secrets = await get('/api/v3/secrets/raw?workspaceSlug=' + slug + '&environment=' + env + '&secretPath=/&recursive=true', auth.accessToken); + + if (!secrets.secrets) { console.error('[infisical] No secrets returned'); process.exit(1); } + + // Output as shell-safe export statements + for (const s of secrets.secrets) { + // Single-quote the value to prevent shell expansion, escape existing single quotes + const escaped = s.secretValue.replace(/'/g, \"'\\\\''\" ); + console.log('export ' + s.secretKey + \"='\" + escaped + \"'\"); + } + } catch (e) { console.error('[infisical] Error:', e.message); process.exit(1); } +})(); +" 2>&1) || { + echo "[infisical] WARNING: Failed to fetch secrets, starting with existing env vars" + exec "$@" +} + +# Check if we got export statements or error messages +if echo "$EXPORTS" | grep -q "^export "; then + COUNT=$(echo "$EXPORTS" | grep -c "^export ") + eval "$EXPORTS" + echo "[infisical] Injected ${COUNT} secrets" +else + echo "[infisical] WARNING: $EXPORTS" + echo "[infisical] Starting with existing env vars" +fi + +exec "$@" diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..348c45a --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,14 @@ +import { dirname } from "path"; +import { fileURLToPath } from "url"; +import { FlatCompat } from "@eslint/eslintrc"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const compat = new FlatCompat({ + baseDirectory: __dirname, +}); + +const eslintConfig = [...compat.extends("next/core-web-vitals")]; + +export default eslintConfig; diff --git a/next.config.ts b/next.config.ts new file mode 100644 index 0000000..8cf83df --- /dev/null +++ b/next.config.ts @@ -0,0 +1,8 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + output: "standalone", + basePath: "/rmesh", +}; + +export default nextConfig; diff --git a/package.json b/package.json new file mode 100644 index 0000000..61694ae --- /dev/null +++ b/package.json @@ -0,0 +1,40 @@ +{ + "name": "rmesh-online", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "eslint", + "postinstall": "prisma generate" + }, + "dependencies": { + "@auth/prisma-adapter": "^2.11.1", + "@encryptid/sdk": "file:../encryptid-sdk", + "@prisma/client": "^6.19.2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.563.0", + "next": "16.1.6", + "next-auth": "^5.0.0-beta.30", + "next-themes": "^0.4.6", + "prisma": "^6.19.2", + "radix-ui": "^1.4.3", + "react": "19.2.3", + "react-dom": "19.2.3", + "sonner": "^2.0.7", + "tailwind-merge": "^3.4.0" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "16.1.6", + "tailwindcss": "^4", + "tw-animate-css": "^1.4.0", + "typescript": "^5" + } +} diff --git a/postcss.config.mjs b/postcss.config.mjs new file mode 100644 index 0000000..61e3684 --- /dev/null +++ b/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..87068fa --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,131 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +// ─── Auth (NextAuth v5 + EncryptID) ───────────────────────────── + +model User { + id String @id @default(cuid()) + email String @unique + name String? + did String? @unique // EncryptID DID (passkey identity) + emailVerified DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + accounts Account[] + sessions Session[] + nodes MeshNode[] + messages LxmfMessage[] +} + +model Account { + id String @id @default(cuid()) + userId String + type String + provider String + providerAccountId String + refresh_token String? @db.Text + access_token String? @db.Text + expires_at Int? + token_type String? + scope String? + id_token String? @db.Text + session_state String? + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([provider, providerAccountId]) +} + +model Session { + id String @id @default(cuid()) + sessionToken String @unique + userId String + expires DateTime + user User @relation(fields: [userId], references: [id], onDelete: Cascade) +} + +model VerificationToken { + identifier String + token String @unique + expires DateTime + + @@unique([identifier, token]) +} + +// ─── Mesh Models ───────────────────────────────────────────────── + +model MeshNode { + id String @id @default(cuid()) + name String + reticulumHash String? @unique @map("reticulum_hash") + nodeType String @default("software") @map("node_type") + hardwareType String @default("") @map("hardware_type") + firmwareVersion String @default("") @map("firmware_version") + location String @default("") + latitude Float? + longitude Float? + isOnline Boolean @default(false) @map("is_online") + lastSeenAt DateTime? @map("last_seen_at") + metadata Json @default("{}") + + registeredById String @map("registered_by_id") + registeredBy User @relation(fields: [registeredById], references: [id]) + + spaceSlug String? @map("space_slug") + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@index([spaceSlug]) + @@index([registeredById]) + @@index([nodeType]) + @@map("mesh_nodes") +} + +model LxmfMessage { + id String @id @default(cuid()) + lxmfHash String? @unique @map("lxmf_hash") + direction String @default("inbound") + senderHash String @default("") @map("sender_hash") + recipientHash String @default("") @map("recipient_hash") + title String @default("") + content String + contentType String @default("text/plain") @map("content_type") + status String @default("pending") + deliveredAt DateTime? @map("delivered_at") + + userId String? @map("user_id") + user User? @relation(fields: [userId], references: [id]) + + spaceSlug String? @map("space_slug") + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@index([spaceSlug]) + @@index([userId]) + @@index([direction]) + @@index([senderHash]) + @@map("lxmf_messages") +} + +model TopologySnapshot { + id String @id @default(cuid()) + nodesJson Json @map("nodes_json") + linksJson Json @map("links_json") + nodeCount Int @default(0) @map("node_count") + linkCount Int @default(0) @map("link_count") + spaceSlug String? @map("space_slug") + capturedAt DateTime @default(now()) @map("captured_at") + + @@index([capturedAt]) + @@index([spaceSlug]) + @@map("topology_snapshots") +} diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..73228a0 --- /dev/null +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,2 @@ +import { handlers } from "@/lib/auth"; +export const { GET, POST } = handlers; diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts new file mode 100644 index 0000000..ee6aa4f --- /dev/null +++ b/src/app/api/health/route.ts @@ -0,0 +1,5 @@ +import { NextResponse } from "next/server"; + +export async function GET() { + return NextResponse.json({ status: "ok" }); +} diff --git a/src/app/api/internal/provision/route.ts b/src/app/api/internal/provision/route.ts new file mode 100644 index 0000000..5796aa5 --- /dev/null +++ b/src/app/api/internal/provision/route.ts @@ -0,0 +1,20 @@ +import { NextResponse } from "next/server"; + +/** + * Internal provision endpoint -- called by rSpace Registry when activating + * rmesh for a space. No auth required (only reachable from Docker network). + */ +export async function POST(request: Request) { + const body = await request.json(); + const space: string = body.space?.trim(); + if (!space) { + return NextResponse.json({ error: "Missing space name" }, { status: 400 }); + } + const ownerDid: string = body.owner_did || ""; + return NextResponse.json({ + status: "ok", + space, + owner_did: ownerDid, + message: "rmesh space acknowledged", + }); +} diff --git a/src/app/api/me/route.ts b/src/app/api/me/route.ts new file mode 100644 index 0000000..3682525 --- /dev/null +++ b/src/app/api/me/route.ts @@ -0,0 +1,17 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; + +export async function GET() { + const session = await auth(); + if (!session?.user) { + return NextResponse.json({ authenticated: false, user: null }); + } + return NextResponse.json({ + authenticated: true, + user: { + id: session.user.id, + name: session.user.name, + email: session.user.email, + }, + }); +} diff --git a/src/app/api/mesh/messages/route.ts b/src/app/api/mesh/messages/route.ts new file mode 100644 index 0000000..56cd14d --- /dev/null +++ b/src/app/api/mesh/messages/route.ts @@ -0,0 +1,34 @@ +import { NextResponse } from "next/server"; +import { getMeshMessages, sendMeshMessage } from "@/lib/mesh-client"; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const limit = parseInt(searchParams.get("limit") || "100"); + const offset = parseInt(searchParams.get("offset") || "0"); + + try { + const data = await getMeshMessages(limit, offset); + return NextResponse.json(data); + } catch { + return NextResponse.json({ messages: [], total: 0 }, { status: 503 }); + } +} + +export async function POST(request: Request) { + try { + const body = await request.json(); + const { destination_hash, content, title } = body; + + if (!destination_hash || !content) { + return NextResponse.json( + { error: "destination_hash and content are required" }, + { status: 400 }, + ); + } + + const data = await sendMeshMessage(destination_hash, content, title || ""); + return NextResponse.json(data); + } catch { + return NextResponse.json({ error: "Failed to send message" }, { status: 503 }); + } +} diff --git a/src/app/api/mesh/nodes/route.ts b/src/app/api/mesh/nodes/route.ts new file mode 100644 index 0000000..3b13738 --- /dev/null +++ b/src/app/api/mesh/nodes/route.ts @@ -0,0 +1,11 @@ +import { NextResponse } from "next/server"; +import { getMeshNodes } from "@/lib/mesh-client"; + +export async function GET() { + try { + const data = await getMeshNodes(); + return NextResponse.json(data); + } catch { + return NextResponse.json({ nodes: [], total: 0 }, { status: 503 }); + } +} diff --git a/src/app/api/mesh/status/route.ts b/src/app/api/mesh/status/route.ts new file mode 100644 index 0000000..40009b6 --- /dev/null +++ b/src/app/api/mesh/status/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from "next/server"; +import { getCombinedStatus } from "@/lib/mesh-client"; + +export async function GET() { + try { + const data = await getCombinedStatus(); + return NextResponse.json(data); + } catch { + return NextResponse.json({ + reticulum: { online: false, transport_enabled: false, identity_hash: "", uptime_seconds: 0, announced_count: 0, path_count: 0 }, + meshcore: { connected: false, device_info: {}, contact_count: 0, message_count: 0 }, + }, { status: 503 }); + } +} diff --git a/src/app/api/mesh/topology/route.ts b/src/app/api/mesh/topology/route.ts new file mode 100644 index 0000000..aad4f3f --- /dev/null +++ b/src/app/api/mesh/topology/route.ts @@ -0,0 +1,11 @@ +import { NextResponse } from "next/server"; +import { getMeshTopology } from "@/lib/mesh-client"; + +export async function GET() { + try { + const data = await getMeshTopology(); + return NextResponse.json(data); + } catch { + return NextResponse.json({ nodes: [], links: [], node_count: 0, link_count: 0 }, { status: 503 }); + } +} diff --git a/src/app/api/meshcore/contacts/route.ts b/src/app/api/meshcore/contacts/route.ts new file mode 100644 index 0000000..cc628c3 --- /dev/null +++ b/src/app/api/meshcore/contacts/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from "next/server"; +import { getMeshCoreContacts, refreshMeshCoreContacts } from "@/lib/mesh-client"; + +export async function GET() { + try { + return NextResponse.json(await getMeshCoreContacts()); + } catch { + return NextResponse.json({ contacts: [], total: 0 }, { status: 503 }); + } +} + +export async function POST() { + try { + return NextResponse.json(await refreshMeshCoreContacts()); + } catch { + return NextResponse.json({ contacts: [], total: 0 }, { status: 503 }); + } +} diff --git a/src/app/api/meshcore/messages/route.ts b/src/app/api/meshcore/messages/route.ts new file mode 100644 index 0000000..089a31a --- /dev/null +++ b/src/app/api/meshcore/messages/route.ts @@ -0,0 +1,26 @@ +import { NextResponse } from "next/server"; +import { getMeshCoreMessages, sendMeshCoreMessage } from "@/lib/mesh-client"; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const limit = parseInt(searchParams.get("limit") || "100"); + const offset = parseInt(searchParams.get("offset") || "0"); + + try { + return NextResponse.json(await getMeshCoreMessages(limit, offset)); + } catch { + return NextResponse.json({ messages: [], total: 0 }, { status: 503 }); + } +} + +export async function POST(request: Request) { + try { + const { contact_name, content } = await request.json(); + if (!contact_name || !content) { + return NextResponse.json({ error: "contact_name and content required" }, { status: 400 }); + } + return NextResponse.json(await sendMeshCoreMessage(contact_name, content)); + } catch { + return NextResponse.json({ error: "Failed to send" }, { status: 503 }); + } +} diff --git a/src/app/api/meshcore/stats/route.ts b/src/app/api/meshcore/stats/route.ts new file mode 100644 index 0000000..003e6a9 --- /dev/null +++ b/src/app/api/meshcore/stats/route.ts @@ -0,0 +1,10 @@ +import { NextResponse } from "next/server"; +import { getMeshCoreStats } from "@/lib/mesh-client"; + +export async function GET() { + try { + return NextResponse.json(await getMeshCoreStats()); + } catch { + return NextResponse.json({}, { status: 503 }); + } +} diff --git a/src/app/api/meshcore/status/route.ts b/src/app/api/meshcore/status/route.ts new file mode 100644 index 0000000..e9316b6 --- /dev/null +++ b/src/app/api/meshcore/status/route.ts @@ -0,0 +1,10 @@ +import { NextResponse } from "next/server"; +import { getMeshCoreStatus } from "@/lib/mesh-client"; + +export async function GET() { + try { + return NextResponse.json(await getMeshCoreStatus()); + } catch { + return NextResponse.json({ connected: false, device_info: {}, contact_count: 0, message_count: 0 }, { status: 503 }); + } +} diff --git a/src/app/api/spaces/route.ts b/src/app/api/spaces/route.ts new file mode 100644 index 0000000..7ed9c5e --- /dev/null +++ b/src/app/api/spaces/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from "next/server"; + +const REGISTRY_URL = process.env.REGISTRY_URL || "http://rspace-registry:8000"; + +export async function GET(request: Request) { + try { + const res = await fetch(`${REGISTRY_URL}/spaces`, { + headers: { + Authorization: request.headers.get("Authorization") || "", + }, + cache: "no-store", + }); + const data = await res.json(); + return NextResponse.json(data); + } catch { + return NextResponse.json({ spaces: [] }, { status: 503 }); + } +} diff --git a/src/app/globals.css b/src/app/globals.css new file mode 100644 index 0000000..129eae5 --- /dev/null +++ b/src/app/globals.css @@ -0,0 +1,84 @@ +@import "tailwindcss"; +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-geist-sans); + --font-mono: var(--font-geist-mono); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --radius-2xl: calc(var(--radius) + 8px); +} + +:root { + --radius: 0.625rem; + --background: oklch(0.98 0.005 140); + --foreground: oklch(0.15 0.02 140); + --card: oklch(1 0 0); + --card-foreground: oklch(0.15 0.02 140); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.15 0.02 140); + --primary: oklch(0.55 0.2 140); + --primary-foreground: oklch(0.98 0 0); + --secondary: oklch(0.85 0.06 140); + --secondary-foreground: oklch(0.2 0.02 140); + --muted: oklch(0.95 0.01 140); + --muted-foreground: oklch(0.45 0.03 140); + --accent: oklch(0.65 0.18 80); + --accent-foreground: oklch(0.15 0.02 80); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.88 0.02 140); + --input: oklch(0.92 0.01 140); + --ring: oklch(0.55 0.2 140); +} + +.dark { + --background: oklch(0.12 0.02 140); + --foreground: oklch(0.95 0.01 140); + --card: oklch(0.18 0.02 140); + --card-foreground: oklch(0.95 0.01 140); + --popover: oklch(0.18 0.02 140); + --popover-foreground: oklch(0.95 0.01 140); + --primary: oklch(0.7 0.2 140); + --primary-foreground: oklch(0.12 0.02 140); + --secondary: oklch(0.35 0.06 140); + --secondary-foreground: oklch(0.95 0.01 140); + --muted: oklch(0.25 0.02 140); + --muted-foreground: oklch(0.65 0.03 140); + --accent: oklch(0.6 0.15 80); + --accent-foreground: oklch(0.95 0.01 80); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(0.3 0.02 140); + --input: oklch(0.25 0.02 140); + --ring: oklch(0.7 0.2 140); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 0000000..e51e10d --- /dev/null +++ b/src/app/layout.tsx @@ -0,0 +1,24 @@ +import type { Metadata } from "next"; +import { ThemeProvider } from "next-themes"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "rMesh - Mesh Networking for rSpace", + description: "Resilient mesh networking and offline-capable messaging for rSpace communities", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + + {children} + + + + ); +} diff --git a/src/app/messages/page.tsx b/src/app/messages/page.tsx new file mode 100644 index 0000000..d911407 --- /dev/null +++ b/src/app/messages/page.tsx @@ -0,0 +1,41 @@ +import Link from "next/link"; +import { Radio, ArrowLeft } from "lucide-react"; +import MessageComposer from "@/components/mesh/MessageComposer"; +import MessageList from "@/components/mesh/MessageList"; +import MeshCoreMessages from "@/components/mesh/MeshCoreMessages"; + +export default function MessagesPage() { + return ( +
+
+
+ + + + +

Messages

+
+
+ +
+
+
+

+ + MeshCore (LoRa) +

+ +
+
+

+ + Reticulum (LXMF) +

+ + +
+
+
+
+ ); +} diff --git a/src/app/nodes/page.tsx b/src/app/nodes/page.tsx new file mode 100644 index 0000000..60f8f2f --- /dev/null +++ b/src/app/nodes/page.tsx @@ -0,0 +1,25 @@ +import Link from "next/link"; +import { Radio, ArrowLeft } from "lucide-react"; +import NodeList from "@/components/mesh/NodeList"; +import NodeRegistration from "@/components/mesh/NodeRegistration"; + +export default function NodesPage() { + return ( +
+
+
+ + + + +

Mesh Nodes

+
+
+ +
+ + +
+
+ ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx new file mode 100644 index 0000000..0de622f --- /dev/null +++ b/src/app/page.tsx @@ -0,0 +1,95 @@ +import Link from "next/link"; +import { Radio, MessageSquare, Network, Antenna, Users } from "lucide-react"; +import NetworkStatus from "@/components/mesh/NetworkStatus"; +import MeshCoreStatus from "@/components/mesh/MeshCoreStatus"; + +const NAV_ITEMS = [ + { href: "/messages", icon: MessageSquare, label: "Messages", description: "LXMF (Reticulum) and MeshCore messages in one view" }, + { href: "/topology", icon: Network, label: "Topology", description: "Visualize mesh network paths and connections" }, + { href: "/nodes", icon: Antenna, label: "Nodes", description: "Register and manage mesh hardware nodes" }, +]; + +export default function HomePage() { + return ( +
+
+
+ +

rMesh

+ Mesh Networking for rSpace + + rSpace + +
+
+ +
+ {/* Hero */} +
+
+ + Part of the rSpace Ecosystem +
+

Resilient Mesh Communications

+

+ Dual-stack mesh networking: MeshCore for LoRa mesh with intelligent routing, + Reticulum for encrypted Internet backbone. Your community stays connected + even when traditional infrastructure fails. +

+
+ + {/* Dual status cards */} +
+ + +
+ + {/* Navigation cards */} +
+ {NAV_ITEMS.map((item) => ( + +
+ +
+

{item.label}

+

{item.description}

+ + ))} +
+ + {/* Architecture explanation */} +
+

Dual-Stack Architecture

+
+
+

MeshCore (LoRa Mesh)

+

+ Intelligent structured routing over LoRa radio. Path learning instead of + flooding — scales to 64+ hops. Companion nodes, repeaters, and room servers. + MIT licensed, runs on $20 hardware. +

+
+
+

Reticulum (Internet Backbone)

+

+ Transport-agnostic encrypted networking. Bridges separate MeshCore LoRa + islands over the Internet. LXMF for delay-tolerant messaging between + sites. All traffic encrypted end-to-end by default. +

+
+
+
+ [MeshCore Nodes] <--868MHz LoRa--> [Companion] <--TCP--> [rMesh Server] <--Reticulum TCP--> [Remote Sites] +
+
+
+
+ ); +} diff --git a/src/app/topology/page.tsx b/src/app/topology/page.tsx new file mode 100644 index 0000000..cb663e2 --- /dev/null +++ b/src/app/topology/page.tsx @@ -0,0 +1,25 @@ +import Link from "next/link"; +import { Radio, ArrowLeft } from "lucide-react"; +import TopologyMap from "@/components/mesh/TopologyMap"; +import NetworkStatus from "@/components/mesh/NetworkStatus"; + +export default function TopologyPage() { + return ( +
+
+
+ + + + +

Network Topology

+
+
+ +
+ + +
+
+ ); +} diff --git a/src/components/mesh/MeshCoreContacts.tsx b/src/components/mesh/MeshCoreContacts.tsx new file mode 100644 index 0000000..a1726f8 --- /dev/null +++ b/src/components/mesh/MeshCoreContacts.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Users, RefreshCw } from "lucide-react"; + +interface Contact { + key_prefix: string; + name: string; + type: number; + last_seen?: number; + path_known: boolean; + public_key: string; +} + +export default function MeshCoreContacts() { + const [contacts, setContacts] = useState([]); + const [total, setTotal] = useState(0); + const [refreshing, setRefreshing] = useState(false); + + const fetchContacts = async () => { + try { + const res = await fetch("/rmesh/api/meshcore/contacts"); + const data = await res.json(); + setContacts(data.contacts || []); + setTotal(data.total || 0); + } catch { + // ignore + } + }; + + const handleRefresh = async () => { + setRefreshing(true); + try { + const res = await fetch("/rmesh/api/meshcore/contacts", { method: "POST" }); + const data = await res.json(); + setContacts(data.contacts || []); + setTotal(data.total || 0); + } catch { + // ignore + } + setRefreshing(false); + }; + + useEffect(() => { + fetchContacts(); + const interval = setInterval(fetchContacts, 30000); + return () => clearInterval(interval); + }, []); + + return ( +
+
+
+ +
+
+

MeshCore Contacts

+

{total} contact{total !== 1 ? "s" : ""}

+
+ +
+ + {contacts.length === 0 ? ( +

+ No contacts yet. Contacts appear when MeshCore nodes advertise on the mesh. +

+ ) : ( +
+ {contacts.map((c) => ( +
+
+
+
+

{c.name || "Unknown"}

+ {c.key_prefix}... +
+
+ + {c.path_known ? "path known" : "no path"} + +
+ ))} +
+ )} +
+ ); +} diff --git a/src/components/mesh/MeshCoreMessages.tsx b/src/components/mesh/MeshCoreMessages.tsx new file mode 100644 index 0000000..25a269d --- /dev/null +++ b/src/components/mesh/MeshCoreMessages.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Radio, ArrowUpRight, ArrowDownLeft, Hash } from "lucide-react"; + +interface MCMessage { + id: string; + type: string; + direction: string; + sender: string; + content: string; + channel?: string; + recipient?: string; + timestamp: number; + status: string; +} + +function formatTime(ts: number): string { + return new Date(ts * 1000).toLocaleString(); +} + +export default function MeshCoreMessages() { + const [messages, setMessages] = useState([]); + const [total, setTotal] = useState(0); + + useEffect(() => { + const fetchMessages = async () => { + try { + const res = await fetch("/rmesh/api/meshcore/messages"); + const data = await res.json(); + setMessages(data.messages || []); + setTotal(data.total || 0); + } catch { + // ignore + } + }; + + fetchMessages(); + const interval = setInterval(fetchMessages, 10000); + return () => clearInterval(interval); + }, []); + + return ( +
+
+
+ +
+
+

MeshCore Messages

+

{total} message{total !== 1 ? "s" : ""}

+
+
+ + {messages.length === 0 ? ( +

+ No MeshCore messages yet. Messages appear when contacts send DMs or channel posts. +

+ ) : ( +
+ {messages.map((msg) => ( +
+
+ {msg.direction === "outbound" ? ( + + ) : ( + + )} + {msg.sender} + {msg.channel && ( + + {msg.channel} + + )} + + {msg.type} + +
+

{msg.content}

+

{formatTime(msg.timestamp)}

+
+ ))} +
+ )} +
+ ); +} diff --git a/src/components/mesh/MeshCoreStatus.tsx b/src/components/mesh/MeshCoreStatus.tsx new file mode 100644 index 0000000..9ee9c75 --- /dev/null +++ b/src/components/mesh/MeshCoreStatus.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Radio, WifiOff, Users, MessageSquare } from "lucide-react"; + +interface MCStatus { + connected: boolean; + device_info: { + name?: string; + firmware?: string; + freq?: number; + bw?: number; + sf?: number; + tx_power?: number; + }; + contact_count: number; + message_count: number; +} + +export default function MeshCoreStatus() { + const [status, setStatus] = useState(null); + + useEffect(() => { + const fetchStatus = async () => { + try { + const res = await fetch("/rmesh/api/meshcore/status"); + setStatus(await res.json()); + } catch { + setStatus(null); + } + }; + + fetchStatus(); + const interval = setInterval(fetchStatus, 10000); + return () => clearInterval(interval); + }, []); + + const connected = status?.connected ?? false; + const info = status?.device_info ?? {}; + + return ( +
+
+
+ {connected ? ( + + ) : ( + + )} +
+
+

MeshCore LoRa Mesh

+

+ {connected ? `Connected: ${info.name || "Companion"}` : "No companion device connected"} +

+
+
+
+ + {connected && info.freq ? ( +
+
+ + Freq: + {(info.freq / 1000000).toFixed(1)} MHz +
+
+ SF{info.sf} / BW{info.bw ? (info.bw / 1000) + "kHz" : "?"} +
+
+ + Contacts: + {status?.contact_count ?? 0} +
+
+ + Messages: + {status?.message_count ?? 0} +
+
+ ) : !connected ? ( +

+ Connect a MeshCore Companion node via WiFi (TCP) or USB serial to enable LoRa mesh. + Set MESHCORE_ENABLED=true and MESHCORE_HOST in the container config. +

+ ) : null} +
+ ); +} diff --git a/src/components/mesh/MessageComposer.tsx b/src/components/mesh/MessageComposer.tsx new file mode 100644 index 0000000..2290c08 --- /dev/null +++ b/src/components/mesh/MessageComposer.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { useState } from "react"; +import { Send } from "lucide-react"; + +export default function MessageComposer({ onSent }: { onSent?: () => void }) { + const [destinationHash, setDestinationHash] = useState(""); + const [title, setTitle] = useState(""); + const [content, setContent] = useState(""); + const [sending, setSending] = useState(false); + const [feedback, setFeedback] = useState<{ type: "success" | "error"; text: string } | null>(null); + + const handleSend = async (e: React.FormEvent) => { + e.preventDefault(); + if (!destinationHash.trim() || !content.trim()) return; + + setSending(true); + setFeedback(null); + + try { + const res = await fetch("/rmesh/api/mesh/messages", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + destination_hash: destinationHash.trim(), + content: content.trim(), + title: title.trim(), + }), + }); + + if (res.ok) { + setFeedback({ type: "success", text: "Message queued for delivery" }); + setDestinationHash(""); + setTitle(""); + setContent(""); + onSent?.(); + } else { + const data = await res.json(); + setFeedback({ type: "error", text: data.error || "Failed to send" }); + } + } catch { + setFeedback({ type: "error", text: "Bridge unreachable" }); + } finally { + setSending(false); + } + }; + + return ( +
+

Send LXMF Message

+ +
+ + setDestinationHash(e.target.value)} + placeholder="Reticulum destination hash (hex)" + className="w-full rounded-lg border bg-background px-3 py-2 text-sm font-mono placeholder:text-muted-foreground/50" + required + /> +
+ +
+ + setTitle(e.target.value)} + placeholder="Message title" + className="w-full rounded-lg border bg-background px-3 py-2 text-sm placeholder:text-muted-foreground/50" + /> +
+ +
+ +