Skip to content

feat: support custom gateway origins - #9845

Closed
pentreathm wants to merge 12 commits into
devfrom
fix/fixture-gateway-url
Closed

feat: support custom gateway origins#9845
pentreathm wants to merge 12 commits into
devfrom
fix/fixture-gateway-url

Conversation

@pentreathm

Copy link
Copy Markdown
Member

Summary

  • add --gateway-url to override the origin used by gateway-routed requests
  • keep service paths compatible with shared-origin fixture gateways
  • update custom-base-domain documentation and coverage

This is needed by the Explorer E2E fixture, whose gateway is served on the realm origin because ephemeral fixture subdomains do not have per-service TLS certificates.

mikhail-dcl and others added 10 commits August 21, 2026 11:34
Adds `DecentralandEnvironment.Custom`, selected by the new `--base-domain` app
arg, so the client can target a deployment served under a base domain other than
decentraland.{org,zone,today} (e.g. interconnected.online).

The url table no longer carries a domain per entry. `RawUrl` composes each host
from a `hostDomain` field (`$"https://peer.{hostDomain}/about"`), replacing the
85 `decentraland.{ENV}` templates and the `Replace` pass that resolved them:
`ENV`, `DOMAIN_TOKEN`, `ResolveDomain` and `decentralandDomain` are gone, `Url()`
only caches what `RawUrl` produced, and `Probe()` is a single call. A custom
domain is no longer a separate substitution path, just a different value in that
field.

`DecentralandUrlsSource.ResolveBaseDomain` is the single place the domain is
decided and `IDecentralandUrlsSource.BaseDomain` the single place to read it, so
nothing restates a literal. A base domain that is not a bare domain, a `Custom`
environment without one, or one paired with another environment all throw rather
than being ignored. `hostDomain` equals `BaseDomain` except in the today
environment, which resolves its `.today` hosts in the constructor and then flips
the field to org — the one reason urls must stay lazily resolved, now an explicit
mutable field instead of an emergent property of token substitution.

`GatewayUrlsSource.IsGatewayTransformable` matched a hardcoded `.decentraland.`
marker, which only worked while it saw unresolved templates; it now accepts
exactly a single-label subdomain under this client's own `BaseDomain`. That is
strictly tighter and domain-agnostic, and it removes an accidental asymmetry
where `LocalGateKeeperSceneAdapter` escaped the gateway on a custom domain while
the same url routed through it on org.

Following the resolved domain: the pre-login feature-flag host, the catalyst
server list (RealmNamesMap), the smart-wearable content fallback
(SmartWearableCache), gateway routing, teleport validation
(ChatEnvironmentValidator), deep-link realm trust
(DeepLinkAllowlist.SetTrustedBaseDomain) and the main-realm comms hostname
(RealmController). `--gatekeeper-url` still outranks all of it. The default
"Empty place" no longer carries a peer.decentraland.org thumbnail hash a custom
deployment cannot serve; its views pass the placeholder sprite their prefab
already ships.

Because the base domain decides which realm hosts are trusted, it is read while
the launch deep link is still deferred: only the command line can set it, and it
outranks a deep-link `--dclenv`.

Every other environment-dependent decision gets an explicit `Custom` arm instead
of falling into a default nobody chose: ethereum network (ChainUtils, now one
`IsMainnet` predicate instead of four copies), marketplace credits, donations,
the stored identity slot, the community-message router identity and the Genesis
City manifest. `Custom` is treated as a non-production stack throughout —
sepolia/Amoy, the sepolia identity slot, `message-router-dev-0` — so an
unverified deployment can never be handed mainnet contracts.

Characterization tests walk every DecentralandUrl and fail if one still resolves
to a decentraland host; unit tests cover base domain validation, gateway routing,
teleport validation and deep-link realm trust under a custom domain.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The startup trusted-realm gate fast-passed a hardcoded list of decentraland
hosts and otherwise consulted the deployment's catalyst server list. That list
enumerates catalysts, not worlds servers -- which is exactly why decentraland's
worlds-content-server is hardcoded there -- so a --base-domain deployment's world
realm matched neither and raised the untrusted-realm confirmation on every
launch. Every host under the resolved base domain is now trusted, so a catalyst
or a world on that deployment connects without it.

Safe because --base-domain is command-line only and never accepted from a deep
link: reaching this gate already required the operator to point the client at
that deployment. The deep-link gate stays deliberately stricter -- subdomains
only, and still gated on the flag-whitelisted world name -- because the realm it
sees is attacker-supplied.

The '.'-boundary check those gates share moves to
IDecentralandUrlsSource.IsSubdomainOf / IsHostWithinDomain, replacing the private
copy in DeepLinkAllowlist with identical semantics, so the rule that rejects
"interconnected.online.attacker.com" and "evil-interconnected.online" lives in
one place and is covered by tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
base-domain is denied by DeepLinkAllowlist, so a link carrying it reaches the
denied-params consent dialog like gatekeeper-url or feature-flags-url. Accepting
it there did nothing: ApplyBaseDomainArg reads the arg before the deep link is
processed and nothing reads it again, so the value sat in the logged arguments
looking applied while the client stayed on decentraland.

