Cairn
Back-office software for a mountaineering expedition company, where three roles share one operational universe and the same URL renders three different products.
The problem
- Roles sharing one universe
- 3
- Modules they diverge across
- 14
- Route files serving them
- 1
Authorisation is an architecture problem long before it is a UI problem.
A director runs the operation, a field lead commands one expedition, and a participant checks in from the mountain. They need the same data and almost none of the same screens — and the moment that distinction lives in components, every new screen inherits the question of who is allowed to see it.
Expedition management was chosen because it demands every hard class of the problem at once: strict role scoping, time-sensitive coordination, live tracking, incident management and archiving. Most enterprise software touches one or two of those. This touches all of them.
Architecture
Three worlds — Plan, Operate, Record — as a type, not a navigation pattern. Fourteen modules behind a single dynamic route, gated server-side before anything renders. Authorisation is a capability list rather than a role check, so components ask what they may do and never who is asking. The universe is one deterministic seed, relational all the way down.
Plan, Operate and Record are a string literal union, not a navigation pattern. Adding a world is a one-line change TypeScript validates end to end.
Eleven capabilities are the unit of authorisation. Components ask can("operations:command") and never which role is asking, so roles can be redefined without touching a screen.
Fourteen module workspaces behind a single dynamic route, with the capability check running server-side and redirecting before anything renders.
One seed string produces the same ten expeditions, people, incidents and gear every build — relational by ID, so a participant appears in the roster, the manifest and the logbook as the same person.
// src/app/(shell)/[world]/[module]/page.tsxif (mod.requiredCapability) { const role = (await readDemoRole()) ?? "director"; const caps = ROLE_CAPABILITIES[role]; if (!caps.includes(mod.requiredCapability)) { redirect(`/${world.key}/${fallback?.key}`); }}The interface
The Live module is the argument: the same expedition at the same URL is an operator workspace for a director and a check-in panel for a participant. Not a hidden button — a different product.
Switching from director to participant on the Live module does not hide controls — it renders a different workspace against the same expedition.
A module a role cannot use is absent from the tab bar rather than disabled, and typing its URL redirects to the first module that role can reach.
A participant's check-in writes to a subscribed store, so the lead's expedition feed updates without a request or a refresh.
Performance
Five dependencies and no client-side data fetching produce the numbers you would expect: Lighthouse scores 100 on desktop and 98 on mobile against the live deployment, and the main thread never registers a blocked frame. The interesting figure is the one nobody reports — a first request to an idle deployment paints in 5.8s rather than 0.5s, because the platform is still waking up. That cost is real and it belongs to the first visitor of the day.
Across three worlds, served by one dynamic route
The whole authorisation surface, as a string union TypeScript checks
next, react, react-dom, clsx, cva — everything else is hand-written
- Rigorous authorisation, no authentication. The server guard trusts a role cookie the browser sets.
- Eleven capabilities and exactly one of them writes. Everything else is a read.
- The participant's check-in reaches the leader's feed instantly. Both are the same browser — there is no second user.
- The README says no inline styles. There are eight, each a percentage no class can express.