← Changelog

The Portal Moves Out — and We Write Down the URL Shape Before It Hardens

The client portal leaves the public marketing repo for a private submodule, and the address model gets decided on purpose: lossless.at/<client>/<service>. The crux is that MCP's OAuth won't tolerate a naive subpath proxy.

· Michael Staton · with Claude Code on Claude Opus 4.8

Backfilled 2026-08-15. This day had no entry despite being the most architecturally consequential one in the repo — nearly everything since depends on the decisions made here.

Why Care?

Two things happened on the same day, and they are the same decision seen from different angles.

The client portal — the page each client bookmarks to reach their own tools — moved out of this repo entirely, into a private one. And the URL shape every client tool would live at got decided deliberately, written down, and the hard part named, before any of it hardened into something expensive to change.

The portal had to move because this repo is public marketing and the portal knows the client roster. Those cannot share a tree. But moving it forced the second question immediately: if the portal is the front door, what are the addresses behind it?

What's New?

  • hubs/lossless-at — the portal extracted to a private repo, mounted as a submodule. Path pluralised to hubs/ to leave room for more than one.

  • Deployment moved to Vercel, from its own repo rather than this one.

  • The path model decided: lossless.at/<client>/<service> — one owned URL shape for every client and every tool.

  • Normalize-Paths-Everywhere.md — the target written down along with the thing that makes it non-trivial.

  • A decisions/ record capturing the choice: portal on Vercel, DNS to Vercel, per-client isolation preserved.

Why the portal could not stay

This repo is the marketing surface. Its README argues the thesis in public; it is meant to be read by strangers.

The portal is the opposite: it enumerates who the clients are and where their tools live. clients.ts is a roster. Keeping it in a public repo would have published the client list as a side effect of publishing the argument.

client-stacks/ was already gitignored for the same class of reason — but gitignoring an app you need to deploy doesn't work, because the deploy needs the source. A separate private repo was the only shape that satisfies both.

Pluralising to hubs/ was a small thing that cost nothing and left room. There is still one hub, but the path no longer assumes it.

The crux, named before it bit

The appealing version of the path model is "just proxy everything under a subpath." Write down lossless.at/<client>/crm/ and have it quietly serve the client's Twenty.

That breaks on MCP.

The MCP OAuth handshake advertises absolute endpoints, derived from the service's own SERVER_URL. A client that starts authenticating at lossless.at/palmer-ai/crm/mcp gets handed discovery metadata pointing back at the raw Railway host — so the login bounces out of the clean address and into the ugly one, and dynamic client registration fails.

So the model that got written down draws a line:

  • Web dashboards stay links-out. lossless.at/<client>/<service> is a 307 to wherever the tool actually lives. The address is memorable and the browser lands on the real host.

  • Only the API/MCP surface gets the clean path, and only once the handshake has been proven end-to-end through a real connector.

Naming that distinction on day one is why the portal has never shipped a broken MCP address. The temptation to "just proxy it" is strong precisely because it looks like it should work.

What this made possible, and what it didn't

Everything since runs on this foundation. Readable client addresses shipped four days later. The wikis got owned hosts on top of it. The 99-tool paradigm showcase that now lives at the apex exists because the portal had its own repo to grow in.

It also inherited a limit that has bitten twice since: a redirect points at a host, it cannot hide one. Outline requests /static and /api as absolute paths, so it cannot live under a subpath at all. Postiz derives its auth cookie domain from FRONTEND_URL, and *.up.railway.app is a Public Suffix browsers reject outright. Both needed real owned hosts rather than a clever URL.

The path model survived both, because it never claimed to hide the origin — only to give humans an address worth remembering.

Files changed

  • hubs/lossless-at (new submodule)
  • context-v/issues/Normalize-Paths-Everywhere.md
  • context-v/decisions/lossless-at-path-based-homebase.md