WarnIfBaseDomainCameFromTheDeepLink now reports that, and the comments and docs
state the actual reason the arg is command-line only -- an ordering constraint,
not a trust boundary. The domain gates which realms DeepLinkAllowlist trusts, so
it has to be registered before InitializeDeepLinks() evaluates a pending link's
whitelisted-realm params; a domain arriving in that same link could not be,
because the link's own params would need gating against a domain it has not
supplied yet.

Also restates why trusting every host under the base domain costs nothing:
whoever sets it has already redirected every backend host, so realm trust adds no
capability on top -- an argument that does not depend on how the value arrived.

Brings docs/custom-base-domain.md back in line with the code: it still described
the hostDomain field that was merged into BaseDomain, and still claimed a custom
world realm costs one extra request.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two areas a --base-domain deployment has to reason about, neither of which the
flag itself decides.

Manifest and roads: RealmKind comes from the realm's own /about, so a custom
catalyst realm classifies as GenesisCity. RoadsPresence switches instanced road
rendering on that alone and the geometry is a local Addressable baked from
decentraland's Genesis City layout, so a custom genesis realm gets
decentraland's roads drawn over its parcels. Skipping the genesis manifest also
leaves WorldManifest.IsEmpty, which disables the occupied-parcel filter in
LoadPointersByIncreasingRadiusSystem and the manifest-derived terrain and spawn
data -- an optimisation lost rather than a break, and still better than applying
decentraland's parcel set to a foreign realm. Per-deployment world manifests
already work through the asset-bundle-registry; only the genesis one has no
per-deployment source.

Authentication: the auth api and the signing web app follow the base domain, so a
deployment must serve both for login to complete. The chain RPC is split -- the
external-wallet dapp path resolves ApiRpc and moves to the base domain, while
ThirdWebAuthenticator keeps its hardcoded rpc.decentraland.org map -- and the
sepolia identity slot is shared with zone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
SDK6 hybrid scenes and LocalIpfsRealm were listed as gaps but neither asks
anything of a reader: the SDK6 path is legacy, and LocalIpfsRealm is only ever
constructed by IRealmData.Fake so it never runs in a shipped client. The ThirdWeb
chain RPC is already covered where it matters, in the authentication section.

What is left is the one thing a reader has to know: which links stay on
decentraland, and that the marketplace/shop/docs ones do not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The manifest path and RealmKind were described as one classification, which hid
that they key off different things. The manifest branch is chosen by matching
configurations.realmName against WorldManifestProvider's hardcoded list of
decentraland's genesis realm names, so a custom deployment calling its main realm
"main" collides with that list; RealmKind ignores the name entirely and comes from
whether /about lists fixed scene URNs.

The distinction is what a reader needs: renaming a realm away from "main" changes
which manifest branch it takes but not that it classifies as Genesis City, so it
does not turn roads off.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The chain was derived from DecentralandEnvironment in four independent
places - the ethereum ids, marketplace credits, donations and the stored
identity slot. An override applied to one of them would have let them
disagree, so they all take the resolved EthereumNetwork now and nothing
re-derives a chain of its own. Each ethereum network carries exactly one
polygon network, because a deployment is on both or neither.

ChainUtils.ResolveNetwork is the single decision. PinnedNetworkOf fixes
org and today to mainnet and zone to sepolia, and no argument moves them:
--eth-network paired with one of those is reported and dropped. Only a
--base-domain deployment reads the value, defaulting to mainnet, which is
what brings a deployment mirroring mainnet into scope for that flag.

The value is captured while the deep link is still deferred, as
--base-domain is, so a link cannot pick the chain - not even through the
denied-params dialog. Where the value is read, one naming no known network
ends the launch instead of falling back: reaching mainnet by mistyping the
flag is what would put real contracts and the production identity slot
behind an operator who asked for a test chain.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The embedded wallet reached decentraland's RPC proxy from a hardcoded
table while the external-wallet path resolved ApiRpc, so the two disagreed
about where a chain lives on a --base-domain deployment. They are one
dependency configured twice: the same host over websocket and https.

The map is now built per instance from the new DecentralandUrl.ChainRpc,
so every chain the client may transact on follows the base domain.

Probed rather than resolved through Url: rpc is a single-label subdomain,
so gateway routing would rewrite it to gateway.{domain}/rpc once that flag
is on - a change of route for every chain call, on the default
environments too, and a separate decision from this one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@pentreathm
pentreathm requested review from a team as code owners August 24, 2026 13:10
@vercel

vercel Bot commented Aug 24, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
unity-explorer Error Error Aug 24, 2026 2:51pm

Request Review

@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

@decentraland-bot

This comment has been minimized.

@decentraland-bot

Copy link
Copy Markdown
Contributor

PR #9845, run #32766857263

Builds: Windows change, Windows baseline, macOS change, macOS baseline

