lukia
← All work

04 / APP · KOTLIN · SPRING · NEXT.JS

A read-later app, and the cookie that decided its architecture

Save a URL. It fetches the page, strips it back to the readable part, and keeps it. Kotlin and Spring on one side, Next.js on the other — and a single security requirement that quietly decided how the whole thing is deployed.

It is one person's reading list behind an allowlist, so the link is evidence that it runs rather than an invitation to sign in. No source link either: the repository is private.

readlater's sign-in screen — the app is single-user behind a GitHub allowlist, so this is as far as a visitor gets.
FIG 01 — as far as a visitor gets
DeployedKotlin · Spring · Next.jsOne origin, one containerSingle-user by design

01

Why build one at all

Pocket shut down in 2025 and nothing has clearly replaced it. That is a real gap rather than a pretext, and it makes for an honest project: the requirements were not invented to justify the code, they came from wanting the thing to exist.

It also covers ground the rest of this portfolio does not. A template with an empty script budget and a validation package with no dependencies both argue for restraint. This one has a database, a session, an OAuth flow and a fetcher that talks to the open internet — the parts where getting it wrong actually costs something.

02

One requirement, and everything it decided

The session cookie carries the __Host- prefix. It is the strictest cookie a browser will accept: it must be secure, it must be scoped to the whole origin, and — the part that matters here — it must carry no domain attribute at all. A browser enforces that itself, so a cookie that breaks the rule is not weakened, it is rejected outright.

No domain attribute means the cookie belongs to exactly one origin. And that single line is what settles the architecture: the API cannot live at a different address from the app. The obvious shape for this stack — one service for the front end, one for the back end, wired together — is unavailable, not because it is worse in general, but because it forfeits the cookie.

So the two run as one origin. The browser only ever talks to the Next.js server, which passes three path prefixes through to Spring; Spring listens on a loopback address that nothing outside can reach. Two processes, one container, one address. A rule in the codebase refuses to build a cross-origin API URL, and a test fails if anyone tries — because this is the kind of decision that gets undone by accident a year later by someone reasonably assuming a config value is meant to be configured.

03

Two processes and no supervisor

Running two processes in one container usually means reaching for a process supervisor. I deliberately did not, and the reason is what a supervisor does when something dies: it restarts the child and keeps the container alive.

That is the worst available outcome. The platform's health check asks the front end whether it can answer; if the back end is failing to start underneath, a supervisor produces a machine that looks healthy to the platform and is broken for every visitor, indefinitely, with nothing anywhere reporting a problem.

Instead the container waits for whichever process exits first and then exits too — so the platform replaces the machine, which is the behaviour actually wanted. It is a handful of lines rather than a dependency. The back end is also polled until it answers before the front end starts accepting traffic, because otherwise the health check passes or fails depending on which process won a race, and an intermittently-green deploy is worse than one that fails honestly.

04

A fetcher that assumes the URL is hostile

Saving an article means an authenticated user handing the server a URL and the server fetching it. That is a server-side request forgery primitive if you build it naively: the interesting attack is not a link to somewhere embarrassing, it is a link that points back inward at something only the server can reach.

So the address is checked before the connection, and then the connection is pinned to the address that was checked. Resolving a name twice is its own vulnerability — the answer is allowed to change between the check and the fetch — and pinning is what closes the gap rather than narrowing it.

Redirects get the same treatment individually, because a permitted URL is free to redirect somewhere that never would have been. There are limits on how many hops and how many bytes, since a fetcher without them can be handed something that simply never ends. The extracted content is sanitised before it is stored: everything here arrives from a stranger's server and is later rendered in a browser.

05

What a passing test cannot tell you

The most useful thing I learned building this is that the standard way to test a Spring controller cannot see cookie attributes at all. The fast in-memory harness builds its response from a stub that has no knowledge of the cookie configuration — so it will happily report a plain, unprefixed, insecure cookie while the real server sends a correct one, and a suite full of confident assertions says nothing whatsoever about the guarantee that matters.

Every claim about the session cookie is therefore made against a real server on a real port, driving the whole sign-in exchange end to end against a stand-in identity provider. That is slower and it is the only version that means anything.

The same instinct applies after a test passes: break the thing it protects and confirm it fails. One assertion here compared a value against the very constant it was testing, so changing the constant moved both sides and the test stayed green over a genuine regression. A test that cannot fail is worse than no test, because it reports a safety that is not there.

And some things no suite reaches. The deployed application is behind a proxy, on a real certificate, with a browser enforcing rules no test harness imitates — which is why the last step of every change here is a person using the running site.

06

Deliberately small

It is single-user, behind an allowlist. Sessions live in memory rather than in a table, which means a deployment signs you out — a recorded trade rather than an oversight, with the condition that would reverse it written down. For one user, one click is cheaper than a schema, a cleanup job and another dependency.

That decision has a consequence worth naming, because it is the sort of thing that bites quietly: the app must run as exactly one instance. Two would sign the user out at random, whenever a request landed on the one that did not hold their session — intermittent, unreproducible, and invisible to every health check. The deployment pins it to one machine on purpose, and the note in that configuration says that if it ever wants two, the session store changes in the same commit.

Tags, full-text search and a browser extension are all designed and none are built. The scope that shipped is the scope that was decided.

More work

The same care goes into the paid work.