@@ -34,7 +34,7 @@ const SERVER_URL = import.meta.env.VITE_SERVER_URL || '';
3434 * Resolve a public form spec by slug. Tries the canonical endpoint first
3535 * and falls back to a client-side scan of the `view` metadata index.
3636 */
37- async function resolvePublicForm ( slug : string ) : Promise < { config : EmbeddableFormConfig ; schema : any | null } | null > {
37+ async function resolvePublicForm ( slug : string ) : Promise < { config : EmbeddableFormConfig ; schema : any | null ; lookupFieldMap : Record < string , string > } | null > {
3838 const publicLink = `/forms/${ slug } ` ;
3939
4040 // 1. Canonical endpoint — when the backend implements it, this becomes
@@ -47,7 +47,7 @@ async function resolvePublicForm(slug: string): Promise<{ config: EmbeddableForm
4747 const spec = await res . json ( ) ;
4848 const config = mapViewSpecToEmbeddableConfig ( spec , slug ) ;
4949 if ( ! config ) return null ;
50- return { config, schema : spec ?. objectSchema ?? null } ;
50+ return { config, schema : spec ?. objectSchema ?? null , lookupFieldMap : buildLookupFieldMap ( spec ) } ;
5151 }
5252 } catch {
5353 // network error — fall through to the discovery fallback
@@ -81,7 +81,7 @@ async function resolvePublicForm(slug: string): Promise<{ config: EmbeddableForm
8181 if ( ! match ) return null ;
8282 const config = mapViewSpecToEmbeddableConfig ( match , slug ) ;
8383 if ( ! config ) return null ;
84- return { config, schema : null } ;
84+ return { config, schema : null , lookupFieldMap : buildLookupFieldMap ( match ) } ;
8585 } catch {
8686 return null ;
8787 }
@@ -129,12 +129,42 @@ function mapViewSpecToEmbeddableConfig(
129129 } ;
130130}
131131
132+ /**
133+ * Map each lookup field surviving the framework's strip step to its
134+ * `referenceTo` target. The framework's `/forms/:slug` handler drops any
135+ * lookup field that doesn't carry an explicit `publicPicker` config, so
136+ * any entry remaining in the map is guaranteed safe to route to the
137+ * scoped picker endpoint.
138+ */
139+ function buildLookupFieldMap ( spec : any ) : Record < string , string > {
140+ const formView = spec ?. __matchedFormView ?? spec ?. form ?? spec ;
141+ const objectFields = spec ?. objectSchema ?. fields ?? { } ;
142+ const map : Record < string , string > = { } ;
143+ for ( const section of formView ?. sections ?? [ ] ) {
144+ for ( const f of section ?. fields ?? [ ] ) {
145+ const name = typeof f === 'string' ? f : f ?. field ;
146+ if ( ! name ) continue ;
147+ const def = objectFields [ name ] ;
148+ if ( ! def ) continue ;
149+ if ( def . type !== 'lookup' && def . type !== 'master_detail' ) continue ;
150+ const target = def . referenceTo ?? def . target ?? def . options ?. objectName ;
151+ if ( target ) map [ target ] = name ;
152+ }
153+ }
154+ return map ;
155+ }
156+
132157/**
133158 * Anonymous data source — posts to the public submit endpoint (preferred)
134159 * and falls back to the legacy data endpoint. No auth header is attached.
135- * Only the `create` op is implemented; the embeddable form never reads.
160+ * `find()` is routed through the per-field scoped picker endpoint when the
161+ * caller is asking about an object referenced by a public-picker lookup.
136162 */
137- function createPublicDataSource ( slug : string , schema : any | null ) : DataSource {
163+ function createPublicDataSource (
164+ slug : string ,
165+ schema : any | null ,
166+ lookupFieldMap : Record < string , string > = { } ,
167+ ) : DataSource {
138168 const post = async ( objectName : string , data : Record < string , unknown > ) => {
139169 // 1. Preferred public endpoint
140170 const submitRes = await fetch (
@@ -168,15 +198,29 @@ function createPublicDataSource(slug: string, schema: any | null): DataSource {
168198 throw new Error ( `Submission failed (${ submitRes . status } ). Please try again.` ) ;
169199 } ;
170200
171- // The EmbeddableForm only calls `.create(...)`. Stubs for the rest of
172- // the DataSource interface keep TypeScript happy without giving guests
173- // any read/edit/delete capability.
201+ // Scoped lookup picker — called by LookupField for an opt-in
202+ // `publicPicker`-enabled field. Routes to the per-field endpoint that
203+ // enforces the form's allowlist + filter + display-field projection.
204+ // For any other object the search returns empty so a stray lookup
205+ // widget in the form can never enumerate unrelated tables.
206+ const find = async ( objectName : string , params : any ) => {
207+ const fieldName = lookupFieldMap [ objectName ] ;
208+ if ( ! fieldName ) return { data : [ ] , total : 0 } ;
209+ const q = String ( params ?. search ?? params ?. q ?? '' ) . trim ( ) ;
210+ const url = `${ SERVER_URL } /api/v1/forms/${ encodeURIComponent ( slug ) } /lookup/${ encodeURIComponent ( fieldName ) } ${ q ? `?q=${ encodeURIComponent ( q ) } ` : '' } ` ;
211+ const res = await fetch ( url , { headers : { Accept : 'application/json' } } ) ;
212+ if ( ! res . ok ) return { data : [ ] , total : 0 } ;
213+ const body : any = await res . json ( ) . catch ( ( ) => null ) ;
214+ const rows : any [ ] = Array . isArray ( body ?. data ) ? body . data : [ ] ;
215+ return { data : rows , total : typeof body ?. total === 'number' ? body . total : rows . length } ;
216+ } ;
217+
174218 return {
175219 create : post ,
176220 update : ( ) => Promise . reject ( new Error ( 'Not permitted on public form' ) ) ,
177221 delete : ( ) => Promise . reject ( new Error ( 'Not permitted on public form' ) ) ,
178222 findOne : ( ) => Promise . resolve ( null ) ,
179- find : ( ) => Promise . resolve ( { data : [ ] , total : 0 } ) ,
223+ find,
180224 // EmbeddableForm calls getObjectSchema() to look up field types and
181225 // labels. The schema is embedded in the public-form resolver response
182226 // so no auth-protected meta call is required. Return a safe stub when
@@ -209,6 +253,7 @@ export function PublicFormPage() {
209253 const { t } = useObjectTranslation ( ) ;
210254 const [ config , setConfig ] = useState < EmbeddableFormConfig | null > ( null ) ;
211255 const [ schema , setSchema ] = useState < any | null > ( null ) ;
256+ const [ lookupFieldMap , setLookupFieldMap ] = useState < Record < string , string > > ( { } ) ;
212257 const [ error , setError ] = useState < string | null > ( null ) ;
213258 const [ loading , setLoading ] = useState ( true ) ;
214259 const [ isDemo , setIsDemo ] = useState ( false ) ;
@@ -242,6 +287,7 @@ export function PublicFormPage() {
242287 setError ( null ) ;
243288 setConfig ( null ) ;
244289 setSchema ( null ) ;
290+ setLookupFieldMap ( { } ) ;
245291
246292 // Dev-only fallback: when no backend is reachable, the
247293 // `?demo=1` query param renders a hardcoded CRM web-to-lead form so
@@ -267,6 +313,7 @@ export function PublicFormPage() {
267313 } else {
268314 setConfig ( result . config ) ;
269315 setSchema ( result . schema ) ;
316+ setLookupFieldMap ( result . lookupFieldMap ?? { } ) ;
270317 }
271318 } )
272319 . catch ( ( err ) => {
@@ -320,7 +367,7 @@ export function PublicFormPage() {
320367 return (
321368 < EmbeddableForm
322369 config = { { ...config , texts : { ...texts , ...config . texts } } }
323- dataSource = { isDemo ? createDemoDataSource ( ) : createPublicDataSource ( slug , schema ) }
370+ dataSource = { isDemo ? createDemoDataSource ( ) : createPublicDataSource ( slug , schema , lookupFieldMap ) }
324371 />
325372 ) ;
326373}
0 commit comments