How to read this table
  • Each build is measured 3 times. The values are the median, and (min–max) is the lowest and highest of those runs — a wide range means the metric is noisy and small differences are not trustworthy.
  • Δ is Change minus Baseline (a negative Δ means Change is faster).
  • 🟢 faster / 🔴 slower — a real difference: larger than both 3% and the run-to-run range.
  • ⚪ within noise — the difference is smaller than how much the build varies between its own runs, so it cannot be told apart from random variation. Treat it as no change.
  • Exceptions per run — the average number of exceptions in a run's log; more than the baseline is flagged 🔴 even when frame times look fine. The Exception breakdown under each table groups them by the explorer's report category and exception type (as totals across the runs).
  • A run that logged unusually many exceptions (at least 10 and 5× the median of its build's runs — e.g. a service was down during it) is excluded from all numbers and called out under the table.

Intel Core i5

Metric Baseline Change Δ Result
Samples 2232 (×3) 2685 (×3)
CPU average 40.0 ms (37.5–40.6) 33.4 ms -6.6 ms 🟢 17% faster
CPU 1% worst 416.2 ms (410.8–424.4) 54.1 ms (53.7–56.4) -362.1 ms 🟢 87% faster
CPU 0.1% worst 448.2 ms (428.8–467.7) 119.4 ms (115.3–128.4) -328.7 ms 🟢 73% faster
GPU average 27.6 ms (26.3–27.6) 19.3 ms (19.3–19.6) -8.2 ms 🟢 30% faster
GPU 1% worst 420.9 ms (415.6–430.0) 37.4 ms (35.4–38.6) -383.5 ms 🟢 91% faster
GPU 0.1% worst 450.9 ms (434.8–471.9) 77.5 ms (70.7–91.7) -373.4 ms 🟢 83% faster
Exceptions per run 1 0 -1 🟢 fewer errors
Exception breakdown
Exception Baseline (3 runs) Change (3 runs)
[ENGINE] ObjectDisposedException 3 0

Apple M1

Metric Baseline Change Δ Result
Samples 2710 (×3) 2905 (×3)
CPU average 33.1 ms (33.0–33.2) 30.8 ms (29.0–31.2) -2.3 ms 🟢 7% faster
CPU 1% worst 228.4 ms (225.1–230.6) 48.8 ms (44.5–228.0) -179.6 ms ⚪ within noise
CPU 0.1% worst 236.7 ms (233.1–286.8) 104.8 ms (88.1–238.8) -131.8 ms ⚪ within noise
GPU average 22.7 ms (21.5–25.0) 25.7 ms (22.4–26.1) 3.0 ms ⚪ within noise
GPU 1% worst 46.3 ms (45.6–46.4) 40.8 ms (35.0–44.0) -5.5 ms ⚪ within noise
GPU 0.1% worst 47.7 ms (47.2–48.2) 43.3 ms (42.9–45.4) -4.4 ms 🟢 9% faster
Exceptions per run 0 0 0 ⚪ none new

mikhail-dcl added a commit that referenced this pull request Aug 25, 2026
--base-domain (#9826) landed in dev and overlaps every file --gateway touches.
Five conflicts, resolved as follows.

GatewayUrlsSource: dev moved the gateway host off the hardcoded env domain onto
gateway.{BaseDomain}, so --gateway now overrides that resolved prefix rather than
a decentraland-shaped one. gatewayPrefix became the single origin both paths
build from, which removes the second branch the transform carried and drops the
domain-derivation the default path used to do -- dev's IsGatewayTransformable
already restricts transformable hosts to this deployment's own base domain, so
nothing else can reach it. The shape follows #9845, which implements the same
override on top of the same dev change.

The value is now validated and normalized (NormalizeGatewayPrefix): an absolute
http(s) url with a host and no query or fragment, reduced to a prefix ending in
'/'. Anything else ends the launch instead of being coerced, because a mistyped
gateway silently routes every supported service somewhere unintended.

MainSceneLoader: kept TrustedRealms.IsTrusted over dev's re-inlined host list,
and kept dev's --base-domain arm alongside it. --gateway is now captured while
the deep link is still deferred and reported through
WarnIfCommandLineOnlyArgCameFromTheDeepLink, matching --base-domain and
--eth-network: reading it later meant accepting it in the denied-params dialog
would have applied it, which is not what "command line only" means.

AppArgsFlags, DeepLinkAllowlist and docs/app-arguments.md: both sides kept, with
gateway named in the same never-permitted tier as the other infrastructure-
pointing params.

Verified in Unity batch EditMode: 203/203 across AppArgsTest,
DecentralandUrlsSourceShould, TrustedRealmsShould, ChatEnvironmentValidatorShould
and RealmLaunchSettingsShould -- dev's base-domain cases included -- no compile
errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@pentreathm

Copy link
Copy Markdown
Member Author

Superseded by #9822, which provides the required argument and custom realm support. The automation now uses ; no separate binary from this PR is required.

@pentreathm pentreathm closed this Aug 25, 2026
@pentreathm

Copy link
Copy Markdown
Member Author

Correction: superseded by #9822, which provides the required --gateway argument and custom realm support. The automation now uses --gateway; no separate binary from this PR is required.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants