PermDock
Adapters

React Native

permdock/react-native adds a persisted snapshot so Expo Router Stack.Protected and Tabs.Protected guards answer synchronously on the first frame and revalidate in the background.

Status: planned Phase: 2

Purpose

Expo Router's Stack.Protected and Tabs.Protected (SDK 53 and later) take a plain boolean guard. They are evaluated synchronously during the first render, before any network request can complete. permdock/react-native re-exports everything from permdock/react and adds one option, storage, so the last known snapshot is read from device storage on launch, guards answer immediately, and a fresh snapshot is fetched and swapped in without a flash. See Expo Router research.

API

import { PermDockProvider, usePermDock, usePermission, Protected } from 'permdock/react-native'
import { permissions } from '@/permissions'

<PermDockProvider
  storage={mmkvStorage}              // { getItem, setItem, removeItem }, sync or async
  endpoint="https://api.example.com/permdock"
  snapshotUrl="https://api.example.com/permdock/snapshot"
>
  <Stack>
    <Stack.Protected guard={usePermission(permissions.admin.access).allowed}>
      <Stack.Screen name="admin" />
    </Stack.Protected>
  </Stack>
</PermDockProvider>
Option or exportRole
storageAny object with getItem, setItem, removeItem (MMKV, expo-secure-store, AsyncStorage). Synchronous stores answer on the first frame; asynchronous stores answer on the second frame with status: 'pending' in between.
snapshotUrlWhere to fetch a fresh snapshot after launch and on refresh(). The request carries the app's session token through the headers or fetch option.
endpointSame batched decision endpoint as React, for closure grants.
revalidate'launch' (default), 'focus' (also on app foreground) or a number of seconds.
usePermission, usePermDock, ProtectedIdentical to permdock/react; status gains no new values, stale covers the persisted-but-revalidating case.
usePermissions, useFilter, useTenant, useMemberships, useRoles, useAssignableRoles, useApproval, useSubjectIdentical to permdock/react (UI). useTenant().switchTo persists the chosen tenant next to the snapshot so the app reopens in the same organisation; with tenants: 'all' in the persisted snapshot the switch works offline, otherwise it queues a refresh({ tenant }) for the next connection and reports status: 'pending'.

Request lifecycle

  1. Launch. The provider reads the persisted snapshot from storage and validates it. If valid, the store is ready and stale at once: guards evaluate against it synchronously.
  2. Revalidate. A fetch to snapshotUrl runs in the background. On success the snapshot is validated, written back to storage, and swapped in through the external store; consumers re-render only if an answer changed.
  3. Check. usePermission and Protected behave exactly as on the web: portable grants answer locally, closure grants go to endpoint in batches.
  4. Sign-out or account switch. The app calls permdock.clear(); the provider removes the persisted snapshot and drops every cached endpoint answer so no grant from the previous user survives on disk.

If storage is empty (first launch) the store starts in pending; guards receive false, which Expo Router treats as "not allowed" and redirects to the anchor route. Apps that need a splash screen until the first snapshot arrives read usePermDock().status.

Offline behaviour

A device with no connectivity keeps answering from the persisted snapshot. Portable grants (including ownership conditions such as authorId equals subject.id) work fully offline because the condition and the resource are both on the device. Closure grants cannot be answered offline: the hook reports server-only and allowed: false, and Protected renders fallback. Apps that need a specific permission offline should express it with a portable condition rather than a closure; permdock doctor lists closure grants that are referenced from React Native code.

Because the on-device answer is a UI hint, an API request made while offline and replayed later is still checked by the server with the current policy, so a stale local grant can never turn into a successful write.

What it validates

  • The persisted snapshot against the snapshot v1 schema before use. A snapshot written by an older app version that fails validation is discarded and refetched rather than trusted.
  • The snapshot's subject.id against the currently signed-in user id when the app provides one through subjectId; a mismatch is treated as empty storage.
  • Nothing else client-side. As on the web, on-device answers are UI hints; every mutation is re-checked by the API.

How denials surface

  • Guards receive false; Expo Router redirects to the nearest allowed route. Because the answer comes from storage there is no denied-then-allowed flash after launch.
  • Protected renders fallback, and the decision is available for an "approval required" screen.
  • Persisted grants are stale by design until revalidation finishes. A role downgrade takes effect on the next successful fetch; for immediate revocation the API should reject the mutation and the app can call refresh(), or a Shared Signals receiver on the server can rotate the snapshot.

Example app

apps/examples/expo: an Expo Router app with Stack.Protected for an admin group and Tabs.Protected for a billing tab, MMKV storage, a Hono API from apps/examples/hono serving the snapshot and decision endpoint, and a Maestro or Detox flow asserting that the admin route is available on the first frame after a cold start for an admin user and never renders for a member.

Open questions

  • Whether to encrypt the persisted snapshot by default (grants reveal role names and condition shapes, not data; with tenants: 'all' it also lists every organisation the user belongs to), or leave the choice to the storage implementation.
  • Whether a persisted multi-tenant snapshot should drop memberships whose expiresAt has passed at read time (current: yes, the evaluator ignores them anyway) and whether a stale persisted snapshot should keep serving another tenant's grants offline after a server-side removal (current: until revalidate runs; the threat model accepts this for UI hints because every mutation is re-checked server-side).
  • The right default for revalidate: launch only, or launch plus foreground.
  • Whether to ship an expo-secure-store and an MMKV adapter in the entry or document the three-method interface only.
  • How Tabs.Protected should behave while status is pending on a cold start with empty storage: hide the tab or show it disabled.

On this page