diff --git a/formulus-formplayer/docs/custom-question-types-architecture.md b/formulus-formplayer/docs/custom-question-types-architecture.md new file mode 100644 index 000000000..1fb24a8fd --- /dev/null +++ b/formulus-formplayer/docs/custom-question-types-architecture.md @@ -0,0 +1,602 @@ +# Custom Question Types — Architecture & Flow + +--- + +## File Structure + +``` +formulus-formplayer/src/ (FORMPLAYER — runs in WebView) +├── types/ +│ ├── CustomQuestionTypeContract.ts ← 1. The contract authors code against +│ └── FormulusInterfaceDefinition.ts ← 2. FormInitData (carries the manifest) +│ +├── services/ +│ ├── CustomQuestionTypeLoader.ts ← 3. Sandboxed evaluation of source strings +│ └── CustomQuestionTypeRegistry.ts ← 4. Auto-generates testers + renderer entries +│ +├── renderers/ +│ └── CustomQuestionTypeAdapter.tsx ← 5. Bridges ControlProps → CustomQuestionTypeProps +│ +└── App.tsx ← 6. Orchestrates everything + +formulus/src/ (FORMULUS — runs in React Native) +├── services/ +│ └── CustomQuestionTypeScanner.ts ← Reads files, screens against blocklist +│ +└── components/ + └── FormplayerModal.tsx ← Calls scanner, passes source in FormInitData +``` + +### Author's Side (custom_app) + +``` +custom_app/ +└── question_types/ + ├── ranking/ + │ └── renderer.js ← default export: React component + ├── select-person/ + │ └── renderer.js + └── test-simple/ + └── renderer.js +``` + +**Note:** The folder name becomes the format string. For example: +- `question_types/ranking/renderer.js` → `format: "ranking"` +- `question_types/select-person/renderer.js` → `format: "select-person"` +- `question_types/test-simple/renderer.js` → `format: "test-simple"` + +**Important:** The scanner specifically looks for `renderer.js` (not `index.js`). The file must be named `renderer.js` in each question type directory. + +--- + +## Security Model — Source Extraction + +Custom question type JS files could contain malicious code. Instead of letting the WebView +`import()` arbitrary scripts (which would give them full access to fetch, DOM, localStorage, etc.), +we use a **source extraction** approach with two layers of defense: + +| Layer | Where | What it does | +|-------|-------|-------------| +| **Static blocklist** | RN side (`CustomQuestionTypeScanner`) | Rejects code containing dangerous patterns before it reaches the WebView | +| **Scoped evaluation** | WebView (`CustomQuestionTypeLoader`) | `new Function()` sandbox — code can only access React and MUI, nothing else | + +### Blocked Patterns (RN-side screening) + +``` +fetch( — network requests +XMLHttpRequest — network requests +WebSocket — persistent connections +eval( — dynamic code execution +new Function( — dynamic code execution +document.cookie — cookie access +localStorage — storage access +sessionStorage — storage access +indexedDB — database access +navigator.sendBeacon — data exfiltration +importScripts( — script injection +``` + +### Scoped Sandbox (WebView-side evaluation) + +```javascript +// Instead of: import("file:///path/to/index.js") +// We do: +const factory = new Function( + 'module', 'exports', 'React', 'MaterialUI', + sourceString // ← sent from RN as a string, not a file path +); + +// React and MaterialUI are accessed from global scope +const ReactLib = window.React || globalThis.React || self.React; +const MUILib = window.MaterialUI || globalThis.MaterialUI || self.MaterialUI; + +factory(moduleObj, exports, ReactLib, MUILib); + +// Custom code CAN access: React, MaterialUI, module, exports +// Custom code CANNOT access: fetch, document, localStorage, window, etc. +``` + +**Important Implementation Details:** +- React and MaterialUI are injected into the global scope by the WebView before custom question types are loaded +- The sandbox factory function receives these as explicit parameters, ensuring they're the only globals accessible +- If React or MaterialUI are not found in the global scope, loading fails with a clear error message +- The code supports both CommonJS patterns: `module.exports = Component` and `module.exports.default = Component` + +--- + +## How Module Loading Works + +### 1. Device Storage + +When the custom_app archive is unzipped, files land on the device filesystem: + +``` +/data/.../Documents/app/ +├── forms/ +│ ├── hh_hut/schema.json +│ ├── hh_person/schema.json +│ ├── p_focal/schema.json +│ └── ... +└── question_types/ + ├── ranking/renderer.js ← pairwise Elo ranking UI + ├── select-person/renderer.js ← person selection with search + └── test-simple/renderer.js ← simple test renderer +``` + +### 2. Formulus RN Scans, Reads & Screens + +`CustomQuestionTypeScanner.ts` scans `question_types/`, reads each `renderer.js` as a raw string, +and screens it against the blocklist: + +```typescript +// In CustomQuestionTypeScanner.ts +const questionTypesDir = `${customAppPath}/question_types`; +const folders = await RNFS.readDir(questionTypesDir); + +for (const folder of folders) { + if (folder.isDirectory()) { + const source = await RNFS.readFile(`${folder.path}/renderer.js`, 'utf8'); + + // Screen against blocklist + const violation = screenSource(source); + if (violation) { + errors.push({ name: folder.name, error: `Blocked: ${violation}` }); + continue; + } + + // Source is clean — include it + custom_types[folder.name] = { source }; + } +} +``` + +**Sample manifest** (source strings, not file paths): + +```json +{ + "custom_types": { + "ranking": { + "source": "(function() { 'use strict'; ... module.exports = { default: RankingRenderer }; })()" + }, + "select-person": { + "source": "(function() { 'use strict'; ... module.exports = { default: SelectPersonRenderer }; })()" + }, + "test-simple": { + "source": "(function() { 'use strict'; ... module.exports = { default: TestSimpleRenderer }; })()" + } + } +} +``` + +**Note:** The format name matches the folder name in `question_types/`. The scanner reads the file content and includes it as a source string in the manifest. + +### 3. FormInitData Carries the Source Strings + +In `FormplayerModal.tsx`, `initializeForm()` calls the scanner and includes the result: + +```typescript +const customAppPath = RNFS.DocumentDirectoryPath + '/app'; + +// Scan and screen custom question types +const scanResult = await scanCustomQuestionTypes(customAppPath); +if (scanResult.errors.length > 0) { + console.warn('Some custom question types failed screening:', scanResult.errors); +} + +const formInitData = { + formType: formType.id, + observationId, + params: formParams, + savedData: existingObservationData || {}, + formSchema: formType.schema, + uiSchema: formType.uiSchema ?? {}, + extensions, + customQuestionTypes: { + custom_types: scanResult.custom_types, + }, +} as FormInitData; +``` + +### 4. WebView Receives & Evaluates in Sandbox + +`FormulusWebViewHandler.sendFormInit()` serializes the `FormInitData` and injects it into +the WebView. Then `CustomQuestionTypeLoader.ts` evaluates each source in a scoped sandbox: + +```typescript +// CustomQuestionTypeLoader.ts — evaluateModuleInSandbox() +const exports: Record = {}; +const moduleObj = { exports }; + +// Access React and MaterialUI from global scope +const ReactLib = window.React || globalThis.React || self.React; +const MUILib = window.MaterialUI || globalThis.MaterialUI || self.MaterialUI; + +if (!ReactLib) { + throw new Error('React is not available in the global scope'); +} + +// Create factory with restricted scope +const factory = new Function( + 'module', 'exports', 'React', 'MaterialUI', + sourceString +); + +factory(moduleObj, exports, ReactLib, MUILib); + +// Extract the component (supports both default and named exports) +const component = moduleObj.exports.default ?? moduleObj.exports; + +// Validate it's a function +if (typeof component !== 'function') { + throw new Error(`Module does not export a valid React component`); +} +``` + +**Error Handling:** +- Each module evaluation is wrapped in try-catch +- Failed modules are logged with detailed error messages +- Loading continues for other modules even if one fails +- Errors are collected and returned in the result for debugging + +### 5. Registry & Rendering + +`CustomQuestionTypeRegistry.ts` takes each loaded component and: +- Auto-generates a tester: `rankWith(6, schemaMatches(s => s.format === name))` + - Priority 6 is higher than default Material renderers (3-5) but lower than specialized built-ins (10+) +- Creates a renderer entry via `CustomQuestionTypeAdapter.tsx` +- Returns renderer entries and format strings for AJV registration + +**Renderer Creation:** +- Each custom question type is wrapped in `QuestionShell` for consistent styling +- An `ErrorBoundary` catches any crashes in custom components +- The adapter maps JSON Forms `ControlProps` to simplified `CustomQuestionTypeProps` +- Config is extracted from schema properties (excluding reserved ones) and merged with `x-config` + +**AJV Format Registration:** +- Format strings are registered with AJV to prevent validation errors for unknown formats +- Registration happens in `App.tsx` after loading: `ajv.addFormat(formatName, () => true)` + +--- + +## Flow Diagram + +``` +┌─────────────────────────────────────────────────────────────┐ +│ DEVICE STORAGE (after custom_app unzip) │ +│ │ +│ /Documents/app/question_types/ranking/renderer.js │ +│ /Documents/app/question_types/select-person/renderer.js │ +│ /Documents/app/question_types/test-simple/renderer.js │ +└────────────────────────┬────────────────────────────────────┘ + │ RNFS.readFile() → string + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ FORMULUS RN — CustomQuestionTypeScanner │ +│ │ +│ 1. Reads each index.js as a raw string │ +│ 2. Screens against blocklist (fetch, eval, etc.) │ +│ 3. Builds manifest with source strings: │ +│ { "ranking": { source: "..." } } │ +│ 4. Rejected modules → logged as warnings │ +└────────────────────────┬────────────────────────────────────┘ + │ FormInitData.customQuestionTypes + │ sendFormInit() → injectJavaScript() + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ FORMPLAYER WEBVIEW — App.tsx │ +│ │ +│ initializeForm() reads initData.customQuestionTypes │ +│ calls loadCustomQuestionTypes(manifest) │ +└────────────────────────┬────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ CustomQuestionTypeLoader.ts — SANDBOX │ +│ │ +│ For each entry in manifest.custom_types: │ +│ 1. Access React/MaterialUI from global scope │ +│ 2. new Function('module','exports','React','MaterialUI',│ +│ source) │ +│ 3. Execute factory(moduleObj, exports, React, MUI) │ +│ 4. Extract module.exports.default or module.exports │ +│ 5. Validate it's a function │ +│ 6. Collect errors if evaluation fails │ +│ ❌ No access to: fetch, document, localStorage, etc. │ +└────────────────────────┬────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ CustomQuestionTypeRegistry.ts │ +│ │ +│ For each loaded component: │ +│ Auto-generates a tester: │ +│ rankWith(6, schemaMatches(s => s.format === name)) │ +│ Creates renderer entry via adapter │ +│ Returns: { renderers[], formats[] } │ +└────────────────────────┬────────────────────────────────────┘ + │ + ┌──────────┴──────────┐ + ▼ ▼ +┌──────────────────────┐ ┌──────────────────────────────────┐ +│ AJV Registration │ │ JsonForms Renderers Array │ +│ │ │ │ +│ ajv.addFormat( │ │ [ │ +│ 'ranking', │ │ ...builtInRenderers, │ +│ 'select-person', │ │ ...customTypeRenderers, ← NEW │ +│ () => true │ │ ] │ +│ ) │ │ ] │ +│ │ │ │ +│ Prevents AJV from │ │ Testers run top-to-bottom, │ +│ rejecting unknown │ │ highest rank wins │ +│ format strings │ │ │ +└──────────────────────┘ └───────────────┬──────────────────┘ + │ at render time + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ CustomQuestionTypeAdapter.tsx │ +│ │ +│ JSON Forms ControlProps → CustomQuestionTypeProps │ +│ ───────────────────── ──────────────────────── │ +│ data value │ +│ handleChange(path, val) onChange(val) │ +│ errors (string/array) validation { error, msg } │ +│ schema (all props) config (merged with x-config) │ +│ enabled enabled │ +│ path fieldPath │ +│ label, description label, description │ +│ │ +│ Config extraction: │ +│ - All schema properties (except reserved) → config │ +│ - x-config properties override schema properties │ +│ - Reserved: type, title, description, format, enum, etc.│ +│ │ +│ Wraps in: QuestionShell + ErrorBoundary │ +│ - ErrorBoundary shows user-friendly error UI │ +│ - Form continues to function if component crashes │ +└────────────────────────┬────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Author's Component │ +│ │ +│ Receives only: { value, config, onChange, validation, ... }│ +│ No JSON Forms knowledge needed. │ +│ Crash-safe via ErrorBoundary. │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## Schema Examples (based on AnthroCollect forms) + +### Ranking Question + +Used for pairwise comparison ranking of people (similar to ODK-X OMO style): + +```json +{ + "ranking_field": { + "type": "array", + "format": "ranking", + "title": "Rank People", + "description": "Rank the people in order of preference", + "items": { + "type": "string" + }, + "people": [ + { + "id": "person1", + "name": "John Doe", + "age": 35, + "clan": "Alpha", + "sex": "male", + "photo_uriFragment": null + }, + { + "id": "person2", + "name": "Jane Smith", + "age": 28, + "clan": "Beta", + "sex": "female", + "photo_uriFragment": null + } + ], + "promptText": "Select the person you prefer" + } +} +``` + +**Key points:** +- `format: "ranking"` (no "x-" prefix needed) +- `people` array is passed directly in schema (becomes `config.people`) +- `promptText` is optional and becomes `config.promptText` +- Value stored is an array of person IDs in ranked order +- Uses Elo-style pairwise comparison algorithm + +### Select Person Question + +Used for selecting a person from a list with optional search: + +```json +{ + "select_person_field": { + "type": "string", + "format": "select-person", + "title": "Select Person", + "description": "Choose a person from the list", + "showSearch": true, + "showPhoto": false, + "people": [ + { + "id": "person1", + "name": "John Doe", + "age": 35, + "clan": "Alpha", + "sex": "male" + }, + { + "id": "person2", + "name": "Jane Smith", + "age": 28, + "clan": "Beta", + "sex": "female" + } + ] + } +} +``` + +**Key points:** +- `format: "select-person"` (no "x-" prefix needed) +- `people` array is passed directly in schema (becomes `config.people`) +- `showSearch` (default: true) enables searchable autocomplete +- `showPhoto` (default: false) shows person photos if available +- Value stored is the selected person's ID (string) + +### Simple Test Question + +Minimal example for testing the custom question type system: + +```json +{ + "test_custom_field": { + "type": "string", + "format": "test-simple", + "title": "Test Custom Question Type", + "description": "This field uses the test-simple custom question type renderer", + "placeholder": "Enter test value here...", + "maxLength": 50 + } +} +``` + +**Key points:** +- `format: "test-simple"` (no "x-" prefix needed) +- `placeholder` is passed directly in schema (becomes `config.placeholder`) +- Standard JSON Schema validation (`maxLength`) still applies + +### Using x-config (Alternative) + +You can also use `x-config` for explicit configuration that overrides schema properties: + +```json +{ + "custom_field": { + "type": "string", + "format": "my-custom-type", + "title": "My Field", + "maxLength": 100, + "x-config": { + "customParam": "value", + "maxLength": 200 + } + } +} +``` + +In this case, `config.maxLength` will be `200` (from `x-config`), not `100` (from schema property). + +**What happens for each:** + +1. `format: "ranking"` → tester matches → the ranking renderer is used +2. Schema properties (except reserved ones) → passed as `props.config` to the author's component +3. `x-config` properties → override schema properties in config +4. Standard JSON Schema keywords (`type`, `maxLength`, etc.) → validated by AJV as normal +5. AJV doesn't reject the custom format strings because we registered them + +--- + +## Implementation Details + +### Security Model Implementation + +**Source Extraction Flow:** +1. RN side (`CustomQuestionTypeScanner`) reads JS files as raw strings +2. Static blocklist screening rejects dangerous patterns (fetch, eval, localStorage, etc.) +3. Clean source strings are passed in `FormInitData.customQuestionTypes` +4. WebView side (`CustomQuestionTypeLoader`) evaluates source in scoped sandbox +5. Only React, MaterialUI, module, and exports are accessible to custom code + +**Global Scope Access:** +- React and MaterialUI must be available in the WebView's global scope before loading +- The loader checks `window`, `globalThis`, and `self` for these libraries +- If not found, loading fails with a clear error message +- This ensures custom code can use React hooks and Material UI components + +**Error Handling:** +- Each module evaluation is wrapped in try-catch +- Failed modules are logged but don't stop other modules from loading +- Errors are collected and returned: `{ format: string, error: string }[]` +- The registry only processes successfully loaded components + +### Module Export Patterns + +Custom question type modules can export components in multiple ways: +```javascript +// Pattern 1: Object with default property (recommended) +module.exports = { + default: function MyComponent(props) { ... } +}; + +// Pattern 2: Direct default export +module.exports.default = function MyComponent(props) { ... }; + +// Pattern 3: Direct export (also supported) +module.exports = function MyComponent(props) { ... }; + +// Pattern 4: Named export (also supported) +module.exports.MyComponent = function MyComponent(props) { ... }; +``` + +The loader checks `module.exports.default` first, then falls back to `module.exports`. The recommended pattern is Pattern 1 (object with default property) as used in the AnthroCollect examples. + +### Config Extraction + +The adapter extracts configuration from the schema: +- All schema properties except reserved ones become `config` +- Reserved properties: `type`, `title`, `description`, `format`, `enum`, `const`, `default`, `required`, `properties`, `items`, `oneOf`, `anyOf`, `allOf`, `$ref`, `$schema`, validation keywords, etc. +- Properties from `x-config` override schema properties +- This allows passing parameters like `maxStars: 5` directly in the schema + +### Tester Priority + +Custom question type testers use priority 6: +- Higher than default Material renderers (priority 3-5) +- Lower than specialized built-in question types (priority 10+) +- Ensures custom types are selected when format matches, but built-ins take precedence for their specific formats + +## Implementation Plan (completed) + +All changes below have been implemented. + +### Formulus RN Side + +| File | Change | +|------|--------| +| `FormulusInterfaceDefinition.ts` | `modulePath` → `source` in `FormInitData.customQuestionTypes` | +| `CustomQuestionTypeScanner.ts` | **NEW** — scans, reads, screens question type modules | +| `FormplayerModal.tsx` | Calls scanner, passes source strings in `FormInitData` | + +### FormPlayer WebView Side + +| File | Change | +|------|--------| +| `FormulusInterfaceDefinition.ts` | `modulePath` → `source` (mirror) | +| `CustomQuestionTypeContract.ts` | `modulePath` → `source` in `CustomQuestionTypeManifest` | +| `CustomQuestionTypeLoader.ts` | Rewritten: `import()` → `new Function()` sandbox with React/MUI from global scope | +| `CustomQuestionTypeRegistry.ts` | Auto-generates testers with priority 6, creates renderer entries | +| `CustomQuestionTypeAdapter.tsx` | Maps ControlProps → CustomQuestionTypeProps, wraps in ErrorBoundary | +| `App.tsx` | Orchestrates loading, registers formats with AJV, merges with built-in renderers | + +### Key Files — Full Reference + +| File | Role | Key Export | +|------|------|-----------| +| `CustomQuestionTypeScanner.ts` (RN) | Reads & screens modules | `scanCustomQuestionTypes()` | +| `CustomQuestionTypeContract.ts` | Defines what authors receive | `CustomQuestionTypeProps` | +| `CustomQuestionTypeLoader.ts` | Sandboxed evaluation | `loadCustomQuestionTypes()` | +| `CustomQuestionTypeRegistry.ts` | Creates JsonForms entries | `registerCustomQuestionTypes()` | +| `CustomQuestionTypeAdapter.tsx` | Props bridge + error isolation | `createCustomQuestionTypeRenderer()` | +| `FormulusInterfaceDefinition.ts` | Carries source from RN → WebView | `FormInitData` | +| `App.tsx` | Orchestrates load → register → render | `initializeForm()` | +| `FormplayerModal.tsx` (RN) | Builds FormInitData, sends to WebView | `initializeForm()` | diff --git a/formulus-formplayer/index.html b/formulus-formplayer/index.html index 7d2bafd72..00b3b1095 100644 --- a/formulus-formplayer/index.html +++ b/formulus-formplayer/index.html @@ -10,6 +10,7 @@ Formulus Form Player + diff --git a/formulus-formplayer/src/App.tsx b/formulus-formplayer/src/App.tsx index c055a748f..bc942f5db 100644 --- a/formulus-formplayer/src/App.tsx +++ b/formulus-formplayer/src/App.tsx @@ -28,6 +28,7 @@ import { tokens } from './theme/tokens-adapter'; import Ajv from 'ajv'; import addErrors from 'ajv-errors'; import addFormats from 'ajv-formats'; +import * as MUI from '@mui/material'; // Import the FormulusInterface client import FormulusClient from './services/FormulusInterface'; @@ -75,6 +76,7 @@ import DraftSelector from './components/DraftSelector'; import { loadExtensions } from './services/ExtensionsLoader'; import { getBuiltinExtensions } from './builtinExtensions'; import { FormEvaluationProvider } from './FormEvaluationContext'; +import { loadCustomQuestionTypes } from './services/CustomQuestionTypeLoader'; // Import development dependencies (Vite will tree-shake these in production) import { webViewMock } from './mocks/webview-mock'; @@ -229,6 +231,16 @@ export const customRenderers = [ numberStepperRenderer, ]; +// Expose React and MaterialUI to global scope for custom question type renderers +// This must be done synchronously at module load time so renderers can access them +if (typeof window !== 'undefined') { + (window as any).React = React; + (window as any).MaterialUI = MUI; + console.log( + '[App] Exposed React and MaterialUI to global scope for custom renderers', + ); +} + function App() { // Initialize WebView mock ONLY in development mode and ONLY if ReactNativeWebView doesn't exist if ( @@ -281,6 +293,11 @@ function App() { const [extensionDefinitions, setExtensionDefinitions] = useState< Record >({}); + // Custom question type renderers (loaded from custom_app) + const [customTypeRenderers, setCustomTypeRenderers] = useState< + JsonFormsRendererRegistryEntry[] + >([]); + const [customTypeFormats, setCustomTypeFormats] = useState([]); // Reference to the FormulusClient instance and loading state const formulusClient = useRef(FormulusClient.getInstance()); @@ -341,7 +358,10 @@ function App() { } // Start with built-in extensions (always available) - const allFunctions = getBuiltinExtensions(); + const allFunctions = getBuiltinExtensions() as Map< + string, + (...args: any[]) => any + >; // Load extensions if provided if (extensions) { @@ -381,6 +401,36 @@ function App() { console.log('[Formplayer] Using only built-in extensions'); } + // Load custom question types if provided + const customQTManifest = initData.customQuestionTypes; + if (customQTManifest) { + try { + const customQTResult = + await loadCustomQuestionTypes(customQTManifest); + setCustomTypeRenderers(customQTResult.renderers); + setCustomTypeFormats(customQTResult.formats); + console.log( + `[Formplayer] Loaded ${customQTResult.renderers.length} custom question type(s): ${customQTResult.formats.join(', ')}`, + ); + if (customQTResult.errors.length > 0) { + console.warn( + '[Formplayer] Custom question type loading errors:', + customQTResult.errors, + ); + } + } catch (error) { + console.error( + '[Formplayer] Failed to load custom question types:', + error, + ); + setCustomTypeRenderers([]); + setCustomTypeFormats([]); + } + } else { + setCustomTypeRenderers([]); + setCustomTypeFormats([]); + } + if (!formSchema) { console.warn( 'formSchema was not provided. Form rendering might fail or be incomplete.', @@ -800,6 +850,19 @@ function App() { return typeof data === 'string' && dateRegex.test(data); }); + // Register custom question type formats with AJV + // Custom question types use "format": "formatName" in schemas (not "type") + // This is required because JSON Schema only allows standard types in the "type" field + if (customTypeFormats.length > 0) { + customTypeFormats.forEach(formatName => { + // Register as format so AJV accepts "format": "formatName" in schemas + instance.addFormat(formatName, () => true); + }); + console.log( + `[Formplayer] Registered ${customTypeFormats.length} custom question type format(s) with AJV`, + ); + } + // Add extension definitions to AJV for $ref support if (Object.keys(extensionDefinitions).length > 0) { // Add each definition individually so $ref can reference them @@ -809,7 +872,7 @@ function App() { } return instance; - }, [extensionDefinitions]); + }, [extensionDefinitions, customTypeFormats]); // Create dynamic theme based on dark mode preference and custom app colors. // When a custom app provides themeColors, they override the default palette @@ -987,6 +1050,7 @@ function App() { ...shellMaterialRenderers, ...materialRenderers, ...customRenderers, + ...customTypeRenderers, // Custom question types from custom_app ...extensionRenderers, // Extension renderers (highest priority) ]} cells={materialCells} diff --git a/formulus-formplayer/src/index.tsx b/formulus-formplayer/src/index.tsx index 30d12270c..f64075fec 100644 --- a/formulus-formplayer/src/index.tsx +++ b/formulus-formplayer/src/index.tsx @@ -1,7 +1,19 @@ +import React from 'react'; import ReactDOM from 'react-dom/client'; +import * as MUI from '@mui/material'; import './index.css'; import App from './App'; +// Expose React and MaterialUI to global scope for custom question type renderers +// This MUST happen at the entry point before any other code runs +if (typeof window !== 'undefined') { + (window as any).React = React; + (window as any).MaterialUI = MUI; + console.log( + '[index] Exposed React and MaterialUI to global scope for custom renderers', + ); +} + const root = ReactDOM.createRoot( document.getElementById('root') as HTMLElement, ); diff --git a/formulus-formplayer/src/renderers/CustomQuestionTypeAdapter.tsx b/formulus-formplayer/src/renderers/CustomQuestionTypeAdapter.tsx new file mode 100644 index 000000000..898c04576 --- /dev/null +++ b/formulus-formplayer/src/renderers/CustomQuestionTypeAdapter.tsx @@ -0,0 +1,213 @@ +/** + * CustomQuestionTypeAdapter.tsx + * + * Bridges JSON Forms ControlProps → CustomQuestionTypeProps. + * Wraps every custom question type in QuestionShell + ErrorBoundary + * so that form authors get consistent styling and crash isolation. + */ + +import React, { Component, type ErrorInfo, type ReactNode } from 'react'; +import { withJsonFormsControlProps } from '@jsonforms/react'; +import type { ControlProps } from '@jsonforms/core'; +import QuestionShell from '../components/QuestionShell'; +import type { CustomQuestionTypeProps } from '../types/CustomQuestionTypeContract'; + +// --------------------------------------------------------------------------- +// Error Boundary — catches crashes in custom components +// --------------------------------------------------------------------------- + +interface ErrorBoundaryProps { + formatName: string; + children: ReactNode; +} + +interface ErrorBoundaryState { + hasError: boolean; + error: Error | null; +} + +class CustomQuestionErrorBoundary extends Component< + ErrorBoundaryProps, + ErrorBoundaryState +> { + state: ErrorBoundaryState = { hasError: false, error: null }; + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, info: ErrorInfo): void { + console.error( + `[CustomQuestionType] "${this.props.formatName}" crashed:`, + error, + info.componentStack, + ); + } + + render() { + if (this.state.hasError) { + return ( +
+ + ⚠️ Custom Question Type Error + +
+ The custom question type "{this.props.formatName}"{' '} + encountered an error and could not be rendered. +
+
+ + Error Details (click to expand) + +
+              {this.state.error?.message || 'Unknown error'}
+              {this.state.error?.stack && (
+                <>
+                  {'\n\n'}
+                  {this.state.error.stack}
+                
+              )}
+            
+
+
+ The form will continue to function, but this field cannot be edited. +
+
+ ); + } + return this.props.children; + } +} + +// --------------------------------------------------------------------------- +// Adapter — maps ControlProps → CustomQuestionTypeProps +// --------------------------------------------------------------------------- + +/** + * Creates a JSON Forms renderer component for a given custom question type. + * + * @param formatName - The format string (e.g., "x-rating-stars") + * @param CustomComponent - The author's React component + */ +export function createCustomQuestionTypeRenderer( + formatName: string, + CustomComponent: React.ComponentType, +): React.ComponentType { + const AdapterInner: React.FC = ({ + data, + handleChange, + path, + schema, + errors, + enabled, + label, + description, + required, + }) => { + // Build the simplified props for the custom component + const hasErrors = + errors && (Array.isArray(errors) ? errors.length > 0 : true); + const errorMessage = hasErrors + ? Array.isArray(errors) + ? errors.map((e: any) => e.message || String(e)).join(', ') + : String(errors) + : ''; + + // Extract all schema properties (except reserved ones) as config + // This allows parameters alongside "format" to be passed to the renderer + const schemaObj = schema as Record; + const RESERVED_PROPERTIES = new Set([ + 'type', + 'title', + 'description', + 'format', + 'enum', + 'const', + 'default', + 'required', + 'properties', + 'items', + 'oneOf', + 'anyOf', + 'allOf', + '$ref', + '$schema', + 'additionalProperties', + 'pattern', + 'minLength', + 'maxLength', + 'minimum', + 'maximum', + 'minItems', + 'maxItems', + ]); + + // Extract all non-reserved properties as config + const config: Record = {}; + for (const [key, value] of Object.entries(schemaObj)) { + if (!RESERVED_PROPERTIES.has(key) && !key.startsWith('$')) { + config[key] = value; + } + } + + // Merge with x-config (x-config takes precedence for explicit configuration) + const xConfig = schemaObj['x-config'] as + | Record + | undefined; + if (xConfig) { + Object.assign(config, xConfig); + } + + const customProps: CustomQuestionTypeProps = { + value: data, + config, + onChange: (newValue: unknown) => handleChange(path, newValue), + validation: { + error: Boolean(hasErrors), + message: errorMessage, + }, + enabled: enabled ?? true, + fieldPath: path, + label: label ?? '', + description: description, + }; + + return ( + + + + + + ); + }; + + AdapterInner.displayName = `CustomQuestionType(${formatName})`; + + // Wrap with JSON Forms HOC + return withJsonFormsControlProps(AdapterInner); +} diff --git a/formulus-formplayer/src/services/CustomQuestionTypeLoader.ts b/formulus-formplayer/src/services/CustomQuestionTypeLoader.ts new file mode 100644 index 000000000..4262b5f2e --- /dev/null +++ b/formulus-formplayer/src/services/CustomQuestionTypeLoader.ts @@ -0,0 +1,186 @@ +/** + * CustomQuestionTypeLoader.ts + * + * Loads custom question type components from source strings provided by the + * Formulus RN side. Instead of dynamically importing files from the filesystem, + * this loader evaluates each module's source in a scoped sandbox using + * `new Function()`, which restricts what the code can access. + * + * Security layers: + * 1. RN-side static blocklist (in CustomQuestionTypeScanner) rejects dangerous patterns + * 2. Scoped evaluation here only exposes React — no fetch, document, localStorage, etc. + * + * This loader: + * 1. Iterates over the manifest + * 2. Evaluates each source string in a scoped sandbox + * 3. Extracts and validates the default export (must be a React component function) + * 4. Passes all loaded components to the registry + * 5. Returns renderer entries + format strings for AJV registration + * + * Custom question types use "format": "formatName" in schemas (not "type"). + */ + +import type { JsonFormsRendererRegistryEntry } from '@jsonforms/core'; +import type { + CustomQuestionTypeManifest, + CustomQuestionTypeProps, +} from '../types/CustomQuestionTypeContract'; +import { registerCustomQuestionTypes } from './CustomQuestionTypeRegistry'; +import type React from 'react'; + +export interface CustomQuestionTypeLoadResult { + /** JSON Forms renderer entries ready to be merged into the renderers array */ + renderers: JsonFormsRendererRegistryEntry[]; + /** Format strings that need to be registered with AJV */ + formats: string[]; + /** Any errors that occurred during loading */ + errors: Array<{ format: string; error: string }>; +} + +/** + * Evaluate a module source string in a scoped sandbox. + * + * The code only has access to the variables we explicitly pass in: + * - module / exports (CommonJS-style export mechanism) + * - React (so the component can use createElement, hooks, etc.) + * + * Dangerous globals (fetch, XMLHttpRequest, document, localStorage, etc.) + * are NOT available in this scope. + */ +function evaluateModuleInSandbox( + source: string, + formatName: string, +): React.ComponentType { + const exports: Record = {}; + const moduleObj = { exports }; + + // Get React from the global scope (it's available in the WebView) + // Try multiple ways to access it (window, globalThis, self) + const ReactLib = + (window as unknown as Record).React || + (globalThis as unknown as Record).React || + (self as unknown as Record).React; + + if (!ReactLib) { + console.error( + '[CustomQuestionTypeLoader] React not found in window, globalThis, or self', + ); + console.error( + '[CustomQuestionTypeLoader] Available window keys:', + Object.keys(window).slice(0, 20), + ); + throw new Error('React is not available in the global scope'); + } + + // Get MUI from the global scope (custom components may use Material UI) + const MUILib = + (window as unknown as Record).MaterialUI || + (globalThis as unknown as Record).MaterialUI || + (self as unknown as Record).MaterialUI; + + try { + // Create a factory function with a restricted scope. + // The code can only access: module, exports, React, MaterialUI + // It CANNOT access: fetch, XMLHttpRequest, document, localStorage, etc. + const factory = new Function( + 'module', + 'exports', + 'React', + 'MaterialUI', + source, + ); + + factory(moduleObj, exports, ReactLib, MUILib); + } catch (err) { + throw new Error( + `Failed to evaluate module source: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + // Extract the component from exports (support both default and module.exports patterns) + const component = + (moduleObj.exports as Record).default ?? moduleObj.exports; + + if (typeof component !== 'function') { + throw new Error( + `Module "${formatName}" does not export a valid React component. ` + + `Expected a function, got ${typeof component}. ` + + `Make sure your module uses module.exports = Component or exports.default = Component.`, + ); + } + + return component as React.ComponentType; +} + +/** + * Load custom question types from a manifest containing source strings. + * + * @param manifest - The manifest describing available custom question types (with source code) + * @returns Loaded renderers, format strings, and any errors + */ +export async function loadCustomQuestionTypes( + manifest: CustomQuestionTypeManifest, +): Promise { + const result: CustomQuestionTypeLoadResult = { + renderers: [], + formats: [], + errors: [], + }; + + if ( + !manifest?.custom_types || + Object.keys(manifest.custom_types).length === 0 + ) { + console.log( + '[CustomQuestionTypeLoader] No custom question types in manifest', + ); + return result; + } + + const loadedComponents = new Map< + string, + React.ComponentType + >(); + + for (const [formatName, meta] of Object.entries(manifest.custom_types)) { + try { + console.log( + `[CustomQuestionTypeLoader] Evaluating "${formatName}" (${meta.source.length} bytes)`, + ); + + // Evaluate the source in a scoped sandbox + const component = evaluateModuleInSandbox(meta.source, formatName); + + loadedComponents.set(formatName, component); + result.formats.push(formatName); + + console.log( + `[CustomQuestionTypeLoader] Successfully loaded "${formatName}"`, + ); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + console.error( + `[CustomQuestionTypeLoader] Failed to load "${formatName}":`, + errorMessage, + ); + result.errors.push({ format: formatName, error: errorMessage }); + } + } + + // Register all successfully loaded components + if (loadedComponents.size > 0) { + result.renderers = registerCustomQuestionTypes(loadedComponents); + console.log( + `[CustomQuestionTypeLoader] Registered ${loadedComponents.size} custom question type(s)`, + ); + } + + if (result.errors.length > 0) { + console.warn( + `[CustomQuestionTypeLoader] ${result.errors.length} format(s) failed to load:`, + result.errors.map(e => e.format).join(', '), + ); + } + + return result; +} diff --git a/formulus-formplayer/src/services/CustomQuestionTypeRegistry.ts b/formulus-formplayer/src/services/CustomQuestionTypeRegistry.ts new file mode 100644 index 000000000..23350abc8 --- /dev/null +++ b/formulus-formplayer/src/services/CustomQuestionTypeRegistry.ts @@ -0,0 +1,61 @@ +/** + * CustomQuestionTypeRegistry.ts + * + * Converts a map of { formatName → React component } into JSON Forms + * RendererRegistryEntries. Each entry gets an auto-generated tester that + * matches on the schema's `format` field. + * + * Usage: + * const renderers = registerCustomQuestionTypes(componentsMap); + * // renderers can then be spread into the JsonForms renderers array + */ + +import type { + JsonFormsRendererRegistryEntry, + RankedTester, +} from '@jsonforms/core'; +import { rankWith, schemaMatches } from '@jsonforms/core'; +import type { CustomQuestionTypeProps } from '../types/CustomQuestionTypeContract'; +import { createCustomQuestionTypeRenderer } from '../renderers/CustomQuestionTypeAdapter'; +import type React from 'react'; + +/** + * Creates a ranked tester for a custom question type based on its schema format. + * + * Uses priority 6 which is higher than default Material renderers (priority 3-5) + * but lower than specialized built-in question types (priority 10+). + */ +function createFormatTester(formatName: string): RankedTester { + return rankWith( + 6, + schemaMatches(schema => { + const schemaObj = schema as Record; + return schemaObj?.format === formatName; + }), + ); +} + +/** + * Registers custom question types by creating JSON Forms renderer entries. + * + * @param components - Map of format name → React component + * @returns Array of JsonFormsRendererRegistryEntry ready to be used with + */ +export function registerCustomQuestionTypes( + components: Map>, +): JsonFormsRendererRegistryEntry[] { + const entries: JsonFormsRendererRegistryEntry[] = []; + + for (const [formatName, component] of components) { + const tester = createFormatTester(formatName); + const renderer = createCustomQuestionTypeRenderer(formatName, component); + + entries.push({ tester, renderer }); + + console.log( + `[CustomQuestionTypeRegistry] Registered renderer for format "${formatName}"`, + ); + } + + return entries; +} diff --git a/formulus-formplayer/src/services/ExtensionsLoader.ts b/formulus-formplayer/src/services/ExtensionsLoader.ts index 14884ec14..ef3291969 100644 --- a/formulus-formplayer/src/services/ExtensionsLoader.ts +++ b/formulus-formplayer/src/services/ExtensionsLoader.ts @@ -44,6 +44,7 @@ export interface LoadedRenderer { */ export interface ExtensionLoadResult { renderers: JsonFormsRendererRegistryEntry[]; + // Functions with explicit signature for type safety functions: Map any>; definitions: Record; errors: Array<{ type: string; message: string; details?: any }>; @@ -189,8 +190,7 @@ async function loadRenderer( async function loadFunction( metadata: ExtensionFunctionMetadata, basePath: string, - // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type -): Promise { +): Promise<((...args: any[]) => any) | null> { try { // Construct module path const modulePath = basePath diff --git a/formulus-formplayer/src/types/CustomQuestionTypeContract.ts b/formulus-formplayer/src/types/CustomQuestionTypeContract.ts new file mode 100644 index 000000000..7a46ca806 --- /dev/null +++ b/formulus-formplayer/src/types/CustomQuestionTypeContract.ts @@ -0,0 +1,67 @@ +/** + * CustomQuestionTypeContract.ts + * + * Defines the public interface that custom question type renderers must follow. + * Form authors create components that accept these props — no JSON Forms knowledge needed. + * + * Usage in JSON Schema: + * { "type": "string", "format": "rating-stars", "x-config": { "maxStars": 5 } } + * + * Usage in custom_app: + * custom_app/question_types/rating-stars/renderer.js + * export default function RatingStars({ value, config, onChange, validation }) { ... } + */ + +/** + * Props that every custom question type renderer receives. + */ +export interface CustomQuestionTypeProps { + /** Current field value (type depends on the question's JSON schema type) */ + value: unknown; + + /** + * Configuration extracted from schema properties. + * Includes all properties alongside "format" (except reserved ones like type, title, etc.) + * and properties from "x-config" (x-config takes precedence). + * For example, if schema has `"format": "rating", "maxStars": 5`, then `config.maxStars === 5`. + */ + config: Record; + + /** Callback to update the field value. Call with the new value. */ + onChange: (newValue: unknown) => void; + + /** Current validation state for this field */ + validation: { + /** Whether the field currently has a validation error */ + error: boolean; + /** The validation error message (empty string if no error) */ + message: string; + }; + + /** Whether the field is currently enabled/editable */ + enabled: boolean; + + /** The field's unique path in the form data (e.g., "satisfaction") */ + fieldPath: string; + + /** Display label from the schema's `title` property */ + label: string; + + /** Optional description from the schema's `description` property */ + description?: string; +} + +/** + * Manifest passed from the native side describing available custom question types. + * Each entry maps a format string to the source code of the module that renders it. + * The RN side reads the JS file and passes the source string here for sandboxed evaluation. + */ +export interface CustomQuestionTypeManifest { + custom_types: Record< + string, + { + /** The JS source code of the module (read by RN via RNFS.readFile) */ + source: string; + } + >; +} diff --git a/formulus-formplayer/src/types/FormulusInterfaceDefinition.ts b/formulus-formplayer/src/types/FormulusInterfaceDefinition.ts index fef86f436..e048cd8dc 100644 --- a/formulus-formplayer/src/types/FormulusInterfaceDefinition.ts +++ b/formulus-formplayer/src/types/FormulusInterfaceDefinition.ts @@ -46,6 +46,7 @@ export interface ExtensionMetadata { * @property {any} [formSchema] - JSON Schema for the form structure and validation (optional) * @property {any} [uiSchema] - UI Schema for form rendering layout (optional) * @property {ExtensionMetadata} [extensions] - Custom app extensions (optional) + * @property {object} [customQuestionTypes] - Custom question type manifest from custom_app (optional) */ export interface FormInitData { formType: string; @@ -56,6 +57,9 @@ export interface FormInitData { uiSchema?: unknown; operationId?: string; extensions?: ExtensionMetadata; + customQuestionTypes?: { + custom_types: Record; + }; } /** diff --git a/formulus-formplayer/tsconfig.json b/formulus-formplayer/tsconfig.json index f3859f36f..3ae6deed4 100644 --- a/formulus-formplayer/tsconfig.json +++ b/formulus-formplayer/tsconfig.json @@ -1,22 +1,23 @@ -{ - "compilerOptions": { - "target": "ES2020", - "useDefineForClassFields": true, - "lib": ["ES2020", "DOM", "DOM.Iterable"], - "allowJs": true, - "checkJs": false, - "skipLibCheck": true, - "esModuleInterop": true, - "allowSyntheticDefaultImports": true, - "strict": true, - "forceConsistentCasingInFileNames": true, - "noFallthroughCasesInSwitch": true, - "module": "esnext", - "moduleResolution": "node", - "resolveJsonModule": true, - "isolatedModules": true, - "noEmit": true, - "jsx": "react-jsx" - }, - "include": ["src"] -} +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "allowJs": true, + "checkJs": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx" + }, + "include": ["src"], + "exclude": ["src/**/__tests__/**", "src/**/*.test.ts"] +} diff --git a/formulus-formplayer/vite.config.ts b/formulus-formplayer/vite.config.ts index 785e5e251..5e9010355 100644 --- a/formulus-formplayer/vite.config.ts +++ b/formulus-formplayer/vite.config.ts @@ -38,7 +38,8 @@ export default defineConfig({ onwarn(warning, warn) { if ( warning.code === 'EVAL' || - (warning.message && warning.message.includes('ExtensionsLoader')) + (warning.message && warning.message.includes('ExtensionsLoader')) || + (warning.code === 'UNUSED_EXTERNAL_IMPORT' && warning.source?.includes('formulus-load.js')) ) { return; } diff --git a/formulus/android/app/src/main/assets/formplayer_dist/index.html b/formulus/android/app/src/main/assets/formplayer_dist/index.html index 309ee212a..f9b4e24f6 100644 --- a/formulus/android/app/src/main/assets/formplayer_dist/index.html +++ b/formulus/android/app/src/main/assets/formplayer_dist/index.html @@ -10,6 +10,7 @@ Formulus Form Player + @@ -18,5 +19,6 @@
+ diff --git a/formulus/metro.config.js b/formulus/metro.config.js index 1e6aae786..970f52a10 100644 --- a/formulus/metro.config.js +++ b/formulus/metro.config.js @@ -29,6 +29,18 @@ const extraModules = { projectRoot, 'node_modules/react-native-svg', ), + '@ode/components/react-native': path.resolve( + monorepoRoot, + 'packages/components/src/react-native/index.ts', + ), + '@ode/components/react-web': path.resolve( + monorepoRoot, + 'packages/components/src/react-web/index.ts', + ), + '@ode/tokens/dist/react-native/tokens-resolved': path.resolve( + monorepoRoot, + 'packages/tokens/dist/react-native/tokens-resolved.js', + ), }; /** @@ -44,12 +56,20 @@ const config = { unstable_enablePackageExports: true, extraNodeModules: extraModules, resolveRequest(context, moduleName, platform) { + // Handle forced modules (react, react-native) if (forcedModules[moduleName]) { return { type: 'sourceFile', filePath: path.join(forcedModules[moduleName], 'index.js'), }; } + // Handle @ode/components subpath exports + if (extraModules[moduleName]) { + return { + type: 'sourceFile', + filePath: extraModules[moduleName], + }; + } return context.resolveRequest(context, moduleName, platform); }, }, diff --git a/formulus/src/api/synkronus/index.ts b/formulus/src/api/synkronus/index.ts index 69d770803..17e7720a4 100644 --- a/formulus/src/api/synkronus/index.ts +++ b/formulus/src/api/synkronus/index.ts @@ -208,16 +208,22 @@ class SynkronusApi { progressCallback?.(80); // Atomic swap: remove old dirs, move staging content into place - if (await RNFS.exists(appDir)) await RNFS.unlink(appDir); - if (await RNFS.exists(formsDir)) await RNFS.unlink(formsDir); + if (await RNFS.exists(appDir)) { + await RNFS.unlink(appDir); + } + if (await RNFS.exists(formsDir)) { + await RNFS.unlink(formsDir); + } const stagingAppDir = `${tempExtractPath}/app`; const stagingFormsDir = `${tempExtractPath}/forms`; - if (await RNFS.exists(stagingAppDir)) + if (await RNFS.exists(stagingAppDir)) { await RNFS.moveFile(stagingAppDir, appDir); - if (await RNFS.exists(stagingFormsDir)) + } + if (await RNFS.exists(stagingFormsDir)) { await RNFS.moveFile(stagingFormsDir, formsDir); + } progressCallback?.(95); diff --git a/formulus/src/components/FormplayerModal.tsx b/formulus/src/components/FormplayerModal.tsx index 8ee736e6b..cc09969eb 100644 --- a/formulus/src/components/FormplayerModal.tsx +++ b/formulus/src/components/FormplayerModal.tsx @@ -35,6 +35,7 @@ import { databaseService } from '../database'; import { colors } from '../theme/colors'; import { FormSpec } from '../services'; // FormService will be imported directly import { ExtensionService } from '../services/ExtensionService'; +import { scanCustomQuestionTypes } from '../services/CustomQuestionTypeScanner'; import RNFS from 'react-native-fs'; import { useAppTheme } from '../contexts/AppThemeContext'; import { geolocationService } from '../services/GeolocationService'; @@ -239,9 +240,9 @@ const FormplayerModal = forwardRef( }; // Load extensions for this form + const customAppPath = RNFS.DocumentDirectoryPath + '/app'; let extensions = undefined; try { - const customAppPath = RNFS.DocumentDirectoryPath + '/app'; const extensionService = ExtensionService.getInstance(); const mergedExtensions = await extensionService.getCustomAppExtensions( customAppPath, @@ -313,6 +314,25 @@ const FormplayerModal = forwardRef( return; } + // Scan custom question types (reads JS files, screens against blocklist) + let customQuestionTypes = undefined; + try { + const scanResult = await scanCustomQuestionTypes(customAppPath); + if (Object.keys(scanResult.custom_types).length > 0) { + customQuestionTypes = { + custom_types: scanResult.custom_types, + }; + } + if (scanResult.errors.length > 0) { + console.warn( + 'Some custom question types failed screening:', + scanResult.errors, + ); + } + } catch (error) { + console.warn('Failed to scan custom question types:', error); + } + const formInitData = { formType: formType.id, observationId: observationId, @@ -321,6 +341,7 @@ const FormplayerModal = forwardRef( formSchema: formType.schema, uiSchema: formType.uiSchema ?? {}, extensions, + customQuestionTypes, } as FormInitData; if (!webViewRef.current) { diff --git a/formulus/src/services/CustomQuestionTypeScanner.ts b/formulus/src/services/CustomQuestionTypeScanner.ts new file mode 100644 index 000000000..e1bc522d8 --- /dev/null +++ b/formulus/src/services/CustomQuestionTypeScanner.ts @@ -0,0 +1,193 @@ +/** + * CustomQuestionTypeScanner.ts + * + * Scans the custom_app's `question_types/` directory on the device filesystem, + * reads each module's source code (from renderer.js files), and screens it + * against a blocklist of dangerous patterns before passing it to FormPlayer. + * + * This runs on the Formulus RN side (not in the WebView). + * + * Security: This is the first line of defense. Source code that contains + * dangerous API calls is rejected before it ever reaches the WebView. + * + * File structure: + * question_types/{formatName}/renderer.js + * + * Schema usage: + * { "type": "string", "format": "{formatName}", ... } + * The format name must match the directory name. + */ + +import RNFS from 'react-native-fs'; + +export interface ScannedQuestionType { + /** The raw JS source code of the module */ + source: string; +} + +export interface ScanResult { + /** Successfully scanned custom question types, keyed by format name (folder name) */ + custom_types: Record; + /** Errors encountered during scanning (types that were rejected or couldn't be read) */ + errors: Array<{ name: string; error: string }>; +} + +/** + * Patterns that indicate potentially dangerous code. + * If any of these are found in the source, the module is rejected. + */ +const BLOCKED_PATTERNS: Array<{ pattern: RegExp; description: string }> = [ + { pattern: /\bfetch\s*\(/, description: 'Network request via fetch()' }, + { + pattern: /\bXMLHttpRequest\b/, + description: 'Network request via XMLHttpRequest', + }, + { pattern: /\bWebSocket\b/, description: 'WebSocket connection' }, + { pattern: /\beval\s*\(/, description: 'Dynamic code evaluation via eval()' }, + { + pattern: /\bnew\s+Function\s*\(/, + description: 'Dynamic code evaluation via new Function()', + }, + { pattern: /\bdocument\.cookie\b/, description: 'Cookie access' }, + { pattern: /\blocalStorage\b/, description: 'localStorage access' }, + { pattern: /\bsessionStorage\b/, description: 'sessionStorage access' }, + { pattern: /\bindexedDB\b/, description: 'IndexedDB access' }, + { + pattern: /\bnavigator\.sendBeacon\b/, + description: 'Data exfiltration via sendBeacon', + }, + { + pattern: /\bimportScripts\s*\(/, + description: 'Script import via importScripts()', + }, +]; + +/** + * Screen source code against the blocklist. + * Returns null if the source is clean, or a description of the violation. + */ +function screenSource(source: string): string | null { + for (const { pattern, description } of BLOCKED_PATTERNS) { + if (pattern.test(source)) { + return description; + } + } + return null; +} + +/** + * Scan the `question_types/` directory inside the custom app path. + * + * For each subdirectory found: + * 1. Check for a `renderer.js` file + * 2. Read the file contents as a string + * 3. Screen the source against the blocklist + * 4. If clean, include in the result + * + * The directory name becomes the format name used in schemas. + * Example: "ranking/" directory → use "format": "ranking" in schema + * + * @param customAppPath - The root path of the custom app (e.g., RNFS.DocumentDirectoryPath + '/app') + * @returns Scanned question types and any errors + */ +export async function scanCustomQuestionTypes( + customAppPath: string, +): Promise { + const result: ScanResult = { + custom_types: {}, + errors: [], + }; + + const questionTypesDir = `${customAppPath}/question_types`; + + const dirExists = await RNFS.exists(questionTypesDir); + + // Check if the question_types directory exists + if (!dirExists) { + console.log( + '[CustomQuestionTypeScanner] No question_types/ directory found at:', + questionTypesDir, + ); + return result; + } + + // Read all items in the question_types directory + let folders: RNFS.ReadDirItem[]; + try { + folders = await RNFS.readDir(questionTypesDir); + } catch (err) { + console.error( + '[CustomQuestionTypeScanner] Failed to read question_types directory:', + err, + ); + return result; + } + + // Process each subdirectory + for (const folder of folders) { + if (!folder.isDirectory()) { + continue; + } + + const formatName = folder.name; // e.g., "ranking" + const rendererPath = `${folder.path}/renderer.js`; + + try { + // Check if renderer.js exists + const fileExists = await RNFS.exists(rendererPath); + if (!fileExists) { + result.errors.push({ + name: formatName, + error: `No renderer.js found in question_types/${formatName}/`, + }); + continue; + } + + // Read the source code + const source = await RNFS.readFile(rendererPath, 'utf8'); + + if (!source || source.trim().length === 0) { + result.errors.push({ + name: formatName, + error: 'renderer.js is empty', + }); + continue; + } + + // Screen against the blocklist + const violation = screenSource(source); + if (violation) { + result.errors.push({ + name: formatName, + error: `Blocked: ${violation}`, + }); + console.warn( + `[CustomQuestionTypeScanner] Rejected "${formatName}": ${violation}`, + ); + continue; + } + + // Source is clean — include it + result.custom_types[formatName] = { source }; + console.log( + `[CustomQuestionTypeScanner] Accepted "${formatName}" (${source.length} bytes)`, + ); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + result.errors.push({ + name: formatName, + error: `Failed to read: ${errorMessage}`, + }); + console.error( + `[CustomQuestionTypeScanner] Error processing "${formatName}":`, + errorMessage, + ); + } + } + + console.log( + `[CustomQuestionTypeScanner] Scan complete: ${Object.keys(result.custom_types).length} accepted, ${result.errors.length} errors`, + ); + + return result; +} diff --git a/formulus/src/services/FormService.ts b/formulus/src/services/FormService.ts index 91c8fe0f4..917885684 100644 --- a/formulus/src/services/FormService.ts +++ b/formulus/src/services/FormService.ts @@ -115,12 +115,13 @@ export class FormService { } const formSpecFolders = await RNFS.readDir(formSpecsDir); - // Skip non-form directories (e.g. extensions/, .hidden) + // Skip non-form directories (e.g. extensions/, question_types/, .hidden) const formDirs = formSpecFolders.filter( f => f.isDirectory() && !f.name.startsWith('.') && - f.name !== 'extensions', + f.name !== 'extensions' && + f.name !== 'question_types', ); for (const formDir of formDirs) { diff --git a/formulus/src/webview/FormulusInterfaceDefinition.ts b/formulus/src/webview/FormulusInterfaceDefinition.ts index fef86f436..e048cd8dc 100644 --- a/formulus/src/webview/FormulusInterfaceDefinition.ts +++ b/formulus/src/webview/FormulusInterfaceDefinition.ts @@ -46,6 +46,7 @@ export interface ExtensionMetadata { * @property {any} [formSchema] - JSON Schema for the form structure and validation (optional) * @property {any} [uiSchema] - UI Schema for form rendering layout (optional) * @property {ExtensionMetadata} [extensions] - Custom app extensions (optional) + * @property {object} [customQuestionTypes] - Custom question type manifest from custom_app (optional) */ export interface FormInitData { formType: string; @@ -56,6 +57,9 @@ export interface FormInitData { uiSchema?: unknown; operationId?: string; extensions?: ExtensionMetadata; + customQuestionTypes?: { + custom_types: Record; + }; } /** diff --git a/packages/package-lock.json b/packages/package-lock.json new file mode 100644 index 000000000..f20691ff2 --- /dev/null +++ b/packages/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "packages", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/packages/tokens/package.json b/packages/tokens/package.json index eff10608f..2f41597b6 100644 --- a/packages/tokens/package.json +++ b/packages/tokens/package.json @@ -4,6 +4,17 @@ "description": "ODE Design System - Unified design tokens for React Native, React Web, and all ODE applications", "main": "dist/js/tokens.js", "types": "dist/js/tokens.d.ts", + "exports": { + ".": { + "react-native": "./dist/react-native/tokens-resolved.js", + "default": "./dist/js/tokens.js" + }, + "./dist/react-native/tokens-resolved": "./dist/react-native/tokens-resolved.js", + "./dist/react-native/tokens-resolved.js": "./dist/react-native/tokens-resolved.js", + "./dist/js/tokens": "./dist/js/tokens.js", + "./dist/css/tokens.css": "./dist/css/tokens.css", + "./dist/json/tokens.json": "./dist/json/tokens.json" + }, "files": [ "dist", "README.md" diff --git a/synkronus-portal/src/pages/Dashboard.tsx b/synkronus-portal/src/pages/Dashboard.tsx index 0ad736d9f..020a54f69 100644 --- a/synkronus-portal/src/pages/Dashboard.tsx +++ b/synkronus-portal/src/pages/Dashboard.tsx @@ -43,7 +43,7 @@ import { HiChevronDown, HiCircleStack, } from 'react-icons/hi2'; -import { ColorBrandPrimary500 } from '@ode/tokens/dist/js/tokens'; +import { ColorBrandPrimary500 } from '@ode/tokens'; import odeLogo from '../assets/ode_logo.png'; import dashboardBackgroundDark from '../assets/dashboard-background.png'; import dashboardBackgroundLight from '../assets/dashboard-background-light.png';