feat(angels): confidential investor portal — access control core
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
This commit is contained in:
2026-08-03 22:09:29 +03:00
parent 807591cdf2
commit 216ebdad58
25 changed files with 2836 additions and 9 deletions

70
Cargo.lock generated
View File

@@ -133,7 +133,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -144,7 +144,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -1191,7 +1191,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -1359,7 +1359,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -2136,6 +2136,34 @@ dependencies = [
"url",
]
[[package]]
name = "helexa-angels"
version = "0.1.16"
dependencies = [
"anyhow",
"argon2",
"axum",
"chrono",
"clap",
"figment",
"minijinja",
"pulldown-cmark",
"rand 0.8.6",
"reqwest",
"serde",
"serde_json",
"sha2",
"sqlx",
"thiserror 2.0.18",
"tokio",
"toml",
"tower",
"tower-http",
"tracing",
"tracing-subscriber",
"uuid",
]
[[package]]
name = "helexa-bench"
version = "0.1.16"
@@ -3166,7 +3194,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -3684,6 +3712,24 @@ dependencies = [
"yansi",
]
[[package]]
name = "pulldown-cmark"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f86ba2052aebccc42cbbb3ed234b8b13ce76f75c3551a303cb2bcffcff12bb14"
dependencies = [
"bitflags",
"memchr",
"pulldown-cmark-escape",
"unicase",
]
[[package]]
name = "pulldown-cmark-escape"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
[[package]]
name = "pulp"
version = "0.21.5"
@@ -4200,7 +4246,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -4655,7 +4701,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [
"libc",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -5054,7 +5100,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -5503,6 +5549,12 @@ dependencies = [
"version_check",
]
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]]
name = "unicode-bidi"
version = "0.3.18"
@@ -5901,7 +5953,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.48.0",
"windows-sys 0.61.2",
]
[[package]]

View File

@@ -6,6 +6,7 @@ members = [
"crates/cortex-cli",
"crates/neuron",
"crates/helexa-acp",
"crates/helexa-angels",
"crates/helexa-bench",
"crates/helexa-router",
"crates/helexa-stream",

View File

@@ -0,0 +1,61 @@
[package]
name = "helexa-angels"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
[[bin]]
name = "helexa-angels"
path = "src/main.rs"
[lib]
name = "helexa_angels"
path = "src/lib.rs"
[dependencies]
tokio = { workspace = true }
axum = { workspace = true }
tower-http = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
toml = { workspace = true }
figment = { workspace = true }
anyhow = { workspace = true }
thiserror = { workspace = true }
clap = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
chrono = { workspace = true }
reqwest = { workspace = true }
# Shares helexa-upstream's database. Runtime query API (not the
# compile-time `query!` macros) for the same reason upstream uses it: the
# crate must build in CI without a live database or a committed offline
# cache.
sqlx = { version = "0.8", default-features = false, features = [
"runtime-tokio",
"tls-rustls",
"postgres",
"macros",
"migrate",
"uuid",
"chrono",
] }
uuid = { version = "1", features = ["v4", "serde"] }
# Credential verification against the shared `users` table. Must stay the
# same algorithm and parameter set helexa-upstream hashes with — see
# `auth::verify_password`.
argon2 = "0.5"
sha2 = "0.10"
rand = "0.8"
# Server-side rendering. The confidentiality model depends on HTML being
# assembled on the server: nothing an unauthenticated request can reach
# ever contains round content.
minijinja = { version = "2", features = ["loader"] }
pulldown-cmark = { version = "0.12", default-features = false, features = ["html"] }
[dev-dependencies]
tower = { workspace = true }

View File

@@ -0,0 +1,154 @@
-- helexa-angels schema — the investor portal behind angels.helexa.ai.
--
-- These tables live in a dedicated `angels` Postgres SCHEMA, not in
-- `public`, for one hard reason: helexa-angels and helexa-upstream share a
-- database, and both run `sqlx::migrate!`. sqlx writes its bookkeeping to
-- an unqualified `_sqlx_migrations`, so two migrators in one schema would
-- fight over the same table and mutually corrupt each other's version
-- history. Giving angels its own schema gives it its own migration table.
--
-- The connection runs with `search_path = angels, public`, so unqualified
-- names below land in `angels` while `public.users` remains reachable.
-- References to upstream-owned tables are written schema-qualified so the
-- dependency direction is impossible to misread: angels reads `users`; it
-- never alters anything upstream owns.
-- ── Rounds ──────────────────────────────────────────────────────────
-- A round carries its own framing (D6): "Early Access Programme" for the
-- Tenstorrent round, something else next time. Nothing in the schema
-- assumes equity, shares, or priced packages.
CREATE TABLE rounds (
slug TEXT PRIMARY KEY,
title TEXT NOT NULL,
-- Displayed instead of the word "round" wherever the UI names it.
framing_label TEXT NOT NULL DEFAULT 'Early Access Programme',
status TEXT NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft', 'open', 'closed')),
-- TRUE: a valid invite grants access immediately. FALSE: the invite
-- creates a `pending` grant an operator must approve first. Per-round
-- because a later round carrying real financials may want the gate
-- even though this one does not.
auto_grant BOOLEAN NOT NULL DEFAULT TRUE,
-- Git SHA (or any opaque tag) of the content tree this round was last
-- rendered from; stamped into every access_log row so "which version
-- did they see?" is answerable.
content_version TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
opened_at TIMESTAMPTZ,
closed_at TIMESTAMPTZ
);
-- ── Invites ─────────────────────────────────────────────────────────
-- Reusable by design (D5). The code is a DISTRIBUTION mechanism, not a
-- security boundary: it will be forwarded. Confidentiality rests on the
-- fact that redeeming it produces a NAMED account and every subsequent
-- document view is attributed. Only the hash is stored — the plaintext
-- exists solely in the link the operator sends.
CREATE TABLE invites (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code_hash BYTEA NOT NULL UNIQUE,
-- Non-secret display tag so the operator can tell codes apart in a
-- listing without holding the plaintext.
label TEXT NOT NULL,
round_slug TEXT NOT NULL REFERENCES rounds(slug) ON DELETE CASCADE,
-- NULL = unlimited uses. Reusability is the point; the cap is a
-- blast-radius control for a code that escapes further than intended.
max_uses INTEGER,
used_count INTEGER NOT NULL DEFAULT 0,
expires_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT invites_uses_sane CHECK (max_uses IS NULL OR max_uses > 0)
);
CREATE INDEX invites_round_idx ON invites (round_slug);
-- ── Grants ──────────────────────────────────────────────────────────
-- Access attaches to the USER, not the code. This is what makes the model
-- work: revoking an invite stops new grants, revoking a grant cuts off one
-- named person — something an unguessable content URL can never do.
CREATE TABLE grants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
round_slug TEXT NOT NULL REFERENCES rounds(slug) ON DELETE CASCADE,
invite_id UUID REFERENCES invites(id) ON DELETE SET NULL,
state TEXT NOT NULL DEFAULT 'active'
CHECK (state IN ('pending', 'active', 'revoked')),
granted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
approved_by TEXT,
revoked_at TIMESTAMPTZ,
UNIQUE (user_id, round_slug)
);
CREATE INDEX grants_user_idx ON grants (user_id);
CREATE INDEX grants_round_idx ON grants (round_slug);
-- ── Sessions ────────────────────────────────────────────────────────
-- Deliberately NOT public.sessions. Credentials are shared with helexa.ai
-- (D2) but session realms are not: the public SPA keeps its token in
-- localStorage and renders markdown, runs a chat loop and fetches remote
-- pages. Honouring those tokens here would put confidential documents one
-- script injection away. This cookie is HttpOnly and host-only.
CREATE TABLE sessions (
token_hash BYTEA PRIMARY KEY,
user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
issued_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL,
ip TEXT,
user_agent TEXT
);
CREATE INDEX sessions_user_idx ON sessions (user_id);
CREATE INDEX sessions_expiry_idx ON sessions (expires_at);
-- ── Access log ──────────────────────────────────────────────────────
-- The answer to D3, and the reason this service is server-rendered rather
-- than an SPA against an API: one document render is one server request,
-- so this table records what was actually read. A client-side app could
-- fetch every document once and re-render offline, and the log would show
-- a single view.
--
-- Holds identifiable data about (probably) EU persons — see the retention
-- sweep in A5 and the portal privacy note.
CREATE TABLE access_log (
id BIGSERIAL PRIMARY KEY,
user_id UUID REFERENCES public.users(id) ON DELETE SET NULL,
-- Denormalised so the trail survives account deletion: who read what
-- is a record we must keep even once the account is gone.
user_email TEXT,
round_slug TEXT,
document_slug TEXT,
content_version TEXT,
kind TEXT NOT NULL DEFAULT 'view'
CHECK (kind IN ('view', 'download', 'export', 'denied')),
viewed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
ip TEXT,
user_agent TEXT
);
CREATE INDEX access_log_user_idx ON access_log (user_id);
CREATE INDEX access_log_round_idx ON access_log (round_slug, viewed_at DESC);
CREATE INDEX access_log_time_idx ON access_log (viewed_at DESC);
-- ── Expressions of interest ─────────────────────────────────────────
-- No payments here: taking six figures through a web form is a different
-- project with its own compliance surface. This records intent and routes
-- it to a human.
--
-- All three commercial axes are the INVESTOR's decision (operator, 2026-08-03):
-- who purchases (investor direct from Tenstorrent, or Bears Lairs EOOD on
-- their behalf), who hosts, and who covers maintenance and running costs.
-- Contracts are bespoke per investor to reflect the combination chosen,
-- so these columns capture a starting position, not a fixed product.
CREATE TABLE interest (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
round_slug TEXT NOT NULL REFERENCES rounds(slug) ON DELETE CASCADE,
package_ref TEXT,
purchaser TEXT,
hosting_choice TEXT,
running_costs TEXT,
message TEXT,
state TEXT NOT NULL DEFAULT 'new'
CHECK (state IN ('new', 'acknowledged', 'closed')),
submitted_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX interest_round_idx ON interest (round_slug, submitted_at DESC);

View File

@@ -0,0 +1,96 @@
//! The access record.
//!
//! This is the requirement the architecture was chosen to satisfy: knowing
//! which named person read which document, and which version of it.
//!
//! Because the portal is server-rendered, one document view is one server
//! request, so this table records reading rather than fetching. An SPA
//! against a JSON API could pull every document once and re-render them
//! offline all week; the log would show a single view. That difference is
//! why the confidentiality requirement drove the architecture rather than
//! just the routing.
//!
//! The email is denormalised alongside `user_id` on purpose: the record of
//! how confidential material was handled must survive the deletion of the
//! account that read it.
use sqlx::postgres::PgPool;
use uuid::Uuid;
pub struct Access<'a> {
pub user_id: Option<Uuid>,
pub user_email: Option<&'a str>,
pub round_slug: Option<&'a str>,
pub document_slug: Option<&'a str>,
pub content_version: Option<&'a str>,
pub kind: &'a str,
pub ip: Option<&'a str>,
pub user_agent: Option<&'a str>,
}
/// Record an access. Never fails a request: a page the investor is
/// entitled to read must not 500 because the audit insert had a hiccup.
/// A failure here is loud in the log instead.
pub async fn record(pool: &PgPool, a: Access<'_>) {
let res = sqlx::query(
"INSERT INTO access_log \
(user_id, user_email, round_slug, document_slug, content_version, kind, ip, user_agent) \
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
)
.bind(a.user_id)
.bind(a.user_email)
.bind(a.round_slug)
.bind(a.document_slug)
.bind(a.content_version)
.bind(a.kind)
.bind(a.ip)
.bind(a.user_agent)
.execute(pool)
.await;
if let Err(e) = res {
tracing::error!(error = %e, kind = a.kind, "FAILED TO RECORD ACCESS — audit gap");
}
}
/// Who read what, most recent first — the operator's answer to "has
/// anyone actually looked at this?"
pub async fn recent(
pool: &PgPool,
round_slug: Option<&str>,
limit: i64,
) -> Result<Vec<(String, String, String, String)>, sqlx::Error> {
let rows = sqlx::query(
"SELECT to_char(viewed_at, 'YYYY-MM-DD HH24:MI') AS at, \
coalesce(user_email, '(deleted account)') AS who, \
coalesce(round_slug, '-') AS round, \
coalesce(document_slug, '-') AS doc \
FROM access_log \
WHERE ($1::text IS NULL OR round_slug = $1) AND kind <> 'denied' \
ORDER BY viewed_at DESC LIMIT $2",
)
.bind(round_slug)
.bind(limit)
.fetch_all(pool)
.await?;
use sqlx::Row;
Ok(rows
.into_iter()
.map(|r| (r.get("at"), r.get("who"), r.get("round"), r.get("doc")))
.collect())
}
/// Delete access records older than the retention window.
///
/// These rows identify people, so keeping them forever is neither
/// necessary nor defensible. The window is stated in the portal's privacy
/// note; the two must agree.
pub async fn prune(pool: &PgPool, months: i64) -> Result<u64, sqlx::Error> {
let res =
sqlx::query("DELETE FROM access_log WHERE viewed_at < now() - make_interval(months => $1)")
.bind(months as i32)
.execute(pool)
.await?;
Ok(res.rows_affected())
}

