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.

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.

Why a persisted snapshot

The guard has no promise, loading state or pending value: when it flips to false the protected screens stop existing and Expo Router moves the user off them. A library that answers usePermission() by calling a server or waiting for a session can only drive it with a splash screen on every cold start or a guard that briefly lies. A snapshot (roles, grants and portable conditions as JSON) persisted on the device answers every guard on the first frame, and because it carries the where conditions, ownership checks such as usePermission(permissions.post.update, post) also work offline without a second copy of the policy in the app. That rules out hooks that suspend or return undefined, boolean-only client state, and a splash screen on every launch; the splash is needed once per install, before the first snapshot arrives.

Stack.Protected and Tabs.Protected decide which screens exist; Protected decides which sections of a screen render, with pending and fallback render props. Both read the same snapshot.

Concernpermdock/next (web)permdock/react-native (Expo)
First-frame answerSnapshot in the prefetched App ShellSnapshot in device storage
What makes it available before render'use cache: private' with cacheLifeA synchronous storage.getItem() on launch
Refresh triggerupdateTag after a mutationFetch on launch, on foreground, or on refresh()
Revocation pathSSF receiver invalidates the tagThe revalidate interval; no push without an app channel
Closure grantsBatched call to permdockHandler()Same endpoint; server-only while offline

Everything above the transport is shared: the snapshot format, the condition evaluator, usePermission's return shape and the decision endpoint.

Set up an Expo app

These steps build a native Expo Router app with tabs that a role hides, a snapshot kept in the device keychain, and a refresh when the app returns to the foreground. apps/examples/expo runs them.

1. Store the snapshot

expo-secure-store has synchronous getItem and setItem, so guards answer on the first frame. Its keys may contain only letters, digits, ., - and _; PermDock writes permdock.snapshot and permdock.tenant. It has no web implementation, so a universal app picks another store on web:

src/storage.ts
import * as SecureStore from "expo-secure-store";
import { memoryStorage, type PermDockStorage } from "permdock/react-native";
import { Platform } from "react-native";

export const storage: PermDockStorage =
  Platform.OS === "web"
    ? memoryStorage()
    : {
        getItem: (key) => SecureStore.getItem(key),
        setItem: (key, value) => {
          SecureStore.setItem(key, value);
        },
        removeItem: async (key) => {
          await SecureStore.deleteItemAsync(key);
        },
      };

MMKV is the faster choice when the snapshot need not be encrypted at rest; what it validates lists what a snapshot reveals.

2. Guard the tabs

Tabs.Protected takes a boolean, so the hook runs in a component inside the provider:

src/app/_layout.tsx
import { Tabs } from "expo-router";
import { AppState } from "react-native";
import { PermDockProvider, usePermission } from "permdock/react-native";

const onForeground = (listener: () => void) => {
  const subscription = AppState.addEventListener("change", (state) => {
    if (state === "active") listener();
  });
  return () => subscription.remove();
};

function AppTabs() {
  const billing = usePermission(permissions.billing.manage);
  return (
    <Tabs>
      <Tabs.Screen name="index" />
      <Tabs.Protected guard={billing.allowed}>
        <Tabs.Screen name="billing" />
      </Tabs.Protected>
    </Tabs>
  );
}

export default function Layout() {
  return (
    <PermDockProvider
      storage={storage}
      snapshotUrl="https://api.example.com/permdock/snapshot"
      headers={{ authorization: `Bearer ${accessToken}` }}
      revalidate="focus"
      subscribeForeground={onForeground}
    >
      <AppTabs />
    </PermDockProvider>
  );
}

Define storage and onForeground outside the component: the provider rebuilds its store when either identity changes. Inline headers, fetch and verifier do not: headers compare by value, and a new token in them rebuilds the store. When the guard turns false, the tab stops existing and Expo Router moves the user to the first allowed route.

3. Refresh on foreground

revalidate: 'focus' with subscribeForeground fetches snapshotUrl on launch and whenever AppState reports active, so a role removed while the app was in the background applies when the user returns. It refreshes only snapshotUrl. A local snapshot re-reads its rows when its own subscribe fires; add the same AppState listener there.

4. Clear on sign-out

Call usePermDock().clear() before switching user. It removes permdock.snapshot from the keychain and drops every cached decision, so nothing from the previous user is read on the next launch. Pass subjectId as well: a persisted snapshot for another user is then treated as empty storage.

5. Plan for offline

Portable grants, including ownership conditions, answer offline from the stored snapshot. Closure grants report server-only and allowed: false until the device is back online (offline behaviour). A guard that must work offline needs a portable grant.

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). A synchronous store answers on the first render. An asynchronous store reports status: 'pending', with guards false, until its read resolves.
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.
sourceA SnapshotSource such as localSnapshot({ manifest, read, subscribe }): the snapshot is built from the device's own rows (Local snapshot).
endpointSame batched decision endpoint as React, for closure grants.
revalidate'launch' (default), 'focus' (also on app foreground when subscribeForeground is set) or a number of seconds.
subscribeForegroundOptional (listener) => unsubscribe for revalidate: 'focus'. Pass AppState.addEventListener('change', ...) from react-native so the snapshot refreshes when the app returns to the foreground.
usePermission, usePermDock, ProtectedIdentical to permdock/react; status gains no new values, stale covers the persisted-but-revalidating case.
usePermissions, useFilter, useTenant, useMemberships, useRoles, useAssignableRoles, useAssignablePermissions, useApproval, useSubjectIdentical to permdock/react (UI). useTenant().switchTo persists the chosen tenant next to the snapshot so the app reopens in the same organisation; a tenant already in the persisted snapshot (tenants: 'all') switches locally and works offline; any other tenant refetches the snapshot with ?tenant=, and a failed refetch (offline, non-2xx, invalid body) keeps the previous tenant and snapshot. Nothing is queued: call switchTo again when the connection returns.

