How this is built

Three deployables, four data stores and five upstreams. This page is the map of what runs where, what holds which data, and what every endpoint is for.

Where a request goes

The two sites are static files. Everything that needs a key, a database or a verified identity goes through the API.

In your hands Served as files · no secret is in them The only thing that decides What it asks, on your behalf Your browser tzortzoglou.eu the portfolio · one call: the contact form app.tzortzoglou.eu signed in · pages only api.tzortzoglou.eu checks the token · re-reads your role · holds every key nothing below this line is reachable without it Firebase who you are Postgres messages · restarts Four upstreams two need a key Open-Meteo · Trafiklab ResRobot · Jolpica · GitHub Actions none of these four is ever called from your browserthe app’s map tiles are the one exception, fetched by your browser itself

Everything that needs a key, a database or an identity goes through the API: two of the five upstreams need a credential, and a key in a page is a key anyone can read with View Source. The one thing that does not is the basemap. Map tiles are fetched one per square as you pan, so your browser asks OpenFreeMap for them directly. Forwarding a few hundred of them a session would mean running a tile server. It is the only upstream your browser contacts itself, so the diagram names it as an exception: its arrow would have to leave from the top of the picture, and every other arrow leaves from the bottom. The upstream list below says what that costs.

The three deployables

Two of them are built from the same repository by two Eleventy configurations that share one set of includes and one stylesheet, so both draw their chrome from the same implementation.

Site tzortzoglou.eu — the public portfolio. Eleventy, output to _site/, served by GitHub Pages. Loads no Firebase at all. static
App app.tzortzoglou.eu — the signed-in app. A second Eleventy build of the same repository, output to _app/. The pages are static; what they show comes from the API, which is what checks who you are. static
API api.tzortzoglou.eu — Express, in its own private repository. Holds every key and is the only thing that talks to a database. node

Where data lives

Four places, and they hold very different things. The weather, the departures and the Formula 1 data are not stored anywhere.

  • Firebase Authentication

    Email addresses and password hashes. The browser trades a password for a signed token, and the API only ever sees the token. Deleting an account here is what actually stops somebody signing in; everything else is a record about them.

  • Firestore

    One document per account: name, role, approval state, home location, home stop. The role is read from here on every single request. It is a separate store from the one above, which is why an account can exist with no document and why erasure removes both.

  • Postgres

    Four tables: contact submissions, a row per process start, which migrations have run, and one row per page view: path, which of the two properties, the referring host and the time, and deliberately nothing else. No address, no cookie, no identifier of any kind, and not the full referring URL, only its host. Browsable on the data view if you are an admin. A contact submission can be deleted one row at a time from Messages, or all of one person's at once by erasure.

  • Memory

    Every upstream answer is cached in the API process for between 45 seconds and six hours, and lost on restart. A bus or a race lives in memory and nowhere else, so there is no history of either to browse.

Until recently nothing in this system could take a person out of any of them. It can now: erasure takes one email address and removes that address's contact messages from Postgres, its account document from Firestore and its sign-in from Firebase Authentication, then answers with a receipt of what it actually removed instead of a success message. One action across three stores, because that is how far a person is spread: deleting from one leaves the other two still holding them.

There is a fifth place, and it is the honest part of this page. A message sent through the contact form is stored and emailed, so a copy sits in a mailbox and in the mail provider's delivery log. Neither is a store this code owns, and nothing in the endpoint list below reaches into them. The erasure preview and the receipt both say so, in the API's own words, so nothing reports a deletion wider than the one that happened.

Why the two sites count differently

The public site counts only after you say yes. The banner is opt-in, declining means nothing is counted at all, and that has not changed. What changed is only where the count goes: to the API above, not to a third party.

This app counts every page load, with no banner. It is behind a login, everyone here is a known account holder, and counting loads of your own service for the running of that service is operational telemetry, not audience measurement. The legal basis is different, so the consent is too. The line that keeps that honest is being told, which is what this paragraph and the privacy notice are for; a count nobody is told about would be the same count with the honesty taken out.

"Everyone here is a known account holder" has to be true of the code, not only of the intent, and for a while it was not: these pages are static files that anyone can fetch, and the sign-in is a redirect JavaScript performs once one has loaded, so a stranger opening /transit/ was counted on the way to being bounced. The beacon now fires from shell.js only after /me has confirmed the session, which is the earliest moment that sentence means anything. Not signed in, not counted.