View File

@@ -0,0 +1,316 @@
//! Sessions and credential verification.
//!
//! Credentials are shared with helexa.ai (D2): a person has one helexa
//! account, and the same email and password work on both properties. That
//! is what lets an investor evaluate the platform they are being asked to
//! back.
//!
//! Sessions are **not** shared. helexa-upstream's `sessions` back the
//! public SPA, whose token lives in `localStorage` in an application that
//! renders markdown, runs a chat loop and fetches remote pages on the
//! user's behalf. Honouring those tokens here would put confidential
//! documents one script injection away from disclosure. The cookie issued
//! here is `HttpOnly` (unreadable from JS at all), `Secure`, `SameSite=Lax`
//! and — importantly — **host-only**: no `Domain` attribute, so it is
//! never sent to helexa.ai.
use crate::config::SessionSettings;
use crate::crypto;
use crate::error::{AngelsError, Result};
use axum::http::HeaderMap;
use axum::http::header::{COOKIE, SET_COOKIE};
use chrono::{DateTime, Utc};
use sqlx::Row;
use sqlx::postgres::PgPool;
use uuid::Uuid;
/// An authenticated visitor.
#[derive(Debug, Clone)]
pub struct Session {
pub user_id: Uuid,
pub email: String,
}
/// A password hash to verify against when the account does not exist.
///
/// Without this, "unknown address" returns in microseconds while "known
/// address, wrong password" takes argon2's deliberate ~100 ms — which
/// turns the sign-in form into an oracle for testing whether a given
/// person has an account here. Since holding an account on *this* portal
/// implies being an invited investor, that leak is more sensitive than
/// usual. Verifying against a real hash equalises the timing.
const DUMMY_PHC: &str = "$argon2id$v=19$m=19456,t=2,p=1$c29tZXNhbHR2YWx1ZQ$\
DdIDPWMLmtGh1jFkRLRcOEjbvRcOUJ8yqIrxCDQGLnU";
/// Verify an email/password pair against the shared `public.users` table.
///
/// Returns `BadCredentials` for a wrong password, an unknown address, and
/// an unverified address alike — the caller must not be able to tell them
/// apart. Unverified accounts are refused because a grant attaches to
/// whoever holds the address: allowing sign-in before the address is
/// confirmed would let someone claim an invitation sent to another person
/// simply by registering their email.
pub async fn verify_credentials(pool: &PgPool, email: &str, password: &str) -> Result<Session> {
let row = sqlx::query(
"SELECT id, email::text AS email, password_hash, email_verified \
FROM public.users WHERE email = $1",
)
.bind(email)
.fetch_optional(pool)
.await?;
let Some(row) = row else {
// Burn the same time an existing account would have cost.
let _ = crypto::verify_password(password, DUMMY_PHC);
return Err(AngelsError::BadCredentials);
};
let phc: String = row.get("password_hash");
if !crypto::verify_password(password, &phc) {
return Err(AngelsError::BadCredentials);
}
if !row.get::<bool, _>("email_verified") {
return Err(AngelsError::BadCredentials);
}
Ok(Session {
user_id: row.get("id"),
email: row.get("email"),
})
}
/// Mint a session and return the raw cookie value. Only its sha256 is
/// stored, so a database disclosure does not yield usable sessions.
pub async fn issue_session(
pool: &PgPool,
user_id: Uuid,
ttl_secs: u64,
ip: Option<&str>,
user_agent: Option<&str>,
) -> Result<String> {
let raw = crypto::random_token();
let expires: DateTime<Utc> = Utc::now() + chrono::Duration::seconds(ttl_secs as i64);
sqlx::query(
"INSERT INTO sessions (token_hash, user_id, expires_at, ip, user_agent) \
VALUES ($1, $2, $3, $4, $5)",
)
.bind(crypto::sha256(&raw))
.bind(user_id)
.bind(expires)
.bind(ip)
.bind(user_agent)
.execute(pool)
.await?;
Ok(raw)
}
/// Resolve a request's cookie to a live session, enforcing both the
/// absolute expiry and the idle timeout, and touching `last_seen_at`.
pub async fn resolve_session(
pool: &PgPool,
headers: &HeaderMap,
settings: &SessionSettings,
) -> Option<Session> {
let raw = cookie_value(headers, &settings.cookie_name)?;
let hash = crypto::sha256(&raw);
let row = sqlx::query(
"SELECT s.user_id, u.email::text AS email \
FROM sessions s JOIN public.users u ON u.id = s.user_id \
WHERE s.token_hash = $1 \
AND s.expires_at > now() \
AND s.last_seen_at > now() - make_interval(secs => $2)",
)
.bind(&hash)
.bind(settings.idle_secs as f64)
.fetch_optional(pool)
.await
.ok()
.flatten()?;
// Touch last_seen so an active reader's session does not idle out
// mid-read. Failure here is not fatal to the request.
let _ = sqlx::query("UPDATE sessions SET last_seen_at = now() WHERE token_hash = $1")
.bind(&hash)
.execute(pool)
.await;
Some(Session {
user_id: row.get("user_id"),
email: row.get("email"),
})
}
/// Drop a session server-side (sign out). Revocation is real, not just a
/// cleared cookie.
pub async fn destroy_session(pool: &PgPool, headers: &HeaderMap, cookie_name: &str) {
if let Some(raw) = cookie_value(headers, cookie_name) {
let _ = sqlx::query("DELETE FROM sessions WHERE token_hash = $1")
.bind(crypto::sha256(&raw))
.execute(pool)
.await;
}
}
/// Build the `Set-Cookie` header value.
///
/// No `Domain` attribute: that makes the cookie host-only, so it is never
/// transmitted to helexa.ai or any other subdomain. `SameSite=Lax` keeps
/// it off cross-site requests while still surviving the ordinary case of
/// following a link from an email.
pub fn session_cookie(name: &str, value: &str, max_age: u64, secure: bool) -> String {
let mut c = format!("{name}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age={max_age}");
if secure {
c.push_str("; Secure");
}
c
}
/// The cookie that clears a session.
pub fn clearing_cookie(name: &str, secure: bool) -> String {
let mut c = format!("{name}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0");
if secure {
c.push_str("; Secure");
}
c
}
/// A short-lived cookie carrying a pending invite across the sign-in or
/// registration detour, so the visitor is not asked to paste the code
/// again after confirming their email.
pub fn pending_invite_cookie(value: &str, secure: bool) -> String {
let mut c = format!(
"angels_invite={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age={}",
3600 * 24
);
if secure {
c.push_str("; Secure");
}
c
}
/// Read one cookie out of a request's `Cookie` headers.
pub fn cookie_value(headers: &HeaderMap, name: &str) -> Option<String> {
for hv in headers.get_all(COOKIE).iter() {
let Ok(s) = hv.to_str() else { continue };
for part in s.split(';') {
let part = part.trim();
if let Some(rest) = part.strip_prefix(name)
&& let Some(v) = rest.strip_prefix('=')
{
return Some(v.to_string());
}
}
}
None
}
/// Client IP, honouring the edge proxy's `X-Forwarded-For`.
pub fn client_ip(headers: &HeaderMap) -> Option<String> {
headers
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.split(',').next())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
pub fn user_agent(headers: &HeaderMap) -> Option<String> {
headers
.get("user-agent")
.and_then(|v| v.to_str().ok())
.map(|s| s.chars().take(400).collect())
}
/// Helper for handlers that need to set a cookie on a redirect.
pub fn with_cookie(mut headers: HeaderMap, cookie: String) -> HeaderMap {
if let Ok(v) = cookie.parse() {
headers.append(SET_COOKIE, v);
}
headers
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::HeaderValue;
fn headers_with(cookie: &str) -> HeaderMap {
let mut h = HeaderMap::new();
h.insert(COOKIE, HeaderValue::from_str(cookie).unwrap());
h
}
#[test]
fn reads_a_cookie_among_several() {
let h = headers_with("foo=1; angels_session=abc123; bar=2");
assert_eq!(
cookie_value(&h, "angels_session").as_deref(),
Some("abc123")
);
assert_eq!(cookie_value(&h, "missing"), None);
}
#[test]
fn does_not_match_a_cookie_by_prefix() {
// "angels_session_other" must not satisfy a lookup for
// "angels_session" — a prefix match here would let an attacker
// who can set any cookie shadow the session name.
let h = headers_with("angels_session_other=nope");
assert_eq!(cookie_value(&h, "angels_session"), None);
}
#[test]
fn session_cookie_is_httponly_samesite_and_host_only() {
let c = session_cookie("angels_session", "tok", 3600, true);
assert!(c.contains("HttpOnly"), "{c}");
assert!(c.contains("Secure"), "{c}");
assert!(c.contains("SameSite=Lax"), "{c}");
// The absence of Domain is the whole point: a Domain=.helexa.ai
// cookie would travel to the public SPA.
assert!(!c.contains("Domain"), "cookie must be host-only: {c}");
}
#[test]
fn insecure_cookie_only_when_explicitly_asked() {
let c = session_cookie("angels_session", "tok", 60, false);
assert!(!c.contains("Secure"), "{c}");
}
#[test]
fn clearing_cookie_expires_immediately() {
let c = clearing_cookie("angels_session", true);
assert!(c.contains("Max-Age=0"), "{c}");
assert!(c.contains("HttpOnly"), "{c}");
}
#[test]
fn forwarded_for_takes_the_first_hop() {
let mut h = HeaderMap::new();
h.insert(
"x-forwarded-for",
HeaderValue::from_static("1.2.3.4, 10.0.0.1"),
);
assert_eq!(client_ip(&h).as_deref(), Some("1.2.3.4"));
}
#[test]
fn user_agent_is_bounded() {
let mut h = HeaderMap::new();
let long = "x".repeat(2000);
h.insert("user-agent", HeaderValue::from_str(&long).unwrap());
assert_eq!(user_agent(&h).map(|s| s.len()), Some(400));
}
#[test]
fn dummy_hash_is_a_valid_argon2_phc() {
// If this ever stopped parsing, the timing-equalisation branch
// would return instantly and reintroduce the enumeration oracle
// it exists to close.
assert!(!crypto::verify_password("anything", DUMMY_PHC));
assert!(
argon2::password_hash::PasswordHash::new(DUMMY_PHC).is_ok(),
"DUMMY_PHC must parse as a real argon2 hash"
);
}
}

