diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 60276fc..66f7385 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -127,6 +127,42 @@ retention error handling. Blocking CI jobs (like formatting and clippy) are strictly tied to our pinned MSRV (1.94) as defined in `rust-toolchain.toml`. This ensures that your local `cargo clippy` and `cargo fmt` results perfectly match CI. An advisory lint job runs on the latest stable toolchain to catch upcoming lints, but it is set to `continue-on-error: true` and will not block your pull request. +## Required Status Checks + +`main` is protected: a pull request cannot be merged until all of the +following required status checks have run **and passed** on the PR's head +commit: + +- `test (1.94)` +- `test (stable)` +- `fmt / clippy` +- `openapi lint` +- `deploy asset consistency` +- `dashboard static checks` +- `dashboard unit tests` + +A PR with failing **or missing** required checks cannot be merged into +`main`. "Missing" matters: if a check never ran, it does not count as passing. +If the merge button is disabled or the checks list is empty, do not merge — +figure out why CI didn't run first. + +Branch protection also requires branches to be **up to date before merging**, +so a PR is tested against the `main` it will actually merge into. If `main` +has moved since your last push, update your branch (merge or rebase `main`) +and let CI re-run before merging. Most breakage we've seen came from +merge-resolution drift, not from the PRs themselves. + +### Fork PRs: approve workflow runs first + +PRs opened from forks do not run Actions automatically — a maintainer must +approve the workflow run (the **"Approve and run"** button on the PR's checks +section) before CI executes. Until that approval happens, the PR shows **zero +checks**, and merging it would bypass every required check above. + +**Maintainers: approve fork workflow runs before reviewing or merging.** A +fork PR with no checks reported is not ready for review — approve the run, +wait for the required checks to go green, and only then review and merge. + ## Dependency Upgrade Policy Dependency upgrades should be small, reviewable, and documented. For routine diff --git a/src/main.rs b/src/main.rs index 0a78059..223d367 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,7 +14,7 @@ use stellargate::{ metrics::{ AuthMetrics, HorizonMetrics, HttpMetrics, PaymentMetrics, TrustlineMetrics, WebhookMetrics, }, - retention, webhook, + retention, supervise, webhook, }; use tokio::sync::watch; use tokio::task::JoinHandle; @@ -94,37 +94,45 @@ async fn main() -> Result<()> { let (shutdown_tx, shutdown_rx) = watch::channel(false); let health = state.task_health.clone(); + // Required workers: a panic must be restarted, not silently dropped. + health.require("poller"); + health.require("sweeper"); + health.require("retention"); + health.require("trustline_checker"); + health.require("redrive"); + if state.config.listener_mode == ListenerMode::Stream { + health.require("stream"); + } + let stream = (state.config.listener_mode == ListenerMode::Stream).then(|| { - spawn_task( - &health, + supervise::supervise( + health.clone(), "stream", horizon::run_stream_listener(state.clone(), shutdown_rx.clone()), ) }); - let poller = spawn_task( - &health, + let poller = supervise::supervise( + health.clone(), "poller", horizon::run_poller(state.clone(), shutdown_rx.clone()), ); - let sweeper = spawn_task( - &health, + let sweeper = supervise::supervise( + health.clone(), "sweeper", expiry::run_sweeper(state.clone(), shutdown_rx.clone()), ); - let retention = spawn_task( - &health, + let retention = supervise::supervise( + health.clone(), "retention", retention::run_retention_worker(state.clone(), shutdown_rx.clone()), ); - let trustline_checker = spawn_task(&health, "trustline_checker", { - let state = state.clone(); - let shutdown_rx = shutdown_rx.clone(); - async move { - horizon::run_trustline_checker(state, shutdown_rx).await; - } - }); - let redrive = spawn_task( - &health, + let trustline_checker = supervise::supervise( + health.clone(), + "trustline_checker", + horizon::run_trustline_checker(state.clone(), shutdown_rx.clone()), + ); + let redrive = supervise::supervise( + health.clone(), "redrive", webhook::run_redrive_worker(state.clone(), shutdown_rx), ); @@ -251,23 +259,9 @@ async fn report_trustlines(state: &Arc) { } } -/// Spawn a background task, keeping [`TaskHealth`] accurate across its -/// lifetime: counted as started before it runs and as stopped when it returns -/// normally. A panic is recorded instead by [`join_task`] at shutdown. -fn spawn_task(health: &TaskHealth, name: &'static str, task: F) -> JoinHandle<()> -where - F: Future + Send + 'static, -{ - let health = health.clone(); - health.task_started(name); - tokio::spawn(async move { - task.await; - health.task_stopped(name); - }) -} - -/// Await a background task. A `JoinError` means it panicked, which is recorded -/// so the failure counter — and any alert watching it — fires. +/// Await a supervised background task. A `JoinError` means the supervisor +/// itself panicked, which is recorded so the failure counter — and any alert +/// watching it — fires. async fn join_task(handle: JoinHandle<()>, name: &'static str, health: &TaskHealth) { if let Err(e) = handle.await && e.is_panic() diff --git a/static/session.js b/static/session.js index a15ebce..153ecaa 100644 --- a/static/session.js +++ b/static/session.js @@ -6,6 +6,13 @@ // How long a stored key stays valid, in milliseconds. const KEY_TTL_MS = 30 * 60 * 1000; +// Storage keys used by the session store. +export const KEY_NAME = "stellargate.apiKey"; +export const KEY_SAVED_AT = "stellargate.apiKeySavedAt"; + +// How long a stored key's display window lasts, in milliseconds. +const SESSION_TTL_MS = 30 * 60 * 1000; + // In-memory storage for the API key. Deliberately not persisted anywhere so // the key can never leak through URLs, logs or console output. let storedKey = null; @@ -30,6 +37,53 @@ export function storeKey(key) { storedKeyExpiresAt = Date.now() + KEY_TTL_MS; } +// Drops the in-memory key. +export function forgetKey() { + storedKey = null; + storedKeyExpiresAt = 0; +} + +// A minimal in-memory Storage stand-in used when no storage area is supplied. +function memoryStorage() { + var data = {}; + return { + getItem: function (k) { + return Object.prototype.hasOwnProperty.call(data, k) ? data[k] : null; + }, + setItem: function (k, v) { + data[k] = String(v); + }, + removeItem: function (k) { + delete data[k]; + }, + }; +} + +function safeGet(area, key) { + try { + return area.getItem(key); + } catch (e) { + return null; + } +} + +function safeSet(area, key, value) { + try { + area.setItem(key, value); + return true; + } catch (e) { + return false; + } +} + +function safeRemove(area, key) { + try { + area.removeItem(key); + } catch (e) { + /* ignore */ + } +} + /** * Build a session store. *