Compare commits

..

10 Commits

Author SHA1 Message Date
452d7d9b3d feat(B7): packaging + CI for helexa-upstream
All checks were successful
CI / CUDA type-check (push) Successful in 1m32s
CI / Format (push) Successful in 57s
CI / Clippy (push) Successful in 2m51s
CI / Test (push) Successful in 8m0s
CI / Build cortex SRPM (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
Ships the mesh authority as an RPM on the same prebuilt-binary pipeline as
helexa-bench.

- rpm/helexa-upstream-prerelease.spec: wraps the CI-built binary; installs
  the systemd unit, sysusers, firewalld service (tcp/8090), and
  /etc/helexa-upstream/helexa-upstream.toml (config noreplace).
- data/helexa-upstream.{service,sysusers.conf,firewalld.xml}: Type=simple
  unit (serve --config), dedicated system user with a StateDirectory home,
  inbound 8090 (/authz/v1 + /web/v1). PostgreSQL is reached outbound; the
  schema migrates on startup.
- build-prerelease.yml: build-upstream + package-upstream jobs with
  change-detection over crates/helexa-upstream/ (UPSTREAM_RE), gated into
  publish. SQLX_OFFLINE=true is set defensively — helexa-upstream uses the
  sqlx runtime query API (no compile-time macros), so it builds with no
  database and no .sqlx cache; DB integration tests stay gated behind
  UPSTREAM_TEST_DATABASE_URL.

Validated: workflow YAML parses, rpmspec expands, and
`SQLX_OFFLINE=true cargo build --release -p helexa-upstream` succeeds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6o3ddqmYNh9kzdwq6eowh
2026-06-23 12:00:41 +03:00
21eb211d6a Merge feat/B6-served-usage: served-usage ledger + reconciliation (B6, #58)
Some checks failed
build-prerelease / Test (push) Blocked by required conditions
build-prerelease / Package cortex RPM (push) Blocked by required conditions
build-prerelease / Package helexa-bench RPM (push) Blocked by required conditions
build-prerelease / Resolve version stamps + change detection (push) Successful in 37s
build-prerelease / Build neuron-blackwell (push) Successful in 1m25s
build-prerelease / Build neuron-ada (push) Successful in 2m3s
build-prerelease / Build neuron-ampere (push) Successful in 2m10s
build-prerelease / Build helexa-bench binary (push) Successful in 2m13s
build-prerelease / Build cortex binary (push) Successful in 3m25s
build-prerelease / Lint (fmt + clippy) (push) Successful in 3m45s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 1m51s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 1m50s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 1m52s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Has been cancelled
2026-06-23 11:56:06 +03:00
0de99a8cc7 Merge feat/F4-account-dashboard: auth + account dashboard (F4)
Some checks failed
build-prerelease / Test (push) Blocked by required conditions
build-prerelease / Resolve version stamps + change detection (push) Successful in 47s
build-prerelease / Build neuron-blackwell (push) Has been skipped
build-prerelease / Build neuron-ampere (push) Has been skipped
build-prerelease / Build neuron-ada (push) Has been skipped
build-prerelease / Package helexa-neuron-ada RPM (push) Has been skipped
build-prerelease / Package helexa-neuron-ampere RPM (push) Has been skipped
build-prerelease / Package helexa-neuron-blackwell RPM (push) Has been skipped
build-prerelease / Lint (fmt + clippy) (push) Successful in 3m16s
build-prerelease / Build cortex binary (push) Has been skipped
build-prerelease / Build helexa-bench binary (push) Has been skipped
build-prerelease / Package cortex RPM (push) Has been skipped
build-prerelease / Package helexa-bench RPM (push) Has been skipped
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Has been cancelled
2026-06-23 11:46:14 +03:00
f4117224fc feat(B6): served-usage ledger + reconciliation (#58)
All checks were successful
CI / Format (push) Successful in 35s
CI / CUDA type-check (push) Successful in 1m37s
CI / Clippy (push) Successful in 2m53s
CI / Test (push) Successful in 6m41s
CI / Build cortex SRPM (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
Operators are now metered for the tokens they serve on behalf of mesh
accounts, and the upstream rolls that up for compensation.

cortex-gateway:
- served_usage.rs: an in-process per-(account,key,UTC-day) served-token
  counter, incremented in metering::usage_sink alongside spend/settle for
  every authenticated request. A flush task (spawned in run() when
  [upstream].enabled, mirroring poller/evictor) POSTs ABSOLUTE cumulative
  counters to upstream on [upstream].served_usage_report_interval_secs.
- The no-limit infra key is the operator's local key with hard_cap=None
  (already supported) — it's metered for served-usage but never budget-
  refused and never hits upstream.

helexa-upstream:
- POST /authz/v1/served-usage: upserts rows keyed by (operator_id from the
  client bearer, account, key, period) with
  GREATEST(existing, incoming) — monotonic + idempotent, so re-sends,
  races, and a restarted cortex's lower counter never regress the total.
- reconcile.rs + `helexa-upstream reconcile` CLI: rolls up unreconciled
  served_usage per operator/period (SUM::bigint), stamps reconciled_at,
  prints the totals. Payout mechanism out of scope.

Validated against a throwaway Postgres: monotonic upsert (100→250, a 50
re-send stays 250, same-value idempotent) and reconcile rollup +
stamp-once; cortex counter unit test for per-principal accumulation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6o3ddqmYNh9kzdwq6eowh
2026-06-23 11:45:57 +03:00
ce29e0c171 Merge feat/F3-anon-chat: anonymous chat landing + IndexedDB + SSE (F3)
All checks were successful
build-prerelease / Resolve version stamps + change detection (push) Successful in 52s
build-prerelease / Build neuron-blackwell (push) Has been skipped
build-prerelease / Lint (fmt + clippy) (push) Successful in 3m14s
build-prerelease / Build neuron-ampere (push) Has been skipped
build-prerelease / Build neuron-ada (push) Has been skipped
build-prerelease / Package helexa-neuron-ada RPM (push) Has been skipped
build-prerelease / Package helexa-neuron-ampere RPM (push) Has been skipped
build-prerelease / Package helexa-neuron-blackwell RPM (push) Has been skipped
build-prerelease / Build cortex binary (push) Has been skipped
build-prerelease / Build helexa-bench binary (push) Has been skipped
build-prerelease / Package cortex RPM (push) Has been skipped
build-prerelease / Package helexa-bench RPM (push) Has been skipped
build-prerelease / Test (push) Successful in 6m13s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Has been skipped
2026-06-23 11:32:00 +03:00
1bf3348c8c feat(F4): auth + account dashboard (mockable client)
All checks were successful
CI / Format (push) Successful in 42s
CI / CUDA type-check (push) Successful in 1m41s
CI / Clippy (push) Successful in 3m9s
CI / Test (push) Successful in 6m30s
CI / Build cortex SRPM (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
The self-service account surface consuming helexa-upstream's /web/v1 (B4/B5),
with a mock so it works before/independent of the live backend.

- api/types.ts + api/account.ts: typed AccountApi over a same-origin `/api`
  prefix (vite-proxied in dev, nginx in prod) covering register/verify/
  login/password-reset/keys(list,create,archive,limit)/account/redeem;
  ApiError carries the backend code. MockAccountApi behind
  VITE_USE_MOCK_ACCOUNT_API (in-memory account, raw-key-once, redeem).
- auth/: context + useAuth, AuthProvider (JWT in localStorage, login fetches
  the account and runs claimAnonymousData → anon IndexedDB history is
  re-owned to the account, still client-side), RequireAuth guard
  (→ /login?next=).
- pages/auth/: Login, Register (sends the FingerprintJS visitor id →
  triggers the silent abuse detection), VerifyEmail (?token), RequestReset,
  ResetPassword (?token, matches the backend /reset?token= link).
- pages/account/: Dashboard (allocation balance + usage bar, redeem top-up,
  logout) and ApiKeys (list, create-modal showing the raw key ONCE with
  copy, per-key limit editor percent↔hardcap, archive). 401 → logout.
- App wraps AuthProvider + routes (account guarded); Header auth cluster
  reflects useAuth (Account/Sign out vs Sign in/up).
- `account` i18n namespace (53 keys) added + wired across all 32 langs.

Validated: lint, typecheck, build, i18n:check, lang-labels all green.
Explicit-path commit; no node_modules/dist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6o3ddqmYNh9kzdwq6eowh
2026-06-23 11:31:43 +03:00
7c12b9ea98 Merge feat/F2-mission: /mission route — EU digital sovereignty (F2)
All checks were successful
build-prerelease / Resolve version stamps + change detection (push) Successful in 49s
build-prerelease / Build neuron-blackwell (push) Has been skipped
build-prerelease / Lint (fmt + clippy) (push) Successful in 2m48s
build-prerelease / Build neuron-ampere (push) Has been skipped
build-prerelease / Build neuron-ada (push) Has been skipped
build-prerelease / Package helexa-neuron-ada RPM (push) Has been skipped
build-prerelease / Package helexa-neuron-ampere RPM (push) Has been skipped
build-prerelease / Package helexa-neuron-blackwell RPM (push) Has been skipped
build-prerelease / Build cortex binary (push) Has been skipped
build-prerelease / Build helexa-bench binary (push) Has been skipped
build-prerelease / Package cortex RPM (push) Has been skipped
build-prerelease / Package helexa-bench RPM (push) Has been skipped
build-prerelease / Test (push) Successful in 6m59s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Has been skipped
2026-06-23 11:19:38 +03:00
a6b1fdc33d Merge feat/F1-theming-i18n: theming + 33-lang i18n + usage-ordered selector (F1)
All checks were successful
build-prerelease / Resolve version stamps + change detection (push) Successful in 41s
build-prerelease / Build neuron-blackwell (push) Has been skipped
build-prerelease / Build neuron-ampere (push) Has been skipped
build-prerelease / Build neuron-ada (push) Has been skipped
build-prerelease / Package helexa-neuron-ada RPM (push) Has been skipped
build-prerelease / Package helexa-neuron-ampere RPM (push) Has been skipped
build-prerelease / Package helexa-neuron-blackwell RPM (push) Has been skipped
build-prerelease / Lint (fmt + clippy) (push) Successful in 3m14s
build-prerelease / Test (push) Successful in 5m37s
build-prerelease / Build cortex binary (push) Has been skipped
build-prerelease / Package cortex RPM (push) Has been skipped
build-prerelease / Build helexa-bench binary (push) Has been skipped
build-prerelease / Package helexa-bench RPM (push) Has been skipped
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Has been skipped
2026-06-23 11:10:33 +03:00
8600d4fbf2 Merge feat/B5-topup-codes: single-use top-up codes + mint CLI (B5, #59)
All checks were successful
build-prerelease / Resolve version stamps + change detection (push) Successful in 37s
build-prerelease / Build neuron-blackwell (push) Has been skipped
build-prerelease / Build neuron-ampere (push) Has been skipped
build-prerelease / Build neuron-ada (push) Has been skipped
build-prerelease / Package helexa-neuron-ada RPM (push) Has been skipped
build-prerelease / Package helexa-neuron-ampere RPM (push) Has been skipped
build-prerelease / Package helexa-neuron-blackwell RPM (push) Has been skipped
build-prerelease / Build cortex binary (push) Has been skipped
build-prerelease / Package cortex RPM (push) Has been skipped
build-prerelease / Build helexa-bench binary (push) Has been skipped
build-prerelease / Package helexa-bench RPM (push) Has been skipped
build-prerelease / Lint (fmt + clippy) (push) Successful in 3m22s
build-prerelease / Test (push) Successful in 5m44s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Has been skipped
2026-06-23 11:01:26 +03:00
2348cc2234 feat(B5): single-use top-up codes (redeem + mint CLI)
All checks were successful
CI / Format (push) Successful in 33s
CI / CUDA type-check (push) Successful in 1m33s
CI / Clippy (push) Successful in 3m5s
CI / Test (push) Successful in 6m29s
CI / Build cortex SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
Completes the hybrid allocation model — the flat free grant (B4) plus
top-up codes that extend an account's allocation.

- topup.rs: redeem(account, raw_code) — timing-safe, single-use. A
  conditional `UPDATE … WHERE redeemed_by IS NULL RETURNING value` claims
  the code atomically (concurrent double-redeem → exactly one winner), then
  raises accounts.allocation_total in the same tx. Only sha256(code) is
  stored; a not-found code and an already-redeemed code return the SAME
  generic error via the same path (no "valid but spent" oracle). Raising
  the total automatically lifts every percent-limited key's effective cap;
  hardcap keys stay pinned (by design).
- mint(value, count, denomination) — inserts codes (hash-only), returns the
  raw codes once. Exposed as `helexa-upstream mint --value --count
  [--denomination]` (raw codes to stdout, one per line) — the seam the
  future faucet bot calls. Bot itself out of scope.
- POST /web/v1/redeem (session-protected) → {allocation_total} | generic 400.

Validated against Postgres: redeem raises the 1_000_000 free grant to
1_500_000, second redemption + unknown code both generic-400, and a
concurrent race for one code yields exactly one HTTP 200.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6o3ddqmYNh9kzdwq6eowh
2026-06-23 10:50:43 +03:00
69 changed files with 4145 additions and 30 deletions

View File

@@ -66,6 +66,7 @@ jobs:
build_cortex: ${{ steps.changes.outputs.build_cortex }}
build_neuron: ${{ steps.changes.outputs.build_neuron }}
build_bench: ${{ steps.changes.outputs.build_bench }}
build_upstream: ${{ steps.changes.outputs.build_upstream }}
check_rust: ${{ steps.changes.outputs.check_rust }}
steps:
- uses: actions/checkout@v4
@@ -104,6 +105,7 @@ jobs:
BUILD_CORTEX=true
BUILD_NEURON=true
BUILD_BENCH=true
BUILD_UPSTREAM=true
CHECK_RUST=true
if [ "${GITHUB_EVENT_NAME}" = "push" ]; then
@@ -149,6 +151,7 @@ jobs:
NEURON_RE='^crates/neuron/|^crates/cortex-core/|^Cargo\.toml$|^Cargo\.lock$|^rpm/helexa-neuron-prerelease\.spec$|^data/neuron|^neuron\.example\.toml$|^\.gitea/workflows/build-prerelease\.yml$'
CORTEX_RE='^crates/cortex-gateway/|^crates/cortex-cli/|^crates/cortex-core/|^Cargo\.toml$|^Cargo\.lock$|^rpm/cortex-prerelease\.spec$|^data/cortex|^cortex\.example\.toml$|^models\.example\.toml$|^\.gitea/workflows/build-prerelease\.yml$'
BENCH_RE='^crates/helexa-bench/|^crates/cortex-core/|^Cargo\.toml$|^Cargo\.lock$|^rpm/helexa-bench-prerelease\.spec$|^data/helexa-bench|^helexa-bench\.example\.toml$|^\.gitea/workflows/build-prerelease\.yml$'
UPSTREAM_RE='^crates/helexa-upstream/|^crates/cortex-core/|^Cargo\.toml$|^Cargo\.lock$|^rpm/helexa-upstream-prerelease\.spec$|^data/helexa-upstream|^helexa-upstream\.example\.toml$|^\.gitea/workflows/build-prerelease\.yml$'
# Any Rust change (incl. crates not packaged here, e.g.
# helexa-acp) still needs lint+test on main.
RUST_RE='\.rs$|^crates/|Cargo\.toml$|^Cargo\.lock$'
@@ -156,10 +159,12 @@ jobs:
CORTEX_BASE=$(base_for cortex)
NEURON_BASE=$(base_for helexa-neuron-blackwell)
BENCH_BASE=$(base_for helexa-bench)
UPSTREAM_BASE=$(base_for helexa-upstream)
BUILD_CORTEX=$(decide "$CORTEX_BASE" "$CORTEX_RE")
BUILD_NEURON=$(decide "$NEURON_BASE" "$NEURON_RE")
BUILD_BENCH=$(decide "$BENCH_BASE" "$BENCH_RE")
if [ "$BUILD_CORTEX" = "true" ] || [ "$BUILD_NEURON" = "true" ] || [ "$BUILD_BENCH" = "true" ]; then
BUILD_UPSTREAM=$(decide "$UPSTREAM_BASE" "$UPSTREAM_RE")
if [ "$BUILD_CORTEX" = "true" ] || [ "$BUILD_NEURON" = "true" ] || [ "$BUILD_BENCH" = "true" ] || [ "$BUILD_UPSTREAM" = "true" ]; then
CHECK_RUST=true
else
CHECK_RUST=$(decide "$CORTEX_BASE" "$RUST_RE")
@@ -170,8 +175,9 @@ jobs:
echo "build_cortex=${BUILD_CORTEX}" >> "$GITHUB_OUTPUT"
echo "build_neuron=${BUILD_NEURON}" >> "$GITHUB_OUTPUT"
echo "build_bench=${BUILD_BENCH}" >> "$GITHUB_OUTPUT"
echo "build_upstream=${BUILD_UPSTREAM}" >> "$GITHUB_OUTPUT"
echo "check_rust=${CHECK_RUST}" >> "$GITHUB_OUTPUT"
echo "### change detection: build_cortex=${BUILD_CORTEX} build_neuron=${BUILD_NEURON} build_bench=${BUILD_BENCH} check_rust=${CHECK_RUST}"
echo "### change detection: build_cortex=${BUILD_CORTEX} build_neuron=${BUILD_NEURON} build_bench=${BUILD_BENCH} build_upstream=${BUILD_UPSTREAM} check_rust=${CHECK_RUST}"
# fmt + clippy + test moved here from ci.yml for main pushes so the
# two workflows stop queueing against each other (ci.yml's checks
@@ -303,6 +309,45 @@ jobs:
path: artifacts/helexa-bench
retention-days: 1
build-upstream:
name: Build helexa-upstream binary
timeout-minutes: 25
needs: prepare
if: needs.prepare.outputs.build_upstream == 'true'
# Pure-Rust, non-CUDA binary — same runner as cortex/bench.
runs-on: rust
env:
RUSTC_WRAPPER: sccache
SCCACHE_BUCKET: sccache
SCCACHE_ENDPOINT: http://caveman.kosherinata.internal:9000
SCCACHE_REGION: auto
SCCACHE_S3_USE_SSL: "false"
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_S3_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_S3_SECRET_KEY }}
# helexa-upstream uses the sqlx runtime query API (no compile-time
# query macros), so it builds without a database or a .sqlx cache.
# Set OFFLINE defensively so a stray macro can never reach for a DB.
SQLX_OFFLINE: "true"
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.ref }}
- name: Build helexa-upstream (release, sccache escalation)
run: script/ci-cargo-escalate.sh cargo build --release -p helexa-upstream
- name: Stage binary
run: |
mkdir --parents artifacts
cp target/release/helexa-upstream artifacts/helexa-upstream
./artifacts/helexa-upstream --version || true
- uses: actions/upload-artifact@v3
with:
name: upstream-fc43
path: artifacts/helexa-upstream
retention-days: 1
build-neuron:
name: Build neuron-${{ matrix.flavour }}
timeout-minutes: 35
@@ -459,6 +504,44 @@ jobs:
path: ~/rpmbuild/RPMS/x86_64/*.rpm
retention-days: 7
package-upstream:
name: Package helexa-upstream RPM
timeout-minutes: 20
needs: [prepare, build-upstream]
runs-on: rpm
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.ref }}
- uses: actions/download-artifact@v3
with:
name: upstream-fc43
path: artifacts/
- name: Build RPM
run: |
set -eux
rm -f ~/.rpmmacros
rpmdev-setuptree
cp artifacts/helexa-upstream ~/rpmbuild/SOURCES/
cp data/helexa-upstream.service ~/rpmbuild/SOURCES/
cp data/helexa-upstream-sysusers.conf ~/rpmbuild/SOURCES/
cp data/helexa-upstream-firewalld.xml ~/rpmbuild/SOURCES/
cp helexa-upstream.example.toml ~/rpmbuild/SOURCES/
cp LICENSE ~/rpmbuild/SOURCES/
rpmbuild -bb rpm/helexa-upstream-prerelease.spec \
--define "upstream_version ${{ needs.prepare.outputs.version }}" \
--define "upstream_prerelease ${{ needs.prepare.outputs.release }}" \
--undefine dist \
--define "dist .fc43"
- uses: actions/upload-artifact@v3
with:
name: rpm-upstream-fc43
path: ~/rpmbuild/RPMS/x86_64/*.rpm
retention-days: 7
package-neuron:
name: Package helexa-neuron-${{ matrix.flavour }} RPM
timeout-minutes: 20
@@ -508,7 +591,7 @@ jobs:
publish:
name: Publish to rpm.lair.cafe (unstable)
timeout-minutes: 25
needs: [lint, test, package-cortex, package-neuron, package-bench]
needs: [lint, test, package-cortex, package-neuron, package-bench, package-upstream]
# Runs when at least one package was built and nothing failed.
# lint/test may be skipped (docs-only refs never get here because
# no packages build), but a real failure in any blocks the
@@ -518,10 +601,11 @@ jobs:
!cancelled()
&& (needs.lint.result == 'success' || needs.lint.result == 'skipped')
&& (needs.test.result == 'success' || needs.test.result == 'skipped')
&& (needs.package-cortex.result == 'success' || needs.package-neuron.result == 'success' || needs.package-bench.result == 'success')
&& (needs.package-cortex.result == 'success' || needs.package-neuron.result == 'success' || needs.package-bench.result == 'success' || needs.package-upstream.result == 'success')
&& needs.package-cortex.result != 'failure'
&& needs.package-neuron.result != 'failure'
&& needs.package-bench.result != 'failure'
&& needs.package-upstream.result != 'failure'
}}
runs-on: rpm
concurrency:

View File

@@ -105,3 +105,5 @@ enabled = false
# upstream). Override via CORTEX_UPSTREAM__BEARER in prod.
# bearer = "replace-with-operator-client-secret"
# timeout_secs = 5
# How often to flush served-usage counters to upstream for reconciliation (#58).
# served_usage_report_interval_secs = 60

View File

@@ -48,11 +48,18 @@ pub struct UpstreamClientConfig {
/// Per-call timeout (seconds) to upstream.
#[serde(default = "default_upstream_timeout")]
pub timeout_secs: u64,
/// How often (seconds) to flush served-usage counters to upstream for
/// reconciliation (#58).
#[serde(default = "default_served_usage_interval")]
pub served_usage_report_interval_secs: u64,
}
fn default_upstream_timeout() -> u64 {
5
}
fn default_served_usage_interval() -> u64 {
60
}
/// `[entitlements]` — the local/static [`crate::entitlements::EntitlementProvider`]
/// source of truth (#50). Accounts, keys, and hard caps live here; the

View File

@@ -322,7 +322,11 @@ async fn anthropic_messages(
)
.await
{
Ok(guard) => Some(crate::metering::usage_sink(principal, guard)),
Ok(guard) => Some(crate::metering::usage_sink(
principal,
guard,
std::sync::Arc::clone(&fleet.served_usage),
)),
Err(env) => return crate::error::envelope_response(env),
}
}
@@ -802,7 +806,11 @@ async fn proxy_with_metrics(
)
.await
{
Ok(guard) => Some(crate::metering::usage_sink(principal, guard)),
Ok(guard) => Some(crate::metering::usage_sink(
principal,
guard,
std::sync::Arc::clone(&fleet.served_usage),
)),
Err(env) => return crate::error::envelope_response(env),
}
}

View File

@@ -11,6 +11,7 @@ pub mod metrics;
pub mod poller;
pub mod proxy;
pub mod router;
pub mod served_usage;
pub mod state;
use anyhow::Result;
@@ -57,6 +58,28 @@ pub async fn run(config: GatewayConfig) -> Result<()> {
evictor::eviction_loop(evictor_fleet).await;
});
// Served-usage reporter (#58): when this operator is part of the mesh,
// periodically flush absolute per-principal served-token counters to
// upstream for reconciliation.
if config.upstream.enabled {
let su_fleet = Arc::clone(&fleet);
let url = config.upstream.url.clone();
let bearer = config.upstream.bearer.clone();
let interval =
std::time::Duration::from_secs(config.upstream.served_usage_report_interval_secs);
tokio::spawn(async move {
loop {
tokio::time::sleep(interval).await;
let rows = su_fleet.served_usage.snapshot();
if let Err(e) =
served_usage::report(&su_fleet.http_client, &url, &bearer, &rows).await
{
tracing::warn!(error = %e, "served-usage report failed (will retry)");
}
}
});
}
let app = build_app(Arc::clone(&fleet));
let listen_addr = config.gateway.listen.parse::<std::net::SocketAddr>()?;

View File

@@ -117,9 +117,21 @@ impl Drop for ReservationGuard {
/// Build the completion sink for an authenticated request: record spend and
/// settle the reservation with the observed total. Dropping it unused (no
/// usage observed) releases the reservation via the guard.
pub fn usage_sink(principal: Principal, guard: ReservationGuard) -> UsageSink {
pub fn usage_sink(
principal: Principal,
guard: ReservationGuard,
served_usage: std::sync::Arc<crate::served_usage::ServedUsage>,
) -> UsageSink {
Box::new(move |prompt, completion| {
record_spend(&principal, prompt, completion);
// Per-principal served-usage tally for #58 reconciliation. Recorded
// for every metered (authenticated) request; the flush task reports
// it to upstream when the operator is part of the mesh.
served_usage.add(
&principal.account_id,
&principal.key_id,
prompt + completion,
);
guard.settle(prompt + completion);
})
}

View File

@@ -0,0 +1,105 @@
//! Served-usage ledger (#58): cortex meters, per principal and per UTC day,
//! the tokens it has served on behalf of mesh accounts, and periodically
//! reports **absolute** cumulative counters to helexa-upstream for
//! reconciliation (operators are compensated for served tokens).
//!
//! Counters are cumulative-since-process-start for the current period;
//! upstream upserts them monotonically (GREATEST), so re-sending the same
//! value is idempotent and a flush that races another is harmless. (A
//! process restart resets the in-memory counter; the monotonic upsert keeps
//! upstream from regressing — at most it under-counts the restarted window,
//! acceptable for beta. One cortex per operator token is assumed.)
use serde::Serialize;
use std::collections::HashMap;
use std::sync::Mutex;
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct ServedRow {
pub account_id: String,
pub key_id: String,
pub period: String, // YYYY-MM-DD (UTC)
pub served_tokens: u64,
}
#[derive(Default)]
pub struct ServedUsage {
inner: Mutex<HashMap<(String, String, String), u64>>,
}
impl ServedUsage {
pub fn new() -> Self {
Self::default()
}
/// Add served tokens for a principal in today's (UTC) period.
pub fn add(&self, account_id: &str, key_id: &str, tokens: u64) {
if tokens == 0 {
return;
}
let period = chrono::Utc::now().format("%Y-%m-%d").to_string();
let mut m = self.inner.lock().expect("served-usage lock");
*m.entry((account_id.to_string(), key_id.to_string(), period))
.or_insert(0) += tokens;
}
/// Absolute cumulative counters, for a flush to upstream.
pub fn snapshot(&self) -> Vec<ServedRow> {
let m = self.inner.lock().expect("served-usage lock");
m.iter()
.map(|((account_id, key_id, period), &served_tokens)| ServedRow {
account_id: account_id.clone(),
key_id: key_id.clone(),
period: period.clone(),
served_tokens,
})
.collect()
}
}
/// POST the absolute counters to upstream's `/authz/v1/served-usage`.
pub async fn report(
client: &reqwest::Client,
base_url: &str,
bearer: &str,
rows: &[ServedRow],
) -> Result<(), reqwest::Error> {
if rows.is_empty() {
return Ok(());
}
let url = format!("{}/authz/v1/served-usage", base_url.trim_end_matches('/'));
client
.post(url)
.bearer_auth(bearer)
.json(&serde_json::json!({ "rows": rows }))
.send()
.await?
.error_for_status()?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accumulates_per_principal_and_period() {
let su = ServedUsage::new();
su.add("acct", "key", 10);
su.add("acct", "key", 5);
su.add("acct", "other", 7);
su.add("acct", "key", 0); // no-op
let mut rows = su.snapshot();
rows.sort_by(|a, b| a.key_id.cmp(&b.key_id));
assert_eq!(rows.len(), 2);
let key_row = rows.iter().find(|r| r.key_id == "key").unwrap();
assert_eq!(key_row.served_tokens, 15);
assert_eq!(
rows.iter()
.find(|r| r.key_id == "other")
.unwrap()
.served_tokens,
7
);
}
}

View File

@@ -22,6 +22,9 @@ pub struct CortexState {
/// Whether to reject unauthenticated requests (#49). Read by the auth
/// middleware once it lands.
pub require_auth: bool,
/// Per-principal served-token tally (#58), reported to upstream for
/// operator reconciliation by the flush task when upstream is enabled.
pub served_usage: Arc<crate::served_usage::ServedUsage>,
}
impl CortexState {
@@ -73,6 +76,7 @@ impl CortexState {
.expect("failed to build HTTP client"),
entitlements,
require_auth: config.entitlements.require_auth,
served_usage: Arc::new(crate::served_usage::ServedUsage::new()),
}
}
}

View File

@@ -60,6 +60,7 @@ fn chain(local: LocalEntitlementProvider, url: &str) -> ChainedEntitlementProvid
url: url.to_string(),
bearer: "client-secret".into(),
timeout_secs: 5,
served_usage_report_interval_secs: 60,
});
ChainedEntitlementProvider::new(local, upstream)
}

View File

@@ -22,7 +22,7 @@ use axum::http::{StatusCode, header};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use axum::routing::post;
use axum::{Json, Router};
use axum::{Extension, Json, Router};
use cortex_core::error_envelope::OpenAiError;
use serde::{Deserialize, Serialize};
use subtle::ConstantTimeEq;
@@ -41,6 +41,7 @@ pub fn router(state: &AppState) -> Router<AppState> {
.route("/authz/v1/settle", post(settle))
.route("/authz/v1/release", post(release))
.route("/authz/v1/snapshot", post(snapshot))
.route("/authz/v1/served-usage", post(served_usage))
.layer(axum::middleware::from_fn_with_state(
state.clone(),
client_auth,
@@ -274,6 +275,61 @@ async fn snapshot(State(state): State<AppState>, Json(req): Json<SnapshotReq>) -
}
}
// ── served-usage report (#58) ───────────────────────────────────────
#[derive(Deserialize)]
struct ServedUsageReport {
rows: Vec<ServedUsageRow>,
}
#[derive(Deserialize)]
struct ServedUsageRow {
account_id: String,
key_id: String,
period: String, // YYYY-MM-DD
served_tokens: i64,
}
/// `POST /authz/v1/served-usage` — a cortex reports the absolute served-token
/// counters it has accrued for the current period. Upsert is monotonic
/// (`GREATEST`) so re-sends and races are idempotent and never regress.
/// `operator_id` comes from the validated client bearer (request extension).
async fn served_usage(
State(state): State<AppState>,
Extension(operator): Extension<OperatorId>,
Json(req): Json<ServedUsageReport>,
) -> Response {
for row in &req.rows {
let (Ok(account_id), Ok(key_id)) = (
Uuid::parse_str(&row.account_id),
Uuid::parse_str(&row.key_id),
) else {
continue; // skip malformed ids rather than fail the whole batch
};
let Ok(period) = chrono::NaiveDate::parse_from_str(&row.period, "%Y-%m-%d") else {
continue;
};
let res = sqlx::query(
"INSERT INTO served_usage (operator_id, account_id, key_id, period, served_tokens) \
VALUES ($1, $2, $3, $4, $5) \
ON CONFLICT (operator_id, account_id, key_id, period) \
DO UPDATE SET served_tokens = GREATEST(served_usage.served_tokens, EXCLUDED.served_tokens)",
)
.bind(&operator.0)
.bind(account_id)
.bind(key_id)
.bind(period)
.bind(row.served_tokens.max(0))
.execute(&state.pool)
.await;
if let Err(e) = res {
tracing::error!(error = %e, "served-usage upsert failed");
return envelope_response(OpenAiError::service_unavailable("authority error", Some(5)));
}
}
StatusCode::NO_CONTENT.into_response()
}
fn bad_request(msg: &str) -> Response {
envelope_response(OpenAiError::new(
400,

View File

@@ -20,7 +20,9 @@ pub mod email;
pub mod error;
pub mod handlers;
pub mod ledger;
pub mod reconcile;
pub mod state;
pub mod topup;
pub mod web;
use anyhow::Result;

View File

@@ -20,6 +20,28 @@ enum Commands {
#[arg(short, long, default_value = "helexa-upstream.toml")]
config: String,
},
/// Mint single-use top-up codes and print them (one per line). The raw
/// codes are shown only here — only their hash is stored. (The future
/// faucet bot calls the same path.)
Mint {
#[arg(short, long, default_value = "helexa-upstream.toml")]
config: String,
/// Tokens each code grants.
#[arg(long)]
value: i64,
/// How many codes to mint.
#[arg(long, default_value_t = 1)]
count: u32,
/// Optional human label (e.g. "small", "beta-launch").
#[arg(long)]
denomination: Option<String>,
},
/// Roll up not-yet-reconciled served usage per operator/period (#58),
/// stamp it reconciled, and print the totals. Payout is out of scope.
Reconcile {
#[arg(short, long, default_value = "helexa-upstream.toml")]
config: String,
},
}
#[tokio::main]
@@ -40,6 +62,37 @@ async fn main() -> Result<()> {
tracing::info!(listen = %cfg.server.listen, "starting helexa-upstream");
helexa_upstream::run(cfg).await?;
}
Commands::Mint {
config,
value,
count,
denomination,
} => {
let cfg = UpstreamConfig::load(&config)
.map_err(|e| anyhow::anyhow!("failed to load config from '{config}': {e}"))?;
let pool =
helexa_upstream::db::connect_and_migrate(&cfg.db.url, cfg.db.max_connections)
.await?;
let codes =
helexa_upstream::topup::mint(&pool, value, count, denomination.as_deref()).await?;
// Raw codes to stdout (one per line) for the operator to distribute;
// logs/diagnostics go to stderr via tracing.
for code in codes {
println!("{code}");
}
}
Commands::Reconcile { config } => {
let cfg = UpstreamConfig::load(&config)
.map_err(|e| anyhow::anyhow!("failed to load config from '{config}': {e}"))?;
let pool =
helexa_upstream::db::connect_and_migrate(&cfg.db.url, cfg.db.max_connections)
.await?;
let rollup = helexa_upstream::reconcile::reconcile(&pool).await?;
for r in &rollup {
println!("{}\t{}\t{}", r.operator_id, r.period, r.total_served_tokens);
}
tracing::info!(operators_periods = rollup.len(), "reconciliation complete");
}
}
Ok(())

View File

@@ -0,0 +1,43 @@
//! Reconciliation rollup (#58): aggregate the served-usage ledger per
//! operator and period for operator compensation, stamping rows
//! `reconciled_at` so each window is settled once. The payout mechanism
//! itself is out of scope — this produces the authoritative per-operator
//! totals a settlement process consumes.
use sqlx::Row;
use sqlx::postgres::PgPool;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RollupRow {
pub operator_id: String,
pub period: chrono::NaiveDate,
pub total_served_tokens: i64,
}
/// Roll up all not-yet-reconciled served-usage into per-(operator, period)
/// totals, then stamp those rows `reconciled_at`. Returns the rollup.
/// Idempotent: a second run finds nothing unreconciled and returns empty.
pub async fn reconcile(pool: &PgPool) -> Result<Vec<RollupRow>, sqlx::Error> {
let mut tx = pool.begin().await?;
let rows = sqlx::query(
// SUM(bigint) is numeric in Postgres — cast back to bigint for i64.
"SELECT operator_id, period, SUM(served_tokens)::bigint AS total \
FROM served_usage WHERE reconciled_at IS NULL \
GROUP BY operator_id, period ORDER BY operator_id, period",
)
.fetch_all(&mut *tx)
.await?;
let rollup: Vec<RollupRow> = rows
.iter()
.map(|r| RollupRow {
operator_id: r.get("operator_id"),
period: r.get("period"),
total_served_tokens: r.get::<i64, _>("total"),
})
.collect();
sqlx::query("UPDATE served_usage SET reconciled_at = now() WHERE reconciled_at IS NULL")
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(rollup)
}

View File

@@ -0,0 +1,82 @@
//! Single-use top-up codes (#B5) — the second half of the hybrid allocation
//! model. Each code grants `value` tokens to the account that redeems it,
//! raising `accounts.allocation_total`. Minting codes is operator/CLI side
//! (the future faucet bot calls the same `mint` path); redemption is a
//! `/web/v1` action.
//!
//! Security: only `sha256(code)` is stored. Redemption is **timing-safe and
//! single-use** — a conditional `UPDATE … WHERE redeemed_by IS NULL` does
//! the claim atomically (concurrent double-redeem → exactly one winner), and
//! a not-found code and an already-redeemed code return the **same** generic
//! failure with the same code path (no oracle for "valid but spent").
use crate::crypto::{random_token, sha256};
use sqlx::Row;
use sqlx::postgres::PgPool;
use uuid::Uuid;
#[derive(Debug, thiserror::Error)]
pub enum TopUpError {
/// Code unknown OR already redeemed — deliberately indistinguishable.
#[error("invalid or already-redeemed code")]
Invalid,
#[error(transparent)]
Db(#[from] sqlx::Error),
}
/// Redeem `raw_code` for `account_id`, raising the account's
/// `allocation_total` by the code's value. Returns the new total.
pub async fn redeem(pool: &PgPool, account_id: Uuid, raw_code: &str) -> Result<i64, TopUpError> {
let mut tx = pool.begin().await?;
// Atomic single-use claim. `redeemed_by IS NULL` is the guarantee: under
// concurrent redemption exactly one UPDATE touches the row.
let claimed = sqlx::query(
"UPDATE top_up_codes SET redeemed_by = $1, redeemed_at = now() \
WHERE code_hash = $2 AND redeemed_by IS NULL RETURNING value",
)
.bind(account_id)
.bind(sha256(raw_code))
.fetch_optional(&mut *tx)
.await?;
let Some(row) = claimed else {
// Not found or already redeemed — same path, same error.
return Err(TopUpError::Invalid);
};
let value: i64 = row.get("value");
let new_total: i64 = sqlx::query(
"UPDATE accounts SET allocation_total = allocation_total + $1 WHERE id = $2 \
RETURNING allocation_total",
)
.bind(value)
.bind(account_id)
.fetch_one(&mut *tx)
.await?
.get("allocation_total");
tx.commit().await?;
Ok(new_total)
}
/// Mint `count` codes each worth `value` tokens, optionally tagged with a
/// `denomination` label. Returns the raw codes (shown once — only their
/// hash is stored). The CLI prints these; the future faucet bot calls this.
pub async fn mint(
pool: &PgPool,
value: i64,
count: u32,
denomination: Option<&str>,
) -> Result<Vec<String>, sqlx::Error> {
let mut codes = Vec::with_capacity(count as usize);
for _ in 0..count {
let raw = format!("helexa-topup-{}", random_token());
sqlx::query(
"INSERT INTO top_up_codes (code_hash, value, denomination) VALUES ($1, $2, $3)",
)
.bind(sha256(&raw))
.bind(value)
.bind(denomination)
.execute(pool)
.await?;
codes.push(raw);
}
Ok(codes)
}

View File

@@ -35,6 +35,7 @@ pub fn router(state: &AppState) -> Router<AppState> {
"/web/v1/keys/{id}/limit",
axum::routing::patch(update_key_limit),
)
.route("/web/v1/redeem", post(redeem))
.layer(axum::middleware::from_fn_with_state(
state.clone(),
require_session,
@@ -565,3 +566,29 @@ async fn update_key_limit(
}
Ok(StatusCode::NO_CONTENT.into_response())
}
#[derive(Deserialize)]
struct RedeemReq {
code: String,
}
/// `POST /web/v1/redeem` — redeem a single-use top-up code, raising the
/// account's allocation. Returns the new total. Generic 400 for an invalid
/// or already-redeemed code (no oracle).
async fn redeem(
State(state): State<AppState>,
Extension(user): Extension<AuthUser>,
Json(req): Json<RedeemReq>,
) -> WebResult<Response> {
let acct = account_id_for(&state, user.0).await?;
match crate::topup::redeem(&state.pool, acct, &req.code).await {
Ok(new_total) => Ok(Json(json!({ "allocation_total": new_total })).into_response()),
Err(crate::topup::TopUpError::Invalid) => {
Err(WebError::BadRequest("invalid or already-redeemed code"))
}
Err(crate::topup::TopUpError::Db(e)) => {
tracing::error!(error = %e, "redeem db error");
Err(WebError::Internal)
}
}
}

View File

@@ -0,0 +1,116 @@
//! Integration test for the served-usage report (#58): the idempotent,
//! monotonic upsert and the reconcile rollup. Gated on
//! UPSTREAM_TEST_DATABASE_URL (skips cleanly when unset).
use helexa_upstream::config::{ClientToken, UpstreamConfig};
use helexa_upstream::db::connect_and_migrate;
use helexa_upstream::email::EmailSender;
use helexa_upstream::reconcile::reconcile;
use helexa_upstream::state::AppState;
use serde_json::{Value, json};
use sqlx::Row;
use sqlx::postgres::PgPool;
use uuid::Uuid;
const CLIENT_TOKEN: &str = "su-test-token";
const OPERATOR: &str = "op-su-test";
async fn spawn_or_skip(test: &str) -> Option<(String, PgPool)> {
let Ok(url) = std::env::var("UPSTREAM_TEST_DATABASE_URL") else {
eprintln!("skipping {test}: UPSTREAM_TEST_DATABASE_URL not set");
return None;
};
let pool = connect_and_migrate(&url, 16).await.expect("migrate");
let mut config = UpstreamConfig {
server: Default::default(),
db: helexa_upstream::config::DbSettings {
url,
max_connections: 16,
},
grant: Default::default(),
abuse: Default::default(),
client_auth: Default::default(),
authz: Default::default(),
auth: Default::default(),
email: Default::default(),
};
config.client_auth.tokens.push(ClientToken {
token: CLIENT_TOKEN.into(),
operator_id: OPERATOR.into(),
});
let email = EmailSender::from_config(&config.email).unwrap();
let state = AppState::new(pool.clone(), config, email);
let app = helexa_upstream::build_app(state);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
Some((format!("http://{addr}"), pool))
}
async fn report(base: &str, rows: Value) -> u16 {
reqwest::Client::new()
.post(format!("{base}/authz/v1/served-usage"))
.bearer_auth(CLIENT_TOKEN)
.json(&json!({ "rows": rows }))
.send()
.await
.unwrap()
.status()
.as_u16()
}
async fn stored(pool: &PgPool, account: Uuid, key: Uuid) -> i64 {
sqlx::query(
"SELECT served_tokens FROM served_usage WHERE operator_id = $1 AND account_id = $2 AND key_id = $3",
)
.bind(OPERATOR)
.bind(account)
.bind(key)
.fetch_one(pool)
.await
.unwrap()
.get("served_tokens")
}
#[tokio::test]
async fn served_usage_upsert_is_monotonic_and_reconciles() {
let Some((base, pool)) = spawn_or_skip("served_usage_upsert_is_monotonic_and_reconciles").await
else {
return;
};
let account = Uuid::new_v4();
let key = Uuid::new_v4();
let period = "2026-06-23";
let row = |n: i64| json!([{"account_id": account, "key_id": key, "period": period, "served_tokens": n}]);
// First report.
assert_eq!(report(&base, row(100)).await, 204);
assert_eq!(stored(&pool, account, key).await, 100);
// Re-send a higher absolute value → advances.
assert_eq!(report(&base, row(250)).await, 204);
assert_eq!(stored(&pool, account, key).await, 250);
// A lower value (e.g. a restarted cortex) must NOT regress (GREATEST).
assert_eq!(report(&base, row(50)).await, 204);
assert_eq!(stored(&pool, account, key).await, 250);
// Re-sending the same value is idempotent.
assert_eq!(report(&base, row(250)).await, 204);
assert_eq!(stored(&pool, account, key).await, 250);
// Reconcile rolls it up and stamps reconciled_at; a second run is empty.
let rollup = reconcile(&pool).await.unwrap();
let mine = rollup
.iter()
.find(|r| r.operator_id == OPERATOR)
.expect("operator in rollup");
assert!(mine.total_served_tokens >= 250);
let again = reconcile(&pool).await.unwrap();
assert!(
again.iter().all(|r| r.operator_id != OPERATOR),
"already reconciled"
);
}

View File

@@ -294,3 +294,132 @@ async fn fingerprint_abuse_silently_deactivates_all_no_clue() {
"deactivated account's key looks like any invalid key"
);
}
#[tokio::test]
async fn topup_redeem_raises_allocation_single_use() {
let Some((base, pool)) = spawn_or_skip("topup_redeem_raises_allocation_single_use").await
else {
return;
};
let email = unique_email();
post(
format!("{base}/web/v1/register"),
json!({"email": email, "password": "password123"}),
None,
)
.await;
pool.execute(
sqlx::query("UPDATE users SET email_verified = true WHERE email = $1").bind(&email),
)
.await
.unwrap();
let token = post(
format!("{base}/web/v1/login"),
json!({"email": email, "password": "password123"}),
None,
)
.await
.json::<Value>()
.await
.unwrap()["token"]
.as_str()
.unwrap()
.to_string();
// Mint a code worth 500_000 (mint path used by the CLI/faucet).
let codes = helexa_upstream::topup::mint(&pool, 500_000, 1, Some("test"))
.await
.unwrap();
let code = &codes[0];
// Redeem → allocation_total rises from the 1_000_000 free grant.
let r = post(
format!("{base}/web/v1/redeem"),
json!({"code": code}),
Some(&token),
)
.await;
assert_eq!(r.status(), 200);
assert_eq!(
r.json::<Value>().await.unwrap()["allocation_total"],
1_500_000
);
// Single-use: a second redemption fails generically (no oracle).
let r = post(
format!("{base}/web/v1/redeem"),
json!({"code": code}),
Some(&token),
)
.await;
assert_eq!(r.status(), 400);
// Unknown code: same generic 400.
let r = post(
format!("{base}/web/v1/redeem"),
json!({"code": "helexa-topup-does-not-exist"}),
Some(&token),
)
.await;
assert_eq!(r.status(), 400);
}
#[tokio::test]
async fn topup_concurrent_double_redeem_one_winner() {
let Some((base, pool)) = spawn_or_skip("topup_concurrent_double_redeem_one_winner").await
else {
return;
};
// Two verified accounts.
let mut tokens = Vec::new();
for _ in 0..2 {
let email = unique_email();
post(
format!("{base}/web/v1/register"),
json!({"email": email, "password": "password123"}),
None,
)
.await;
pool.execute(
sqlx::query("UPDATE users SET email_verified = true WHERE email = $1").bind(&email),
)
.await
.unwrap();
let t = post(
format!("{base}/web/v1/login"),
json!({"email": email, "password": "password123"}),
None,
)
.await
.json::<Value>()
.await
.unwrap()["token"]
.as_str()
.unwrap()
.to_string();
tokens.push(t);
}
let code = helexa_upstream::topup::mint(&pool, 100, 1, None)
.await
.unwrap()
.remove(0);
// Both accounts race to redeem the same code; exactly one wins.
let (a, b) = tokio::join!(
post(
format!("{base}/web/v1/redeem"),
json!({"code": code}),
Some(&tokens[0])
),
post(
format!("{base}/web/v1/redeem"),
json!({"code": code}),
Some(&tokens[1])
),
);
let wins = [a.status(), b.status()]
.iter()
.filter(|s| s.as_u16() == 200)
.count();
assert_eq!(wins, 1, "exactly one redemption wins the single-use code");
}

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<service>
<short>helexa-upstream</short>
<description>helexa-upstream — mesh account + budget authority API (/authz/v1 for cortex, /web/v1 for the frontend)</description>
<port protocol="tcp" port="8090"/>
</service>

View File

@@ -0,0 +1,3 @@
g helexa-upstream - -
u helexa-upstream - "helexa-upstream authority" /var/lib/helexa-upstream /sbin/nologin
m helexa-upstream helexa-upstream

View File

@@ -0,0 +1,22 @@
[Unit]
Description=helexa-upstream — mesh account + budget authority (accounts, API keys, allocation ledger, top-up codes)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/bin/helexa-upstream serve --config /etc/helexa-upstream/helexa-upstream.toml
# HTTP authority for cortex (/authz/v1) and the frontend (/web/v1); restart
# unconditionally if it ever exits. It connects out to PostgreSQL (the
# system of record) and runs schema migrations on startup.
Restart=always
RestartSec=10
User=helexa-upstream
Group=helexa-upstream
# Service user home; no local state (PostgreSQL holds everything), but
# StateDirectory gives the user a writable, correctly-owned home.
StateDirectory=helexa-upstream
StateDirectoryMode=0755
[Install]
WantedBy=multi-user.target

View File

@@ -23,7 +23,7 @@ const ROOT = path.resolve(
const RESOURCES_DIR = path.join(ROOT, "src", "i18n", "resources");
// Namespaces to validate.
const NAMESPACES = ["common", "mission", "chat"];
const NAMESPACES = ["common", "mission", "chat", "account"];
// Languages to validate should track SUPPORTED_LANGUAGES in src/i18n/languages.ts.
// NOTE: This list is intentionally narrower than SUPPORTED_LANGUAGES and does not

View File

@@ -1,26 +1,58 @@
import { BrowserRouter, Routes, Route } from "react-router-dom";
import ThemeProvider from "./layout/ThemeProvider";
import AuthProvider from "./auth/AuthProvider";
import RequireAuth from "./auth/RequireAuth";
import Header from "./components/Header";
import Footer from "./components/Footer";
import Mission from "./pages/Mission";
import Chat from "./pages/Chat";
import Login from "./pages/auth/Login";
import Register from "./pages/auth/Register";
import VerifyEmail from "./pages/auth/VerifyEmail";
import RequestReset from "./pages/auth/RequestReset";
import ResetPassword from "./pages/auth/ResetPassword";
import Dashboard from "./pages/account/Dashboard";
import ApiKeys from "./pages/account/ApiKeys";
import "./App.css";
// Composition root: theme + router + layout shell. `/` is the chat
// workspace (F3, anonymous for now); `/mission` (F2) is the EU-sovereignty
// narrative; the auth/account routes (F4) land next.
// Composition root: theme router → auth → layout shell. `/` is the chat
// workspace (F3); `/mission` the EU-sovereignty narrative (F2); the auth +
// account routes (F4) follow, with /account guarded.
export default function App() {
return (
<ThemeProvider>
<BrowserRouter>
<div className="d-flex flex-column min-vh-100">
<Header />
<Routes>
<Route path="/" element={<Chat />} />
<Route path="/mission" element={<Mission />} />
</Routes>
<Footer />
</div>
<AuthProvider>
<div className="d-flex flex-column min-vh-100">
<Header />
<Routes>
<Route path="/" element={<Chat />} />
<Route path="/mission" element={<Mission />} />
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/verify" element={<VerifyEmail />} />
<Route path="/forgot" element={<RequestReset />} />
<Route path="/reset" element={<ResetPassword />} />
<Route
path="/account"
element={
<RequireAuth>
<Dashboard />
</RequireAuth>
}
/>
<Route
path="/account/keys"
element={
<RequireAuth>
<ApiKeys />
</RequireAuth>
}
/>
</Routes>
<Footer />
</div>
</AuthProvider>
</BrowserRouter>
</ThemeProvider>
);

View File

@@ -0,0 +1,213 @@
// Account API client over helexa-upstream's /web/v1 (B4/B5). The browser
// calls a same-origin `/api` prefix (vite-proxied in dev, nginx-routed in
// prod). A MockAccountApi behind VITE_USE_MOCK_ACCOUNT_API lets the
// dashboard be built/demoed before the upstream service is reachable.
import {
ApiError,
type AccountBalance,
type ApiKeySummary,
type CreatedKey,
type Session,
} from "./types";
export interface AccountApi {
register(email: string, password: string, fingerprint?: string): Promise<void>;
verify(token: string): Promise<void>;
login(email: string, password: string): Promise<Session>;
requestReset(email: string): Promise<void>;
confirmReset(token: string, newPassword: string): Promise<void>;
account(token: string): Promise<AccountBalance>;
listKeys(token: string): Promise<ApiKeySummary[]>;
createKey(
token: string,
label: string,
limitKind: "percent" | "hardcap",
limitValue: number,
): Promise<CreatedKey>;
archiveKey(token: string, id: string): Promise<void>;
updateKeyLimit(
token: string,
id: string,
limitKind: "percent" | "hardcap",
limitValue: number,
): Promise<void>;
redeem(token: string, code: string): Promise<AccountBalance>;
}
const BASE = (import.meta.env.VITE_ACCOUNT_BASE_URL || "/api").replace(/\/$/, "");
async function call<T>(
path: string,
init: RequestInit & { token?: string } = {},
): Promise<T> {
const headers: Record<string, string> = { "content-type": "application/json" };
if (init.token) headers.authorization = `Bearer ${init.token}`;
let resp: Response;
try {
resp = await fetch(`${BASE}${path}`, { ...init, headers });
} catch {
throw new ApiError(0, "network_error", "Could not reach the account service.");
}
if (resp.status === 204) return undefined as T;
let body: unknown = null;
try {
body = await resp.json();
} catch {
/* empty body */
}
if (!resp.ok) {
const err = (body as { error?: { code?: string; message?: string } })?.error;
throw new ApiError(resp.status, err?.code ?? "error", err?.message ?? "Request failed.");
}
return body as T;
}
class RealAccountApi implements AccountApi {
async register(email: string, password: string, fingerprint?: string) {
await call("/register", {
method: "POST",
body: JSON.stringify({ email, password, fingerprint }),
});
}
async verify(token: string) {
await call("/verify", { method: "POST", body: JSON.stringify({ token }) });
}
login(email: string, password: string) {
return call<Session>("/login", {
method: "POST",
body: JSON.stringify({ email, password }),
});
}
async requestReset(email: string) {
await call("/password-reset/request", {
method: "POST",
body: JSON.stringify({ email }),
});
}
async confirmReset(token: string, newPassword: string) {
await call("/password-reset/confirm", {
method: "POST",
body: JSON.stringify({ token, new_password: newPassword }),
});
}
account(token: string) {
return call<AccountBalance>("/account", { token });
}
listKeys(token: string) {
return call<{ keys: ApiKeySummary[] }>("/keys", { token }).then((r) => r.keys);
}
createKey(token: string, label: string, limit_kind: "percent" | "hardcap", limit_value: number) {
return call<CreatedKey>("/keys", {
method: "POST",
token,
body: JSON.stringify({ label, limit_kind, limit_value }),
});
}
async archiveKey(token: string, id: string) {
await call(`/keys/${id}/archive`, { method: "POST", token, body: "{}" });
}
async updateKeyLimit(
token: string,
id: string,
limit_kind: "percent" | "hardcap",
limit_value: number,
) {
await call(`/keys/${id}/limit`, {
method: "PATCH",
token,
body: JSON.stringify({ limit_kind, limit_value }),
});
}
redeem(token: string, code: string) {
return call<AccountBalance>("/redeem", {
method: "POST",
token,
body: JSON.stringify({ code }),
});
}
}
// ── Mock (VITE_USE_MOCK_ACCOUNT_API) ────────────────────────────────
// Minimal in-memory account so the dashboard is fully developable offline.
class MockAccountApi implements AccountApi {
private total = 1_000_000;
private spent = 0;
private reserved = 0;
private keys: ApiKeySummary[] = [];
private seq = 1;
async register() {}
async verify() {}
async login(): Promise<Session> {
return { token: "mock-token", expires_in: 604800 };
}
async requestReset() {}
async confirmReset() {}
async account(): Promise<AccountBalance> {
return {
account_id: "mock-account",
allocation_total: this.total,
allocation_spent: this.spent,
allocation_reserved: this.reserved,
};
}
async listKeys(): Promise<ApiKeySummary[]> {
return [...this.keys];
}
async createKey(
_t: string,
label: string,
limit_kind: "percent" | "hardcap",
limit_value: number,
): Promise<CreatedKey> {
const id = `mock-${this.seq++}`;
const prefix = `sk-helexa-mock${this.seq}`;
this.keys.push({
id,
prefix,
label,
status: "active",
limit_kind,
limit_value,
spent: 0,
reserved: 0,
created_at: new Date().toISOString(),
});
return { id, key: `${prefix}-RAWSECRETSHOWNONCE`, prefix, limit_kind, limit_value };
}
async archiveKey(_t: string, id: string) {
const k = this.keys.find((x) => x.id === id);
if (k) k.status = "archived";
}
async updateKeyLimit(
_t: string,
id: string,
limit_kind: "percent" | "hardcap",
limit_value: number,
) {
const k = this.keys.find((x) => x.id === id);
if (k) {
k.limit_kind = limit_kind;
k.limit_value = limit_value;
}
}
async redeem(_t: string, code: string): Promise<AccountBalance> {
if (!code.startsWith("helexa-topup-")) {
throw new ApiError(400, "bad_request", "invalid or already-redeemed code");
}
this.total += 500_000;
return this.account();
}
}
let instance: AccountApi | null = null;
export function accountApi(): AccountApi {
if (!instance) {
instance = import.meta.env.VITE_USE_MOCK_ACCOUNT_API
? new MockAccountApi()
: new RealAccountApi();
}
return instance;
}