View File

@@ -0,0 +1,184 @@
//! helexa-angels configuration: `helexa-angels.toml` via figment, with
//! `ANGELS_`-prefixed env overrides (the cortex/router/upstream
//! convention, e.g. `ANGELS_SERVER__LISTEN`, `ANGELS_DB__URL`).
use figment::{
Figment,
providers::{Env, Format, Toml},
};
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AngelsConfig {
#[serde(default)]
pub server: ServerSettings,
pub db: DbSettings,
#[serde(default)]
pub session: SessionSettings,
#[serde(default)]
pub site: SiteSettings,
#[serde(default)]
pub content: ContentSettings,
#[serde(default)]
pub upstream: UpstreamSettings,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerSettings {
#[serde(default = "default_listen")]
pub listen: String,
}
impl Default for ServerSettings {
fn default() -> Self {
Self {
listen: default_listen(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DbSettings {
/// The **same** database helexa-upstream uses — credential auth is
/// shared (D2). angels confines its own tables to the `angels` schema.
pub url: String,
#[serde(default = "default_max_connections")]
pub max_connections: u32,
}
/// `[session]` — the angels session realm, deliberately separate from
/// helexa.ai's. See `migrations/0001_init.sql` for why.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionSettings {
#[serde(default = "default_cookie_name")]
pub cookie_name: String,
/// Absolute lifetime.
#[serde(default = "default_session_ttl")]
pub ttl_secs: u64,
/// Idle timeout — a session untouched for this long is dead even if
/// its absolute lifetime has not expired.
#[serde(default = "default_session_idle")]
pub idle_secs: u64,
/// `Secure` attribute on the cookie. Only ever false for local dev
/// over plain HTTP; production is TLS-only.
#[serde(default = "default_true")]
pub secure: bool,
}
impl Default for SessionSettings {
fn default() -> Self {
Self {
cookie_name: default_cookie_name(),
ttl_secs: default_session_ttl(),
idle_secs: default_session_idle(),
secure: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SiteSettings {
/// Public origin, used to build absolute links (invite URLs, mail).
#[serde(default = "default_base_url")]
pub base_url: String,
/// Where expressions of interest are routed.
#[serde(default = "default_contact")]
pub contact_email: String,
}
impl Default for SiteSettings {
fn default() -> Self {
Self {
base_url: default_base_url(),
contact_email: default_contact(),
}
}
}
/// `[content]` — where round documents live on disk.
///
/// Deliberately outside any web root and outside the source repository:
/// `helexa/helexa` is open source, so a business plan committed there is
/// a business plan published.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContentSettings {
#[serde(default = "default_content_dir")]
pub dir: String,
}
impl Default for ContentSettings {
fn default() -> Self {
Self {
dir: default_content_dir(),
}
}
}
/// `[upstream]` — helexa-upstream, reached over the mesh.
///
/// Registration is delegated there rather than reimplemented: upstream
/// already owns password policy, argon2 parameters, verification email,
/// unverified-signup reaping and registration fingerprinting. Two
/// divergent implementations against one `users` table is a defect
/// waiting to happen.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpstreamSettings {
#[serde(default = "default_upstream_url")]
pub base_url: String,
#[serde(default = "default_upstream_timeout")]
pub timeout_secs: u64,
}
impl Default for UpstreamSettings {
fn default() -> Self {
Self {
base_url: default_upstream_url(),
timeout_secs: default_upstream_timeout(),
}
}
}
fn default_listen() -> String {
"127.0.0.1:8092".into()
}
fn default_max_connections() -> u32 {
5
}
fn default_cookie_name() -> String {
"angels_session".into()
}
fn default_session_ttl() -> u64 {
7 * 24 * 3600
}
fn default_session_idle() -> u64 {
12 * 3600
}
fn default_true() -> bool {
true
}
fn default_base_url() -> String {
"https://angels.helexa.ai".into()
}
fn default_contact() -> String {
"angels@helexa.ai".into()
}
fn default_content_dir() -> String {
"/var/lib/helexa-angels/content".into()
}
fn default_upstream_url() -> String {
"http://localhost:8090".into()
}
fn default_upstream_timeout() -> u64 {
30
}
impl AngelsConfig {
pub fn load(path: impl AsRef<Path>) -> anyhow::Result<Self> {
let cfg: Self = Figment::new()
.merge(Toml::file(path.as_ref()))
.merge(Env::prefixed("ANGELS_").split("__"))
.extract()?;
Ok(cfg)
}
}

View File

@@ -0,0 +1,123 @@
//! Hashing and secret generation.
//!
//! Mirrors `helexa_upstream::crypto` deliberately — passwords are verified
//! against hashes **that service wrote**, so the algorithm and parameter
//! set must match. argon2id with `Argon2::default()` on both sides; if
//! upstream ever changes its parameters, this must change with it.
//!
//! High-entropy secrets we mint ourselves (session tokens, invite codes)
//! are stored as sha256 only.
use argon2::Argon2;
use argon2::password_hash::{PasswordHash, PasswordVerifier};
use rand::RngCore;
use sha2::{Digest, Sha256};
/// sha256 of `input` as raw bytes, matching the `BYTEA` columns.
pub fn sha256(input: &str) -> Vec<u8> {
let mut h = Sha256::new();
h.update(input.as_bytes());
h.finalize().to_vec()
}
/// Verify a password against a stored argon2 PHC hash from `public.users`.
///
/// `false` on any mismatch or malformed hash — never panics, and never
/// distinguishes "no such user" from "wrong password" to the caller.
pub fn verify_password(password: &str, phc: &str) -> bool {
match PasswordHash::new(phc) {
Ok(parsed) => Argon2::default()
.verify_password(password.as_bytes(), &parsed)
.is_ok(),
Err(_) => false,
}
}
/// A fresh URL-safe 256-bit secret. Callers store only `sha256` of it.
pub fn random_token() -> String {
let mut bytes = [0u8; 32];
rand::rngs::OsRng.fill_bytes(&mut bytes);
base62(&bytes)
}
/// An invite code. Long enough that guessing is not a threat model even
/// before rate limiting, and it is only ever half the story — redeeming it
/// still requires a verified account (see `migrations/0001_init.sql`).
pub fn generate_invite_code() -> String {
let mut bytes = [0u8; 24];
rand::rngs::OsRng.fill_bytes(&mut bytes);
base62(&bytes)
}
/// base62 (0-9A-Za-z) — URL and clipboard friendly, no padding.
fn base62(bytes: &[u8]) -> String {
const ALPHABET: &[u8] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
let mut num = bytes.to_vec();
let mut out = Vec::new();
while num.iter().any(|&b| b != 0) {
let mut rem = 0u32;
for byte in num.iter_mut() {
let acc = (rem << 8) | u32::from(*byte);
*byte = (acc / 62) as u8;
rem = acc % 62;
}
out.push(ALPHABET[rem as usize]);
}
if out.is_empty() {
out.push(ALPHABET[0]);
}
out.reverse();
String::from_utf8(out).unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sha256_is_stable_and_32_bytes() {
let a = sha256("hello");
assert_eq!(a.len(), 32);
assert_eq!(a, sha256("hello"));
assert_ne!(a, sha256("hello "));
}
#[test]
fn tokens_are_unique_and_url_safe() {
let a = random_token();
let b = random_token();
assert_ne!(a, b);
assert!(a.chars().all(|c| c.is_ascii_alphanumeric()), "{a}");
assert!(a.len() > 30, "token too short: {}", a.len());
}
#[test]
fn invite_codes_are_unguessable_length() {
let c = generate_invite_code();
assert!(c.chars().all(|c| c.is_ascii_alphanumeric()), "{c}");
// 24 random bytes in base62 lands around 32 characters; anything
// markedly shorter would mean the encoder dropped entropy.
assert!(c.len() >= 28, "invite code too short: {} ({c})", c.len());
}
#[test]
fn malformed_password_hash_is_rejected_not_panicked() {
assert!(!verify_password("anything", "not-a-phc-string"));
assert!(!verify_password("anything", ""));
}
#[test]
fn verifies_a_hash_produced_the_way_upstream_produces_them() {
// Guards the shared-credential contract: if upstream's argon2
// parameters and ours ever diverge, this fails.
use argon2::password_hash::rand_core::OsRng as ArgonOsRng;
use argon2::password_hash::{PasswordHasher, SaltString};
let salt = SaltString::generate(&mut ArgonOsRng);
let phc = Argon2::default()
.hash_password(b"correct horse battery staple", &salt)
.unwrap()
.to_string();
assert!(verify_password("correct horse battery staple", &phc));
assert!(!verify_password("wrong password", &phc));
}
}

View File

@@ -0,0 +1,60 @@
//! PostgreSQL pool + embedded migrations, in a dedicated schema.
//!
//! angels shares helexa-upstream's database so that credential auth is
//! genuinely shared (D2) — a cross-database join against `users` is not
//! possible, so "same database" is a requirement, not a convenience.
//!
//! Sharing it safely needs one precaution. Both services call
//! `sqlx::migrate!`, and sqlx records applied migrations in an
//! **unqualified** `_sqlx_migrations` table. Two migrators in one schema
//! would therefore write to the same bookkeeping table, each seeing the
//! other's versions as unknown and its checksums as corrupt. Giving angels
//! its own schema gives it its own `_sqlx_migrations`.
//!
//! Every pooled connection runs with `search_path = angels, public`, so
//! angels' own unqualified names resolve to its schema while
//! `public.users` stays reachable for the credential join.
use anyhow::{Context, Result};
use sqlx::postgres::{PgPool, PgPoolOptions};
use sqlx::{Executor, PgConnection};
/// The schema angels owns. Nothing outside it is ever written by this
/// service — upstream-owned tables are read-only from here.
pub const SCHEMA: &str = "angels";
/// Connect, ensure the schema exists, pin `search_path`, and migrate.
pub async fn connect_and_migrate(url: &str, max_connections: u32) -> Result<PgPool> {
// Bootstrap on a throwaway connection: the schema must exist before a
// pooled connection can set `search_path` to it.
{
use sqlx::Connection;
let mut conn = PgConnection::connect(url)
.await
.with_context(|| "connecting to PostgreSQL (schema bootstrap)")?;
conn.execute(format!("CREATE SCHEMA IF NOT EXISTS {SCHEMA}").as_str())
.await
.with_context(|| format!("creating schema {SCHEMA}"))?;
let _ = conn.close().await;
}
let pool = PgPoolOptions::new()
.max_connections(max_connections)
.after_connect(|conn, _meta| {
Box::pin(async move {
conn.execute(format!("SET search_path = {SCHEMA}, public").as_str())
.await?;
Ok(())
})
})
.connect(url)
.await
.with_context(|| "connecting to PostgreSQL")?;
sqlx::migrate!("./migrations")
.run(&pool)
.await
.with_context(|| "running angels migrations")?;
Ok(pool)
}

View File

@@ -0,0 +1,85 @@
//! 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>;

View File

@@ -0,0 +1,149 @@
//! Round entitlement.
//!
//! Access attaches to a **user**, never to a URL or a code. That is the
//! property the whole confidentiality model rests on: an unguessable
//! content link, once forwarded, grants anonymous access forever and
//! cannot be withdrawn; a grant names one person, is revocable, and leaves
//! a trail.
use crate::error::Result;
use serde::Serialize;
use sqlx::Row;
use sqlx::postgres::PgPool;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize)]
pub struct RoundSummary {
pub slug: String,
pub title: String,
pub framing_label: String,
pub status: String,
pub state: String,
pub granted_at: String,
}
/// Rounds this user may see. `pending` grants are included so an
/// awaiting-approval visitor is told that, rather than being shown an
/// empty portal that looks like a mistake.
pub async fn rounds_for_user(pool: &PgPool, user_id: Uuid) -> Result<Vec<RoundSummary>> {
let rows = sqlx::query(
"SELECT r.slug, r.title, r.framing_label, r.status, g.state, \
to_char(g.granted_at, 'YYYY-MM-DD') AS granted_at \
FROM grants g JOIN rounds r ON r.slug = g.round_slug \
WHERE g.user_id = $1 AND g.state IN ('active', 'pending') \
AND r.status <> 'draft' \
ORDER BY g.granted_at DESC",
)
.bind(user_id)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|r| RoundSummary {
slug: r.get("slug"),
title: r.get("title"),
framing_label: r.get("framing_label"),
status: r.get("status"),
state: r.get("state"),
granted_at: r.get("granted_at"),
})
.collect())
}
/// Whether this user may read this round's documents right now.
///
/// Deliberately strict: the grant must be `active` (not `pending`, not
/// `revoked`) and the round must not be a draft. A draft round is one
/// whose content is still being written, and half-written material is
/// exactly what should not reach an investor.
pub async fn has_access(pool: &PgPool, user_id: Uuid, round_slug: &str) -> Result<bool> {
let row = sqlx::query(
"SELECT 1 AS ok FROM grants g JOIN rounds r ON r.slug = g.round_slug \
WHERE g.user_id = $1 AND g.round_slug = $2 \
AND g.state = 'active' AND r.status IN ('open', 'closed')",
)
.bind(user_id)
.bind(round_slug)
.fetch_optional(pool)
.await?;
Ok(row.is_some())
}
/// Create (or re-activate) a grant. Idempotent: opening the same invite
/// twice is ordinary behaviour, not an error.
///
/// A previously **revoked** grant is deliberately NOT resurrected by
/// re-using an invite — revocation is an operator decision, and a code
/// still circulating must not undo it.
pub async fn upsert(
pool: &PgPool,
user_id: Uuid,
round_slug: &str,
invite_id: Option<Uuid>,
active: bool,
) -> Result<String> {
let state = if active { "active" } else { "pending" };
let row = sqlx::query(
"INSERT INTO grants (user_id, round_slug, invite_id, state) \
VALUES ($1, $2, $3, $4) \
ON CONFLICT (user_id, round_slug) DO UPDATE \
SET state = CASE WHEN grants.state = 'revoked' THEN 'revoked' \
ELSE EXCLUDED.state END \
RETURNING state",
)
.bind(user_id)
.bind(round_slug)
.bind(invite_id)
.bind(state)
.fetch_one(pool)
.await?;
Ok(row.get("state"))
}
/// Withdraw one person's access.
pub async fn revoke(pool: &PgPool, email: &str, round_slug: &str) -> Result<u64> {
let res = sqlx::query(
"UPDATE grants SET state = 'revoked', revoked_at = now() \
WHERE round_slug = $1 \
AND user_id = (SELECT id FROM public.users WHERE email = $2)",
)
.bind(round_slug)
.bind(email)
.execute(pool)
.await?;
Ok(res.rows_affected())
}
/// Approve a pending grant (used when a round runs with `auto_grant`
/// disabled).
pub async fn approve(pool: &PgPool, email: &str, round_slug: &str, by: &str) -> Result<u64> {
let res = sqlx::query(
"UPDATE grants SET state = 'active', approved_by = $3 \
WHERE round_slug = $1 AND state = 'pending' \
AND user_id = (SELECT id FROM public.users WHERE email = $2)",
)
.bind(round_slug)
.bind(email)
.bind(by)
.execute(pool)
.await?;
Ok(res.rows_affected())
}
/// Everyone holding a grant on a round, for the operator listing.
pub async fn holders(pool: &PgPool, round_slug: &str) -> Result<Vec<(String, String, String)>> {
let rows = sqlx::query(
"SELECT u.email::text AS email, g.state, \
to_char(g.granted_at, 'YYYY-MM-DD HH24:MI') AS granted_at \
FROM grants g JOIN public.users u ON u.id = g.user_id \
WHERE g.round_slug = $1 ORDER BY g.granted_at",
)
.bind(round_slug)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|r| (r.get("email"), r.get("state"), r.get("granted_at")))
.collect())
}

