Skip to content
bitzorcas
中EN

Reference

The platform contract layer

@bitz/platform-sdk is the only sanctioned bridge between the web app and the .NET backend — it owns the request pipeline, auth session, ProblemDetails mapping, gates, Identity API, and generated OpenAPI contract.

Last updated

@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

ConcernExports
ConfigcreateConfig, createConfigFromEnv, DEFAULT_PLATFORM_CONFIG, PlatformSdkConfig
Request pipelinecreatePlatformClient, PlatformClient, PlatformResult, PlatformResponse, PlatformFailure, PlatformRequestOptions, HttpMethod
Typed APIcreatePlatformApi, PlatformApi (flat aggregate of Identity/Menu/User and dozens of module APIs), IdentityApi, MenuApi, UserApi
AuthAuthSession, createAuthApi, AuthStatus, LoginRequest, LoginResponse, MfaVerifyRequest, TokenResponse, CurrentUser, SessionStateEvent
ErrorsparseProblemDetails, networkError, cancelledError, isRetryable, isAuthError, isForbidden, AppError, AppErrorType, ValidationErrors
ReactPlatformProvider, usePlatform, PlatformContextValue, PlatformProviderProps
GatesPermissionGate, PermissionGateProps, FeatureGate, FeatureGateProps
Generated contractclient/generated.d.ts plus schema aliases in contracts/*

2. Configuration

PlatformSdkConfig is a small, readonly shape:

FieldDefaultMeaning
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)
defaultTimeoutMs30000Per-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 varMaps toFallback
VITE_API_BASE_URLbaseUrl'' (dev-proxy mode)
VITE_CLIENT_PLATFORMclientPlatform'Web'
VITE_API_TIMEOUT_MSdefaultTimeoutMs30000

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:

  1. Build headers: Content-Type: application/json, Accept: application/json, X-Client-Platform: <config.clientPlatform>, and Authorization: Bearer <token> only if an access token exists.
  2. Attach a timeout via AbortSignal.timeout(opts.timeoutMs ?? config.defaultTimeoutMs); if the caller passes an external signal, the two are combined with AbortSignal.any([...]) — no setTimeout leaks.
  3. Send a single fetch with credentials: 'include' so the HttpOnly refresh cookie travels automatically.
  4. On 204 No Content, return { ok: true, status: 204, data: undefined }.
  5. Read the body as text, JSON-parse defensively, then — on non-ok status — call parseProblemDetails to produce an AppError.
  6. On a result where isAuthError(error) is true and the caller didn’t pass skipAuthRefresh, call auth.refreshOnce(). If it returns a token, retry once with the new token. If the retry is still a 401, call auth.forceRefreshFailed() to prevent a refresh storm.
  7. 403 is never auto-refreshed (returns Forbidden immediately).
  8. A thrown DOMException with name === 'AbortError' becomes cancelledError(); a TypeError (DNS/CORS/refused) becomes networkError().

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 };
Consuming the API via context
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<...>):

MethodPathReturns
searchUsers(query)QUERY /api/usersPage<UserSummaryDto>
getUserById(userId)GET /api/users/{userId}UserSummaryDto
createUser(request)POST /api/usersUserSummaryDto
updateUser(userId, request)PUT /api/users/{userId}UserSummaryDto
deleteUser(userId)DELETE /api/users/{userId}void
enableUser(userId)POST /api/users/{userId}/enablevoid (idempotent)
disableUser(userId)POST /api/users/{userId}/disablevoid
lockUser(userId, { durationMinutes })POST /api/users/{userId}/lockvoid
unlockUser(userId)POST /api/users/{userId}/unlockvoid
userStatus—a constant object of UserStatus values for rendering

MenuApi:

MethodPathReturns
getNavigation()QUERY /api/menus/navigationreadonly NavigationItem[]

IdentityApi:

CapabilityMethods
PasswordrequestPasswordReset, resetPassword, changePassword
MFAsetupMfa, confirmMfaSetup, disableMfa
InvitationslistInvitations, getInvitation, createInvitation, revokeInvitation, acceptInvitation
AdmissionslistAdmissions, approveAdmission, rejectAdmission
LifecyclecompleteActivation, sendEmailConfirmation, confirmEmail, sendPhoneConfirmation, confirmPhone
External sign-inlistExternalLoginProviders, initiateExternalLogin
SessionslistSessions, 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 to localStorage / 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 emits refresh-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:

typeWhen
authenticatedlogin success and token stored
impersonation-restoredsession restored under an impersonating identity
mfa-requiredLoginResponse.requiresMfa true (token not stored yet)
logged-outlogout() completes
refresh-failedrefresh rejected or 401-after-refresh
password-change-requiredsign-in succeeded but the server requires a password change first
mfa-enrollment-requiredserver 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.

Mounting the provider once (apps/app/src/main.tsx)
// 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.

GatePropsBehavior
PermissionGaterequire: string | readonly string[], mode?: 'all' | 'any' (default 'all'), fallback?Checks currentUser?.permissions; empty require → allow; all = every, any = some
FeatureGatefeature, 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:

Terminal window
# from the backend monorepo root
scripts/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 CI
scripts/build/check-openapi-drift.sh

The 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

  1. Confirm the new export is added to the package barrel (src/index.ts); deep-path imports are not part of the public API.
  2. If a new HTTP method is added, confirm it flows through createPlatformApi and the shared pipeline — not a bespoke client.
  3. If auth behavior changes, update the invariants in auth-session.ts and this page together; do not leave the comment block stale.
  4. If a new gate is added, confirm it stays UX-only and document the matching backend capability.
  5. If the backend contract changes, export and regenerate. Add readable schema aliases in src/contracts/*.ts only when needed.

11. Source review

Terminal window
# Confirm the public API surface and the single pipeline/auth home.
sed -n '1,80p' frontend/packages/platform-sdk/src/index.ts
rg -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.ts
rg -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

100%

Scroll or use controls to zoom · drag when enlarged · double-click for 100% / 200%