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:
- The interface every provider must implement.
- The four
MediaCapabilityvalues and what each maps to on each platform. - Skeleton implementations for Expo / React Native and Capacitor.
- 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
| Capability | Browser API | Expo / RN module | Capacitor plugin |
|---|---|---|---|
microphone | getUserMedia({ audio: true }) | expo-av Audio.requestPermissionsAsync | @capacitor/microphone |
camera | getUserMedia({ video: ... }) | expo-camera | @capacitor/camera |
screen | getDisplayMedia() | expo-screen-capture | community plugin (RN screen recording) |
geolocation | navigator.geolocation.getCurrentPosition | expo-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 typedChatSdkErrors.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(orGeolocationPosition) on grant; the manager hands it back to the caller and never retains a reference. Caller callsstream.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
PermissionManager— the orchestrator.PermissionProvider— the contract interface.captureFromDevice— wrapsPermissionManager.ensure()and feeds the captured stream into the upload pipeline.- Error codes —
PERMISSION_*— thrown byensure()and the capture helpers.
docs/reference/permission-providers.md