View File

@@ -0,0 +1,208 @@
//! Invitation codes.
//!
//! A reusable code is a **distribution** mechanism, not a security
//! boundary. It will be forwarded — to a spouse, an accountant, a
//! co-investor — and the design assumes so rather than pretending
//! otherwise. What the code buys is a chance to identify yourself;
//! confidentiality then rests on two things it cannot bypass:
//!
//! 1. documents are served only to an authenticated user holding a grant;
//! 2. every view is attributed and logged.
//!
//! Someone who forwards a code produces another *named* account, not
//! anonymous access. That is strictly better than an unguessable content
//! URL, which produces anonymous access and cannot be revoked once shared.
use crate::auth;
use crate::crypto;
use crate::error::Result;
use crate::state::AppState;
use axum::extract::{Path, State};
use axum::http::{HeaderMap, StatusCode, header};
use axum::response::{IntoResponse, Response};
use sqlx::Row;
use sqlx::postgres::PgPool;
use uuid::Uuid;
pub struct Invite {
pub id: Uuid,
pub round_slug: String,
pub label: String,
pub round_title: String,
pub auto_grant: bool,
}
/// Look up a live invite by its plaintext code.
///
/// Returns `None` for unknown, revoked, expired and exhausted codes
/// alike — the caller must not be able to tell them apart, or the portal
/// becomes an oracle for probing which codes exist.
pub async fn lookup(pool: &PgPool, code: &str) -> Option<Invite> {
let row = sqlx::query(
"SELECT i.id, i.round_slug, i.label, r.title, r.auto_grant \
FROM invites i JOIN rounds r ON r.slug = i.round_slug \
WHERE i.code_hash = $1 \
AND i.revoked_at IS NULL \
AND (i.expires_at IS NULL OR i.expires_at > now()) \
AND (i.max_uses IS NULL OR i.used_count < i.max_uses) \
AND r.status <> 'draft'",
)
.bind(crypto::sha256(code))
.fetch_optional(pool)
.await
.ok()
.flatten()?;
Some(Invite {
id: row.get("id"),
round_slug: row.get("round_slug"),
label: row.get("label"),
round_title: row.get("title"),
auto_grant: row.get("auto_grant"),
})
}
/// `GET /i/{code}` — the entry point an operator actually sends.
///
/// Signed in already: redeem immediately. Not signed in: stash the code in
/// a short-lived cookie and send them to sign in, so they are not asked to
/// find the link again afterwards.
pub async fn enter(
State(state): State<AppState>,
headers: HeaderMap,
Path(code): Path<String>,
) -> Result<Response> {
let Some(invite) = lookup(&state.pool, &code).await else {
// Identical to any other dead link. Nothing here confirms whether
// a code ever existed.
return Ok(crate::error::AngelsError::NotFound.into_response());
};
if let Some(session) = auth::resolve_session(&state.pool, &headers, &state.config.session).await
{
let dest = redeem(&state, &invite, session.user_id).await?;
return Ok((StatusCode::SEE_OTHER, [(header::LOCATION, dest)]).into_response());
}
let out = auth::with_cookie(
HeaderMap::new(),
auth::pending_invite_cookie(&code, state.config.session.secure),
);
Ok((
StatusCode::SEE_OTHER,
out,
[(header::LOCATION, "/signin".to_string())],
)
.into_response())
}
/// Turn an invite into a grant for a now-known user, and count the use.
async fn redeem(state: &AppState, invite: &Invite, user_id: Uuid) -> Result<String> {
let landed = crate::grants::upsert(
&state.pool,
user_id,
&invite.round_slug,
Some(invite.id),
invite.auto_grant,
)
.await?;
let _ = sqlx::query("UPDATE invites SET used_count = used_count + 1 WHERE id = $1")
.bind(invite.id)
.execute(&state.pool)
.await;
tracing::info!(
round = %invite.round_slug,
state = %landed,
"invite redeemed"
);
Ok(match landed.as_str() {
"active" => format!("/r/{}", invite.round_slug),
// pending (awaiting approval) or revoked — the portal explains.
_ => "/".to_string(),
})
}
/// Redeem whatever invite was waiting in the pending cookie, if any.
/// Returns where to send the visitor next.
pub async fn redeem_pending(
state: &AppState,
headers: &HeaderMap,
user_id: Uuid,
) -> Option<String> {
let code = auth::cookie_value(headers, "angels_invite")?;
let invite = lookup(&state.pool, &code).await?;
redeem(state, &invite, user_id).await.ok()
}
/// The round title behind a pending invite, so the sign-in page can say
/// what the visitor is signing in *for*.
pub async fn pending_invite_label(state: &AppState, headers: &HeaderMap) -> Option<String> {
let code = auth::cookie_value(headers, "angels_invite")?;
lookup(&state.pool, &code).await.map(|i| i.round_title)
}
/// Mint a code. The plaintext is returned once, here; only its hash is
/// stored, so a database disclosure does not yield working invitations.
pub async fn mint(
pool: &PgPool,
round_slug: &str,
label: &str,
max_uses: Option<i32>,
expires_days: Option<i64>,
) -> Result<String> {
let code = crypto::generate_invite_code();
let expires = expires_days.map(|d| chrono::Utc::now() + chrono::Duration::days(d));
sqlx::query(
"INSERT INTO invites (code_hash, label, round_slug, max_uses, expires_at) \
VALUES ($1, $2, $3, $4, $5)",
)
.bind(crypto::sha256(&code))
.bind(label)
.bind(round_slug)
.bind(max_uses)
.bind(expires)
.execute(pool)
.await?;
Ok(code)
}
/// Stop a code issuing further grants. Existing grants are untouched —
/// revoking a code and revoking a person are different acts.
pub async fn revoke(pool: &PgPool, label: &str) -> Result<u64> {
let res = sqlx::query(
"UPDATE invites SET revoked_at = now() WHERE label = $1 AND revoked_at IS NULL",
)
.bind(label)
.execute(pool)
.await?;
Ok(res.rows_affected())
}
/// Operator listing. Never shows a code — we do not hold the plaintext.
pub async fn list(pool: &PgPool) -> Result<Vec<(String, String, String, i32)>> {
let rows = sqlx::query(
"SELECT label, round_slug, \
CASE WHEN revoked_at IS NOT NULL THEN 'revoked' \
WHEN expires_at IS NOT NULL AND expires_at < now() THEN 'expired' \
WHEN max_uses IS NOT NULL AND used_count >= max_uses THEN 'exhausted' \
ELSE 'live' END AS status, \
used_count \
FROM invites ORDER BY created_at DESC",
)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|r| {
(
r.get("label"),
r.get("round_slug"),
r.get("status"),
r.get("used_count"),
)
})
.collect())
}

