┌─────────────┐ ┌────────────────────┐ ┌──────────────┐
│ Browser UI │────▶│ Next.js (web) │────▶│ Postgres 18 │
│ │◀────│ API routes + SSR │◀────│ (RLS) │
└─────────────┘ └────────────────────┘ └──────────────┘
▲
┌────────────────────┐ │
│ Worker (TS) │─────────────┘
│ FX rate fetchers │
└────────────────────┘
Four components, one database:
- web (
apps/web/) — Next.js 16 app. Serves the UI and exposes API routes for transactions, balances, budget, and FX data. All SQL runs against Postgres via a sharedpg.Poolwith per-request RLS context. - sql-api (
apps/sql-api/) — Two AWS Lambdas behind API Gateway (REST API) for machine clients. Lambda Authorizer validatesApiKeyagent tokens; the handler serves discovery, workspace setup, and SQL with the same v1 machine surface. Separate from the web stack — no ALB involved. - worker (
apps/worker/) — TypeScript process that fetches daily raw exchange rates from ECB, CBR, NBS, NBU, and USDT, stores them infx_rates_raw, and rebuilds query-ready all-pairs daily rates infx_rates_daily. Runs on a schedule (local Docker) or as a Lambda (AWS). - Postgres — single source of truth. Eight tables (six with RLS), one view.
workspaces workspace_members
────────── ─────────────────
workspace_id (PK) workspace_id (PK, FK)
name user_id (PK)
created_at
ledger_entries fx_rates_raw budget_lines
───────────── ──────────── ────────────
entry_id (PK) base_currency (PK) budget_month
workspace_id (RLS) quote_currency (PK) workspace_id (RLS)
event_id rate_date (PK) direction
ts rate category
account_id source kind (base|modifier)
amount inserted_at currency
currency planned_value
kind (income|spend| inserted_at
transfer)
category fx_rates_daily workspace_settings
counterparty ───────────── ──────────────────
note base_currency (PK) workspace_id (PK,RLS)
quote_currency (PK) reporting_currency
accounts (VIEW) calendar_date (PK)
────────────── rate budget_comments
derived from source_rate_date ───────────────
ledger_entries inserted_at budget_month
workspace_id (RLS)
direction
category
comment
inserted_at
workspaces— one row per workspace. RLS: user sees only workspaces they belong to.workspace_members— (workspace_id, user_id) pairs. RLS: user sees only their own memberships.ledger_entries— one row per account movement. Immutable except category/note. RLS byworkspace_id.fx_rates_raw— canonical FX source-of-truth. One row per(base, USD, rate_date)triple plus source metadata. No RLS — global data.fx_rates_daily— query-ready daily all-pairs FX read model. One row per(base, quote, calendar_date)triple. No RLS — global data.budget_lines— append-only. Effective value resolved by latestinserted_atper cell. RLS byworkspace_id.budget_comments— append-only. Same last-write-wins pattern. RLS byworkspace_id.workspace_settings— one row per workspace storing reporting currency. RLS byworkspace_id.accounts— view derived fromledger_entries(inherits RLS automatically).
Data isolation using Postgres Row Level Security with workspace membership checks.
| Role | Used by | RLS | Purpose |
|---|---|---|---|
tracker (owner) |
migrate.sh only |
Bypassed (table owner) | DDL, creates tables/policies/roles |
app |
Web app | Enforced | SET LOCAL app.user_id + app.workspace_id per transaction |
- Web app: proxy.ts extracts user identity (
AUTH_MODE=noneonly for explicit local dev/test →"local",AUTH_MODE=cognito→ JWTsubclaim fromsessioncookie) and forwards it asx-user-idandx-workspace-idheaders. - db.ts:
queryAs(userId, workspaceId, sql, params)wraps each query inBEGIN→SET LOCAL app.user_id→SET LOCAL app.workspace_id→ query →COMMIT. RLS policies check workspace membership viaworkspace_membersand filter byworkspace_id = current_setting('app.workspace_id').
RLS policies check workspace membership via app.user_id and filter by app.workspace_id. Each data table has a PERMISSIVE policy that verifies the user is a member of the workspace and narrows to the active workspace.
For programmatic access (LLM agents, scripts, dashboards), start from GET /v1/, complete email OTP onboarding, and use the returned agent ApiKey.
Machine clients (LLM agents, scripts, dashboards) use a separate path from the browser stack:
Machine: Cloudflare → API Gateway (REST API) → Lambda Authorizer → SQL Lambda → RDS
Browser: Cloudflare → ALB → ECS (Next.js, Cognito Email OTP) → RDS
The SQL API runs on API Gateway (REST API) with its own domain (api.example.com), fully separate from the ALB. This provides per-key rate limiting via Usage Plans (10 req/s, 10k req/day per key), auth at the gateway (Lambda Authorizer with 5-min cache), CloudWatch metrics per endpoint, and a clean boundary for future machine-facing services.
curl / LLM agent
│
▼
GET https://api.example.com/v1/
Authorization: none
...
POST https://api.example.com/v1/sql
Authorization: ApiKey ebta_...
X-Workspace-Id: workspace-id
│
▼
API Gateway → Lambda Authorizer (validates key, resolves identity)
│
▼
SQL Lambda (sets RLS context, executes query)
│
▼
Postgres (same app role + RLS as web app)
Agents start from GET /v1/, complete email OTP on auth.*, store the returned ApiKey, load /v1/me, list or create /v1/workspaces, optionally inspect /v1/schema, select a workspace via /v1/workspaces/{workspaceId}/select, and send SQL to /v1/sql. X-Workspace-Id remains supported for explicit overrides, but is optional after a workspace is selected for that API key. The SQL execution path uses the same app role and RLS enforcement as the web application — SET LOCAL app.user_id and app.workspace_id per transaction.
| Concern | Mitigation |
|---|---|
| Key storage | SHA-256 hash only, plaintext never stored |
| Workspace isolation | Same RLS via SET LOCAL as all other routes |
| SQL injection / DDL | Keyword whitelist: only SELECT/WITH/INSERT/UPDATE/DELETE |
| Resource exhaustion | statement_timeout = 30s, 100-row limit, per-key throttling (10 req/s, 10k/day via Usage Plans) |
| Auth caching | 5-min TTL by Authorization header — repeated requests skip Lambda + DB |
| Stale keys | last_used_at tracking, manual revocation |
| Member removal | Auto-revoke trigger deletes all keys for removed user |
curl -X POST https://api.example.com/v1/sql \
-H "Authorization: ApiKey ebta_ABCD1234_0123456789ABCDEFGHJKMNPQRS" \
-H "X-Workspace-Id: workspace-id" \
-H "Content-Type: application/json" \
-d '{"sql": "SELECT * FROM ledger_entries ORDER BY ts DESC LIMIT 10"}'X-Workspace-Id is optional if the same API key has already called POST /v1/workspaces/{workspaceId}/select. If no workspace is saved and exactly one workspace exists for the user, the API auto-saves and uses that workspace for the key.
All amounts are stored in native currency only. No precomputed report-currency column exists on ledger_entries.
Conversion to the reporting currency uses a two-layer FX model:
fx_rates_rawstores canonical raw market rates against the internal pivot currencyUSD.- The worker rebuilds
fx_rates_daily, which contains exact-date all-pairs rates for every supportedbase -> quotecombination. - Weekend and holiday carry-forward are resolved during the rebuild, not inside dashboard queries.
- The reporting currency is read from
workspace_settings(per workspace) and passed to read queries as thequote_currency. - If an exact daily pair is missing, the converted amount is
NULLand the UI surfaces an unconvertible warning.
Zero built-in auth logic. Two modes controlled by AUTH_MODE env var:
none(default) — no authentication. App binds to127.0.0.1, userId is hardcoded to"local", workspaceId is"local". All data belongs to this single workspace.cognito— passwordless Email OTP via Cognito (Essentials tier, USER_AUTH + EMAIL_OTP). Auth is handled by a standalone Hono service onauth.*. IdToken is stored insessioncookie (Domain=baseDomain), verified byCognitoJwtVerifierin the web app. Extractssubclaim as userId. The browser keeps the active workspace in aworkspacecookie; if it is missing or stale, the app resolves the newest accessible workspace or creates the first one automatically. Redirects toauth.*/loginif the session cookie is missing or invalid. Open registration: anyone can sign up via Cognito — each user gets a first regular workspace plus workspace-scoped isolation via RLS.
Details in apps/web/src/proxy.ts.
- Local — Docker Compose: Postgres + web + worker + migrate init container. See
docs/deployment.md. - AWS — CDK stack: ECS Fargate + RDS + ALB/Cognito + Lambda + WAF + monitoring. Images built in CI, pushed to ECR.
/api/liveis used for ECS and ALB liveness, while/api/healthstays DB-backed and is checked after deploy to confirm readiness. The root domain (domain.com) redirects toapp.domain.comvia ALB rule. To serve your own site on the root domain, deploy it independently and point DNS there. Schema changes must stay backward-compatible for at least one deploy unless you use a separate two-phase rollout. Seeinfra/aws/README.md.