Notes

A token says who is asking, not whose site it is

Every juudd tool that takes a site checks the caller's account first. A valid sign-in proves a request is genuine; it never decides whose site it touches.

Every tool on juudd that takes a site asks one question before it does anything else: does this account own that site? If not, the call is refused by name and nothing happens. The account is user:<sub>, the subject of the verified sign-in token and nothing else, and a site has exactly one owner.

That is the decision. The rest of this post is why a working sign-in was not enough, which identity we refused to use, and the row of the rule that matters most.

A verified token had never been asked whose

The first version of the server did authentication properly. The token's signature was checked, and it was pinned to this server, so a token minted for a different one was turned away. What it never did was read the result. The verified identity was written onto the request, and no tool looked at it.

Every tool took a site argument and none of them checked it. list_deploys with no argument returned every site on the platform. deploy_site would have replaced someone else's site, and transfer_database would have handed someone else's database to whoever asked. None of it was exposed, only because there was one customer and one token. That stops being true the moment a second person connects, and it stops being true silently.

A verified token says a request is genuine. It does not say whose.

The identity we refused to use

The SDK hands the server a client id, and it was sitting right there. It is the wrong identity in both directions. One customer reaching us from two apps would look like two people. Worse, two customers reaching us through the same app would look like one, and the app is the one almost every customer uses.

Authorising on it would have been worse than not authorising at all, because it would have looked like a control. So the account is the sub claim and only that, and the client id is never consulted.

We also turned down an organisation as the identity. A sign-in that sometimes carries an organisation and sometimes does not would be two accounts for one person, and the second would be locked out of the first one's sites with every credential perfectly valid. An identity whose shape depends on how the session began silently reassigns ownership. Sharing a site between two people is deferred rather than refused, and ADR-0013 says it arrives as an addition over this rule, not a replacement.

A token with no sub gets no account, and no fallback. The server answers 403 and says which claim is missing, because there is nothing to act as.

The whole rule, in four rows

the sitewhat happens
has an owner, and it is the callerproceed
has an owner, and it is somebody elserefused
has no owner and nothing stored under that idclaimed, by deploy_site only
has no owner and has stored versionsrefused to everybody, including the caller

The last row is the one that matters, and it fails closed on purpose. Thirteen sites predated the rule, counted on the day it shipped. Every one belonged to somebody, and "unowned" was a fact about our records, not about the site. Treating it as claimable would have let the first caller to guess an id take a live site with a live database. They were locked until the operator adopted them, and the sentence a caller gets says exactly that.

A site is claimed by deploying to it, because that is the only call that creates one. There is no claim_site: a tool you must remember to call before the real one is a tool you will not call. The claim happens before the bundle is stored, and a primary key decides a race, so two callers reaching for the same new id cannot both win it.

What a refusal says

A refusal names the site and says it belongs to another account. It does not pretend the site does not exist, because a site id is a public hostname that anyone can resolve, so hiding it would buy nothing and confuse the caller.

The order inside the check follows from that. Ownership is read first and suspension second, so a suspended account asking about somebody else's site is told it is not theirs, not that it is suspended. The second answer would confirm the site exists.

The wording lives in one place. The gate returns a refusal or nothing, so no two tools word "you do not own this" a little differently, which matters because the reader is a model that will act on the difference. And the check and the record of the refusal are one call, so a tool cannot forget the second half.

list_deploys with no argument now answers with the caller's own sites, not the platform's. hello reports the account the connection is acting as, because it is the only way a customer can read the identity every refusal is about.

How we know every tool does it

A rule that each tool must remember is a rule one tool will forget. So one test does not keep a list of tools. It asks the server for its tool list, finds every tool that takes a site, and drives each one with an account that does not own it. A tool added later that forgets the check fails there, not in front of a customer.

The same pattern now covers the one kind of tool keyed on a job instead of a site, which the site sweep could not see.

What is not decided

  • The decision was written and shipped on the expectation that sub is the sign-in provider's own id for the person. The first real token read afterwards agreed, which made it a record rather than a hope.
  • Sharing a site between accounts does not exist yet.

The rule, once

A verified token says a request is genuine, not whose site it touches: every tool that takes a site checks the caller's account first, and an unowned site with history is refused to everyone. The tools and their limits are listed in the docs.