Skip to content

Commit 4b705eb

Browse files
fix: resolve World owner profile on /jump cards via Worlds Content Server (#563)
* fix: resolve World owner profile on /jump cards via Worlds Content Server The places card resolved its "By <creator>" name through `getSceneMetadata`, which always queried the main Catalyst's active entity at the Genesis City `position` and read that scene's `deployedBy`. Worlds are not deployed on the main Catalyst at a position — their scene lives on the Worlds Content Server keyed by the world name — so for a World URL (e.g. `/jump?realm=impssbldimnsn.dcl.eth&position=25,4`) the lookup resolved an unrelated Genesis City scene deployer (or nothing), and the real world owner's profile was never fetched. The card then fell back to the Places API `contact_name`. Make `getSceneMetadata` world-aware: when `realm` is an ENS name, fetch the world's active scene entity from `WORLDS_CONTENT_SERVER_URL` and resolve the owner from the entity's `metadata.owner`, looking that profile up on the Catalyst lambdas. Genesis City keeps the existing scene deploy-audit path. The shared profile-resolution logic is extracted into `resolveDeployerInfo`. * fix: treat a /jump World absent from the Worlds Content Server as an invalid jump * fix: use the owner address for the /jump creator profile link, not the display name * fix: hide profile badges when the address has no Catalyst profile --------- Co-authored-by: Braian Mellor <braianj@gmail.com>
1 parent b9d8013 commit 4b705eb

10 files changed

Lines changed: 430 additions & 25 deletions

File tree

src/features/places/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export {
2+
isWorldNotFoundError,
23
placesEndpoints,
34
useGetJumpEventByIdQuery,
45
useGetJumpEventsQuery,

src/features/places/places.client.spec.ts

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -549,6 +549,152 @@ describe('placesEndpoints', () => {
549549
expect(result.data).toBeNull()
550550
})
551551
})
552+
553+
describe('and the realm is a World (ENS name)', () => {
554+
const mockWorldsAndPeer = (key: string) => {
555+
if (key === 'WORLDS_CONTENT_SERVER_URL') return 'https://worlds.test'
556+
if (key === 'PEER_URL') return 'https://peer.test'
557+
return undefined
558+
}
559+
560+
describe('and the world scene entity exposes an owner with a Catalyst profile', () => {
561+
beforeEach(() => {
562+
mockGetEnv.mockImplementation(mockWorldsAndPeer)
563+
fetchSpy
564+
.mockResolvedValueOnce({
565+
ok: true,
566+
json: () => Promise.resolve([{ id: 'world-entity', metadata: { owner: '0xOwner' } }])
567+
} as unknown as Response)
568+
.mockResolvedValueOnce({
569+
ok: true,
570+
json: () =>
571+
Promise.resolve([
572+
{
573+
timestamp: 0,
574+
avatars: [
575+
{
576+
name: 'Chiri',
577+
userId: '0xOwner',
578+
avatar: { snapshots: { face256: 'owner.png', body: 'body.png' } }
579+
}
580+
]
581+
}
582+
])
583+
} as unknown as Response)
584+
})
585+
586+
it('should resolve the owner profile from the Worlds Content Server scene entity', async () => {
587+
const store = createTestStore()
588+
const result = await store.dispatch(
589+
placesEndpoints.endpoints.getSceneMetadata.initiate({ position: '25,4', realm: 'MyWorld.dcl.eth' })
590+
)
591+
592+
expect(fetchSpy).toHaveBeenCalledWith(
593+
'https://worlds.test/entities/active',
594+
expect.objectContaining({ method: 'POST', body: JSON.stringify({ pointers: ['myworld.dcl.eth'] }) })
595+
)
596+
expect(result.data).toEqual({
597+
deployerAddress: '0xOwner',
598+
deployerName: 'Chiri',
599+
deployerAvatar: 'owner.png'
600+
})
601+
})
602+
603+
it('should not query the main Catalyst active-entities endpoint by position', async () => {
604+
// Drop call history accumulated by earlier suites so the assertion only
605+
// reflects this dispatch; the `mockResolvedValueOnce` queue survives.
606+
fetchSpy.mockClear()
607+
const store = createTestStore()
608+
await store.dispatch(placesEndpoints.endpoints.getSceneMetadata.initiate({ position: '25,4', realm: 'myworld.dcl.eth' }))
609+
610+
const calledUrls = fetchSpy.mock.calls.map(call => call[0])
611+
expect(calledUrls).toContain('https://worlds.test/entities/active')
612+
expect(calledUrls).not.toContain('https://peer.test/content/entities/active')
613+
})
614+
})
615+
616+
describe('and the world has no active scene entity (server answered 200 with [])', () => {
617+
beforeEach(() => {
618+
mockGetEnv.mockImplementation(mockWorldsAndPeer)
619+
fetchSpy.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve([]) } as unknown as Response)
620+
})
621+
622+
it('should surface WORLD_NOT_FOUND so the page can treat the realm as an invalid jump', async () => {
623+
const store = createTestStore()
624+
const result = await store.dispatch(
625+
placesEndpoints.endpoints.getSceneMetadata.initiate({ position: '0,0', realm: 'empty.dcl.eth' })
626+
)
627+
expect(result.error).toEqual(expect.objectContaining({ status: 'WORLD_NOT_FOUND' }))
628+
})
629+
})
630+
631+
describe('and the world scene entity has no owner', () => {
632+
beforeEach(() => {
633+
mockGetEnv.mockImplementation(mockWorldsAndPeer)
634+
fetchSpy.mockResolvedValueOnce({
635+
ok: true,
636+
json: () => Promise.resolve([{ id: 'world-entity', metadata: {} }])
637+
} as unknown as Response)
638+
})
639+
640+
it('should return null', async () => {
641+
const store = createTestStore()
642+
const result = await store.dispatch(
643+
placesEndpoints.endpoints.getSceneMetadata.initiate({ position: '0,0', realm: 'ownerless.dcl.eth' })
644+
)
645+
expect(result.data).toBeNull()
646+
})
647+
})
648+
649+
describe('and the owner has no Catalyst profile', () => {
650+
beforeEach(() => {
651+
mockGetEnv.mockImplementation(mockWorldsAndPeer)
652+
fetchSpy
653+
.mockResolvedValueOnce({
654+
ok: true,
655+
json: () => Promise.resolve([{ id: 'world-entity', metadata: { owner: '0xOwner' } }])
656+
} as unknown as Response)
657+
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve([]) } as unknown as Response)
658+
})
659+
660+
it('should return null so the Places API contact_name is used', async () => {
661+
const store = createTestStore()
662+
const result = await store.dispatch(
663+
placesEndpoints.endpoints.getSceneMetadata.initiate({ position: '0,0', realm: 'noprofile.dcl.eth' })
664+
)
665+
expect(result.data).toBeNull()
666+
})
667+
})
668+
669+
describe('and the Worlds Content Server returns a server error', () => {
670+
beforeEach(() => {
671+
mockGetEnv.mockImplementation(mockWorldsAndPeer)
672+
fetchSpy.mockResolvedValueOnce({ ok: false, status: 503 } as unknown as Response)
673+
})
674+
675+
it('should surface FETCH_ERROR (not WORLD_NOT_FOUND) so an outage does not redirect valid worlds to invalid', async () => {
676+
const store = createTestStore()
677+
const result = await store.dispatch(
678+
placesEndpoints.endpoints.getSceneMetadata.initiate({ position: '0,0', realm: 'down.dcl.eth' })
679+
)
680+
expect(result.error).toEqual(expect.objectContaining({ status: 'FETCH_ERROR' }))
681+
})
682+
})
683+
684+
describe('and WORLDS_CONTENT_SERVER_URL is not set', () => {
685+
beforeEach(() => {
686+
mockGetEnv.mockImplementation(key => (key === 'PEER_URL' ? 'https://peer.test' : undefined))
687+
})
688+
689+
it('should surface FETCH_ERROR', async () => {
690+
const store = createTestStore()
691+
const result = await store.dispatch(
692+
placesEndpoints.endpoints.getSceneMetadata.initiate({ position: '0,0', realm: 'noenv.dcl.eth' })
693+
)
694+
expect(result.error).toEqual(expect.objectContaining({ status: 'FETCH_ERROR' }))
695+
})
696+
})
697+
})
552698
})
553699

