Note
Grok responding on behalf of Julius.
Thanks for the detailed write-up. I checked it against main (14fe0158ed) by reading the code; I haven't run the repro on a device.
Confirmed in code
- Credentials are per route. A paired route's id is
bearer:<envId>:<origin>(onboarding.ts#L94-L102, used at L136). The resolver looks up the credential by that id, and a learned route borrows the credential of the route it was learned through (resolver.ts#L150-L161, routes.ts#L311-L333). - Re-pairing only writes the new route's credential.
registerreplaces a route only when it has the same id or the same base URL, and otherwise inserts the new route next to the others (registry.ts#L566-L585, routes.ts#L143-L167). The catalog write stores the credential under the new id only (storageDocument.ts#L136-L160). Nothing updates the other bearer routes. Learned routes the server still reports keep their id, so they keep borrowing from whichever route they were learned through (routes.ts#L268-L274). - Legacy ids aren't rewritten. The legacy mobile migration still writes
bearer:<envId>(migration.ts#L67), and #15467 changed onboarding away from that form. I found no code that renames old ids. One caveat: re-pairing at the same base URL does replace a legacy route, becausefindRouteToSameAddresscompares addresses, not ids. - Removing a route deletes its credential.
removeRoute→routesAfterRemovingdrops the route and any learned routes that borrow from it (registry.ts#L946-L968, routes.ts#L340-L361).setRoutesInCatalogthen deletes the dropped routes' credentials (storageDocument.ts#L77-L133). So in your reduced repro (pair over LAN, revoke, re-pair over Tailscale, remove the Tailscale route), only the LAN route is left, still holding the revoked token. - Transient errors win over blocked ones.
connectOverRoutesreturnstransient ?? blocked, and it tries routes that failed the 2.5 s check last (driver.ts#L78-L135). A timeout on an unreachable LAN route is therefore reported instead of the 401 from Tailscale. The doc comment says this is deliberate, so the supervisor keeps retrying a route that may come back. - Every 401 shows the same message.
EnvironmentAuthInvalidErroralways maps to "The environment credential is invalid." (errors.ts#L121-L126). - Blocked has no re-pair action. On a
ConnectionBlockedError, the supervisor goes toblockedand waits for a signal (supervisor.ts#L928-L944). The presentation layer turns that into anerrorphase that carries the message (presentation.ts#L51-L56). The shared mobile notice only offers Retry now (EnvironmentConnectionNotice.tsx#L88-L96). I didn't check every mobile screen.
One correction
The client isn't dropping "Session token expired." That text is the server's log annotation (EnvironmentAuth.ts#L695-L705, SessionStore.ts#L105-L116). The 401 body only carries reason: missing_credential | invalid_credential (environmentHttp.ts#L98-L101, http.ts#L96-L112). Showing "expired on " would need a contract change as well as a client change.
Not verified
- I couldn't reconstruct from code exactly how your Tailscale route ended up holding the September token. If it was a route learned through the legacy
bearer:<envId>LAN route, re-pairing at the same LAN URL should have replaced that route and deleted its credential. The learned route would then point at a missing credential and fail with "Connection credential … is unavailable", not send the old token. Re-pairing doesn't cascade to learned routes the wayroutesAfterRemovingdoes. Can you say whether the Tailscale route was one you added yourself, or one the app learned? And was the September LAN pairing at exactly the same URL as the October one?
Possible fixes (untested sketches)
- After a bearer pairing, write the new token to every direct bearer route of that environment. Another option is to key direct-route credentials per environment instead of per route, and keep relay and SSH separate.
- When re-pairing replaces a route, re-point or drop the learned routes that borrow from it, matching what
routesAfterRemovingalready does. - In
connectOverRoutes, keep retrying while any route failed transiently, but surface an authenticationblockederror from a route that reached the right server. - Add an expired/revoked reason to
EnvironmentAuthInvalidReason, and show a Pair again action on authentication failures.
Workaround: as you found, remove the whole environment on the phone and pair again.
Related: #13055 (fixed 30-day session lifetime, which triggered this; open PR #13092 is titled as making the lifetime configurable), #15802 (re-pairing at the same address leaves a stale entry), #13072 and #8112 (other "credential is invalid / have to re-pair" reports). #5332 is closed. None of the open PRs I checked change this re-pair or removal path.