Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
64 changes: 29 additions & 35 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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),
);
Expand Down Expand Up @@ -251,23 +259,9 @@ async fn report_trustlines(state: &Arc<AppState>) {
}
}

/// 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<F>(health: &TaskHealth, name: &'static str, task: F) -> JoinHandle<()>
where
F: Future<Output = ()> + 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()
Expand Down
54 changes: 54 additions & 0 deletions static/session.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
*
Expand Down