554700
describe('getJumpPlaces with no position or realm', () => {

src/features/places/places.client.ts

Lines changed: 77 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,19 @@ import type {
2121
SceneDeployerInfo
2222
} from './places.types'
2323

24+
// Custom RTK Query error status used by getSceneMetadata to signal that an ENS
25+
// realm does not resolve to a real World on the Worlds Content Server. Consumers
26+
// (PlacesPage) match on it via isWorldNotFoundError to redirect to
27+
// /jump/places/invalid.
28+
const WORLD_NOT_FOUND = 'WORLD_NOT_FOUND'
29+
30+
// Narrow a getSceneMetadata error to the WORLD_NOT_FOUND signal. Keeps the
31+
// producer and consumer matching on one shape instead of a structural cast at
32+
// the call site, so the contract stays in a single place.
33+
function isWorldNotFoundError(error: unknown): boolean {
34+
return typeof error === 'object' && error !== null && (error as { status?: unknown }).status === WORLD_NOT_FOUND
35+
}
36+
2437
function resolveIdentity(address: string | undefined) {
2538
// Resolving the identity inside queryFn (instead of accepting it as a query
2639
// arg) keeps the ephemeral key material out of `state.placesClient.queries.*.
@@ -82,6 +95,27 @@ async function fetchPeerDeployment(peerUrl: string, entityId: string): Promise<P
8295
return body.deployments?.[0] ?? null
8396
}
8497

98+
// Worlds live on the Worlds Content Server, which exposes the active scene
99+
// entity (carrying `metadata.owner`) under `/entities/active` keyed by the world
100+
// name — note the path has no `/content` prefix, unlike the main Catalyst's
101+
// `fetchPeerSceneEntity`.
102+
//
103+
// A `null` return is meaningful: it means the server answered 200 with no
104+
// active entity, i.e. the world does not exist on the WCS. A non-OK response
105+
// (outage, 5xx) THROWS instead, so callers can tell "world doesn't exist" apart
106+
// from "couldn't reach the server" and avoid treating a transient outage as a
107+
// missing world.
108+
async function fetchWorldSceneEntity(worldsUrl: string, worldName: string): Promise<PeerSceneEntity | null> {
109+
const response = await fetch(`${worldsUrl}/entities/active`, {
110+
method: 'POST',
111+
headers: { Accept: '*/*', 'Content-Type': 'application/json' },
112+
body: JSON.stringify({ pointers: [worldName] })
113+
})
114+
if (!response.ok) throw new Error(`Worlds Content Server responded ${response.status}`)
115+
const entities: PeerSceneEntity[] = await response.json()
116+
return entities?.[0] ?? null
117+
}
118+
85119
async function fetchPeerProfile(peerUrl: string, address: string): Promise<PeerProfile | null> {
86120
const response = await fetch(`${peerUrl}/lambdas/profiles`, {
87121
method: 'POST',
@@ -104,6 +138,23 @@ function toCreator(address: string, profile: PeerProfile | null): Creator | null
104138
}
105139
}
106140

141+
// Resolve a Catalyst profile into the card's creator slot. Returns null when the
142+
// address has no profile name — curated scenes (e.g. Genesis Plaza) and some
143+
// world owners are deployed by / owned by addresses with no Catalyst user
144+
// profile, and returning null lets the Card fall back to the Places API's
145+
// contact_name instead of overriding it with a placeholder like "Unknown".
146+
async function resolveDeployerInfo(peerUrl: string, address: string): Promise<SceneDeployerInfo | null> {
147+
const profile = await fetchPeerProfile(peerUrl, address)
148+
const avatar = profile?.avatars?.[0]
149+
const deployerName = avatar?.name || avatar?.realName
150+
if (!deployerName) return null
151+
return {
152+
deployerAddress: address,
153+
deployerName,
154+
deployerAvatar: avatar?.avatar?.snapshots?.face256
155+
}
156+
}
157+
107158
const placesEndpoints = placesClient.injectEndpoints({
108159
endpoints: build => ({
109160
getJumpPlaces: build.query<JumpPlace[], GetPlacesArgs>({
@@ -171,37 +222,45 @@ const placesEndpoints = placesClient.injectEndpoints({
171222
providesTags: (_result, _error, { id }) => [{ type: 'JumpEvent', id }]
172223
}),
173224
getSceneMetadata: build.query<SceneDeployerInfo | null, GetSceneMetadataArgs>({
174-
queryFn: async ({ position }) => {
225+
queryFn: async ({ position, realm }) => {
175226
try {
176227
const peerUrl = getEnv('PEER_URL')
177228
if (!peerUrl) throw new Error('PEER_URL is not set')
178229

230+
// Worlds aren't on the main Catalyst at `position` — their scene lives
231+
// on the Worlds Content Server keyed by the world name. Resolve the
232+
// owner from the scene entity's `metadata.owner` and look that profile
233+
// up on the Catalyst lambdas.
234+
if (realm && isEns(realm)) {
235+
const worldsUrl = getEnv('WORLDS_CONTENT_SERVER_URL')
236+
if (!worldsUrl) throw new Error('WORLDS_CONTENT_SERVER_URL is not set')
237+
238+
const entity = await fetchWorldSceneEntity(worldsUrl, realm.toLowerCase())
239+
// No active scene entity means the realm is not a real World on the
240+
// WCS — even when the Places API still serves a stale record for it.
241+
// Surface a typed not-found so the page can redirect to
242+
// /jump/places/invalid; this is distinct from a FETCH_ERROR raised on
243+
// a WCS outage (fetchWorldSceneEntity throws), which must NOT redirect.
244+
if (!entity) return { error: { status: WORLD_NOT_FOUND } }
245+
246+
const ownerAddress = entity.metadata?.owner
247+
if (!ownerAddress) return { data: null }
248+
249+
return { data: await resolveDeployerInfo(peerUrl, ownerAddress) }
250+
}
251+
179252
const entity = await fetchPeerSceneEntity(peerUrl, position)
180253
if (!entity) return { data: null }
181254

182255
const deployment = await fetchPeerDeployment(peerUrl, entity.id)
183256
if (!deployment) return { data: null }
184257

185-
const profile = await fetchPeerProfile(peerUrl, deployment.deployedBy)
186-
const avatar = profile?.avatars?.[0]
187-
const deployerName = avatar?.name || avatar?.realName
188-
// Curated scenes (e.g. Genesis Plaza) are deployed by addresses that
189-
// have no Catalyst user profile. Returning null here lets the Card
190-
// fall back to the Places API's contact_name instead of overriding
191-
// it with a placeholder like "Unknown".
192-
if (!deployerName) return { data: null }
193-
194-
const info: SceneDeployerInfo = {
195-
deployerAddress: deployment.deployedBy,
196-
deployerName,
197-
deployerAvatar: avatar?.avatar?.snapshots?.face256
198-
}
199-
return { data: info }
258+
return { data: await resolveDeployerInfo(peerUrl, deployment.deployedBy) }
200259
} catch (error) {
201260
return { error: { status: 'FETCH_ERROR', error: error instanceof Error ? error.message : 'Unknown error' } }
202261
}
203262
},
204-
providesTags: (_result, _err, args) => [{ type: 'SceneMetadata', id: args.position }]
263+
providesTags: (_result, _err, args) => [{ type: 'SceneMetadata', id: args.realm ? args.realm.toLowerCase() : args.position }]
205264
}),
206265
getProfileCreator: build.query<Creator | null, { address: string }>({
207266
queryFn: async ({ address }) => {
@@ -225,6 +284,7 @@ const { useGetJumpEventByIdQuery, useGetJumpEventsQuery, useGetJumpPlacesQuery,
225284
placesEndpoints
226285

227286
export {
287+
isWorldNotFoundError,
228288
placesEndpoints,
229289
useGetJumpEventByIdQuery,
230290
useGetJumpEventsQuery,

src/features/places/places.mappers.spec.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,70 @@ describe('jump.mappers', () => {
103103
it('should default user_name to Unknown', () => {
104104
expect(fromPlace(place).user_name).toBe('Unknown')
105105
})
106+
107+
it('should leave user undefined when there is no owner', () => {
108+
expect(fromPlace(place).user).toBeUndefined()
109+
})
110+
})
111+
112+
describe('and the owner is a real wallet address', () => {
113+
const place: JumpPlace = {
114+
id: 'p5',
115+
title: 'Owned World',
116+
image: '',
117+
description: '',
118+
positions: ['0,0'],
119+
base_position: '0,0',
120+
owner: '0xd46a1da2aae4afc4a272dc3f28b2025ae9c18df1',
121+
world: true,
122+
world_name: 'dexou.dcl.eth'
123+
}
124+
125+
it('should expose the address as user so the profile link resolves', () => {
126+
expect(fromPlace(place).user).toBe('0xd46a1da2aae4afc4a272dc3f28b2025ae9c18df1')
127+
})
128+
})
129+
130+
describe('and the owner is a Places API display string instead of an address', () => {
131+
// Some (notably stale) World records carry a display string in `owner`,
132+
// e.g. zone returns "dexou by xyz.lb" for dexou.dcl.eth.
133+
const place: JumpPlace = {
134+
id: 'p6',
135+
title: 'DEXOU',
136+
image: '',
137+
description: '',
138+
positions: ['0,0'],
139+
base_position: '0,0',
140+
owner: 'dexou by xyz.lb ',
141+
world: true,
142+
world_name: 'dexou.dcl.eth'
143+
}
144+
145+
it('should not turn the display string into a profile link (user stays undefined)', () => {
146+
expect(fromPlace(place).user).toBeUndefined()
147+
})
148+
149+
it('should still surface the display string as the creator name', () => {
150+
expect(fromPlace(place).user_name).toBe('dexou by xyz.lb ')
151+
})
152+
})
153+
154+
describe('and the owner is a whitespace-padded address', () => {
155+
// The address validator is anchored and does not trim, so a padded value
156+
// is rejected — guards against feeding a non-canonical address to the link.
157+
const place: JumpPlace = {
158+
id: 'p7',
159+
title: 'Padded',
160+
image: '',
161+
description: '',
162+
positions: ['0,0'],
163+
base_position: '0,0',
164+
owner: ' 0xd46a1da2aae4afc4a272dc3f28b2025ae9c18df1 '
165+
}
166+
167+
it('should leave user undefined', () => {
168+
expect(fromPlace(place).user).toBeUndefined()
169+
})
106170
})
107171
})
108172

src/features/places/places.mappers.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/* eslint-disable @typescript-eslint/naming-convention */
22
import decentralandLogo from '../../images/jump/decentraland-logo.svg'
3+
import { isValidEthAddress } from '../../utils/avatar'
34
import { resolveEventRealm } from '../../utils/whatsOnUrl'
45
import type { CardData, JumpEvent, JumpPlace } from './places.types'
56

@@ -11,12 +12,18 @@ function fromPlace(data: JumpPlace): CardData {
1112
// foundation. User-deployed places can also have owner=null + a contact_name
1213
// (e.g. "Pink Oasis"), and those should fall back to the generic avatar.
1314
const isFoundationPlace = data.contact_name === FOUNDATION_CONTACT_NAME
15+
// `owner` doubles as a display string on some records (zone returns
16+
// "dexou by xyz.lb" for dexou.dcl.eth), so only feed it to the profile link +
17+
// avatar lookup when it is a real wallet address. A non-address owner still
18+
// shows as the creator name, but the name is not turned into a profile link
19+
// that would resolve to a bogus `?profile=<display name>`.
20+
const ownerAddress = isValidEthAddress(data.owner) ? data.owner : undefined
1421
return {
1522
id: data.id,
1623
type: 'place',
1724
title: data.title,
1825
user_name: data.owner || data.contact_name || 'Unknown',
19-
user: data.owner ?? undefined,
26+
user: ownerAddress,
2027
user_avatar: isFoundationPlace ? decentralandLogo : undefined,
2128
coordinates,
2229
image: data.image,

0 commit comments

Comments
 (0)