Permission providers

The headless chat SDK ships a cross-platform device-permission orchestrator (PermissionManager) plus a default BrowserPermissionProvider. Native runtimes (React Native, Expo, Capacitor, Electron) plug in their own implementation of PermissionProvider.

This document is the contract reference for those adapters. It covers:

  1. The interface every provider must implement.
  2. The four MediaCapability values and what each maps to on each platform.
  3. Skeleton implementations for Expo / React Native and Capacitor.
  4. Behavioral expectations the manager relies on.

Contract

import type { PermissionProvider, MediaCapability, PermissionRequest, PermissionResult, PermissionStatus } from 'gecx-chat';

export function createMyProvider(): PermissionProvider {
  return {
    id: 'my-platform',
    supports(capability: MediaCapability): boolean { /* sync feature detect */ },
    async query(capability: MediaCapability): Promise<PermissionStatus> { /* read without prompting */ },
    async request(req: PermissionRequest): Promise<PermissionResult> { /* trigger OS prompt */ },
    async revoke(capability: MediaCapability): Promise<PermissionStatus> { /* optional */ },
    subscribe(capability, listener) { /* optional */ },
  };
}

Capability mapping

CapabilityBrowser APIExpo / RN moduleCapacitor plugin
microphonegetUserMedia({ audio: true })expo-av Audio.requestPermissionsAsync@capacitor/microphone
cameragetUserMedia({ video: ... })expo-camera@capacitor/camera
screengetDisplayMedia()expo-screen-capturecommunity plugin (RN screen recording)
geolocationnavigator.geolocation.getCurrentPositionexpo-location@capacitor/geolocation

Behavioral expectations

The PermissionManager relies on these invariants:

  • request() MUST NOT throw. Wrap failures in { capability, status, reason }. The manager surfaces a throwing convenience (ensure()) that translates results into typed ChatSdkErrors.
  • supports() is synchronous and cheap. Feature detection only — never prompt.
  • query() does not prompt. When the platform has no read-without-prompt API for a capability, return 'unknown'.
  • revoke() returns 'unsupported' when the platform cannot programmatically revoke (every browser). The manager still emits an audit event capturing intent.
  • Captured streams transfer ownership. Return the MediaStream (or GeolocationPosition) on grant; the manager hands it back to the caller and never retains a reference. Caller calls stream.getTracks().forEach(t => t.stop()) when done.

The result reason codes (user_denied, system_blocked, hardware_unavailable, timeout, aborted, insecure_context, unsupported) are stable branching keys — keep them faithful to the underlying platform signal so host UI can render the right recovery affordance.

Skeleton — Expo / React Native

// packages/my-app/permissions/createExpoPermissionProvider.ts
import * as Audio from 'expo-av';
import { Camera } from 'expo-camera';
import * as Location from 'expo-location';
import * as ScreenCapture from 'expo-screen-capture';
import { Platform } from 'react-native';
import type {
  MediaCapability,
  PermissionProvider,
  PermissionRequest,
  PermissionResult,
  PermissionStatus,
} from 'gecx-chat';

const STATUS_MAP: Record<string, PermissionStatus> = {
  granted: 'granted',
  denied: 'denied',
  undetermined: 'prompt',
  blocked: 'blocked',
};

