Add an endpoint
Every public route has four parts that change together: the handler, its OpenAPI entry, its row in the authorization matrix, and the web app’s schema for its response. Tests fail when the route, its document or its row is missing, which is the point: a route nobody thought about authorizing is how the 2022 layout got its worst bugs.
-
Pick the route. Name the resource, not the action:
POST /, notv1/ me/ email-change /. A route underv1/ changeEmail /acts on the caller, which keeps IDs of other users out of URLs.v1/ me -
Check who may call it in the use case. In the user service the
appmethod decides, from the identity in the context, never from the body or the path.app.Actorrefuses an anonymous caller with401, and a client acting for itself or a token without the scopes with403:internal/user/app/app.go // Actor returns the user ctx's identity acts for, which must hold every// scope in scopes: an apperr.ErrUnauthorized for an anonymous request, and// an apperr.ErrForbidden for a client acting for itself or a missing scope.func Actor(ctx context.Context, scopes ...string) (uuid.UUID, error) {id, ok := authn.FromContext(ctx)switch {case !ok:return uuid.Nil(), errNotSignedIncase !id.IsUser():return uuid.Nil(), apperr.New(apperr.ErrForbidden, "This needs a user's access token.")}if err := authn.MissingScope(id, scopes); err != nil {return uuid.Nil(), err}return id.UserID, nil}internal/user/app/app.go // RequestEmailChange mails a confirmation link to email, the address the// calling user wants instead of theirs. The address changes only when the// link's code comes back through ConfirmEmailChange, and whether another// account has it is told only then.func (s *Service) RequestEmailChange(ctx context.Context, email string) error {id, err := Actor(ctx, ScopeUserWrite)if err != nil {return err}addr, err := domain.ParseEmail(email)if err != nil {return err}u, err := s.users.ByID(ctx, id)if err != nil {return fmt.Errorf("request email change: %w", err)}if u.Email == addr.String() {return domain.ErrSameEmail}now := s.clock()code, hash := newCode()lc := store.LoginCode{Hash: hash, Purpose: store.PurposeEmailChange, Email: addr.String(), UserID: id, ExpiresAt: now.Add(LoginCodeTTL)}link := s.link(url.Values{"code": {code}}, "profile", "confirm-email")msg, err := outbox.NewMessage(mail.TopicEmailChange, mail.Link{To: addr.String(), URL: link.String(), ExpiresAt: lc.ExpiresAt}, now)if err != nil {return fmt.Errorf("request email change: %w", err)}err = s.uow.Do(ctx, func(tx uow.Tx) error {if err := s.codes.Add(ctx, tx, lc); err != nil {return err}return s.outbox.Add(ctx, tx, msg)})if err != nil {return fmt.Errorf("request email change: %w", err)}return nil}auth checks in its routes instead, after
identify(authn.Authenticate), with the narrowest guard that fits frominternal/:platform/ authn authn.Require(scopes...): any valid token with these scopes;authn.RequireUser(scopes...): a user’s token, not a client’s;authn.RequireFirstParty(): only the web app’s own session, thefirstPartychain in its router.
In auth, a route with none of these is public. Rate-limit a public route that sends email or creates anything.
-
Write the handler with
httpx.Handle. It returns an error instead of writing one, so every failure goes through the one error table and becomes a problem response. It decodes the body withhttpx.DecodeJSON[T](r, maxBytes), which rejects the wrong content type, unknown or duplicate fields, trailing data and oversized bodies, calls one use case, and writes the result withhttpx.WriteJSON:internal/user/httpapi/httpapi.go func (h *handler) requestEmailChange(w http.ResponseWriter, r *http.Request) error {body, err := httpx.DecodeJSON[struct {Email string `json:"email"`}](r, maxBody)if err != nil {return err}if err := h.app.RequestEmailChange(r.Context(), body.Email); err != nil {return err}return httpx.WriteJSON(w, http.StatusAccepted, accepted{})} -
Register it on the service’s gorouter v5 router in
internal/. In the user service, a route that has a caller is wrapped in<service>/ httpapi/ httpapi.go identify(authn.Authenticate), which puts the identity in the context. Without it,app.Actorfinds no identity and every call is a401. A route that takes no caller isn’t wrapped, so a stale cookie can’t fail it. In auth, a route that reads a cookie and changes state also getscookie(httpx.CrossOrigin). Handlers read path parameters with the standardr.PathValue. -
Document it in
api/(OpenAPI 3.1): request body, every response status, and the problem response for errors.<service>.openapi.yaml TestOpenAPIDocumentsEveryRoutefails if a route is missing from the file or the file lists a route that doesn’t exist, and the tests’ rig checks every response against its schema. -
Add its authorization-matrix row in
internal/.<service>/ httpapi/ matrix_test.go TestAuthorizationMatrixcalls every route as each caller that file lists and checks the status each must get, and a route with no row fails it. The user service’s callers areanonymous,client(a client-credentials token, with no user),noScope(the owner, without the route’s scope),other(another user) andowner. auth’s are anonymous, another user, the owner, a token without the scope, a client’s own token, and a third-party app’s token for the user. In the user service, a route that answers with the caller’s user is also checked to return the caller, not someone else. The user service’s rows:internal/user/httpapi/matrix_test.go // The authorization matrix: every route against every kind of caller. A// route added without a row fails TestEveryRouteHasARow.var rows = []access{{method: http.MethodGet, pattern: "/livez", want: all(http.StatusOK)},{method: http.MethodGet, pattern: "/readyz", want: all(http.StatusOK)},{method: http.MethodGet, pattern: "/v1/config", want: all(http.StatusOK)},{method: http.MethodPost, pattern: "/v1/login-links",body: func(_ *matrix, n int) any {return map[string]string{"email": fmt.Sprintf("someone-%d@example.com", n)}},want: all(http.StatusAccepted),},{method: http.MethodGet, pattern: "/v1/me", scope: app.ScopeUserRead,want: [callers]int{http.StatusUnauthorized, http.StatusForbidden, http.StatusForbidden, http.StatusOK, http.StatusOK},},{method: http.MethodPost, pattern: "/v1/me/email-change", scope: app.ScopeUserWrite,body: func(_ *matrix, n int) any { return map[string]string{"email": fmt.Sprintf("new-%d@example.com", n)} },want: [callers]int{http.StatusUnauthorized, http.StatusForbidden, http.StatusForbidden, http.StatusAccepted, http.StatusAccepted},},{method: http.MethodPost, pattern: "/v1/me/email-change/confirm", scope: app.ScopeUserWrite,body: func(m *matrix, _ int) any { return map[string]string{"code": m.ownerCode} },// Another user's code is no code at all to them.want: [callers]int{http.StatusUnauthorized, http.StatusForbidden, http.StatusForbidden, http.StatusBadRequest, http.StatusOK},},{method: http.MethodGet, pattern: "/v1/google/start", path: "/v1/google/start?link=1", scope: app.ScopeUserWrite,want: [callers]int{http.StatusUnauthorized, http.StatusForbidden, http.StatusForbidden, http.StatusFound, http.StatusFound},},{method: http.MethodGet, pattern: "/v1/google/start",want: all(http.StatusFound),},// Without its flow cookie, the callback only sends the browser back.{method: http.MethodGet, pattern: "/v1/google/callback", path: "/v1/google/callback?code=c&state=s", want: all(http.StatusFound)},} -
Update the web app if it calls the route: the zod schema that validates the response in
web/, which must match the OpenAPI schema, the call insrc/ api/ schemas.ts web/, and the screen, with every message in bothsrc/ api/ endpoints.ts web/andsrc/ i18n/ en.ts pl.ts. -
Add an end-to-end case in
e2e/for a flow people use, and update this site’s page for the flow. The REST reference is generated from the OpenAPI document, so it follows on its own.e2e_test.go -
Run the checks:
Terminal window go test -race -shuffle=on ./internal/<service>/...make check
Cookies and cross-site requests
Section titled “Cookies and cross-site requests”Browser requests carry the access_token cookie, so unsafe methods (POST, PATCH, DELETE) go through httpx.CrossOrigin, built on http.CrossOriginProtection: the user service applies it to every route, and auth to each route that reads a cookie. A cross-site form post gets a 403 problem. You don’t need CSRF tokens: the gateway puts the app and the APIs on one origin.