View File

@@ -0,0 +1,45 @@
// Wire types for the helexa-upstream /web/v1 account API (B4/B5).
export interface ApiKeySummary {
id: string;
prefix: string;
label: string;
status: "active" | "archived";
limit_kind: "percent" | "hardcap";
limit_value: number;
spent: number;
reserved: number;
created_at: string;
}
export interface CreatedKey {
id: string;
/** Raw secret — shown exactly once at creation. */
key: string;
prefix: string;
limit_kind: "percent" | "hardcap";
limit_value: number;
}
export interface AccountBalance {
account_id: string;
allocation_total: number;
allocation_spent: number;
allocation_reserved: number;
}
export interface Session {
token: string;
expires_in: number;
}
/** Typed error carrying the backend's machine-readable code. */
export class ApiError extends Error {
code: string;
status: number;
constructor(status: number, code: string, message: string) {
super(message);
this.code = code;
this.status = status;
}
}

View File

@@ -0,0 +1,53 @@
import { useState, type ReactNode } from "react";
import { accountApi } from "../api/account";
import { claimAnonymousData } from "../data/repositories";
import { getFingerprint } from "../lib/fingerprint";
import { AuthContext } from "./context";
const TOKEN_KEY = "helexa.token";
const EMAIL_KEY = "helexa.email";
export default function AuthProvider({ children }: { children: ReactNode }) {
const [token, setToken] = useState<string | null>(() =>
localStorage.getItem(TOKEN_KEY),
);
const [email, setEmail] = useState<string | null>(() =>
localStorage.getItem(EMAIL_KEY),
);
async function login(em: string, password: string): Promise<void> {
const api = accountApi();
const session = await api.login(em, password);
localStorage.setItem(TOKEN_KEY, session.token);
localStorage.setItem(EMAIL_KEY, em);
setToken(session.token);
setEmail(em);
// Claim anonymous local history into the account (stays client-side).
try {
const acct = await api.account(session.token);
await claimAnonymousData(acct.account_id);
} catch {
/* non-fatal */
}
}
async function register(em: string, password: string): Promise<void> {
const fingerprint = await getFingerprint();
await accountApi().register(em, password, fingerprint);
}
function logout(): void {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(EMAIL_KEY);
setToken(null);
setEmail(null);
}
return (
<AuthContext.Provider
value={{ token, email, status: token ? "authed" : "anon", login, register, logout }}
>
{children}
</AuthContext.Provider>
);
}