View File

@@ -0,0 +1,134 @@
//! helexa-angels — the investor portal served at `angels.helexa.ai`.
//!
//! # Why this is a separate service
//!
//! `helexa.ai` is a static SPA: `vite build` → `dist` → nginx. Every
//! string in it, including all 42 locale bundles, is compiled into
//! `/assets/index-*.js` and served to anyone who asks. A React route guard
//! therefore gates *navigation*, not *access* — confidential material
//! placed in that bundle is one `curl` away from disclosure. That is a
//! property of static hosting, not a routing bug to be fixed with a better
//! guard.
//!
//! This service inverts it. Pages are assembled on the server, so the only
//! thing an unauthenticated request can obtain is a sign-in form. Three
//! further properties follow from that choice rather than being bolted on:
//!
//! - **The audit is honest.** One document render is one server request,
//! so [`audit`] 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.** The viewer's identity is in scope at
//! render time, so every page carries it.
//! - **No second asset pipeline**, and no i18n machinery — the portal is
//! English-only by decision, and all its wording is operator-reviewed.
//!
//! # What is shared with helexa.ai, and what is not
//!
//! Credentials are shared: one helexa account signs in on both properties,
//! so an investor can evaluate the platform they are being asked to back.
//! Sessions are **not** — see [`auth`] for why that separation is
//! load-bearing rather than fussy.
pub mod audit;
pub mod auth;
pub mod config;
pub mod crypto;
pub mod db;
pub mod error;
pub mod grants;
pub mod invites;
pub mod state;
pub mod templates;
pub mod upstream;
pub mod web;
use anyhow::Result;
use config::AngelsConfig;
use state::AppState;
use tower_http::trace::TraceLayer;
/// The entity contractually responsible for the current round.
///
/// Bears Lairs EOOD is helexa's operator zero; Helexa AI (a Bulgarian VCC)
/// is not yet registered. Named in the footer of every page and in the
/// privacy note, because a confidential document should always say who is
/// holding the material.
pub const ENTITY_NAME: &str = "Bears Lairs EOOD";
/// How long access records are kept. Stated in the privacy note, enforced
/// by the retention sweep — the two must not drift apart.
pub const RETENTION_MONTHS: i64 = 24;
/// Build the axum application.
pub fn build_app(state: AppState) -> axum::Router {
axum::Router::new()
.merge(web::router())
.route("/i/{code}", axum::routing::get(invites::enter))
.fallback(web::fallback)
// No CORS layer, deliberately: nothing here is meant to be
// fetched by another origin's JavaScript. The absence is the
// policy.
.layer(TraceLayer::new_for_http())
.with_state(state)
}
/// Start the service.
pub async fn run(config: AngelsConfig) -> Result<()> {
let pool = db::connect_and_migrate(&config.db.url, config.db.max_connections).await?;
let listen = config.server.listen.clone();
let state = AppState::new(pool, config);
spawn_session_reaper(&state);
spawn_retention_sweep(&state);
let addr = listen.parse::<std::net::SocketAddr>()?;
tracing::info!("helexa-angels listening on {addr}");
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, build_app(state)).await?;
Ok(())
}
/// Delete dead sessions. Expiry is enforced on every lookup, so this is
/// hygiene rather than a control — it stops the table growing without
/// bound.
fn spawn_session_reaper(state: &AppState) {
let pool = state.pool.clone();
let idle = state.config.session.idle_secs as f64;
tokio::spawn(async move {
loop {
tokio::time::sleep(std::time::Duration::from_secs(3600)).await;
let res = sqlx::query(
"DELETE FROM sessions \
WHERE expires_at < now() \
OR last_seen_at < now() - make_interval(secs => $1)",
)
.bind(idle)
.execute(&pool)
.await;
match res {
Ok(r) if r.rows_affected() > 0 => {
tracing::debug!(n = r.rows_affected(), "reaped dead sessions")
}
Ok(_) => {}
Err(e) => tracing::warn!(error = %e, "session reap failed"),
}
}
});
}
/// Enforce the retention window on the access log. These rows identify
/// people; keeping them indefinitely is neither necessary nor defensible.
fn spawn_retention_sweep(state: &AppState) {
let pool = state.pool.clone();
tokio::spawn(async move {
loop {
tokio::time::sleep(std::time::Duration::from_secs(24 * 3600)).await;
match audit::prune(&pool, RETENTION_MONTHS).await {
Ok(n) if n > 0 => tracing::info!(pruned = n, "access records past retention"),
Ok(_) => {}
Err(e) => tracing::warn!(error = %e, "retention sweep failed"),
}
}
});
}

View File

@@ -0,0 +1,192 @@
use anyhow::Result;
use clap::{Parser, Subcommand};
use helexa_angels::config::AngelsConfig;
use tracing_subscriber::EnvFilter;
#[derive(Parser)]
#[command(name = "helexa-angels")]
#[command(about = "Confidential investor portal for helexa (angels.helexa.ai)")]
#[command(version)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Start the portal.
Serve {
#[arg(short, long, default_value = "/etc/helexa-angels/helexa-angels.toml")]
config: String,
},
/// Mint an invitation code and print it. The plaintext is shown only
/// here — only its hash is stored, so it cannot be recovered later.
Invite {
#[arg(short, long, default_value = "/etc/helexa-angels/helexa-angels.toml")]
config: String,
/// Round the code grants access to.
#[arg(long)]
round: String,
/// Human label, so codes are tellable apart in a listing.
#[arg(long)]
label: String,
/// Maximum redemptions. Omit for unlimited — reusability is the
/// point; this is a blast-radius control for a code that travels
/// further than intended.
#[arg(long)]
max_uses: Option<i32>,
/// Expire the code after this many days.
#[arg(long)]
expires_days: Option<i64>,
},
/// List invitation codes and their state. Never prints a code.
Invites {
#[arg(short, long, default_value = "/etc/helexa-angels/helexa-angels.toml")]
config: String,
},
/// Who holds access to a round.
Access {
#[arg(short, long, default_value = "/etc/helexa-angels/helexa-angels.toml")]
config: String,
#[arg(long)]
round: String,
},
/// Who has read what, most recent first.
Reads {
#[arg(short, long, default_value = "/etc/helexa-angels/helexa-angels.toml")]
config: String,
#[arg(long)]
round: Option<String>,
#[arg(long, default_value_t = 50)]
limit: i64,
},
/// Withdraw one person's access to a round.
Revoke {
#[arg(short, long, default_value = "/etc/helexa-angels/helexa-angels.toml")]
config: String,
#[arg(long)]
round: String,
/// Email address of the person to cut off.
#[arg(long)]
email: String,
},
/// Approve a pending grant (rounds running without auto-grant).
Approve {
#[arg(short, long, default_value = "/etc/helexa-angels/helexa-angels.toml")]
config: String,
#[arg(long)]
round: String,
#[arg(long)]
email: String,
},
/// Stop an invitation code issuing further grants. Existing grants are
/// untouched — revoking a code and revoking a person are different acts.
RevokeInvite {
#[arg(short, long, default_value = "/etc/helexa-angels/helexa-angels.toml")]
config: String,
#[arg(long)]
label: String,
},
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("info,helexa_angels=debug")),
)
.init();
match Cli::parse().command {
Commands::Serve { config } => {
helexa_angels::run(AngelsConfig::load(&config)?).await?;
}
Commands::Invite {
config,
round,
label,
max_uses,
expires_days,
} => {
let (pool, cfg) = open(&config).await?;
let code = helexa_angels::invites::mint(&pool, &round, &label, max_uses, expires_days)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
println!("{}/i/{}", cfg.site.base_url.trim_end_matches('/'), code);
eprintln!(
"\nThis link is shown once. Only its hash is stored, so it cannot be \
recovered — send it now or mint another."
);
}
Commands::Invites { config } => {
let (pool, _) = open(&config).await?;
let rows = helexa_angels::invites::list(&pool)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
println!("{:<28} {:<18} {:<10} USES", "LABEL", "ROUND", "STATUS");
for (label, round, status, uses) in rows {
println!("{label:<28} {round:<18} {status:<10} {uses}");
}
}
Commands::Access { config, round } => {
let (pool, _) = open(&config).await?;
let rows = helexa_angels::grants::holders(&pool, &round)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
println!("{:<40} {:<10} GRANTED", "EMAIL", "STATE");
for (email, state, granted) in rows {
println!("{email:<40} {state:<10} {granted}");
}
}
Commands::Reads {
config,
round,
limit,
} => {
let (pool, _) = open(&config).await?;
let rows = helexa_angels::audit::recent(&pool, round.as_deref(), limit).await?;
println!("{:<18} {:<36} {:<18} DOCUMENT", "WHEN", "WHO", "ROUND");
for (at, who, round, doc) in rows {
println!("{at:<18} {who:<36} {round:<18} {doc}");
}
}
Commands::Revoke {
config,
round,
email,
} => {
let (pool, _) = open(&config).await?;
let n = helexa_angels::grants::revoke(&pool, &email, &round)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
println!("revoked {n} grant(s) for {email} on {round}");
}
Commands::Approve {
config,
round,
email,
} => {
let (pool, _) = open(&config).await?;
let n = helexa_angels::grants::approve(&pool, &email, &round, "cli")
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
println!("approved {n} grant(s) for {email} on {round}");
}
Commands::RevokeInvite { config, label } => {
let (pool, _) = open(&config).await?;
let n = helexa_angels::invites::revoke(&pool, &label)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
println!("revoked {n} invite code(s) labelled {label}");
}
}
Ok(())
}
/// Shared setup for the CLI subcommands.
async fn open(path: &str) -> Result<(sqlx::postgres::PgPool, AngelsConfig)> {
let cfg = AngelsConfig::load(path)?;
let pool = helexa_angels::db::connect_and_migrate(&cfg.db.url, cfg.db.max_connections).await?;
Ok((pool, cfg))
}

View File

