@proteles/alpine
v0.1.1
Published
Alpine.js plugin for Proteles authentication: a reactive $auth store and x-signed-in / x-signed-out directives. Works with no build step via a CDN script tag. Tokens never reach the browser.
Maintainers
Readme
@proteles/alpine
Alpine.js plugin for Proteles authentication — no build step required.
Tokens live only in an encrypted, httpOnly cookie set by your own server. The
browser never receives one, only the sanitized user from /api/auth/me.
Alpine has no component model, so this package looks different from the other
framework SDKs: instead of <SignedIn> components you get a reactive store, a
$auth magic, and two directives you put on any element.
The two-script setup
<!-- 1. Proteles first, so it can hook alpine:init before Alpine starts -->
<script src="https://cdn.jsdelivr.net/npm/@proteles/alpine"></script>
<!-- 2. Alpine -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script>
<body x-data>
<div x-signed-out>
<button @click="$auth.signIn()">Sign in</button>
</div>
<div x-signed-in>
Hi <span x-text="$auth.user?.email"></span>
<button @click="$auth.signOut()">Sign out</button>
</div>
</body>That's it — the bundle registers itself on alpine:init.
⚠️ You need x-data somewhere above your directives
Alpine only initializes directives inside an x-data tree. An element with
x-signed-in and no x-data ancestor is silently never processed, so it stays
visible — showing signed-in content to signed-out visitors. One empty x-data
on <body> (as above) covers the whole page and is the simplest fix.
With a bundler instead
npm install @proteles/alpine alpinejsimport Alpine from "alpinejs";
import { protelesAuth } from "@proteles/alpine";
Alpine.plugin(protelesAuth());
Alpine.start();What the plugin registers
| Thing | Use |
| --- | --- |
| Alpine.store("proteles") | $store.proteles.user, etc. |
| $auth magic | $auth.user, $auth.isLoading, $auth.isAuthenticated, $auth.signIn(), $auth.signOut(), $auth.reload() |
| x-signed-in | shows the element only when signed in |
| x-signed-out | shows the element only when signed out |
Both directives keep the element hidden while the first /api/auth/me is in
flight, so neither branch flashes the wrong UI. They set display the way
x-show does, and restore the element's original value when revealing it.
$auth.isLoading is there if you want an explicit placeholder.
Alpine.store() provides the reactivity: the store is a plain object, and
Alpine's init() convention triggers the first revalidation automatically.
Options
Alpine.plugin(protelesAuth({
basePath: "/api/auth", // BFF mount path (default; or VITE_PROTELES_BASE_PATH)
initialUser: serverUser, // skip the signed-out flash if the page knows the user
revalidateOnInit: true, // set false to trust initialUser and save a request
storeName: "proteles", // Alpine store name
magicName: "auth", // so `$auth`
}));With the CDN build, set window.protelesAuthOptions = {...} before the
script tag, or window.protelesAuthOptions = null to opt out of
auto-registration and call Alpine.plugin(protelesAuth({...})) yourself.
The server half
Alpine is client-only, so there's no meta-framework to plug into: you serve
/api/auth/* from whatever backend you have. With Node, that's about ten lines
using @proteles/bff:
import http from "node:http";
import { AuthApp, sendNodeResponse, toWebRequest } from "@proteles/bff";
const auth = new AuthApp(); // reads PROTELES_* from the environment
http.createServer(async (req, res) => {
if (req.url.startsWith("/api/auth/")) {
await sendNodeResponse(res, await auth.handle(toWebRequest({ node: { req } })));
return;
}
// …serve your pages
}).listen(3000);See sdk/examples/alpine-quickstart for a
complete runnable version. In another language, implement the same four routes —
this repo's Go webapp and dashboard
are reference implementations (the dashboard is itself an Alpine app).
Local-dev gotcha on Node 18
Point PROTELES_ISSUER at 127.0.0.1, not localhost. Node 18's fetch
resolves localhost to IPv6 ::1 first, and an IPv4-bound authorization server
refuses that — the browser redirect works but the server-side token exchange
fails. If a login bounces back with ?proteles_error=login_failed, check the
server log: the BFF reports the reason there (never to the browser).
Configuration (environment)
Server-side, read by @proteles/bff: PROTELES_ISSUER, PROTELES_CLIENT_ID,
PROTELES_CLIENT_SECRET, PROTELES_SESSION_SECRET (base64 of 32 random bytes),
PROTELES_APP_URL or PROTELES_REDIRECT_URI, plus optional PROTELES_SCOPES,
PROTELES_COOKIE_SECURE, PROTELES_BASE_PATH.
Client-side, only VITE_PROTELES_BASE_PATH is read, and only under a bundler.
Develop
npm install # from the sdk/ workspace root
npm run build # tsc (ESM + types) + esbuild (standalone browser bundle)
npm test # tsx + node:testHow this package is tested. Alpine can't be imported in Node at all — it
touches MutationObserver at module scope — so the unit tests run against a
faithful stub of the three plugin APIs used (store, magic, directive),
including Alpine's reactivity and its init() call. Separate tests evaluate the
built browser bundle in a fake-window sandbox to prove it's self-contained and
self-registering.
Real-DOM behaviour is verified in an actual browser, and the server half runs
over real HTTP against a live authorization server as Test 14e of
scripts/e2e-smoke-test.sh (see
verify-alpine.mjs).
