Skip to content

feat(ui): the UI reaches data only through the API, in-process - #124

Merged
jayleaton merged 1 commit into
mainfrom
feat/ui-through-api
Oct 23, 2026
Merged

jayleaton merged 1 commit into
mainfrom
feat/ui-through-api

Conversation

@jayleaton

@jayleaton jayleaton commented Oct 23, 2026 •

Copy link
Copy Markdown
Owner

Summary

JJ's rule: all data access goes through the API, and browser interactions go through UI server actions, never to the API directly. Before this PR the UI called services in-process, next to the API, so a page could reach data without passing the API's access policy and validation. Now the layering is UI → API → services → repositories, in both apps. Calls stay in-process: there is no loopback HTTP hop, and the browser still never sees the API.

flowchart LR
  browser -->|form / Datastar action| action["UI server action<br/>action(apiclient.SignedIn, ...)<br/>CSRF-protected"]
  action -->|"ui.api.UpdateMe(ctx, input)"| client["api.Client<br/>(in-process)"]
  mobile[mobile app / servers] -->|JSON over HTTP| serve["Endpoint.Serve"]
  client --> call["Endpoint.Call"]
  serve --> core{{"same Endpoint:<br/>gate + policy → Validate → handler"}}
  call --> core
  core --> svc[service] --> repo[repository]
