Files
helexa/crates/helexa-angels/src/error.rs
rob thijssen 216ebdad58
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
feat(angels): confidential investor portal — access control core
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
2026-08-03 22:09:29 +03:00

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>;