From b50bd27eea26797df6c43622792cc05b3d1239a3 Mon Sep 17 00:00:00 2001 From: Jessie Ssebuliba Date: Wed, 11 Feb 2026 10:53:13 +0300 Subject: [PATCH 1/7] fix lint errors in the formplyer Signed-off-by: Jessie Ssebuliba --- formulus-formplayer/src/services/ExtensionsLoader.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 From ef922799a14dbfdbe1a456a83776e1bc19dbc5a8 Mon Sep 17 00:00:00 2001 From: Jessie Ssebuliba Date: Tue, 17 Feb 2026 17:19:52 +0300 Subject: [PATCH 2/7] feat: secure custom question type loading via source extraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace dynamic import() of file:// URIs with a sandboxed evaluation approach for custom question type modules. Security: - Add CustomQuestionTypeScanner (RN side) that reads index.js files as strings and screens them against a blocklist (fetch, XMLHttpRequest, eval, document.cookie, localStorage, etc.) - Rewrite CustomQuestionTypeLoader (WebView side) to evaluate source in a scoped sandbox via new Function(), exposing only React and MUI - Manifest shape changed from { modulePath: string } to { source: string } New files: - formulus/src/services/CustomQuestionTypeScanner.ts - formulus-formplayer/src/services/CustomQuestionTypeLoader.ts (rewritten) - formulus-formplayer/src/services/CustomQuestionTypeRegistry.ts - formulus-formplayer/src/renderers/CustomQuestionTypeAdapter.tsx - formulus-formplayer/src/types/CustomQuestionTypeContract.ts - formulus-formplayer/docs/custom-question-types-architecture.md Modified files: - formulus/src/components/FormplayerModal.tsx (calls scanner) - FormulusInterfaceDefinition.ts (both projects, modulePath → source) - formulus-formplayer/src/App.tsx (orchestration) Signed-off-by: Jessie Ssebuliba --- .../custom-question-types-architecture.md | 404 ++++++++++++++++++ formulus-formplayer/src/App.tsx | 45 +- .../renderers/CustomQuestionTypeAdapter.tsx | 127 ++++++ .../src/services/CustomQuestionTypeLoader.ts | 163 +++++++ .../services/CustomQuestionTypeRegistry.ts | 57 +++ .../src/types/CustomQuestionTypeContract.ts | 67 +++ .../src/types/FormulusInterfaceDefinition.ts | 4 + formulus/src/components/FormplayerModal.tsx | 23 +- .../src/services/CustomQuestionTypeScanner.ts | 182 ++++++++ .../webview/FormulusInterfaceDefinition.ts | 4 + 10 files changed, 1074 insertions(+), 2 deletions(-) create mode 100644 formulus-formplayer/docs/custom-question-types-architecture.md create mode 100644 formulus-formplayer/src/renderers/CustomQuestionTypeAdapter.tsx create mode 100644 formulus-formplayer/src/services/CustomQuestionTypeLoader.ts create mode 100644 formulus-formplayer/src/services/CustomQuestionTypeRegistry.ts create mode 100644 formulus-formplayer/src/types/CustomQuestionTypeContract.ts create mode 100644 formulus/src/services/CustomQuestionTypeScanner.ts 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..bb53d1cab --- /dev/null +++ b/formulus-formplayer/docs/custom-question-types-architecture.md @@ -0,0 +1,404 @@ +# 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/ + ├── x-ranking/ + │ └── index.js ← default export: React component + ├── x-dynamicEnum/ + │ └── index.js + └── x-custom-text/ + └── index.js +``` + +--- + +## 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 +); + +// Custom code CAN access: React, MaterialUI, module, exports +// Custom code CANNOT access: fetch, document, localStorage, window, etc. +``` + +--- + +## 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/ + ├── x-ranking/index.js ← pairwise Elo ranking UI + ├── x-dynamicEnum/index.js ← dynamic choice list from DB queries + └── x-custom-text/index.js ← enhanced text input +``` + +### 2. Formulus RN Scans, Reads & Screens + +`CustomQuestionTypeScanner.ts` scans `question_types/`, reads each `index.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}/index.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": { + "x-ranking": { + "source": "(function() { 'use strict'; ... module.exports = RankingRenderer; })()" + }, + "x-dynamicEnum": { + "source": "(function() { 'use strict'; ... module.exports = DynamicEnumControl; })()" + }, + "x-custom-text": { + "source": "(function() { 'use strict'; ... module.exports = CustomTextRenderer; })()" + } + } +} +``` + +### 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 = {}; +const moduleObj = { exports }; + +const factory = new Function( + 'module', 'exports', 'React', 'MaterialUI', + meta.source, +); + +factory(moduleObj, exports, React, MaterialUI); + +// Extract only the component +const component = moduleObj.exports.default ?? moduleObj.exports; +``` + +### 5. Registry & Rendering + +`CustomQuestionTypeRegistry.ts` takes each loaded component and: +- Auto-generates a tester: `rankWith(6, schemaMatches(s => s.format === name))` +- Creates a renderer entry via `CustomQuestionTypeAdapter.tsx` +- Registers the format with AJV: `ajv.addFormat('x-ranking', () => true)` + +--- + +## Flow Diagram + +``` +┌─────────────────────────────────────────────────────────────┐ +│ DEVICE STORAGE (after custom_app unzip) │ +│ │ +│ /Documents/app/question_types/x-ranking/index.js │ +│ /Documents/app/question_types/x-dynamicEnum/index.js │ +│ /Documents/app/question_types/x-custom-text/index.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: │ +│ { "x-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: │ +│ new Function('module','exports','React','MaterialUI', │ +│ source) │ +│ Extracts module.exports.default (React component) │ +│ Validates it's a function │ +│ ❌ 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 │ +└────────────────────────┬────────────────────────────────────┘ + │ + ┌──────────┴──────────┐ + ▼ ▼ +┌──────────────────────┐ ┌──────────────────────────────────┐ +│ AJV Registration │ │ JsonForms Renderers Array │ +│ │ │ │ +│ ajv.addFormat( │ │ [ │ +│ 'x-ranking', │ │ ...builtInRenderers, │ +│ () => true │ │ ...customTypeRenderers, ← NEW │ +│ ) │ │ ] │ +│ │ │ │ +│ 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) validation { error, msg } │ +│ schema['x-config'] config │ +│ enabled enabled │ +│ path fieldPath │ +│ label, description label, description │ +│ │ +│ Wraps in: QuestionShell + ErrorBoundary │ +└────────────────────────┬────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ 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 in `p_focal` — pairwise Elo ranking of people by social attributes: + +```json +{ + "ranking_result": { + "type": "object", + "format": "x-ranking", + "x-config": { + "sexFilter": "female", + "hardLimit": 250 + } + } +} +``` + +### Dynamic Enum Question + +Used across many forms — dropdown choices populated from database queries: + +```json +{ + "selected_person": { + "type": "string", + "format": "x-dynamicEnum", + "x-config": { + "query": "p_consent", + "params": { + "scope": "{{data.scope}}" + }, + "valueField": "observationId", + "labelField": "data.name" + } + } +} +``` + +### Custom Text Question + +Enhanced text input with configurable multiline and placeholder: + +```json +{ + "notes": { + "type": "string", + "format": "x-custom-text", + "maxLength": 500, + "x-config": { + "placeholder": "Enter field notes...", + "helperText": "Describe any notable observations" + } + } +} +``` + +**What happens for each:** + +1. `format: "x-ranking"` → tester matches → the ranking renderer is used +2. `x-config` → passed as `props.config` to the author's component +3. Standard JSON Schema keywords (`type`, `maxLength`, etc.) → validated by AJV as normal +4. AJV doesn't reject the custom format strings because we registered them + +--- + +## 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 | + +### 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/src/App.tsx b/formulus-formplayer/src/App.tsx index cc8192e19..0de7ceec1 100644 --- a/formulus-formplayer/src/App.tsx +++ b/formulus-formplayer/src/App.tsx @@ -75,6 +75,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'; @@ -281,6 +282,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()); @@ -381,6 +387,32 @@ 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)`, + ); + 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.', @@ -799,6 +831,16 @@ function App() { return typeof data === 'string' && dateRegex.test(data); }); + // Register custom question type formats with AJV + if (customTypeFormats.length > 0) { + customTypeFormats.forEach((fmt) => { + instance.addFormat(fmt, () => true); + }); + console.log( + `[Formplayer] Registered ${customTypeFormats.length} custom 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 @@ -808,7 +850,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 @@ -986,6 +1028,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/renderers/CustomQuestionTypeAdapter.tsx b/formulus-formplayer/src/renderers/CustomQuestionTypeAdapter.tsx new file mode 100644 index 000000000..cf2a52fae --- /dev/null +++ b/formulus-formplayer/src/renderers/CustomQuestionTypeAdapter.tsx @@ -0,0 +1,127 @@ +/** + * 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 "{this.props.formatName}" failed +
+ {this.state.error?.message} +
+ ); + } + 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 customProps: CustomQuestionTypeProps = { + value: data, + config: (schema as Record)?.['x-config'] as Record ?? {}, + onChange: (newValue: unknown) => handleChange(path, newValue), + validation: { + error: Boolean(errors && errors.length > 0), + message: errors ?? '', + }, + 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..54d3b82fe --- /dev/null +++ b/formulus-formplayer/src/services/CustomQuestionTypeLoader.ts @@ -0,0 +1,163 @@ +/** + * 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 + */ + +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) + const ReactLib = (window as unknown as Record).React; + if (!ReactLib) { + 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; + + 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} type(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..3e6cae1ea --- /dev/null +++ b/formulus-formplayer/src/services/CustomQuestionTypeRegistry.ts @@ -0,0 +1,57 @@ +/** + * 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) => { + return (schema as Record)?.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/types/CustomQuestionTypeContract.ts b/formulus-formplayer/src/types/CustomQuestionTypeContract.ts new file mode 100644 index 000000000..657d6e958 --- /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": "number", "format": "x-rating-stars", "x-config": { "maxStars": 5 } } + * + * Usage in custom_app: + * custom_app/question_types/rating-stars/index.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 from the schema's `x-config` property. + * For example, if schema has `"x-config": { "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/src/components/FormplayerModal.tsx b/formulus/src/components/FormplayerModal.tsx index 8dc1dc3e4..90a910005 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'; @@ -217,9 +218,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, @@ -291,6 +292,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, @@ -299,6 +319,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..85db639ca --- /dev/null +++ b/formulus/src/services/CustomQuestionTypeScanner.ts @@ -0,0 +1,182 @@ +/** + * CustomQuestionTypeScanner.ts + * + * Scans the custom_app's `question_types/` directory on the device filesystem, + * reads each module's source code, 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. + */ + +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 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 an `index.js` file + * 2. Read the file contents as a string + * 3. Screen the source against the blocklist + * 4. If clean, include in the result + * + * @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`; + + // Check if the question_types directory exists + const dirExists = await RNFS.exists(questionTypesDir); + 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., "x-ranking" + const indexPath = `${folder.path}/index.js`; + + try { + // Check if index.js exists + const fileExists = await RNFS.exists(indexPath); + if (!fileExists) { + result.errors.push({ + name: formatName, + error: `No index.js found in question_types/${formatName}/`, + }); + continue; + } + + // Read the source code + const source = await RNFS.readFile(indexPath, 'utf8'); + + if (!source || source.trim().length === 0) { + result.errors.push({ + name: formatName, + error: 'index.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/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; + }; } /** From ea89e8f2cd30715033b7b1b652fdcc01b98556dc Mon Sep 17 00:00:00 2001 From: Mishael-2584 Date: Thu, 19 Feb 2026 15:46:37 +0300 Subject: [PATCH 3/7] feat: enhance module resolution and improve custom question type handling --- formulus-formplayer/index.html | 1 + formulus-formplayer/src/App.tsx | 23 ++++- formulus-formplayer/src/index.tsx | 10 ++ .../renderers/CustomQuestionTypeAdapter.tsx | 97 +++++++++++++++++-- .../src/services/CustomQuestionTypeLoader.ts | 18 +++- .../services/CustomQuestionTypeRegistry.ts | 3 +- .../src/types/CustomQuestionTypeContract.ts | 11 ++- formulus-formplayer/tsconfig.json | 45 ++++----- formulus-formplayer/vite.config.ts | 3 +- .../main/assets/formplayer_dist/index.html | 2 + formulus/metro.config.js | 20 ++++ formulus/src/api/synkronus/index.ts | 14 ++- .../src/services/CustomQuestionTypeScanner.ts | 35 ++++--- formulus/src/services/FormService.ts | 5 +- packages/tokens/package.json | 9 ++ synkronus/pkg/appbundle/versioning.go | 5 +- 16 files changed, 237 insertions(+), 64 deletions(-) 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 0de7ceec1..9e72ed61e 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'; @@ -230,7 +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 ( process.env.NODE_ENV === 'development' && @@ -347,7 +357,7 @@ function App() { } // Start with built-in extensions (always available) - const allFunctions = getBuiltinExtensions(); + const allFunctions = getBuiltinExtensions() as Map any>; // Load extensions if provided if (extensions) { @@ -395,7 +405,7 @@ function App() { setCustomTypeRenderers(customQTResult.renderers); setCustomTypeFormats(customQTResult.formats); console.log( - `[Formplayer] Loaded ${customQTResult.renderers.length} custom question type(s)`, + `[Formplayer] Loaded ${customQTResult.renderers.length} custom question type(s): ${customQTResult.formats.join(', ')}`, ); if (customQTResult.errors.length > 0) { console.warn( @@ -832,12 +842,15 @@ function App() { }); // 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((fmt) => { - instance.addFormat(fmt, () => true); + customTypeFormats.forEach((formatName) => { + // Register as format so AJV accepts "format": "formatName" in schemas + instance.addFormat(formatName, () => true); }); console.log( - `[Formplayer] Registered ${customTypeFormats.length} custom format(s) with AJV`, + `[Formplayer] Registered ${customTypeFormats.length} custom question type format(s) with AJV`, ); } diff --git a/formulus-formplayer/src/index.tsx b/formulus-formplayer/src/index.tsx index 30d12270c..38d4d719d 100644 --- a/formulus-formplayer/src/index.tsx +++ b/formulus-formplayer/src/index.tsx @@ -1,7 +1,17 @@ +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 index cf2a52fae..6c4b5c6aa 100644 --- a/formulus-formplayer/src/renderers/CustomQuestionTypeAdapter.tsx +++ b/formulus-formplayer/src/renderers/CustomQuestionTypeAdapter.tsx @@ -49,16 +49,47 @@ class CustomQuestionErrorBoundary extends Component< return (
- Custom question type "{this.props.formatName}" failed -
- {this.state.error?.message} + + ⚠️ 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. +
); } @@ -92,13 +123,63 @@ export function createCustomQuestionTypeRenderer( 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: (schema as Record)?.['x-config'] as Record ?? {}, + config, onChange: (newValue: unknown) => handleChange(path, newValue), validation: { - error: Boolean(errors && errors.length > 0), - message: errors ?? '', + error: Boolean(hasErrors), + message: errorMessage, }, enabled: enabled ?? true, fieldPath: path, diff --git a/formulus-formplayer/src/services/CustomQuestionTypeLoader.ts b/formulus-formplayer/src/services/CustomQuestionTypeLoader.ts index 54d3b82fe..55b87c619 100644 --- a/formulus-formplayer/src/services/CustomQuestionTypeLoader.ts +++ b/formulus-formplayer/src/services/CustomQuestionTypeLoader.ts @@ -16,6 +16,8 @@ * 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'; @@ -53,13 +55,23 @@ function evaluateModuleInSandbox( const moduleObj = { exports }; // Get React from the global scope (it's available in the WebView) - const ReactLib = (window as unknown as Record).React; + // 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; + 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. @@ -154,7 +166,7 @@ export async function loadCustomQuestionTypes( if (result.errors.length > 0) { console.warn( - `[CustomQuestionTypeLoader] ${result.errors.length} type(s) failed to load:`, + `[CustomQuestionTypeLoader] ${result.errors.length} format(s) failed to load:`, result.errors.map((e) => e.format).join(', '), ); } diff --git a/formulus-formplayer/src/services/CustomQuestionTypeRegistry.ts b/formulus-formplayer/src/services/CustomQuestionTypeRegistry.ts index 3e6cae1ea..d7a99bd1e 100644 --- a/formulus-formplayer/src/services/CustomQuestionTypeRegistry.ts +++ b/formulus-formplayer/src/services/CustomQuestionTypeRegistry.ts @@ -26,7 +26,8 @@ function createFormatTester(formatName: string): RankedTester { return rankWith( 6, schemaMatches((schema) => { - return (schema as Record)?.format === formatName; + const schemaObj = schema as Record; + return schemaObj?.format === formatName; }), ); } diff --git a/formulus-formplayer/src/types/CustomQuestionTypeContract.ts b/formulus-formplayer/src/types/CustomQuestionTypeContract.ts index 657d6e958..955ba021f 100644 --- a/formulus-formplayer/src/types/CustomQuestionTypeContract.ts +++ b/formulus-formplayer/src/types/CustomQuestionTypeContract.ts @@ -5,10 +5,10 @@ * Form authors create components that accept these props — no JSON Forms knowledge needed. * * Usage in JSON Schema: - * { "type": "number", "format": "x-rating-stars", "x-config": { "maxStars": 5 } } + * { "type": "string", "format": "rating-stars", "x-config": { "maxStars": 5 } } * * Usage in custom_app: - * custom_app/question_types/rating-stars/index.js + * custom_app/question_types/rating-stars/renderer.js * export default function RatingStars({ value, config, onChange, validation }) { ... } */ @@ -20,9 +20,10 @@ export interface CustomQuestionTypeProps { value: unknown; /** - * Configuration from the schema's `x-config` property. - * For example, if schema has `"x-config": { "maxStars": 5 }`, - * then `config.maxStars === 5`. + * 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; 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/services/CustomQuestionTypeScanner.ts b/formulus/src/services/CustomQuestionTypeScanner.ts index 85db639ca..e1bc522d8 100644 --- a/formulus/src/services/CustomQuestionTypeScanner.ts +++ b/formulus/src/services/CustomQuestionTypeScanner.ts @@ -2,13 +2,20 @@ * CustomQuestionTypeScanner.ts * * Scans the custom_app's `question_types/` directory on the device filesystem, - * reads each module's source code, and screens it against a blocklist of - * dangerous patterns before passing it to FormPlayer. + * 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'; @@ -19,7 +26,7 @@ export interface ScannedQuestionType { } export interface ScanResult { - /** Successfully scanned custom types, keyed by format name (folder name) */ + /** 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 }>; @@ -72,11 +79,14 @@ function screenSource(source: string): string | null { * Scan the `question_types/` directory inside the custom app path. * * For each subdirectory found: - * 1. Check for an `index.js` file + * 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 */ @@ -90,8 +100,9 @@ export async function scanCustomQuestionTypes( const questionTypesDir = `${customAppPath}/question_types`; - // Check if the question_types directory exists const dirExists = await RNFS.exists(questionTypesDir); + + // Check if the question_types directory exists if (!dirExists) { console.log( '[CustomQuestionTypeScanner] No question_types/ directory found at:', @@ -118,27 +129,27 @@ export async function scanCustomQuestionTypes( continue; } - const formatName = folder.name; // e.g., "x-ranking" - const indexPath = `${folder.path}/index.js`; + const formatName = folder.name; // e.g., "ranking" + const rendererPath = `${folder.path}/renderer.js`; try { - // Check if index.js exists - const fileExists = await RNFS.exists(indexPath); + // Check if renderer.js exists + const fileExists = await RNFS.exists(rendererPath); if (!fileExists) { result.errors.push({ name: formatName, - error: `No index.js found in question_types/${formatName}/`, + error: `No renderer.js found in question_types/${formatName}/`, }); continue; } // Read the source code - const source = await RNFS.readFile(indexPath, 'utf8'); + const source = await RNFS.readFile(rendererPath, 'utf8'); if (!source || source.trim().length === 0) { result.errors.push({ name: formatName, - error: 'index.js is empty', + error: 'renderer.js is empty', }); continue; } 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/packages/tokens/package.json b/packages/tokens/package.json index eff10608f..3f9bf7e3a 100644 --- a/packages/tokens/package.json +++ b/packages/tokens/package.json @@ -4,6 +4,15 @@ "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/json/tokens.json": "./dist/json/tokens.json" + }, "files": [ "dist", "README.md" diff --git a/synkronus/pkg/appbundle/versioning.go b/synkronus/pkg/appbundle/versioning.go index 48279b4f7..d2399ea7b 100644 --- a/synkronus/pkg/appbundle/versioning.go +++ b/synkronus/pkg/appbundle/versioning.go @@ -39,7 +39,7 @@ func (s *Service) PushBundle(ctx context.Context, zipReader io.Reader) (*Manifes if err != nil { return nil, fmt.Errorf("failed to open zip file: %w", err) } - defer zipFile.Close() + // Note: We'll close zipFile explicitly before copying tempZipFile to bundle.zip // Validate the bundle structure if err := s.validateBundleStructure(&zipFile.Reader); err != nil { @@ -123,6 +123,9 @@ func (s *Service) PushBundle(ctx context.Context, zipReader io.Reader) (*Manifes dstFile.Close() } + // Close the zip reader before copying the temp file + zipFile.Close() + // Save the original zip to the version directory for direct download if _, err := tempZipFile.Seek(0, 0); err != nil { return nil, fmt.Errorf("failed to rewind zip for saving: %w", err) From be93a9122adde67981f9e098b8b97b0548a1b4ee Mon Sep 17 00:00:00 2001 From: Mishael-2584 Date: Thu, 19 Feb 2026 15:50:13 +0300 Subject: [PATCH 4/7] added package changes --- packages/package-lock.json | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 packages/package-lock.json 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": {} +} From 25a737e1e1c928e4921ade49aebd2cfcf695a735 Mon Sep 17 00:00:00 2001 From: Mishael-2584 Date: Thu, 19 Feb 2026 16:19:40 +0300 Subject: [PATCH 5/7] chore: apply prettier formatting fixes --- formulus-formplayer/src/App.tsx | 20 +++++++++----- formulus-formplayer/src/index.tsx | 4 ++- .../renderers/CustomQuestionTypeAdapter.tsx | 27 +++++++++++-------- .../src/services/CustomQuestionTypeLoader.ts | 25 ++++++++++++----- .../services/CustomQuestionTypeRegistry.ts | 7 +++-- .../src/types/CustomQuestionTypeContract.ts | 1 - 6 files changed, 56 insertions(+), 28 deletions(-) diff --git a/formulus-formplayer/src/App.tsx b/formulus-formplayer/src/App.tsx index 9e72ed61e..6d336efa9 100644 --- a/formulus-formplayer/src/App.tsx +++ b/formulus-formplayer/src/App.tsx @@ -236,11 +236,12 @@ export const customRenderers = [ 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'); + 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 ( process.env.NODE_ENV === 'development' && @@ -357,7 +358,10 @@ function App() { } // Start with built-in extensions (always available) - const allFunctions = getBuiltinExtensions() as Map any>; + const allFunctions = getBuiltinExtensions() as Map< + string, + (...args: any[]) => any + >; // Load extensions if provided if (extensions) { @@ -401,7 +405,8 @@ function App() { const customQTManifest = initData.customQuestionTypes; if (customQTManifest) { try { - const customQTResult = await loadCustomQuestionTypes(customQTManifest); + const customQTResult = + await loadCustomQuestionTypes(customQTManifest); setCustomTypeRenderers(customQTResult.renderers); setCustomTypeFormats(customQTResult.formats); console.log( @@ -414,7 +419,10 @@ function App() { ); } } catch (error) { - console.error('[Formplayer] Failed to load custom question types:', error); + console.error( + '[Formplayer] Failed to load custom question types:', + error, + ); setCustomTypeRenderers([]); setCustomTypeFormats([]); } @@ -845,7 +853,7 @@ function App() { // 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) => { + customTypeFormats.forEach(formatName => { // Register as format so AJV accepts "format": "formatName" in schemas instance.addFormat(formatName, () => true); }); diff --git a/formulus-formplayer/src/index.tsx b/formulus-formplayer/src/index.tsx index 38d4d719d..f64075fec 100644 --- a/formulus-formplayer/src/index.tsx +++ b/formulus-formplayer/src/index.tsx @@ -9,7 +9,9 @@ import App from './App'; 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'); + console.log( + '[index] Exposed React and MaterialUI to global scope for custom renderers', + ); } const root = ReactDOM.createRoot( diff --git a/formulus-formplayer/src/renderers/CustomQuestionTypeAdapter.tsx b/formulus-formplayer/src/renderers/CustomQuestionTypeAdapter.tsx index 6c4b5c6aa..898c04576 100644 --- a/formulus-formplayer/src/renderers/CustomQuestionTypeAdapter.tsx +++ b/formulus-formplayer/src/renderers/CustomQuestionTypeAdapter.tsx @@ -55,14 +55,13 @@ class CustomQuestionErrorBoundary extends Component< backgroundColor: '#ffebee', color: '#c62828', margin: '8px 0', - }} - > + }}> ⚠️ Custom Question Type Error
- The custom question type "{this.props.formatName}" encountered an error - and could not be rendered. + The custom question type "{this.props.formatName}"{' '} + encountered an error and could not be rendered.
@@ -76,8 +75,7 @@ class CustomQuestionErrorBoundary extends Component< borderRadius: '4px', overflow: 'auto', fontSize: '0.8em', - }} - > + }}> {this.state.error?.message || 'Unknown error'} {this.state.error?.stack && ( <> @@ -87,7 +85,12 @@ class CustomQuestionErrorBoundary extends Component< )}
-
+
The form will continue to function, but this field cannot be edited.
@@ -123,7 +126,8 @@ export function createCustomQuestionTypeRenderer( required, }) => { // Build the simplified props for the custom component - const hasErrors = errors && (Array.isArray(errors) ? errors.length > 0 : true); + 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(', ') @@ -168,7 +172,9 @@ export function createCustomQuestionTypeRenderer( } // Merge with x-config (x-config takes precedence for explicit configuration) - const xConfig = schemaObj['x-config'] as Record | undefined; + const xConfig = schemaObj['x-config'] as + | Record + | undefined; if (xConfig) { Object.assign(config, xConfig); } @@ -192,8 +198,7 @@ export function createCustomQuestionTypeRenderer( title={label} description={description} required={required} - error={errors} - > + error={errors}> diff --git a/formulus-formplayer/src/services/CustomQuestionTypeLoader.ts b/formulus-formplayer/src/services/CustomQuestionTypeLoader.ts index 55b87c619..4262b5f2e 100644 --- a/formulus-formplayer/src/services/CustomQuestionTypeLoader.ts +++ b/formulus-formplayer/src/services/CustomQuestionTypeLoader.ts @@ -60,10 +60,15 @@ function evaluateModuleInSandbox( (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)); + 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'); } @@ -93,7 +98,8 @@ function evaluateModuleInSandbox( } // Extract the component from exports (support both default and module.exports patterns) - const component = (moduleObj.exports as Record).default ?? moduleObj.exports; + const component = + (moduleObj.exports as Record).default ?? moduleObj.exports; if (typeof component !== 'function') { throw new Error( @@ -121,8 +127,13 @@ export async function loadCustomQuestionTypes( errors: [], }; - if (!manifest?.custom_types || Object.keys(manifest.custom_types).length === 0) { - console.log('[CustomQuestionTypeLoader] No custom question types in manifest'); + if ( + !manifest?.custom_types || + Object.keys(manifest.custom_types).length === 0 + ) { + console.log( + '[CustomQuestionTypeLoader] No custom question types in manifest', + ); return result; } @@ -167,7 +178,7 @@ export async function loadCustomQuestionTypes( if (result.errors.length > 0) { console.warn( `[CustomQuestionTypeLoader] ${result.errors.length} format(s) failed to load:`, - result.errors.map((e) => e.format).join(', '), + result.errors.map(e => e.format).join(', '), ); } diff --git a/formulus-formplayer/src/services/CustomQuestionTypeRegistry.ts b/formulus-formplayer/src/services/CustomQuestionTypeRegistry.ts index d7a99bd1e..23350abc8 100644 --- a/formulus-formplayer/src/services/CustomQuestionTypeRegistry.ts +++ b/formulus-formplayer/src/services/CustomQuestionTypeRegistry.ts @@ -10,7 +10,10 @@ * // renderers can then be spread into the JsonForms renderers array */ -import type { JsonFormsRendererRegistryEntry, RankedTester } from '@jsonforms/core'; +import type { + JsonFormsRendererRegistryEntry, + RankedTester, +} from '@jsonforms/core'; import { rankWith, schemaMatches } from '@jsonforms/core'; import type { CustomQuestionTypeProps } from '../types/CustomQuestionTypeContract'; import { createCustomQuestionTypeRenderer } from '../renderers/CustomQuestionTypeAdapter'; @@ -25,7 +28,7 @@ import type React from 'react'; function createFormatTester(formatName: string): RankedTester { return rankWith( 6, - schemaMatches((schema) => { + schemaMatches(schema => { const schemaObj = schema as Record; return schemaObj?.format === formatName; }), diff --git a/formulus-formplayer/src/types/CustomQuestionTypeContract.ts b/formulus-formplayer/src/types/CustomQuestionTypeContract.ts index 955ba021f..7a46ca806 100644 --- a/formulus-formplayer/src/types/CustomQuestionTypeContract.ts +++ b/formulus-formplayer/src/types/CustomQuestionTypeContract.ts @@ -65,4 +65,3 @@ export interface CustomQuestionTypeManifest { } >; } - From a77df63ab3e32cc9e153624e72ff5ed9b2b15881 Mon Sep 17 00:00:00 2001 From: Mishael-2584 Date: Thu, 19 Feb 2026 17:08:03 +0300 Subject: [PATCH 6/7] Update md file --- .../custom-question-types-architecture.md | 328 ++++++++++++++---- 1 file changed, 263 insertions(+), 65 deletions(-) diff --git a/formulus-formplayer/docs/custom-question-types-architecture.md b/formulus-formplayer/docs/custom-question-types-architecture.md index bb53d1cab..1fb24a8fd 100644 --- a/formulus-formplayer/docs/custom-question-types-architecture.md +++ b/formulus-formplayer/docs/custom-question-types-architecture.md @@ -32,14 +32,21 @@ formulus/src/ (FORMULUS — runs in React Native) ``` custom_app/ └── question_types/ - ├── x-ranking/ - │ └── index.js ← default export: React component - ├── x-dynamicEnum/ - │ └── index.js - └── x-custom-text/ - └── index.js + ├── 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 @@ -79,10 +86,22 @@ const factory = new Function( 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 @@ -99,14 +118,14 @@ When the custom_app archive is unzipped, files land on the device filesystem: │ ├── p_focal/schema.json │ └── ... └── question_types/ - ├── x-ranking/index.js ← pairwise Elo ranking UI - ├── x-dynamicEnum/index.js ← dynamic choice list from DB queries - └── x-custom-text/index.js ← enhanced text input + ├── 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 `index.js` as a raw string, +`CustomQuestionTypeScanner.ts` scans `question_types/`, reads each `renderer.js` as a raw string, and screens it against the blocklist: ```typescript @@ -116,7 +135,7 @@ const folders = await RNFS.readDir(questionTypesDir); for (const folder of folders) { if (folder.isDirectory()) { - const source = await RNFS.readFile(`${folder.path}/index.js`, 'utf8'); + const source = await RNFS.readFile(`${folder.path}/renderer.js`, 'utf8'); // Screen against blocklist const violation = screenSource(source); @@ -136,19 +155,21 @@ for (const folder of folders) { ```json { "custom_types": { - "x-ranking": { - "source": "(function() { 'use strict'; ... module.exports = RankingRenderer; })()" + "ranking": { + "source": "(function() { 'use strict'; ... module.exports = { default: RankingRenderer }; })()" }, - "x-dynamicEnum": { - "source": "(function() { 'use strict'; ... module.exports = DynamicEnumControl; })()" + "select-person": { + "source": "(function() { 'use strict'; ... module.exports = { default: SelectPersonRenderer }; })()" }, - "x-custom-text": { - "source": "(function() { 'use strict'; ... module.exports = CustomTextRenderer; })()" + "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: @@ -183,26 +204,57 @@ the WebView. Then `CustomQuestionTypeLoader.ts` evaluates each source in a scope ```typescript // CustomQuestionTypeLoader.ts — evaluateModuleInSandbox() -const exports = {}; +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', - meta.source, + sourceString ); -factory(moduleObj, exports, React, MaterialUI); +factory(moduleObj, exports, ReactLib, MUILib); -// Extract only the component +// 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` -- Registers the format with AJV: `ajv.addFormat('x-ranking', () => true)` +- 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)` --- @@ -212,9 +264,9 @@ const component = moduleObj.exports.default ?? moduleObj.exports; ┌─────────────────────────────────────────────────────────────┐ │ DEVICE STORAGE (after custom_app unzip) │ │ │ -│ /Documents/app/question_types/x-ranking/index.js │ -│ /Documents/app/question_types/x-dynamicEnum/index.js │ -│ /Documents/app/question_types/x-custom-text/index.js │ +│ /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 ▼ @@ -224,7 +276,7 @@ const component = moduleObj.exports.default ?? moduleObj.exports; │ 1. Reads each index.js as a raw string │ │ 2. Screens against blocklist (fetch, eval, etc.) │ │ 3. Builds manifest with source strings: │ -│ { "x-ranking": { source: "..." } } │ +│ { "ranking": { source: "..." } } │ │ 4. Rejected modules → logged as warnings │ └────────────────────────┬────────────────────────────────────┘ │ FormInitData.customQuestionTypes @@ -242,10 +294,13 @@ const component = moduleObj.exports.default ?? moduleObj.exports; │ CustomQuestionTypeLoader.ts — SANDBOX │ │ │ │ For each entry in manifest.custom_types: │ -│ new Function('module','exports','React','MaterialUI', │ -│ source) │ -│ Extracts module.exports.default (React component) │ -│ Validates it's a function │ +│ 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. │ └────────────────────────┬────────────────────────────────────┘ │ @@ -257,6 +312,7 @@ const component = moduleObj.exports.default ?? moduleObj.exports; │ Auto-generates a tester: │ │ rankWith(6, schemaMatches(s => s.format === name)) │ │ Creates renderer entry via adapter │ +│ Returns: { renderers[], formats[] } │ └────────────────────────┬────────────────────────────────────┘ │ ┌──────────┴──────────┐ @@ -265,8 +321,9 @@ const component = moduleObj.exports.default ?? moduleObj.exports; │ AJV Registration │ │ JsonForms Renderers Array │ │ │ │ │ │ ajv.addFormat( │ │ [ │ -│ 'x-ranking', │ │ ...builtInRenderers, │ -│ () => true │ │ ...customTypeRenderers, ← NEW │ +│ 'ranking', │ │ ...builtInRenderers, │ +│ 'select-person', │ │ ...customTypeRenderers, ← NEW │ +│ () => true │ │ ] │ │ ) │ │ ] │ │ │ │ │ │ Prevents AJV from │ │ Testers run top-to-bottom, │ @@ -282,13 +339,20 @@ const component = moduleObj.exports.default ?? moduleObj.exports; │ ───────────────────── ──────────────────────── │ │ data value │ │ handleChange(path, val) onChange(val) │ -│ errors (string) validation { error, msg } │ -│ schema['x-config'] config │ +│ 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 │ └────────────────────────┬────────────────────────────────────┘ │ ▼ @@ -307,69 +371,200 @@ const component = moduleObj.exports.default ?? moduleObj.exports; ### Ranking Question -Used in `p_focal` — pairwise Elo ranking of people by social attributes: +Used for pairwise comparison ranking of people (similar to ODK-X OMO style): ```json { - "ranking_result": { - "type": "object", - "format": "x-ranking", - "x-config": { - "sexFilter": "female", - "hardLimit": 250 - } + "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" } } ``` -### Dynamic Enum Question +**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 across many forms — dropdown choices populated from database queries: +Used for selecting a person from a list with optional search: ```json { - "selected_person": { + "select_person_field": { "type": "string", - "format": "x-dynamicEnum", - "x-config": { - "query": "p_consent", - "params": { - "scope": "{{data.scope}}" + "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" }, - "valueField": "observationId", - "labelField": "data.name" - } + { + "id": "person2", + "name": "Jane Smith", + "age": 28, + "clan": "Beta", + "sex": "female" + } + ] } } ``` -### Custom Text Question +**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 -Enhanced text input with configurable multiline and placeholder: +Minimal example for testing the custom question type system: ```json { - "notes": { + "test_custom_field": { "type": "string", - "format": "x-custom-text", - "maxLength": 500, + "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": { - "placeholder": "Enter field notes...", - "helperText": "Describe any notable observations" + "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: "x-ranking"` → tester matches → the ranking renderer is used -2. `x-config` → passed as `props.config` to the author's component -3. Standard JSON Schema keywords (`type`, `maxLength`, etc.) → validated by AJV as normal -4. AJV doesn't reject the custom format strings because we registered them +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. @@ -388,7 +583,10 @@ All changes below have been implemented. |------|--------| | `FormulusInterfaceDefinition.ts` | `modulePath` → `source` (mirror) | | `CustomQuestionTypeContract.ts` | `modulePath` → `source` in `CustomQuestionTypeManifest` | -| `CustomQuestionTypeLoader.ts` | Rewritten: `import()` → `new Function()` sandbox | +| `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 From 265116acdd772c5e0e8d90d99004f43731802c1b Mon Sep 17 00:00:00 2001 From: Najuna Date: Fri, 20 Feb 2026 13:54:06 +0300 Subject: [PATCH 7/7] fix: resolve @ode/tokens import path for Dashboard build --- packages/tokens/package.json | 2 ++ synkronus-portal/src/pages/Dashboard.tsx | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/tokens/package.json b/packages/tokens/package.json index 3f9bf7e3a..2f41597b6 100644 --- a/packages/tokens/package.json +++ b/packages/tokens/package.json @@ -11,6 +11,8 @@ }, "./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": [ 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';