← Blog

Same-origin bought us a lot, and charged us one path space

3 min read

The web app is split by plane: account pages served by the control plane on its own origin, your instance’s pages served by your instance on its. Each app is same-origin with the data it reads.

That buys a lot. No CORS on any data call. The browser is an ordinary OAuth client rather than a privileged one — it ends up holding exactly what an agent holds, which means we did not invent a second auth mechanism for the UI. And opening the app does not wake a sleeping container, because the shell is served from the edge.

Building it surfaced three things the design had not accounted for. This is the second one, which is the one with a general lesson in it.

One origin means one path space

If the app and the API share an origin, an app route and an API operation cannot share a path. Obvious once written down. Not obvious while writing either side, because each side picks the natural name for the thing it is doing.

Four collisions, all of them the natural name:

  • /search — the API’s search operation, and the app’s search screen.
  • /diff — the API’s diff operation, and the app’s compare screen.
  • /proposals — the API’s proposals collection, and the app’s review screen.
  • /orgs — the control API’s organisations collection, and the account app’s screens.

Every one is the name you would choose if you were only building one of the two.

What we did

The contracts kept their names. The apps were renamed around them: /read, /log, /compare, /ask, /find, /review on the tenant app, and the account app’s screens moved under /account.

That direction was not arbitrary. The API contract is the boundary we hold stable for customers, agents and generated clients — three consumers per plane, all of them versioned. An app route is an internal name that one router owns. When two names collide, the one with external consumers wins.

The part that makes it stay fixed

A rule that lives in a design document is a rule until somebody adds an endpoint on a Friday. So the allowlist is one module — the only place the line between app and API is drawn — and a test loads both the real route enums and the real contract operations and proves the two sets are disjoint.

Adding an operation to either contract means adding it to that list and re-running the test. The failure is loud, immediate, and arrives at the moment the mistake is made rather than the moment a route silently starts returning JSON to a browser expecting a page.

The module deliberately imports nothing from the Worker, so the test can run under a plain test runner. A rule enforceable only inside a full deployment is a rule people work around.

The general shape

Same-origin architectures trade a distributed problem for a namespace problem. That is usually a good trade — namespace problems are local, visible and testable, and CORS problems are none of those.

But it is a trade, not a free win, and the invoice arrives late: nothing collides until the day the two halves are served together, by which point both sets of names are in code, docs and muscle memory. If you are going same-origin, write the allowlist and its test on day one, while renaming things is still free.