export function createExpoPermissionProvider(): PermissionProvider {
  return {
    id: 'rn-expo',
    supports(capability) {
      switch (capability) {
        case 'microphone': return true;
        case 'camera': return true;
        case 'geolocation': return true;
        case 'screen': return Platform.OS === 'android'; // Expo's coverage is partial
      }
    },
    async query(capability) {
      switch (capability) {
        case 'microphone': {
          const r = await Audio.getPermissionsAsync();
          return STATUS_MAP[r.status] ?? 'unknown';
        }
        case 'camera': {
          const r = await Camera.getCameraPermissionsAsync();
          return STATUS_MAP[r.status] ?? 'unknown';
        }
        case 'geolocation': {
          const r = await Location.getForegroundPermissionsAsync();
          return STATUS_MAP[r.status] ?? 'unknown';
        }
        case 'screen': return 'unknown';
      }
    },
    async request(req: PermissionRequest): Promise<PermissionResult> {
      switch (req.capability) {
        case 'microphone': {
          const r = await Audio.requestPermissionsAsync();
          return {
            capability: 'microphone',
            status: r.granted ? 'granted' : (STATUS_MAP[r.status] ?? 'denied'),
            reason: r.granted ? undefined : 'user_denied',
          };
        }
        case 'camera': {
          const r = await Camera.requestCameraPermissionsAsync();
          return {
            capability: 'camera',
            status: r.granted ? 'granted' : (STATUS_MAP[r.status] ?? 'denied'),
            reason: r.granted ? undefined : 'user_denied',
          };
        }
        case 'geolocation': {
          const r = await Location.requestForegroundPermissionsAsync();
          if (!r.granted) {
            return { capability: 'geolocation', status: 'denied', reason: 'user_denied' };
          }
          const pos = await Location.getCurrentPositionAsync();
          return {
            capability: 'geolocation',
            status: 'granted',
            position: pos as unknown as GeolocationPosition,
          };
        }
        case 'screen': {
          // expo-screen-capture is detection-only on iOS; Android needs the
          // MediaProjection bridge. Treat as unsupported on iOS.
          if (Platform.OS === 'ios') {
            return { capability: 'screen', status: 'unsupported', reason: 'unsupported' };
          }
          // Android: bridge to MediaProjection in a custom native module.
          return { capability: 'screen', status: 'unsupported', reason: 'unsupported' };
        }
      }
    },
  };
}

Wire it on ChatClient:

const client = createChatClient({
  // ...
  permissionProvider: createExpoPermissionProvider(),
});

Skeleton — Capacitor

// packages/my-app/permissions/createCapacitorPermissionProvider.ts
import { Camera } from '@capacitor/camera';
import { Geolocation } from '@capacitor/geolocation';
import type {
  MediaCapability,
  PermissionProvider,
  PermissionRequest,
  PermissionResult,
  PermissionStatus,
} from 'gecx-chat';

const STATUS_MAP: Record<string, PermissionStatus> = {
  granted: 'granted',
  denied: 'denied',
  prompt: 'prompt',
  'prompt-with-rationale': 'prompt',
  limited: 'granted',
};

export function createCapacitorPermissionProvider(): PermissionProvider {
  return {
    id: 'capacitor',
    supports(capability) {
      return capability === 'camera' || capability === 'geolocation' || capability === 'microphone';
    },
    async query(capability) {
      switch (capability) {
        case 'camera': {
          const r = await Camera.checkPermissions();
          return STATUS_MAP[r.camera] ?? 'unknown';
        }
        case 'microphone': {
          const r = await Camera.checkPermissions();
          // Capacitor's @capacitor/camera covers both; for mic only, use a
          // dedicated microphone plugin.
          return STATUS_MAP[r.photos] ?? 'unknown';
        }
        case 'geolocation': {
          const r = await Geolocation.checkPermissions();
          return STATUS_MAP[r.location] ?? 'unknown';
        }
        default: return 'unsupported';
      }
    },
    async request(req: PermissionRequest): Promise<PermissionResult> {
      switch (req.capability) {
        case 'camera': {
          const r = await Camera.requestPermissions({ permissions: ['camera'] });
          return { capability: 'camera', status: STATUS_MAP[r.camera] ?? 'denied' };
        }
        case 'microphone': {
          // Use @capacitor-community/microphone (or a custom plugin) here.
          return { capability: 'microphone', status: 'unsupported', reason: 'unsupported' };
        }
        case 'geolocation': {
          const r = await Geolocation.requestPermissions({ permissions: ['location'] });
          if (STATUS_MAP[r.location] !== 'granted') {
            return { capability: 'geolocation', status: 'denied', reason: 'user_denied' };
          }
          const pos = await Geolocation.getCurrentPosition();
          return {
            capability: 'geolocation',
            status: 'granted',
            position: pos as unknown as GeolocationPosition,
          };
        }
        default: return { capability: req.capability, status: 'unsupported', reason: 'unsupported' };
      }
    },
  };
}

Testing your adapter

Use createMockPermissionProvider for unit tests; substitute your adapter only in environment-level smoke tests where the OS prompt is actually surfaced. The mock supports per-capability initial, onRequest, and reason overrides — see packages/gecx-chat/src/permissions/providers/mockPermissionProvider.ts.

import { createMockPermissionProvider, PermissionManager } from 'gecx-chat';

const provider = createMockPermissionProvider({
  behaviors: {
    camera: { onRequest: 'denied', reason: 'user_denied' },
  },
});
const manager = new PermissionManager({ provider });

See also

Source: docs/reference/permission-providers.md