Erasure does not reach this table, and there is nothing in it to reach. A view row names nobody: there is no account on it, no address, and nothing to join it to a person with, which is deliberate.

The endpoints

Every route the API serves, and the lowest role that may call it, grouped and folded away on a narrow screen. Each one re-reads your role and approval from Firestore before doing anything, because a token proves who signed in, not what they may do now.

Loading the endpoint list…

The five upstreams, in detail

Four of them are proxied. Two of those need a credential, which has to stay on the server; the other two so that one call serves every reader. The fifth is the basemap, and it is not proxied at all: your browser fetches its tiles itself, which makes it the only one of the five your browser has ever heard of. Each one below lists what is called, how long an answer is held, and how their field names become ours.

In the mapping tables, theirs is on the left and ours on the right. A row marked derived is something nobody sent: it is worked out here, and those rows are the decisions, not the transport.

Open-Meteo no key held 10 minutes, a day for place lookups

Forecast, air quality, and turning a place name into a coordinate.

What is called

/v1/forecast current, hourly and five days, timezone=auto
/v1/air-quality European AQI and PM2.5, on a separate host
/v1/search place name to coordinate, held a day. Uppsala does not move

How it is mapped

current.temperature_2m current.temperature
current.apparent_temperature current.feelsLike
current.relative_humidity_2m current.humidity
current.wind_speed_10m current.windSpeed
current.is_day (0 or 1) current.isDay (boolean)
daily.temperature_2m_max[] daily[].max
current.european_aqi air.aqi

Worth knowing

A forecast that is ten minutes old is the same forecast. The window exists to keep one open tab from calling a free service every time somebody switches back to it.

  • Air quality is fetched alongside the forecast and allowed to fail on its own. Losing a forecast because a different service is having a bad morning would be a poor trade.
  • An AQI is only attached when it arrives as a number. A null from upstream would otherwise be drawn as a band on the scale.
Trafiklab ResRobot v2.1 key held 45 seconds

Swedish departures, arrivals, stop search and stops near a point.

What is called

/v2.1/departureBoard the board for one stop id
/v2.1/arrivalBoard same shape, opposite direction
/v2.1/location.name stop search by name
/v2.1/location.nearbystops stops within 1.5 km of a coordinate

How it is mapped

ProductAtStop.line line
ProductAtStop.catOutS category
ProductAtStop.operator operator
direction, or origin on an arrival board towards
time scheduledAt
rtTime, falling back to time expectedAt
expected minus scheduled delayMinutes derived
expected minus now inMinutes derived

Worth knowing

A departure board is wrong the moment it is stale, so this is the shortest window here. Departures and arrivals for the same stop are cached apart, because they are two answers.

  • This one needs a credential, which is what keeps it on the server. That the key travels as a query parameter rather than a header makes it worse than usual: it would sit in the URL of every request a tab made, and a URL is the part everything keeps: the vendor’s own access logs, the browser’s history, any screenshot of a network tab. A header is not logged by default; a query string is.
  • Times arrive as Swedish wall-clock with no offset. The container runs in UTC, so parsing them directly put every departure two hours out. They are resolved against Europe/Stockholm instead, which is tested across both daylight-saving changeovers.
  • The board's `direction` is documented as the last stop of the trip, so it is a stop name rather than the headsign on the front of the bus. UL's own app shows something different because that is UL's headsign, which this feed does not carry.
Jolpica no key held 6 hours for the calendar, 5 minutes for results

Formula 1 calendar, championship standings and race results. The maintained successor to Ergast.

What is called

/{season}/races/ every round and its session times
/{season}/driverStandings/ and constructorStandings
/{season}/{round}/results/ plus qualifying and sprint, each allowed to be absent

How it is mapped

FirstPractice, SecondPractice, … sessions[].label
Circuit.Location.locality locality
Results[].positionText positionText
Results[].status status, passed through unedited
grid minus position gained derived
presence of a Sprint session sprint (boolean) derived

Worth knowing

Two clocks, so two windows. The calendar changes a few times a year; results change the moment a session ends, and this has no way of knowing when that was.

  • Session times arrive as real UTC instants, which is the whole reason this one is pleasant and departures were not. Nothing here converts to a local zone: the instant is sent as ISO and the browser renders it wherever the reader is.
  • A weekend stays the current weekend for three hours after its last session starts. The feed gives start times and no durations, so that number is an estimate and is named as one in the code.
  • Results are read from whichever of the three tables exist. Mid-weekend that is the normal state, and an absent one is Saturday rather than an error.