View File

@@ -0,0 +1,14 @@
import { type ReactNode } from "react";
import { Navigate, useLocation } from "react-router-dom";
import { useAuth } from "./context";
/** Route guard: redirect unauthenticated users to /login?next=…. */
export default function RequireAuth({ children }: { children: ReactNode }) {
const { status } = useAuth();
const location = useLocation();
if (status !== "authed") {
const next = encodeURIComponent(location.pathname + location.search);
return <Navigate to={`/login?next=${next}`} replace />;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,23 @@
import { createContext, useContext } from "react";
export interface AuthContextValue {
token: string | null;
email: string | null;
status: "anon" | "authed";
login: (email: string, password: string) => Promise<void>;
register: (email: string, password: string) => Promise<void>;
logout: () => void;
}
export const AuthContext = createContext<AuthContextValue>({
token: null,
email: null,
status: "anon",
login: async () => {},
register: async () => {},
logout: () => {},
});
export function useAuth(): AuthContextValue {
return useContext(AuthContext);
}

View File

@@ -6,11 +6,12 @@ import { useTheme } from "../layout/theme";
import { useTranslation } from "react-i18next";
import { AUTONYM_MAP, type LanguageCode, isRtlLanguage } from "../i18n/languages";
import { getLanguageOptionsByUsage } from "../i18n/translation-priority";
import { useAuth } from "../auth/context";
/**
* Top navigation: brand, primary routes (chat at `/`, `/mission`), an
* auth-aware cluster (stubbed until F4 wires sessions), the theme toggle,
* and the language selector.
* auth-aware cluster (Account/Sign out when signed in, else Sign in/up),
* the theme toggle, and the language selector.
*
* The language picker is ordered by **estimated usage**
* (getLanguageOptionsByUsage), not alphabetically — a deliberate choice that
@@ -21,6 +22,7 @@ import { getLanguageOptionsByUsage } from "../i18n/translation-priority";
const Header: React.FC = () => {
const { theme, toggleTheme } = useTheme();
const { t, i18n } = useTranslation("common");
const { status, logout } = useAuth();
const currentLanguage: LanguageCode = (i18n.language.split("-")[0] ||
"en") as LanguageCode;
@@ -75,13 +77,31 @@ const Header: React.FC = () => {
</Nav>
<div className="d-flex align-items-center gap-2">
{/* Auth cluster — plain links until F4 wires session state. */}
<NavLink to="/login" className="nav-link">
{t("nav.login")}
</NavLink>
<NavLink to="/register" className="nav-link">
{t("nav.register")}
</NavLink>
{/* Auth-aware cluster. */}
{status === "authed" ? (
<>
<NavLink to="/account" className="nav-link">
{t("nav.account")}
</NavLink>
<Button
size="sm"
variant="outline-secondary"
onClick={logout}
className="me-1"
>
{t("nav.logout")}
</Button>
</>
) : (
<>
<NavLink to="/login" className="nav-link">
{t("nav.login")}
</NavLink>
<NavLink to="/register" className="nav-link">
{t("nav.register")}
</NavLink>
</>
)}
<Button
size="sm"

View File

@@ -13,131 +13,163 @@ import ruCommon from "./resources/ru/common.json";
import enMission from "./resources/en/mission.json";
import ruMission from "./resources/ru/mission.json";
import enChat from "./resources/en/chat.json";
import enAccount from "./resources/en/account.json";
import ruChat from "./resources/ru/chat.json";
import ruAccount from "./resources/ru/account.json";
// Scandinavian & Nordic languages
import daCommon from "./resources/da/common.json";
import daMission from "./resources/da/mission.json";
import daChat from "./resources/da/chat.json";
import daAccount from "./resources/da/account.json";
import fiCommon from "./resources/fi/common.json";
import fiMission from "./resources/fi/mission.json";
import fiChat from "./resources/fi/chat.json";
import fiAccount from "./resources/fi/account.json";
import noCommon from "./resources/no/common.json";
import noMission from "./resources/no/mission.json";
import noChat from "./resources/no/chat.json";
import noAccount from "./resources/no/account.json";
import svCommon from "./resources/sv/common.json";
import svMission from "./resources/sv/mission.json";
import svChat from "./resources/sv/chat.json";
import svAccount from "./resources/sv/account.json";
import bgCommon from "./resources/bg/common.json";
import bgMission from "./resources/bg/mission.json";
import bgChat from "./resources/bg/chat.json";
import bgAccount from "./resources/bg/account.json";
import etCommon from "./resources/et/common.json";
import etMission from "./resources/et/mission.json";
import etChat from "./resources/et/chat.json";
import etAccount from "./resources/et/account.json";
// African & MENA languages
import swCommon from "./resources/sw/common.json";
import swMission from "./resources/sw/mission.json";
import swChat from "./resources/sw/chat.json";
import swAccount from "./resources/sw/account.json";
import arCommon from "./resources/ar/common.json";
import arMission from "./resources/ar/mission.json";
import arChat from "./resources/ar/chat.json";
import arAccount from "./resources/ar/account.json";
import faCommon from "./resources/fa/common.json";
import faMission from "./resources/fa/mission.json";
import faChat from "./resources/fa/chat.json";
import faAccount from "./resources/fa/account.json";
import haCommon from "./resources/ha/common.json";
import haMission from "./resources/ha/mission.json";
import haChat from "./resources/ha/chat.json";
import haAccount from "./resources/ha/account.json";
import amCommon from "./resources/am/common.json";
import amMission from "./resources/am/mission.json";
import amChat from "./resources/am/chat.json";
import amAccount from "./resources/am/account.json";
import yoCommon from "./resources/yo/common.json";
import yoMission from "./resources/yo/mission.json";
import yoChat from "./resources/yo/chat.json";
import yoAccount from "./resources/yo/account.json";
import zuCommon from "./resources/zu/common.json";
import zuMission from "./resources/zu/mission.json";
import zuChat from "./resources/zu/chat.json";
import zuAccount from "./resources/zu/account.json";
// Darija (Moroccan Arabic)
import maCommon from "./resources/ma/common.json";
import maMission from "./resources/ma/mission.json";
import maChat from "./resources/ma/chat.json";
import maAccount from "./resources/ma/account.json";
// European / other languages
import esCommon from "./resources/es/common.json";
import esMission from "./resources/es/mission.json";
import esChat from "./resources/es/chat.json";
import esAccount from "./resources/es/account.json";
import frCommon from "./resources/fr/common.json";
import frMission from "./resources/fr/mission.json";
import frChat from "./resources/fr/chat.json";
import frAccount from "./resources/fr/account.json";
import deCommon from "./resources/de/common.json";
import deMission from "./resources/de/mission.json";
import deChat from "./resources/de/chat.json";
import deAccount from "./resources/de/account.json";
import elCommon from "./resources/el/common.json";
import elMission from "./resources/el/mission.json";
import elChat from "./resources/el/chat.json";
import elAccount from "./resources/el/account.json";
import itCommon from "./resources/it/common.json";
import itMission from "./resources/it/mission.json";
import itChat from "./resources/it/chat.json";
import itAccount from "./resources/it/account.json";
import heCommon from "./resources/he/common.json";
import heMission from "./resources/he/mission.json";
import heChat from "./resources/he/chat.json";
import heAccount from "./resources/he/account.json";
import ptCommon from "./resources/pt/common.json";
import ptMission from "./resources/pt/mission.json";
import ptChat from "./resources/pt/chat.json";
import ptAccount from "./resources/pt/account.json";
import roCommon from "./resources/ro/common.json";
import roMission from "./resources/ro/mission.json";
import roChat from "./resources/ro/chat.json";
import roAccount from "./resources/ro/account.json";
import kaCommon from "./resources/ka/common.json";
import kaMission from "./resources/ka/mission.json";
import kaChat from "./resources/ka/chat.json";
import kaAccount from "./resources/ka/account.json";
import trCommon from "./resources/tr/common.json";
import trMission from "./resources/tr/mission.json";
import trChat from "./resources/tr/chat.json";
import trAccount from "./resources/tr/account.json";
import plCommon from "./resources/pl/common.json";
import plMission from "./resources/pl/mission.json";
import plChat from "./resources/pl/chat.json";
import plAccount from "./resources/pl/account.json";
import ukCommon from "./resources/uk/common.json";
import ukMission from "./resources/uk/mission.json";
import ukChat from "./resources/uk/chat.json";
import ukAccount from "./resources/uk/account.json";
import nlCommon from "./resources/nl/common.json";
import nlMission from "./resources/nl/mission.json";
import nlChat from "./resources/nl/chat.json";
import nlAccount from "./resources/nl/account.json";
import srCommon from "./resources/sr/common.json";
import srMission from "./resources/sr/mission.json";
import srChat from "./resources/sr/chat.json";
import srAccount from "./resources/sr/account.json";
import kkCommon from "./resources/kk/common.json";
import kkMission from "./resources/kk/mission.json";
import kkChat from "./resources/kk/chat.json";
import kkAccount from "./resources/kk/account.json";
import uzCommon from "./resources/uz/common.json";
import uzMission from "./resources/uz/mission.json";
import uzChat from "./resources/uz/chat.json";
import uzAccount from "./resources/uz/account.json";
/**
* Application translation resources, split by language and namespace.
@@ -151,41 +183,49 @@ const resources: Resource = {
common: enCommon,
mission: enMission,
chat: enChat,
account: enAccount,
},
ru: {
common: ruCommon,
mission: ruMission,
chat: ruChat,
account: ruAccount,
},
bg: {
common: bgCommon,
mission: bgMission,
chat: bgChat,
account: bgAccount,
},
da: {
common: daCommon,
mission: daMission,
chat: daChat,
account: daAccount,
},
et: {
common: etCommon,
mission: etMission,
chat: etChat,
account: etAccount,
},
fi: {
common: fiCommon,
mission: fiMission,
chat: fiChat,
account: fiAccount,
},
kk: {
common: kkCommon,
mission: kkMission,
chat: kkChat,
account: kkAccount,
},
uz: {
common: uzCommon,
mission: uzMission,
chat: uzChat,
account: uzAccount,
},
// African & MENA languages (LTR unless marked RTL via isRtlLanguage)
@@ -193,41 +233,49 @@ const resources: Resource = {
common: swCommon,
mission: swMission,
chat: swChat,
account: swAccount,
},
ar: {
common: arCommon,
mission: arMission,
chat: arChat,
account: arAccount,
},
fa: {
common: faCommon,
mission: faMission,
chat: faChat,
account: faAccount,
},
ha: {
common: haCommon,
mission: haMission,
chat: haChat,
account: haAccount,
},
am: {
common: amCommon,
mission: amMission,
chat: amChat,
account: amAccount,
},
yo: {
common: yoCommon,
mission: yoMission,
chat: yoChat,
account: yoAccount,
},
zu: {
common: zuCommon,
mission: zuMission,
chat: zuChat,
account: zuAccount,
},
ma: {
common: maCommon,
mission: maMission,
chat: maChat,
account: maAccount,
},
// European & other languages
@@ -235,81 +283,97 @@ const resources: Resource = {
common: esCommon,
mission: esMission,
chat: esChat,
account: esAccount,
},
fr: {
common: frCommon,
mission: frMission,
chat: frChat,
account: frAccount,
},
de: {
common: deCommon,
mission: deMission,
chat: deChat,
account: deAccount,
},
el: {
common: elCommon,
mission: elMission,
chat: elChat,
account: elAccount,
},
it: {
common: itCommon,
mission: itMission,
chat: itChat,
account: itAccount,
},
he: {
common: heCommon,
mission: heMission,
chat: heChat,
account: heAccount,
},
pt: {
common: ptCommon,
mission: ptMission,
chat: ptChat,
account: ptAccount,
},
ro: {
common: roCommon,
mission: roMission,
chat: roChat,
account: roAccount,
},
ka: {
common: kaCommon,
mission: kaMission,
chat: kaChat,
account: kaAccount,
},
tr: {
common: trCommon,
mission: trMission,
chat: trChat,
account: trAccount,
},
pl: {
common: plCommon,
mission: plMission,
chat: plChat,
account: plAccount,
},
uk: {
common: ukCommon,
mission: ukMission,
chat: ukChat,
account: ukAccount,
},
nl: {
common: nlCommon,
mission: nlMission,
chat: nlChat,
account: nlAccount,
},
sr: {
common: srCommon,
mission: srMission,
chat: srChat,
account: srAccount,
},
no: {
common: noCommon,
mission: noMission,
chat: noChat,
account: noAccount,
},
sv: {
common: svCommon,
mission: svMission,
chat: svChat,
account: svAccount,
},
};
@@ -335,7 +399,7 @@ i18n.use(initReactI18next).init({
lng: browserLang,
fallbackLng: "en",
supportedLngs: SUPPORTED_LANGUAGES,
ns: ["common", "mission", "chat"],
ns: ["common", "mission", "chat", "account"],
defaultNS: "common",
// Because we control the keys and interpolate only simple values.
interpolation: {

View File

@@ -0,0 +1,69 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -0,0 +1,69 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -0,0 +1,69 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -0,0 +1,69 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -0,0 +1,69 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -0,0 +1,69 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -0,0 +1,69 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -0,0 +1,69 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -0,0 +1,69 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -0,0 +1,69 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -0,0 +1,69 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -0,0 +1,69 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -0,0 +1,69 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -0,0 +1,69 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -0,0 +1,69 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -0,0 +1,69 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -0,0 +1,69 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -0,0 +1,69 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -0,0 +1,69 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -0,0 +1,69 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -0,0 +1,69 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -0,0 +1,69 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -0,0 +1,69 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -0,0 +1,69 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -0,0 +1,69 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -0,0 +1,69 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -0,0 +1,69 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -0,0 +1,69 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -0,0 +1,69 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -0,0 +1,69 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -0,0 +1,69 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -0,0 +1,69 @@
{
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"noAccount": "No account? Sign up"
},
"register": {
"title": "Create your account",
"email": "Email",
"password": "Password",
"submit": "Sign up",
"haveAccount": "Already have an account? Sign in",
"checkEmail": "Almost there — check your email to verify your account."
},
"verify": {
"verifying": "Verifying…",
"ok": "Email verified. You can now sign in.",
"failed": "This verification link is invalid or has expired.",
"toLogin": "Go to sign in"
},
"reset": {
"requestTitle": "Reset your password",
"email": "Email",
"requestSubmit": "Send reset link",
"requestDone": "If that email has an account, a reset link is on its way.",
"confirmTitle": "Choose a new password",
"newPassword": "New password",
"confirmSubmit": "Set password",
"ok": "Password updated. You can now sign in."
},
"dashboard": {
"title": "Account",
"balance": "Allocation",
"total": "Total",
"spent": "Spent",
"reserved": "Reserved",
"remaining": "Remaining",
"manageKeys": "Manage API keys",
"redeemTitle": "Redeem a top-up code",
"redeemPlaceholder": "helexa-topup-…",
"redeem": "Redeem",
"redeemed": "Code redeemed.",
"logout": "Sign out"
},
"keys": {
"title": "API keys",
"create": "Create key",
"label": "Label",
"limitKind": "Limit",
"percent": "% of allocation",
"hardcap": "Hard cap (tokens)",
"value": "Value",
"none": "No keys yet.",
"createdTitle": "Your new API key",
"createdWarn": "Copy it now — you won't see it again.",
"copy": "Copy",
"copied": "Copied",
"archive": "Archive",
"save": "Save",
"status": "Status",
"usage": "Used"
},
"error": {
"generic": "Something went wrong.",
"unauthorized": "Please sign in again."
}
}

View File

@@ -0,0 +1,159 @@
import { useCallback, useEffect, useState } from "react";
import { Alert, Badge, Button, Container, Form, Modal, Table } from "react-bootstrap";
import { useTranslation } from "react-i18next";
import { useAuth } from "../../auth/context";
import { accountApi } from "../../api/account";
import { ApiError, type ApiKeySummary, type CreatedKey } from "../../api/types";
type LimitKind = "percent" | "hardcap";
export default function ApiKeys() {
const { t } = useTranslation("account");
const { token, logout } = useAuth();
const [keys, setKeys] = useState<ApiKeySummary[]>([]);
const [error, setError] = useState<string | null>(null);
// Create-key form state.
const [label, setLabel] = useState("");
const [limitKind, setLimitKind] = useState<LimitKind>("percent");
const [limitValue, setLimitValue] = useState(100);
const [created, setCreated] = useState<CreatedKey | null>(null);
const [copied, setCopied] = useState(false);
const load = useCallback(async () => {
if (!token) return;
try {
setKeys(await accountApi().listKeys(token));
} catch (err) {
if (err instanceof ApiError && err.status === 401) logout();
else setError(t("error.generic"));
}
}, [token, logout, t]);
useEffect(() => {
// load() is async; setState happens after await, not synchronously.
// eslint-disable-next-line react-hooks/set-state-in-effect
void load();
}, [load]);
async function create(e: React.FormEvent) {
e.preventDefault();
if (!token) return;
setError(null);
try {
const key = await accountApi().createKey(token, label, limitKind, limitValue);
setCreated(key);
setLabel("");
await load();
} catch (err) {
setError(err instanceof ApiError ? err.message : t("error.generic"));
}
}
async function archive(id: string) {
if (!token) return;
await accountApi().archiveKey(token, id);
await load();
}
return (
<Container className="py-5 flex-grow-1" style={{ maxWidth: 860 }}>
<h1 className="h3 mb-4">{t("keys.title")}</h1>
{error && <Alert variant="warning">{error}</Alert>}
<Form onSubmit={create} className="surface-elevated p-3 rounded-3 mb-4">
<div className="row g-2 align-items-end">
<div className="col">
<Form.Label className="small">{t("keys.label")}</Form.Label>
<Form.Control value={label} onChange={(e) => setLabel(e.target.value)} />
</div>
<div className="col">
<Form.Label className="small">{t("keys.limitKind")}</Form.Label>
<Form.Select
value={limitKind}
onChange={(e) => setLimitKind(e.target.value as LimitKind)}
>
<option value="percent">{t("keys.percent")}</option>
<option value="hardcap">{t("keys.hardcap")}</option>
</Form.Select>
</div>
<div className="col">
<Form.Label className="small">{t("keys.value")}</Form.Label>
<Form.Control
type="number"
min={0}
value={limitValue}
onChange={(e) => setLimitValue(Number(e.target.value))}
/>
</div>
<div className="col-auto">
<Button type="submit">{t("keys.create")}</Button>
</div>
</div>
</Form>
{keys.length === 0 ? (
<p className="text-muted">{t("keys.none")}</p>
) : (
<Table responsive hover>
<thead>
<tr>
<th>{t("keys.label")}</th>
<th>Prefix</th>
<th>{t("keys.limitKind")}</th>
<th>{t("keys.usage")}</th>
<th>{t("keys.status")}</th>
<th />
</tr>
</thead>
<tbody>
{keys.map((k) => (
<tr key={k.id}>
<td>{k.label || "—"}</td>
<td>
<code>{k.prefix}</code>
</td>
<td>
{k.limit_kind === "percent" ? `${k.limit_value}%` : k.limit_value.toLocaleString()}
</td>
<td>{k.spent.toLocaleString()}</td>
<td>
<Badge bg={k.status === "active" ? "success" : "secondary"}>{k.status}</Badge>
</td>
<td className="text-end">
{k.status === "active" && (
<Button size="sm" variant="outline-danger" onClick={() => void archive(k.id)}>
{t("keys.archive")}
</Button>
)}
</td>
</tr>
))}
</tbody>
</Table>
)}
{/* The raw key is shown exactly once. */}
<Modal show={!!created} onHide={() => setCreated(null)} centered>
<Modal.Header closeButton>
<Modal.Title className="h6">{t("keys.createdTitle")}</Modal.Title>
</Modal.Header>
<Modal.Body>
<Alert variant="warning" className="py-2">{t("keys.createdWarn")}</Alert>
<div className="d-flex gap-2">
<Form.Control readOnly value={created?.key ?? ""} />
<Button
variant="outline-secondary"
onClick={() => {
if (created) void navigator.clipboard.writeText(created.key);
setCopied(true);
}}
>
{copied ? t("keys.copied") : t("keys.copy")}
</Button>
</div>
</Modal.Body>
</Modal>
</Container>
);
}

