[feat](trx-rs): harden auth UI and extend planning docs
Co-authored-by: OpenAI Codex <codex@openai.com> Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
This commit is contained in:
@@ -0,0 +1,129 @@
|
|||||||
|
//! APRS.fi integration implementation draft (server-side)
|
||||||
|
//!
|
||||||
|
//! Goal:
|
||||||
|
//! - Add optional APRS.fi upload/logging support for decoded APRS packets.
|
||||||
|
//! - Keep feature disabled by default.
|
||||||
|
//! - Reuse existing decode pipeline in `trx-server`.
|
||||||
|
//!
|
||||||
|
//! This is a planning artifact, not active runtime logic.
|
||||||
|
|
||||||
|
/// Delivery phases.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub enum Phase {
|
||||||
|
Config,
|
||||||
|
PacketSelection,
|
||||||
|
UplinkWorker,
|
||||||
|
RetryAndRateLimit,
|
||||||
|
PrivacyControls,
|
||||||
|
Tests,
|
||||||
|
Docs,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Proposed config block for `trx-server.toml`.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub const CONFIG_PROPOSAL: &str = r#"
|
||||||
|
[aprsfi]
|
||||||
|
enabled = false
|
||||||
|
|
||||||
|
# APRS.fi API token / key (required when enabled)
|
||||||
|
api_key = ""
|
||||||
|
|
||||||
|
# Optional station identity metadata
|
||||||
|
receiver_callsign = "N0CALL"
|
||||||
|
receiver_locator = "JO93"
|
||||||
|
|
||||||
|
# Upload endpoint override for testing
|
||||||
|
endpoint = "https://api.aprs.fi/api"
|
||||||
|
|
||||||
|
# Upload policy
|
||||||
|
include_third_party = false
|
||||||
|
min_interval_ms = 1000
|
||||||
|
max_queue = 1000
|
||||||
|
"#;
|
||||||
|
|
||||||
|
/// Validation rules.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub const VALIDATION: &[&str] = &[
|
||||||
|
"If aprsfi.enabled=false: ignore all aprsfi fields",
|
||||||
|
"If aprsfi.enabled=true: api_key must be non-empty",
|
||||||
|
"min_interval_ms must be > 0",
|
||||||
|
"max_queue must be > 0",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Runtime architecture.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub const ARCHITECTURE: &[&str] = &[
|
||||||
|
"Spawn dedicated APRS.fi worker task in src/trx-server/src/main.rs",
|
||||||
|
"Subscribe to decode broadcast channel (existing decode_tx.subscribe())",
|
||||||
|
"Filter DecodedMessage::Aprs only",
|
||||||
|
"Transform AprsPacket into APRS.fi payload DTO",
|
||||||
|
"Queue and POST asynchronously with bounded backpressure",
|
||||||
|
"Never block decoder tasks on network I/O",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Integration points in current code.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub const INTEGRATION_POINTS: &[&str] = &[
|
||||||
|
"src/trx-server/src/config.rs: add AprsFiConfig",
|
||||||
|
"src/trx-server/src/main.rs: start worker when enabled",
|
||||||
|
"src/trx-server/src/audio.rs: no direct changes required (consume from decode stream)",
|
||||||
|
"src/trx-server/src/<new>/aprsfi.rs: worker + payload mapping + HTTP client",
|
||||||
|
"trx-server.toml.example + CONFIGURATION.md: docs",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Packet handling policy.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub const PACKET_POLICY: &[&str] = &[
|
||||||
|
"Upload only packets with valid callsign and parseable position by default",
|
||||||
|
"Optionally allow non-position packets if APRS.fi endpoint supports them",
|
||||||
|
"Deduplicate burst repeats (same src/info within short window)",
|
||||||
|
"Drop malformed frames silently with debug log",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Retry/rate limiting policy.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub const RELIABILITY_POLICY: &[&str] = &[
|
||||||
|
"Bounded mpsc queue (max_queue)",
|
||||||
|
"If queue full: drop oldest or newest by configurable policy (MVP: drop newest)",
|
||||||
|
"Exponential backoff on HTTP/network errors",
|
||||||
|
"Respect min_interval_ms between outbound requests",
|
||||||
|
"Throttle warning logs to avoid spam",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Privacy/safety controls.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub const PRIVACY_CONTROLS: &[&str] = &[
|
||||||
|
"Feature disabled by default",
|
||||||
|
"API key never logged",
|
||||||
|
"Optional include_third_party flag for re-published packets",
|
||||||
|
"Document that enabling uploads sends decoded RF data to external service",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Test plan.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub const TEST_PLAN: &[&str] = &[
|
||||||
|
"Unit: config parse + validation",
|
||||||
|
"Unit: APRS packet -> APRS.fi payload mapping",
|
||||||
|
"Unit: dedupe and queue/backpressure behavior",
|
||||||
|
"Unit: retry/backoff timing logic",
|
||||||
|
"Integration: mock HTTP endpoint receives expected payloads",
|
||||||
|
"Integration: disabled mode performs no outbound requests",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Suggested first implementation milestone (M1).
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub const M1: &[&str] = &[
|
||||||
|
"Add config + validation + docs",
|
||||||
|
"Create aprsfi worker skeleton (no uploads yet, just consume + structured logs)",
|
||||||
|
"Add payload mapping function with tests",
|
||||||
|
"Add feature flag + startup logs",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Suggested second milestone (M2).
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub const M2: &[&str] = &[
|
||||||
|
"Implement real HTTP POST uploads",
|
||||||
|
"Add retry/backoff + queue policy",
|
||||||
|
"Add integration test with mock server",
|
||||||
|
"Add operational metrics counters",
|
||||||
|
];
|
||||||
@@ -12,10 +12,15 @@ function loadSetting(key, fallback) {
|
|||||||
|
|
||||||
// --- Authentication ---
|
// --- Authentication ---
|
||||||
let authRole = null; // null (not authenticated), "rx" (read-only), or "control" (full access)
|
let authRole = null; // null (not authenticated), "rx" (read-only), or "control" (full access)
|
||||||
|
let authEnabled = true;
|
||||||
|
|
||||||
async function checkAuthStatus() {
|
async function checkAuthStatus() {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch("/auth/session");
|
const resp = await fetch("/auth/session");
|
||||||
|
if (resp.status === 404) {
|
||||||
|
// Auth API not exposed -> treat as auth-disabled mode.
|
||||||
|
return { authenticated: true, role: "control", auth_disabled: true };
|
||||||
|
}
|
||||||
if (!resp.ok) return { authenticated: false };
|
if (!resp.ok) return { authenticated: false };
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
return data;
|
return data;
|
||||||
@@ -32,6 +37,9 @@ async function authLogin(passphrase) {
|
|||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ passphrase }),
|
body: JSON.stringify({ passphrase }),
|
||||||
});
|
});
|
||||||
|
if (resp.status === 404) {
|
||||||
|
return { authenticated: true, role: "control", auth_disabled: true };
|
||||||
|
}
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
const text = await resp.text();
|
const text = await resp.text();
|
||||||
throw new Error(text || "Login failed");
|
throw new Error(text || "Login failed");
|
||||||
@@ -46,7 +54,7 @@ async function authLogin(passphrase) {
|
|||||||
async function authLogout() {
|
async function authLogout() {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch("/auth/logout", { method: "POST" });
|
const resp = await fetch("/auth/logout", { method: "POST" });
|
||||||
if (!resp.ok) throw new Error("Logout failed");
|
if (resp.status !== 404 && !resp.ok) throw new Error("Logout failed");
|
||||||
authRole = null;
|
authRole = null;
|
||||||
// Disconnect and show auth gate without page reload
|
// Disconnect and show auth gate without page reload
|
||||||
disconnect();
|
disconnect();
|
||||||
@@ -66,6 +74,7 @@ async function authLogout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function showAuthGate(allowGuest = false) {
|
function showAuthGate(allowGuest = false) {
|
||||||
|
if (!authEnabled) return;
|
||||||
document.getElementById("loading").style.display = "none";
|
document.getElementById("loading").style.display = "none";
|
||||||
document.getElementById("content").style.display = "none";
|
document.getElementById("content").style.display = "none";
|
||||||
document.getElementById("auth-gate").style.display = "block";
|
document.getElementById("auth-gate").style.display = "block";
|
||||||
@@ -121,6 +130,12 @@ function updateAuthUI() {
|
|||||||
const badgeRole = document.getElementById("auth-role-badge");
|
const badgeRole = document.getElementById("auth-role-badge");
|
||||||
const headerAuthBtn = document.getElementById("header-auth-btn");
|
const headerAuthBtn = document.getElementById("header-auth-btn");
|
||||||
|
|
||||||
|
if (!authEnabled) {
|
||||||
|
if (badge) badge.style.display = "none";
|
||||||
|
if (headerAuthBtn) headerAuthBtn.style.display = "none";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (authRole) {
|
if (authRole) {
|
||||||
badge.style.display = "block";
|
badge.style.display = "block";
|
||||||
badgeRole.textContent = authRole === "control" ? "Control (full access)" : "RX (read-only)";
|
badgeRole.textContent = authRole === "control" ? "Control (full access)" : "RX (read-only)";
|
||||||
@@ -990,7 +1005,7 @@ function disconnect() {
|
|||||||
|
|
||||||
async function postPath(path) {
|
async function postPath(path) {
|
||||||
const resp = await fetch(path, { method: "POST" });
|
const resp = await fetch(path, { method: "POST" });
|
||||||
if (resp.status === 401) {
|
if (authEnabled && resp.status === 401) {
|
||||||
// Not authenticated - return to login
|
// Not authenticated - return to login
|
||||||
authRole = null;
|
authRole = null;
|
||||||
if (es) es.close();
|
if (es) es.close();
|
||||||
@@ -1247,6 +1262,18 @@ document.querySelector(".tab-bar").addEventListener("click", (e) => {
|
|||||||
// --- Auth startup sequence ---
|
// --- Auth startup sequence ---
|
||||||
async function initializeApp() {
|
async function initializeApp() {
|
||||||
const authStatus = await checkAuthStatus();
|
const authStatus = await checkAuthStatus();
|
||||||
|
authEnabled = !authStatus.auth_disabled;
|
||||||
|
|
||||||
|
if (!authEnabled) {
|
||||||
|
authRole = "control";
|
||||||
|
hideAuthGate();
|
||||||
|
updateAuthUI();
|
||||||
|
connect();
|
||||||
|
resizeHeaderSignalCanvas();
|
||||||
|
startHeaderSignalSampling();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (authStatus.authenticated) {
|
if (authStatus.authenticated) {
|
||||||
// User has valid session
|
// User has valid session
|
||||||
authRole = authStatus.role;
|
authRole = authStatus.role;
|
||||||
|
|||||||
@@ -54,15 +54,23 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="content" style="display:none;">
|
<div id="content" style="display:none;">
|
||||||
<div class="status">
|
<div class="status">
|
||||||
<div class="full-row freq-row label-below-row">
|
<div class="full-row freq-row">
|
||||||
<div class="label"><span>Frequency</span></div>
|
|
||||||
<div class="inline freq-inline">
|
<div class="inline freq-inline">
|
||||||
<div class="wavelength-display" id="wavelength" title="Wavelength">--</div>
|
<div class="freq-field wavelength-col">
|
||||||
<input class="status-input" id="freq" type="text" value="--" />
|
<div class="wavelength-display" id="wavelength" title="Wavelength">--</div>
|
||||||
<div class="jog-step" id="jog-step">
|
<div class="label"><span>Wavelength</span></div>
|
||||||
<button type="button" data-step="1000000">MHz</button>
|
</div>
|
||||||
<button type="button" data-step="1000" class="active">kHz</button>
|
<div class="freq-field frequency-col">
|
||||||
<button type="button" data-step="1">Hz</button>
|
<input class="status-input" id="freq" type="text" value="--" />
|
||||||
|
<div class="label"><span>Frequency</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="freq-field unit-col">
|
||||||
|
<div class="jog-step" id="jog-step">
|
||||||
|
<button type="button" data-step="1000000">MHz</button>
|
||||||
|
<button type="button" data-step="1000" class="active">kHz</button>
|
||||||
|
<button type="button" data-step="1">Hz</button>
|
||||||
|
</div>
|
||||||
|
<div class="label"><span>Unit</span></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -234,7 +234,33 @@ button { padding: 0.5rem 0.9rem; border-radius: 6px; border: 1px solid var(--btn
|
|||||||
button:disabled { opacity: 0.6; cursor: not-allowed; }
|
button:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||||
.hint { color: var(--text-muted); font-size: 0.85rem; }
|
.hint { color: var(--text-muted); font-size: 0.85rem; }
|
||||||
.inline { display: flex; gap: 0.5rem; align-items: center; }
|
.inline { display: flex; gap: 0.5rem; align-items: center; }
|
||||||
.freq-inline #freq { flex: 1 1 auto; }
|
.freq-inline {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.freq-field {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 3.35rem auto;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.freq-field .label {
|
||||||
|
margin-top: 0.45rem;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.frequency-col {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.frequency-col #freq {
|
||||||
|
width: 100%;
|
||||||
|
height: 3.35rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.wavelength-col {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.unit-col {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
.label-below-row {
|
.label-below-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
Reference in New Issue
Block a user