Request lifecycle

  1. Launch. The provider reads the persisted snapshot from storage and validates it, and drops it when its principal is not subjectId. A valid snapshot answers at once. With snapshotUrl or source its status is stale until the first revalidation lands, and ready without either.
  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. A source read whose content did not change is dropped, so it re-renders nothing; a failed read keeps the current snapshot.
  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. subjectId becomes null or another id, or the app calls permdock.clear(). The provider removes the persisted snapshot and tenant and drops every cached endpoint answer, so no grant from the previous user survives on disk.

If storage holds no snapshot for subjectId (first launch), guards receive false, which Expo Router treats as "not allowed" and redirects to the anchor route. The status is pending while an asynchronous read or the first fetch runs. With neither snapshotUrl nor source and nothing stored, it settles on server-only. 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 principal.id, and sqlFunction grants whose twin is portable) 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 those referenced from React Native code (PD065).

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.

Local snapshot

An app that syncs its membership and custom-role rows to the device (SQLite, a PowerSync or Electric replica) can build the snapshot from those rows instead of fetching it. The policy stays on the server: localSnapshotManifest(policy) from permdock writes, at build time, the JSON that localSnapshot reads on the device.

scripts/permdock-manifest.ts
import { writeFileSync } from "node:fs";
import { localSnapshotManifest } from "permdock";
import { policy } from "../src/policy";

writeFileSync(
  "app/permdock-manifest.json",
  JSON.stringify(localSnapshotManifest(policy)),
);
import {
  localSnapshot,
  PermDockProvider,
  type LocalSnapshotManifest,
} from "permdock/react-native";
import manifest from "./permdock-manifest.json";

// readMemberships, readCustomRoles and watchTables are the app's own queries over its local database.
const source = localSnapshot({
  manifest: manifest as LocalSnapshotManifest, // a JSON import widens `v: 1` to number
  read: async () => ({
    principal: {
      id: userId,
      tenant: organizationId,
      memberships: await readMemberships(userId), // [{ tenant: 'acme', roles: ['admin'] }]
      attributes: { claims: { team_ids: teamIds } },
    },
    customRoles: await readCustomRoles(organizationId),
  }),
  subscribe: (listener) =>
    watchTables(["organization_users", "roles"], listener),
});

<PermDockProvider storage={secureStorage} source={source}>
  {children}
</PermDockProvider>;

read() returns what a MembershipSource and a RoleSource would on the server: the memberships, global roles, plans and custom roles of the signed-in user. attributes carries the principal values conditions read, such as principal.claims.team_ids. On every subscribe call the provider rebuilds the snapshot and persists it to storage. A failed read() keeps the current snapshot.

The local snapshot decides like the server snapshot for the same rows, with these limits:

  • A grant on a relation grantee (relation(job, 'watcher')) is server-only on the device: the relation graph is not in the manifest.
  • A custom role resolves through tables the server computed per permission, level and included role. An entry the manifest does not know, such as a permission added after the build, grants nothing until the manifest is rebuilt.
  • useAssignableRoles and useAssignablePermissions return nothing; the role editor needs the server snapshot.

With snapshotUrl set as well, the latest answer wins: the server snapshot after a revalidation, the local one after the next row change.

PowerSync source

With PowerSync, permdock powersync generate writes the manifest to powersync.manifest and adds permdock_* streams for the rows read needs (PowerSync). powersyncSource(db, { manifest, queries, read }) replaces the hand-written read and subscribe: get runs every query with db.getAll, and one db.watch per query reports changes. permdock/react-native never imports @powersync/*; the app passes its PowerSyncDatabase.

import {
  powersyncSource,
  type LocalSnapshotManifest,
} from "permdock/react-native";
import manifest from "./permdock-manifest.json";

const source = powersyncSource(db, {
  manifest: manifest as LocalSnapshotManifest,
  queries: {
    memberships: {
      sql: "SELECT organization_id, tier FROM organization_users WHERE user_id = ?",
      parameters: [userId],
    },
  },
  read: ({ memberships }) => ({
    principal: {
      id: userId,
      tenant: organizationId,
      memberships: memberships.map((row) => ({
        tenant: row.organization_id,
        roles: [row.tier],
      })),
    },
  }),
});

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 principal id against subjectId, which the provider requires (null when signed out). A mismatch clears the stored snapshot and tenant, and 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.

The snapshot is stored as the storage implementation stores it. The entry ships only memoryStorage and the three-method interface; an app that wants the snapshot encrypted at rest (it reveals role names, condition shapes and, with tenants: 'all', every organisation the user belongs to, but no rows) passes expo-secure-store or an encrypted MMKV instance. Memberships whose expiresAt has passed are ignored by the evaluator at read time. A persisted snapshot keeps serving grants that the server has since removed until the next revalidation; the threat model accepts this for UI hints because every mutation is re-checked server-side.

How denials surface

  • Guards receive false, including while status is pending on a cold start with empty storage, so a Tabs.Protected tab is hidden until the first snapshot arrives; 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 expo-secure-store storage (memoryStorage on web), a localSnapshot over in-memory rows that stand in for a sync engine, an AppState foreground listener, Tabs.Protected and Protected. pnpm gen writes src/permdock-manifest.json from the policy; the app bundle never imports the policy. http://127.0.0.1:3486/ shows edit, locked for publish and one tab; "Sync admin role" changes the row, and publish and the Publish tab appear with no request.

Last updated on

On this page