View File

@@ -0,0 +1,104 @@
import { useCallback, useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { Alert, Button, Card, Container, Form, ProgressBar } from "react-bootstrap";
import { useTranslation } from "react-i18next";
import { useAuth } from "../../auth/context";
import { accountApi } from "../../api/account";
import { ApiError, type AccountBalance } from "../../api/types";
export default function Dashboard() {
const { t } = useTranslation("account");
const { token, logout } = useAuth();
const [balance, setBalance] = useState<AccountBalance | null>(null);
const [code, setCode] = useState("");
const [msg, setMsg] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
if (!token) return;
try {
setBalance(await accountApi().account(token));
} catch (err) {
if (err instanceof ApiError && err.status === 401) logout();
else setError(t("error.generic"));
}
}, [token, logout, t]);
useEffect(() => {
// load() is async; setState happens after await, not synchronously.
// eslint-disable-next-line react-hooks/set-state-in-effect
void load();
}, [load]);
async function redeem(e: React.FormEvent) {
e.preventDefault();
if (!token) return;
setError(null);
setMsg(null);
try {
setBalance(await accountApi().redeem(token, code.trim()));
setCode("");
setMsg(t("dashboard.redeemed"));
} catch (err) {
setError(err instanceof ApiError ? err.message : t("error.generic"));
}
}
const remaining = balance
? balance.allocation_total - balance.allocation_spent - balance.allocation_reserved
: 0;
const pct = balance && balance.allocation_total > 0
? Math.round(((balance.allocation_spent + balance.allocation_reserved) / balance.allocation_total) * 100)
: 0;
return (
<Container className="py-5 flex-grow-1" style={{ maxWidth: 720 }}>
<div className="d-flex justify-content-between align-items-center mb-4">
<h1 className="h3 mb-0">{t("dashboard.title")}</h1>
<Button variant="outline-secondary" size="sm" onClick={logout}>
{t("dashboard.logout")}
</Button>
</div>
<Card className="surface-elevated mb-4">
<Card.Body>
<Card.Title className="h6 text-uppercase text-muted">
{t("dashboard.balance")}
</Card.Title>
{balance && (
<>
<ProgressBar now={pct} className="my-3" />
<div className="d-flex justify-content-between small">
<span>{t("dashboard.total")}: {balance.allocation_total.toLocaleString()}</span>
<span>{t("dashboard.spent")}: {balance.allocation_spent.toLocaleString()}</span>
<span>{t("dashboard.reserved")}: {balance.allocation_reserved.toLocaleString()}</span>
<span>{t("dashboard.remaining")}: {remaining.toLocaleString()}</span>
</div>
</>
)}
<Link to="/account/keys" className="btn btn-primary btn-sm mt-3">
{t("dashboard.manageKeys")}
</Link>
</Card.Body>
</Card>
<Card className="surface-elevated">
<Card.Body>
<Card.Title className="h6">{t("dashboard.redeemTitle")}</Card.Title>
{msg && <Alert variant="success" className="py-2">{msg}</Alert>}
{error && <Alert variant="warning" className="py-2">{error}</Alert>}
<Form onSubmit={redeem} className="d-flex gap-2">
<Form.Control
value={code}
placeholder={t("dashboard.redeemPlaceholder")}
onChange={(e) => setCode(e.target.value)}
/>
<Button type="submit" disabled={!code.trim()}>
{t("dashboard.redeem")}
</Button>
</Form>
</Card.Body>
</Card>
</Container>
);
}

