Migrating from the 2022 layout
The rewrite replaces the API outright. There is no compatibility layer: the old API was built around generic command dispatch, and that shape is what made authorization easy to get wrong. This page maps each old endpoint and concept to its replacement, so you can move a client or a fork across.
The old code is tagged legacy-2022, with the October 2026 security fixes applied; see the changelog. Some reasons below describe the code before those fixes.
User endpoints
Section titled “User endpoints”| 2022 | Now | Why |
|---|---|---|
POST / |
POST / |
The account is created when the code is redeemed, so typing an address creates nothing. |
POST / |
POST /, then POST / with the code |
The emailed link carried the 365-day token itself. It now carries a single-use, 15-minute code. |
POST / |
POST /, then .../ |
The new route acts on the caller, never on an ID from the body, and changes nothing until the new address confirms. |
POST / |
GET / |
Google sign-in runs only through the OIDC callback, where Google vouches for the email. |
POST / |
removed | Facebook login is dropped: it isn’t OIDC and adds nothing to the example. |
POST /, POST / |
GET /, GET / |
OIDC with PKCE, email_verified required. The old callbacks were POST-only and couldn’t complete. |
POST /, POST / |
removed | As above. |
GET / |
GET / |
Unchanged in purpose; needs user:read. |
GET / |
removed | There is no user list. An admin listing, if you add one, needs its own scope. |
GET / |
removed | Use /. A user reads only their own account. |
GET /, GET / |
GET /, GET / |
Readiness now turns 503 while a service drains. |
Auth endpoints
Section titled “Auth endpoints”| 2022 | Now | Why |
|---|---|---|
GET and POST / |
GET and POST / |
Adds a consent screen and requires PKCE S256. Consent used to be silent. |
POST / |
POST / |
Authorization code with PKCE, client credentials and refresh only. The password grant is gone. |
POST / |
POST / |
The secret is shown once and stored as an argon2id hash, not kept in plaintext events. |
POST / |
DELETE / |
Deleting a client now revokes its grants. |
POST / |
removed | Tokens come only from a session or the token endpoint. |
POST / |
DELETE /, DELETE /, POST / |
Sign out, revoke a device, or revoke an app’s token. |
GET / |
GET / |
Only the caller’s clients, never a secret, paginated with a ceiling. |
GET / |
removed | Use the list. |
GET / |
removed | Access tokens are JWTs and aren’t stored. |
GET / |
GET / |
Sessions replace stored login tokens. The list shows the caller’s own devices, with no token material. |
| 2022 | Now | Why |
|---|---|---|
UserService.DispatchUserCommand |
removed | Each change has its own route and use case instead of generic dispatch. |
UserService.GetUser, UserService.ListUsers |
removed | Nothing needs them: each API reads its own data. |
AuthenticationService.ValidationBearerToken |
removed | Each API verifies tokens itself with auth’s JWKS, with no call per request. |
AuthenticationService.DispatchClientCommand, DispatchTokenCommand |
removed | Clients and sessions have explicit REST routes. |
user.v1.LoginService/, in proto/ |
The one internal call, from auth only, with a scoped token. |
Tokens and cookies
Section titled “Tokens and cookies”| 2022 | Now | Why |
|---|---|---|
HS512 JWT signed with AUTH_SECRET or USER_SECRET |
ES256 JWT signed in internal/, private key in auth only, public keys at GET / |
With a shared secret, every service that can verify a token can also forge one. |
| One 365-day token with every permission | 15-minute access token and a 30-day refresh token, rotated with reuse detection at POST / and POST / |
A leaked token is worth minutes, not a year. |
?authToken= query parameter |
Authorization: Bearer header or the access_token cookie, read by internal/ |
Query strings end up in logs and browser history. |
at cookie, readable from JavaScript, not cleared on logout |
access_token and refresh_token cookies, HttpOnly, set by POST / and cleared by DELETE / |
A script on the page can’t read them. |
Permissions from the client’s registered scopes, scope all |
Scopes user:read, user:write, clients:read, clients:write in internal/, granted at consent |
There is no catch-all scope. |
Concepts and layout
Section titled “Concepts and layout”| 2022 | Now | Why |
|---|---|---|
pkg/ |
internal/ |
Nothing imported it, and internal stops forks depending on a kit that isn’t a stable API. |
cmd/ |
internal/, internal/, and a thin cmd/ and cmd/ |
Grouped by what the code is about. |
| Command bus and command handlers | Use-case methods in internal/ |
A bus hid the call graph and once crashed the process on a cancelled request. A method call does neither. |
| Event bus and async event handlers | Projections in the append transaction (internal/), side effects through internal/ |
Async handlers dropped errors and let read models drift for good. |
| Event types named by their Go type | Registered, versioned names such as user.registered.v1, with upcasts: internal/, and the names in internal/ |
Renaming a type no longer orphans stored events. |
| No version check on append | es.ErrConcurrency in internal/, a 409 |
Two writers could both append the same version. |
pkg/ |
internal/, behind one contract suite in internal/ |
The MySQL event store couldn’t write or read. A shared suite holds every backend to the same rules. |
pkg/ with stack traces |
internal/ kinds and fmt.Errorf("…: %w") |
Nobody read the stacks. Kinds decide the status code. |
{"code", "message"} error bodies |
RFC 9457 problem details, written by internal/ |
A standard shape with a request ID, and no internals in 5xx bodies. |
golog, trace, expvar and the debug adapter |
log/ and OpenTelemetry, set up in internal/ |
Standard tools, off by default where they cost anything. |
gocontainer, message-bus, pubsub, pushpull, shutdown, gollback |
the standard library, internal/ for shutdown, or removed |
Each has a standard replacement, or the redesign removed its job. |
| Build tags inside packages | Store build tags only in cmd/ and cmd/ |
Packages compile the same way for every store. |
cmd/ and an init container |
<service> migrate, run by the Helm hook in deploy/, or MIGRATE_ON_START in compose.mysql.yaml and compose.mongodb.yaml |
One image, one schema source. |
Terraform, Telepresence, / aliases, a self-signed TLS setup |
compose.yaml on localhost, and one Helm chart in deploy/ |
No cluster or hosts edits needed to run it. |
| maildev | Mailpit, the mailpit service in compose.yaml |
|
cmd/ |
web/: Vite, React 19, strict TypeScript |
Rebuilt small, with cookies only and no tokens in JavaScript. |
go-oauth2/ |
A small OAuth2 server in internal/ |
Three grants are small enough to own, and the library had no refresh-token reuse detection. |
Ingress v1beta1 |
Gateway API HTTPRoute, Ingress optional, in deploy/ |
v1beta1 was removed in Kubernetes 1.22. |