Some checks failed
CI / Test (push) Waiting to run
CI / CUDA type-check (push) Waiting to run
CI / Format (push) Successful in 10s
CI / Clippy (push) Successful in 2m28s
CI / Build cortex SRPM (push) Has been cancelled
CI / Build neuron SRPM (push) Has been cancelled
CI / Publish cortex to COPR (push) Has been cancelled
CI / Publish neuron to COPR (push) Has been cancelled
CI / Bump version in source (push) Has been cancelled
New crate `helexa-angels`, the service behind angels.helexa.ai. Why a separate service rather than routes in the helexa.ai SPA: that app is a static bundle (vite build -> dist -> nginx `try_files`), so every string in it — all 42 locale files included — is compiled into /assets/index-*.js and served unauthenticated. A React route guard gates navigation, not access; confidential material placed there is one curl from disclosure. That is a property of static hosting, not a routing bug. Server-rendering inverts it, and three things follow from the choice rather than being bolted on: - the audit is honest — one document render is one server request, so the log records reading rather than fetching. An SPA against a JSON API could pull every document once and re-read them offline for a week while the log showed a single view; - watermarking is possible at all, since the viewer is in scope at render time; - no second asset pipeline and no i18n machinery — the portal is English-only by decision, all wording operator-reviewed. Shared with helexa.ai: credentials. One helexa account signs in on both, so an investor can evaluate the platform they are being asked to back. NOT shared: sessions. Upstream's `sessions` back the public SPA, whose token sits in localStorage in an app that renders markdown, runs a chat loop and fetches remote pages; honouring those tokens here would put confidential documents one script injection away. The cookie minted here is HttpOnly, Secure, SameSite=Lax and host-only — no Domain attribute, so it never travels to helexa.ai. Schema lives in its own `angels` Postgres schema, not `public`. Both services run sqlx::migrate! against one database and sqlx writes an unqualified `_sqlx_migrations`; two migrators in one schema would fight over that table and corrupt each other's history. Own schema, own bookkeeping, with `search_path = angels, public` keeping public.users reachable for the credential join. Invites are reusable and treated as a distribution mechanism, not a security boundary — a code will be forwarded, and the design assumes so. What it buys is a chance to identify yourself; access then attaches to the USER. Someone who forwards a code produces another *named* account, never anonymous access, and a grant can be revoked where an unguessable content URL cannot. Only the code hash is stored. Sharp edges handled: sign-in verifies against a real argon2 hash even for unknown addresses, since holding an account here implies being an invited investor and timing would otherwise leak that; `next=` is confined to same-site paths (//host and scheme URLs rejected) so the portal can't be used as an open redirect; unknown, revoked, expired and exhausted codes are indistinguishable; unverified accounts cannot sign in, or an invite sent to one person could be claimed by whoever registered that address first; a revoked grant is never resurrected by re-using a live code. Operator CLI: invite / invites / access / reads / revoke / approve / revoke-invite. Registration delegates to helexa-upstream rather than forking password policy, argon2 params, verification mail and the unverified reaper. 19 unit tests. Workspace fmt/clippy/test green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0165r11RzqkMqWWXfJE8tAVU
86 lines
3.0 KiB
Rust
86 lines
3.0 KiB
Rust
//! Error type and its HTTP rendering.
|
|
//!
|
|
//! The portal renders HTML, not JSON: an error here is seen by a person,
|
|
//! so it becomes a page. Two rules shape the mapping —
|
|
//!
|
|
//! 1. **Never confirm existence to an unauthenticated caller.** A bad
|
|
//! invite code, a code for a round that does not exist, and a revoked
|
|
//! code all render identically. Otherwise the portal becomes an oracle
|
|
//! for enumerating rounds and codes.
|
|
//! 2. **Never leak internals.** Database and template failures render a
|
|
//! generic page; the detail goes to the log.
|
|
|
|
use axum::http::StatusCode;
|
|
use axum::response::{Html, IntoResponse, Response};
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum AngelsError {
|
|
#[error("not found")]
|
|
NotFound,
|
|
|
|
/// Authenticated, but not entitled to this round. Distinct from
|
|
/// `NotFound` internally so the access log can record a denial, but
|
|
/// it renders as an ordinary "no access" page.
|
|
#[error("no access to this round")]
|
|
Forbidden,
|
|
|
|
/// Not signed in. Triggers a redirect to the sign-in page.
|
|
#[error("authentication required")]
|
|
Unauthenticated,
|
|
|
|
#[error("invalid credentials")]
|
|
BadCredentials,
|
|
|
|
#[error("{0}")]
|
|
BadRequest(String),
|
|
|
|
#[error(transparent)]
|
|
Db(#[from] sqlx::Error),
|
|
|
|
#[error(transparent)]
|
|
Template(#[from] minijinja::Error),
|
|
|
|
#[error(transparent)]
|
|
Other(#[from] anyhow::Error),
|
|
}
|
|
|
|
impl AngelsError {
|
|
pub fn status(&self) -> StatusCode {
|
|
match self {
|
|
Self::NotFound => StatusCode::NOT_FOUND,
|
|
Self::Forbidden => StatusCode::FORBIDDEN,
|
|
Self::Unauthenticated => StatusCode::UNAUTHORIZED,
|
|
Self::BadCredentials => StatusCode::UNAUTHORIZED,
|
|
Self::BadRequest(_) => StatusCode::BAD_REQUEST,
|
|
Self::Db(_) | Self::Template(_) | Self::Other(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
|
}
|
|
}
|
|
|
|
/// What the visitor is told. Internal failures are deliberately vague.
|
|
pub fn public_message(&self) -> String {
|
|
match self {
|
|
Self::NotFound => "That link doesn't lead anywhere.".into(),
|
|
Self::Forbidden => "This material isn't available to your account.".into(),
|
|
Self::Unauthenticated => "Please sign in to continue.".into(),
|
|
Self::BadCredentials => "That email address and password don't match.".into(),
|
|
Self::BadRequest(m) => m.clone(),
|
|
Self::Db(_) | Self::Template(_) | Self::Other(_) => {
|
|
"Something went wrong at our end. It has been logged.".into()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl IntoResponse for AngelsError {
|
|
fn into_response(self) -> Response {
|
|
// Log the real cause for the 5xx family; the visitor never sees it.
|
|
if self.status() == StatusCode::INTERNAL_SERVER_ERROR {
|
|
tracing::error!(error = %self, "angels request failed");
|
|
}
|
|
let body = crate::templates::render_error(self.status(), &self.public_message());
|
|
(self.status(), Html(body)).into_response()
|
|
}
|
|
}
|
|
|
|
pub type Result<T> = std::result::Result<T, AngelsError>;
|