Repository navigation
feat(ui): the UI reaches data only through the API, in-process - #124
Merged
Merged
Conversation
jayleaton
force-pushed
the
feat/ui-through-api
branch
from
October 23, 2026 09:50
e545113 to
6783943
Compare
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
force-pushed
the
feat/ui-through-api
branch
from
October 23, 2026 09:52
6783943 to
8d3d6b9
Compare
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>
This was referenced Oct 23, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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]api.Endpoint[Input, Result]declares an operation once: method, path, access policy and handler.Serveis the HTTP route, built onapi.Route.Callis the in-process path. It runs the sameGate.Admitwith the same policy, the sameValidate()(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:Clientplus aliases of the types, inputs, errors and policies. It is the only data packageui/**may import. It has nonet/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.Admitnow 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.ui.Users; admin users/admins/audit/analytics) and takesDeps.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-ownedAuthport for sign-in, sign-up and reset flows stays: it is packages/auth, not app data (see "Open question").apps/web/ui/cmd/preview) now serves the real API endpoints over its fake service.Enforcement (each probed by
make lint-layering)services,validate,accessorapi(onlyapiclient)ui-not-api/admin-ui-not-api(exact-matchapi$soapiclientis allowed)apiclienthas no HTTP and no dataapiclient(deniesnet/http, pgx, database, auth, api)http.Client/DefaultClient/Get/Head/Post/PostForminui/**config.APIRoutesinui/**scripts/check-ui-api-paths.shfails on/api/in UI templates, generated Go, code and static assets; runs inmake lint,make check-fastand CI; the probe plants a template linking/api/core/v1/users/mePR #38's protections are unchanged and now documented next to the layering:
apiOnly404s 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
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 inconfig.APIRoutes), thenmux.Handle(widgets.create.Pattern(), widgets.create.Serve(widgets.gate)). The handler isfunc(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.apiclient.Client(with type aliases) and implement it onapi.ClientwithEndpoint.Call.ui.goRoutes):page(apiclient.SignedIn, "GET "+config.AppRoutes.Widgets, ui.widgetsPage)oraction(apiclient.SignedIn, "POST "+routes.WidgetCreate, ui.createWidget). The handler getscaller apiclient.Callerand callsui.api.CreateWidget(ctx, input). Useui.refused(...)/ui.apiFailed(...)for access refusals. Never call a service, and never post to/api/from the browser.Policyinaccessand re-export it inapiclient; seedocs/route-access.md.Example
Evidence
Real web binary built from this branch, on the sandbox Postgres:
UI pages and a server action, through the API client:
One custom policy denies the same data in the UI and the API. A throwaway build gave
GET /users/mea policy that denies ada-route@example.com; the edit was reverted after the build and the binary deleted:Boundary tests (
apps/web/internal/api/client_test.go,apps/admin/internal/api/client_test.go):Clientmethod reaches its service with the caller's actor and the API-normalized input;access.ErrSignedOutand invalid input →*validate.Error, both without touching the service;TestEndpointPolicyHoldsForBothWays: one custom-policyEndpointanswers 403 over HTTP andErrDeniedthroughCall, and never runs the handler for the denied caller.Real admin binary, data through its in-process API client:
Checks on this branch:
CI (manual full run, 39934290436) on e545113:
cisuccess,ci-okposted. 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
Authport topackages/authin-process, not throughapiclient. 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 (anapiclient.Authover 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
Depslose their service ports (Users,Admins,Audit,Analytics) in favour ofAPI. Branches that add UI data calls must add anEndpointand aClientmethod instead of calling a service. Builds on #120 (route access, merged).🤖 Generated with Claude Code