View File

@@ -0,0 +1,64 @@
import { useState } from "react";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { Alert, Button, Container, Form } from "react-bootstrap";
import { useTranslation } from "react-i18next";
import { useAuth } from "../../auth/context";
import { ApiError } from "../../api/types";
export default function Login() {
const { t } = useTranslation("account");
const { login } = useAuth();
const nav = useNavigate();
const [params] = useSearchParams();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function submit(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
setError(null);
try {
await login(email, password);
nav(params.get("next") || "/account", { replace: true });
} catch (err) {
setError(err instanceof ApiError ? err.message : t("error.generic"));
} finally {
setBusy(false);
}
}
return (
<Container className="py-5 flex-grow-1" style={{ maxWidth: 420 }}>
<h1 className="h3 mb-4">{t("login.title")}</h1>
{error && <Alert variant="warning">{error}</Alert>}
<Form onSubmit={submit}>
<Form.Group className="mb-3">
<Form.Label>{t("login.email")}</Form.Label>
<Form.Control
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>{t("login.password")}</Form.Label>
<Form.Control
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</Form.Group>
<Button type="submit" disabled={busy} className="w-100">
{t("login.submit")}
</Button>
</Form>
<p className="mt-3 small">
<Link to="/register">{t("login.noAccount")}</Link>
</p>
</Container>
);
}

View File

@@ -0,0 +1,70 @@
import { useState } from "react";
import { Link } from "react-router-dom";
import { Alert, Button, Container, Form } from "react-bootstrap";
import { useTranslation } from "react-i18next";
import { useAuth } from "../../auth/context";
import { ApiError } from "../../api/types";
export default function Register() {
const { t } = useTranslation("account");
const { register } = useAuth();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [done, setDone] = useState(false);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function submit(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
setError(null);
try {
await register(email, password);
setDone(true);
} catch (err) {
setError(err instanceof ApiError ? err.message : t("error.generic"));
} finally {
setBusy(false);
}
}
return (
<Container className="py-5 flex-grow-1" style={{ maxWidth: 420 }}>
<h1 className="h3 mb-4">{t("register.title")}</h1>
{done ? (
<Alert variant="success">{t("register.checkEmail")}</Alert>
) : (
<>
{error && <Alert variant="warning">{error}</Alert>}
<Form onSubmit={submit}>
<Form.Group className="mb-3">
<Form.Label>{t("register.email")}</Form.Label>
<Form.Control
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>{t("register.password")}</Form.Label>
<Form.Control
type="password"
minLength={8}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</Form.Group>
<Button type="submit" disabled={busy} className="w-100">
{t("register.submit")}
</Button>
</Form>
<p className="mt-3 small">
<Link to="/login">{t("register.haveAccount")}</Link>
</p>
</>
)}
</Container>
);
}