@@ -0,0 +1,38 @@
//! Shared application state.
use crate::config::AngelsConfig;
use sqlx::postgres::PgPool;
use std::sync::Arc;
#[derive(Clone)]
pub struct AppState {
pub pool: PgPool,
pub config: Arc<AngelsConfig>,
pub http: reqwest::Client,
}
impl AppState {
pub fn new(pool: PgPool, config: AngelsConfig) -> Self {
let http = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(config.upstream.timeout_secs))
.build()
.unwrap_or_default();
Self {
pool,
config: Arc::new(config),
http,
}
}
/// Context every template needs: branding, the contracting entity, and
/// the contact address. Kept in one place so a page cannot accidentally
/// render without the confidentiality footer.
pub fn base_context(&self) -> Vec<(&'static str, String)> {
vec![
("site_name", "helexa investor portal".to_string()),
("site_tagline", "investor portal".to_string()),
("entity_name", crate::ENTITY_NAME.to_string()),
("contact_email", self.config.site.contact_email.clone()),
]
}
}

View File

@@ -0,0 +1,79 @@
//! Server-side rendering.
//!
//! Templates are compiled into the binary with `include_str!` rather than
//! read from `/usr/share`: they are application chrome, not content, so
//! embedding them means the RPM ships one file and a template can never
//! drift out of sync with the code that fills it. Round *content* is a
//! different matter and does live on disk — see [`crate::content`].
use axum::http::StatusCode;
use minijinja::{Environment, context};
use std::sync::LazyLock;
static ENV: LazyLock<Environment<'static>> = LazyLock::new(|| {
let mut env = Environment::new();
// Auto-escape everything. Round content is the one place we render
// pre-sanitised HTML, and it goes through an explicit `|safe`.
env.set_auto_escape_callback(|_| minijinja::AutoEscape::Html);
env.add_template("base.html", include_str!("../templates/base.html"))
.expect("base.html is embedded and must compile");
env.add_template("signin.html", include_str!("../templates/signin.html"))
.expect("signin.html is embedded and must compile");
env.add_template("error.html", include_str!("../templates/error.html"))
.expect("error.html is embedded and must compile");
env.add_template("portal.html", include_str!("../templates/portal.html"))
.expect("portal.html is embedded and must compile");
env.add_template("account.html", include_str!("../templates/account.html"))
.expect("account.html is embedded and must compile");
env.add_template("privacy.html", include_str!("../templates/privacy.html"))
.expect("privacy.html is embedded and must compile");
env
});
/// Render a named template with the given context.
pub fn render(name: &str, ctx: minijinja::value::Value) -> Result<String, minijinja::Error> {
ENV.get_template(name)?.render(ctx)
}
/// The error page. Infallible by construction — an error while rendering
/// the error page would otherwise recurse, so this falls back to plain
/// text rather than propagating.
pub fn render_error(code: StatusCode, message: &str) -> String {
ENV.get_template("error.html")
.and_then(|t| t.render(context! { code => code.as_u16(), message => message }))
.unwrap_or_else(|_| format!("<!doctype html><title>{code}</title><p>{message}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_embedded_template_compiles() {
// Touching ENV forces the LazyLock, which panics on a bad
// template. This is the test that catches a syntax error before
// it reaches a request path.
let _ = ENV.get_template("base.html").expect("base");
let _ = ENV.get_template("signin.html").expect("signin");
let _ = ENV.get_template("portal.html").expect("portal");
let _ = ENV.get_template("account.html").expect("account");
let _ = ENV.get_template("privacy.html").expect("privacy");
}
#[test]
fn error_page_renders_and_escapes() {
let html = render_error(StatusCode::NOT_FOUND, "nothing here");
assert!(html.contains("404"));
assert!(html.contains("nothing here"));
assert!(html.contains("noindex"));
}
#[test]
fn error_page_escapes_hostile_input() {
let html = render_error(StatusCode::BAD_REQUEST, "<script>alert(1)</script>");
assert!(
!html.contains("<script>alert"),
"error message was not escaped: {html}"
);
}
}

View File

@@ -0,0 +1,48 @@
//! Client for helexa-upstream.
//!
//! Registration is delegated rather than reimplemented. helexa-upstream
//! already owns password policy, argon2 parameters, the verification email
//! (via Stalwart, from `no-reply@helexa.ai`), the unverified-signup reaper
//! and registration fingerprinting. Two implementations writing one
//! `users` table would drift, and the first symptom would be an account
//! that works on one property and not the other.
//!
//! The practical consequence is that a new investor confirms their address
//! through the ordinary helexa flow and then signs in here — which is also
//! correct, because it is one account for both properties by design.
use crate::state::AppState;
use anyhow::{Context, Result, bail};
use serde_json::json;
/// Register a new helexa account.
///
/// Upstream deliberately does not distinguish "created" from "address
/// already in use" (it no-ops on the unique-email conflict) so that this
/// endpoint cannot be used to enumerate accounts. We inherit that.
pub async fn register(state: &AppState, email: &str, password: &str) -> Result<()> {
let url = format!(
"{}/web/v1/register",
state.config.upstream.base_url.trim_end_matches('/')
);
let resp = state
.http
.post(&url)
.json(&json!({ "email": email, "password": password }))
.send()
.await
.with_context(|| format!("calling {url}"))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
bail!("upstream register returned {status}: {body}");
}
Ok(())
}
// The evaluation allocation that ships with a grant needs an endpoint
// upstream does not have yet (its allocation paths are either the signup
// grant or a redeemed top-up code). Added in A4 alongside the matching
// upstream handler, rather than left here calling a route that would 404
// on every grant.

View File

@@ -0,0 +1,407 @@
//! HTTP surface.
//!
//! Every response is assembled here, on the server. An unauthenticated
//! request can reach exactly three things: the sign-in page, the
//! registration page, and the privacy note. There is no bundle to scrape
//! and no API that hands content to anything holding a token — which is
//! the entire reason this service exists separately from the helexa.ai
//! SPA.
use crate::auth::{self, Session};
use crate::error::{AngelsError, Result};
use crate::state::AppState;
use crate::templates;
use axum::extract::{Query, State};
use axum::http::{HeaderMap, StatusCode, header};
use axum::response::{Html, IntoResponse, Redirect, Response};
use axum::routing::get;
use axum::{Form, Router};
use minijinja::value::Value;
use serde::Deserialize;
use std::collections::BTreeMap;
pub fn router() -> Router<AppState> {
Router::new()
.route("/", get(portal))
.route("/signin", get(signin_page).post(signin_submit))
.route("/register", get(register_page).post(register_submit))
.route("/signout", get(signout))
.route("/account", get(account))
.route("/privacy", get(privacy))
.route("/health", get(health))
}
/// Liveness only — deliberately says nothing about rounds, grants, or
/// whether the content tree is loaded. It is reachable unauthenticated,
/// so it must not become a status oracle.
async fn health() -> impl IntoResponse {
(StatusCode::OK, "ok")
}
/// Build a template context from the state's base plus per-page extras.
fn ctx(state: &AppState, extra: Vec<(&str, Value)>) -> Value {
let mut map: BTreeMap<String, Value> = BTreeMap::new();
for (k, v) in state.base_context() {
map.insert(k.to_string(), Value::from(v));
}
for (k, v) in extra {
map.insert(k.to_string(), v);
}
Value::from_object(map)
}
#[derive(Debug, Deserialize, Default)]
pub struct AuthQuery {
/// Where to go after signing in, preserved across the auth detour.
pub next: Option<String>,
pub error: Option<String>,
pub notice: Option<String>,
}
impl AuthQuery {
/// Re-encode as a query string for the tab links, so `?next=` is not
/// lost when the visitor switches between sign in and register.
fn qs(&self) -> String {
match self.next.as_deref().filter(|s| is_safe_next(s)) {
Some(n) => format!("?next={}", urlencode(n)),
None => String::new(),
}
}
}
/// Only ever redirect within this site.
///
/// An unchecked `next` is an open redirect: a link to
/// `angels.helexa.ai/signin?next=https://evil.example` would bounce a
/// signed-in investor off-site, and the address bar would have said
/// `helexa` the whole way. Requiring a single leading slash (and rejecting
/// `//host`, which browsers read as protocol-relative) confines it.
fn is_safe_next(next: &str) -> bool {
next.starts_with('/') && !next.starts_with("//") && !next.contains("://")
}
fn urlencode(s: &str) -> String {
s.bytes()
.map(|b| match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' => {
(b as char).to_string()
}
_ => format!("%{b:02X}"),
})
.collect()
}
// ── Portal ──────────────────────────────────────────────────────────
async fn portal(State(state): State<AppState>, headers: HeaderMap) -> Result<Response> {
let Some(session) = auth::resolve_session(&state.pool, &headers, &state.config.session).await
else {
return Ok(Redirect::to("/signin").into_response());
};
let rounds = crate::grants::rounds_for_user(&state.pool, session.user_id).await?;
let body = templates::render(
"portal.html",
ctx(
&state,
vec![
("user_email", Value::from(session.email)),
("rounds", Value::from_serialize(&rounds)),
],
),
)?;
Ok(Html(body).into_response())
}
async fn account(State(state): State<AppState>, headers: HeaderMap) -> Result<Response> {
let Some(session) = auth::resolve_session(&state.pool, &headers, &state.config.session).await
else {
return Ok(Redirect::to("/signin?next=/account").into_response());
};
let rounds = crate::grants::rounds_for_user(&state.pool, session.user_id).await?;
let body = templates::render(
"account.html",
ctx(
&state,
vec![
("user_email", Value::from(session.email)),
("rounds", Value::from_serialize(&rounds)),
],
),
)?;
Ok(Html(body).into_response())
}
async fn privacy(State(state): State<AppState>, headers: HeaderMap) -> Result<Response> {
let session = auth::resolve_session(&state.pool, &headers, &state.config.session).await;
let body = templates::render(
"privacy.html",
ctx(
&state,
vec![
(
"user_email",
session.map(|s| Value::from(s.email)).unwrap_or_default(),
),
("retention_months", Value::from(crate::RETENTION_MONTHS)),
],
),
)?;
Ok(Html(body).into_response())
}
// ── Sign in / register ──────────────────────────────────────────────
fn auth_page(
state: &AppState,
q: &AuthQuery,
tab: &str,
invite_label: Option<String>,
) -> Result<Response> {
let (heading, subheading) = match (tab, invite_label.as_deref()) {
("register", Some(_)) => (
"Create your account",
"You've been invited to review confidential material. Create an \
account and it will be attached to you.",
),
("register", None) => (
"Create your account",
"One account covers this portal and helexa.ai itself.",
),
(_, Some(_)) => (
"Sign in to continue",
"You've been invited to review confidential material. Sign in \
and it will be attached to your account.",
),
_ => (
"Sign in",
"This portal holds confidential material prepared for named \
recipients.",
),
};
let body = templates::render(
"signin.html",
ctx(
state,
vec![
("tab", Value::from(tab)),
("qs", Value::from(q.qs())),
("heading", Value::from(heading)),
("subheading", Value::from(subheading)),
(
"invite_label",
invite_label.map(Value::from).unwrap_or_default(),
),
(
"error",
q.error.clone().map(Value::from).unwrap_or_default(),
),
(
"notice",
q.notice.clone().map(Value::from).unwrap_or_default(),
),
],
),
)?;
Ok(Html(body).into_response())
}
async fn signin_page(
State(state): State<AppState>,
headers: HeaderMap,
Query(q): Query<AuthQuery>,
) -> Result<Response> {
// Already signed in — skip the form.
if auth::resolve_session(&state.pool, &headers, &state.config.session)
.await
.is_some()
{
let dest = q.next.filter(|n| is_safe_next(n)).unwrap_or("/".into());
return Ok(Redirect::to(&dest).into_response());
}
let label = crate::invites::pending_invite_label(&state, &headers).await;
auth_page(&state, &q, "signin", label)
}
async fn register_page(
State(state): State<AppState>,
headers: HeaderMap,
Query(q): Query<AuthQuery>,
) -> Result<Response> {
let label = crate::invites::pending_invite_label(&state, &headers).await;
auth_page(&state, &q, "register", label)
}
#[derive(Debug, Deserialize)]
pub struct Credentials {
pub email: String,
pub password: String,
}
async fn signin_submit(
State(state): State<AppState>,
headers: HeaderMap,
Query(q): Query<AuthQuery>,
Form(form): Form<Credentials>,
) -> Result<Response> {
let session = match auth::verify_credentials(&state.pool, &form.email, &form.password).await {
Ok(s) => s,
Err(_) => {
// One message for every failure mode — see verify_credentials.
let dest = format!(
"/signin?error={}{}",
urlencode(
"That email address and password don't match, or the address isn't confirmed yet."
),
q.next
.as_deref()
.filter(|n| is_safe_next(n))
.map(|n| format!("&next={}", urlencode(n)))
.unwrap_or_default()
);
return Ok(Redirect::to(&dest).into_response());
}
};
complete_signin(&state, &headers, session, q.next.as_deref()).await
}
/// Issue the session, redeem any pending invite, and land the visitor.
async fn complete_signin(
state: &AppState,
headers: &HeaderMap,
session: Session,
next: Option<&str>,
) -> Result<Response> {
let raw = auth::issue_session(
&state.pool,
session.user_id,
state.config.session.ttl_secs,
auth::client_ip(headers).as_deref(),
auth::user_agent(headers).as_deref(),
)
.await?;
// An invitation waiting in the pending cookie becomes a grant now that
// we know who the visitor is. This is the moment an anonymous, freely
// forwardable code turns into a named, revocable, audited grant.
let landed = crate::invites::redeem_pending(state, headers, session.user_id).await;
let mut out = HeaderMap::new();
out = auth::with_cookie(
out,
auth::session_cookie(
&state.config.session.cookie_name,
&raw,
state.config.session.ttl_secs,
state.config.session.secure,
),
);
// The invite has been consumed either way; do not leave it lying about.
out = auth::with_cookie(
out,
auth::clearing_cookie("angels_invite", state.config.session.secure),
);
let dest = next
.filter(|n| is_safe_next(n))
.map(|n| n.to_string())
.or(landed)
.unwrap_or_else(|| "/".into());
Ok((StatusCode::SEE_OTHER, out, [(header::LOCATION, dest)]).into_response())
}
async fn register_submit(
State(state): State<AppState>,
Form(form): Form<Credentials>,
) -> Result<Response> {
if form.password.chars().count() < 8 {
return Ok(Redirect::to(&format!(
"/register?error={}",
urlencode("Please choose a password of at least 8 characters.")
))
.into_response());
}
// Delegated to helexa-upstream rather than reimplemented here: it owns
// password policy, argon2 parameters, the verification email, the
// unverified-signup reaper and fingerprinting. Two implementations
// writing one `users` table is a defect waiting to happen.
match crate::upstream::register(&state, &form.email, &form.password).await {
Ok(()) => Ok(Redirect::to(&format!(
"/signin?notice={}",
urlencode(
"Check your email — we've sent you a link to confirm the address. \
Once confirmed, sign in here."
)
))
.into_response()),
Err(e) => {
tracing::warn!(error = %e, "registration via upstream failed");
Ok(Redirect::to(&format!(
"/register?error={}",
urlencode(
"We couldn't create that account. If you already have a helexa \
account, sign in instead."
)
))
.into_response())
}
}
}
async fn signout(State(state): State<AppState>, headers: HeaderMap) -> Result<Response> {
auth::destroy_session(&state.pool, &headers, &state.config.session.cookie_name).await;
let out = auth::with_cookie(
HeaderMap::new(),
auth::clearing_cookie(
&state.config.session.cookie_name,
state.config.session.secure,
),
);
Ok((StatusCode::SEE_OTHER, out, [(header::LOCATION, "/signin")]).into_response())
}
/// Not found, rendered as a page rather than an empty 404.
pub async fn fallback() -> Response {
AngelsError::NotFound.into_response()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_off_site_redirects() {
assert!(is_safe_next("/r/tt-eap-2026"));
assert!(is_safe_next("/account"));
// Protocol-relative: browsers treat //evil.example as a host.
assert!(!is_safe_next("//evil.example"));
assert!(!is_safe_next("https://evil.example"));
assert!(!is_safe_next("javascript:alert(1)"));
assert!(!is_safe_next("evil.example"));
}
#[test]
fn query_string_round_trips_only_safe_next() {
let q = AuthQuery {
next: Some("/r/tt-eap-2026".into()),
..Default::default()
};
assert_eq!(q.qs(), "?next=/r/tt-eap-2026");
let hostile = AuthQuery {
next: Some("https://evil.example".into()),
..Default::default()
};
assert_eq!(hostile.qs(), "", "an unsafe next must not survive");
}
#[test]
fn urlencode_escapes_delimiters() {
assert_eq!(urlencode("a b&c=d"), "a%20b%26c%3Dd");
assert_eq!(urlencode("/r/x"), "/r/x");
}
}

