feat(meshcore): set/clear room-server passwords from the routing picker (#112)

Adds PUT/DELETE /api/meshcore/room-password/{pubkey} (dedicated route — the
generic secrets allowlist rejects dynamic per-room vars) and a password_set
flag on GET /api/meshcore/rooms. The routing picker gains an inline lock +
set/clear editor on room-mode cells; state is keyed by room pubkey and shared
across cells targeting the same room. Send-time login already reads the stored
password via secrets_store — no dispatch changes needed.

Co-authored-by: Matt Johnson <mj@k7zvx.com>
This commit is contained in:
malice 2026-07-09 23:11:38 -06:00 committed by GitHub
commit 22037c9a23
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 156 additions and 9 deletions

View file

@ -614,6 +614,7 @@ export interface MeshcoreRoom {
pubkey: string
prefix: string
path_established: boolean
password_set?: boolean
}
export interface MeshcoreRooms {
active: boolean
@ -624,6 +625,36 @@ export async function getMeshcoreRooms(): Promise<MeshcoreRooms> {
return fetchJson<MeshcoreRooms>('/api/meshcore/rooms')
}
// Set (or clear, if password is empty) a MeshCore room server's login password.
// The value is never read back — only a boolean status is returned.
export async function setRoomPassword(
pubkey: string,
password: string,
): Promise<{ ok: boolean; password_set: boolean }> {
const response = await fetch(`/api/meshcore/room-password/${encodeURIComponent(pubkey)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value: password }),
})
if (!response.ok) {
throw new Error(`API error: ${response.status} ${response.statusText}`)
}
return response.json()
}
// Clear a MeshCore room server's stored login password.
export async function clearRoomPassword(
pubkey: string,
): Promise<{ ok: boolean; password_set: boolean }> {
const response = await fetch(`/api/meshcore/room-password/${encodeURIComponent(pubkey)}`, {
method: 'DELETE',
})
if (!response.ok) {
throw new Error(`API error: ${response.status} ${response.statusText}`)
}
return response.json()
}
export interface MeshcoreContact {
name: string | null
pubkey: string

View file

@ -14,7 +14,7 @@ import {
type RegionCell,
type RegionRoutes,
} from './Notifications'
import { getMeshcoreRooms, type MeshcoreRoom } from '@/lib/api'
import { getMeshcoreRooms, setRoomPassword, clearRoomPassword, type MeshcoreRoom } from '@/lib/api'
// Merge only the MeshCore-owned fields of `mine` into `fresh`, preserving every
// other (Meshtastic / Other-channels / general) field on the family. The
@ -90,12 +90,6 @@ function mergeRegionRoutesForMc(
return { mt_enabled, mc_enabled, cells: newCells }
}
// TODO: room password UI needs a backend secrets endpoint. secrets_store.py
// has set/get/clear_room_password() keyed by room pubkey, but NO HTTP route
// exposes them (the generic /api/secrets endpoint is gated to a fixed
// SECRET_LABELS allowlist, so it cannot set a dynamic per-room password).
// Open rooms route fine without a password; password-protected rooms need
// that endpoint before a set/clear affordance can be added here.
export default function MeshCoreRouting() {
const { setDirty } = useDirty()
const [config, setConfig] = useState<NotificationsConfig | null>(null)
@ -110,6 +104,10 @@ export default function MeshCoreRouting() {
// targeted by writing the cell value as `room:<pubkey>`; channels stay bare.
const [rooms, setRooms] = useState<MeshcoreRoom[]>([])
const [roomsActive, setRoomsActive] = useState(false)
// Inline room-password editor: open for at most one room pubkey at a time.
const [pwdEditorPk, setPwdEditorPk] = useState<string | null>(null)
const [pwdInput, setPwdInput] = useState('')
const [pwdError, setPwdError] = useState<string | null>(null)
// Local map: family key -> explicitly toggled on/off for region expand.
const [regionExpandedMap, setRegionExpandedMap] = useState<Record<string, boolean | undefined>>({})
@ -519,7 +517,8 @@ export default function MeshCoreRouting() {
</button>
</div>
{targetsRoom ? (
roomsActive || room ? (
<>
{roomsActive || room ? (
<div className="flex items-center gap-1 w-40">
<select
value={room ? room.pubkey : ''}
@ -551,7 +550,86 @@ export default function MeshCoreRouting() {
</div>
) : (
<span className="w-40 text-xs text-slate-600 italic truncate">MeshCore not connected</span>
)
)}
{roomPubkeyOf(mcVal) && (() => {
const pk = roomPubkeyOf(mcVal)
const isSet = rooms.find((r) => r.pubkey === pk)?.password_set ?? false
const editing = pwdEditorPk === pk
return (
<div className="flex items-center gap-1">
<button
type="button"
title={isSet ? 'Room password: set' : 'Room password: not set'}
aria-label={isSet ? 'Room password: set' : 'Room password: not set'}
onClick={() => {
setPwdError(null)
if (editing) { setPwdEditorPk(null) }
else { setPwdEditorPk(pk); setPwdInput('') }
}}
className={`px-1 py-1 text-xs ${isSet ? 'text-accent' : 'text-slate-600 hover:text-slate-400'}`}
>
{isSet ? '🔒' : '🔓'}
</button>
{editing && (
<div className="flex items-center gap-1">
<input
type="password"
value={pwdInput}
onChange={(e) => setPwdInput(e.target.value)}
placeholder="room password"
className="w-28 px-1.5 py-1 bg-[#0a0e17] border border-[#1e2a3a] rounded text-xs text-slate-200 focus:outline-none focus:border-accent"
/>
<button
type="button"
title="Save room password"
onClick={async () => {
try {
setPwdError(null)
await setRoomPassword(pk, pwdInput)
await fetchRooms()
setPwdEditorPk(null)
setPwdInput('')
} catch {
setPwdError('save failed')
}
}}
className="px-1.5 py-1 bg-accent hover:bg-accent/80 rounded text-xs text-white"
>
Save
</button>
<button
type="button"
title="Clear room password"
onClick={async () => {
try {
setPwdError(null)
await clearRoomPassword(pk)
await fetchRooms()
setPwdEditorPk(null)
setPwdInput('')
} catch {
setPwdError('clear failed')
}
}}
className="px-1.5 py-1 border border-[#1e2a3a] rounded text-xs text-slate-400 hover:text-slate-200"
>
Clear
</button>
<button
type="button"
title="Cancel"
onClick={() => { setPwdEditorPk(null); setPwdError(null) }}
className="px-1.5 py-1 border border-[#1e2a3a] rounded text-xs text-slate-500 hover:text-slate-300"
>
Cancel
</button>
{pwdError && <span className="text-[10px] text-red-400">{pwdError}</span>}
</div>
)}
</div>
)
})()}
</>
) : (
<input
type="text"

View file

@ -6,6 +6,7 @@ from typing import Optional, Union
from fastapi import APIRouter, Request
from pydantic import BaseModel
from meshai import secrets_store
logger = logging.getLogger(__name__)
router = APIRouter(tags=["mesh-send"])
@ -72,6 +73,11 @@ async def meshcore_rooms(request: Request):
rooms = list(mc.get_rooms())
except Exception:
rooms = []
for r in rooms:
try:
r["password_set"] = secrets_store.room_password_is_set(r.get("pubkey") or "")
except Exception:
r["password_set"] = False
return {"active": True, "rooms": rooms}
return {"active": False, "rooms": []}

View file

@ -40,3 +40,35 @@ async def delete_secret(env_var: str):
raise HTTPException(status_code=400, detail="unknown secret var")
secrets_store.delete_secret(env_var)
return {"ok": True, "restart_required": True}
_ROOM_PK_HEX = set("0123456789abcdefABCDEF")
def _valid_room_pubkey(pk: str) -> bool:
return len(pk) >= 12 and all(c in _ROOM_PK_HEX for c in pk)
@router.put("/meshcore/room-password/{pubkey}")
async def set_room_password_route(pubkey: str, body: SecretUpdate):
"""Set (or, if the value is empty, clear) a MeshCore room server's password.
The value is never read back only a boolean status is returned."""
pk = pubkey.strip()
if not _valid_room_pubkey(pk):
raise HTTPException(status_code=400, detail="invalid room pubkey")
value = (body.value or "").strip()
if value:
secrets_store.set_room_password(pk, value)
else:
secrets_store.delete_room_password(pk)
return {"ok": True, "password_set": secrets_store.room_password_is_set(pk)}
@router.delete("/meshcore/room-password/{pubkey}")
async def clear_room_password_route(pubkey: str):
"""Clear a MeshCore room server's stored password."""
pk = pubkey.strip()
if not _valid_room_pubkey(pk):
raise HTTPException(status_code=400, detail="invalid room pubkey")
secrets_store.delete_room_password(pk)
return {"ok": True, "password_set": False}