View File

@@ -0,0 +1,48 @@
import { useState } from "react";
import { Alert, Button, Container, Form } from "react-bootstrap";
import { useTranslation } from "react-i18next";
import { accountApi } from "../../api/account";
export default function RequestReset() {
const { t } = useTranslation("account");
const [email, setEmail] = useState("");
const [done, setDone] = useState(false);
const [busy, setBusy] = useState(false);
async function submit(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
// Always succeeds from the UI's view (no account enumeration).
try {
await accountApi().requestReset(email);
} catch {
/* swallow */
}
setDone(true);
setBusy(false);
}
return (
<Container className="py-5 flex-grow-1" style={{ maxWidth: 420 }}>
<h1 className="h3 mb-4">{t("reset.requestTitle")}</h1>
{done ? (
<Alert variant="info">{t("reset.requestDone")}</Alert>
) : (
<Form onSubmit={submit}>
<Form.Group className="mb-3">
<Form.Label>{t("reset.email")}</Form.Label>
<Form.Control
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</Form.Group>
<Button type="submit" disabled={busy} className="w-100">
{t("reset.requestSubmit")}
</Button>
</Form>
)}
</Container>
);
}

View File

@@ -0,0 +1,64 @@
import { useState } from "react";
import { Link, useSearchParams } from "react-router-dom";
import { Alert, Button, Container, Form } from "react-bootstrap";
import { useTranslation } from "react-i18next";
import { accountApi } from "../../api/account";
import { ApiError } from "../../api/types";
export default function ResetPassword() {
const { t } = useTranslation("account");
const [params] = useSearchParams();
const [password, setPassword] = useState("");
const [done, setDone] = useState(false);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function submit(e: React.FormEvent) {
e.preventDefault();
const token = params.get("token");
if (!token) {
setError(t("verify.failed"));
return;
}
setBusy(true);
setError(null);
try {
await accountApi().confirmReset(token, password);
setDone(true);
} catch (err) {
setError(err instanceof ApiError ? err.message : t("error.generic"));
} finally {
setBusy(false);
}
}
return (
<Container className="py-5 flex-grow-1" style={{ maxWidth: 420 }}>
<h1 className="h3 mb-4">{t("reset.confirmTitle")}</h1>
{done ? (
<Alert variant="success">
{t("reset.ok")} <Link to="/login">{t("verify.toLogin")}</Link>
</Alert>
) : (
<>
{error && <Alert variant="warning">{error}</Alert>}
<Form onSubmit={submit}>
<Form.Group className="mb-3">
<Form.Label>{t("reset.newPassword")}</Form.Label>
<Form.Control
type="password"
minLength={8}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</Form.Group>
<Button type="submit" disabled={busy} className="w-100">
{t("reset.confirmSubmit")}
</Button>
</Form>
</>
)}
</Container>
);
}

