@proteles/alpine-ajax
v0.1.1
Published
Alpine AJAX server helpers for Proteles authentication: target-aware sign-in prompts, route guards and session helpers. Server-only — Alpine AJAX needs no client-side auth script.
Maintainers
Readme
@proteles/alpine-ajax
Server helpers for Alpine AJAX apps authenticating with Proteles.
Alpine AJAX keeps no client auth state — the server renders the HTML — so this package is server-only. There is no browser bundle and no client-side auth script to keep in sync, which is rather the point of choosing it.
Install
npm install @proteles/alpine-ajaxThe one rule
Your sign-in and sign-out controls carry no x-target.
<!-- right: a real navigation to the authorization server -->
<a href="/api/auth/login">Sign in</a>
<form method="post" action="/api/auth/logout"><button>Sign out</button></form>
<!-- wrong: fetches the sign-in page and merges it into #app -->
<a href="/api/auth/login" x-target="app">Sign in</a>That is not a style preference, and it is worth understanding why, because it is the difference between this package and its htmx sibling.
htmx has the same underlying problem — XHR follows redirects, so an
unauthenticated fragment request ends up swapping a login page into a table cell
— and htmx supplies an escape hatch: answer 200 with HX-Redirect and the
browser navigates. @proteles/htmx is essentially that one rewrite.
Alpine AJAX has no such hatch. Its own reference is explicit: "The JavaScript Fetch API follows all redirects transparently, so Alpine AJAX cannot distinguish between 300 class status codes." No response header makes it navigate. A server cannot force a full page load.
So this package does not try. Signing in is a navigation, so it stays one.
What it does instead
Alpine AJAX merges response bodies for every status, including 4xx. So the
right answer to "this fragment needs a session" is not a redirect that fetch
will silently follow — it is a 401 whose body is addressed to the ids the
caller asked for in X-Alpine-Target.
The user sees "Your session has expired — Sign in" exactly where the content
would have gone. No login page in a table cell, no address bar surprise, and the
status is a real one that x-target.4xx, an ajax:error listener and your logs
can all key on.
import { protelesAuthHandler, requireUser } from "@proteles/alpine-ajax";
import { sendNodeResponse } from "@proteles/bff";
// the four auth routes
if (url.pathname.startsWith("/api/auth/")) {
return sendNodeResponse(res, await protelesAuthHandler({ node: { req } }));
}
// a route that renders a fragment
const auth = await requireUser({ node: { req } });
if (auth.response) return sendNodeResponse(res, auth.response);
html(res, `<div id="profile">Hello ${auth.user.email}</div>`);The id in the body matters. Alpine merges by id, and a response that does not
contain the requested one fires ajax:missing and swaps nothing — which, to the
person clicking, is indistinguishable from the click not working. requireUser
takes the ids from the request rather than guessing.
API
| Export | What it does |
| --- | --- |
| protelesAuthHandler(source, config?) | Handles login, callback, logout, me. Redirects stay redirects. |
| guardRequest(source, options?, config?) | Middleware guard. Returns a response to send, or undefined to continue. |
| requireUser(source, options?, config?) | In-route guard. Returns { user } or { response }. |
| getUser(source, config?) | The sanitized user (no tokens), or null. |
| attachUser(source, config?) | Same, cached on the framework's context bag. |
| getSession(source, config?) | The full session including tokens — server-side only. |
| isAlpineRequest(source) | Whether X-Alpine-Request: true is set. |
| alpineTargets(source) | The ids from X-Alpine-Target. |
| signInFragment(targets, options) | The body requireUser sends, if you want to build your own. |
options on the two guards takes message and linkText to change the copy,
and guardRequest also takes publicPaths.
Reacting on the client
The 401 is a normal Alpine response, so the usual hooks work:
<div id="profile" x-init x-target="profile"
@ajax:error="if ($event.detail.status === 401) location.reload()">
</div>Reloading is often the friendliest thing: the guarded page then does a real
navigation to sign-in, with returnTo already set.
Security notes
X-Alpine-Target is a request header, so it is attacker-controlled, and it is
interpolated into HTML. Ids that could not be Alpine targets anyway — anything
outside [A-Za-z][\w:.-]*, or longer than 128 characters — are dropped rather
than escaped, which removes the question instead of answering it. Everything
that is interpolated is escaped as well.
The sign-in response is Cache-Control: no-store. It is per-session by
definition, and a shared cache handing it to the next visitor would be worse
than useless.
Develop
npm run build # tsc
npm run typecheck
npm testThe live loop against a real authorization server runs in the repository's e2e:
sdk/scripts/verify-alpine-ajax.mjs.
