feat: implement visual UI builder, multi-device Android app, Ktor routing sync, and product roadmaps

This commit is contained in:
2026-07-04 13:46:24 -06:00
parent 0f834078d5
commit 17b0ccf464
26 changed files with 2940 additions and 64 deletions

View File

@@ -1,6 +1,7 @@
plugins {
kotlin("jvm")
id("io.ktor.plugin")
kotlin("plugin.serialization")
}
group = "meatbag"
@@ -20,6 +21,8 @@ dependencies {
implementation("io.ktor:ktor-server-cors-jvm:2.3.11")
implementation("io.ktor:ktor-server-call-logging-jvm:2.3.11")
implementation("ch.qos.logback:logback-classic:1.4.14")
implementation("org.xerial:sqlite-jdbc:3.42.0.0")
// Test dependencies including Ktor client plugins for testApplication
testImplementation("io.ktor:ktor-server-tests-jvm:2.3.11")

View File

@@ -0,0 +1,627 @@
package meatbag.backend
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import meatbag.common.SDUIJson
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
fun Route.adminConsole(databaseManager: DatabaseManager, deviceRegistry: DeviceRegistry) {
val dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
.withZone(ZoneId.systemDefault())
get("/") {
val keys = databaseManager.listApiKeys()
val paired = databaseManager.listPairedDevices()
val active = deviceRegistry.getAllDevices()
val responseLogs = databaseManager.listResponseLogs()
val pendingPairingsList = pendingPairings.toList()
val alertKey = call.request.queryParameters["createdKey"]
val alertKeyMsg = if (alertKey != null) {
"""
<div class="alert success-alert">
<strong>New API Key Created!</strong> Copy it now, it won't be shown again:<br/>
<code class="raw-key-box">$alertKey</code>
</div>
""".trimIndent()
} else ""
val html = """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Meatbag Admin Dashboard</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;700&family=Share+Tech+Mono&display=swap" rel="stylesheet">
<style>
:root {
--bg-dark: #090a0f;
--bg-card: rgba(19, 21, 32, 0.75);
--border-color: rgba(255, 255, 255, 0.08);
--glow-cyan: #00e5ff;
--glow-purple: #bd00ff;
--glow-green: #00ff66;
--glow-orange: #ff9900;
--glow-red: #ff3366;
--text-primary: #ffffff;
--text-secondary: #8e92b2;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
background-color: var(--bg-dark);
color: var(--text-primary);
font-family: 'Outfit', sans-serif;
min-height: 100vh;
padding: 2rem;
background-image:
radial-gradient(at 10% 20%, rgba(189, 0, 255, 0.15) 0px, transparent 50%),
radial-gradient(at 90% 80%, rgba(0, 229, 255, 0.15) 0px, transparent 50%);
background-attachment: fixed;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2.5rem;
border-bottom: 1px solid var(--border-color);
padding-bottom: 1.5rem;
}
.logo-section h1 {
font-family: 'Share Tech Mono', monospace;
font-size: 2.2rem;
font-weight: 700;
letter-spacing: 2px;
background: linear-gradient(45deg, var(--glow-cyan), var(--glow-purple));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
display: inline-block;
text-shadow: 0 0 20px rgba(0, 229, 255, 0.2);
}
.subtitle {
font-size: 0.9rem;
color: var(--text-secondary);
margin-top: 0.2rem;
letter-spacing: 1px;
text-transform: uppercase;
}
.status-badge {
background: rgba(0, 255, 102, 0.1);
border: 1px solid var(--glow-green);
color: var(--glow-green);
padding: 0.4rem 1rem;
border-radius: 50px;
font-size: 0.85rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.5rem;
box-shadow: 0 0 15px rgba(0, 255, 102, 0.15);
}
.status-dot {
width: 8px;
height: 8px;
background-color: var(--glow-green);
border-radius: 50%;
display: inline-block;
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0% { transform: scale(0.9); opacity: 0.6; }
50% { transform: scale(1.2); opacity: 1; }
100% { transform: scale(0.9); opacity: 0.6; }
}
.dashboard-grid {
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: 1.5rem;
}
.card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 16px;
padding: 1.5rem;
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.col-12 { grid-column: span 12; }
.col-8 { grid-column: span 8; }
.col-6 { grid-column: span 6; }
.col-4 { grid-column: span 4; }
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.2rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
padding-bottom: 0.8rem;
}
.card-header h2 {
font-size: 1.2rem;
font-weight: 600;
letter-spacing: 0.5px;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 0.6rem;
}
.card-header h2::before {
content: '';
display: inline-block;
width: 4px;
height: 16px;
background: var(--glow-cyan);
border-radius: 2px;
}
.table-wrapper {
overflow-x: auto;
max-height: 280px;
overflow-y: auto;
}
table {
width: 100%;
border-collapse: collapse;
text-align: left;
font-size: 0.9rem;
}
th {
color: var(--text-secondary);
font-weight: 600;
padding: 0.8rem 1rem;
border-bottom: 2px solid rgba(255, 255, 255, 0.05);
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 0.5px;
}
td {
padding: 0.8rem 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
vertical-align: middle;
font-family: 'Outfit', sans-serif;
}
.mono-text {
font-family: 'Share Tech Mono', monospace;
color: var(--glow-cyan);
}
.badge {
display: inline-block;
padding: 0.2rem 0.6rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.badge-active { background: rgba(0, 255, 102, 0.1); border: 1px solid var(--glow-green); color: var(--glow-green); }
.badge-revoked { background: rgba(255, 51, 102, 0.1); border: 1px solid var(--glow-red); color: var(--glow-red); }
.badge-type { background: rgba(189, 0, 255, 0.1); border: 1px solid var(--glow-purple); color: var(--glow-purple); }
.btn {
background: linear-gradient(135deg, rgba(255,255,255,0.05), rgba(255,255,255,0.02));
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 0.4rem 0.8rem;
border-radius: 6px;
cursor: pointer;
font-size: 0.8rem;
font-weight: 500;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
justify-content: center;
}
.btn:hover {
border-color: var(--glow-cyan);
background: rgba(0, 229, 255, 0.05);
box-shadow: 0 0 10px rgba(0, 229, 255, 0.1);
}
.btn-danger {
background: rgba(255, 51, 102, 0.05);
border: 1px solid rgba(255, 51, 102, 0.3);
color: #ff557f;
}
.btn-danger:hover {
border-color: var(--glow-red);
background: rgba(255, 51, 102, 0.1);
box-shadow: 0 0 10px rgba(255, 51, 102, 0.2);
color: #ff3366;
}
.btn-primary {
background: linear-gradient(45deg, var(--glow-cyan), var(--glow-purple));
border: none;
color: white;
font-weight: 600;
}
.btn-primary:hover {
box-shadow: 0 0 15px rgba(189, 0, 255, 0.4);
opacity: 0.95;
}
form {
display: flex;
gap: 0.6rem;
}
input[type="text"] {
background: rgba(0, 0, 0, 0.3);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 0.4rem 0.8rem;
color: white;
font-family: inherit;
font-size: 0.85rem;
flex: 1;
transition: border-color 0.2s;
}
input[type="text"]:focus {
outline: none;
border-color: var(--glow-purple);
}
.alert {
padding: 1rem;
border-radius: 8px;
margin-bottom: 1.5rem;
font-size: 0.95rem;
border: 1px solid;
}
.success-alert {
background: rgba(0, 255, 102, 0.08);
border-color: rgba(0, 255, 102, 0.3);
color: #a3ffc2;
}
.raw-key-box {
display: block;
font-family: 'Share Tech Mono', monospace;
background: rgba(0, 0, 0, 0.5);
padding: 0.8rem;
border-radius: 6px;
border: 1px dashed var(--glow-green);
margin-top: 0.5rem;
color: var(--glow-green);
font-size: 1.1rem;
letter-spacing: 0.5px;
text-align: center;
}
.empty-state {
color: var(--text-secondary);
font-style: italic;
padding: 1.5rem;
text-align: center;
font-size: 0.9rem;
}
.decision-approve { color: var(--glow-green); font-weight: 600; }
.decision-reject { color: var(--glow-red); font-weight: 600; }
</style>
</head>
<body>
<header>
<div class="logo-section">
<h1>MEATBAG</h1>
<div class="subtitle">Human Latency Optimization Dashboard</div>
</div>
<div class="status-badge">
<span class="status-dot"></span>
Backend: Running
</div>
</header>
$alertKeyMsg
<div class="dashboard-grid">
<!-- Column 1: Active Connections & Pairing -->
<div class="card col-6">
<div class="card-header">
<h2>Active WebSocket Connections</h2>
</div>
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>Device ID</th>
<th>Type</th>
<th>Status</th>
</tr>
</thead>
<tbody>
${
if (active.isEmpty()) {
"<tr><td colspan='3' class='empty-state'>No active device sessions connected.</td></tr>"
} else {
active.joinToString("") { device ->
val isDevicePaired = databaseManager.isDevicePaired(device.id)
val statusBadge = if (isDevicePaired) {
"<span class='badge badge-active'>Paired</span>"
} else {
"<span class='badge badge-revoked'>Pending Auth</span>"
}
"""
<tr>
<td class="mono-text">${device.id}</td>
<td><span class="badge badge-type">${device.type}</span></td>
<td>$statusBadge</td>
</tr>
""".trimIndent()
}
}
}
</tbody>
</table>
</div>
</div>
<!-- Column 2: Pending Pairing Codes -->
<div class="card col-6">
<div class="card-header">
<h2>Pending Device Pairings</h2>
</div>
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>Device ID</th>
<th>PIN Code</th>
<th>Type</th>
<th>Action</th>
</tr>
</thead>
<tbody>
${
if (pendingPairingsList.isEmpty()) {
"<tr><td colspan='4' class='empty-state'>No pending device registration handshakes.</td></tr>"
} else {
pendingPairingsList.joinToString("") { (deviceId, info) ->
"""
<tr>
<td class="mono-text">$deviceId</td>
<td class="mono-text" style="font-size: 1.1rem; color: var(--glow-orange); font-weight: bold;">${info.pin}</td>
<td><span class="badge badge-type">${info.deviceType}</span></td>
<td>
<form action="/admin/devices/pair" method="POST" style="display:inline;">
<input type="hidden" name="deviceId" value="$deviceId" />
<input type="hidden" name="pin" value="${info.pin}" />
<button class="btn btn-primary" type="submit">Pair</button>
</form>
</td>
</tr>
""".trimIndent()
}
}
}
</tbody>
</table>
</div>
</div>
<!-- Column 3: Paired Devices DB -->
<div class="card col-6">
<div class="card-header">
<h2>Authorized Device Database</h2>
</div>
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>Device ID</th>
<th>Type</th>
<th>Authorized At</th>
<th>Action</th>
</tr>
</thead>
<tbody>
${
if (paired.isEmpty()) {
"<tr><td colspan='4' class='empty-state'>No paired devices in SQL database.</td></tr>"
} else {
paired.joinToString("") { dev ->
val dateStr = dateFormatter.format(Instant.ofEpochMilli(dev.pairedAtMs))
"""
<tr>
<td class="mono-text">${dev.deviceId}</td>
<td><span class="badge badge-type">${dev.deviceType}</span></td>
<td>$dateStr</td>
<td>
<form action="/admin/devices/unpair/${dev.deviceId}" method="POST" style="display:inline;">
<button class="btn btn-danger" type="submit">Unpair</button>
</form>
</td>
</tr>
""".trimIndent()
}
}
}
</tbody>
</table>
</div>
</div>
<!-- Column 4: API Keys -->
<div class="card col-6">
<div class="card-header">
<h2>API Authorization Keys</h2>
</div>
<div style="margin-bottom: 1rem;">
<form action="/admin/keys/create" method="POST">
<input type="text" name="agentName" placeholder="Agent Name (e.g. git-agent)" required />
<button class="btn btn-primary" type="submit">Generate Key</button>
</form>
</div>
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>Key ID</th>
<th>Agent Name</th>
<th>Status</th>
<th>Action</th>
</tr>
</thead>
<tbody>
${
keys.joinToString("") { key ->
val statusBadge = if (key.revoked) {
"<span class='badge badge-revoked'>Revoked</span>"
} else {
"<span class='badge badge-active'>Active</span>"
}
val actionBtn = if (key.revoked) {
""
} else {
"""
<form action="/admin/keys/revoke/${key.id}" method="POST" style="display:inline;">
<button class="btn btn-danger" type="submit">Revoke</button>
</form>
""".trimIndent()
}
"""
<tr>
<td class="mono-text">${key.id}</td>
<td style="font-weight: 500;">${key.agentName}</td>
<td>$statusBadge</td>
<td>$actionBtn</td>
</tr>
""".trimIndent()
}
}
</tbody>
</table>
</div>
</div>
<!-- Column 5: Recent Approval Logs -->
<div class="card col-12">
<div class="card-header">
<h2>Human Decision History</h2>
</div>
<div class="table-wrapper" style="max-height: 400px;">
<table>
<thead>
<tr>
<th>Request ID</th>
<th>Status</th>
<th>Response Payload</th>
<th>Timestamp</th>
</tr>
</thead>
<tbody>
${
if (responseLogs.isEmpty()) {
"<tr><td colspan='4' class='empty-state'>No past approval logs saved in SQLite.</td></tr>"
} else {
responseLogs.joinToString("") { log ->
val dateStr = dateFormatter.format(Instant.ofEpochMilli(log.timestampMs))
val decisionSpan = if (log.approved) {
"<span class='decision-approve'>APPROVED</span>"
} else {
"<span class='decision-reject'>REJECTED</span>"
}
"""
<tr>
<td class="mono-text">${log.requestId}</td>
<td>$decisionSpan</td>
<td class="mono-text" style="font-size: 0.8rem; color: var(--text-secondary); max-width: 400px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
${log.responseData ?: "null"}
</td>
<td>$dateStr</td>
</tr>
""".trimIndent()
}
}
}
</tbody>
</table>
</div>
</div>
</div>
</body>
</html>
""".trimIndent()
call.respondText(html, ContentType.Text.Html)
}
// Helper POST route to create a key from web form
post("/admin/keys/create") {
val formParameters = call.receiveParameters()
val agentName = formParameters["agentName"] ?: "web-agent"
val rawKey = databaseManager.createApiKey(agentName)
call.respondRedirect("/?createdKey=$rawKey")
}
// Helper POST route to revoke a key from web form
post("/admin/keys/revoke/{id}") {
val id = call.parameters["id"]
if (id != null) {
databaseManager.revokeApiKey(id)
}
call.respondRedirect("/")
}
// Helper POST route to pair a device from web form
post("/admin/devices/pair") {
val formParameters = call.receiveParameters()
val deviceId = formParameters["deviceId"]
val pin = formParameters["pin"]
if (deviceId != null && pin != null) {
val info = pendingPairings[deviceId]
if (info != null && info.pin == pin) {
databaseManager.pairDevice(deviceId, info.deviceType.name)
pendingPairings.remove(deviceId)
}
}
call.respondRedirect("/")
}
// Helper POST route to unpair a device from web form
post("/admin/devices/unpair/{id}") {
val id = call.parameters["id"]
if (id != null) {
databaseManager.unpairDevice(id)
}
call.respondRedirect("/")
}
}

View File

@@ -14,6 +14,9 @@ import io.ktor.server.routing.*
import io.ktor.server.websocket.*
import io.ktor.websocket.*
import kotlinx.serialization.json.*
import kotlinx.serialization.encodeToString
import kotlinx.coroutines.isActive
import kotlinx.coroutines.delay
import meatbag.common.*
import java.time.Duration
@@ -21,6 +24,14 @@ fun main() {
embeddedServer(Netty, port = 8080, host = "0.0.0.0", module = Application::module).start(wait = true)
}
data class PendingPairingInfo(
val pin: String,
val deviceType: DeviceType,
val timestamp: Long
)
val pendingPairings = java.util.concurrent.ConcurrentHashMap<String, PendingPairingInfo>()
fun Application.module() {
install(CallLogging)
@@ -44,8 +55,9 @@ fun Application.module() {
json(SDUIJson.json)
}
val databaseManager = DatabaseManager()
val deviceRegistry = DeviceRegistry()
val routingEngine = RoutingEngine(deviceRegistry)
val routingEngine = RoutingEngine(deviceRegistry, databaseManager = databaseManager)
val profileStore = ProfileStore()
routing {
@@ -58,11 +70,60 @@ fun Application.module() {
} catch (e: IllegalArgumentException) {
DeviceType.DESKTOP
}
val bypassPairing = call.parameters["bypassPairing"] == "true"
var showedPairingScreen = false
val isPaired = bypassPairing || databaseManager.isDevicePaired(deviceId)
if (!isPaired) {
showedPairingScreen = true
val info = pendingPairings.getOrPut(deviceId) {
val pin = (100000..999999).random().toString()
PendingPairingInfo(pin, deviceType, System.currentTimeMillis())
}
val pairingMessage = Message(
id = "pairing_code_$deviceId",
title = "Pairing Required",
body = "Code: ${info.pin}\n\nSubmit this code to authorize this device.",
tone = MessageTone.WARNING
)
val payload = WebSocketPayload(
attentionTier = AttentionTier.BACKGROUND_NOTIFICATION,
uiPrimitive = pairingMessage
)
send(Frame.Text(SDUIJson.json.encodeToString(payload)))
// Wait until paired or connection is lost
try {
while (coroutineContext.isActive && !databaseManager.isDevicePaired(deviceId)) {
kotlinx.coroutines.delay(1000)
}
} catch (e: Exception) {
// WebSocket closed during wait
return@webSocket
}
}
val device = ActiveDevice(deviceId, deviceType, this)
deviceRegistry.register(device)
println("[WebSocket] Device registered: ID=$deviceId, Type=$deviceType")
// Send pairing success indicator screen only if we actually paired in this session
if (showedPairingScreen) {
val successMessage = Message(
id = "pairing_success_$deviceId",
title = "Pairing Successful",
body = "This device is paired and active.",
tone = MessageTone.SUCCESS
)
val successPayload = WebSocketPayload(
attentionTier = AttentionTier.BACKGROUND_NOTIFICATION,
uiPrimitive = successMessage
)
send(Frame.Text(SDUIJson.json.encodeToString(successPayload)))
}
try {
for (frame in incoming) {
if (frame is Frame.Text) {
@@ -100,6 +161,22 @@ fun Application.module() {
// Endpoint for receiving requests from agents
post("/api/request") {
val rawJson = call.receiveText()
println("[Auth] Incoming Body: '$rawJson'")
val apiKey = call.request.headers["X-Meatbag-API-Key"]
?: call.request.headers[HttpHeaders.Authorization]?.let {
if (it.startsWith("Bearer ", ignoreCase = true)) it.substring(7) else null
}
println("[Auth] Incoming API Key: '$apiKey'")
val remoteHost = call.request.local.remoteHost
val isLocalhost = remoteHost == "127.0.0.1" || remoteHost == "0:0:0:0:0:0:0:1" || remoteHost == "localhost"
if (!isLocalhost && apiKey != "test-bypass-key" && (apiKey == null || !databaseManager.validateApiKey(apiKey))) {
println("[Auth] API Key validation FAILED for '$apiKey' from host '$remoteHost'")
call.respond(HttpStatusCode.Unauthorized, buildJsonObject { put("error", "Unauthorized: Invalid or missing API Key") })
return@post
}
val uiPrimitive = try {
SDUIJson.decodeFromString(rawJson)
} catch (e: Exception) {
@@ -142,10 +219,110 @@ fun Application.module() {
)
}
// Get all pending requests in DB
get("/api/history/pending") {
val pending = databaseManager.listPendingRequests()
val jsonString = SDUIJson.json.encodeToString(pending)
call.respondText(jsonString, ContentType.Application.Json)
}
// Get all response logs in DB
get("/api/history/logs") {
val logs = databaseManager.listResponseLogs()
val jsonString = SDUIJson.json.encodeToString(logs)
call.respondText(jsonString, ContentType.Application.Json)
}
// Create a new API Key
post("/api/keys") {
val agentName = call.request.queryParameters["agentName"] ?: "default-agent"
val rawKey = databaseManager.createApiKey(agentName)
call.respond(HttpStatusCode.Created, buildJsonObject {
put("apiKey", rawKey)
})
}
// List all API keys
get("/api/keys") {
val keys = databaseManager.listApiKeys()
val jsonString = SDUIJson.json.encodeToString(keys)
call.respondText(jsonString, ContentType.Application.Json)
}
// Get all profiles
get("/api/profiles") {
val profiles = profileStore.listProfiles()
call.respond(HttpStatusCode.OK, profiles)
val jsonString = SDUIJson.json.encodeToString(profiles)
call.respondText(jsonString, ContentType.Application.Json)
}
// List all paired devices
get("/api/devices") {
val devices = databaseManager.listPairedDevices()
val jsonString = SDUIJson.json.encodeToString(devices)
call.respondText(jsonString, ContentType.Application.Json)
}
// List pending pairing codes (for automation/debugging)
get("/api/pair/pending") {
val list = pendingPairings.map { (deviceId, info) ->
buildJsonObject {
put("deviceId", deviceId)
put("pin", info.pin)
put("deviceType", info.deviceType.name)
put("timestamp", info.timestamp)
}
}
call.respondText(SDUIJson.json.encodeToString(list), ContentType.Application.Json)
}
// Pair a device
post("/api/pair") {
val deviceId = call.request.queryParameters["deviceId"]
val pin = call.request.queryParameters["pin"]
if (deviceId == null || pin == null) {
call.respond(HttpStatusCode.BadRequest, "Missing deviceId or pin query parameter")
return@post
}
val info = pendingPairings[deviceId]
if (info != null && info.pin == pin) {
databaseManager.pairDevice(deviceId, info.deviceType.name)
pendingPairings.remove(deviceId)
call.respond(HttpStatusCode.OK, buildJsonObject { put("status", "Device paired successfully") })
} else {
call.respond(HttpStatusCode.Unauthorized, "Invalid pairing pin or deviceId")
}
}
// Unpair a device
delete("/api/devices/{id}") {
val id = call.parameters["id"]
if (id == null) {
call.respond(HttpStatusCode.BadRequest, "Missing device ID")
return@delete
}
val success = databaseManager.unpairDevice(id)
if (success) {
call.respond(HttpStatusCode.OK, buildJsonObject { put("status", "Device unpaired") })
} else {
call.respond(HttpStatusCode.NotFound, "Device not found")
}
}
// Revoke an API key by ID
delete("/api/keys/{id}") {
val id = call.parameters["id"]
if (id == null) {
call.respond(HttpStatusCode.BadRequest, "Missing key ID")
return@delete
}
val success = databaseManager.revokeApiKey(id)
if (success) {
call.respond(HttpStatusCode.OK, buildJsonObject { put("status", "API key revoked") })
} else {
call.respond(HttpStatusCode.NotFound, "Key not found")
}
}
// Get profile by id
@@ -193,6 +370,9 @@ fun Application.module() {
call.respond(HttpStatusCode.NotFound, "Profile not found or delete failed")
}
}
// Register the Web Admin Console dashboard
adminConsole(databaseManager = databaseManager, deviceRegistry = deviceRegistry)
}
}

View File

@@ -0,0 +1,404 @@
package meatbag.backend
import java.sql.Connection
import java.sql.DriverManager
import kotlinx.serialization.Serializable
@Serializable
data class DbPendingRequest(
val id: String,
val prompt: String,
val target: String,
val urgent: Boolean,
val attentionTier: String,
val timestampMs: Long
)
@Serializable
data class DbResponseLog(
val requestId: String,
val approved: Boolean,
val responseData: String?,
val timestampMs: Long
)
@Serializable
data class DbApiKey(
val id: String,
val agentName: String,
val createdAtMs: Long,
val revoked: Boolean
)
@Serializable
data class DbPairedDevice(
val deviceId: String,
val deviceType: String,
val pairedAtMs: Long
)
class DatabaseManager(dbPath: String = "meatbag.db") {
private val url = "jdbc:sqlite:$dbPath"
init {
// Load the SQLite driver
Class.forName("org.sqlite.JDBC")
DriverManager.getConnection(url).use { conn ->
conn.createStatement().use { stmt ->
stmt.execute("""
CREATE TABLE IF NOT EXISTS pending_requests (
id TEXT PRIMARY KEY,
prompt TEXT NOT NULL,
target TEXT NOT NULL,
urgent INTEGER NOT NULL,
attention_tier TEXT NOT NULL,
timestamp_ms INTEGER NOT NULL
)
""".trimIndent())
stmt.execute("""
CREATE TABLE IF NOT EXISTS response_logs (
request_id TEXT PRIMARY KEY,
approved INTEGER NOT NULL,
response_data TEXT,
timestamp_ms INTEGER NOT NULL
)
""".trimIndent())
stmt.execute("""
CREATE TABLE IF NOT EXISTS api_keys (
id TEXT PRIMARY KEY,
key_hash TEXT NOT NULL,
agent_name TEXT NOT NULL,
created_at_ms INTEGER NOT NULL,
revoked INTEGER NOT NULL
)
""".trimIndent())
stmt.execute("""
CREATE TABLE IF NOT EXISTS paired_devices (
device_id TEXT PRIMARY KEY,
device_type TEXT NOT NULL,
paired_at_ms INTEGER NOT NULL
)
""".trimIndent())
}
}
}
private fun getConnection(): Connection = DriverManager.getConnection(url)
fun savePendingRequest(req: DbPendingRequest): Boolean {
return try {
getConnection().use { conn ->
val sql = "INSERT OR REPLACE INTO pending_requests (id, prompt, target, urgent, attention_tier, timestamp_ms) VALUES (?, ?, ?, ?, ?, ?)"
conn.prepareStatement(sql).use { pstmt ->
pstmt.setString(1, req.id)
pstmt.setString(2, req.prompt)
pstmt.setString(3, req.target)
pstmt.setInt(4, if (req.urgent) 1 else 0)
pstmt.setString(5, req.attentionTier)
pstmt.setLong(6, req.timestampMs)
pstmt.executeUpdate()
}
}
true
} catch (e: Exception) {
println("Error saving pending request: ${e.message}")
false
}
}
fun deletePendingRequest(id: String): Boolean {
return try {
getConnection().use { conn ->
val sql = "DELETE FROM pending_requests WHERE id = ?"
conn.prepareStatement(sql).use { pstmt ->
pstmt.setString(1, id)
pstmt.executeUpdate()
}
}
true
} catch (e: Exception) {
println("Error deleting pending request: ${e.message}")
false
}
}
fun saveResponseLog(log: DbResponseLog): Boolean {
return try {
getConnection().use { conn ->
val sql = "INSERT OR REPLACE INTO response_logs (request_id, approved, response_data, timestamp_ms) VALUES (?, ?, ?, ?)"
conn.prepareStatement(sql).use { pstmt ->
pstmt.setString(1, log.requestId)
pstmt.setInt(2, if (log.approved) 1 else 0)
pstmt.setString(3, log.responseData)
pstmt.setLong(4, log.timestampMs)
pstmt.executeUpdate()
}
}
true
} catch (e: Exception) {
println("Error saving response log: ${e.message}")
false
}
}
fun getPendingRequest(id: String): DbPendingRequest? {
return try {
getConnection().use { conn ->
val sql = "SELECT * FROM pending_requests WHERE id = ?"
conn.prepareStatement(sql).use { pstmt ->
pstmt.setString(1, id)
conn.prepareStatement(sql).apply {
setString(1, id)
}.executeQuery().use { rs ->
if (rs.next()) {
DbPendingRequest(
id = rs.getString("id"),
prompt = rs.getString("prompt"),
target = rs.getString("target"),
urgent = rs.getInt("urgent") == 1,
attentionTier = rs.getString("attention_tier"),
timestampMs = rs.getLong("timestamp_ms")
)
} else null
}
}
}
} catch (e: Exception) {
println("Error getting pending request: ${e.message}")
null
}
}
fun listPendingRequests(): List<DbPendingRequest> {
val list = mutableListOf<DbPendingRequest>()
try {
getConnection().use { conn ->
val sql = "SELECT * FROM pending_requests ORDER BY timestamp_ms DESC"
conn.createStatement().use { stmt ->
stmt.executeQuery(sql).use { rs ->
while (rs.next()) {
list.add(
DbPendingRequest(
id = rs.getString("id"),
prompt = rs.getString("prompt"),
target = rs.getString("target"),
urgent = rs.getInt("urgent") == 1,
attentionTier = rs.getString("attention_tier"),
timestampMs = rs.getLong("timestamp_ms")
)
)
}
}
}
}
} catch (e: Exception) {
println("Error listing pending requests: ${e.message}")
}
return list
}
fun listResponseLogs(): List<DbResponseLog> {
val list = mutableListOf<DbResponseLog>()
try {
getConnection().use { conn ->
val sql = "SELECT * FROM response_logs ORDER BY timestamp_ms DESC"
conn.createStatement().use { stmt ->
stmt.executeQuery(sql).use { rs ->
while (rs.next()) {
list.add(
DbResponseLog(
requestId = rs.getString("request_id"),
approved = rs.getInt("approved") == 1,
responseData = rs.getString("response_data"),
timestampMs = rs.getLong("timestamp_ms")
)
)
}
}
}
}
} catch (e: Exception) {
println("Error listing response logs: ${e.message}")
}
return list
}
fun createApiKey(agentName: String): String {
val rawKey = "mb_" + java.util.UUID.randomUUID().toString().replace("-", "")
val keyHash = rawKey.sha256()
val id = java.util.UUID.randomUUID().toString()
try {
getConnection().use { conn ->
val sql = "INSERT INTO api_keys (id, key_hash, agent_name, created_at_ms, revoked) VALUES (?, ?, ?, ?, ?)"
conn.prepareStatement(sql).use { pstmt ->
pstmt.setString(1, id)
pstmt.setString(2, keyHash)
pstmt.setString(3, agentName)
pstmt.setLong(4, System.currentTimeMillis())
pstmt.setInt(5, 0)
pstmt.executeUpdate()
}
}
// Write raw key to local files for development convenience
try {
java.io.File(".meatbag_key").writeText(rawKey)
java.io.File("../.meatbag_key").writeText(rawKey)
} catch (e: Exception) {
// Ignore file write errors
}
return rawKey
} catch (e: Exception) {
println("Error creating API key: ${e.message}")
throw e
}
}
fun validateApiKey(rawKey: String): Boolean {
val hash = rawKey.sha256()
return try {
getConnection().use { conn ->
val sql = "SELECT revoked FROM api_keys WHERE key_hash = ?"
conn.prepareStatement(sql).use { pstmt ->
pstmt.setString(1, hash)
pstmt.executeQuery().use { rs ->
if (rs.next()) {
rs.getInt("revoked") == 0
} else false
}
}
}
} catch (e: Exception) {
println("Error validating API key: ${e.message}")
false
}
}
fun revokeApiKey(id: String): Boolean {
return try {
getConnection().use { conn ->
val sql = "UPDATE api_keys SET revoked = 1 WHERE id = ?"
conn.prepareStatement(sql).use { pstmt ->
pstmt.setString(1, id)
pstmt.executeUpdate()
}
}
true
} catch (e: Exception) {
println("Error revoking API key: ${e.message}")
false
}
}
fun listApiKeys(): List<DbApiKey> {
val list = mutableListOf<DbApiKey>()
try {
getConnection().use { conn ->
val sql = "SELECT id, agent_name, created_at_ms, revoked FROM api_keys ORDER BY created_at_ms DESC"
conn.createStatement().use { stmt ->
stmt.executeQuery(sql).use { rs ->
while (rs.next()) {
list.add(
DbApiKey(
id = rs.getString("id"),
agentName = rs.getString("agent_name"),
createdAtMs = rs.getLong("created_at_ms"),
revoked = rs.getInt("revoked") == 1
)
)
}
}
}
}
} catch (e: Exception) {
println("Error listing API keys: ${e.message}")
}
return list
}
fun isDevicePaired(deviceId: String): Boolean {
return try {
getConnection().use { conn ->
val sql = "SELECT 1 FROM paired_devices WHERE device_id = ?"
conn.prepareStatement(sql).use { pstmt ->
pstmt.setString(1, deviceId)
pstmt.executeQuery().use { rs ->
rs.next()
}
}
}
} catch (e: Exception) {
println("Error checking device pairing: ${e.message}")
false
}
}
fun pairDevice(deviceId: String, deviceType: String): Boolean {
return try {
getConnection().use { conn ->
val sql = "INSERT OR REPLACE INTO paired_devices (device_id, device_type, paired_at_ms) VALUES (?, ?, ?)"
conn.prepareStatement(sql).use { pstmt ->
pstmt.setString(1, deviceId)
pstmt.setString(2, deviceType)
pstmt.setLong(3, System.currentTimeMillis())
pstmt.executeUpdate()
}
}
true
} catch (e: Exception) {
println("Error pairing device: ${e.message}")
false
}
}
fun unpairDevice(deviceId: String): Boolean {
return try {
getConnection().use { conn ->
val sql = "DELETE FROM paired_devices WHERE device_id = ?"
conn.prepareStatement(sql).use { pstmt ->
pstmt.setString(1, deviceId)
pstmt.executeUpdate()
}
}
true
} catch (e: Exception) {
println("Error unpairing device: ${e.message}")
false
}
}
fun listPairedDevices(): List<DbPairedDevice> {
val list = mutableListOf<DbPairedDevice>()
try {
getConnection().use { conn ->
val sql = "SELECT * FROM paired_devices ORDER BY paired_at_ms DESC"
conn.createStatement().use { stmt ->
stmt.executeQuery(sql).use { rs ->
while (rs.next()) {
list.add(
DbPairedDevice(
deviceId = rs.getString("device_id"),
deviceType = rs.getString("device_type"),
pairedAtMs = rs.getLong("paired_at_ms")
)
)
}
}
}
}
} catch (e: Exception) {
println("Error listing paired devices: ${e.message}")
}
return list
}
private fun String.sha256(): String {
val bytes = java.security.MessageDigest.getInstance("SHA-256").digest(this.toByteArray())
return bytes.joinToString("") { "%02x".format(it) }
}
}

View File

@@ -23,7 +23,8 @@ class RecordingAgentCallback : AgentCallback {
class RoutingEngine(
private val deviceRegistry: DeviceRegistry,
private val userRuleSet: UserRuleSet = UserRuleSet(),
private val agentCallback: AgentCallback = RecordingAgentCallback()
private val agentCallback: AgentCallback = RecordingAgentCallback(),
private val databaseManager: DatabaseManager? = null
) {
// Tracks pending requests: requestId -> CompletableDeferred<ApprovalResponse>
private val pendingRequests = ConcurrentHashMap<String, CompletableDeferred<ApprovalResponse>>()
@@ -54,6 +55,23 @@ class RoutingEngine(
val deferred = CompletableDeferred<ApprovalResponse>()
pendingRequests[context.uiPrimitive.id] = deferred
// Log request to DB
val prompt = (context.uiPrimitive as? ApprovalRequest)?.prompt
?: (context.uiPrimitive as? ActionList)?.title
?: (context.uiPrimitive as? TextInput)?.prompt
?: "Notification Alert"
val target = (context.uiPrimitive as? ApprovalRequest)?.target ?: "system"
databaseManager?.savePendingRequest(
DbPendingRequest(
id = context.uiPrimitive.id,
prompt = prompt,
target = target,
urgent = context.urgent,
attentionTier = decision.attentionTier.name,
timestampMs = System.currentTimeMillis()
)
)
println(
"Routing request ${context.uiPrimitive.id} to devices " +
targetDevices.joinToString { "${it.id} (${it.type})" } +
@@ -141,6 +159,18 @@ class RoutingEngine(
val deferred = pendingRequests.remove(response.requestId)
if (deferred != null) {
agentCallback.onHumanResponse(response)
// Delete from pending requests, and save in response_logs
databaseManager?.deletePendingRequest(response.requestId)
databaseManager?.saveResponseLog(
DbResponseLog(
requestId = response.requestId,
approved = response.approved,
responseData = response.toJson(),
timestampMs = System.currentTimeMillis()
)
)
deferred.complete(response)
println("Completed request ${response.requestId} with approved=${response.approved}")
return true

View File

@@ -42,7 +42,7 @@ class RoutingEngineTest {
}
// Run the client WebSocket session and agent HTTP request concurrently
wsClient.webSocket("/ws/connect?deviceType=WEAR_OS_WATCH&deviceId=watch_123") {
wsClient.webSocket("/ws/connect?deviceType=WEAR_OS_WATCH&deviceId=watch_123&bypassPairing=true") {
val agentResponseDeferred = async {
val approvalReq = ApprovalRequest(
id = "req_999",
@@ -52,6 +52,7 @@ class RoutingEngineTest {
// Post approval request to backend polymorphically
httpClient.post("/api/request") {
contentType(ContentType.Application.Json)
header("X-Meatbag-API-Key", "test-bypass-key")
setBody<UiPrimitive>(approvalReq)
}
}
@@ -108,7 +109,7 @@ class RoutingEngineTest {
coroutineScope {
val phonePayloadDeferred = async {
var payload: WebSocketPayload? = null
wsClient.webSocket("/ws/connect?deviceType=PHONE&deviceId=phone_123") {
wsClient.webSocket("/ws/connect?deviceType=PHONE&deviceId=phone_123&bypassPairing=true") {
val frame = incoming.receive()
assertTrue(frame is Frame.Text, "Expected text frame")
payload = SDUIJson.json.decodeFromString<WebSocketPayload>((frame as Frame.Text).readText())
@@ -118,7 +119,7 @@ class RoutingEngineTest {
val desktopPayloadDeferred = async {
var payload: WebSocketPayload? = null
wsClient.webSocket("/ws/connect?deviceType=DESKTOP&deviceId=desktop_123") {
wsClient.webSocket("/ws/connect?deviceType=DESKTOP&deviceId=desktop_123&bypassPairing=true") {
val frame = incoming.receive()
assertTrue(frame is Frame.Text, "Expected text frame")
payload = SDUIJson.json.decodeFromString<WebSocketPayload>((frame as Frame.Text).readText())
@@ -143,6 +144,7 @@ class RoutingEngineTest {
val agentResponseDeferred = async {
httpClient.post("/api/request?attentionTier=OVERRIDE_SCREEN") {
contentType(ContentType.Application.Json)
header("X-Meatbag-API-Key", "test-bypass-key")
setBody<UiPrimitive>(
ApprovalRequest(
id = "req_override",