View File

@@ -0,0 +1,50 @@
{% extends "base.html" %}
{% block title %}Your account — {{ site_name }}{% endblock %}
{% block content %}
<div class="prose">
<div class="eyebrow">Your account</div>
<h1>{{ user_email }}</h1>
<p class="lede">
One helexa account covers both this portal and
<a href="https://helexa.ai">helexa.ai</a> itself &mdash; the same email
and password sign you in to evaluate the platform.
</p>
<h2>Access</h2>
{% if rounds %}
<div class="scroll-x">
<table>
<thead><tr><th>Material</th><th>Status</th><th>Granted</th></tr></thead>
<tbody>
{% for r in rounds %}
<tr>
<td><a href="/r/{{ r.slug }}">{{ r.title }}</a></td>
<td>{{ r.state }}</td>
<td>{{ r.granted_at }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p>No material is currently shared with this account.</p>
{% endif %}
<h2>Password</h2>
<p>
Your password is managed with your helexa account. To change or reset
it, use <a href="https://helexa.ai/forgot">the reset flow on
helexa.ai</a> &mdash; the change applies here too.
</p>
<h2>What we record</h2>
<p>
We log which documents your account opens, and when. That is
deliberate: this material is confidential, and knowing who has read
what is part of how we look after it. See
<a href="/privacy">how we handle your data</a>.
</p>
<p style="margin-top:2rem"><a href="/signout">Sign out</a></p>
</div>
{% endblock %}

View File

@@ -0,0 +1,151 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- Confidential material: keep it out of indexes and out of link
previews. The header is also set at the nginx layer; both is
deliberate, since either alone is a single point of failure. -->
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet, noimageindex">
<meta name="referrer" content="no-referrer">
<title>{% block title %}{{ site_name }}{% endblock %}</title>
<style>
:root {
--bg: #0d1117; --panel: #151b23; --line: #262d38;
--ink: #e6edf3; --muted: #9aa7b4; --accent: #5eead4; --accent2: #818cf8;
--warn: #fbbf24;
--measure: 68ch;
}
@media (prefers-color-scheme: light) {
:root {
--bg: #fbfcfd; --panel: #ffffff; --line: #e3e8ee;
--ink: #16202b; --muted: #5b6875; --accent: #0d9488; --accent2: #4f46e5;
--warn: #a16207;
}
}
* { box-sizing: border-box; }
body {
margin: 0; background: var(--bg); color: var(--ink);
font: 16px/1.65 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
-webkit-font-smoothing: antialiased;
}
a { color: var(--accent2); text-decoration-thickness: 1px; text-underline-offset: 2px; }
a:hover { color: var(--accent); }
header.top {
border-bottom: 1px solid var(--line); background: var(--panel);
position: sticky; top: 0; z-index: 10;
}
.top-inner {
max-width: 1080px; margin: 0 auto; padding: 0.85rem 1.25rem;
display: flex; align-items: center; gap: 1rem; flex-wrap: wrap;
}
.brand { font-weight: 650; letter-spacing: -0.01em; display: flex; align-items: center; gap: .55rem; }
.brand .mark {
width: 1.6rem; height: 1.6rem; border-radius: 6px; flex: none;
background: linear-gradient(135deg, var(--accent), var(--accent2));
}
.brand small { display:block; font-weight: 450; color: var(--muted); font-size: .72rem; letter-spacing: .04em; text-transform: uppercase; }
nav.top-nav { margin-left: auto; display: flex; gap: 1.1rem; align-items: center; font-size: .92rem; }
nav.top-nav a { color: var(--muted); text-decoration: none; }
nav.top-nav a:hover, nav.top-nav a[aria-current="page"] { color: var(--ink); }
main { max-width: 1080px; margin: 0 auto; padding: 2rem 1.25rem 4rem; }
.prose { max-width: var(--measure); }
.card {
background: var(--panel); border: 1px solid var(--line);
border-radius: 12px; padding: 1.5rem;
}
.card-narrow { max-width: 26rem; margin: 3rem auto; }
h1 { font-size: 1.9rem; line-height: 1.25; letter-spacing: -0.02em; margin: 0 0 .5rem; }
h2 { font-size: 1.35rem; letter-spacing: -0.01em; margin: 2.2rem 0 .6rem; }
h3 { font-size: 1.08rem; margin: 1.6rem 0 .4rem; }
.eyebrow {
font-size: .74rem; letter-spacing: .10em; text-transform: uppercase;
color: var(--accent); font-weight: 600; margin-bottom: .45rem;
}
.lede { color: var(--muted); font-size: 1.05rem; }
label { display: block; font-size: .88rem; color: var(--muted); margin-bottom: .3rem; }
input[type=email], input[type=password], input[type=text], select, textarea {
width: 100%; padding: .6rem .7rem; border-radius: 8px;
border: 1px solid var(--line); background: var(--bg); color: var(--ink);
font: inherit; margin-bottom: 1rem;
}
input:focus, select:focus, textarea:focus { outline: 2px solid var(--accent2); outline-offset: 1px; }
button.primary {
width: 100%; padding: .65rem 1rem; border: 0; border-radius: 8px;
background: linear-gradient(135deg, var(--accent), var(--accent2));
color: #06121a; font: inherit; font-weight: 650; cursor: pointer;
}
button.primary:hover { filter: brightness(1.08); }
.alert { border-radius: 8px; padding: .7rem .9rem; margin-bottom: 1rem; font-size: .93rem; border: 1px solid; }
.alert-warn { border-color: var(--warn); color: var(--warn); background: color-mix(in srgb, var(--warn) 10%, transparent); }
.alert-ok { border-color: var(--accent); color: var(--accent); background: color-mix(in srgb, var(--accent) 10%, transparent); }
table { border-collapse: collapse; width: 100%; margin: 1rem 0; font-size: .94rem; }
th, td { text-align: left; padding: .55rem .7rem; border-bottom: 1px solid var(--line); vertical-align: top; }
th { color: var(--muted); font-weight: 600; font-size: .82rem; text-transform: uppercase; letter-spacing: .04em; }
.scroll-x { overflow-x: auto; }
/* Per-viewer watermark. Attribution, not access control — it makes a
leak traceable, which is the realistic threat here. */
.watermark {
border: 1px dashed var(--line); border-radius: 8px;
padding: .5rem .75rem; margin-bottom: 1.5rem;
font-size: .78rem; color: var(--muted);
display: flex; justify-content: space-between; gap: 1rem; flex-wrap: wrap;
}
.watermark strong { color: var(--ink); font-weight: 600; }
footer.bot {
border-top: 1px solid var(--line); margin-top: 3rem;
color: var(--muted); font-size: .84rem;
}
.bot-inner { max-width: 1080px; margin: 0 auto; padding: 1.5rem 1.25rem; }
@media print {
header.top, nav.top-nav, footer.bot form { display: none; }
body { background: #fff; color: #000; }
/* The watermark must survive printing — that is the whole point. */
.watermark { border-color: #999; color: #333; }
}
</style>
</head>
<body>
<header class="top">
<div class="top-inner">
<a class="brand" href="/" style="text-decoration:none;color:inherit">
<span class="mark" aria-hidden="true"></span>
<span>helexa<small>{{ site_tagline }}</small></span>
</a>
<nav class="top-nav">
{% block nav %}{% endblock %}
{% if user_email %}
<a href="/account">{{ user_email }}</a>
<a href="/signout">Sign out</a>
{% endif %}
</nav>
</div>
</header>
<main>
{% block content %}{% endblock %}
</main>
<footer class="bot">
<div class="bot-inner">
<p style="margin:0 0 .4rem">
Confidential. Prepared for the named recipient above and not for
onward distribution.
</p>
<p style="margin:0">
{{ entity_name }} &middot; <a href="/privacy">How we handle your data</a>
</p>
</div>
</footer>
</body>
</html>

View File

@@ -0,0 +1,30 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<meta name="referrer" content="no-referrer">
<title>{{ code }} — helexa</title>
<style>
:root { --bg:#0d1117; --panel:#151b23; --line:#262d38; --ink:#e6edf3; --muted:#9aa7b4; --accent2:#818cf8; }
@media (prefers-color-scheme: light) {
:root { --bg:#fbfcfd; --panel:#fff; --line:#e3e8ee; --ink:#16202b; --muted:#5b6875; --accent2:#4f46e5; }
}
body { margin:0; background:var(--bg); color:var(--ink); display:grid; place-items:center; min-height:100vh;
font:16px/1.6 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
.box { background:var(--panel); border:1px solid var(--line); border-radius:12px;
padding:2rem; max-width:26rem; margin:1.25rem; text-align:center; }
.code { font-size:2.4rem; font-weight:700; letter-spacing:-.03em; margin:0 0 .35rem; }
p { color:var(--muted); margin:0 0 1.25rem; }
a { color:var(--accent2); }
</style>
</head>
<body>
<div class="box">
<p class="code">{{ code }}</p>
<p>{{ message }}</p>
<p style="margin:0"><a href="/">Back to the portal</a></p>
</div>
</body>
</html>

View File

@@ -0,0 +1,40 @@
{% extends "base.html" %}
{% block title %}Portal — {{ site_name }}{% endblock %}
{% block content %}
<div class="prose">
<div class="eyebrow">Investor portal</div>
<h1>Welcome{% if user_email %}, {{ user_email }}{% endif %}</h1>
{% if rounds %}
<p class="lede">
Material prepared for you. Everything here is confidential and
attributed to your account.
</p>
<div style="display:grid;gap:1rem;margin-top:1.75rem">
{% for r in rounds %}
<a href="/r/{{ r.slug }}" class="card" style="text-decoration:none;color:inherit;display:block">
<div class="eyebrow">{{ r.framing_label }}</div>
<h2 style="margin:.1rem 0 .35rem;font-size:1.2rem">{{ r.title }}</h2>
<p style="margin:0;color:var(--muted);font-size:.92rem">
{% if r.status == 'open' %}Open{% elif r.status == 'closed' %}Closed{% else %}In preparation{% endif %}
</p>
</a>
{% endfor %}
</div>
{% else %}
<p class="lede">
Your account doesn&rsquo;t currently have access to any material.
</p>
<div class="card" style="margin-top:1.5rem">
<p style="margin:0 0 .5rem">
If you were sent an invitation link, open it again while signed in
and access will be attached to this account.
</p>
<p style="margin:0;color:var(--muted);font-size:.9rem">
If you think this is a mistake, reply to the message that brought
you here and we&rsquo;ll sort it out.
</p>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,66 @@
{% extends "base.html" %}
{% block title %}How we handle your data — {{ site_name }}{% endblock %}
{% block content %}
<div class="prose">
<div class="eyebrow">Privacy</div>
<h1>How we handle your data</h1>
<p class="lede">
Short version: we keep the minimum needed to run a confidential
document portal, and we are direct about the one thing people are
usually not told &mdash; we log what you read.
</p>
<h2>What we hold</h2>
<table>
<thead><tr><th>Data</th><th>Why</th></tr></thead>
<tbody>
<tr>
<td>Email address and password hash</td>
<td>Your helexa account, shared with helexa.ai. We never store the password itself.</td>
</tr>
<tr>
<td>Which documents you open, and when</td>
<td>
This material is confidential and commercially sensitive.
Knowing which named account read which version of a document is
how we look after it &mdash; and how we can tell you what you
were shown if a question comes up later.
</td>
</tr>
<tr>
<td>IP address and browser user-agent</td>
<td>Recorded alongside access events, to spot credential sharing or misuse.</td>
</tr>
<tr>
<td>Anything you send us in the interest form</td>
<td>So a human can respond to you.</td>
</tr>
</tbody>
</table>
<h2>How long</h2>
<p>
Access records are kept for {{ retention_months }} months and then
deleted automatically. Your account lasts until you ask us to remove
it.
</p>
<h2>Who sees it</h2>
<p>
{{ entity_name }} and nobody else. We do not use analytics services,
advertising trackers, or third-party fonts or scripts &mdash; this
portal loads nothing from anyone else&rsquo;s servers, which you can
verify in your browser&rsquo;s network tab.
</p>
<h2>Your rights</h2>
<p>
You can ask for a copy of what we hold about you, ask us to correct
it, or ask us to delete your account, by writing to
<a href="mailto:{{ contact_email }}">{{ contact_email }}</a>. If we
delete your account we keep the access record itself, with your
account reference removed, because it is a record of how confidential
material was handled.
</p>
</div>
{% endblock %}

View File

@@ -0,0 +1,51 @@
{% extends "base.html" %}
{% block title %}Sign in — {{ site_name }}{% endblock %}
{% block content %}
<div class="card card-narrow">
<div class="eyebrow">{{ invite_label or "Investor portal" }}</div>
<h1 style="font-size:1.45rem">{{ heading }}</h1>
<p class="lede" style="font-size:.95rem">{{ subheading }}</p>
{% if error %}<div class="alert alert-warn">{{ error }}</div>{% endif %}
{% if notice %}<div class="alert alert-ok">{{ notice }}</div>{% endif %}
<div style="display:flex;gap:.5rem;border-bottom:1px solid var(--line);margin:1.25rem 0 1.25rem">
<a href="/signin{{ qs }}"
style="padding:.5rem .1rem;margin-right:1rem;text-decoration:none;font-weight:600;
color:{% if tab != 'register' %}var(--ink){% else %}var(--muted){% endif %};
border-bottom:2px solid {% if tab != 'register' %}var(--accent){% else %}transparent{% endif %}">Sign in</a>
<a href="/register{{ qs }}"
style="padding:.5rem .1rem;text-decoration:none;font-weight:600;
color:{% if tab == 'register' %}var(--ink){% else %}var(--muted){% endif %};
border-bottom:2px solid {% if tab == 'register' %}var(--accent){% else %}transparent{% endif %}">Create account</a>
</div>
{% if tab == 'register' %}
<form method="post" action="/register{{ qs }}">
<label for="email">Email address</label>
<input id="email" name="email" type="email" autocomplete="email" required>
<label for="password">Password</label>
<input id="password" name="password" type="password" autocomplete="new-password"
minlength="8" required>
<button class="primary" type="submit">Create account</button>
</form>
<p style="color:var(--muted);font-size:.84rem;margin:1rem 0 0">
This is a helexa account: the same credentials sign you in at
<a href="https://helexa.ai">helexa.ai</a>, so you can evaluate the
platform itself. We&rsquo;ll email you a link to confirm the address.
</p>
{% else %}
<form method="post" action="/signin{{ qs }}">
<label for="email">Email address</label>
<input id="email" name="email" type="email" autocomplete="email" required>
<label for="password">Password</label>
<input id="password" name="password" type="password" autocomplete="current-password" required>
<button class="primary" type="submit">Sign in</button>
</form>
<p style="color:var(--muted);font-size:.84rem;margin:1rem 0 0">
Already have a <a href="https://helexa.ai">helexa.ai</a> account? Use
the same email and password.
</p>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,52 @@
# helexa-angels — the confidential investor portal at angels.helexa.ai.
#
# Every value can be overridden by an ANGELS_-prefixed environment
# variable with `__` separators, e.g. ANGELS_DB__URL=postgres://...
#
# This file carries a database URL, so the RPM installs it
# root:helexa-angels 0640. Keep it that way.
[server]
# Bound to loopback: the public entrypoint is the nginx vhost on the edge
# proxies, which terminates TLS and adds the confidentiality headers.
# Nothing should reach this port directly.
listen = "127.0.0.1:8092"
[db]
# The SAME database helexa-upstream uses. That is deliberate and required:
# credential auth is shared so an investor has one helexa account for both
# this portal and helexa.ai itself, and a cross-database join against
# `users` is not possible.
#
# angels confines its own tables to the `angels` schema, which also keeps
# its sqlx migration bookkeeping from colliding with upstream's.
url = "postgres://helexa:helexa@localhost/helexa_upstream"
max_connections = 5
[session]
# Deliberately NOT helexa-upstream's session realm. Credentials are
# shared; sessions are not. See crates/helexa-angels/src/auth.rs.
cookie_name = "angels_session"
# Absolute lifetime (7 days) and idle timeout (12 hours).
ttl_secs = 604800
idle_secs = 43200
# Only ever false for local development over plain HTTP.
secure = true
[site]
base_url = "https://angels.helexa.ai"
# Where expressions of interest are routed. A real, monitored inbox.
contact_email = "angels@helexa.ai"
[content]
# Round documents, on disk, outside any web root and outside the source
# repository — helexa/helexa is open source, so a business plan committed
# there is a business plan published.
dir = "/var/lib/helexa-angels/content"
[upstream]
# helexa-upstream, over the mesh. Registration is delegated there rather
# than reimplemented: it owns password policy, argon2 parameters, the
# verification email, the unverified-signup reaper and fingerprinting.
base_url = "http://gallumbits.kosherinata.internal:8090"
timeout_secs = 30