Delivery

The website was the easy part

Between 20 June and 24 July I built the digital side of a private therapy practice in St Petersburg: the public site, a client area, and the admin system behind both. 170 commits, roughly 48,000 lines of TypeScript, 96 API routes, one person from schema to CSS. The public part is at psysteps.com. It is maybe a third of the work.

I am writing this up because clients ask me a version of the same question every time: you say you hold the whole stack, but what does that buy me. This project is a compact answer, because it contains, at small scale, three of the problems I have spent thirty years on at large scale.

What a practice actually needs

The brief said website. Five weeks later the running system does this.

A parent books a session and gets verified as a real person over one of three channels: email, phone, or Telegram. The calendar underneath knows the therapist’s working hours, her time off, and which slots a group already occupies. Clients exist as records with a history, not as form submissions in an inbox. Sessions are marked attended or no-show after the fact. Money is tracked against them. Group sessions have membership and attendance rolls. Reviews get pulled in from an external marketplace on a schedule. The therapist has a login with TOTP on it, because that account can read what parents wrote about their children.

None of that is visible from the front page. All of it is why the practice runs on the system instead of on a notebook and a phone.

Money turns a website into a billing system

Here is the part that made me laugh when I hit it.

The practice sells packages: ten sandplay sessions, paid up front or in parts. Sessions get consumed as they happen. Some are cancelled, some are moved, some are no-shows, and a no-show may or may not burn a session depending on notice. Payments arrive in cash, by card, by transfer, sometimes partially, occasionally in reverse as a refund.

That is a rating and charging problem. It has a different vocabulary and four orders of magnitude fewer events than a fixed-line operator, and structurally it is the same object I ran at PeterStar for eleven years. So I built it the way billing has to be built.

Money lives in integer kopecks as bigint. Never a float, anywhere, at any layer. A payment is its own entity with its own timestamp, not a boolean flag on the appointment, because the moment you model it as a flag you have no way to express a deposit, an instalment, or a refund. A refund is a negative amount against the same client, which keeps the ledger append-only and keeps history honest. Balance and debt compute on read from the events rather than sitting in a counter, because a counter drifts the first time somebody cancels a session at 23:40 and reschedules it at 09:05, and drift in a money counter is the kind of bug you find six months later through a customer complaint.

A denormalised cache would be faster. This practice will not hit that wall this decade, and adding the cache before the wall is how you buy yourself reconciliation work you did not need.

Four transports, two of which can talk back

The comms layer took longer than the entire public site, and every hour of it went into constraints rather than features.

The system reaches a client through the on-site chat, Telegram, email, and SMS. Only the first two are two-way. Email replies land in a live mailbox outside the system, since parsing inbound mail was out of scope on purpose. SMS goes out through a Mikrotik gateway that only exposes an outbound queue, which means no inbound messages at all, 159 characters per segment, Cyrillic transliterated, opt-out handling, a heartbeat to know the gateway is alive, and real money per send. The chat only works while the tab is open, on a three-second poll, with no push channel behind it.

Design around that and the interesting question stops being “how do I send a message”. It becomes: this person is reading the chat right now, so do not also spend an SMS; this one has no phone on file and closed the tab an hour ago, so Telegram is the only thing that will reach them; this reminder already went out once, so the retry must not fire again.

Anyone who has integrated a contact centre recognises the shape. Channel capabilities differ, presence is unreliable, and the escalation logic is the product. The vendor documentation describes the transports, and it says nothing about the choice between them, which is the only part that determines whether the system feels attentive or spammy.

I audited my own API and did not like the result

In the middle of July I stopped building and ran a static security audit against the code I had written, in writing, with the same structure I would use for a client.

It found two high-severity authorisation defects. The worse one was an account takeover in the Telegram login flow: the login token was issued to the initiating browser but bound to the client only by Telegram chat id, so a stranger could generate a login link, send it to a client, wait for them to tap it, and collect an authenticated session by polling the status endpoint. That session reads appointments, contact details, payment history, and the message thread with the therapist. The second one was the chat endpoint signing a session id supplied in the request body, which handed the bearer a valid cookie for somebody else’s conversation.

