feat: Recovery/custom question types 20260224#355
Open
najuna-brian wants to merge 24 commits intoOpenDataEnsemble:devfrom
Open
feat: Recovery/custom question types 20260224#355najuna-brian wants to merge 24 commits intoOpenDataEnsemble:devfrom
najuna-brian wants to merge 24 commits intoOpenDataEnsemble:devfrom
Conversation
Signed-off-by: Jessie Ssebuliba <jessiessebuliba@gmail.com>
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 <jessiessebuliba@gmail.com>
Collaborator
- Added a private method to recursively remove directories and their contents, handling nested directories and permission issues. - Updated the app bundle extraction process to use a unique staging path, improving conflict management. - Enhanced error handling for directory creation and extraction, ensuring non-fatal errors are logged without interrupting the process. - Increased the timeout for form initialization to reduce false positives in loading status.
- Prefix unused catch variables with _ to indicate intentional unused - Fix prettier formatting issues (remove extra whitespace) - All linting errors resolved for CI checks
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 <jessiessebuliba@gmail.com>
Signed-off-by: Jessie Ssebuliba <jessiessebuliba@gmail.com>
…ifest Signed-off-by: Jessie Ssebuliba <jessiessebuliba@gmail.com>
… ES6 syntax Signed-off-by: Jessie Ssebuliba <jessiessebuliba@gmail.com>
Signed-off-by: Jessie Ssebuliba <jessiessebuliba@gmail.com>
Signed-off-by: Jessie Ssebuliba <jessiessebuliba@gmail.com>
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 <jessiessebuliba@gmail.com>
- Added a private method to recursively remove directories and their contents, handling nested directories and permission issues. - Updated the app bundle extraction process to use a unique staging path, improving conflict management. - Enhanced error handling for directory creation and extraction, ensuring non-fatal errors are logged without interrupting the process. - Increased the timeout for form initialization to reduce false positives in loading status.
- Add formulus API parameter to sandbox evaluation - Custom renderers can now safely query database via formulus.getObservationsByQuery() - Still maintains sandbox security (blocks fetch, localStorage, etc.) - Add documentation for security options
- Fix scanning path to check app/question_types (was app/forms/question_types) - Add fallback to get people data from root schema for ranking format - Remove CustomQuestionTypeScanner.ts (use inline scanning per PR OpenDataEnsemble#333) - Remove security blocklist and documentation files - Revert bundle extraction fixes (handled in AnthroCollect) - Remove x-config references - Align with PR OpenDataEnsemble#333 style (inline evaluation) - Remove debug logging and instrumentation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Recovery/custom question types 20260224
Custom Question Types - Plugin system for app-defined renderers
Summary
This PR introduces a custom question type system that allows custom apps (e.g., AnthroCollect) to define their own form field renderers as plain JavaScript files, which are dynamically loaded and sandboxed by the Formplayer at runtime.
Custom question types are identified by the
formatfield in JSON Schema (nottype), because JSON Schema strictly only allows standard types (string,number,integer,boolean,array,object). This is consistent with how all existing built-in question types (photo, GPS, QR code, signature, audio, video, adate) already work in ODE.This is an MVP implementation to prove the pipeline works end-to-end. Two real-world custom question types - Ranking (pairwise ELO) and Select Person - are implemented in the AnthroCollect companion PR to validate the system.
Architecture
The pipeline has four stages across Formulus (React Native) and Formplayer (WebView):
Security Model
Custom renderers run through two layers of security:
CustomQuestionTypeScanner.tsfetch(),eval(),new Function(),XMLHttpRequest,WebSocket,localStorage,sessionStorage,indexedDB,document.cookie,navigator.sendBeacon,importScripts())CustomQuestionTypeLoader.tsnew Function()with onlymodule,exports,React, andMaterialUIin scope. No access tofetch,document,localStorage,window, or any other browser API.Files Changed
New Files
CustomQuestionTypeScanner.tsformulus/src/services/question_types/directory, reads renderer.js files, screens against security blocklistCustomQuestionTypeLoader.tsformulus-formplayer/src/services/new Function(), extracts React componentCustomQuestionTypeRegistry.tsformulus-formplayer/src/services/CustomQuestionTypeAdapter.tsxformulus-formplayer/src/renderers/ControlProps→ simplifiedCustomQuestionTypeProps; wraps in ErrorBoundary + QuestionShellCustomQuestionTypeContract.tsformulus-formplayer/src/types/Modified Files
FormplayerModal.tsxscanCustomQuestionTypes()when opening a form; passes result to Formplayer viaFormInitData.customQuestionTypesApp.tsx(formplayer)loadCustomQuestionTypes(); registers renderers and AJV formats; exposesReactandMaterialUItowindowfor sandbox accessFormulusInterfaceDefinition.tsFormInitDatawith optionalcustomQuestionTypesfieldHow it Works for Form Authors
Convention
Custom question types live in the app bundle at:
The folder name becomes the
formatvalue used in JSON Schema. The file must be namedrenderer.jsand must export a default React function component.Renderer Contract
Every custom renderer receives these props — no JSON Forms knowledge needed:
ReactandMaterialUI(the full@mui/materialpackage) are injected into scope automatically — no imports needed.Writing the Ranking Question Type
schema.json:
{ "ranking_field": { "type": "array", "format": "ranking", "title": "Rank these people by influence", "description": "Click on the person you prefer in each pair", "items": { "type": "string" }, "people": [ { "id": "person1", "name": "John Doe", "age": 35, "sex": "male", "clan": "Alpha" }, { "id": "person2", "name": "Jane Smith", "age": 28, "sex": "female", "clan": "Beta" }, { "id": "person3", "name": "Peter Jones", "age": 42, "sex": "male", "clan": "Gamma" }, { "id": "person4", "name": "Alice Brown", "age": 30, "sex": "female", "clan": "Delta" } ] } }ui.json:
{ "type": "Control", "scope": "#/properties/ranking_field", "label": "Rank these people by influence" }"type": "array""format": "ranking"ranking/renderer.jscustom renderer"people": [...]config.people— the list of people to rank"items": { "type": "string" }Stored value:
["person3", "person1", "person4", "person2"]— an array of person IDs in ranked order (highest first).Writing the Select Person Question Type
schema.json:
{ "selected_person": { "type": "string", "format": "select-person", "title": "Select the focal person", "description": "Choose a person from the list", "showSearch": true, "showPhoto": false, "people": [ { "id": "person1", "name": "John Doe", "age": 35, "sex": "male", "clan": "Alpha" }, { "id": "person2", "name": "Jane Smith", "age": 28, "sex": "female", "clan": "Beta" }, { "id": "person3", "name": "Peter Jones", "age": 42, "sex": "male", "clan": "Gamma" } ] } }ui.json:
{ "type": "Control", "scope": "#/properties/selected_person", "label": "Select the focal person" }"type": "string""format": "select-person"select-person/renderer.jscustom renderer"people": [...]config.people"showSearch": trueconfig.showSearch- enables the autocomplete search UI"showPhoto": falseconfig.showPhoto- whether to show photos in the listStored value:
"person2"— the ID of the selected person.How Config Passthrough Works
Any property in the schema that is not a reserved JSON Schema keyword is automatically extracted and passed to the renderer as
config:config.*type,title,description,format,enum,const,default,required,properties,items,oneOf,anyOf,allOf,$ref,$schema,pattern,minLength,maxLength,minimum,maximum,minItems,maxItemspeople,showSearch,showPhoto,placeholder,promptText,maxStars, etc.Additionally,
x-configcan be used for explicit configuration and takes precedence over inline properties.How This Relates to the Old System (ODK-X / OMO)
In ODK-X, custom question types used
customPromptTypes.jswith Backbone views and Handlebars templates. They were declared in the Excelprompt_typessheet and referenced directly in thetypecolumn (e.g.,type: select_person).ODE uses JSON Forms + JSON Schema, where
typeis restricted to standard types. The equivalent mechanism isformat:prompt_typessheetquestion_types/{name}/renderer.jsconventiontypecolumn in survey sheet"format"field in JSON Schemadisplay.ranking.age_min, etc.)configobjectWhat This PR Does NOT Cover (Future Work)
peoplearrays in the schema. Production forms will need people populated dynamically from the database (viaformParamsor the dynamic choice list system).p_ranking,p_ranking_male,p_ranking_female) still use the old$reftorankingResult. They will need to be updated to use"format": "ranking"with the custom type system.React.createElement()directly. A lightweight JSX transform could be added later for better DX.Related
ranking/renderer.js,select-person/renderer.js,test-simple/renderer.js) and the test form.Fixes #251
Fixes #252
Fixes #253
Testing Details:
Migration Notes:
module.exports = { default: Component }patternrenderer.js(notindex.js)question_types/ranking/→format: "ranking")Key Changes
Formulus RN Side
renderer.jsfiles, screens against blocklist, passes source strings in FormInitDataFormPlayer WebView Side
new Function()sandbox instead of dynamic importsSecurity Features
Video Demo: https://github.com/user-attachments/assets/db53a67b-04da-436c-b972-21573e73afa8