L2 Package Design: @oneplatform/app-sdk¶
Document Type: L2 Package Design
Status: Draft
Date: 2026-06-10
Author: Principal Architect
References: ADR-26 (App-SDK and BFF Design), ADR-25 (App Platform Design), ADR-27 (App Access Control), L1 Design Spec §9
Table of Contents¶
- Package Overview
- Constraints and Invariants
- Module Structure
- Public API Surface
- AppProvider
- BFF Client (Internal)
- Permission Cache (Internal)
- useQuery Hook
- useMutation Hook
- useSubscription Hook
- useUser Hook
- usePermission Hook
- useAppStorage Hook
- Error Handling
- Type Generation and Ontology Generics
- Security Design
- Testing Strategy
- Build and Distribution
- Open Questions and Deferred Decisions
1. Package Overview¶
@oneplatform/app-sdk is a React hooks library that provides the complete data and platform API surface for apps built inside OnePlatform. It is the only permitted way for hosted apps to interact with platform data and services.
Scope¶
This package is used exclusively inside platform-hosted apps — that is, React applications written in the Monaco editor, built by esbuild in the Execution Service sandbox, and served by the App Service. It is not a general-purpose SDK. External server-to-server integrations use @oneplatform/sdk. Plugin development uses @oneplatform/plugin-sdk.
What this package does¶
- Provides seven public exports:
AppProvider,useQuery,useMutation,useSubscription,useUser,usePermission,useAppStorage - All network I/O targets the App Service BFF (
/bff/*routes) — never internal services directly - Authenticates via the
op_sessionhttpOnly cookie; no tokens are ever accessible in JavaScript - Maintains a client-side permission cache seeded at mount time and refreshed on visibility change
- Manages a single persistent WebSocket connection for real-time subscriptions
Peer dependencies¶
{
"peerDependencies": {
"react": ">=18.0.0",
"react-dom": ">=18.0.0"
},
"peerDependenciesMeta": {
"react-dom": { "optional": false }
}
}
React 18 is the minimum version. The package uses useId, useSyncExternalStore, and the concurrent-mode-safe startTransition API, all of which require React 18.
Runtime environment assumptions¶
- Executes in a browser sandbox (
Content-Security-Policy: default-src 'self'; script-src 'self'; connect-src 'self') - No
eval, no dynamicimport()after initial load, no cross-origin fetches window.__OP_APP_CONFIG__is present and populated by the App Service HTML shell before the bundle executes- All
fetchcalls are same-origin (no CORS needed) - WebSocket connects to the same origin over the
wss:scheme
2. Constraints and Invariants¶
These are non-negotiable properties that every code change must preserve.
| # | Invariant | Rationale |
|---|---|---|
| C-1 | No service token or internal access JWT is ever present in JavaScript-accessible memory | XSS cannot steal credentials it cannot read |
| C-2 | All BFF calls use credentials: "include" | The httpOnly op_session cookie is sent automatically; no explicit auth header |
| C-3 | usePermission is always synchronous | Components must not suspend on permission checks; UI jank is unacceptable |
| C-4 | Permission cache is loaded before any hook renders data | Hooks must not render data before permission state is known |
| C-5 | The BFF client never follows redirects to non-same-origin URLs | Prevents open redirect exploitation |
| C-6 | WebSocket URL is constructed from window.location.origin only | Prevents WebSocket endpoint injection |
| C-7 | useAppStorage values are serialised as JSON before storage; maximum 64 KB enforced client-side before the write | Prevents silent truncation on the server |
| C-8 | No default exports | Tree-shaking requires named exports; default exports in React component libraries cause bundler confusion |
| C-9 | Zero runtime dependencies beyond react and react-dom | The sandbox image pre-bundles a fixed set of modules; no additional node_modules can be fetched at runtime |
3. Module Structure¶
packages/app-sdk/
package.json
tsconfig.json
tsconfig.build.json
vitest.config.ts
src/
index.ts # Public barrel — named re-exports only
provider/
AppProvider.tsx # Root context provider
AppContext.ts # React context shape and default value
types.ts # AppConfig, AppProviderState
client/
BffClient.ts # Internal fetch wrapper
BffClient.test.ts
errors.ts # AppSDKError class and error factory
errors.test.ts
cache/
PermissionCache.ts # Permission snapshot cache
PermissionCache.test.ts
QueryCache.ts # In-memory query result cache
QueryCache.test.ts
hooks/
useQuery.ts
useQuery.test.ts
useMutation.ts
useMutation.test.ts
useSubscription.ts
useSubscription.test.ts
useUser.ts
useUser.test.ts
usePermission.ts
usePermission.test.ts
useAppStorage.ts
useAppStorage.test.ts
ws/
WebSocketManager.ts # Singleton WebSocket lifecycle
WebSocketManager.test.ts
types/
api.ts # BFF request/response shapes
entities.ts # Generic entity types
events.ts # EntityEvent, SubscriptionOptions
index.ts # Re-exports all public types
__mocks__/
BffClient.ts # Manual mock for tests
WebSocketManager.ts
Barrel export discipline¶
src/index.ts exports exactly the public API surface. Internal modules (client/, cache/, ws/) are not re-exported. Any import of @oneplatform/app-sdk/client/BffClient from user code will result in a build error because esbuild's allowed-imports enforcement operates at the bundle level.
// src/index.ts — complete export list
export { AppProvider } from "./provider/AppProvider";
export { useQuery } from "./hooks/useQuery";
export { useMutation } from "./hooks/useMutation";
export { useSubscription } from "./hooks/useSubscription";
export { useUser } from "./hooks/useUser";
export { usePermission } from "./hooks/usePermission";
export { useAppStorage } from "./hooks/useAppStorage";
export type {
QueryOptions,
QueryResult,
MutationResult,
SubscriptionOptions,
SubscriptionResult,
UserContext,
AppSDKError,
EntityEvent,
BulkResult,
FilterValue,
} from "./types/index";
4. Public API Surface¶
Complete TypeScript signatures for all public exports.
// ─────────────────────────────────────────────────────────────────────────────
// AppProvider
// ─────────────────────────────────────────────────────────────────────────────
interface AppProviderProps {
children: React.ReactNode;
/**
* Override appId for testing. In production this is always read from
* window.__OP_APP_CONFIG__.appId and this prop is ignored.
*/
_testAppId?: string;
_testTenantId?: string;
}
function AppProvider(props: AppProviderProps): JSX.Element;
// ─────────────────────────────────────────────────────────────────────────────
// useQuery<T>
// ─────────────────────────────────────────────────────────────────────────────
type FilterOperator = "eq" | "ne" | "lt" | "lte" | "gt" | "gte" | "in" | "nin" | "contains";
type FilterValue = string | number | boolean | string[] | number[];
interface FilterSpec {
[field: string]: Partial<Record<FilterOperator, FilterValue>>;
}
interface QueryOptions {
filter?: FilterSpec;
sort?: string[]; // ["name", "-createdAt"] — prefix "-" for DESC
fields?: string[]; // field projection; omit for all
cursor?: string; // pagination cursor; omit for first page
limit?: number; // default 50, max 100
enabled?: boolean; // if false, skip fetch (default true)
staleTime?: number; // ms before refetch on next render (default 30_000)
onError?: (error: AppSDKError) => void;
}
interface Pagination {
nextCursor: string | null;
total: number;
}
interface QueryResult<T> {
data: T[] | null;
pagination: Pagination | null;
isLoading: boolean;
isError: boolean;
error: AppSDKError | null;
refetch: () => Promise<void>;
fetchNextPage: () => Promise<void>;
}
function useQuery<T = unknown>(
entity: string,
options?: QueryOptions
): QueryResult<T>;
// ─────────────────────────────────────────────────────────────────────────────
// useMutation<T>
// ─────────────────────────────────────────────────────────────────────────────
interface BulkResult<T> {
created: T[];
errors: Array<{ index: number; error: AppSDKError }>;
}
interface MutationResult<T> {
create: (data: Partial<T>) => Promise<T>;
update: (id: string, data: Partial<T>) => Promise<T>; // PATCH
replace: (id: string, data: T) => Promise<T>; // PUT
remove: (id: string) => Promise<void>;
bulkCreate: (items: Partial<T>[]) => Promise<BulkResult<T>>;
isLoading: boolean;
isError: boolean;
error: AppSDKError | null;
reset: () => void; // clears isError and error
}
function useMutation<T = unknown>(entity: string): MutationResult<T>;
// ─────────────────────────────────────────────────────────────────────────────
// useSubscription<T>
// ─────────────────────────────────────────────────────────────────────────────
type EntityEventType = "created" | "updated" | "deleted";
interface EntityEvent<T> {
type: EntityEventType;
entity: string;
id: string;
data: T;
timestamp: string; // ISO 8601
tenantId: string;
}
interface SubscriptionOptions {
filter?: FilterSpec;
events?: EntityEventType[]; // default: all three
onEvent?: (event: EntityEvent<unknown>) => void;
}
interface SubscriptionResult<T> {
lastEvent: EntityEvent<T> | null;
isConnected: boolean;
reconnectAttempts: number;
}
function useSubscription<T = unknown>(
entity: string,
options?: SubscriptionOptions
): SubscriptionResult<T>;
// ─────────────────────────────────────────────────────────────────────────────
// useUser
// ─────────────────────────────────────────────────────────────────────────────
interface UserContext {
id: string;
email: string | null; // null for guest sessions
displayName: string;
tenantId: string;
roles: string[];
isGuest: boolean;
}
function useUser(): UserContext;
// ─────────────────────────────────────────────────────────────────────────────
// usePermission
// ─────────────────────────────────────────────────────────────────────────────
type PermissionAction = "create" | "read" | "update" | "delete" | "admin";
function usePermission(
action: PermissionAction | string,
resource: string // entity name or "*" for any
): boolean;
// ─────────────────────────────────────────────────────────────────────────────
// useAppStorage
// ─────────────────────────────────────────────────────────────────────────────
function useAppStorage<T>(
key: string,
defaultValue: T
): [T, (value: T) => Promise<void>];
// ─────────────────────────────────────────────────────────────────────────────
// Error type
// ─────────────────────────────────────────────────────────────────────────────
interface AppSDKError {
code: string; // e.g. "PERMISSION_DENIED", "ENTITY_NOT_FOUND", "NETWORK_ERROR"
message: string;
statusCode: number; // HTTP status code; 0 for network errors
isRetryable: boolean; // true for 429, 503, NETWORK_ERROR
requestId: string; // X-Request-ID from BFF response; empty string for network errors
}
5. AppProvider¶
Responsibilities¶
AppProvider is the mandatory root component for every hosted app. It must be the outermost component in the component tree. It performs four tasks at mount time:
- Reads
window.__OP_APP_CONFIG__and validates the shape - Initialises the
BffClientsingleton with the base URL - Seeds the
PermissionCachefromGET /bff/permissions - Seeds the user context from
GET /bff/me - Opens the WebSocket connection via
WebSocketManager
All hooks assume they run inside an AppProvider. If they are called outside one, they throw a clear error in development mode and return safe fallback values in production mode.
window.OP_APP_CONFIG¶
The App Service injects the following into the HTML shell as an inline script (the only inline script allowed by CSP) using a nonce:
<script nonce="{{CSP_NONCE}}">
window.__OP_APP_CONFIG__ = {
"appId": "{{APP_ID}}",
"tenantId": "{{TENANT_ID}}"
};
</script>
AppProvider reads and validates this object at mount using a type guard. If the object is absent or malformed, AppProvider renders an error boundary fallback and logs a console error. It does not throw during rendering, to avoid an unhandled error from crashing the entire shell page.
interface OPAppConfig {
appId: string;
tenantId: string;
}
function readAppConfig(overrides?: Partial<OPAppConfig>): OPAppConfig {
// overrides used only in tests (_testAppId, _testTenantId props)
const raw = (window as Window & { __OP_APP_CONFIG__?: unknown }).__OP_APP_CONFIG__;
if (!raw || typeof raw !== "object") {
throw new Error("[app-sdk] window.__OP_APP_CONFIG__ is missing. Ensure the App Service HTML shell is serving the app correctly.");
}
const config = raw as Record<string, unknown>;
if (typeof config.appId !== "string" || typeof config.tenantId !== "string") {
throw new Error("[app-sdk] window.__OP_APP_CONFIG__ is missing appId or tenantId.");
}
return {
appId: overrides?.appId ?? config.appId,
tenantId: overrides?.tenantId ?? config.tenantId,
};
}
AppContext¶
// src/provider/AppContext.ts
interface AppContextValue {
appId: string;
tenantId: string;
bffClient: BffClient;
permissionCache: PermissionCache;
wsManager: WebSocketManager;
user: UserContext | null; // null while loading
isReady: boolean; // true after permissions + user are loaded
}
const AppContext = React.createContext<AppContextValue | null>(null);
export function useAppContext(): AppContextValue {
const ctx = React.useContext(AppContext);
if (ctx === null) {
if (process.env.NODE_ENV !== "production") {
throw new Error("[app-sdk] Hook called outside of <AppProvider>. Wrap your app root with <AppProvider>.");
}
// Production: return a safe no-op context rather than crashing
return createNullContext();
}
return ctx;
}
Initialisation sequence¶
AppProvider executes the following on mount. Steps 2 and 3 run in parallel. The app renders a loading state until both complete, then sets isReady: true.
Mount
│
├─ 1. readAppConfig() → { appId, tenantId }
│
├─ 2. GET /bff/me (via BffClient) ─────────────────────┐
│ │ parallel
└─ 3. GET /bff/permissions (via BffClient) ────────── ──────────┘
│
both resolve ▼
set user, seed PermissionCache
isReady = true
render children
If either request returns 401, AppProvider redirects to /login?redirect={encodeURIComponent(window.location.pathname + window.location.search)}. This handles expired sessions transparently.
If either request fails with a non-401 error, AppProvider renders an inline error UI (not a full-page redirect). The error is surfaced to the developer in the console with the requestId for debugging.
Re-initialisation on visibility change¶
When document.visibilityState changes to "visible", AppProvider triggers a permission cache refresh (calls PermissionCache.refresh()). This handles the case where a user's permissions change while they have the app open in a background tab. The refresh is debounced with a 2-second delay to avoid hammering the BFF immediately on tab switch.
React.useEffect(() => {
const onVisible = debounce(() => {
if (document.visibilityState === "visible") {
permissionCache.refresh();
}
}, 2000);
document.addEventListener("visibilitychange", onVisible);
return () => document.removeEventListener("visibilitychange", onVisible);
}, [permissionCache]);
Children loading behaviour¶
While isReady is false, AppProvider renders null by default. App developers may pass a loadingFallback prop to render a custom loading UI:
interface AppProviderProps {
children: React.ReactNode;
loadingFallback?: React.ReactNode; // default: null
_testAppId?: string;
_testTenantId?: string;
}
Rationale: rendering children before isReady would cause every hook to fire without permission state, producing a brief flash of forbidden data or errors before the permission cache is ready. Suppressing render until isReady is the correct default.
6. BFF Client (Internal)¶
BffClient is an internal class (not exported). It wraps fetch with consistent error handling, request ID generation, and credential handling.
Class definition¶
// src/client/BffClient.ts
interface BffRequestOptions {
method?: "GET" | "POST" | "PATCH" | "PUT" | "DELETE";
body?: unknown;
queryParams?: Record<string, string | number | boolean | string[]>;
signal?: AbortSignal;
}
class BffClient {
private readonly baseUrl: string;
constructor() {
// Base URL is always the same origin — no configuration needed
this.baseUrl = window.location.origin;
}
async request<T>(path: string, options: BffRequestOptions = {}): Promise<T> {
const url = new URL(`${this.baseUrl}${path}`);
if (options.queryParams) {
appendQueryParams(url.searchParams, options.queryParams);
}
let response: Response;
try {
response = await fetch(url.toString(), {
method: options.method ?? "GET",
headers: {
"Content-Type": "application/json",
"Accept": "application/json",
},
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
credentials: "include", // Always include httpOnly cookie
redirect: "error", // Never follow redirects — C-5
signal: options.signal,
});
} catch (err) {
throw createNetworkError(err);
}
if (!response.ok) {
throw await parseBffError(response);
}
return response.json() as Promise<T>;
}
}
Query parameter serialisation¶
Filter parameters follow the bracket notation used by the BFF:
filter[status][eq]=active
filter[ownerId][in][]=id1&filter[ownerId][in][]=id2
sort[0]=name&sort[1]=-createdAt
fields[0]=id&fields[1]=name
cursor=eyJpZCI6IjEyMyJ9
limit=50
appendQueryParams handles array flattening and nested object notation.
Error parsing¶
async function parseBffError(response: Response): Promise<AppSDKError> {
const requestId = response.headers.get("X-Request-ID") ?? "";
let body: { error?: { code?: string; message?: string } } = {};
try {
body = await response.json();
} catch {
// non-JSON error body — use HTTP status text
}
return {
code: body.error?.code ?? httpStatusToCode(response.status),
message: body.error?.message ?? response.statusText,
statusCode: response.status,
isRetryable: [429, 503].includes(response.status),
requestId,
};
}
function httpStatusToCode(status: number): string {
const map: Record<number, string> = {
400: "BAD_REQUEST",
401: "UNAUTHORIZED",
403: "PERMISSION_DENIED",
404: "ENTITY_NOT_FOUND",
409: "CONFLICT",
429: "RATE_LIMITED",
500: "INTERNAL_ERROR",
503: "SERVICE_UNAVAILABLE",
};
return map[status] ?? "UNKNOWN_ERROR";
}
Network error factory¶
function createNetworkError(err: unknown): AppSDKError {
const message = err instanceof Error ? err.message : "Network request failed";
return {
code: "NETWORK_ERROR",
message,
statusCode: 0,
isRetryable: true,
requestId: "",
};
}
AbortController management¶
Every BffClient.request call accepts an optional AbortSignal. Hooks create and manage their own AbortController instances in useEffect cleanup. BffClient itself does not manage timeouts — that is the responsibility of individual hooks.
7. Permission Cache (Internal)¶
PermissionCache holds a snapshot of all permissions for the current user and supports synchronous boolean lookups. It is seeded at AppProvider mount and refreshed on visibility change.
BFF endpoint¶
GET /bff/permissions
Response: {
permissions: Array<{
action: string; // "create" | "read" | "update" | "delete" | "admin"
resource: string; // entity name or "*"
allowed: boolean;
}>
}
Cache shape¶
Permissions are indexed in a Map<string, boolean> keyed by "${action}:${resource}" for O(1) synchronous lookup.
// src/cache/PermissionCache.ts
class PermissionCache {
private snapshot: Map<string, boolean> = new Map();
private hasWildcardAdmin = false;
private listeners = new Set<() => void>();
private refreshTimer: ReturnType<typeof setTimeout> | null = null;
private readonly REFRESH_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
async seed(bffClient: BffClient): Promise<void> {
const data = await bffClient.request<PermissionsResponse>("/bff/permissions");
this.applySnapshot(data.permissions);
this.scheduleRefresh(bffClient);
}
check(action: string, resource: string): boolean {
// Admin wildcard on any resource grants all actions on that resource
if (this.snapshot.get(`admin:*`) === true) return true;
if (this.snapshot.get(`admin:${resource}`) === true) return true;
return this.snapshot.get(`${action}:${resource}`) === true ||
this.snapshot.get(`${action}:*`) === true;
}
async refresh(bffClient: BffClient): Promise<void> {
try {
const data = await bffClient.request<PermissionsResponse>("/bff/permissions");
this.applySnapshot(data.permissions);
this.notifyListeners();
} catch {
// Refresh failure is non-fatal. Keep serving the existing snapshot.
// The next scheduled refresh will try again.
}
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private applySnapshot(permissions: PermissionEntry[]): void {
this.snapshot = new Map(
permissions.map((p) => [`${p.action}:${p.resource}`, p.allowed])
);
}
private notifyListeners(): void {
this.listeners.forEach((fn) => fn());
}
private scheduleRefresh(bffClient: BffClient): void {
this.refreshTimer = setInterval(
() => void this.refresh(bffClient),
this.REFRESH_INTERVAL_MS
);
}
destroy(): void {
if (this.refreshTimer !== null) clearInterval(this.refreshTimer);
this.listeners.clear();
}
}
Why a flat snapshot, not a tree¶
A nested { resource: { action: boolean } } tree would require a lookup like snapshot?.customers?.read. A flat Map<"read:customers", boolean> is O(1), requires no optional chaining, and is trivially inspectable in DevTools. The wildcard check adds at most one additional lookup.
Stale permission handling¶
If the user's permissions change between a PermissionCache refresh, components calling usePermission will temporarily show a stale result. This is an acceptable trade-off — the BFF enforces RBAC on every actual data request regardless of the client-side cache. A stale true from usePermission followed by a 403 from the BFF is handled by the hook's error path. A stale false hides UI elements that the user now has access to; these reappear on next refresh or visibility change.
8. useQuery Hook¶
Behaviour contract¶
- Fetches
GET /bff/data/{entity}with query parameters derived fromoptions - Deduplicates concurrent calls with the same
(entity, options)key — a second call within thestaleTimewindow returns the cached result without re-fetching - Implements stale-while-revalidate: returns cached data immediately, triggers a background refetch if the entry is older than
staleTime fetchNextPageappends todata[]using cursor pagination — it does not replace the current result- Does not fetch if
enabled: false - Cancels in-flight fetches on unmount (via
AbortController)
Query cache key¶
The cache key is a stable JSON string of { entity, filter, sort, fields, limit }. The cursor is excluded from the key because cursor state is managed inside the hook's internal page list. staleTime is excluded because it controls eviction, not identity.
function buildCacheKey(entity: string, options: QueryOptions): string {
return JSON.stringify({
entity,
filter: options.filter ?? null,
sort: options.sort ?? null,
fields: options.fields ?? null,
limit: options.limit ?? 50,
});
}
QueryCache (internal)¶
QueryCache is a module-level singleton (not stored in React state) to enable deduplication across multiple component instances mounting simultaneously.
// src/cache/QueryCache.ts
interface CacheEntry<T> {
data: T[] | null;
pagination: Pagination | null;
error: AppSDKError | null;
fetchedAt: number; // Date.now()
promise: Promise<void> | null; // in-flight deduplification
}
class QueryCache {
private entries = new Map<string, CacheEntry<unknown>>();
get<T>(key: string): CacheEntry<T> | undefined {
return this.entries.get(key) as CacheEntry<T> | undefined;
}
set<T>(key: string, entry: CacheEntry<T>): void {
this.entries.set(key, entry as CacheEntry<unknown>);
}
isStale(key: string, staleTime: number): boolean {
const entry = this.entries.get(key);
if (!entry) return true;
return Date.now() - entry.fetchedAt > staleTime;
}
invalidate(entity: string): void {
// Invalidate all cache entries for a given entity (called by useMutation after write)
for (const key of this.entries.keys()) {
if (key.includes(`"entity":"${entity}"`)) {
this.entries.delete(key);
}
}
}
}
export const queryCache = new QueryCache();
Hook implementation sketch¶
// src/hooks/useQuery.ts
export function useQuery<T = unknown>(
entity: string,
options: QueryOptions = {}
): QueryResult<T> {
const { bffClient, isReady } = useAppContext();
const cacheKey = buildCacheKey(entity, options);
const staleTime = options.staleTime ?? 30_000;
// useSyncExternalStore for cache-driven re-renders
const cachedEntry = useSyncExternalStore(
(notify) => queryCache.subscribe(cacheKey, notify),
() => queryCache.get<T>(cacheKey),
);
const isLoading = !isReady || (cachedEntry === undefined && !cachedEntry?.error);
const pages = useRef<string[]>([]); // cursor chain for fetchNextPage
const fetchPage = useCallback(
async (cursor?: string, append = false) => {
const controller = new AbortController();
const params = buildQueryParams(options, cursor);
try {
const result = await bffClient.request<BffDataResponse<T>>(
`/bff/data/${entity}`,
{ queryParams: params, signal: controller.signal }
);
queryCache.set(cacheKey, {
data: append
? [...(queryCache.get<T>(cacheKey)?.data ?? []), ...result.data]
: result.data,
pagination: result.pagination,
error: null,
fetchedAt: Date.now(),
promise: null,
});
} catch (err) {
if ((err as Error).name === "AbortError") return;
const sdkError = err as AppSDKError;
queryCache.set(cacheKey, {
data: cachedEntry?.data ?? null,
pagination: cachedEntry?.pagination ?? null,
error: sdkError,
fetchedAt: Date.now(),
promise: null,
});
options.onError?.(sdkError);
}
},
[entity, cacheKey, bffClient, options]
);
// Initial fetch and stale-while-revalidate
useEffect(() => {
if (!isReady || options.enabled === false) return;
if (queryCache.isStale(cacheKey, staleTime)) {
void fetchPage(undefined, false);
}
}, [isReady, options.enabled, cacheKey, staleTime, fetchPage]);
const refetch = useCallback(() => fetchPage(undefined, false), [fetchPage]);
const fetchNextPage = useCallback(async () => {
const cursor = cachedEntry?.pagination?.nextCursor;
if (!cursor) return;
await fetchPage(cursor, true);
}, [cachedEntry, fetchPage]);
return {
data: cachedEntry?.data ?? null,
pagination: cachedEntry?.pagination ?? null,
isLoading: isLoading && !cachedEntry,
isError: !!cachedEntry?.error,
error: cachedEntry?.error ?? null,
refetch,
fetchNextPage,
};
}
BFF request URL pattern¶
GET /bff/data/{entity}
?filter[field][op]=value
&sort[0]=field&sort[1]=-otherField
&fields[0]=id&fields[1]=name
&cursor=eyJpZCI6IjEyMyJ9
&limit=50
Stale-while-revalidate semantics¶
| Cache state | isLoading | data | Behaviour |
|---|---|---|---|
| No entry | true | null | Fetch in progress, nothing to show yet |
| Fresh entry | false | cached | No re-fetch triggered |
| Stale entry | false | cached | Background re-fetch; data updates when resolved |
| Errored entry | false | null | isError: true, error set |
9. useMutation Hook¶
Behaviour contract¶
- Exposes
create,update,replace,remove,bulkCreatefunctions - Each function returns a
Promisethat resolves with the server's response or rejects withAppSDKError - Applies optimistic updates to
QueryCachebefore the network request, then rolls back on error - Sets
isLoading: truewhile any mutation is in flight;falseotherwise - Multiple concurrent mutations on the same entity are serialised (queued) to avoid race conditions on optimistic state
BFF request mapping¶
| Method | HTTP | URL |
|---|---|---|
create(data) | POST | /bff/data/{entity} |
update(id, data) | PATCH | /bff/data/{entity}/{id} |
replace(id, data) | PUT | /bff/data/{entity}/{id} |
remove(id) | DELETE | /bff/data/{entity}/{id} |
bulkCreate(items) | POST | /bff/data/{entity}/bulk |
Optimistic update strategy¶
On create: append a provisional record (with a client-generated _optimisticId field) to all QueryCache entries for the entity.
On update/replace: replace the matching record in all QueryCache entries for the entity.
On remove: remove the matching record from all QueryCache entries for the entity.
The provisional record or modification is applied synchronously before the fetch. On network error, the cache is restored to its pre-mutation state using the saved snapshot.
export function useMutation<T = unknown>(entity: string): MutationResult<T> {
const { bffClient } = useAppContext();
const [state, setState] = useState<MutationState>({
isLoading: false,
isError: false,
error: null,
});
const mutationQueue = useRef<Promise<void>>(Promise.resolve());
function enqueue<R>(fn: () => Promise<R>): Promise<R> {
let resolve!: (v: R) => void;
let reject!: (e: unknown) => void;
const p = new Promise<R>((res, rej) => { resolve = res; reject = rej; });
mutationQueue.current = mutationQueue.current.then(async () => {
try { resolve(await fn()); } catch (e) { reject(e); }
});
return p;
}
const create = useCallback((data: Partial<T>): Promise<T> => {
return enqueue(async () => {
setState({ isLoading: true, isError: false, error: null });
const snapshot = queryCache.snapshot(entity);
const optimisticId = `_opt_${crypto.randomUUID()}`;
queryCache.optimisticCreate(entity, { ...data, _optimisticId: optimisticId });
try {
const result = await bffClient.request<T>(`/bff/data/${entity}`, {
method: "POST",
body: data,
});
queryCache.confirmCreate(entity, optimisticId, result as Record<string, unknown>);
setState({ isLoading: false, isError: false, error: null });
return result;
} catch (err) {
queryCache.restoreSnapshot(entity, snapshot);
const sdkError = err as AppSDKError;
setState({ isLoading: false, isError: true, error: sdkError });
throw sdkError;
}
});
}, [entity, bffClient]);
// update, replace, remove, bulkCreate follow the same pattern
// ...
return {
create,
update: /* ... */,
replace: /* ... */,
remove: /* ... */,
bulkCreate: /* ... */,
isLoading: state.isLoading,
isError: state.isError,
error: state.error,
reset: () => setState({ isLoading: false, isError: false, error: null }),
};
}
Why a mutation queue¶
Without serialisation, two rapid calls to remove("id-1") followed by create({ ... }) can apply optimistic state out of order, producing a corrupted cache. Queuing mutations ensures they apply sequentially while still returning individual Promises to callers. The queue is per-hook instance, not global.
Cache invalidation after mutation¶
After a successful mutation, useMutation calls queryCache.invalidate(entity). This removes all cached entries for the entity, causing useQuery instances for that entity to re-fetch on the next render cycle. This is correct but can cause a brief flash; the alternative (manual cache patching) is complex and error-prone. The staleTime on useQuery defaults to 30 seconds, so frequent mutations do not cause excessive re-fetches in practice.
10. useSubscription Hook¶
Behaviour contract¶
- Opens (or reuses) a shared WebSocket connection to
wss://{origin}/apps/{slug}/wsviaWebSocketManager - Registers interest in a specific entity and filter with the server by sending a subscription message over the WebSocket
- Delivers incoming events to the caller via
lastEventstate and the optionalonEventcallback - Reports connection status (
isConnected) and reconnect attempt count - Unregisters the subscription on unmount (sends an unsubscribe message to the server)
WebSocketManager (internal)¶
One WebSocket connection is shared across all useSubscription instances within a single app. WebSocketManager is a singleton managed by AppProvider.
// src/ws/WebSocketManager.ts
interface SubscriptionRegistration {
entity: string;
filter?: FilterSpec;
events?: EntityEventType[];
onEvent: (event: EntityEvent<unknown>) => void;
}
class WebSocketManager {
private socket: WebSocket | null = null;
private registrations = new Map<string, SubscriptionRegistration>();
private reconnectAttempts = 0;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private statusListeners = new Set<(connected: boolean, attempts: number) => void>();
private readonly MAX_RECONNECT_DELAY_MS = 30_000;
private readonly BASE_RECONNECT_DELAY_MS = 1_000;
connect(appSlug: string): void {
const url = `${window.location.origin.replace(/^http/, "ws")}/apps/${appSlug}/ws`;
this.socket = new WebSocket(url);
this.socket.addEventListener("open", this.onOpen);
this.socket.addEventListener("message", this.onMessage);
this.socket.addEventListener("close", this.onClose);
this.socket.addEventListener("error", this.onError);
}
register(id: string, reg: SubscriptionRegistration): void {
this.registrations.set(id, reg);
if (this.socket?.readyState === WebSocket.OPEN) {
this.send({ type: "subscribe", subscriptionId: id, entity: reg.entity, filter: reg.filter, events: reg.events });
}
// If not connected, registrations are replayed in onOpen
}
unregister(id: string): void {
this.registrations.delete(id);
if (this.socket?.readyState === WebSocket.OPEN) {
this.send({ type: "unsubscribe", subscriptionId: id });
}
}
private onOpen = (): void => {
this.reconnectAttempts = 0;
this.notifyStatus(true, 0);
// Replay all registrations (handles reconnect case)
for (const [id, reg] of this.registrations) {
this.send({ type: "subscribe", subscriptionId: id, entity: reg.entity, filter: reg.filter, events: reg.events });
}
};
private onMessage = (event: MessageEvent): void => {
let msg: EntityEvent<unknown> & { subscriptionId?: string };
try { msg = JSON.parse(event.data as string); } catch { return; }
const reg = this.registrations.get(msg.subscriptionId ?? "");
if (reg) reg.onEvent(msg);
};
private onClose = (): void => {
this.notifyStatus(false, this.reconnectAttempts);
this.scheduleReconnect();
};
private scheduleReconnect(): void {
const delay = Math.min(
this.BASE_RECONNECT_DELAY_MS * 2 ** this.reconnectAttempts,
this.MAX_RECONNECT_DELAY_MS
);
this.reconnectAttempts++;
this.reconnectTimer = setTimeout(() => {
const slug = this.extractSlug();
if (slug) this.connect(slug);
}, delay);
}
destroy(): void {
if (this.reconnectTimer !== null) clearTimeout(this.reconnectTimer);
this.socket?.close();
this.statusListeners.clear();
}
}
Reconnect policy¶
Exponential backoff with jitter, capped at 30 seconds:
attempt 0 → 1s
attempt 1 → 2s
attempt 2 → 4s
attempt 3 → 8s
attempt 4 → 16s
attempt 5+ → 30s (capped)
After reconnect, all active subscriptions are re-sent automatically (the onOpen handler replays registrations). No subscriber needs to do anything on reconnect.
useSubscription implementation¶
// src/hooks/useSubscription.ts
export function useSubscription<T = unknown>(
entity: string,
options: SubscriptionOptions = {}
): SubscriptionResult<T> {
const { wsManager } = useAppContext();
const id = useId(); // React 18 stable ID
const [lastEvent, setLastEvent] = useState<EntityEvent<T> | null>(null);
const [status, setStatus] = useSyncExternalStore(
(notify) => wsManager.subscribeToStatus(notify),
() => wsManager.getStatus(),
);
useEffect(() => {
wsManager.register(id, {
entity,
filter: options.filter,
events: options.events,
onEvent: (event) => {
setLastEvent(event as EntityEvent<T>);
options.onEvent?.(event);
},
});
return () => wsManager.unregister(id);
}, [id, entity, wsManager]);
// options.filter, options.events, options.onEvent are intentionally excluded
// from deps to avoid re-registering on every render if caller passes literals.
// Callers must memoize options if they need dynamic filter changes.
return {
lastEvent,
isConnected: status.isConnected,
reconnectAttempts: status.reconnectAttempts,
};
}
Options stability note¶
The options object is excluded from the useEffect dependency array. This is intentional and must be documented for app developers. If options.filter changes and the developer needs the subscription to re-register with the new filter, they must wrap the options in useMemo. This mirrors the pattern used by libraries like react-query for query key stability.
11. useUser Hook¶
Behaviour contract¶
- Returns the cached user context loaded by
AppProviderat mount - No network calls per render —
AppProviderhas already populated the context - If the session expires during the app's lifetime (rare for a 7-day session), the next BFF call returns
401, andAppProvider's global error handler redirects to login.useUseritself does not re-fetch.
Implementation¶
// src/hooks/useUser.ts
export function useUser(): UserContext {
const { user } = useAppContext();
if (user === null) {
// AppProvider is still loading (isReady: false). Return a safe sentinel value.
return {
id: "",
email: null,
displayName: "",
tenantId: "",
roles: [],
isGuest: false,
};
}
return user;
}
BFF endpoint¶
GET /bff/me
Response: {
id: string;
email: string | null; // null for guest sessions
displayName: string;
tenantId: string;
roles: string[];
isGuest: boolean;
}
The response shape maps directly to UserContext. The App Service derives all fields from the op_session cookie via the Auth Service without any user-supplied input.
12. usePermission Hook¶
Behaviour contract¶
- Synchronous — never suspends, never causes a loading state
- Reads from
PermissionCachewhich is loaded beforeAppProviderrenders children (so it is always ready when a hook component renders) - Re-renders affected components when
PermissionCacheis refreshed (viauseSyncExternalStore)
Implementation¶
// src/hooks/usePermission.ts
export function usePermission(
action: PermissionAction | string,
resource: string
): boolean {
const { permissionCache } = useAppContext();
// useSyncExternalStore ensures re-render when cache is refreshed
return useSyncExternalStore(
(notify) => permissionCache.subscribe(notify),
() => permissionCache.check(action, resource),
);
}
Why useSyncExternalStore¶
useSyncExternalStore is the correct React 18 primitive for subscribing to external mutable stores. It handles concurrent mode safely — it tears consistently and avoids the tearing problems that useState + useEffect + listener patterns introduce in React 18's concurrent rendering.
Usage guidance (for app developers)¶
// Conditional render based on permission
function AdminPanel() {
const canAdmin = usePermission("admin", "users");
if (!canAdmin) return null;
return <UserTable />;
}
// Disable button based on permission
function SaveButton({ onClick }) {
const canCreate = usePermission("create", "orders");
return (
<button onClick={onClick} disabled={!canCreate}>
Create Order
</button>
);
}
Permission model mapping¶
The BFF returns a flat array of allowed permissions. The PermissionCache.check method applies the following precedence:
- If
admin:*is in the snapshot, all checks returntrue - If
admin:{resource}is in the snapshot, all action checks for that resource returntrue - If
{action}:*is in the snapshot, the action is permitted on any resource - If
{action}:{resource}is in the snapshot, the action is permitted on that specific resource - Otherwise,
false
13. useAppStorage Hook¶
Behaviour contract¶
- Returns
[value, setValue]wherevalueis the current stored value (ordefaultValueif nothing is stored) setValuesends aPUT /bff/storage/{key}request and updates local state optimistically- Key constraints: maximum 128 characters, alphanumeric plus
-and_ - Value constraints: serialisable as JSON, maximum 64 KB (enforced client-side before PUT)
- Storage is per-app and per-user — two users viewing the same app have independent storage
- Persistence: survives browser refresh via the BFF (not
localStorage)
BFF endpoints¶
GET /bff/storage/{key}
Response: { key: string; value: unknown; updatedAt: string } | { key: string; value: null }
PUT /bff/storage/{key}
Request body: { value: unknown }
Response: { key: string; value: unknown; updatedAt: string }
Implementation¶
// src/hooks/useAppStorage.ts
const MAX_KEY_LENGTH = 128;
const MAX_VALUE_BYTES = 64 * 1024; // 64 KB
const VALID_KEY_PATTERN = /^[a-zA-Z0-9_-]+$/;
export function useAppStorage<T>(
key: string,
defaultValue: T
): [T, (value: T) => Promise<void>] {
const { bffClient, isReady } = useAppContext();
const [value, setValueState] = useState<T>(defaultValue);
const [isLoaded, setIsLoaded] = useState(false);
if (key.length > MAX_KEY_LENGTH || !VALID_KEY_PATTERN.test(key)) {
throw new Error(`[app-sdk] useAppStorage key "${key}" is invalid. Keys must be 1-128 alphanumeric characters, hyphens, or underscores.`);
}
// Load initial value from BFF
useEffect(() => {
if (!isReady) return;
let cancelled = false;
bffClient.request<StorageGetResponse>(`/bff/storage/${encodeURIComponent(key)}`)
.then((res) => {
if (!cancelled) {
setValueState(res.value !== null ? (res.value as T) : defaultValue);
setIsLoaded(true);
}
})
.catch(() => {
if (!cancelled) setIsLoaded(true); // Use defaultValue on error
});
return () => { cancelled = true; };
}, [isReady, key]);
const setValue = useCallback(async (newValue: T): Promise<void> => {
const serialised = JSON.stringify(newValue);
const byteLength = new TextEncoder().encode(serialised).length;
if (byteLength > MAX_VALUE_BYTES) {
throw {
code: "VALUE_TOO_LARGE",
message: `Storage value for key "${key}" exceeds 64 KB limit (${byteLength} bytes).`,
statusCode: 0,
isRetryable: false,
requestId: "",
} satisfies AppSDKError;
}
// Optimistic update
setValueState(newValue);
await bffClient.request(`/bff/storage/${encodeURIComponent(key)}`, {
method: "PUT",
body: { value: newValue },
});
}, [key, bffClient]);
return [isLoaded ? value : defaultValue, setValue];
}
Guest session behaviour¶
For guest sessions (useUser().isGuest === true), the BFF writes storage to a short-lived guest session record. Writes succeed but expire when the guest session expires (24 hours). useAppStorage has no special handling for this — the hook behaves identically. The expiry semantics are a BFF concern.
14. Error Handling¶
Error taxonomy¶
All errors are normalised to AppSDKError before reaching the caller.
| Code | Status | Retryable | Source |
|---|---|---|---|
NETWORK_ERROR | 0 | Yes | fetch threw (offline, DNS, timeout) |
UNAUTHORIZED | 401 | No | Session expired — AppProvider redirects to login |
PERMISSION_DENIED | 403 | No | RBAC check failed at BFF |
ENTITY_NOT_FOUND | 404 | No | Entity or record does not exist |
VALIDATION_ERROR | 400 | No | Request body failed BFF validation |
CONFLICT | 409 | No | Optimistic lock conflict |
RATE_LIMITED | 429 | Yes | Too many requests from this browser |
INTERNAL_ERROR | 500 | No | BFF internal error |
SERVICE_UNAVAILABLE | 503 | Yes | Downstream service temporarily down |
VALUE_TOO_LARGE | 0 | No | Storage value exceeds 64 KB (client-side check) |
Global 401 handling¶
Any 401 response from any BFF endpoint is intercepted by BffClient before returning the error to the hook. BffClient calls a registered onUnauthorized callback (set by AppProvider) which triggers the redirect to /login.
class BffClient {
private onUnauthorized: (() => void) | null = null;
setUnauthorizedHandler(handler: () => void): void {
this.onUnauthorized = handler;
}
async request<T>(path: string, options: BffRequestOptions = {}): Promise<T> {
// ...
if (response.status === 401) {
this.onUnauthorized?.();
// Throw so the individual hook also knows (even though AppProvider will redirect)
throw await parseBffError(response);
}
// ...
}
}
AppProvider registers the handler at mount:
bffClient.setUnauthorizedHandler(() => {
window.location.href = `/login?redirect=${encodeURIComponent(window.location.pathname + window.location.search)}`;
});
useQuery error behaviour¶
useQuery does not throw on error. It sets isError: true and error on the returned object. This follows the pattern established by React Query and SWR, which developers are already familiar with. useQuery does NOT use React's ErrorBoundary mechanism — the caller is expected to handle the isError state explicitly.
useMutation error behaviour¶
useMutation.create/update/replace/remove/bulkCreate return Promises that reject with AppSDKError. The caller must handle the rejected Promise. isError and error on the hook result reflect the most recent mutation's error state.
Retry policy¶
No automatic retry is built into the SDK. The isRetryable flag on AppSDKError signals to the caller whether retrying makes sense. App developers who want retry behaviour should wrap mutation calls with their own retry logic. The rationale is that automatic retries for mutations (create, update, delete) can cause duplicate operations if the original request succeeded but the response was lost. Automatic retry for queries is safer but would require exponential backoff state, increasing complexity. Since the platform rate-limits at 500 requests/minute (well above any realistic app usage), network transients are rare.
15. Type Generation and Ontology Generics¶
Goal¶
App developers write useQuery<Customer>() and get full TypeScript type safety on data, create, update etc. — field names autocomplete, wrong field names are compile errors, returned data is typed as Customer[].
How types reach Monaco¶
The App Service generates TypeScript declaration files for all tenant entities and injects them into Monaco using monaco.languages.typescript.typescriptDefaults.addExtraLib() before the editor renders. These files are generated by the Ontology Service's code generation path (ADR-14) and delivered to the browser via GET /api/v1/apps/{id}/types.
The injected declarations look like:
// auto-generated — do not edit
declare module "@oneplatform/app-sdk" {
export interface Customer {
id: string;
name: string;
email: string;
status: "active" | "churned" | "prospect";
createdAt: string;
tenantId: string;
}
export interface Order {
id: string;
customerId: string;
total: number;
status: "pending" | "fulfilled" | "cancelled";
createdAt: string;
tenantId: string;
}
}
With these declarations present, useQuery<Customer> resolves to QueryResult<Customer>, and data is typed as Customer[] | null.
Package-level generics¶
The SDK itself ships with all hooks typed as generics with default T = unknown. This means the package works correctly without any injected declarations — the developer simply loses static type safety. The injected declarations are additive, not required.
TypeScript strict mode¶
All package source files are compiled with "strict": true. The generated declaration files must also pass strict type checking (the App Service validates them after generation).
16. Security Design¶
Authentication model¶
The SDK carries no tokens. Authentication state is entirely in the op_session httpOnly Secure SameSite=Strict cookie, which is set by the App Service at login and is invisible to JavaScript. Every fetch call uses credentials: "include" to send this cookie automatically.
An attacker who achieves XSS in the app sandbox cannot extract the session cookie. They can call BFF endpoints via fetch (the cookie is sent automatically), but: 1. All BFF calls are subject to RBAC enforcement before reaching any internal service 2. The connect-src 'self' CSP directive prevents calling any non-same-origin URL 3. The Content-Security-Policy: frame-ancestors 'self' header prevents the app from being embedded in a malicious page
CSRF¶
SameSite=Strict cookies are not sent on cross-site requests. A cross-site attacker cannot include the op_session cookie in a forged request. For apps served under the platform's own origin, SameSite=Strict is fully effective. For custom subdomain deployments where SameSite may not prevent cross-subdomain requests (same eTLD+1), the App Service additionally checks the Origin header against the app's registered origin (per ADR-26).
Input sanitisation¶
The SDK validates: - Storage keys: pattern /^[a-zA-Z0-9_-]+$/, max 128 chars — prevents path traversal in /bff/storage/{key} - Storage value size: max 64 KB checked client-side before PUT — fail-fast, better UX than a server 413 - Entity names passed to hooks: entity names containing / or .. would produce invalid BFF URLs. The SDK normalises entity names with encodeURIComponent.
Scope isolation¶
useAppStorage keys are scoped to { appId, userId } on the server. There is no client-side enforcement needed — the BFF does the scoping. However, developers should be aware that two apps cannot share storage via useAppStorage; cross-app communication is an inter-app messaging feature outside the scope of this package.
Permission cache staleness¶
The permission cache is a best-effort UI optimisation. The BFF enforces RBAC on every actual data request regardless of what the client-side cache says. A stale cached true (user lost permission but cache has not refreshed) results in a 403 PERMISSION_DENIED error from the BFF, which the hook surfaces as isError: true. This is the correct security outcome.
17. Testing Strategy¶
Framework¶
- Unit tests: Vitest with React Testing Library (
@testing-library/react,@testing-library/hooks) - Mock BFF:
__mocks__/BffClient.ts— a manual mock that captures calls and lets tests assert on request arguments and return controlled responses - No integration tests in this package — integration is tested at the App Service level via Docker Compose
What each test file covers¶
| File | What to test |
|---|---|
BffClient.test.ts | Error parsing for each status code, network error wrapping, query param serialisation, redirect: "error" enforcement, credentials: "include" assertion |
PermissionCache.test.ts | Wildcard rules, admin:* override, refresh on visibility change, staleness after refresh, subscribe/unsubscribe |
QueryCache.test.ts | Stale detection, invalidation by entity, snapshot and restore |
useQuery.test.ts | Data rendering, isLoading transitions, isError on 403/500, fetchNextPage appending, deduplication (same key, two mounts), enabled: false skips fetch |
useMutation.test.ts | Optimistic create, rollback on error, queue serialisation (two concurrent creates), reset() clears error state |
useSubscription.test.ts | lastEvent updates on message, isConnected reflects WebSocket state, unmount unregisters |
useUser.test.ts | Returns sentinel when isReady: false, returns user when ready, guest session nulls email |
usePermission.test.ts | Synchronous return, re-renders on cache refresh, wildcard matching |
useAppStorage.test.ts | Loads default on empty, 64 KB guard throws before fetch, optimistic update before PUT resolves |
AppProvider.test.ts | Renders children after init, redirects on 401 from /bff/me, renders loadingFallback during init, invalid window.__OP_APP_CONFIG__ shows error state |
Mock BFF pattern¶
// __mocks__/BffClient.ts
const handlers: Map<string, unknown> = new Map();
export function mockBffResponse(path: string, response: unknown): void {
handlers.set(path, response);
}
export function mockBffError(path: string, error: Partial<AppSDKError>): void {
handlers.set(path, { __isMockError: true, ...error });
}
export class BffClient {
async request<T>(path: string): Promise<T> {
const handler = handlers.get(path);
if (!handler) throw new Error(`[mock-bff] No handler for ${path}`);
if ((handler as { __isMockError?: boolean }).__isMockError) throw handler;
return handler as T;
}
}
Test wrapper¶
All hook tests use a shared renderWithProvider helper:
function renderWithProvider(
ui: React.ReactElement,
overrides: { appId?: string; tenantId?: string } = {}
) {
return render(
<AppProvider _testAppId={overrides.appId ?? "test-app"} _testTenantId={overrides.tenantId ?? "test-tenant"}>
{ui}
</AppProvider>
);
}
Coverage targets¶
- Statement coverage: >= 90%
- Branch coverage: >= 85%
- All
AppSDKErrorcodes exercised in at least one test
18. Build and Distribution¶
Build output¶
The package is built using tsc (type declarations) and the monorepo's shared esbuild config (CJS + ESM bundles).
// packages/app-sdk/package.json (relevant fields)
{
"name": "@oneplatform/app-sdk",
"version": "0.1.0",
"main": "dist/index.cjs",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
}
},
"sideEffects": false,
"files": ["dist"]
}
"sideEffects": false enables tree-shaking in esbuild. Since the package has no module-level side effects (no global registrations, no polyfills), this is accurate.
Pre-bundled in sandbox¶
The Execution Service sandbox image pre-installs @oneplatform/app-sdk from a private registry. User apps do not install it at build time — it is already present. The esbuild call that compiles user apps marks @oneplatform/app-sdk as external, so the bundle does not include the SDK source; it is loaded from the pre-bundled copy at runtime.
This means the SDK version is locked to what the sandbox image was built with. Updating the SDK requires rebuilding the sandbox image. This is intentional: it ensures all hosted apps use the same SDK version and prevents a malicious app from loading a modified SDK.
Type declaration injection¶
The App Service reads the SDK's dist/index.d.ts at startup and caches it in memory. When the Monaco editor loads for an app, the App Service serves this file (plus the tenant's generated entity declarations) to the browser for injection via addExtraLib. This means Monaco always uses type declarations that match the deployed SDK version.
19. Open Questions and Deferred Decisions¶
| # | Question | Impact | Decision Needed By |
|---|---|---|---|
| OQ-1 | Should useSubscription support SSE as a fallback for environments where WebSocket is blocked? ADR-26 references SSE for App Service → Gateway, but the browser → App Service connection is documented as WebSocket only. | Medium — some enterprise proxies block WebSocket | Before Phase 6 (SDK implementation) |
| OQ-2 | Should useQuery.fetchNextPage support backwards pagination (previous page)? The current design is cursor-forward only. | Low — most app use cases are forward-only lists | Can defer until user feedback |
| OQ-3 | useMutation.bulkCreate — should it be transactional (all-or-nothing) or best-effort (partial success)? The current BulkResult<T> shape supports partial success, but the BFF contract must be clarified. | Medium — affects error recovery logic in apps | Requires BFF contract decision |
| OQ-4 | Inter-app communication: apps in the same tenant cannot currently communicate except through shared useQuery data. Should the SDK expose a useAppMessage hook? | Low for MVP | Defer to post-MVP |
| OQ-5 | Should useAppStorage support listing all keys for the current app/user (GET /bff/storage)? Useful for migration/debugging. | Low — not needed for core use cases | Can add without breaking change |
| OQ-6 | The options stability note in useSubscription (users must useMemo filter options) is a footgun. Should the SDK do deep equality comparison internally on each render to detect filter changes? | Medium — developer experience | Evaluate in Phase 6; requires benchmark to confirm no perf regression |
Appendix A: BFF Endpoint Summary¶
All endpoints are relative to window.location.origin (same-origin). All include Cookie: op_session=... automatically.
| Endpoint | Method | Hook | Description |
|---|---|---|---|
/bff/me | GET | AppProvider, useUser | Current user context |
/bff/permissions | GET | AppProvider, PermissionCache | Permission snapshot |
/bff/data/{entity} | GET | useQuery | List records with filtering, sorting, pagination |
/bff/data/{entity} | POST | useMutation.create | Create one record |
/bff/data/{entity}/bulk | POST | useMutation.bulkCreate | Create multiple records |
/bff/data/{entity}/{id} | PATCH | useMutation.update | Partial update |
/bff/data/{entity}/{id} | PUT | useMutation.replace | Full replacement |
/bff/data/{entity}/{id} | DELETE | useMutation.remove | Delete one record |
/bff/storage/{key} | GET | useAppStorage | Read one storage value |
/bff/storage/{key} | PUT | useAppStorage | Write one storage value |
wss://{origin}/apps/{slug}/ws | WS | useSubscription | Real-time entity events |
Appendix B: Data Flow Diagrams¶
AppProvider Initialisation¶
window.__OP_APP_CONFIG__
│
▼
readAppConfig()
│
├──────────────────────────────┐
▼ ▼
GET /bff/me GET /bff/permissions
──── 200 ────────────────────── 200 ────────────────
│ │
▼ ▼
user = UserContext PermissionCache.applySnapshot()
│ │
└──────────────────────────────┘
│
▼
isReady = true
render children
useQuery Data Flow¶
Component renders
│
▼
useQuery("customers", { filter: { status: { eq: "active" } } })
│
├── QueryCache.get(key)
│ │
│ ├── MISS → isLoading: true, fetch GET /bff/data/customers?...
│ │ │
│ │ ▼
│ │ App Service BFF
│ │ │ validate session cookie
│ │ │ check RBAC (can user read customers?)
│ │ │ forward to Ontology Service
│ │ │ apply field-level permissions
│ │ ▼
│ │ { data: [...], pagination: { nextCursor, total } }
│ │ │
│ │ ▼
│ └── QueryCache.set(key, entry) → useSyncExternalStore notify → re-render
│
└── HIT (fresh) → data: cached, isLoading: false
└── HIT (stale) → data: cached, isLoading: false, background refetch
useMutation Optimistic Update¶
useMutation("orders").create({ total: 99.99 })
│
├─ 1. QueryCache snapshot saved
├─ 2. QueryCache.optimisticCreate → provisional record appended
├─ 3. Component re-renders with provisional record visible
│
├─ 4. POST /bff/data/orders { total: 99.99 }
│ │
│ ├── 201 Created → { id: "real-id", ... }
│ │ │
│ │ └─ 5a. QueryCache.confirmCreate → replace provisional with real record
│ │ Component re-renders with confirmed record
│ │
│ └── 4xx/5xx
│ │
│ └─ 5b. QueryCache.restoreSnapshot → undo provisional record
│ isError: true, error: AppSDKError
│ Component re-renders without provisional record