Files
helexa/crates/helexa-upstream/migrations/0001_init.sql
rob thijssen 71106afaf1
All checks were successful
CI / CUDA type-check (push) Successful in 1m37s
CI / Format (push) Successful in 33s
CI / Clippy (push) Successful in 3m14s
CI / Test (push) Successful in 5m38s
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
feat(B1): helexa-upstream crate skeleton + Postgres schema + ledger
First milestone of the mesh-level account/authorization authority (#59).
New workspace crate `crates/helexa-upstream` (binary + lib), mirroring the
helexa-router skeleton: axum `build_app`/`run`, clap `serve --config`,
figment `UPSTREAM_`-prefixed config.

- **Storage:** PostgreSQL via sqlx (runtime query API — builds in CI with no
  DB or offline cache; correctness covered by gated integration tests).
  `migrations/0001_init.sql` is the full schema: users (argon2 hash slot,
  email_verified, registration_fingerprint), email_tokens, accounts
  (allocation total/spent/reserved + `accounts_no_overshoot` CHECK, silent
  `status` deactivation flag, fingerprint_flagged), api_keys (sha256 hash,
  percent|hardcap limit, cap_window, per-key ledger), reservations
  (BIGSERIAL id → maps to cortex Reservation.id u64), top_up_codes,
  served_usage, sessions. Migrations run on startup.
- **Ledger (no-overshoot core):** `ledger::{reserve,settle,release}` —
  reserve takes `SELECT … FOR UPDATE` on the account + key rows so
  concurrent reserves serialize and spent+reserved can never exceed the
  effective cap (= min(resolved key cap, remaining account allocation));
  the CHECK is the DB backstop. Settle clamps actual to [0,reserved] and is
  idempotent; release is idempotent. `resolve_abs_cap` (percent/hardcap,
  i128 math) is pure + unit-tested. Balance semantics here; rolling-window
  sub-caps + RateLimited land with the authz API (B2).
- `/health` does a DB round-trip.
- Config + example TOML ([server]/[db]/[grant] free grant/[abuse]
  fingerprint threshold).

Validated end-to-end against a throwaway Postgres 16: migration applies,
20 concurrent reserves of 100 against a 500 cap admit exactly 5 (reserved
== 500, never over), settle/release idempotent, hardcap key sub-cap binds
below the account, `/health` → db ok. CI runs the cap-math + config unit
tests; the DB integration tests skip cleanly when UPSTREAM_TEST_DATABASE_URL
is unset.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6o3ddqmYNh9kzdwq6eowh
2026-06-23 09:49:19 +03:00

138 lines
6.8 KiB
SQL

-- helexa-upstream initial schema (#59): accounts, keys, ledger, top-up
-- codes, served-usage. The mesh-level authority's system of record.
--
-- Token amounts are BIGINT (i64) throughout; the cortex EntitlementProvider
-- carries u64 but mesh allocations sit comfortably inside i64 and Postgres
-- has no unsigned type.
CREATE EXTENSION IF NOT EXISTS citext;
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- ── Users (web auth: email + password) ──────────────────────────────
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email CITEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL, -- argon2id PHC string
email_verified BOOLEAN NOT NULL DEFAULT FALSE,
-- Browser fingerprint captured at registration (#abuse). Best-effort,
-- client-supplied; the primary signal for silent multi-account
-- detection. NULL when the client could not produce one.
registration_fingerprint TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX users_registration_fingerprint_idx
ON users (registration_fingerprint)
WHERE registration_fingerprint IS NOT NULL;
-- Single-use email tokens for verification and password reset. Only the
-- sha256 of the emailed secret is stored.
CREATE TABLE email_tokens (
token_hash BYTEA PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK (kind IN ('verify', 'reset')),
expires_at TIMESTAMPTZ NOT NULL,
consumed_at TIMESTAMPTZ
);
CREATE INDEX email_tokens_user_idx ON email_tokens (user_id);
-- ── Accounts (the billable allocation ledger) ───────────────────────
CREATE TABLE accounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
allocation_total BIGINT NOT NULL DEFAULT 0,
allocation_spent BIGINT NOT NULL DEFAULT 0,
allocation_reserved BIGINT NOT NULL DEFAULT 0,
-- 'deactivated' is the SILENT abuse flag: keys stop authorizing but no
-- surface ever tells the user why (see resolve → 401).
status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'deactivated')),
-- This account shares a registration fingerprint with >= 1 other.
fingerprint_flagged BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- The no-overshoot backstop to the atomic reserve UPDATE.
CONSTRAINT accounts_no_overshoot
CHECK (allocation_spent + allocation_reserved <= allocation_total),
CONSTRAINT accounts_nonneg
CHECK (allocation_spent >= 0 AND allocation_reserved >= 0)
);
CREATE INDEX accounts_owner_idx ON accounts (owner_user_id);
-- ── API keys (Principal.key_id = api_keys.id) ───────────────────────
CREATE TABLE api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
key_hash BYTEA NOT NULL, -- sha256(raw key)
key_prefix TEXT NOT NULL, -- non-secret display prefix
label TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'archived')),
-- Per-key sub-cap: 'hardcap' = absolute tokens; 'percent' = % of the
-- account's allocation_total (resolved to an absolute at reserve time).
limit_kind TEXT NOT NULL DEFAULT 'percent'
CHECK (limit_kind IN ('percent', 'hardcap')),
limit_value BIGINT NOT NULL DEFAULT 100,
-- serde of cortex_core::entitlements::CapWindow (Balance | Rolling).
cap_window JSONB NOT NULL DEFAULT '{"kind":"balance"}'::jsonb,
-- Per-key running ledger (mirrors the account ledger; Balance semantics
-- in this migration — rolling-window reset lands with the authz API).
key_spent BIGINT NOT NULL DEFAULT 0,
key_reserved BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT api_keys_key_nonneg
CHECK (key_spent >= 0 AND key_reserved >= 0)
);
-- A raw key resolves only while active; the hash is unique among active keys.
CREATE UNIQUE INDEX api_keys_active_hash_idx
ON api_keys (key_hash) WHERE status = 'active';
CREATE INDEX api_keys_account_idx ON api_keys (account_id);
-- ── Reservations (reserve → settle/release) ─────────────────────────
-- id is BIGSERIAL so it maps to the cortex Reservation.id (u64) verbatim,
-- with the Postgres sequence as the sole global authority.
CREATE TABLE reservations (
id BIGSERIAL PRIMARY KEY,
account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
key_id UUID NOT NULL REFERENCES api_keys(id) ON DELETE CASCADE,
reserved BIGINT NOT NULL,
actual BIGINT,
state TEXT NOT NULL DEFAULT 'open'
CHECK (state IN ('open', 'settled', 'released')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
settled_at TIMESTAMPTZ
);
-- The sweeper scans open reservations by age.
CREATE INDEX reservations_open_idx
ON reservations (created_at) WHERE state = 'open';
-- ── Top-up codes (hybrid allocation) ────────────────────────────────
CREATE TABLE top_up_codes (
code_hash BYTEA PRIMARY KEY, -- sha256(raw code)
value BIGINT NOT NULL, -- tokens this code grants
denomination TEXT, -- human label (e.g. "small")
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
redeemed_by UUID REFERENCES accounts(id) ON DELETE SET NULL,
redeemed_at TIMESTAMPTZ
);
-- ── Served-usage ledger (#58 reconciliation) ────────────────────────
-- Absolute per-(operator, account, key, period) served tokens, upserted by
-- each cortex; reconciliation rolls these up for operator compensation.
CREATE TABLE served_usage (
operator_id TEXT NOT NULL,
account_id UUID NOT NULL,
key_id UUID NOT NULL,
period DATE NOT NULL,
served_tokens BIGINT NOT NULL DEFAULT 0,
reconciled_at TIMESTAMPTZ,
PRIMARY KEY (operator_id, account_id, key_id, period)
);
-- ── Web sessions (DB-backed; alt/complement to stateless JWT) ───────
CREATE TABLE sessions (
token_hash BYTEA PRIMARY KEY, -- sha256(session token)
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX sessions_user_idx ON sessions (user_id);