Skip to content
➜cat tradestats/architecture.md
DEPLOYED·tradestats.online
cd ../tradestats
TIER_01

Architecture

Five decisions that shape the system. The theme running through all of them is that the client is never the authority, and time is stored once.

➜STACK_TOPOLOGY--tiers=4 --serverless
policy enforced below the client

Tier_01 // edge

// static bundle, no server of my own

Browser

React 19 SPA on Vercel. Marketing page public, everything else gated.

Tier_02 // application

// the client holds a session, never a secret

Vite // React 19

Router, calculator, journal, analytics. Bundle carries the anon key only.

Google OAuth

Supabase is the OAuth client. No password ever reaches the app.

Realtime channel

Postgres changes streamed to the dashboard for live level updates.

Tier_03 // policy

// the only place authorisation is decided

Row-level security

Every table filtered by user_id = auth.uid(). Reads and writes both.

Analytics RPCs

Security invoker, so aggregates obey the same policies as the rows.

Tier_04 // data plane

// one instant, stored once

Postgres

Trades, strategies, expenses, patterns. Every timestamp TIMESTAMPTZ.

Object storage

Chart screenshots and receipts under a per-account path prefix.

➜ invariant: user_id = auth.uid() on every row[the client is never the authority]
➜DECISIONS--count=5
[01]SHAPE

No backend of my own

PROBLEM

A trading journal for one person does not justify a server to operate, patch, and pay for. But a client-only app usually means authorisation lives in the client, which is not authorisation at all.

APPROACH

Supabase supplies Postgres, auth, storage, and realtime. The React bundle talks to it directly with the anon key, and every rule that matters is a row-level security policy in the database. There is no API tier to bypass because there is no API tier.

[02]POLICY

Authorisation below the query

PROBLEM

The anon key ships inside the JavaScript bundle. Anyone can read it, open a console, and issue their own queries. Any check written in React is decoration.

APPROACH

Each table carries a user_id defaulting to auth.uid(), and a policy that filters both reads and writes to the owner. The session token decides what the row-level filter resolves to, so a hand-written query from the browser console returns exactly the same rows as the app.

[03]AGGREGATES

Functions that obey the same rules

PROBLEM

Analytics runs as Postgres functions so the aggregation happens next to the data. Written as security definer they run as the owner, which quietly bypasses row-level security and returns every account's numbers to whoever calls.

APPROACH

The analytics functions were switched to security invoker. They execute as the caller, the policies apply, and the aggregate covers only the caller's rows. Nothing in the function bodies changed. The privilege they ran under did.

[04]TIME

One instant, many clocks

PROBLEM

A trader in Mumbai and a trader in Chicago open the same journal. If the app stores wall-clock time, the record is wrong for one of them. If it converts everything blindly, calendar dates slide by a day.

APPROACH

Instants are TIMESTAMPTZ, absolute and stored once, rendered in the timezone on the profile. Date columns, the day an expense was recorded, are formatted verbatim and never pass through a timezone. Day and hour buckets in analytics are computed in the caller's zone, so a trade at 02:00 local counts against the local day.

[05]INPUT

Wall clock in, instant out

PROBLEM

A datetime input hands back a naive string with no zone attached. Reading it with the browser's zone is wrong the moment a user picks a timezone different from their laptop.

APPROACH

Both directions run through a converter that resolves the wall clock against the chosen zone, with a refinement pass so instants near a daylight-saving boundary land correctly. Round trips are covered by tests across five zones and both sides of a DST change.

➜// withheld on purpose

Policy bodies, table definitions, function signatures, and bucket paths stay internal. They change, and publishing them helps nobody except someone probing the deployment. What stays stable is the claim: the database is the authorisation layer, and every read path, including aggregates, resolves through it.