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
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:
70
Cargo.lock
generated
70
Cargo.lock
generated
@@ -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]]
|
||||
|
||||
@@ -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",
|
||||
|
||||
61
crates/helexa-angels/Cargo.toml
Normal file
61
crates/helexa-angels/Cargo.toml
Normal 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 }
|
||||
154
crates/helexa-angels/migrations/0001_init.sql
Normal file
154
crates/helexa-angels/migrations/0001_init.sql
Normal 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);
|
||||
96
crates/helexa-angels/src/audit.rs
Normal file
96
crates/helexa-angels/src/audit.rs
Normal 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())
|
||||
}
|
||||
316
crates/helexa-angels/src/auth.rs
Normal file
316
crates/helexa-angels/src/auth.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
184
crates/helexa-angels/src/config.rs
Normal file
184
crates/helexa-angels/src/config.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
123
crates/helexa-angels/src/crypto.rs
Normal file
123
crates/helexa-angels/src/crypto.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
60
crates/helexa-angels/src/db.rs
Normal file
60
crates/helexa-angels/src/db.rs
Normal 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)
|
||||
}
|
||||
85
crates/helexa-angels/src/error.rs
Normal file
85
crates/helexa-angels/src/error.rs
Normal 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>;
|
||||
149
crates/helexa-angels/src/grants.rs
Normal file
149
crates/helexa-angels/src/grants.rs
Normal 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())
|
||||
}
|
||||
208
crates/helexa-angels/src/invites.rs
Normal file
208
crates/helexa-angels/src/invites.rs
Normal 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())
|
||||
}
|
||||
134
crates/helexa-angels/src/lib.rs
Normal file
134
crates/helexa-angels/src/lib.rs
Normal 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"),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
192
crates/helexa-angels/src/main.rs
Normal file
192
crates/helexa-angels/src/main.rs
Normal 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))
|
||||
}
|
||||
38
crates/helexa-angels/src/state.rs
Normal file
38
crates/helexa-angels/src/state.rs
Normal 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()),
|
||||
]
|
||||
}
|
||||
}
|
||||
79
crates/helexa-angels/src/templates.rs
Normal file
79
crates/helexa-angels/src/templates.rs
Normal 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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
48
crates/helexa-angels/src/upstream.rs
Normal file
48
crates/helexa-angels/src/upstream.rs
Normal 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.
|
||||
407
crates/helexa-angels/src/web.rs
Normal file
407
crates/helexa-angels/src/web.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
50
crates/helexa-angels/templates/account.html
Normal file
50
crates/helexa-angels/templates/account.html
Normal 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 — 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> — 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 %}
|
||||
151
crates/helexa-angels/templates/base.html
Normal file
151
crates/helexa-angels/templates/base.html
Normal 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 }} · <a href="/privacy">How we handle your data</a>
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
30
crates/helexa-angels/templates/error.html
Normal file
30
crates/helexa-angels/templates/error.html
Normal 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>
|
||||
40
crates/helexa-angels/templates/portal.html
Normal file
40
crates/helexa-angels/templates/portal.html
Normal 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’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’ll sort it out.
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
66
crates/helexa-angels/templates/privacy.html
Normal file
66
crates/helexa-angels/templates/privacy.html
Normal 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 — 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 — 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 — this
|
||||
portal loads nothing from anyone else’s servers, which you can
|
||||
verify in your browser’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 %}
|
||||
51
crates/helexa-angels/templates/signin.html
Normal file
51
crates/helexa-angels/templates/signin.html
Normal 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’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 %}
|
||||
52
helexa-angels.example.toml
Normal file
52
helexa-angels.example.toml
Normal 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
|
||||
Reference in New Issue
Block a user