Last time: I cut a flagship-platform plan down to three stateless TypeScript services and five written decisions. This chapter is the result: what runs, how a request travels, and what keeps it safe.
TL;DR
- The app reads an Android money manager’s backup from the user’s Google Drive and turns it into reports.
- Three services: a static React dashboard, a public back end (
drive-sync-service) and an internal calculator (analytics-service). - A report request is a pure function of
(backup, timezone, rate overrides). Change the timezone and the same request runs again with new parameters. Nothing is stored. - The security model is mostly subtraction: tokens never reach the browser, the calculator is unreachable from outside, and every container runs unprivileged.
The shape
1
2
3
4
5
6
7
8
9
10
Browser ──► Ingress (Traefik)
├── / ──► web-dashboard React app, served by nginx, static only
└── /api ──► drive-sync-service public API (a BFF)
│ Google sign-in, Drive read-only
│ encrypted cookie session
▼
analytics-service reachable inside the cluster only
│ validate → index → pure report functions
▼
ECB daily exchange rates (+ fallback)
A BFF (backend for frontend) is a server that exists to serve one front end. Here it owns everything that touches Google, so the browser never does.
| Service | Owns | State | Public? |
|---|---|---|---|
web-dashboard |
the UI | none | yes, / |
drive-sync-service |
sign-in, token refresh, folder choice, listing backups, choosing which bytes to analyse | an encrypted cookie | yes, /api |
analytics-service |
validation, business rules, exchange rates, every number on screen | an in-memory cache | no |
A shared packages/contracts package holds the request and response types, so a change to a report’s shape fails the build in both services.
Opening a report, one hop at a time
Say you open the balances page.
- The browser calls
GET /api/backups/:fileId/balanceswith a timezone and optional rate overrides. - drive-sync decrypts the session cookie and refreshes the Google access token if it has expired. If the refresh token is dead it answers
401 REAUTH_REQUIREDand the UI shows a “Reconnect” link. - It checks the file is in the folder you chose. A file ID from anywhere else gets a 404. That check is the line between “your backup folder” and “any file this token could see”.
- It builds the dataset key:
sha256(user id, file id, Drive md5). Two users never share a cache entry, and an edited file gets a new key. - It asks analytics for the report. If analytics answers 404, the dataset isn’t loaded: first open, evicted, or the pod restarted. drive-sync then downloads the backup (with a size limit enforced up front and while streaming),
PUTs it to analytics, and retries once. - Analytics computes the report from the dataset, timezone and overrides, and returns the numbers with the rates it used and any warnings.
The 404-then-upload dance is the design in miniature. Analytics holds parsed backups in an in-memory LRU cache, and the cache is allowed to be wrong. When it’s empty, it gets refilled. That is why a pod can be killed at any time without anyone noticing, and why there is no database to back up.
It is also why “change the timezone” or “override this exchange rate” needs no endpoint of its own. It is the same request with different query parameters.
Security: mostly subtraction
Most of the model is things the system doesn’t have.
- Tokens never reach the browser. The session is an encrypted cookie that only drive-sync can open, and Google access is limited to the
drive.readonlyscope. - Only named reports pass through. drive-sync forwards a report name to analytics only if it is in a fixed allowlist (
REPORTS). A new report is invisible to the outside world until someone adds it there. - Default deny. Every namespace starts from a deny-all NetworkPolicy, and each allowed path (ingress to dashboard, ingress to drive-sync, drive-sync to analytics) is written explicitly.
- Restricted Pod Security. The namespace enforces the
restrictedprofile: no root, no privilege escalation, dropped capabilities. - Small images. The Node services run on distroless images (no shell, no package manager, non-root). The dashboard uses
nginx-unprivileged. The root filesystems are read-only. - A smoke test proves the claim. The deploy check requests analytics from outside the cluster and expects it to fail. A policy nobody tests is a hope.
The calculator
analytics-service is where the rules live, so it is where bugs will live too. Its shape keeps them findable:
- Validate once. A backup is checked against a schema with ajv on upload. Everything after that trusts the shape.
- Reports are pure functions. Input is the dataset, a timezone and rate overrides. Output is numbers. There is nothing to mock and nothing to reset between tests.
- Time is bucketed by the user’s timezone, with weeks starting on Monday. A transaction near midnight lands in the day the user would call it.
- Rates degrade gracefully. ECB rates are cached and served stale if a refresh fails. A user override wins over any fetched rate. A currency with no rate at all is reported as “unavailable”, not guessed.
- Problems become warnings, not failures. A report with one unconvertible transaction still renders, and says so.
What it could do at launch
Sign-in, a folder picker, a backup list, a transactions table, balances and net worth, income and expense, and the Trends charts. Later chapters add budgets, a projection, tags and custom reports.
How it gets onto a cluster
make up creates a local k3d cluster (Kubernetes in Docker) and deploys everything with kustomize. Overlays for local, CI and a multi-node cluster differ in small ways, such as which ingressClassName the Ingress objects use. There is also a CI pipeline that tests, scans and signs images. At this point it is written but has not run once.
A small question worth recording
kubectl port-forward svc/traefik 30080:80 reaches the container’s port 8000, not 80. The Service’s port 80 maps to a named targetPort, and the Traefik chart binds a non-root port because it runs unprivileged. The “80” is the Service’s, not the container’s.
Takeaways
- If a result is a function of its inputs, don’t store it. Recomputing from
(backup, tz, rates)removed the database, the migrations and every “which copy is right” question. - Let the cache be wrong. A cache that can be empty at any time, with a defined refill path, turns pod restarts into non-events.
- Prove the negative. “Analytics is internal” is only true if a test tries from outside and fails.
- Allowlist what a public server forwards. An allowlist fails closed; a blocklist fails whenever you add something.
What’s next
The next chapter moves this off my laptop’s local cluster onto three machines at home. It did not go the way the diagram suggests.
The story continues as the work does.
Built with Claude Code as a pair. What I directed, checked and changed is in the story.
Source: github.com/eyosias-k-negash/ivk8. First commits: e1d849a, 65bac77.