Loading
  • api.Endpoint[Input, Result] declares an operation once: method, path, access policy and handler.
    • Serve is the HTTP route, built on api.Route.
    • Call is the in-process path. It runs the same Gate.Admit with the same policy, the same Validate() (identical normalization) and the same handler. Errors come back as values (ErrSignedOut, ErrDenied, *validate.Error, service errors).
  • apiclient (apps/web/internal/apiclient, and its own copy in admin) is the contract: Client plus aliases of the types, inputs, errors and policies. It is the only data package ui/** may import. It has no net/http, so there is no HTTP client and no base URL. An HTTP implementation for a split deploy could implement the same interface later; it isn't built.
  • Gate.Admit now returns the request carrying the resolved caller. A page's API calls reuse it instead of re-resolving (one admin 2FA lookup per request, not one per call), and only a Gate can attach one.
  • The UI has its service ports removed (web ui.Users; admin users/admins/audit/analytics) and takes Deps.API apiclient.Client. It no longer validates inputs itself. A refused API call is answered like a refused page: login redirect (SSE for Datastar) or 403. The UI-owned Auth port for sign-in, sign-up and reset flows stays: it is packages/auth, not app data (see "Open question").
  • The preview command (apps/web/ui/cmd/preview) now serves the real API endpoints over its fake service.

Enforcement (each probed by make lint-layering)

Rule How
UI imports no services, validate, access or api (only apiclient) depguard ui-not-api / admin-ui-not-api (exact-match api$ so apiclient is allowed)
apiclient has no HTTP and no data depguard apiclient (denies net/http, pgx, database, auth, api)
UI makes no outbound HTTP calls forbidigo http.Client/DefaultClient/Get/Head/Post/PostForm in ui/**
UI never names an API route forbidigo config.APIRoutes in ui/**
Rendered HTML/JS has no API path scripts/check-ui-api-paths.sh fails on /api/ in UI templates, generated Go, code and static assets; runs in make lint, make check-fast and CI; the probe plants a template linking /api/core/v1/users/me

PR #38's protections are unchanged and now documented next to the layering: apiOnly 404s browser-shaped requests to /api/* and /healthz, sends no CORS headers, and turns a 405 into a 404.

How to register a route after this change

  1. API endpoint (apps/<app>/internal/api): declare it once with its access policy and serve it:
    widgets.create = Endpoint[validate.CreateWidget, Widget]{"POST", routes.Widgets, access.SignedIn, widgets.add} (path in config.APIRoutes), then mux.Handle(widgets.create.Pattern(), widgets.create.Serve(widgets.gate)). The handler is func(request *http.Request, caller access.Caller, input validate.CreateWidget) (Widget, error). For a handler that does its own decoding, api.Route(gate, access.SignedIn, handler) still works.
  2. Client method: add it to apiclient.Client (with type aliases) and implement it on api.Client with Endpoint.Call.
  3. UI page or server action (ui.go Routes): page(apiclient.SignedIn, "GET "+config.AppRoutes.Widgets, ui.widgetsPage) or action(apiclient.SignedIn, "POST "+routes.WidgetCreate, ui.createWidget). The handler gets caller apiclient.Caller and calls ui.api.CreateWidget(ctx, input). Use ui.refused(...) / ui.apiFailed(...) for access refusals. Never call a service, and never post to /api/ from the browser.
  4. Roles or tenants: add a Policy in access and re-export it in apiclient; see docs/route-access.md.

Example

// api/widgets.go: declare once, serve over HTTP, expose in-process
widgets.create = Endpoint[validate.CreateWidget, Widget]{"POST", routes.Widgets, access.SignedIn, widgets.add}
mux.Handle(widgets.create.Pattern(), widgets.create.Serve(widgets.gate))
func (client *Client) CreateWidget(ctx context.Context, input apiclient.CreateWidget) (Widget, error) {
	return client.widgets.create.Call(ctx, client.widgets.gate, input)
}
// ui: a server action route, which calls the client
action(apiclient.SignedIn, "POST "+routes.WidgetCreate, ui.createWidget)
widget, err := ui.api.CreateWidget(ctx, apiclient.CreateWidget{Name: request.PostFormValue("name")})

Evidence

Real web binary built from this branch, on the sandbox Postgres:

== public: GET /healthz
{"data":{"status":"ok"},"error":null} [200]
== signed out: GET /api/core/v1/users/me
{"data":null,"error":"Unauthorized"} [401]
== signed in: GET /api/core/v1/users/me
{"data":{"user":{"id":"…","name":"Grace","email":"grace-route@example.com",…},"session":{"id":"…","userId":"…","expiresAt":"…"}},"error":null} [200]
== UI signed out: GET /dashboard
303 -> http://127.0.0.1:3417/login
== UI signed in: GET /dashboard
200 <title>Dashboard — My App</title>

UI pages and a server action, through the API client:

== signed out: /dashboard, /dashboard/account
303 -> /login
303 -> /login
login 303 -> /dashboard
== signed in
dashboard 200   <title>Dashboard — My App</title>  ada-route@example.com   (user read via api.Client.Me)
account 200     <title>Account — My App</title>
== server action: profile form post (/dashboard/account/profile)
303 -> /dashboard/account     page now shows value="Ada Lovelace"
== API sees the same change
"name":"Ada Lovelace"
== crawl: 6 public pages + 2 signed-in pages (88 KB of HTML)
API paths in rendered HTML: 0
== browser to the API (PR #38)
Sec-Fetch-Mode navigate: 404
Origin header: 404

One custom policy denies the same data in the UI and the API. A throwaway build gave GET /users/me a policy that denies ada-route@example.com; the edit was reverted after the build and the binary deleted:

== API, denied user: GET /api/core/v1/users/me
{"data":null,"error":"Forbidden"} [403]
== UI, same user: GET /dashboard (reads Me through the API client)
You do not have access to this page [403]
== UI, same user: GET /dashboard/account (other endpoints, still SignedIn)
[200]
== another user is let through both ways
API grace [200]
UI grace /dashboard [200]
log: msg="access denied" path=/api/core/v1/users/me err="access: denied: demo policy blocks ada-route@example.com"
log: msg="ui: access denied" path=/dashboard err="access: denied: demo policy blocks ada-route@example.com"

Boundary tests (apps/web/internal/api/client_test.go, apps/admin/internal/api/client_test.go):

  • every Client method reaches its service with the caller's actor and the API-normalized input;
  • signed out → access.ErrSignedOut and invalid input → *validate.Error, both without touching the service;
  • TestEndpointPolicyHoldsForBothWays: one custom-policy Endpoint answers 403 over HTTP and ErrDenied through Call, and never runs the handler for the denied caller.

Real admin binary, data through its in-process API client:

== admin signed out
/users 303 -> /login    /admins 303 -> /login    /audit 303 -> /login    /analytics 303 -> /login
== admin sign in
login 303 -> /users
== admin signed in
/users 200 <title>Users — Admin</title>
/admins 200 <title>Admins — Admin</title>
/audit 200 <title>Audit log — Admin</title>
/analytics 200 <title>Analytics — Admin</title>
API paths in admin HTML (5 pages): 0
== server action: POST /admins/invite (email=ops-route@example.com)
303 -> /admins      Admins page lists ops-route@example.com; audit log has the invite
server log lines for /api requests: 0

Checks on this branch:

make check-fast    → check-fast: ok in 5.1s (includes "UI renders no API path")
make lint-layering → all ok, including:
  ok    apps/web/ui must not import …/apps/web/internal/services
  ok    apps/admin/ui must not import …/apps/admin/internal/validate
  ok    apps/web/internal/apiclient must not import net/http
  ok    apps/admin/ui must not call config.APIRoutes
  ok    apps/web/ui must not call http.DefaultClient
  ok    apps/web/ui must not render an API path
  ok    apps/admin/ui must not render an API path
go test ./apps/web/internal/api   → ok, 97.1% of api + access
go test ./apps/admin/internal/api → ok, 98.3% of api + access

CI (manual full run, 39934290436) on e545113: ci success, ci-ok posted. Race tests: web/internal/api 97.5%, admin/internal/api 98.7%, web and admin services 100.0%, packages/auth 86.4%.

Open question

Sign-in, sign-up, password reset and email verification still go from the UI's own Auth port to packages/auth in-process, not through apiclient. They are auth flows (Better Auth's endpoints under /api/auth/), not app data, and are already server actions. Moving them behind the API client too is possible (an apiclient.Auth over the auth handler) but is a separate change.

Merge Danger

Door: two-way. No schema or data change; the browser surface is the same.

Blast Radius: transports. Every UI data call in both apps moves from services to the API client. The UI Deps lose their service ports (Users, Admins, Audit, Analytics) in favour of API. Branches that add UI data calls must add an Endpoint and a Client method instead of calling a service. Builds on #120 (route access, merged).

🤖 Generated with Claude Code

@jayleaton
jayleaton force-pushed the feat/ui-through-api branch from e545113 to 6783943 Compare October 23, 2026 09:50
@jayleaton
jayleaton changed the base branch from feat/route-access to main October 23, 2026 09:50
@jayleaton
jayleaton marked this pull request as ready for review October 23, 2026 09:50
Layering is now UI -> API -> services -> repositories in both apps. Each
API operation is declared once as an api.Endpoint (method, path, access
policy, handler): Serve is its HTTP route, and Call runs the same gate,
policy, validation and handler in-process for api.Client, which
implements the UI's apiclient.Client contract. Pages and server actions
call the client; the UI no longer imports services, validators or access
internals, and no longer validates inputs itself.

Browser interactions stay server actions: forms and Datastar actions post
to CSRF-protected UI routes, which call the API client and answer with
HTML. The browser never calls /api/, and PR #38's apiOnly protections
are unchanged. Enforced and probed by make lint-layering: depguard keeps
ui/** to apiclient and apiclient free of net/http; forbidigo rejects
config.APIRoutes and HTTP clients in UI code; check-ui-api-paths.sh
(lint, check-fast, CI) fails on any /api/ path in UI templates, code or
assets.

Gate.Admit now returns the request carrying the resolved caller, so a
page's API calls reuse it (one admin 2FA lookup per request).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jayleaton
jayleaton force-pushed the feat/ui-through-api branch from 6783943 to 8d3d6b9 Compare October 23, 2026 09:52
@jayleaton
jayleaton merged commit 6890123 into main Oct 23, 2026
3 checks passed
jayleaton added a commit that referenced this pull request Oct 23, 2026
…k on a UI page

Turns packages/auth's social sign-in on from env and wires it the way
route-access (#120, #124) wants: the browser only talks to the UI, and the
UI reaches auth only through the API client.

- config.Social: GOOGLE_CLIENT_ID, APPLE_CLIENT_ID, APPLE_TEAM_ID (plain);
  GOOGLE_CLIENT_SECRET, APPLE_PRIVATE_KEY + APPLE_KEY_ID as live secrets,
  validated with every snapshot and swapped in with SetSocialSecrets.
- API: public api.Endpoints under /api/core/v1/auth/social (providers,
  {provider}/start, {provider}/finish) → services.SocialSignInService →
  server.SocialAuth → packages/auth. Cookies (flow state, session) travel
  as data (services.Cookie); apiclient gains SocialProviders,
  StartSocialSignIn and FinishSocialSignIn.
- packages/auth: BeginSocialSignIn / CompleteSocialSignIn return cookies
  as data (StartSocialSignIn / SocialCallback wrap them for handlers that
  write responses); Config.SocialCallbackPath moves the provider callback
  to an app page, and the JSON handler then has no callback routes.
- UI: "Continue with Google/Apple" on login and sign-up for enabled
  providers; the action /auth/social/{provider} (Datastar redirect: the
  CSP's form-action 'self' blocks a plain form POST's redirect) and the
  page /auth/callback/{provider} (GET and Apple's cross-site form POST,
  public; the single-use OAuth state authenticates it), both through
  apiclient only. setCookies writes the cookies the API returns.
  Failures land on /login?error=<code> with a translated message.
- Every /api/ path stays a bare 404 for browsers; callbacks come from
  PUBLIC_URL; docs/sign-in.md covers credentials, turning it on, deleting it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jayleaton added a commit that referenced this pull request Oct 23, 2026
…k on a UI page (#116)

Turns packages/auth's social sign-in on from env and wires it the way
route-access (#120, #124) wants: the browser only talks to the UI, and the
UI reaches auth only through the API client.

- config.Social: GOOGLE_CLIENT_ID, APPLE_CLIENT_ID, APPLE_TEAM_ID (plain);
  GOOGLE_CLIENT_SECRET, APPLE_PRIVATE_KEY + APPLE_KEY_ID as live secrets,
  validated with every snapshot and swapped in with SetSocialSecrets.
- API: public api.Endpoints under /api/core/v1/auth/social (providers,
  {provider}/start, {provider}/finish) → services.SocialSignInService →
  server.SocialAuth → packages/auth. Cookies (flow state, session) travel
  as data (services.Cookie); apiclient gains SocialProviders,
  StartSocialSignIn and FinishSocialSignIn.
- packages/auth: BeginSocialSignIn / CompleteSocialSignIn return cookies
  as data (StartSocialSignIn / SocialCallback wrap them for handlers that
  write responses); Config.SocialCallbackPath moves the provider callback
  to an app page, and the JSON handler then has no callback routes.
- UI: "Continue with Google/Apple" on login and sign-up for enabled
  providers; the action /auth/social/{provider} (Datastar redirect: the
  CSP's form-action 'self' blocks a plain form POST's redirect) and the
  page /auth/callback/{provider} (GET and Apple's cross-site form POST,
  public; the single-use OAuth state authenticates it), both through
  apiclient only. setCookies writes the cookies the API returns.
  Failures land on /login?error=<code> with a translated message.
- Every /api/ path stays a bare 404 for browsers; callbacks come from
  PUBLIC_URL; docs/sign-in.md covers credentials, turning it on, deleting it.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant