@c1m-public/platform-auth
v0.26.0
Published
Session and authorization helpers for C1M platform surfaces. The root is runtime-neutral (Web Request/Response): token/session reads, the first-party handoff (PKCE target + source), proxy session gate and coalesced refresh; ./next adds the server-only gua
Readme
@c1m-public/platform-auth
Server-side session and authorization helpers for C1M platform surfaces (Platform Admin Center, Knowledge Library, SaaSDash BFFs, and starter BFFs).
Two halves, one package. The root and ./next entry points carry session
protocol behaviour and stay framework-neutral; everything under ./access
carries the resolved authorization snapshot. Import only the entry point you
need — the boundary is the subpath.
. session: token reads, handoff, proxy gate, refresh (Web Request/Response)
./next session, server-only guard (Next route handlers)
./access authorization: snapshot, keys, predicates (framework-neutral)
./access/next access loader: require / forbidden / invalidate (Next, server-only)
./access/react <Can>, useCan, AccessProvider (client)next and react are optional peer dependencies: a BFF that only reads
sessions never needs them.
. — session
| Export | Purpose |
|---|---|
| decodeTokenPayload, decodeDefaultSession, hasTokenAudience, isTokenExpired, isTokenExpiringSoon, tokenMaxAgeSeconds | Read an access token for routing. Decoding is not verification — the backend re-verifies every call. decodeDefaultSession prefers impersonation_target, so a support session resolves as the impersonated user. |
| parseAuthResponse, AuthResponse | Validate a backend auth envelope before trusting it. |
| readCookie, serializeCookie, setCookie, deleteCookie | Cookie helpers over Web Request/Response. __Host- names get Secure automatically. |
| createDeviceIdentity | The X-Client-Device-ID cookie the backend requires to create a session. |
| applyForwardedClientHeaders, forwardedRequestHeaders | Copy client identity (IP, geo, UA) onto a backend call. Never used for auth. |
| safeInternalPath(raw, { stripParams? }) | Strict same-origin return-path sanitiser (rejects //, ://, encoded slashes, control chars, anything that does not round-trip). stripParams drops named query params (e.g. a one-shot auth/from flag) from the accepted value so it doesn't survive into the redirect target. |
| createSessionRefresher | Coalesced refresh-token exchange: one backend call per token per window. |
| evaluateSessionGate, hasSessionShape | Pure proxy decision — pass / redirect_home / anonymous_public / unauthorized_api / redirect_login. The proxy owns response construction. eagerReauthBufferSeconds demotes a token expiring within that window to not-live, so a failed refresh rejects the session instead of passing through on the still-valid token. |
| createHandoffTarget, createHandoffSource, brokerPaths, sourceStartPath | Browser halves of the first-party session handoff (/api/v1/platform/auth/handoffs/{app}): PKCE S256 + state, one-time code, host-local cookies, no tokens in URLs. |
| HANDOFF_ERROR_MESSAGES, SSO_ERROR_MESSAGES, AUTH_ERROR_MESSAGES, authErrorMessage(code, overrides?), isAuthErrorCode, handoffErrorMessage, isHandoffErrorCode | One sign-in error vocabulary — handoff, admission and SSO codes. Surfaces pass their own copy as overrides instead of forking the map. |
| ADMIN_HOST_ROOT, isAdminRootHost, adminSlugFromHost, recoveryAdminOrigin, brandedAdminHost | The <slug>.saasdash.app / admin.<domain> admin-host rules, mirroring the backend's internal/platform/domainmanagement/admin_dash_hostnames.go. |
| SIGN_IN_HINT_COOKIE, signInHintDomain, signInHintCookie, hasSignInHint | The non-secret "signed in" display hint an admin host sets on its own domain so the Dash's public site can show "Open your workspace" instead of "Sign in". signInHintDomain/signInHintCookie are the pure writer half; hasSignInHint the reader. The hint carries no identity and must never gate access. |
// proxy.ts
const refresher = createSessionRefresher({ apiUrl: API_URL, refreshPath, clientType, audience });
const decision = await evaluateSessionGate({
accessToken: request.cookies.get(SESSION)?.value,
refreshToken: request.cookies.get(REFRESH)?.value,
audience, refresher,
isPublic, isLoginPath, isApiPath: pathname.startsWith("/api/"),
});// target: app/api/auth/platform-sso/{start,callback}
const target = createHandoffTarget({ app: "docs", apiUrl, audience: "platform_docs", clientType,
sources: { admin: ADMIN_URL, saasdash: SAASDASH_URL }, defaultSource: "admin",
cookiePrefix: "docs", loginPath: "/login", defaultReturnPath: "/docs", device, establishSession });
// source: app/api/auth/handoffs/[app]/start
const source = createHandoffSource({ apiUrl, clientType, targets: { docs: { origin: DOCS_URL } },
getAccessToken, loginPath: "/login" });./next — a Next.js surface's session, fetch and errors
Server-only. Everything under . plus the three things every first-party app
used to hand-roll:
| Export | Purpose |
|---|---|
| createSessionStore({ audience, cookies, maxAgeSeconds?, secure?, cookieDomain?, defaultRememberMe?, refresh?, requestBoundary?, onError? }) | The session/refresh/remember cookies over next/headers: createSession, writeSessionCookies(response, …), getSession, getAccessToken, getRefreshToken, getSessionWithRefresh, updateAccessToken, isRememberMe, destroySession, clearSessionCookies(response), requireSession, isAuthenticated. rememberMe=false → no Max-Age; the remember choice survives refresh; deletes carry the same attributes as sets (a __Host- cookie ignores a Secure-less delete); a token for another audience is never stored or read. Pass requestBoundary: connection (from next/server) so Cache Components treat session state as request-time. |
| createPlatformFetch({ apiUrl, getAccessToken, forwardHeaders?, timeoutMs?, fetch? }) | Bearer + forwarded client headers + no-store + per-method timeout (10 s reads, 30 s writes): fetchAuthed, fetchAuthedJson (null on 204/404), fetchAuthedJsonStrict, fetchJsonWithAccessToken, fetchPublicJson, fetchAuthedField, fetchAuthedItems. A missing session throws (401), never returns null. |
| buildForwardedRequestHeaders | The incoming request's client identity as headers, for a call you make yourself. |
| SessionExpiredError, RecentAuthenticationRequiredError, SSOReauthenticationRequiredError, PlatformServerFetchError{path,status?,code?,requestId?} + is*Error predicates | One error vocabulary; 401 bodies map by code. |
| mapControlError, controlRequest(run) | Translate a @c1m-public/control-client failure into that vocabulary, keeping the backend's code and request id. |
// lib/auth/session.ts
export const sessions = createSessionStore({
audience: "platform_admin",
cookies: { session: "__Host-platform_session", refreshToken: "__Host-platform_refresh_token", rememberMe: "platform_remember" },
requestBoundary: connection,
refresh: { apiUrl: getApiUrl, refreshPath: "/api/v1/platform/auth/refresh", clientType: "nextjs-platform" },
});
// lib/platform/fetch.ts
export const platform = createPlatformFetch({ apiUrl: getApiUrl, getAccessToken: sessions.getAccessToken });
const items = await platform.fetchAuthedItems<Workspace>("/api/v1/platform/workspaces");./access — authorization
The resolved snapshot from GET /api/v1/platform/access.
| Export | Purpose |
|---|---|
| PLATFORM_PERMISSIONS, LEADBROKER_PERMISSIONS, WORKSPACE_PERMISSIONS, WORKSPACE_ROLES + key types | Generated from the backend catalog — see the drift guard below. |
| parseAccessSnapshot, emptySnapshot, AccessSnapshot, AccessEntry | Validate and shape the snapshot. |
| hasPermission, hasAnyPermission, hasAllPermissions, hasWorkspacePermission, isPlatformOwner, appScopes, permissionsFor | Scope-aware predicates. |
| managedDashes, dashRole | The dashes a subject runs, from the mirrored dash.owner / dash.admin bundle grants (snapshot.dash_roles). Admission only — gate an action with hasPermission(snapshot, "dash.publish", { dashId }). Platform managers are not listed; admit them on platform.dashes.manage. |
| fetchAccessSnapshot | Framework-neutral fetch. Pass request and it forwards client identity for you. |
| AccessSnapshotError, isAccessSnapshotError | unauthenticated / forbidden / unavailable / invalid_snapshot. |
./access/next adds createAccessLoader (request-scoped via React cache),
requireAccess, requireWorkspaceAccess, invalidateAccess, accessCacheTag.
./access/react adds AccessProvider, useCan, useCanWorkspace,
useIsPlatformOwner, <Can>.
// lib/access.server.ts
export const access = createAccessLoader({
apiUrl: getApiUrl,
getAccessToken: async () => (await cookies()).get(SESSION)?.value ?? null,
// Cross-request caching is the APP's job: the "use cache" directive does not
// apply inside node_modules, so wrap the fetch here and tag it yourself.
fetchSnapshot: cachedSnapshot,
});
await access.requireAccess("platform.dashes.manage");Omit getSubjectId and the loader derives the cache-tag subject from the access
token, so per-user tagging cannot silently collapse to one shared tag.
Pipeline stages
PIPELINE_STAGES / PipelineStage / isPipelineStage under ./access are
the onboarding funnel strings in order (created, awaiting_payment,
provisioning, attention, active), generated from the backend's
schemas/api/pipeline-stages.json the same way the permission keys are.
Labels and descriptions stay with the app; the strings are the backend's.
Keys drift guard
src/access/generated/keys.ts is generated from the backend authz catalog
(c1m-server/schemas/api/authz-keys.json, produced by make authzgen) and
vendored to contracts/authz-keys.json. Never hand-edit it. Regenerate with
node scripts/generate-access-types.mjs; pnpm contract:check runs
--check and fails on drift, so a backend rename cannot silently diverge from
the frontend key unions.
Notes
- Import from
@c1m-public/platform-auth/next(or/access/next) inside Next.js server code — those entries carry theserver-onlyguard. - Session cookies stay host-local per surface; this package never shares a session across origins. Crossing hosts is always a fresh handoff.