GitHub Actions key held 2 minutes

Recent workflow runs, for the deploy history on the Health page.

What is called

/repos/{owner}/{repo}/actions/runs the last 30, then the newest few per workflow
/repos/{owner}/{repo}/actions/runs/{id}/jobs only for failures, and only the newest three

How it is mapped

run_started_at startedAt
updated_at minus run_started_at durationMs derived
head_sha, first seven sha
head_commit.message, first line message
the first failing step of the first failing job failedStep derived

Worth knowing

Nobody watches a deploy list continuously, and the token has a rate limit worth respecting.

  • This is the only upstream that knows about a deploy that FAILED. Anything read from the running service — a restart time, a build stamp — can only describe deploys that worked, which is the half nobody needs telling about.
  • Errors from it carry the status code and never the body. A GitHub error body can echo the request back, and the request carries the token.
OpenFreeMap no key held by your browser, never by the API

The map under the circuit on /f1/ and under the stops on /transit/: vector tiles cut to the OpenMapTiles schema, from OpenStreetMap's data, in a light and a dark palette.

What is called

/styles/{positron|dark} the style document, chosen from the theme
/planet/{version}/{z}/{x}/{y}.pbf one vector tile per square, as you pan
/fonts/{fontstack}/{range}.pbf glyph ranges, only for the labels actually drawn
/sprites/… one sheet of icons for the whole style

How it is mapped

Nothing is mapped, because nothing arrives to be mapped. A tile is geometry to be drawn, not a document with fields, so there is no field of theirs that became a field of ours - the only decision here is which of the two styles to ask for, and that is read off the theme. The coordinates these maps are pointed AT come from the Trafiklab and Jolpica entries above, never from here.

Worth knowing

The one upstream that is not proxied, and so the only one of the five a browser talks to itself. The other four are proxied precisely so that it never has to; this one is the exception because tiles are images, fetched one per tile as you pan and zoom, and standing in front of a few hundred images a session would not be forwarding a call — it would be running a tile server. What that trade costs belongs here rather than in a footnote: the request carries the reader's IP address and, in the tile numbers themselves, roughly which part of the world they are looking at and how closely, to a service neither they nor this site has any relationship with. It is also the only entry on this list this code never sees happen, because nothing of it passes through the API — which is why it is written down here, on the privacy notice and on the cookies page rather than left to a network tab.

  • Only the picture is theirs. MapLibre itself is served from /vendor, so no third party is in script-src on any page — which is the distinction that gets lost the moment somebody says “a third-party map”, and it is the half that would actually matter.
  • Needing no key is the only reason a browser can be the one asking. The two keyless upstreams above are still proxied, because one call there serves every reader; a tile fetched by one browser serves that browser and nobody else, so proxying would buy nothing and cost a tile server.
  • The provider was chosen by rendering one, which is the only way this particular thing can be checked. CARTO answers without a key and then prints “API KEY REQUIRED” across every tile - a 200 to curl and a ruined map to a person. OpenStreetMap’s own tiles are clean and keyless, but their usage policy says plainly they are not for an app’s basemap, and testing against them was throttled within minutes, which is that policy working rather than failing. This one asks for no key and sets no limit, and says so as its purpose.
  • Both maps are an addition to a page that already works without one. Blocked, missing or failing, the library takes nothing down with it: the departures and the results still draw, and a place whose coordinate did not resolve says it has none rather than drawing an empty ocean at 0,0.
  • This is written down twice more on purpose — the privacy notice and the cookies page both name the host. A request this code cannot see is one a reader can only learn about by being told, so being told is the whole control they have.

The two credentials are read from the environment on the server and stay out of every response, error message and log line. Upstream errors are reported by status code only, because an error body can echo the request back, and the request carries the key.

Rules the code follows

Each of these is here because breaking it caused a real bug in this codebase.

  • Check the type before coercing a number. Number(null) is 0, Number([]) is 0, and a query string can produce an array without being asked. Every parser guards on typeof first.
  • An unknown role is the lowest role. Role names are an explicit list, not case-folding. Anything unrecognised resolves to the most restrictive answer.
  • A comment may not claim a control the code does not have. If the code cannot be made true, the comment changes in the same commit.
  • A signal that was never about the thing being checked proves nothing about it. A green test suite that never loads a module says nothing about that module.
  • Never invent data on a dashboard. A panel with no source behind it says so.