View File

@@ -0,0 +1,40 @@
import { useEffect, useState } from "react";
import { Link, useSearchParams } from "react-router-dom";
import { Alert, Container, Spinner } from "react-bootstrap";
import { useTranslation } from "react-i18next";
import { accountApi } from "../../api/account";
export default function VerifyEmail() {
const { t } = useTranslation("account");
const [params] = useSearchParams();
const [state, setState] = useState<"verifying" | "ok" | "failed">("verifying");
useEffect(() => {
const token = params.get("token");
// Keep all setState in async callbacks (no synchronous setState in the
// effect body): a missing token resolves to a rejected promise.
const run = token ? accountApi().verify(token) : Promise.reject(new Error("no token"));
run.then(() => setState("ok")).catch(() => setState("failed"));
}, [params]);
return (
<Container className="py-5 flex-grow-1" style={{ maxWidth: 480 }}>
{state === "verifying" && (
<p>
<Spinner size="sm" className="me-2" />
{t("verify.verifying")}
</p>
)}
{state === "ok" && (
<Alert variant="success">
{t("verify.ok")} <Link to="/login">{t("verify.toLogin")}</Link>
</Alert>
)}
{state === "failed" && (
<Alert variant="warning">
{t("verify.failed")} <Link to="/login">{t("verify.toLogin")}</Link>
</Alert>
)}
</Container>
);
}

View File

@@ -0,0 +1,109 @@
# Prebuilt-binary spec for helexa-upstream.
#
# Wraps a pre-built `helexa-upstream` binary produced by an upstream CI
# job and packages it for rpm.lair.cafe. The %build phase is a no-op.
# helexa-upstream is a pure-Rust, non-CUDA daemon: the mesh account +
# budget authority. It serves an inbound HTTP API on tcp/8090 (/authz/v1
# for cortex, /web/v1 for the frontend) — hence the firewalld service —
# and connects out to PostgreSQL (the system of record), running schema
# migrations on startup.
#
# Required defines at rpmbuild time:
# upstream_version e.g. "0.1.16"
# upstream_prerelease e.g. "0.1.20260518140530.gitabcdef0"
# ^^^^^^^^^^^^^^^^^^ ^^^^^^^^
# commit time (sec) commit sha
# (used as Release; the timestamp prefix
# keeps same-day builds strictly ordered.)
%global _build_id_links none
%global debug_package %{nil}
%global __strip /usr/bin/true
%{!?upstream_version: %global upstream_version 0.0.0}
%if 0%{?upstream_prerelease:1}
%global upstream_release %{upstream_prerelease}
%else
%global upstream_release 1
%endif
Name: helexa-upstream
Version: %{upstream_version}
Release: %{upstream_release}%{?dist}
Summary: Mesh account + budget authority for helexa (prebuilt)
License: GPL-3.0-or-later
URL: https://git.lair.cafe/helexa/helexa
Source0: helexa-upstream
Source1: helexa-upstream.service
Source2: helexa-upstream-sysusers.conf
Source3: helexa-upstream.example.toml
Source4: LICENSE
Source5: helexa-upstream-firewalld.xml
Requires: firewalld-filesystem
ExclusiveArch: x86_64
Requires(pre): shadow-utils
Requires: systemd
Provides: user(helexa-upstream)
%description
helexa-upstream is the mesh-level authority: it issues user accounts and
API keys, holds the allocation ledger (free grant + redeemable top-up
codes), enforces per-key budgets via a transactional reserve/settle
contract, and reconciles served usage for operator compensation. cortex
validates locally-unrecognised keys against its /authz/v1 surface
(fail-closed); the helexa.ai frontend drives account self-service via
/web/v1. PostgreSQL is the system of record; the schema is migrated on
startup.
%prep
cp %{SOURCE0} ./helexa-upstream
cp %{SOURCE1} .
cp %{SOURCE2} .
cp %{SOURCE3} .
cp %{SOURCE4} .
cp %{SOURCE5} .
%build
# Already built in the upstream CI build job.
%install
install -Dm755 helexa-upstream %{buildroot}%{_bindir}/helexa-upstream
install -Dm644 helexa-upstream.service %{buildroot}%{_unitdir}/helexa-upstream.service
install -Dm644 helexa-upstream-sysusers.conf %{buildroot}%{_sysusersdir}/helexa-upstream.conf
install -Dm644 helexa-upstream-firewalld.xml %{buildroot}%{_prefix}/lib/firewalld/services/helexa-upstream.xml
install -dm755 %{buildroot}%{_sysconfdir}/helexa-upstream
install -Dm644 helexa-upstream.example.toml %{buildroot}%{_sysconfdir}/helexa-upstream/helexa-upstream.toml
%pre
getent group helexa-upstream >/dev/null || groupadd -r helexa-upstream
getent passwd helexa-upstream >/dev/null || \
useradd -r -g helexa-upstream -d /var/lib/helexa-upstream -s /sbin/nologin \
-c "helexa-upstream authority" helexa-upstream
%post
%systemd_post helexa-upstream.service
%preun
%systemd_preun helexa-upstream.service
%postun
%systemd_postun_with_restart helexa-upstream.service
%files
%license LICENSE
%{_bindir}/helexa-upstream
%{_unitdir}/helexa-upstream.service
%{_sysusersdir}/helexa-upstream.conf
%{_prefix}/lib/firewalld/services/helexa-upstream.xml
%dir %{_sysconfdir}/helexa-upstream
%config(noreplace) %{_sysconfdir}/helexa-upstream/helexa-upstream.toml
%changelog
* Mon Jun 23 2026 Gitea Actions <actions@git.lair.cafe> - %{upstream_version}-%{upstream_release}
- Prerelease build from upstream CI binary.