@bitz/platform-sdk is the deep module that connects the web app to the .NET backend. It hides five concerns behind one barrel: configuration, the request pipeline, the auth session, error mapping, and the tenant/permission context. Pages consume it through React context (usePlatform) and never call fetch directly.
1. What the package owns
| Concern | Exports |
|---|---|
| Config | createConfig, createConfigFromEnv, DEFAULT_PLATFORM_CONFIG, PlatformSdkConfig |
| Request pipeline | createPlatformClient, PlatformClient, PlatformResult, PlatformResponse, PlatformFailure, PlatformRequestOptions, HttpMethod |
| Typed API | createPlatformApi, PlatformApi (flat aggregate of Identity/Menu/User and dozens of module APIs), IdentityApi, MenuApi, UserApi |
| Auth | AuthSession, createAuthApi, AuthStatus, LoginRequest, LoginResponse, MfaVerifyRequest, TokenResponse, CurrentUser, SessionStateEvent |
| Errors | parseProblemDetails, networkError, cancelledError, isRetryable, isAuthError, isForbidden, AppError, AppErrorType, ValidationErrors |
| React | PlatformProvider, usePlatform, PlatformContextValue, PlatformProviderProps |
| Gates | PermissionGate, PermissionGateProps, FeatureGate, FeatureGateProps |
| Generated contract | client/generated.d.ts plus schema aliases in contracts/* |
2. Configuration
PlatformSdkConfig is a small, readonly shape:
| Field | Default | Meaning |
|---|---|---|
baseUrl | '' (same-origin) | API origin; empty in dev so the Vite proxy handles /api/* |
clientPlatform | 'Web' | Sent as the X-Client-Platform header (Web / App / Harmony / MiniProgram) |
defaultTimeoutMs | 30000 | Per-request timeout via AbortSignal.timeout |
refreshPath | '/api/auth/refresh' | Where the pipeline sends refresh calls |
chatHubPath | '/hubs/chat' | SignalR chat hub path |
operationsHubPath | '/hubs/operations-metrics' | Operations metrics hub path |
exportHubPath | '/hubs/export' | Export progress hub path |
createConfigFromEnv(env) reads VITE_* variables (the Vite convention):
| Env var | Maps to | Fallback |
|---|---|---|
VITE_API_BASE_URL | baseUrl | '' (dev-proxy mode) |
VITE_CLIENT_PLATFORM | clientPlatform | 'Web' |
VITE_API_TIMEOUT_MS | defaultTimeoutMs | 30000 |
createConfig(partial) is the explicit constructor for tests and non-Vite runtimes.
3. The request pipeline
createPlatformClient produces the one client instance the application uses (memoized in PlatformProvider). A request flows through these steps:
- Build headers:
Content-Type: application/json,Accept: application/json,X-Client-Platform: <config.clientPlatform>, andAuthorization: Bearer <token>only if an access token exists. - Attach a timeout via
AbortSignal.timeout(opts.timeoutMs ?? config.defaultTimeoutMs); if the caller passes an externalsignal, the two are combined withAbortSignal.any([...])— nosetTimeoutleaks. - Send a single
fetchwithcredentials: 'include'so the HttpOnly refresh cookie travels automatically. - On
204 No Content, return{ ok: true, status: 204, data: undefined }. - Read the body as text, JSON-parse defensively, then — on non-ok status — call
parseProblemDetailsto produce anAppError. - On a result where
isAuthError(error)is true and the caller didn’t passskipAuthRefresh, callauth.refreshOnce(). If it returns a token, retry once with the new token. If the retry is still a 401, callauth.forceRefreshFailed()to prevent a refresh storm. - 403 is never auto-refreshed (returns
Forbiddenimmediately). - A thrown
DOMExceptionwithname === 'AbortError'becomescancelledError(); aTypeError(DNS/CORS/refused) becomesnetworkError().
QUERY adds transport negotiation. Criteria become JSON request content; on 405, 501, or a browser network rejection, the client retries the same content once as POST {path}/_query and remembers the result when the link is proven unsupported. Authentication, authorization, validation, timeout, and business failures do not trigger fallback. See the QUERY protocol for the state machine, OpenAPI 3.1 representation, and .NET 11 migration gate.
The pipeline returns a discriminated PlatformResult<T>:
type PlatformResult<T> = | { readonly ok: true; readonly status: number; readonly data: T } | { readonly ok: false; readonly status: number; readonly error: AppError };import { usePlatform } from "@bitz/platform-sdk";
function UsersPage() { const { api } = usePlatform(); // The API is FLAT — methods live directly on `api`, not on `api.userApi`. // api.searchUsers(query) returns PlatformResult<UserPage>, never a raw // Response, never a thrown fetch error.}4. The typed API surface
PlatformApi combines Identity, Menu, User, Announcements, Files, Workflow, and the other module factories by spreading each create*Api result. Methods are flattened onto api, not namespaced under api.identityApi. To add a module API, spread create<Module>Api(client) into createPlatformApi.
UserApi (each returns PlatformResult<...>):
| Method | Path | Returns |
|---|---|---|
searchUsers(query) | QUERY /api/users | Page<UserSummaryDto> |
getUserById(userId) | GET /api/users/{userId} | UserSummaryDto |
createUser(request) | POST /api/users | UserSummaryDto |
updateUser(userId, request) | PUT /api/users/{userId} | UserSummaryDto |
deleteUser(userId) | DELETE /api/users/{userId} | void |
enableUser(userId) | POST /api/users/{userId}/enable | void (idempotent) |
disableUser(userId) | POST /api/users/{userId}/disable | void |
lockUser(userId, { durationMinutes }) | POST /api/users/{userId}/lock | void |
unlockUser(userId) | POST /api/users/{userId}/unlock | void |
userStatus | — | a constant object of UserStatus values for rendering |
MenuApi:
| Method | Path | Returns |
|---|---|---|
getNavigation() | QUERY /api/menus/navigation | readonly NavigationItem[] |
IdentityApi:
| Capability | Methods |
|---|---|
| Password | requestPasswordReset, resetPassword, changePassword |
| MFA | setupMfa, confirmMfaSetup, disableMfa |
| Invitations | listInvitations, getInvitation, createInvitation, revokeInvitation, acceptInvitation |
| Admissions | listAdmissions, approveAdmission, rejectAdmission |
| Lifecycle | completeActivation, sendEmailConfirmation, confirmEmail, sendPhoneConfirmation, confirmPhone |
| External sign-in | listExternalLoginProviders, initiateExternalLogin |
| Sessions | listSessions, revokeSession, revokeAllSessions |
5. Error model
AppErrorType is a string union (not a TS enum):
'Validation' | 'NotFound' | 'Conflict' | 'Forbidden' | 'Unauthorized' |'Failure' | 'Unexpected' | 'ServiceUnavailable' | 'RequestTimeout' |'RateLimited' | 'Network' | 'Cancelled'AppError carries: status (0 for network/cancel), errorType, errorCode, detail, optional validationErrors (Record<string, string[]>), and optional traceId / correlationId / requestId / retryAfterSeconds / type / instance. parseProblemDetails reads these from the root of the response body (ASP.NET Results.Problem merges extensions to the root), guessing errorType from status when absent: 400→Validation, 401→Unauthorized, 403→Forbidden, 404→NotFound, 408→RequestTimeout, 409→Conflict, 422→Failure, 429→RateLimited, 503→ServiceUnavailable, ≥500→Unexpected.
Helpers: isRetryable (Network / RequestTimeout / ServiceUnavailable / RateLimited), isAuthError (errorType === Unauthorized && status === 401), isForbidden (errorType === Forbidden && status === 403).
6. The auth session
AuthSession owns credential state and refresh coordination. Its invariants are security-critical:
- Access token lives in a module-private field (
private accessToken: string | null). Never written tolocalStorage/sessionStorage. - Refresh token is carried by an HttpOnly cookie. The browser submits it via
credentials: 'include'; client JavaScript cannot read it. - Concurrent 401s trigger exactly one refresh.
refreshOnce()stores one in-flight promise (refreshPromise) and returns it to every caller; the field is cleared after it resolves to avoid a race window. - Refresh failure clears the session.
forceRefreshFailed()drops the in-memory token and emitsrefresh-failed; the app redirects to login. - Listeners are isolated. Each listener is invoked in its own
try/catch, so one throwing listener cannot break the session.
SessionStateEvent has seven type values, with an optional userId:
type | When |
|---|---|
authenticated | login success and token stored |
impersonation-restored | session restored under an impersonating identity |
mfa-required | LoginResponse.requiresMfa true (token not stored yet) |
logged-out | logout() completes |
refresh-failed | refresh rejected or 401-after-refresh |
password-change-required | sign-in succeeded but the server requires a password change first |
mfa-enrollment-required | server requires MFA enrollment before proceeding |
LoginResponse comes from the generated OpenAPI schema: accessToken, expiresIn, requiresMfa, requiresPasswordChange, with optional mfaToken / requiresCaptcha / captchaChallengeId / captchaRenderData / isHost / refreshToken. The login screen branches on these server-declared flags rather than guessing.
7. React context
PlatformProvider mounts once near the root (main.tsx). It memoizes the session, client, and typed API, restores an in-memory access token from the HttpOnly refresh cookie, and then loads /api/auth/me. Until bootstrap finishes, authStatus remains bootstrapping, so the route guard does not redirect early. PlatformContextValue exposes config, client, session, api, authStatus, login(request), verifyMfa(request), logout(), fetchCurrentUser(), currentUser, tenantId, and the server-issued tenant feature set features: readonly string[] that backs FeatureGate.
// Provider order is fixed; PlatformProvider owns the SDK singletons.// WorkspacePreferencesProvider injects safe first-paint shell defaults until// server-side workspace preferences take over after sign-in.<PlatformProvider config={platformConfig}> <WorkspacePreferencesProvider defaultBrand={brand} defaultColorMode={colorMode} defaultDensity={density} defaultShellPreset={shellPreset}> <QueryClientProvider client={queryClient}> {/* Principal cache boundary: invalidates caches on identity switches */} <PrincipalQueryCacheBoundary> <RouterProvider router={router} /> <AppToaster /> </PrincipalQueryCacheBoundary> </QueryClientProvider> </WorkspacePreferencesProvider></PlatformProvider>8. Gates
Two declarative gates live in the package. Both are UX only — they decide what to render, not whether an action is permitted.
| Gate | Props | Behavior |
|---|---|---|
PermissionGate | require: string | readonly string[], mode?: 'all' | 'any' (default 'all'), fallback? | Checks currentUser?.permissions; empty require → allow; all = every, any = some |
FeatureGate | feature, mode?, children, fallback? | Evaluates against the tenant feature set from usePlatform().features, issued by the server at sign-in |
9. Generated OpenAPI contract
The backend artifact and TypeScript types now form one closed generation loop:
# from the backend monorepo rootscripts/build/export-openapi.sh # → artifacts/openapi/openapi-v1.json
# from frontend/yarn workspace @bitz/platform-sdk generate-client# → openapi-typescript ../../../artifacts/openapi/openapi-v1.json \# --empty-objects-unknown -o src/client/generated.d.ts
# guard against silent drift on CIscripts/build/check-openapi-drift.shThe current OpenAPI 3.1 artifact carries a QUERY schema on the POST /_query Operation and publishes the preferred route through x-http-query-*. Generated types may therefore index paths['/.../_query']['post'] while the runtime module API sends QUERY /.... This is an intentional compatibility design, not contract drift. Pages must not reference generated transport paths directly. See the QUERY protocol.
10. Reviewing a platform-sdk change
- Confirm the new export is added to the package barrel (
src/index.ts); deep-path imports are not part of the public API. - If a new HTTP method is added, confirm it flows through
createPlatformApiand the shared pipeline — not a bespoke client. - If auth behavior changes, update the invariants in
auth-session.tsand this page together; do not leave the comment block stale. - If a new gate is added, confirm it stays UX-only and document the matching backend capability.
- If the backend contract changes, export and regenerate. Add readable schema aliases in
src/contracts/*.tsonly when needed.
11. Source review
# Confirm the public API surface and the single pipeline/auth home.sed -n '1,80p' frontend/packages/platform-sdk/src/index.tsrg -n "createPlatformClient|class AuthSession|refreshOnce|forceRefreshFailed" \ frontend/packages/platform-sdk/src/client \ frontend/packages/platform-sdk/src/auth
# Identity contracts must alias generated schemas, not redeclare fields.rg -n "components\\['schemas'\\]" frontend/packages/platform-sdk/src/contracts/identity.tsrg -n "^export interface" frontend/packages/platform-sdk/src/contracts/identity.ts# Expected: the second command returns no matches.Back to Frontend · QUERY protocol · Architecture & red lines · Web admin app