@mosano-product-framework/sdk
v0.2.1
Published
MPF SDK: typed clients for the MPF services
Readme
@mosano-product-framework/sdk/react
React bindings for the MPF SDK: a provider, six hooks, and nothing else.
Dependency-free apart from React. No TanStack Query, no zustand, no router.
State lives in a small external store read through useSyncExternalStore, so
there are no constraints on how you manage the rest of your data — SWR, Apollo,
TanStack Query and plain fetch consumers are equally supported.
For the SDK core — tenant selection, claims, session management — see README.md.
Installation
React is an optional peer dependency. Installing the SDK does not install React, and importing the root barrel never pulls React in.
pnpm add @mosano-product-framework/sdk reactRequires React 18+ (useSyncExternalStore is 18+).
Setup
import { MPFAuthProvider } from '@mosano-product-framework/sdk/react';
import { createAuthClient } from '@mosano-product-framework/sdk/identity';
import { createDefaultStorage } from '@mosano-product-framework/sdk/auth';
const auth = createAuthClient({ baseUrl: 'https://api.example.com/auth' });
export function App() {
return (
<MPFAuthProvider
renew={(refreshToken) => auth.renewSession({ refresh_token: refreshToken })}
storage={createDefaultStorage()}
onDeadSession={() => navigate('/login')}
>
<Routes />
</MPFAuthProvider>
);
}<MPFAuthProvider> props
| Prop | Required | Description |
|---|---|---|
| renew | ✅ | (refreshToken) => Promise<{ access_token, refresh_token }>. Wire to auth.renewSession. |
| storage | | Where the refresh token and tenant selection are persisted. Defaults to localStorage, falling back to memory. |
| onDeadSession | | Called when the session cannot be renewed. Defaults to a one-time console.warn. |
| tenantProvider | | A pre-built createTenantSelection() store, so non-React code can share the same selection. |
| loadTenantDirectory | | Fetches tenant display names. See useTenantDirectory(). |
| autoLoadTenantDirectory | | Fetch names automatically once signed in. Default true when a loader is supplied. |
| sessionOptions | | Forwarded to the session manager (bufferSeconds, jitterMs, crossTab, ...). |
| session | | A pre-built SessionManager. Mainly a test seam. |
Hydration
The provider hydrates synchronously, during its first render. Anything
already in storage — the refresh token, the selected tenant — is present on the
very first paint, so a reload does not flash a login form and does not reset the
user's tenant to "none". There is no loading state to handle for hydration;
useAuth().hydrated is always true by the time your component runs.
Hooks
useAuth()
Core state and actions.
function Header() {
const { authenticated, claims, signOut } = useAuth();
if (!authenticated) return <SignInLink />;
return <button onClick={signOut}>Sign out {claims?.uid}</button>;
}| Field | Description |
|---|---|
| hydrated | Always true after the first render. |
| authenticated | Whether an access token is present. |
| claims | Decoded (unverified) claims, or null. |
| getAccessToken() | Promise<string \| null>, renewing first if the token is stale. |
| setTokens(pair) | Adopt a fresh token pair after sign-in. |
| signOut() | Drop the session locally. Does not navigate. |
Sign-in looks like this — the SDK does not own your form or your routing:
const { setTokens } = useAuth();
async function onSubmit(email: string, password: string) {
const result = await auth.signInEmailPassword({ email, password });
setTokens(result); // provider state updates synchronously
navigate('/dashboard'); // your router, your call
}useTenants()
The tenants the current token grants access to. Synchronous, and cannot fail — the answer is already in the access token, which is fresher than anything a round trip could tell you.
function TenantSwitcher() {
const { status, tenants } = useTenants();
const current = useCurrentTenant();
const select = useSelectTenant();
if (status === 'unloaded') return null; // not signed in
return (
<select value={current.tenant ?? ''} onChange={(e) => select(e.target.value || null)}>
<option value="">All (no tenant)</option>
{tenants.map((t) => (
<option key={t.id} value={t.id}>{t.name ?? t.id}</option>
))}
</select>
);
}status is only 'unloaded' | 'loaded', because there is no fetch here and
therefore nothing to fail. Each entry has id, roles, defaultRole (the
token's explicit dfr for that tenant when it carries one, else roles[0]),
and — once the directory has loaded — name and slug.
useTenantDirectory()
Tenant display names, which do require a network call and can genuinely fail.
tnts in the token carries ids and roles only, so without this a switcher shows
UUIDs. Opt in by giving the provider a loader:
// Simplest: REST. `listMyTenants()` returns id, name and slug.
<MPFAuthProvider
renew={renew}
loadTenantDirectory={() => auth.listMyTenants()}
>// Or GraphQL, if your app already has a client. Query with `{ tenant: null }` —
// the tenantless role has exactly the permission a switcher needs.
import { TENANT_DIRECTORY_QUERY } from '@mosano-product-framework/sdk/auth';
<MPFAuthProvider
renew={renew}
loadTenantDirectory={async () => {
const data = await gql.query(TENANT_DIRECTORY_QUERY, undefined, { tenant: null });
return data.identity_tenants;
}}
>The loader is injected rather than built in, so the React layer never depends on the GraphQL client and either transport works unchanged.
const { status, directory, error, available, load } = useTenantDirectory();| status | Meaning |
|---|---|
| 'unloaded' | No loader supplied, or not fetched yet. |
| 'loading' | In flight. |
| 'loaded' | Names fetched. May legitimately be empty — that means "this user has no tenants". |
| 'failed' | The fetch failed. error says why. |
'loaded'-but-empty and 'failed' are deliberately distinct. Collapsing them is
how an empty switcher comes to silently mean "the server was unreachable".
A failed name lookup never empties the switcher. The ids come from the token,
so they remain listed and selectable; only the names are missing. Render
name ?? id:
function TenantSwitcher() {
const { tenants } = useTenants();
const { status, error } = useTenantDirectory();
return (
<>
{status === 'failed' && <Warning>Couldn't load names: {error?.message}</Warning>}
{tenants.map((t) => <Option key={t.id} label={t.name ?? t.id} />)}
</>
);
}Names are dropped on signOut(). Entries for tenants the token does not grant
are ignored, so a name lookup can never make an unauthorised tenant selectable.
useCurrentTenant()
The current selection: { tenant: string | null, role?: string }. tenant is
null on the no-tenant path.
useSelectTenant()
const select = useSelectTenant();
select('tenant-uuid'); // select a tenant, default role
select('tenant-uuid', 'admin'); // select a specific role
select(null); // no tenantSynchronous. No await, no network, no renewal. Tenant is a request header,
so switching is a local state change and the next request simply carries a
different header — the access token is byte-identical before and after. A test
asserts it fires zero fetches and zero renewals.
The selection is persisted, so it survives a reload.
useClaims()
Decoded claims of the current access token, or null.
⚠️ Decode, not verify
These claims are decoded, never verified. There is no signature check and no key material in this package. A hostile client can forge any value here: any user id, any tenant, any role, any expiry.
Use them for UI only — rendering a switcher, greying out an option, deciding when to renew. Never gate a security-relevant branch on them. The server verifies the signature and re-derives tenant and role on every request, and a forged claim buys an attacker nothing there.
const claims = useClaims();
const isOwnerSomewhere = (claims?.tnts ?? []).some((t) => t.rls.includes('owner'));
// ^ fine for showing a menu item. NOT fine as an authorization decision.useMPFAuthOptional()
Returns the context or null instead of throwing, for components rendered both
inside and outside an authenticated shell.
function Header() {
const ctx = useMPFAuthOptional();
if (!ctx) return <MarketingHeader />;
return <AppHeader />;
}Every other hook throws a descriptive error when no provider is mounted.
Storage adapters
import { createDefaultStorage, createMemoryStorage } from '@mosano-product-framework/sdk/auth';createDefaultStorage()—localStorage, probed for writability and falling back to memory (Safari private mode and blocked third-party contexts exposelocalStoragebut throw on write).createMemoryStorage()— in-memory; the automatic choice whenwindowis absent, and useful in tests.- Anything with
getItem/setItem/removeItemworks, sosessionStorageor an encrypted wrapper drops straight in.
Only the refresh token and the tenant selection are persisted. The access token stays in memory.
onDeadSession is a callback, never a navigation
The SDK never navigates. Routing is your application's concern, and hard-coding
window.location or a useNavigate() call would couple the library to one
router and one URL scheme.
<MPFAuthProvider
renew={renew}
onDeadSession={(error) => {
queryClient.clear();
navigate('/login', { state: { reason: error.message } });
}}
>The default is a one-time console.warn telling you to supply one.
Using with TanStack Query
The bindings hold auth state; your query library holds server state. Wire them
with getAccessToken, and key queries on the tenant so a switch refetches.
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useAuth, useCurrentTenant, useSelectTenant } from '@mosano-product-framework/sdk/react';
function useMembers() {
const { getAccessToken } = useAuth();
const { tenant } = useCurrentTenant();
return useQuery({
// Tenant in the key: switching tenants refetches instead of showing stale rows.
queryKey: ['members', tenant],
queryFn: async () => {
await getAccessToken(); // renews if stale, before the request goes out
return auth.listTenantMembers(tenant!);
},
enabled: Boolean(tenant),
});
}
// Switching stays synchronous; the query layer reacts to the key change.
function Switcher() {
const select = useSelectTenant();
return <button onClick={() => select('other-tenant')}>Switch</button>;
}On sign-out, clear the cache so one user's data cannot be shown to the next:
const queryClient = useQueryClient();
const { signOut } = useAuth();
const logout = () => {
signOut();
queryClient.clear();
};The same shape works with SWR (useSWR(['members', tenant], ...)) or Apollo
(refetch on the tenant change).
Out of scope
GraphQL subscriptions and websockets are not supported — see the Out of scope section of the main README for why the tenant model makes long-lived sockets a separate design problem rather than a missing feature.
License
MIT
