Case study · 01

Nura

Nura is an AI journaling app, built on background agent jobs. The app itself is calm and almost wordless, but there's a lot going on underneath it. Two of those decisions matter most to me: how I keep the background work from taking itself down when something fails, and how I decide a session can still be trusted. Both are running below.

Role
Backend architecture & implementation
Stack
Node · TypeScript · PostgreSQL · Redis · BullMQ
Shape
AI journaling app · async agent pipelines

One stuck job shouldn't freeze the whole app

When a session closes, I kick off two background jobs. One looks back over what happened in the session. The other generates an insight, but only when the user's patterns cross a threshold. I put these on separate queues on purpose. If the analysis job stalls, it can't starve insight generation or block it, and a recovery pass comes back around for anything left stuck. The diagram below runs the real thing.

Fig. 01 · Queue isolation & recovery BullMQ · Redis
session .closed analysis worker session-analysis queued insight worker insight-generation queued recovery-scheduler job a·1 b·1
Idle. Press play to run a job through both queues.

There are two separate queues here. When the session-analysis worker stalls, the insight-generation queue keeps draining right past it. The failure doesn't cross over. A recovery pass comes back for the stuck job, so nothing quietly disappears. For the user, it means one broken pipeline never freezes the rest of the app.

A stolen session shouldn't stay alive

Access uses a short-lived token. The long-lived one rotates every time it's used. That rotation is what lets me catch theft. If a token that already rotated shows up again, two people are holding it, and one of them shouldn't be. So I revoke every session for that user at once. Killing only the suspicious request would leave the attacker a working token somewhere else. The diagram catches a reuse as it happens.

Fig. 02 · Token rotation & replay detection JWT · Redis blacklist
auth server RT · 1 valid RT · 2 valid RT · 3 valid rotate rotate access · 15m your device holds RT · 3 another device replays RT · 1
Idle. Press play to watch a session rotate, then get replayed.

Every refresh hands back a new token and retires the old one. If an already-rotated token shows up again, that's the fingerprint of a stolen session, and rejecting that one request isn't enough. So I revoke every session for that user at once. For them, it means a leaked token can't quietly hold a door open somewhere else.


The rest of the system, briefly

Provider abstraction

Claude runs as the main model, with OpenRouter as a fallback and a mock version for tests. Switching models means changing one environment variable, and the code stays untouched.

Context assembly

Each user's context comes from their strongest patterns plus their most recent session summaries, all kept under a 4k-token budget. Every request logs what went into it, so cost and behavior stay easy to trace later.

Clean architecture

The domain, application, infrastructure, and presentation layers stay separate, wired together with constructor injection. That keeps every use case testable without spinning up a database or an HTTP server.

Server-driven UI

The app pulls its feature flags, config, and copy from a bootstrap endpoint when it starts. So rolling out a feature or fixing a bit of wording doesn't need an App Store update.