Astro integration and headless runtime client for fa-auth-m8.
Part of the M8 auth stack: mano8/astro-auth-m8 is the Astro plugin layer for the mano8/fa-auth-m8 backend service, and it is ready to compose into mano8/fa-ui-m8.
npm install @mano8/astro-auth-m8Use it with a fa-auth-m8 backend that satisfies the fa-auth-m8@2.0 contract.
This package targets the fa-auth-m8@2.0 API contract and tracks fa-auth-m8 service version 2.2.1. Supported backend service versions are >=2.0.0 <3.0.0 - the contract is unchanged across the 2.x line, so older 2.x backends remain supported.
Compatibility helpers are exported from @mano8/astro-auth-m8/compatibility. fa-auth-m8 (>= 1.0.0) exposes a public GET {API_PREFIX}/meta route returning a ServiceMeta payload - pass it straight to the assert:
import { assertFaAuthM8Compatibility } from "@mano8/astro-auth-m8/compatibility";
const meta = await fetch(`${base}/user/meta`).then((r) => r.json());
// meta = { service, version, api_version, contract: { name, version, range } }
assertFaAuthM8Compatibility(meta); // reads nested contract.version + versionThe helper also accepts flat fields (auth_contract_version / contract_version / service_version) for backends that surface metadata elsewhere.
installFaAuthBrowserAdapter (wired in by this package's Astro integration on every page) runs a GET {API_PREFIX}/meta preflight itself, once per install, and calls getFaAuthM8Compatibility (not the throwing assert) on the result. An incompatible contract or service version logs one console.warn naming the expected contract/range; an unrecognized ("unknown") /meta payload warns at most once per page. Neither case throws or blocks adapter setup, and a /meta fetch failure (offline, CORS, a pre-1.0 backend without the route) is swallowed silently, since the preflight is advisory only. Hosts that want a hard version gate instead should still call assertFaAuthM8Compatibility themselves — it is exported unchanged and throws on "incompatible" (and on "unknown" unless requireKnown is passed false).
@mano8/astro-auth-m8/authorization is the single place this package encodes the role hierarchy and the role/is_superuser cross-field invariant. It mirrors the backend's canonical auth_sdk_m8.authorization module, so client-side gating cannot drift from what the server enforces.
import { hasMinimumRole, hasSuperuserPrivileges } from "@mano8/astro-auth-m8/authorization";
// Ordered hierarchy - a higher role satisfies a lower requirement.
hasMinimumRole(user.role, "admin"); // true for "superadmin"
// Dual evidence - never decide from `role` alone or `is_superuser` alone.
hasSuperuserPrivileges(user.role, user.is_superuser);The valid role/flag pairs are superadmin with is_superuser: true, and every other role with is_superuser: false. privilegeClaimsAreConsistent exposes that invariant on its own, and ORDERED_ROLES exposes the hierarchy, highest privilege first. Any other pair - including an unrecognised role - grants nothing. These are display predicates; the backend stays the authority.
All four are also re-exported from @mano8/astro-auth-m8/react, as the same bindings, so a consumer gating something that is not a subtree - a menu entry, a table row action, a disabled attribute - reaches the comparison from the subpath it already imports RequireRole from. Wherever a subtree is what you are gating, RequireRole is the shorter spelling:
import { RequireRole } from "@mano8/astro-auth-m8/react";
// Minimum-role mode: at least admin, so a superadmin is admitted too.
<RequireRole minimumRole="admin" fallback={<p>Admin access required.</p>}>
<AuditLog />
</RequireRole>minimumRole is the form to reach for: it says what the backend dependency says. roles={[...]} remains for a guard that genuinely accepts several unrelated tiers - each entry is a floor there too, so roles={["admin"]} and minimumRole="admin" decide identically, but the array reads as exact membership and invites hand-enumeration. superuser is the separate dual-evidence gate. A guard may carry more than one mode and grants on the first that holds; one carrying none grants nothing.
@mano8/astro-auth-m8/errors exports ApiError plus describeApiError(error, fallback), which maps the fa-auth-m8 2.0.0 authorization/rate-limit/retention error contracts to an operator-readable { title, description? } (used by the errorMessage helper in the account registry blocks):
import { describeApiError } from "@mano8/astro-auth-m8/errors";
const { title, description } = describeApiError(error, "Update failed");409 last_superuser_required is labelled (the raw token is never surfaced); 403 is titled by status (Not permitted) with the backend's own readable detail as the description, since several surfaces produce a 403 and the detail that distinguishes them is not a stable contract; 429 and 503 are distinguished (rate limited vs. an unknown outcome that must not be retried); 400 free-text detail (e.g. a purge's retention-floor rejection) is surfaced verbatim under a labelled heading rather than pattern-matched.
Every starter island root is wrapped in AuthErrorBoundary, exported from
@mano8/astro-auth-m8/react. Its default fallback deliberately hides the
caught message so a token fragment, username, or API URL cannot leak onto a
public auth page. Applications can replace the fallback, report the error, or
reset the boundary when route-specific keys change:
import { AuthErrorBoundary } from "@mano8/astro-auth-m8/react";
<AuthErrorBoundary
onError={(error, info) => reportAuthViewError(error, info)}
resetKeys={[locale]}
>
<AuthView />
</AuthErrorBoundary>The retry action resets the failed subtree; it does not automatically repeat a request whose outcome may be unknown.
@mano8/astro-auth-m8/api and @mano8/astro-auth-m8/hooks wrap the five 2.0.0 admin routes:
import { adminListUserApiKeys, adminRevokeApiKey, getAuditLog, purgeApiKeys, purgeAuditLog } from "@mano8/astro-auth-m8/api";
import { useAdminApiKeys, useAuditLog, useSecurityPurges } from "@mano8/astro-auth-m8/hooks";useAdminApiKeys(userId)- superadmin-only list + revoke of another user's keys. Metadata only (ApiKeyAdminPublic, exported from@mano8/astro-auth-m8/schemas): addsuser_idand a server-derivedstatus(active/revoked/expired), omitsupdated_at- a distinct shape from the owner-facingApiKeyPublic, never reused for it.useAuditLog(params)- the read-only privileged-action audit trail. Gate withhasMinimumRole(role, "admin"), not the superuser predicate: an admin sees only the rows it authored, a superadmin sees every row, and the split is decided server-side from the authenticated principal - the client never sends an actor id to widen or narrow it.useSecurityPurges()- both retention purges (purgeAudit,purgeKeys). Thewindowis the closed enum"1w" | "1m" | "3m" | "6m" | "1y"(RetentionWindowSchema), never free text or a date. Gate withhasSuperuserPrivileges, a different predicate from the audit-log read. All three admin surfaces are rate limited:429means the action was not attempted (safe to retry later, never automatically);503means the outcome is unknown (do not retry); a purge's400is a free-text retention-floor rejection - see Error presentation for howdescribeApiErrormaps all three.
GET /security/superuser-probe is deliberately not wrapped - it is the security-tests-m8 harness canary, not a client surface. The four /security/* routes are excluded from the backend's OpenAPI schema (include_in_schema=False); their shapes here are recorded from the backend source and a live/tested response, not schema-generated.
The ready-made skin for both tiers is the security-panel registry item (see Items); AccountTab.minRole on account-dashboard is how an admin-tier tab is declared in the account shell, alongside the existing superuser-only superuserOnly.
PATCH /users/update/{id}/ answers with revocation_enqueued. When it is true, that user's sessions have already been revoked and an authorization-generation bump is propagating, so any client state describing its privileges is stale on arrival. useUsers().update therefore invalidates the affected auth caches (profile, sessions, API keys, that user's admin key list) and emits a revocation notification:
import { AUTH_REVOCATION_EVENT, emitAuthRevocation } from "@mano8/astro-auth-m8/react";
// Only needed if you bypass `useUsers` with your own mutation layer.
emitAuthRevocation(updated.id);A mounted AuthProvider listens for it and, when the id is the signed-in principal's, re-reads the profile immediately rather than waiting for the next incidental getProfile(). It raises loading while doing so, so RequireRole/RequireAuth fall back instead of rendering privileged UI from superseded claims. Notifications for any other user id are ignored. The backend remains the authority; this only stops a stale client view outliving the change.
headless: exports typed schemas, API wrappers, token handling, React provider/hooks, and route helpers without injecting pages.starter: injects small default login, logout, callback, and account routes withinjectRoute().scaffold: copies editable Astro/React/CSS files into a consumer app withastro-auth-m8 scaffold --views --target src/auth.
For package development, npm run preview:dev serves a dev-only /_preview
gallery backed by a deterministic browser stub. npm run preview:build runs
the typecheck-and-bundle gate used by CI; the preview is a repository fixture
and is not shipped as a consumer route.
import { defineConfig } from "astro/config";
import react from "@astrojs/react";
import faAuth from "@mano8/astro-auth-m8";
export default defineConfig({
integrations: [
react(),
faAuth({
apiBase: "/user",
mode: "starter",
routes: { base: "/[locale]" }
})
]
});The runtime keeps access tokens in memory only, sends refresh requests with credentials: "include", and models public backend responses without secret session fields.
For shadcn/Tailwind apps, this package ships a shadcn registry of ready-to-run styled views. The headless logic stays a live dependency (@mano8/astro-auth-m8/react + /hooks); only the skin is copied into the consumer, so views adopt the app's own tokens and are fully editable. The registry items are pre-built into the package at registry/r/*.json (regenerate with npm run build:registry; the output is byte-for-byte identical to shadcn build).
Shared table and state primitives live in @mano8/astro-ui-m8. This package depends on it normally because auth registry items reference astro-ui-m8 generated registry JSON from node_modules.
Install @mano8/astro-auth-m8 from npm first, then consume the registry as a local file out of node_modules (no external host or token). Because shadcn resolves namespaced registries (@name/item) over HTTP, local consumption uses the direct .json path form of shadcn add. Optionally declare the namespace in components.json for documentation or future HTTP hosting:
| Item | shadcn add (run from the consumer project root) |
registryDependencies | npm dependencies | Needs @mano8/astro-auth-m8? |
|---|---|---|---|---|
activity-bar-chart |
npx shadcn add ./node_modules/@mano8/astro-auth-m8/registry/r/activity-bar-chart.json |
chart |
recharts |
no |
dashboard-overview |
npx shadcn add ./node_modules/@mano8/astro-auth-m8/registry/r/dashboard-overview.json |
card, skeleton, activity-bar-chart |
none | yes (useDashboard) |
account-dashboard |
npx shadcn add ./node_modules/@mano8/astro-auth-m8/registry/r/account-dashboard.json |
button, skeleton, tabs, dashboard-overview |
none | yes (AuthProvider, useAuth) |
profile-panel |
npx shadcn add ./node_modules/@mano8/astro-auth-m8/registry/r/profile-panel.json |
card, button, input, label |
none | yes (useAuth, useProfile) |
sessions-panel |
npx shadcn add ./node_modules/@mano8/astro-auth-m8/registry/r/sessions-panel.json |
card, button |
lucide-react |
yes (useAuth, useSessions, useDashboard) |
account-crud |
npx shadcn add ./node_modules/@mano8/astro-auth-m8/registry/r/account-crud.json |
button, dialog, alert-dialog, astro-ui-m8 data-table and toast |
lucide-react |
no |
api-keys-panel |
npx shadcn add ./node_modules/@mano8/astro-auth-m8/registry/r/api-keys-panel.json |
card, button, input, label, badge, dialog, account-crud, astro-ui-m8 data-table |
lucide-react, @tanstack/react-table |
yes (useApiKeys) |
admin-users-panel |
npx shadcn add ./node_modules/@mano8/astro-auth-m8/registry/r/admin-users-panel.json |
card, button, input, label, badge, dialog, account-crud, astro-ui-m8 data-table |
lucide-react, @tanstack/react-table |
yes (RequireRole, useUsers) |
security-panel |
npx shadcn add ./node_modules/@mano8/astro-auth-m8/registry/r/security-panel.json |
card, button, label, badge, account-crud, astro-ui-m8 data-table |
lucide-react, @tanstack/react-table |
yes (RequireRole, useAuditLog, useSecurityPurges) |
dashboard-overview is the landing view; profile-panel, sessions-panel, api-keys-panel, admin-users-panel, and security-panel are the secondary account tabs (drop them into account-dashboard's extraTabs, or into your own shell). Each reads its headless logic straight from the package hooks - no local adapter layer - and takes its strings via labels. api-keys-panel and admin-users-panel use the canonical astro-ui-m8 data-table with client-side search, sorting, pagination, column visibility, and row selection. Their shared account-crud dependency is installed automatically; it supplies the popup form, destructive-action confirmation, fixed row actions, and bottom-right toast host. admin-users-panel self-gates with RequireRole superuser.
security-panel carries both 2.0.0 admin tiers in one tab and gates them differently, mirroring the service: the audit log is behind RequireRole minimumRole="admin" (role hierarchy alone, so an admin sees its own surface and a superadmin is admitted too) and the two retention purges are behind RequireRole superuser (dual evidence). Each purge picks its window from the closed RetentionWindowSchema enum and runs only through a confirmation that names what is deleted and that a window below the service's retention floor is refused - the floor is server-side configuration published by no endpoint, so the service's rejection is surfaced verbatim instead of pre-validated. A 503 locks the purge control and reports the outcome as unknown until the operator explicitly acknowledges having checked, so the next click can never be a blind retry. Drop it into account-dashboard's extraTabs with minRole: "admin".
Files land under src/components/fa-auth/ (the items' target), import shadcn primitives via @/components/ui/*, and pull headless logic from the installed package. The plugin package is intentionally not listed in registry item dependencies; install the published @mano8/astro-auth-m8 package from npm yourself so shadcn only copies the skin files.
When a copied auth skin references @mano8/astro-ui-m8 registry items, shadcn will also copy those files into src/components/m8-ui/ from ./node_modules/@mano8/astro-ui-m8/registry/r/*.json.
- shadcn configured with
style: radix-nova,baseColor: neutral,cssVariables: true, lucide icons, and Tailwind v4 tokens insrc/styles/global.css. - The published
@mano8/astro-auth-m8npm package installed and anAuthProviderin the tree (the dashboard hooks read the package's configured client). - All view labels are props with English defaults - pass your own i18n strings to localize.