I wrote both of those. I wrote them while being reasonably careful, in a codebase whose whole point is that it holds sensitive material about children.

The lesson is the one I keep relearning in a different costume. Authorisation bugs do not look like bugs while you are writing the feature, because you are thinking about the happy path and the happy path works perfectly. They only surface when somebody deliberately asks who else could have arrived at this line of code. That question needs its own scheduled session and its own document. If it is a thing you plan to do eventually, you will not do it.

The fix was incomplete, and the test is what said so

I fixed both defects, and then, before publishing this, I sat down to write regression tests for the two login flows. Writing the test is where the second half of the story appeared.

The fix for the account takeover binds the login to the browser that started it: a high-entropy verifier goes into an HttpOnly cookie, its hash goes into the token row, and the flow only completes when the two match. Correct, and I checked it against the endpoint that finishes a Telegram login. There are two endpoints that consume login tokens. The other one serves email magic links, and it burned any verified token presented to it, without asking about the channel or the verifier at all.

So the original attack still worked, one call to the left. Send the victim a deep link, wait for the tap, then post the same token to the magic link endpoint instead of polling the status endpoint, and you get their session. I had fixed the path I was looking at while the vulnerability lived in the shape of the token, which two different paths could redeem.

The repair is small: the magic link path now takes the channel as a required argument and refuses anything that is not email. The general point is not small. A patch aimed at one endpoint is a patch aimed at one endpoint. What made the gap visible was writing a test that had to name what must not happen, because a test forces you to state the rule instead of the fix, and the rule immediately asks where else it applies.

What one person on the whole stack actually buys

The honest answer has two halves.

The gain is that the boundaries sit inside one head. The kopeck decision in the schema, the subscription state machine, the admin UI showing a debt, and the wording of the SMS that mentions it are one decision made four times, not four teams agreeing on an interface. Nothing gets lost between a designer’s Figma frame and a backend contract, because the disagreement gets resolved in the ten seconds it takes me to notice it. Five weeks is not a heroic sprint. It is what happens when nobody has to be told anything.

The cost is that there is no second pair of eyes, and that cost is real. My defence is procedure rather than talent: a written audit on a date, a threat model that assumes the reader is hostile, and negative tests that state what must never happen, wired into CI ahead of the build step so a red one cannot ship. The audit found two high-severity holes. The tests found that one of my two fixes was half a fix. Neither will catch everything, and both beat feeling careful.

The other two

The same approach, different shapes.

lpltd.realgeek.biz is a corporate site for a federal telecom operator, currently on a staging domain ahead of cutover. Service catalogue, a network map with 200-plus access nodes, project passports, a licence registry, and a quote builder that produces a technical spec in three clicks. That last piece exists because the buyer is an engineer at another operator, and engineers abandon contact forms that ask for a phone number and nothing else.

ghost.realgeek.biz is a public status page for internet restrictions in St Petersburg and the Leningrad region: current network state, availability over a period, and a timeline of outages. It is a telemetry problem wearing a small UI, and it exists because I wanted the data for myself.

Why any of this belongs on a consulting site

Because the failure I get called in to fix is almost never a shortage of frameworks.

It is a payment modelled as a boolean. A balance kept in a counter that nobody reconciles. An escalation rule that spends an SMS on a customer who is already watching the chat. An endpoint that trusts an identifier from the request body. These defects cost about the same to avoid at design time in a therapy practice as they do in an operator’s billing, and they cost wildly different amounts to find later.

Five weeks of building for one psychologist put me back through all four of them. That is a decent use of a summer.

Describe your problem in three sentences. I'll tell you honestly whether I can help.

If it isn't my kind of problem, I'll say so and point you somewhere better. Direct email works too: hi@realgeek.biz.